← Developers

API Reference del Control Plane

Spec riferita a api v1

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:

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.

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:

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 GET qui 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