Il Control Plane espone un’unica API versionata sotto /api/v1. Tre confini di fiducia, tre credenziali diverse — mai mescolarle.
Canale edge — Bearer <edge api_key> + x-edge-signature
Usato dai nodi edge. L’API key viene emessa al claim del bootstrap e ruotata a ogni re-claim; il Control Plane conserva solo il suo hash SHA-256. Ogni chiamata autenticata aggiorna anche l’heartbeat di flotta (last_poll_at).
I nodi con node_pubkey registrata devono inoltre firmare ogni richiesta: header x-edge-signature: ed25519.<b64> calcolata sui byte esatti del body HTTP — un JSON riserializzato non verifica. Nodo firmato senza firma → 403 EDGE_SIGNATURE_REQUIRED; firma errata → EDGE_SIGNATURE_INVALID. I nodi senza pubkey (rollout legacy) sono accettati non firmati. Il replay è un no-op grazie alla dedup, quindi le firme non portano timestamp. Identificazione del nodo via x-edge-node; heartbeat opzionale x-kernel-version.
| Metodo | Path | Scopo |
|---|---|---|
POST |
/api/v1/sync/ingest |
Drain dell’outbox CDC: eventi batch, idempotente su id evento. Max 1000 eventi per richiesta — oltre, 413 con code: BATCH_TOO_LARGE (spezza il batch) |
POST |
/api/v1/sync/edge-audit |
Audit operativo OT: batch NDJSON dall’edge agent (Content-Type: application/x-ndjson), idempotente su id record, store-and-forward. Max 5000 righe per richiesta |
GET |
/api/v1/sync/manifest |
Manifest attivo + firma Ed25519. Servito dalla sync cache in-memory del Control Plane (TTL ~60s, invalidata istantaneamente al publish) — il polling di routine non tocca il database |
GET |
/api/v1/sync/licenses |
Licenze plugin + licenza contrattuale del tenant (firmate). Stessa sync cache del manifest — invalidata a ogni variazione di subscription/licenze |
Gli errori usano codici stabili (LICENSE_SUSPENDED, EDGE_CAP_EXCEEDED, DRAINING, BATCH_TOO_LARGE, EDGE_SIGNATURE_REQUIRED, EDGE_SIGNATURE_INVALID, SCOPE_INSUFFICIENT…). Un 503 con code: DRAINING indica spegnimento in corso — ritenta più tardi, non allarmarti.
I batch di ingest firmati sono conservati verbatim in cp_ingest_batches (body grezzo + firma + nodo); ogni evento memorizzato vi si collega via batch_id — è ciò che ri-verifica l’oracolo pubblico.
Wire contract (v1)
Shape canoniche dei payload — specchiate da src/contracts/edge-cloud.sync.v1.ts nel repo kernel. L’identità del tenant deriva sempre dalla Bearer key: i campi tenant_id inviati nei body sono tollerati ma ignorati.
// POST /sync/ingest — richiesta
{ "events": [{
"id": "uuid", // chiave dedup globale (cp_event_dedup)
"aggregate_type": "string",
"aggregate_id": "uuid",
"event_type": "string",
"payload": { /* dati evento */ },
"created_at": "ISO-8601" // opzionale, timestamp lato edge
}]}
// → 201 { status, processed_count, duplicate_count, latest_manifest_version }
// POST /sync/edge-audit — una riga NDJSON per record
{ "id": "uuid", "ts": 1735000000.0, "node_id": "edge-01",
"action": "string", "actor": "string", "status": "string",
"details": { /* forma libera */ } }
// → 201 { status, received_count, duplicate_count }
// GET /sync/licenses → 200
{ "licenses": [{ "plugin_code": "string", "version": "string",
"expires_at": 1735689600000, // epoch ms
"signature": "ed25519 hex" }], // firma "tenant:plugin:expiresMs"
"revoked": ["plugin_code"],
"tenant_license": { // null per tenant legacy/dev
"license": { // ← nested! lo status vive QUI
"type": "ax_tenant_license", "v": 1, "tenant_id": "uuid",
"status": "trial | active | past_due | canceled",
"plan": "string", "max_edges": 25, "monthly_event_cap": 25000000,
"features": ["..."], "issued_at": "ISO-8601",
"valid_until": "ISO-8601", "grace_offline_hours": 168
},
"signature": "ed25519 hex" // firma stable-stringify(license)
}
}
// Leggere tenant_license.license.status — NON tenant_license.status.
// `canceled` o valid_until + grace scaduti → l'edge deve rifiutare le scritture.
// GET /sync/manifest → 200
{ "version": 12, "manifest_raw": "{...}", // la firma copre i byte raw
"signature": "ed25519 hex", "migrations": [...] }
Scope delle chiavi (key_scope)
Enforced al middleware, prima dell’heartbeat — una chiamata respinta non conta mai come segno di vita. Negato → 403 SCOPE_INSUFFICIENT.
| Scope | Capability |
|---|---|
full |
Tutto (default back-compat) |
sync |
solo /api/v1/sync/* — emesso al bootstrap claim |
read-only |
GET/HEAD/OPTIONS su ogni superficie |
ingest-only |
solo POST /sync/ingest + /sync/edge-audit |
Endpoint pubblici — nessuna credenziale
| Metodo | Path | Scopo |
|---|---|---|
POST |
/api/v1/bootstrap/claim |
Scambia un token bt_* monouso + node_pubkey (Ed25519 SPKI DER, b64) per tenant_id + node_id + API key. hw_meta opzionale self-reported (hostname, arch, mac_hash, cpu_model) — metadato di audit, non attestazione hardware. Monouso, TTL, rate-limited, transazionale |
GET |
/api/v1/public/verify/event/:id |
Oracolo di integrità unauthenticated: verifica il batch firmato conservato dell’evento (firma valida + membership + payload memorizzato integro). Zero-leakage — non espone mai tenant_id, credenziali o payload. Rate-limited 30/min |
POST |
/api/v1/waitlist |
Iscrizione waitlist pubblica (CORS via WAITLIST_ALLOWED_ORIGIN) |
POST |
/api/v1/webhooks/stripe |
Eventi Stripe — firma verificata byte-per-byte, dedup su event.id |
GET |
/api/v1/live /ready /health |
Probe di liveness / readiness |
GET |
/metrics |
Esposizione Prometheus |
Portale tenant — Bearer <portal-session>
La superficie rivolta al cliente (utenti umani), servita dal Control Plane sullo stesso base URL /api/v1. Due credenziali intercambiabili:
- Sessione portale — Bearer UUID. Si ottiene riscattando un invito
pi_*monouso (/portal/invites/claim, arriva via email) o con/portal/login. TTL 12h, revocabile singolarmente, legata a un utente portale e a un tenant. - API key dispositivo
ak_*+tenant_id— back-compat: funziona su questa superficie solo con scopefull. Le chiavi emesse al bootstrap claim sono scopesync→403qui, by design.
Una stessa email può esistere su più tenant (consulenti multi-cliente): se la password corrisponde a più account il login risponde 401 + code: TENANT_AMBIGUOUS — riprova passando tenant_id. La risposta non rivela mai a quali tenant l’email appartiene.
| Metodo | Path | Scopo |
|---|---|---|
POST |
/api/v1/portal/login |
{email, password, tenant_id?} → {token, tenant_id, email, display_name, role}. Rate limit 10/min |
POST |
/api/v1/portal/logout |
Revoca la sessione corrente (idempotente) |
POST |
/api/v1/portal/invites/claim |
Pubblico, monouso: {token: "pi_<48 hex>", password ≥10, display_name?} → apre subito una sessione. Re-invito = reset password |
GET |
/api/v1/portal/me |
Identità della sessione: {kind: "portal_user", tenant_id, email, display_name, role, last_login_at} |
GET |
/api/v1/tenant/usage |
Un JSON = la fotografia del cliente: dati tenant, subscription (status, plan, max_edges, monthly_event_cap, valid_until, grace_offline_hours, stripe_portal_available), nodi edge con heartbeat, eventi del mese, ultimo backup |
POST |
/api/v1/tenant/portal |
→ {url} sessione Stripe Customer Portal (cambio piano, pagamenti). 503 se Stripe non è configurato per il tenant |
GET |
/api/v1/tenant/invoices |
Fatture Stripe (link PDF). 503 se non configurato — fare gate su usage.subscription.stripe_portal_available |
POST |
/api/v1/tenant/import-jobs |
Workbench migrazione Guardian Gate: {input ≤100k char, mode?} → 202 {id, status:"queued", redactions}. L’input è redatto prima della persistenza. Header X-Idempotency-Key → retry sicuro (replay 200 con idempotent_replay). Rate: 10/15min |
GET |
/api/v1/tenant/import-jobs /…/:id |
Ultimi 50 job / dettaglio con draft_manifest, report, guidance |
POST |
/api/v1/tenant/import-jobs/:id/approve |
Approvazione bozza lato tenant (sblocca il publish ops). Solo owner — member → 403; solo da draft_ready → altrimenti 409 |
Ruoli: owner (completo) / member (lettura + submit, niente approvazioni).
Audit trail — export hash-chained + webhook SIEM
Ogni mutazione admin sul tenant finisce in cp_admin_audit — journal append-only con hash-chain (SHA-256 prev_hash → hash per tenant, ancorato al genesis). La superficie tenant lo espone read-only per i collector SIEM; la verifica della catena è self-service.
| Method | Path | Scopo |
|---|---|---|
GET |
/api/v1/tenant/audit?since_seq=&limit= |
Export NDJSON (Content-Type: application/x-ndjson), paginato a cursore su seq. limit ≤ 5000 (default 1000). ?format=json_array per tooling non-NDJSON. 400 su cursori malformati/negativi — mai rewind silenzioso a 0. Leggibile con scope read-only |
GET |
/api/v1/tenant/audit/verify |
Riverifica la catena del tenant → {ok, checked, head, brokenAt?} |
GET POST DELETE |
/api/v1/tenant/audit-webhooks /…/:id |
Registry push: solo URL HTTPS, mutazioni solo ruolo owner. Ogni evento fa fan-out su una riga outbox per webhook (stessa transazione della scrittura audit), firmato X-Axiom-Trace-Audit-Signature: ed25519:<hex128> sul body raw — verificabile con la pubkey del CP. Timeout 15s, 5 retry con backoff esponenziale → dead_letter (mai rischedulato) |
// GET /tenant/audit — una riga NDJSON per evento
{ "audit_version": "1.0",
"seq": "100523", // BIGSERIAL a 64 bit come STRINGA — i parser
// JS perdono precisione oltre 2^53; fare Number()
"event_id": "adm_100523",
"timestamp": "ISO-8601",
"tenant_id": "uuid",
"actor": { "id": "…", "ip": "…", "request_id": "…" },
"action": "TENANT_PROVISIONED | SUBSCRIPTION_PATCHED | SYSTEM_RETENTION_PRUNED | …",
"target": "…",
"details": { /* dati evento */ },
"integrity": { "prev_hash": "sha256 hex", "hash": "sha256 hex" }
}
Rate limit export: 60 req/min per tenant. Gli ingester devono conservare since_seq = ultimo seq ricevuto e fare polling incrementale.
Accesso dati tenant — proiezioni via data-link
Non esistono endpoint di business hard-coded sulla superficie tenant. I dati verticali (lotti, misure, ordini…) vivono nelle tabelle biz_* del tenant dichiarate dal data_link_engine del manifest, e le letture passano per lo stesso engine: le query di lettura dichiarate nel manifest sono indirizzabili come trigger azione che restituiscono righe. Lo stream di eventi sincronizzato in cp_events è ispezionabile dalla superficie admin (GET /api/v1/admin/tenants/:id/events /aggregates).
CORS
/waitlist è l’unica rotta sempre CORS-aperta. Le route portale/tenant usano un modello ibrido:
PORTAL_ALLOWED_ORIGINS(env): le origini cloud nostre — valide per qualunque tenant (es. cockpit hostato sucp.axiom-trace.eu).cp_tenants.portal_origin(per-tenant, via adminPATCH …/portal-origin): il dominio on-prem del cliente — vincolato a quel tenant. Una richiesta dall’origine del tenant A con la sessione del tenant B →403. Le origini registrate sono in cache ~60s; la PATCH admin invalida istantaneamente.
Il preflight OPTIONS è risposto sull’unione env ∪ DB (niente credenziali sul preflight); il binding per-tenant è enforced sulla richiesta reale. Senza header Origin niente CORS: curl, server-to-server e proxy same-origin restano identici.
Provalo live — l’API Playground è una console read-only su questa origine: accedi con un account portale e chiama gli endpoint
GETqui sopra contro il tuo tenant. Prima volta? Leggi Il Contratto, poi gli esempi end-to-end.
Admin — Bearer <admin-session>
Le sessioni vengono da POST /api/v1/admin/login (rate-limited, password scrypt, scadenza enforced). Ogni chiamata admin mutante è registrata in cp_admin_audit.
| Metodo | Path | Scopo |
|---|---|---|
POST |
/api/v1/admin/login /logout |
Ciclo di vita della sessione |
POST |
/api/v1/admin/tenants |
Crea un tenant |
GET POST |
/api/v1/admin/tenants/:id/bootstrap-tokens |
Emette token di provisioning monouso |
GET POST |
/api/v1/admin/tenants/:id/manifests |
Manifest attivo / pubblica una nuova versione firmata |
GET POST DELETE |
/api/v1/admin/tenants/:id/licenses |
Gestione licenze plugin (firmate Ed25519) |
GET PATCH |
/api/v1/admin/tenants/:id/subscription |
Stato contratto, cap, metering vs piano |
PATCH |
/api/v1/admin/tenants/:id/plan |
Flag piano Founding Partner (plan_type, is_billing_exempt, compute_pass_through, founding_expires_at) |
POST |
/api/v1/admin/tenants/:id/api-key |
Rotazione API key tenant (key_scope: full|sync|read-only|ingest-only, TTL opzionale) — invalida la precedente all’istante |
PATCH |
/api/v1/admin/tenants/:id/portal-origin |
Origine CORS per-tenant per il cockpit del cliente ({origin: "https://…" | null}) |
GET POST DELETE |
/api/v1/admin/tenants/:id/portal-invites /…/portal-users |
Account portale umani: emissione/revoca inviti (pi_* + email), lista/disattivazione utenti |
GET POST |
/api/v1/admin/tenants/:id/import-jobs, /api/v1/admin/import-jobs/:id/requeue /…/publish |
Ops Guardian Gate: submit/ispezione/requeue job, publish delle bozze approvate come manifest firmati |
GET |
/api/v1/admin/tenants/:id/events /aggregates |
Ispezione dei dati sincronizzati |
GET |
/api/v1/admin/fleet |
Tutti i tenant + salute edge, stato contratto, heartbeat |
GET POST |
/api/v1/admin/notifications /…/:id/retry |
Ispezione outbox notifiche + retry dead-letter |
GET |
/api/v1/admin/waitlist |
Lead della waitlist |
GET POST DELETE |
/api/v1/admin/users |
Gestione admin multi-utente + cambio password |
GET |
/api/v1/admin/audit |
Registro append-only delle azioni admin |
GET |
/api/v1/admin/audit/verify |
Verifica globale della hash-chain → {ok, checked, head, brokenAt?} |
GET POST DELETE |
/api/v1/admin/audit-webhooks /…/:id |
Registro webhook SIEM globali (stesso modello signed-push dei webhook tenant) |
GET |
/api/v1/admin/edge-audit |
Audit operativo ricevuto dai nodi edge |
Webhook Stripe — Stripe-Signature verificata
Eventi gestiti: checkout.session.completed (provisioning self-service del tenant + token bootstrap + welcome email), customer.subscription.* (state machine del contratto), invoice.paid / invoice.payment_failed (riattivazione / dunning + grace). Endpoint non firmato → 503 se STRIPE_WEBHOOK_SECRET non è configurato — fail-closed.
Convenzioni
requestIdsu ogni risposta per correlazione edge ↔ cloud; includilo nelle segnalazioni di supporto.- Idempotenza: l’ingest deduplica su
idevento; ogni chiamata di sync è sicura da ritentare. - Rate limit: tetto globale più bucket per rotta sugli endpoint pubblici, più bucket per-tenant su
/sync/ingeste/sync/edge-auditcosì il flush post-blackout di un tenant non affama la flotta. Un429porta l’headerRetry-After— rispettalo (backoff con jitter), non tempesta di retry.