Salta al contenuto
openplate

In questa pagina

Il server centrale

Protocollo di sincronizzazione di openplate

Protocollo di rete e delle chiavi, versione 2

Questa pagina è tradotta automaticamente dalla documentazione in inglese.

Versione del protocollo: 2 · Versione dell'envelope: 1 · Stato: pre-1.0, nulla di rilasciato

Questa è la specifica normativa del protocollo di rete tra un client openplate e un server centrale. È scritta affinché terze parti possano implementare ciascun lato senza leggere il nostro codice: un client alternativo che si sincronizza con il nostro servizio gestito, oppure un server alternativo verso cui puntare un client openplate tramite CORE_URL.

La controparte leggibile da una macchina si trova in due file che sono duplicati gestiti manualmente l'uno dell'altro:

RepositoryFile
openplate-coresrc/protocol.ts
openplateapp/lib/sync/engine/protocol.ts

Ogni repository ha un test unitario che verifica le proprie costanti a fronte di valori letterali trascritti (tests/unit/protocol.test.ts e tests/unit/sync-engine/protocol.test.ts). Non c'è una CI condivisa tra i repository, quindi quei test sono l'unica barriera contro una divergenza silenziosa del protocollo. Questo documento è normativo, il codice TypeScript ne è la trascrizione.

1. Il riassunto in un paragrafo

Il client conserva tutte le chiavi. Serializza l'intero archivio locale, lo comprime con gzip, lo cifra con AES-256-GCM usando una chiave che il server non ha mai visto e invia il risultato come un unico blob opaco. Il server memorizza i byte, ne gestisce le versioni e rifiuta le scritture che sovrascriverebbero quelle di un altro dispositivo. Memorizza anche due piccoli record di chiavi (la stessa chiave di cifratura dei dati incapsulata sotto due diverse chiavi di cifratura delle chiavi, una derivata dalla passphrase dell'utente e una da un codice di recupero) in modo che un secondo dispositivo possa inizializzarsi. Nessun percorso di codice sul server decifra alcunché. Il server conserva il codice di recupero di ciascun account, sigillato con un proprio segreto (§3.1), quindi il gestore di un'istanza gestita possiede quanto serve per aprire un diario. Il §9 indica esattamente cosa conosce il server.

L'immagine sotto rappresenta un'intera sessione. Per prima cosa viene eseguito l'handshake di versione, che non è consultivo: in caso di mancata corrispondenza, o se un servizio non è raggiungibile, il client si ferma invece di inviare una busta che l'altra parte potrebbe interpretare diversamente. Il §6 definisce questa regola, i §§5.7-5.9 descrivono l'accesso che essa protegge e il §5.1 descrive l'invio, inclusa la perdita nel compare-and-swap da cui un client deve riprendersi.

Una sessione, nell'ordine: l'handshake di versione, che rifiuta la sincronizzazione a ogni mancata corrispondenza, poi l'accesso, infine un invio compare-and-swap.
Sorgente del diagramma
sequenceDiagram
    participant C as Client
    participant S as Core server
    C->>S: GET /health
    S-->>C: protocolVersion, envelopeVersion
    alt versions differ, or unreachable
        C->>C: refuse to sync
        Note over C: no push, no pull, no retry
    else versions equal
        C->>S: POST /v1/auth/kdf
        S-->>C: salt, Argon2id params
        C->>C: derive authHash, derive KEK
        C->>S: POST /v1/auth/login
        S-->>C: access token, refresh token
        C->>C: encrypt snapshot under DEK
        C->>S: POST /blob, baseVersion 3
        alt baseVersion matches
            S-->>C: 200, newVersion 4
        else another device wrote first
            S-->>C: 409, currentVersion 5
            C->>S: GET /blob
            C->>C: decrypt, merge, re-encrypt
            C->>S: POST /blob, baseVersion 5
            S-->>C: 200, newVersion 6
        end
    end

2. Terminologia

TermineSignificato
DEKData-encryption key (chiave di cifratura dei dati). 32 byte casuali. Cifra il blob. Non lascia mai il client in chiaro.
KEKKey-encryption key (chiave di cifratura delle chiavi). Incapsula la DEK. Ne esistono due: una derivata dalla passphrase e una derivata dal codice di recupero.
BustaIl formato di trasmissione del blob cifrato: iv ‖ AES-256-GCM(gzip(JSON(payload))).
Record di chiaviUna DEK incapsulata, più (solo per il tipo con passphrase) i parametri KDF necessari per ricalcolare la relativa KEK.
blobVersionContatore monotonico per account. Il token di compare-and-swap.
AccountL'unità di isolamento. Un account ha al massimo un blob corrente e al massimo due record di chiavi.
EmailL'identificatore dell'account: un indirizzo canonico (NFKC, spazi rimossi, minuscolo). Univoco per server.
InvitoUna capability monouso INDIRIZZATA a un'email, generata da un operatore. L'unico modo per accedere.
Deposito fiduciarioIl codice di recupero dell'account, sigillato sul server con una sottochiave di SERVER_SECRET.
Ruoloadmin o member. Il token di accesso di un amministratore autentica /v1/admin.

Il protocollo 2 ha sostituito l'handle con un'email (ADR-0005). L'Handle della versione 1, un identificatore opaco per server che non poteva contenere un @, non esiste più: la colonna, il parser e la regola sono stati rimossi. Un client che usa la versione 1 deve rifiutarsi di comunicare con un servizio in versione 2 invece di funzionare a metà; vedi §6.

3. Crittografia (lato client; il server non ne implementa alcuna parte)

Un server conforme non ha bisogno di questa sezione; si trova qui affinché un client alternativo possa interagire e un revisore possa verificare le affermazioni fatte.

3.1 Derivazione della chiave

                          ┌─HKDF-SHA-256(salt, info=PASSPHRASE_KEK)──► KEK_p   (never sent)
passphrase ─Argon2id(salt, m, t, p)─► hash ─┤
                          └─HKDF-SHA-256(salt, info=AUTH)───────────► authHash (sent to the server)

                                     ┌─HKDF-SHA-256(salt="", info=RECOVERY_KEK)──► KEK_r            (never sent)
recovery code ───────────────────────┤
                                     └─HKDF-SHA-256(salt="", info=RECOVERY_AUTH)─► recoveryAuthHash (sent)
  • Parametri Argon2id (registrati per account nel campo kdfDescriptor del record della chiave della passphrase e nel descrittore KDF dell'account stesso, così da poterli aumentare in seguito senza compromettere gli account esistenti): memorySizeKib: 65536 (64 MiB), iterations: 3, parallelism: 1, hashLength: 32. Salt: 16 byte casuali.
  • Le Etichette HKDF info sono stringhe di byte fisse, codificate in UTF-8. Forniscono una separazione dei domini affinché i valori derivati siano crittograficamente indipendenti: - openplate-sync:passphrase-kek:v1 - openplate-sync:recovery-kek:v1 - openplate-sync:auth:v1 - openplate-sync:recovery-auth:v1
  • Il ramo auth è ciò che il client invia come propria password. È sorella di KEK_p, non genitore e non figlia: entrambe sono output HKDF sullo stesso hash Argon2id sotto diverse etichette info, quindi il possesso di una non fornisce alcuna informazione sull'altra. Questo è l'unico motivo per cui il server può autenticare un utente per il quale non può decifrare i dati. authHash è di 32 byte, in base64 durante la trasmissione.
  • Il ramo recovery-auth è ciò che il client invia per dimostrare il possesso del codice di recupero (§5.14). È sorella di KEK_r esattamente nello stesso senso in cui authHash è sorella di KEK_p, ed è di 32 byte, in base64 durante la trasmissione.
  • L'etichetta recovery-auth non è mai l'etichetta recovery-kek. Quella separazione di domini è strutturale, non formale. Il ramo KEK deriva la chiave che apre il diario; se lo stesso output venisse inviato anche al server, questo servizio memorizzerebbe un HMAC del materiale che decifra una DEK, e l'affermazione "l'operatore non può leggere i tuoi dati" (valida solo finché l'operatore non possiede il codice di recupero depositato in garanzia, §9.1) si baserebbe sull'irreversibilità di SHA-256 anziché sul fatto che l'operatore non ha mai posseduto quel valore. Entrambe le etichette sono congelate, nessuna delle due deriva dall'altra, e una futura modifica a una di esse costituirà una nuova etichetta :v2 anziché una ridefinizione (ADR-0004).
  • Il server non memorizza mai nemmeno authHash o recoveryAuthHash. Memorizza HMAC-SHA-256(serverPepper, ...) di ciascuno, conservando il pepper fuori dal database. Vedi §5.8.
  • Il percorso di recupero ignora deliberatamente Argon2id e usa un salt HKDF vuoto. È corretto, non una svista: la sezione 3.1 di RFC 5869 lo consente quando il materiale della chiave in ingresso ha già un'entropia elevata, come nel caso di un codice casuale a 160 bit per costruzione. Solo le passphrase umane a bassa entropia richiedono un'estensione hard per la memoria e un vero salt.
  • Codice di recupero: 20 byte casuali (160 bit), rappresentati in un alfabeto base32 in stile Crockford (0123456789ABCDEFGHJKMNPQRSTVWXYZ, senza O, I, L per resistere agli errori di trascrizione) in gruppi di 5. In forma canonica, 32 caratteri senza raggruppamento e in maiuscolo; questo è il formato che il server sigilla.
  • Il codice di recupero è DEPOSITATO SUL SERVER (protocollo 2, ADR-0005). Il client non lo mostra più alla persona: invia il codice grezzo una volta sola nel corpo della registrazione, e il server memorizza iv(12) ‖ AES-256-GCM(escrowKey, code) ‖ tag(16) in accounts.recovery_code_escrow, dove escrowKey è una terza sottochiave HMAC fissa di SERVER_SECRET (openplate-sync:escrow-key:v1, accanto al pepper del verificatore e alla chiave del descrittore fittizio). Un ripristino via email (§5.12) restituisce il codice al titolare dell'account, che poi esegue l'ordinaria rotazione §5.14 utilizzandolo. Il gestore di un'istanza gestita possiede quindi ciò che serve per aprire un diario. Si tratta di un cambiamento concreto della natura di questo servizio, indicato qui chiaramente anziché nascosto, e motivato per intero in docs/adr/0005-organization-accounts-and-escrowed-recovery.md.
  • Il deposito riguarda il CODICE, non KEK_r e non la DEK. Nulla sul server deriva una KEK, scompatta una DEK o ne conserva una; il codice diventa una chiave solo dopo che un client vi ha eseguito HKDF sopra. Questo non garantisce la segretezza rispetto al gestore, che può eseguire a sua volta HKDF; garantisce invece un server il cui percorso di esecuzione del codice non contiene alcuna decifrazione dei dati utente, ed è questo che rende l'affermazione verificabile invece che una semplice promessa.
  • Le KEK sono chiavi AES-GCM a 256 bit, importate come non estraibili.

3.2 L'envelope

build:  payload ─► JSON ─► UTF-8 ─► gzip ─► AES-256-GCM(key=DEK, iv=random 12B, aad=AAD) ─► iv ‖ ciphertext‖tag
parse:  split(iv, rest) ─► AES-256-GCM decrypt ─► gunzip ─► UTF-8 ─► JSON ─► payload
  • IV: 12 byte casuali, generati per ogni cifratura, inseriti come byte iniziali di ciphertext. Non esiste un campo IV separato in nessun punto di questo protocollo.
  • Tag: il tag di autenticazione GCM di 16 byte viene accodato al testo cifrato (convenzione di WebCrypto).
  • AAD è la codifica UTF-8 di un oggetto JSON canonico con ordine delle chiavi fisso:
    json
    {"accountId":<int>,"blobVersion":<int>,"payloadSchemaVersion":<int>}

    Vincolare questi elementi impedisce gli attacchi copia e incolla (ripresentare un blob in un account diverso) e di rollback (ripresentare una versione precedente o un payload proveniente da uno schema di archiviazione locale incompatibile). Un client deve presentare la medesima tripletta durante la decifrazione, altrimenti la verifica del tag fallisce; questo è il comportamento previsto, non un errore da aggirare.

  • Compressione (gzip, RFC 1952) viene applicato al testo in chiaro prima della cifratura. Il testo cifrato non è comprimibile, quindi o si comprime prima o non si comprime affatto. Vedi il §8 per capire perché questo sia importante e il §9.2 per l'indicazione trasparente di ciò che trapela.
  • Struttura del Payload (tutto ciò che si trova dentro snapshot è opaco per questo protocollo):
    json
    {
      "snapshot": { "...": "the client's local-store snapshot, protocol-opaque" },
      "syncMeta": {
        "perEntity": { "<entityId>": { "lamport": 3, "deviceId": "abc" } },
        "tombstones": [{ "entityId": "x", "entityType": "foodLog", "lamport": 4, "deviceId": "abc" }]
      }
    }
  • DEK incapsulata: iv ‖ AES-256-GCM(key=KEK, plaintext=DEK), nessun AAD: una DEK incapsulata non è vincolata ad alcuna versione specifica del blob. La lunghezza è sempre di 12 + 32 + 16 = 60 byte.

3.3 Semantica di merge (lato client)

I conflitti vengono risolti per entità tramite (lamport, deviceId): vince il contatore di Lamport più alto; gli spareggi si risolvono sul valore lessicografico di deviceId. L'orologio di sistema del dispositivo non è esplicitamente non un'autorità di ordinamento; va fuori sincrono ed è facilmente errato tra dispositivi diversi. Un tombstone partecipa allo stesso confronto di un valore attivo. Compromesso accettato nella v1: last-writer-wins sull'intero record, quindi una modifica offline simultanea alla stesso entità su due dispositivi scarta la scrittura meno recente in modo silente. Nessun merge a livello di campo, nessuna interfaccia per i conflitti.

3.4 L'incapsulamento di condivisione (ADR-0002)

Una condivisione è un terzo incapsulamento della stessa DEK, indirizzato alla chiave pubblica di un altro account. Il server la memorizza, la fornisce all'unico account a cui è destinata e non conserva alcuna chiave per essa; il §9.1 non viene modificato da questa funzionalità.

sender (grantor, holding recipientPub):
  (ephPriv, ephPub) ← ECDH P-256, fresh per wrap, discarded after
  Z         ← ECDH(ephPriv, recipientPub)
  KEK_share ← HKDF-SHA-256(salt = empty, IKM = Z,
                           info = "openplate-sync:share-kek:p256:v1")
  AAD       ← UTF-8 of canonical fixed-key-order JSON:
              {"grantorAccountId":<int>,"recipientKeyFingerprint":"<base64>"}
  wrap      ← ephPub(65, uncompressed SEC1) ‖ iv(12) ‖ AES-256-GCM(KEK_share, DEK, aad=AAD)
  • La lunghezza è di 125 byte, sempre. Nota che questa è un'invariante diversa rispetto alla DEK incapsulata di 60 byte del §3.2: 60 per un record di chiave, 125 per una condivisione. Risiedono in tabelle diverse e nessun percorso di validazione condiviso crea rami basati sulla lunghezza.
  • P-256, e la curva è specificata nell'etichetta invece che solo nella versione, così che una costruzione futura sia una nuova etichetta anziché un'ambiguità su :v1.
  • Il salt HKDF vuoto è corretto, per gli stessi motivi dell'RFC 5869 §3.1 già indicati nel §3.1 per il codice di recupero: l'IKM è un output ECDH nuovo e ad alta entropia, non un segreto umano che richiede uno stretching memory-hard.
  • Questo incapsulamento include l'AAD; le DEK incapsulate del §3.2 non lo includono. L'incapsulamento di un record di chiave è limitato a una riga accessibile solo al proprietario e non può essere confuso con quello di nessun altro. Un incapsulamento di condivisione risiede in una tabella di associazione controllata dal server, dove potrebbe esserlo: vincolarlo fa sì che una riga manipolata fallisca il controllo del tag anziché decifrare nel diario errato.
  • L'AAD vincola l'impronta della chiave del destinatario, non l'id account del beneficiario. La sostituzione attacca la chiave, quindi la chiave è ciò che il vincolo specifica, e il beneficiario ricostruisce l'AAD da un'impronta calcolata localmente, impedendo a qualsiasi valore fornito dal server di entrare nel percorso di fiducia.
  • recipientKeyFingerprint è SHA-256 della chiave pubblica grezza non compressa. Il server lo memorizza come metadato di pinning e non convalida, distribuisce né genera mai una chiave pubblica; la chiave vincolata autorevole risiede all'interno dello snapshot cifrato del concedente.

Un beneficiario deve decifrare per tentativi. L'AAD del blob del §3.2 vincola payloadSchemaVersion, che il §7 definisce come un intero opaco che non compare mai sul cavo. Un proprietario conosce il proprio; un beneficiario non conosce quello del concedente. Di conseguenza, un beneficiario tenta la decifratura tra le versioni di schema supportate dalla sua build e sceglie quella il cui tag GCM viene verificato. Questo processo è economico ed è il comportamento previsto; non aggiungere un campo in chiaro con la versione dello schema per risolverlo.

3.5 La busta per il contributo di ricerca (ADR-0003)

Una contributo è una porzione ridotta e delimitata da date del diario, cifrata per la chiave pubblica di uno studio. Si tratta di un elemento diverso da una condivisione, non di uno più ristretto: payload differente, chiave differente, ciclo di vita differente e nessuna DEK coinvolta; l'incapsulamento avviene direttamente sul payload.

Lo pseudonimo. Una radice casuale a 256 bit per account risiede nello scomparto privato del proprietario, quindi sopravvive a un ripristino di recupero e raggiunge un secondo dispositivo.

pid = HMAC-SHA-256(root, "openplate-sync:study-pseudonym:v1" ‖ uint64be(studyAccountId))
      truncated to the leading 128 bits, Crockford base32, 26 characters

I byte sono fissi, perché una concatenazione poco specificata si traduce in due implementazioni in disaccordo nella stessa installazione. L'etichetta è costituita dai suoi byte UTF-8 senza terminatore; studyAccountId è 8 byte, senza segno, big-endian, sempre otto, mai il suo testo decimale e mai una codifica a lunghezza minima. L'output è formato dai primi 16 byte del MAC nell'alfabeto base32 di Crockford 0123456789ABCDEFGHJKMNPQRSTVWXYZ (senza simbolo di controllo, senza trattini), ovvero esattamente 26 caratteri maiuscoli. Un client che effettua la derivazione sulle cifre ASCII dell'id produce uno pseudonimo ben formato che non corrisponde a nulla.

Stabile tra gli invii di un collaboratore, non collegabile tra studi diversi (gli output HMAC con messaggi diversi sono indipendenti) e non derivabile da chiunque sia in possesso sia della tabella degli account sia di una coorte. H(accountId ‖ studyId) non avere quest'ultima proprietà: con input pubblici si inverte per enumerazione.

Lo pseudonimo protegge dal ricercatore, non dal server. Il server autentica il push tramite bearer token e quindi conosce comunque l'account dietro ogni riga; vedi §9.2.

La busta.

  (ephPriv, ephPub) ← ECDH P-256, fresh per contribution
  Z         ← ECDH(ephPriv, studyPub)
  KEK       ← HKDF-SHA-256(salt = empty, IKM = Z,
                           info = "openplate-sync:research-kek:p256:v1")
  AAD       ← UTF-8 of canonical fixed-key-order JSON:
              {"studyAccountId":<int>,"pseudonym":"<string>",
               "contributionVersion":<int>,"schemaTier":"<string>",
               "studyKeyFingerprint":"<base64>"}
  body      ← ephPub(65) ‖ iv(12) ‖ AES-256-GCM(KEK, payload, aad = AAD)

Una nuova etichetta congelata invece di una versione dell'etichetta di condivisione: scopo diverso, stessa logica che ha inserito la curva nel nome.

L'AAD non contiene alcun id account, e lo stesso vale per qualsiasi risposta lato studio. Questa è la deliberata inversione del §5.16, dove grantorAccountId è richiesto perché l'AAD del §3.2 lo vincola. Qui ogni campo AAD è ricostruibile dal ricercatore prima della decifratura: quattro viaggiano nella risposta e l'impronta digitale viene calcolata in locale a partire dalla propria chiave.

Il payload è un livello fisso, selezionato per nome. Uno studio sceglie un livello e una finestra temporale; non fornisce mai un elenco di campi. La v1 ne definisce uno:

daily-intake:v1: una riga per ogni giorno di calendario nella finestra temporale, con date (granularità giornaliera, senza timestamp), energyKcal, proteinG, carbsG, fatG, fiberG, loggedEntryCount. Il conteggio esiste perché un ricercatore non può altrimenti distinguere tra "non ha mangiato nulla" e "non ha registrato"; è un conteggio, mai le voci vere e proprie.

Un nuovo campo rappresenta una revisione del protocollo, mai una configurazione. Vedi ADR-0003.

4. Convenzioni di trasporto

  • Tutti i corpi delle richieste e delle risposte sono in formato application/json.
  • I campi binari (ciphertext, wrappedDek) sono stringhe base64 (alfabeto standard, con spaziatura di riempimento). Non vengono inviati deliberatamente come content type binario: ogni campo di ogni richiesta deve essere leggibile da chi si auto-ospita il servizio durante il debug della propria istanza.
  • I timestamp sono stringhe UTC ISO-8601, ad esempio 2026-08-04T10:11:12.000Z.
  • Un codice di recupero trasmesso sulla rete è TESTO in base32 di Crockford, ovunque compaia (signup.recoveryCode, recover-rotate.recoveryCode, rotate-dek.recoveryCode e la risposta di reset/open). Un server DEVE accettarlo raggruppato o non raggruppato e, in entrambi i casi, normalizzarlo in 32 caratteri maiuscoli rimuovendo spazi e trattini, sigillare QUESTO e restituire la stessa forma canonica da reset/open. Un codice ha quindi un'unica forma sigillata, così un nuovo deposito in garanzia dopo una rotazione è confrontabile con quanto presente in precedenza, e un client che visualizza il codice in gruppi di cinque può inviare nuovamente ciò che ha mostrato. Anche un client conforme accetta entrambe le forme.
  • Ogni corpo di risposta non-2xx è in formato {"error": "<human-readable text>"}. Il testo ha solo scopo diagnostico; i client devono gestire i rami in base al codice di stato, mai in base al messaggio.
  • Le richieste che superano il limite del corpo vengono rifiutate con 413. Ogni famiglia di route in /v1/sync ha un limite dedicato, senza ereditare quello delle altre: i record blob e key usano il limite del blob in base64 più 4 KiB, rotate-dek il limite del blob in base64 più 64 KiB, la famiglia share 8 KiB e la famiglia research 512 KiB.
  • Una route autenticata convalida il token bearer prima di leggere il corpo. Un chiamante privo di token valido riceve 401, mai 413, indipendentemente dalla dimensione del corpo.
  • Un indirizzo di origine è un indirizzo IPv4 o una /64 IPv6. Ogni limitatore che questo documento definisce per IP o per indirizzo di origine conta un chiamante IPv6 in base ai primi 64 bit del suo indirizzo, poiché una singola connessione domestica possiede un'intera /64. Un indirizzo IPv6 con mapping IPv4 (::ffff:a.b.c.d) conta come l'indirizzo IPv4 che include. Un indirizzo IPv4 conta come se stesso. Una richiesta di cui il server non riesce a determinare l'indirizzo condivide un unico raggruppamento con tutte le altre richieste di questo tipo.

4.1 Autenticazione

Un token bearer in un'intestazione Authorization: Bearer <token>. Nessun cookie, in nessuna delle due direzioni.

  • Access-Control-Allow-Origin: *, e Access-Control-Allow-Credentials non viene mai inviato. Qualsiasi client openplate (il nostro, quello di chi fa self-hosting sul proprio dominio, o un'implementazione di terze parti) può quindi comunicare con qualsiasi istanza di questo servizio indipendentemente dall'origine.
  • Questa combinazione è sicura proprio perché non esistono credenziali d'ambiente. Una pagina malevola può inviare una richiesta cross-origin e riceverà un 401, perché il browser non ha nulla da allegare automaticamente. Questa è la proprietà anti-CSRF che manca ai cookie, ed è il motivo per cui un'origine del tutto aperta è una scelta ponderata e non una scorciatoia.
  • I chiamanti non autenticati ricevono 401. I chiamanti autenticati ma non autorizzati ricevono 403. Un server conforme non deve confonderli.
  • Due 403 contengono un codice macchina fisso su cui il client si dirama: account-suspended su qualsiasi route bearer, e health-consent-required su qualsiasi route dati che il §5.15.1 non elenca come aperta. Sono l'eccezione alla regola "basa le diramazioni sullo stato, mai sul messaggio", perché 403 da solo non può distinguerli l'uno dall'altro né da un normale rifiuto:

    | Stato | Corpo | Significato | Cosa fa il client | | ------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | | 401 | qualsiasi | Nessun token di accesso valido | Esegue un refresh, poi chiede di accedere | | 403 | {"error":"account-suspended"} | Un operatore ha sospeso l'account (§5) | Lo segnala; accedere di nuovo non servirà | | 403 | {"error":"health-consent-required"} | L'istanza richiede il consenso ai dati sanitari e l'account non ne ha la versione attuale (§5.15.1) | Chiede il consenso, poi riprova | | 403 | qualsiasi altra cosa | Autenticato, non autorizzato per questa specifica richiesta | Legge la tabella dell'endpoint |

  • Ogni intestazione di richiesta personalizzata letta da una route è indicata in Access-Control-Allow-Headers: Authorization, Content-Type, Idempotency-Key (§5.23) e X-Intake-Id (§5.19). Ogni intestazione di risposta personalizzata letta da un client è indicata in Access-Control-Expose-Headers: Retry-After, X-Trial-Scans-Left, X-Quota-Used e X-Quota-Limit. Un browser rifiuta di inviare un'intestazione omessa dal primo elenco e nasconde quella omessa dal secondo elenco, senza riportare nulla nei log.

Questo ha sostituito un cookie di sessione same-origin presente quando i core degli handler erano montati dentro l'app openplate. Questa modifica, insieme allo spostamento delle route di sincronizzazione da /api/sync a /v1/sync, sono pre-1.0 e non incrementano PROTOCOL_VERSION: non esiste alcun blob in produzione, non ci sono implementazioni di terze parti e nessun client distribuito può subire rotture. Quando questo documento sarà pubblicato insieme a una release pubblica, tale libertà terminerà; vedi §7.

4.2 Ciclo di vita dei token

Due tipi di token, entrambi stringhe casuali opache, entrambi memorizzati solo come digest SHA-256. Il dump di una tabella di token non produce nulla di riutilizzabile, e uno SHA-256 senza stretching è corretto in questo contesto perché la preimmagine consiste in 256 bit di casualità; non c'è alcun dizionario da scorrere.

TokenDurataScopo
access15 minInviato a ogni richiesta. Breve, perché un token trapelato rimane utile per tutta la sua durata.
refresh30 giorniScambiato con una nuova coppia. A rotazione: ogni utilizzo lo consuma.

Perché una coppia opaca e non un JWT. La revoca è un elemento portante di questo protocollo: una modifica della passphrase e una rotazione del codice di recupero devono invalidare ogni sessione attiva immediatamente, e un utente che cambia la propria passphrase per sospetta compromissione si aspetta esattamente questo. Un token stateless può solo essere lasciato scadere, mai bloccato all'istante, a meno di non aggiungere la stessa denylist lato server che un token opaco su database costituisce già.

Perché serve una coppia. Il client non deve mai salvare la passphrase in modo persistente, quindi non può ricalcolare in background un auth-hash per accedere di nuovo. Un token di aggiornamento a rotazione e a lunga durata è l'unico elemento che rende possibile la riautenticazione automatica in un'architettura in cui il server non vede mai la passphrase.

Rilevamento di rotazione e riutilizzo. Ciascuna coppia include un identificatore di famiglia che permane dopo la rotazione.

  • POST /v1/auth/refresh con un refresh token valido lo revoca e restituisce una nuova coppia nella stessa famiglia.
  • Presentare un refresh token che è già revocato rappresenta il segnale di riutilizzo: il client legittimo lo ha ruotato, perciò chiunque lo stia presentando adesso possiede una copia non autorizzata. L'intera famiglia viene revocata. Questo disconnette l'attaccante e l'utente reale, ed è l'esito corretto; l'alternativa lascerebbe a un malintenzionato una sessione attiva.
  • Conta il consumo, non la lettura. Due richieste che usano lo stesso token di aggiornamento possono trovarlo entrambe valido. Solo una potrà consumarlo (un aggiornamento condizionale da "live" a "revoked"), mentre l'altra verrà considerata un riuso: 401, con conseguente revoca dell'intera famiglia. Di conseguenza, solo una tra due richieste concorrenti di aggiornamento con lo stesso token riceve 200, e quella coppia non sopravvive alla risposta di riuso dell'altra. Il client deve serializzare le proprie richieste di aggiornamento (§11); la presenza di un secondo client in competizione con il primo è il motivo preciso per cui esiste il rilevamento del riuso.
  • I token di accesso generati da rotazioni precedenti vengono lasciati invariati di proposito; scadono da soli nel giro di pochi minuti, e revocarli al momento della rotazione interromperebbe una richiesta legittimamente in corso.

Trigger di revoca. Ciascuno di questi eventi revoca tutti i token access e refresh in sospeso per l'account:

  • POST /v1/auth/change-passphrase
  • POST /v1/auth/recover-rotate
  • POST /v1/sync/rotate-dek, tranne la famiglia di appartenenza del chiamante (§5.17)
  • sospensione da parte di un operatore
  • eliminazione dell'account (tramite cancellazione a catena della riga)

POST /v1/auth/logout revoca una sola famiglia (quel dispositivo) e lascia intatte le altre sessioni dell'account.

I token di sessione sono l'unico tipo presente in account_tokens. Fino alla versione 0.5.0 quella tabella conteneva anche due tipi di LINK monouso, generati per essere inseriti in un messaggio: uno confermava un indirizzo, l'altro riscattava un link di recupero inviato per email. Entrambi sono stati rimossi insieme al modulo email, e nessuno dei due è stato reintrodotto. Il protocollo 2 non prevede alcuna conferma dell'indirizzo (l'invito funge da verifica, §5.8) e il suo link di reimpostazione non sostituisce alcuna credenziale (§5.12).

Due token di funzionalità si trovano al di fuori di quella tabella, ed entrambi hanno un prefisso in modo che non sia possibile inviare l'uno al posto dell'altro:

TokenPrefissoDurataMemorizzato inCosa consente
Invito di registrazionesi_7 gsignup_invitesCrea UN account, all'indirizzo indicato nell'invito.
Reimpostazione passwordsr_60 minpassword_resetsRestituisce il codice di recupero dell'account depositato a garanzia, una sola volta.

Entrambi sono composti da 256 bit di casualità, entrambi vengono memorizzati solo come digest SHA-256 ed entrambi sono monouso. Nessuno dei due viene mai accettato come credenziale Authorization: Bearer, e un token di sessione non viene mai accettato al loro posto: il prefisso è un filtro di forma applicato prima di qualsiasi ricerca, e il suo rifiuto produce lo stesso errore generico di un token errato, quindi non aggiunge alcun oracolo.

L'impostazione di Anche la sospensione revoca. accounts.suspended_at revoca ogni token access e refresh in sospeso all'interno della stessa transazione, quindi una sospensione ha effetto immediato invece di attendere la scadenza del token di accesso attuale.

5. Endpoint

Due famiglie, all'interno di un unico spazio dei nomi con versione:

FamigliaPrefissoAutenticazione
Sincronizzazione (da §5.1 a §5.5)/v1/sync (SYNC_API_PREFIX)Bearer, sempre
Handshake (§5.6)/healthNessuno
Account (da §5.7 a §5.15)/v1/authMisto: specificato per endpoint

Un account sospeso viene rifiutato ovunque. POST /login, POST /refresh, POST /recover, POST /recover-rotate, ogni route protetta da bearer e l'albero admin rispondono 403 {"error":"account-suspended"}, esattamente con questa stringa, così un client può riconoscerla e spiegare cosa è successo. Su login e sui percorsi di recupero il controllo viene eseguito DOPO la verifica delle credenziali, quindi un indirizzo sconosciuto riceve comunque l'ordinario e indistinguibile 401.

A un account senza il consenso dell'istanza viene rifiutata ogni route dati. Quando instance.healthConsent è diverso da null (§5.6), un account che non possiede esattamente quella versione riceve 403 {"error":"health-consent-required"} su ogni route che archivia, invia o consuma qualcosa per suo conto, e mantiene le route necessarie per acconsentire, andarsene e rileggere la propria copia. Il §5.15.1 le elenca entrambe. La sospensione viene verificata per prima, quindi un account sospeso riceve account-suspended.

I percorsi da §5.1 a §5.5 sono scritti in modo relativo a SYNC_API_PREFIX; tutti gli altri sono assoluti.

5.1 POST /blob: push (compare-and-swap)

Richiesta:

json
{ "baseVersion": 3, "envelopeVersion": 1, "ciphertext": "<base64>", "shrinkAcknowledged": false }
  • baseVersion: la blobVersion che il client ritiene sia attualmente memorizzata. 0 asserisce "questo account non ha ancora alcun blob".
  • La scrittura viene accettata solo se baseVersion equivale alla versione attuale dell'account. Questo costituisce l'intero modello di concorrenza. Non esiste alcun force-push e nessuna scrittura priva di If-Match.
  • shrinkAcknowledged: FACOLTATIVO, e la sua assenza indica false. Vedi la protezione dal restringimento più sotto.

Risposte:

StatoCorpoSignificato
200{"newVersion": 4}Accettato. Il blob è ora a newVersion.
409{"currentVersion": 5}Corsa persa. Un altro dispositivo ha scritto per primo.
400{"error": "..."}baseVersion non è un intero non negativo, envelopeVersion non è un intero positivo, ciphertext è assente/non è base64 oppure è vuoto, o shrinkAcknowledged è presente e non è un booleano.
400{"error": "...", "currentSizeBytes": 5310, "nextSizeBytes": 1588}Un restringimento consistente non confermato. Non è stato scritto nulla. Vedi sotto.
413{"error": "..."}Il blob supera MAX_BLOB_BYTES.
401/403{"error": "..."}Non autenticato / non consentito.

La protezione dal restringimento (M224). Un push la cui ciphertext decodificata è strettamente inferiore alla metà della size_bytes della versione memorizzata (BLOB_SHRINK_ACK_RATIO) viene RIFIUTATO con 400, a meno che la richiesta non contenga "shrinkAcknowledged": true. Un account che non ha ancora alcun blob non viene mai rifiutato; un primo push non è una cancellazione.

È una CONFERMA DI PRESA IN CARICO, non un verdetto. Un client la imposta true esattamente quando emette cancellazioni da uno stato di cui si fida per certo, e un client che non può sostenerlo omette il campo e accetta il rifiuto. Il servizio conserva testo cifrato e non può distinguere una cancellazione intenzionale da un client che ha perso l'archivio locale e crede che tutto sia stato cancellato; si tratta degli stessi byte. Quindi chiede, e un client che non dice nulla riceve un rifiuto invece di una cancellazione totale.

Quando una riduzione confermata VIENE accettata, la versione immediatamente precedente viene conservata senza essere eliminata per BLOB_PRE_SHRINK_PIN_DAYS (§8).

Il CAS viene verificato PER PRIMO: un push basato su un baseVersion obsoleto genera il normale 409, a prescindere dalle sue dimensioni, perché il compito di quel client è scaricare ed eseguire il merge, e di solito non si riduce dopo averlo fatto. La clausola di guardia si applica solo a un push che altrimenti sarebbe stato accettato.

Il rifiuto è 400 e deliberatamente NON 409: un 409 su questa route significa "un altro dispositivo ha scritto per primo" e impone il ciclo di ripristino descritto sotto, che invierebbe di nuovo gli stessi byte. Non è stato usato nemmeno 413: la richiesta non è troppo grande.

shrinkAcknowledged è un CAMPO DEL CORPO e non deve mai diventare un'intestazione. Una nuova intestazione di richiesta personalizzata deve essere indicata nel Access-Control-Allow-Headers CORS del servizio, altrimenti il browser legge il preflight, rileva un'intestazione che non ha il permesso di inviare e non invia affatto la richiesta, senza alcuna riga di log e senza che un test non eseguito da browser possa notare nulla. Motivazione: docs/adr/0009-a-shrinking-blob-is-acknowledged-or-refused.md.

Il ciclo di ripristino per l'errore 409 è un comportamento obbligatorio del client, non un'ottimizzazione: scarica currentVersion, decifralo, uniscilo allo stato locale (§3.3), cifra nuovamente con l'AAD associato a nuovo blobVersion e invia di nuovo con un push specificando baseVersion: currentVersion. Un client che tratta 409 come un errore irreversibile lascerà il dispositivo dell'utente costantemente non sincronizzato.

5.2 GET /blob: pull

StatoCorpo
200{"blobVersion": 4, "envelopeVersion": 1, "ciphertext": "<base64>", "createdAt": "<iso>"}
404{"error": "..."}: questo account non ha mai eseguito il push di un blob. Non è una condizione di errore; è l'aspetto di un account appena creato.

5.3 GET /key-records: list

json
{
  "records": [
    {
      "kind": "passphrase",
      "kdfDescriptor": { "salt": "<base64>", "params": { "memorySizeKib": 65536, "iterations": 3, "parallelism": 1 } },
      "wrappedDek": "<base64>",
      "updatedAt": "<iso>"
    },
    { "kind": "recovery", "kdfDescriptor": null, "wrappedDek": "<base64>", "updatedAt": "<iso>" }
  ]
}

Restituisce {"records": []} per un account che non ha completato la configurazione. Al massimo un record per kind.

5.4 PUT /key-records/:kind: creazione o rotazione (compare-and-swap)

:kind è passphrase o recovery; qualsiasi altro valore è 400.

Richiesta:

json
{
  "kdfDescriptor": { "...": "..." } | null,
  "wrappedDek": "<base64>",
  "expectedUpdatedAt": "<iso>" | null,
  "currentAuthHash": "<base64, 32 bytes>"
}
  • expectedUpdatedAt: null asserisce "non esiste ancora alcun record di questo tipo" (prima configurazione).
  • Qualsiasi altro valore asserisce "il record letto l'ultima volta aveva esattamente questo updatedAt" (rotazione).
  • La chiave deve essere presente. L'assenza di expectedUpdatedAt genera un 400, di proposito: chi effettua la chiamata non deve poter saltare il controllo di concorrenza dimenticando un campo.
  • Una sovrascrittura richiede la verifica della passphrase. Quando expectedUpdatedAt non è null, il campo currentAuthHash (il ramo di autenticazione della passphrase attuale, §3.1) è OBBLIGATORIO: se assente o non valido produce un errore 400 che lo indica espressamente, mentre se non corrisponde all'account produce 401 {"error":"current passphrase is incorrect"}, il corpo inviato da change-passphrase, senza scrivere alcunché. Una creazione (null) richiede solo il token bearer e ignora il campo: assegna uno spazio vuoto durante la configurazione iniziale e l'operazione CAS la rifiuta non appena esiste già un record. Sostituire un wrap significa sostituire la chiave di accesso all'account, un'operazione che un semplice token bearer non deve poter eseguire da solo.
  • I tentativi sono limitati per account, raggruppato nello stesso contatore con change-passphrase, delete e rotate-dek: un account bloccato restituisce 429 con Retry-After per tutte e quattro le richieste, da qualsiasi indirizzo. Una corrispondenza corretta azzera il contatore.

Validazione, tutti 400:

  • wrappedDek vuoto
  • kind: "recovery" con un kdfDescriptor non nullo (il percorso di recupero usa solo HKDF; non ci sono parametri da registrare)
  • kind: "passphrase" con un kdfDescriptor nullo

Risposte:

StatoCorpo
200Il record memorizzato, con la stessa struttura di una voce GET /key-records.
400{"error": "..."}: la convalida descritta sopra, oppure una sovrascrittura priva di un currentAuthHash ben formato.
401{"error": "current passphrase is incorrect"}: una sovrascrittura in cui currentAuthHash non corrisponde.
409`{"currentUpdatedAt": "<iso>" \null}`: l'asserzione CAS non era valida.
429{"error": "..."} con Retry-After: i tentativi di inserimento della passphrase per questo account sono bloccati.

5.5 DELETE /key-records/:kind: rimosso, non ripristinato

Rimosso a settembre 2026. Ora questo percorso risponde come qualunque percorso sconosciuto sotto il prefisso: 401 senza un token, 403 se l'account non ha il consenso dell'istanza, e il consueto 404 in tutti gli altri casi. Nessun client lo chiamava, e l'eliminazione dell'unico record key rimasto rendeva ogni blob memorizzato perennemente indecifrabile tramite il solo token bearer.

Un record key viene sostituito tramite il §5.4, che convalida la passphrase, oppure tramite una rotazione (§5.14, §5.17), e viene eliminato solo assieme all'account (§5.15).

Una condivisione (§5.16) non conta come record di chiave. Dal punto di vista crittografico è un terzo incapsulamento della stessa DEK, ma è una facoltà che appartiene a un'altra persona, da lei revocabile, per te non verificabile e dipendente dalla sua costante collaborazione e onestà. Nessun client deve mai proporre «recupera i tuoi dati tramite il tuo dietista» come percorso di recupero.

5.6 GET /health: handshake della versione

Senza autenticazione, intenzionalmente: un client deve poter scoprire di essere incompatibile prima che abbia le credenziali, e un healthcheck che richiedesse un token starebbe verificando il token.

json
{
  "protocolVersion": 2,
  "envelopeVersion": 1,
  "serviceVersion": "0.20.0",
  "instance": {
    "name": "openplate",
    "language": "de",
    "mail": true,
    "memberInvites": true,
    "openSignup": true,
    "signupCaptcha": { "provider": "turnstile", "siteKey": "0x4AAAAAAAexample" },
    "trial": { "scans": 10, "days": 14 },
    "plans": true,
    "push": false,
    "healthConsent": { "version": "2026-09-28" },
    "nutrientReferenceBasis": "dge",
    "ai": { "model": "vendor/model-name" },
    "defaultCapabilities": null
  }
}

instance descrive che cos'è questo deployment e cosa può fare, ed è opzionale: un servizio più vecchio del campo lo omette, e un client che lo richiedesse si rifiuterebbe di comunicare con ciascuna di queste istanze. name è l'etichetta dell'operatore per l'istanza, language è uno tra en, de, fr, it, es, tr (le sei lingue in cui sono scritte le sue email; un client la mostra e non crea mai diramazioni su di essa, quindi una settima lingua non costituisce una modifica al protocollo), mail indica se l'istanza può inviare messaggi, memberInvites indica se un membro ordinario può invitare persone qui (§5.21), openSignup indica se una persona può richiedere un account qui (§5.8.3), signupCaptcha specifica cosa richiede tale richiesta, trial promette le scansioni gratuite che un nuovo account riceve (§5.19), plans indica se dietro questa istanza è presente un sistema di fatturazione in modo che esista /v1/plans/* (§5.22), push indica se questa istanza può inviare notifiche web push in modo che esista /v1/push/* (§5.24), healthConsent indica il consenso ai dati sanitari richiesto a ogni account (§5.15.1), nutrientReferenceBasis specifica di chi sono i valori di riferimento per i micronutrienti mostrati, e ai è null quando non è configurata alcuna chiave upstream. ai.model è il modello del livello predefinito dell'istanza (§5.19): il modello a cui il proxy invia una richiesta, a meno che l'operatore non abbia instradato lo schema di tale richiesta verso un altro livello. È null quando l'operatore non ne ha indicato alcuno e viene inviato il modello specificato dal chiamante.

defaultCapabilities è l'elenco delle capability (§5.19, "Capabilities") che un account possiede quando non ha un proprio record, per esempio ["scan", "recipes"]. È sempre presente: null significa che questa istanza non controlla alcuna capability, quindi ogni funzionalità è accessibile, come avviene in un'istanza in Self-hosting che non imposta nulla, mentre [] significa che un account non ne possiede alcuna finché non lo indica un record. Un client che non trova alcuna chiave (ogni servizio precedente a questo campo) lo interpreta come null. È descrittivo, non concede mai un permesso: il proxy decide per ogni richiesta in base al record dell'account e a questo valore predefinito.

healthConsent è il consenso esplicito ai dati sanitari che questa istanza richiede a ogni account, {"version": "<v>"}, oppure null quando non ne richiede alcuno, che è l'impostazione predefinita nel Self-hosting. È null invece di essere assente, come ai, e un client che non trova alcuna chiave (ogni servizio precedente a questo campo) lo legge come null. A differenza del resto di questo blocco, il servizio lo applica: finché non è null, la creazione dell'account richiede il consenso corrispondente (§5.8), e ogni route dati rifiuta un account che non possiede esattamente questa versione con 403 {"error":"health-consent-required"} finché non acconsente al §5.15.1. Un client che trova una versione chiede conferma prima di sincronizzare, e tratta quel 403 come la stessa domanda posta in ritardo. Un client che trova null non mostra alcuna casella per il consenso, e il servizio non gli rifiuta nulla.

push segue plans alla lettera: un booleano che indica solo se esiste una porta. false significa che l'intero sottoalbero /v1/push risponde con il consueto 404 di percorso sconosciuto, quindi un client non mostra alcuna impostazione per le notifiche. Non dice nulla su cosa contenga una notifica push, perché una notifica push contiene un tipo e nient'altro (§5.24).

plans è un booleano e non una promessa opzionale, l'opposto intenzionale della scelta fatta da instance.feedback più sotto. Quel campo è una promessa su cosa accade a una fotografia, e un'istanza che non ha nulla da promettere lo omette. Questo non promette nulla: dice solo se esiste una porta, esattamente lo stesso tipo di affermazione fatta da mail e memberInvites, quindi false è la risposta corretta sia per un'istanza senza gestore dei pagamenti sia per un servizio creato prima che il campo esistesse.

openSignup è un booleano, come memberInvites e plans: indica solo se una porta esiste. true significa che POST /v1/auth/signup-request accetta un indirizzo (§5.8.3); false, insieme a un servizio creato prima del campo, significa che il percorso risponde con il consueto percorso sconosciuto 404, e un client mostra il testo di invito anziché un modulo di registrazione. È descrittivo, non concede mai permessi: le limitazioni di frequenza, il captcha, i domini rifiutati e il limite di un'email al giorno per casella postale rimangono sul servizio.

signupCaptcha è presente solo finché openSignup è true e l'operatore esegue un captcha. provider oggi è turnstile; siteKey è la chiave pubblica del sito di Cloudflare Turnstile, con cui un client visualizza il widget e che non concede alcun permesso. Il token prodotto dal widget viaggia come captchaToken nella richiesta di registrazione. La sua assenza significa che la richiesta non richiede alcun token.

trial è un promessa, come feedback sotto, quindi è assente anziché null su un'istanza che non prevede un periodo di prova per le scansioni. scans è il numero di scansioni IA gratuite che un nuovo account riceve su di essa (§5.19, "The scan trial"). days è il numero di giorni dopo quello della registrazione al termine dei quali il periodo di prova finisce anche se restano scansioni, a seconda di quale condizione si verifichi per prima (§5.8 indica dove cade questo termine); vale assente, mai null, su un'istanza il cui periodo di prova non ha una data di fine, e un client che non trova alcun days indica le scansioni esattamente come prima e NON DEVE indicare un numero di giorni. Un client che non trova alcun trial NON DEVE indicare un numero di scansioni gratuite. Entrambi i numeri sono quelli registrati da ogni punto di ingresso al periodo di prova, pubblicati a partire dalle stesse impostazioni (TRIAL_SCANS, TRIAL_DAYS), così la frase letta da una persona prima di registrarsi e i limiti applicati dal proxy non possono divergere.

memberInvites è descrittivo, mai una concessione, come tutto il resto in questo blocco. Un client lo legge per decidere se mostrare la scheda di invito; non lo legge mai per decidere se può crearne uno. false significa che POST /v1/auth/invites risponde con il consueto 404 di percorso sconosciuto, e true lascia comunque al servizio il limite complessivo, la regola sul reinvito e la limitazione di frequenza.

È descrittivo, mai autorevole. mail: true non promette l'arrivo di un'email, e ai riporta quanto configurato dal gestore anziché concedere qualcosa; un account con dailyAiLimit: 0 riceve un 403 a prescindere da questo valore.

nutrientReferenceBasis è dge, efsa o us: di quale ente ogni client su questa istanza mostra i valori di riferimento per i micronutrienti, se i valori della DGE tedesca, dell'EFSA dell'UE o della NASEM statunitense. È opzionale, quindi un servizio compilato prima dell'introduzione del campo lo omette e un client che non lo conosce lo ignora. È un criterio per istanza, mai per lingua e mai per persona: lingua ed ente di riferimento sono ortogonali, e un valore predefinito legato alle impostazioni internazionali sarebbe un criterio su base individuale mascherato.

È anche il l'unico campo in questo blocco che un amministratore può modificare mentre il servizio è in esecuzione. Tutto il resto qui appartiene all'ambiente dell'operatore, fisso fino a un nuovo deploy; questo valore viene memorizzato, e PATCH /v1/admin/settings (§5.20) lo scrive. Un client lo legge quindi a ogni connessione invece di memorizzarlo nella cache per l'intera durata dell'installazione. Un servizio DEVE servire questo percorso da una copia locale al processo del valore e NON DEVE leggere il proprio archivio per rispondere a /health: questo è il percorso per l'healthcheck del container, interrogato di continuo, e una lettura dell'archivio in questo punto trasformerebbe un intoppo del database in un riavvio.

instance.feedback è l'unico campo qui a essere una promessa anziché una descrizione, ed è l'eccezione al paragrafo precedente. Un'istanza che accetta le stime segnalate conserva la foto del cibo di qualcuno, e pubblica per quanto tempo:

json
{
  "protocolVersion": 2,
  "envelopeVersion": 1,
  "serviceVersion": "0.6.0",
  "instance": { "name": "openplate", "language": "en", "mail": true, "ai": null, "feedback": { "retentionDays": 30 } }
}

retentionDays è il valore numerico su cui si basa la pulizia periodica del servizio, pubblicato dallo stesso binding, affinché la frase mostrata dal client all'utente prima dell'invio di una fotografia e la successiva eliminazione non divergano mai.

Il campo è assente, mai null, su un'istanza che non accetta segnalazioni. ai: null è una dichiarazione presente in ogni istanza; questa è una promessa, e un'istanza con la funzionalità disattivata non ha nulla da promettere, quindi non aggiunge alcuna chiave e resta indistinguibile da una creata prima dell'esistenza del campo, proprio come il suo albero /v1/feedback resta indistinguibile da uno in cui la funzionalità non è mai stata scritta.

Un client che non trova alcun intervallo dichiarato NON DEVE indicarne uno. Non offre alcuna segnalazione, oppure usa un testo che non indica alcun periodo; mostrare un numero basato su un valore predefinito locale equivale a pubblicare una promessa mai fatta dal servizio, a una persona che sta decidendo se inviare una fotografia.

signupMode è rimosso nel protocollo 2, insieme all'impostazione descritta: un account viene creato solo riscattando un invito, e openSignup indica se una persona può richiederne uno (§5.8). Un servizio che pubblica ancora signupMode sta usando la versione 1.

notice è il messaggio del gestore per tutti i client, ed è opzionale esattamente nello stesso senso di instance: un'istanza che non ha nulla da dire omette il campo, e un client che non lo conosce lo ignora.

json
{
  "protocolVersion": 2,
  "envelopeVersion": 1,
  "serviceVersion": "0.6.0",
  "notice": { "text": "This instance moves to a new address on 1 March.", "url": "https://example.org/moving" }
}

text è obbligatorio quando il campo è presente; url è opzionale e, se presente, è un URL assoluto https:/http:. Il servizio limita text a 280 caratteri e rifiuta l'avvio con una lunghezza superiore, perché /health è anche il percorso HEALTHCHECK del container ed è interrogato di continuo.

Questo è un canale pull e nulla più. Non può sapere chi ha letto un avviso: chi apre l'applicazione lo vede, chi non lo fa, non lo vede. Non è un meccanismo di notifica e non deve essere considerato tale. Il protocollo 2 fornisce al servizio due sole email da inviare (un invito e un ripristino della password, §5.8 e §5.12), e nessuna delle due serve ad altro: il gestore che deve comunicare qualcosa ai propri utenti conserva l'elenco dei contatti per conto proprio, fuori da questo servizio.

Un client DEVE considerare text e url come input non fidato. Arrivano dal server indicato dall'utente. Mostra text come testo e mai come markup, e apri url solo dopo averne verificato esplicitamente lo schema.

5.7 POST /v1/auth/kdf: descrittore KDF pre-accesso

Senza autenticazione, con frequenza limitata per IP. Restituisce il sale Argon2id e i parametri necessari al dispositivo per derivare authHash prima di poter accedere.

POST invece di GET, pur trattandosi di una lettura: un GET inserisce l'indirizzo nella riga di richiesta, finendo nei log di accesso, nei log dei proxy, nelle intestazioni Referer e nella cronologia del browser. Un endpoint il cui unico scopo è non rivelare chi possiede un account non deve diffondere l'identificativo richiesto. Questo valeva già per un handle; con un indirizzo inviato sulla rete fa la differenza tra una fuga di dati e una lista di distribuzione.

Richiesta: {"email": "anna@example.org"} · Risposta 200:

json
{
  "kdfDescriptor": {
    "salt": "<base64, 16 bytes>",
    "params": { "memorySizeKib": 65536, "iterations": 3, "parallelism": 1 }
  }
}

Anche un indirizzo sconosciuto riceve un descrittore. Viene derivato in modo deterministico come HMAC(serverSecret, email) sull'indirizzo canonico (§5.8), quindi resta stabile tra le richieste, identico nella struttura e prodotto dallo stesso percorso di codice. Un 400 viene restituito solo per input che non potrebbero affatto essere un indirizzo. Né il passaggio agli handle in M181 né il ritorno agli indirizzi in M192 hanno modificato una sola riga della derivazione: opera su una stringa opaca, ed entrambe le forme lo sono.

Questo conta più di quanto sembri. Un accesso in cui il server non vede mai la passphrase richiede un endpoint non autenticato, basato su identificatore, che risponde prima dell'autenticazione; se gestito in modo ingenuo diventa un elenco libero, silenzioso e non limitabile degli indirizzi che possiedono un account. La stabilità è fondamentale quanto la forma: un valore fittizio casuale sarebbe distinguibile effettuando due richieste.

Un server conforme NON DEVE restituire 404, un corpo vuoto o una struttura diversa per un indirizzo sconosciuto. Deve anche:

  • Esegui lo stesso lavoro su entrambi i rami. Derivare il valore fittizio incondizionatamente, anche per gli account esistenti che non lo useranno mai, così che un riscontro positivo e uno negativo costino la stessa ricerca e lo stesso HMAC. Derivarlo in modo lazy lascia un dislivello temporale: la risposta non dice nulla, ma il tempo impiegato per produrla sì.
  • Derivalo sull'indirizzo canonico, così che due grafie diverse dello stesso indirizzo sconosciuto non siano distinguibili dai loro descrittori.
  • Limita la frequenza in base all'indirizzo di origine, restituendo 429 con Retry-After. Questa è l'altra metà della stessa difesa: il segnale temporale residuo è statistico, ed emerge solo da molti campioni per indirizzo. Negare i campioni è ciò che lo neutralizza. Limitare la frequenza in base all'indirizzo inviato sarebbe peggio che non fare nulla, poiché sondare molti indirizzi è l'attacco, quindi un bucket per indirizzo offre una nuova quota per ogni indirizzo che l'attaccante vuole testare.

5.8 POST /v1/auth/signup

Non autenticato, con limitazione per IP. Un invito è ancora l'unica cosa che crea un account, su ogni istanza. Su un'istanza con instance.openSignup: true, una persona può RICHIEDERE un invito indirizzato a se stessa (§5.8.3); ciò che riceve è un normale invito, riscattato qui esattamente come uno generato da un operatore. SIGNUP_MODE è un errore di avvio, perché non c'è alcuna modalità da impostare: l'unico interruttore è se la porta di richiesta di §5.8.3 esiste.

json
{
  "inviteToken": "si_…",
  "authHash": "<base64, 32 bytes>",
  "kdfDescriptor": { "...": "..." },
  "displayName": "optional or null",
  "recoveryAuthHash": "<base64, 32 bytes>",
  "recoveryCode": "ABCDE-FGHJK-MNPQR-STVWX-YZ012-3456",
  "keyRecords": [
    { "kind": "passphrase", "kdfDescriptor": { "...": "..." }, "wrappedDek": "<base64>" },
    { "kind": "recovery", "kdfDescriptor": null, "wrappedDek": "<base64>" }
  ],
  "healthConsent": { "version": "2026-09-28" }
}

healthConsent è obbligatorio dove instance.healthConsent non è null e viene ignorato ovunque altrove (§5.15.1). Il suo version deve corrispondere byte per byte a quello dell'istanza. Senza di esso la risposta è 400 {"error":"health-consent-required"} e nulla viene creato o speso: l'invito resta riscattabile, quindi l'utente spunta la casella e invia di nuovo. La verifica viene eseguita dopo ogni altro campo, quindi un invito malformato risponde comunque prima al 403 qui sotto. Un account creato con esso possiede il consenso fin dalla sua prima richiesta, quindi nessuna route dati lo rifiuta; un client che crea account su un'istanza di questo tipo (una console di studio, uno strumento di seeding) invia anche questo campo, altrimenti al suo account viene rifiutata ogni route dati (§5.15.1).

Non esiste un campo email, ed è proprio questo il punto. L'indirizzo proviene dalla riga dell'invito, all'interno della transazione. Un corpo non può rivendicare una casella di posta a cui l'operatore non ha scritto, ed è questo a rendere l'invito stesso la verifica dell'indirizzo: la persona che ha ricevuto la lettera è la persona che lo riscatta, quindi non c'è alcun link di conferma e nulla da confermare in seguito. role e dailyAiLimit provengono dall'invito per lo stesso motivo: un account non chiede mai il proprio stato.

recoveryAuthHash, recoveryCode ed ENTRAMBI i record di chiave sono obbligatori. Ciascuno era facoltativo nel protocollo 1 e nessuno lo è adesso:

  • Il client non mostra più il codice di recupero alla persona (§3.1), quindi un account creato senza escrow non potrà mai essere ripristinato da alcun reset, e il proprietario non è mai stato avvisato.
  • Un record passphrase è ciò che consente alla passphrase di decifrare qualsiasi cosa; senza di esso l'account accede e non legge nulla, e il client ha già scartato la passphrase nel momento in cui se ne accorgerebbe.
  • Un record recovery è ciò che consente di sbloccare il codice in escrow; senza di esso un reset inviato per posta recapita una credenziale che autentica e non apre nulla, scoperta solo il giorno in cui serve.

recoveryCode viene convalidato come Crockford base32 da 20 byte (32 caratteri dopo aver rimosso spazi e trattini e convertito il valore in maiuscolo) e normalizzato in tale forma prima di essere sigillato. Non viene mai registrato nei log, in alcuna forma, su alcun percorso.

StatoSignificato
201{"account": AccountView, "tokens": {...}} (§5.15). Viene sempre rilasciata una sessione; non resta nulla da confermare.
400Un authHash, recoveryAuthHash o recoveryCode con formato errato; un descrittore privo di un sale da 16 byte e parametri Argon2id positivi; oppure keyRecords privo di un tipo. {"error":"health-consent-required"}: l'istanza richiede un consenso e il corpo non ne contiene alcuno, o contiene un'altra versione; l'invito NON viene consumato.
403{"error":"invite-invalid"}: l'invito è mancante, malformato, relativo a un altro servizio, sconosciuto, scaduto, revocato o già riscattato. Tutte e sette le casistiche, un'unica risposta.
409Esiste già un account per l'indirizzo dell'invito. L'invito NON viene consumato.
429Frequenza limitata. Retry-After in secondi.

Il server memorizza HMAC-SHA-256(serverPepper, authHash), e la stessa costruzione su recoveryAuthHash, non un secondo KDF lento su entrambi. Il client ha già sostenuto il costo memory-hard; ricalcolare l'hash lato server non aggiungerebbe alcuna resistenza alla forza bruta (un attaccante in possesso dell'auth-hash ha già saltato Argon2id), creando al contempo un DoS da saturazione degli accessi in cui ogni tentativo blocca 64 MiB. L'uso del pepper serve ancora al suo scopo originario: tenendo il pepper fuori dal database, una tabella sottratta non può essere riutilizzata contro un'istanza attiva né verificata offline a tentativi.

L'intero invio viene eseguito in un'unica transazione: il riscatto dell'invito, la riga dell'account (con il consenso, dove l'istanza ne richiede uno), il deposito a garanzia sigillato ed entrambi i record delle chiavi. Ogni stato intermedio è un disastro distinto che l'utente non può vedere finché non tenta di leggere il proprio diario.

L'oracolo di enumerazione in questo protocollo è solo 409, e il protocollo 2 lo ha reso quasi nullo. È raggiungibile solo da chi possiede un invito attivo INDIRIZZATO proprio all'indirizzo indicato come già occupato, quindi conferma solo ciò che il gestore ha scritto nel messaggio. Nel protocollo 1 chi aveva un invito poteva sondare identificatori arbitrari con un solo invito; ora non può farlo, perché l'indirizzo non è una sua scelta. Non consuma l'invito, quindi se un gestore invita per errore una persona due volte non distrugge l'invito attivo. Spiegazione completa: SECURITY.md.

5.8.1 Inviti

Un invito è una capability a uso singolo e con scadenza indirizzata a una sola persona. Contiene l'indirizzo con cui verrà creato l'account, il nome ipotizzato dal gestore, il ruolo e la quota giornaliera di IA. I token sconosciuti, non validi, mancanti, destinati a un altro servizio, scaduti, revocati o già riscattati producono tutti lo STESSO 403 e lo stesso corpo, {"error":"invite-invalid"}: distinguerli consentirebbe a un chiamante di verificare quali token esistono, rivelando che un token un tempo era valido.

Cosa concede il riscatto (2026-09-30), stabilita dalla riga di invito, in quest'ordine: una riga con una prova basata su scansioni concede tale prova (sotto); una riga generata da un membro concede l'accesso della porta del membro (§5.21), la prova basata su scansioni o la coppia di giorni, anche quando l'autore dell'invito ha nel frattempo eliminato il proprio account, e nessuna IA quando l'istanza ha disattivato gli inviti dei membri dopo l'invio della lettera; qualsiasi altra riga, ovvero un'emissione dell'operatore senza prova o una registrazione aperta su un'istanza che non ne prevede, concede la propria quota giornaliera come concessione gratuita permanente dell'account (freeDailyAiLimit, §5.15) con un valore a pagamento dailyAiLimit pari a 0. In precedenza, quest'ultimo caso scriveva un dailyAiLimit senza data, formato per il quale il §5.19 non concede più nulla.

Un token di invito inizia con si_, e il servizio rifiuta qualsiasi valore diverso. Il prefisso vincola il token a questo servizio e a questo endpoint. Una persona riceve un invito tramite email, insieme a un token di ripristino della password che inizia con sr_; senza i prefissi, i due token sarebbero stringhe intercambiabili e uno potrebbe essere inviato all'endpoint errato. Il controllo è un filtro sul formato prima della ricerca, rifiutato con lo stesso stato e lo stesso corpo di qualsiasi altro invito non valido, quindi questo filtro non introduce alcun oracolo. I token di sessione non hanno prefisso e restano invariati.

La generazione è POST /v1/admin/invites. Un invito PENDING precedente per lo stesso indirizzo viene revocato da uno nuovo, così non c'è mai più di una capability attiva per indirizzo; un indirizzo che ha già un account non può essere invitato affatto (409). L'unica eccezione è la porta di richiesta di §5.8.3, che lascia intatto un invito in sospeso dell'operatore o di un membro anziché revocarlo su richiesta di uno sconosciuto.

Un invito può includere una periodo di prova per le scansioni (trialScans, §5.19): la registrazione aperta, una generazione da parte di un amministratore con "trial": true e, dove l'istanza lo prevede, l'invito di un membro scrivono il numero dell'istanza sulla riga, e il riscatto lo copia sull'account. Dove l'istanza imposta anche un limite di giorni (instance.trial.days), la riga riporta pure quello, e il riscatto avvia il conteggio: il giorno del riscatto non conta, e il trialEndsAt dell'account è la mezzanotte locale al termine del days-esimo giorno successivo, nel fuso orario dell'istanza (TRIAL_TIME_ZONE, UTC a meno che l'operatore non ne abbia impostato uno). Un riscatto avvenuto il 2026-09-29 in Europe/Berlin, alle 10:00 o alle 23:30, con quattordici giorni, termina alle 2026-10-14 00:00 ora di Berlino, 2026-10-13T22:00:00.000Z. È una regola di calendario, non una durata: anche in caso di cambio dell'ora l'ultimo giorno termina comunque alla mezzanotte locale. Il fuso viene letto al riscatto e non è scritto sulla riga, poiché sposta il confine tra due giorni e mai il numero di giorni. Una riga generata prima dell'introduzione del limite di giorni non ne riporta alcuno, e l'account che crea non ha data di fine.

Un'istanza può anche consentire a un utente normale di generarne uno, alle condizioni stabilite dall'istanza e senza alcuna delle divulgazioni previste da 409 in questo paragrafo. Si veda POST /v1/auth/invites, §5.21.

5.8.2 POST /v1/auth/invite-lookup

Non autenticato, con limitazione della frequenza per IP. Richiesta {"inviteToken": "si_…"}.

json
{ "email": "anna@example.org", "displayName": "Anna", "expiresAt": "2026-09-11T10:00:00.000Z" }

Il client lo chiama quando una persona apre il link ricevuto via email, così il modulo di registrazione può MOSTRARE l'indirizzo a cui è stato inviato il messaggio invece di chiederle di digitarlo. È l'intero scopo di un invito indirizzato: evitare che la persona digiti per errore un indirizzo sbagliato per un account irraggiungibile.

Non mostra nient'altro. Il ruolo e la quota concessi dall'invito sono volutamente assenti: chi non si è ancora registrato non ha motivo di sapere che il gestore lo ha reso admin, e un chiamante in possesso del link di qualcun altro ne ha ancora meno.

I token sconosciuti, non validi, destinati a un altro servizio, scaduti, revocati o esauriti restituiscono UNO 404 {"error":"invite-invalid"}, dopo la medesima elaborazione: per ogni ramo viene calcolato l'hash del token ed eseguita la query sulla tabella. Una ricerca valida non consuma nulla, quindi chi apre il link due volte conserva il proprio invito.

5.8.3 POST /v1/auth/signup-request: una persona richiede un account

Non autenticato. Presente solo dove instance.openSignup è true; ovunque altrove il percorso risponde con il consueto percorso sconosciuto 404. Un'istanza deve avere la posta configurata per aprirlo, perché l'email costituisce la verifica dell'indirizzo.

Richiesta: {"email": "anna@example.org", "captchaToken": "…", "plan": "yearly", "tier": "tier-a", "locale": "de"}. captchaToken è obbligatorio quando instance.signupCaptcha è presente, altrimenti viene ignorato. plan, tier e locale sono facoltativi e indicano ciò che la persona ha selezionato nella schermata di registrazione prima della richiesta; nessun altro elemento nel corpo viene letto.

  • plan è "monthly" o "yearly". Quando è uno di questi, il link inviato via email contiene &plan=<key> dopo l'invito.
  • tier è l'ID del livello del fatturatore a cui appartiene il piano (§5.22). Il servizio non conosce alcun elenco di livelli, quindi valuta il struttura e nient'altro: un'etichetta in minuscolo da 1 a 32 caratteri, che inizia con una lettera seguita da lettere, cifre e trattini (^[a-z][a-z0-9-]{0,31}$), confrontata esattamente senza rimozione di spazi e senza conversione delle maiuscole. Se è valida, il link inviato via email include &tier=<id> dopo &plan= (o dopo l'invito quando non c'è alcun piano). Il servizio non verifica che il fatturatore venda effettivamente il livello, né che sia stato associato un piano; spetta al client deciderlo quando legge il link.
  • locale è una delle sei lingue dell'istanza (en, de, fr, it, es, tr), lo stesso elenco accettato dalla push locale (§5.24). Quando corrisponde a una di queste, il link inviato via email include &lang=<code>, e l'email, o la nota per il titolare dell'account, viene scritta in quella lingua. Senza un locale valido, entrambe vengono scritte nella lingua dell'istanza (instance.language).
  • Un valore mancante, null, un valore di un altro tipo e qualsiasi altra stringa sono ignorati senza segnalazione: mai un 400, e la risposta sottostante non cambia. Per tier questo include Alpha (maiuscole/minuscole), alpha (spaziature), un'etichetta di 33 caratteri, 42, un oggetto, un array e a&plan=monthly (che non ha & o = da offrire al frammento). Nessun campo viene memorizzato; ciascuno viaggia all'interno del link, così un link aperto su un altro dispositivo riconosce comunque il piano e il livello. La nota per il titolare dell'account non contiene link, quindi non ne include alcuno; soltanto la sua lingua segue locale.

Un link che contiene tutti e tre ha questo aspetto: <client>/join#server=…&invite=si_…&plan=yearly&tier=tier-a&lang=de.

json
{}

→ 202 con quel corpo, vuoto e fisso.

Per un nuovo indirizzo il servizio genera un normale invito nominativo e lo invia via email: ruolo member, durata predefinita dell'invito, nessun mittente dell'invito e condizioni dell'istanza, che corrispondono al periodo di prova per le scansioni quando ne prevede uno (§5.19) e a nessuna IA in caso contrario. Il link inviato via email porta a §5.8.2 e a §5.8, senza modifiche. La risposta NON DEVE variare in base a ciò che è vero riguardo all'indirizzo, come in §5.21: un nuovo indirizzo, un indirizzo associato a un account, un indirizzo che ha già un'email in sospeso dall'operatore o da un membro e una casella postale che ha già ricevuto un'email oggi sono un solo 202 con un solo corpo. Le email sono specifiche della porta, mai l'invito o la nota di §5.21, che affermano che qualcuno ha invitato il destinatario: un nuovo indirizzo riceve un'email che informa che tale indirizzo, o qualcuno che lo usa, ha chiesto di creare un account, con l'unico link, la sua scadenza e la precisazione che ignorare l'email non cambia nulla; il titolare di un account riceve una nota che dice la stessa cosa e specifica che non è stato creato alcun secondo account, senza alcun link. Un'email in sospeso proveniente da un'altra porta viene lasciata intatta, così che uno sconosciuto non possa revocare l'invito di un operatore inviando l'indirizzo. Differiscono soltanto le email.

StatoSignificato
202{}. Accettato, qualunque cosa sia vera riguardo all'indirizzo
400{"error":"email-invalid"}: non è un indirizzo. {"error":"email-domain-refused"}: indirizzo presso un servizio noto di email usa e getta, confrontato con il dominio e ciascuno dei suoi domini di livello superiore. {"error":"captcha-failed"}: il token captcha è mancante o è stato rifiutato; risolvilo di nuovo
404L'istanza non prevede la registrazione aperta
429Più di cinque richieste da un singolo indirizzo sorgente in un'ora. Retry-After in secondi
503{"error":"captcha-unavailable"}: impossibile interrogare il fornitore del captcha. Riprova più tardi

I 400 descrivono la richiesta, mai gli account dell'istanza: un dominio non dice nulla su chi possiede un account, quindi rifiutarne uno non costituisce un oracolo.

Due limiti di frequenza. Per indirizzo sorgente, cinque richieste all'ora contando ogni tentativo, ponendo un limite a un singolo script. Per casella postale, un'email al giorno: ulteriori richieste rispondono comunque 202 e non inviano nulla, così questo limite non può rivelare per quali indirizzi qualcun altro ha fatto richiesta. La chiave della casella postale è il chiave di prova: l'indirizzo canonico (§5.8) con la parte +tag rimossa dalla parte locale, e per gmail.com e googlemail.com con ogni punto rimosso e il dominio scritto gmail.com. anna+x@gmail.com, a.n.n.a@gmail.com e anna@gmail.com condividono una sola chiave; a.nna@example.org e anna@example.org no.

Una sola casella postale, una sola prova, per sempre. Una casella postale la cui chiave ha già riscattato un invito con scansioni gratuite, con qualunque grafia, o il cui account includeva una prova ed è stato eliminato, riceve un invito la cui prova è 0: la persona ottiene comunque un account, e la prima scansione risponde 403 trial-scans-spent. Il servizio riconosce la casella postale tramite un hash con chiave della sua chiave di prova, mai tramite un indirizzo memorizzato (§9.2).

Nessun indirizzo viene registrato nei log, su nessun ramo. Un server NON DEVE registrare nei log l'indirizzo inviato né il token captcha.

5.9 POST /v1/auth/login

Non autenticata, con due limitatori. Entrambi contano un 401 e nient'altro, e un'operazione riuscita li azzera entrambi.

  • Per IP ed email. Cinque fallimenti sono consentiti senza blocchi. Questo rallenta gli attacchi a forza bruta da una singola origine senza permettere a nessuno di bloccare l'accesso di una vittima al proprio account da un altro indirizzo.
  • Per email, da qualsiasi indirizzo. Venti fallimenti ricevono risposta; la ventunesima richiesta viene rifiutata per un minuto, e ogni fallimento successivo raddoppia la durata del blocco fino a quindici minuti. Un raggruppamento senza fallimenti per quindici minuti si azzera. Questo limita chi tenta di indovinare le credenziali cambiando indirizzo a rotazione. Un indirizzo senza account associato viene conteggiato nello stesso modo, così il rifiuto non rivela se l'account esista o meno. L'indirizzo viene normalizzato come nella ricerca dell'account (§2), quindi una diversa grafia dello stesso indirizzo ricade nello stesso raggruppamento.

Ciascun blocco restituisce lo stesso 429 con Retry-After, applicando l'attesa più lunga tra le due.

Richiesta {"email": "...", "authHash": "..."} → 200 {"account": AccountView, "tokens": {...}}.

400 quando email non è un indirizzo plausibile o authHash non corrisponde a 32 byte decodificati in base64: la richiesta non raggiunge mai la verifica delle credenziali, quindi questo stato non rivela se l'account esista. 401 per un account sconosciuto e per un hash di autenticazione errato, con il corpo del testo identico e dopo la medesima elaborazione, poiché il confronto di verifica viene eseguito su entrambi i rami rispetto a un valore fittizio di lunghezza intera. 403 {"error":"account-suspended"} quando l'account è sospeso, controllato DOPO le credenziali, così solo chi ha dimostrato di possedere l'account apprende il motivo dell'accesso negato. 429 in caso di limite di frequenza superato.

5.10 POST /v1/auth/refresh

Non autenticato (il refresh token funge da credenziale). Richiesta {"refreshToken": "..."} → 200 {"tokens": {...}}. Consulta il §4.2 per la rotazione e il rilevamento del riutilizzo. Ogni errore restituisce 401, tranne nel caso di un account sospeso, che restituisce 403 {"error":"account-suspended"} e NON consuma il token presentato: una sospensione può essere revocata, e bruciare il token disconnetterebbe la persona da un dispositivo di cui rientrerà in possesso. Lo stato distinto impedisce a un client di rimanere bloccato in un ciclo infinito su questo endpoint.

5.11 POST /v1/auth/logout

Bearer. 204. Revoca la famiglia di token del chiamante: solo questo dispositivo.

5.12 POST /v1/auth/reset/request e POST /v1/auth/reset/open: il ripristino via email

Questi numeri sono stati rimossi nella versione 0.5.0, quando verify-email e request-reset sono stati eliminati insieme al gestore delle email. Il protocollo 2 li riutilizza, e la scelta di riusarli anziché assegnarne due nuovi è intenzionale: ciò che si trova qui ora risponde a ciò che c'era prima, e chi segue un riferimento §5.12 da un commento nel codice sorgente deve trovare la soluzione e non un segnaposto obsoleto.

§5.12.1 POST /v1/auth/reset/request: non autenticato, con frequenza limitata per (IP, email), MAI azzerato in caso di successo.

Richiesta {"email": "anna@example.org"} → 202 {}, sempre.

202 indistintamente per un indirizzo noto, uno sconosciuto e uno non valido. Prima di rispondere, un server conforme DEVE eseguire lo stesso lavoro su entrambi i rami: cercare l'indirizzo, emettere il token, calcolarne il digest. La scrittura nello store e l'invio, che riguardano solo un indirizzo noto, NON DEVONO ritardare la risposta: il server di riferimento li esegue dopo l'invio di 202 (da 2026-09), e un eventuale errore viene registrato nei log, mai restituito. Tale simmetria costituisce l'intera difesa contro l'enumerazione, ed è quella che questo documento indicava in precedenza come MANCANTE: il vecchio request-reset eseguiva il lavoro oneroso solo per gli indirizzi esistenti, quindi i suoi tempi rivelavano ciò che il corpo non diceva. Prima del 2026-09 questo server attendeva ancora una scrittura e un invio solo per il ramo noto.

Non viene mai restituito un 400, nemmeno per un valore che non è palesemente un indirizzo: il codice di stato diventerebbe un oracolo gratuito per dedurre la struttura degli indirizzi presenti nell'istanza, e chi chiama non potrebbe trarre alcuna utilità da questa distinzione.

Il token è composto da 32 byte casuali, in formato base64url, con prefisso sr_. Viene memorizzato solo il suo digest SHA-256, in password_resets, con un TTL di 60 minuti. Un solo token attivo per account: una nuova richiesta contrassegna come consumata ogni riga precedente non consumata, nella stessa transazione, così chi scorre la propria casella di posta non può riscattare la lettera del giorno prima. Queste transazioni vengono serializzate per account (un blocco sulla riga dell'account), quindi le richieste che si sovrappongono lasciano comunque esattamente un token attivo.

Quando il servizio di posta non è configurato l'invio è un'operazione vuota e l'endpoint risponde comunque 202. In questo caso gli utenti di un'istanza self-hosted non hanno modo di ripristinare l'account; la soluzione per il gestore è POST /v1/admin/accounts/:id/reset-mail, che restituisce il link.

§5.12.2 POST /v1/auth/reset/open: non autenticato, limitato per IP.

Richiesta {"resetToken": "sr_…"} → 200:

json
{ "email": "anna@example.org", "recoveryCode": "ABCDEFGHJKMNPQRSTVWXYZ0123456789" }

Il token viene consumato nella STESSA istruzione che lo legge (UPDATE … WHERE consumed_at IS NULL AND expires_at > now RETURNING), quindi due richieste con lo stesso token non possono ricevere risposta entrambe. Token sconosciuti, consumati e scaduti producono UN SOLO 404 {"error":"reset-invalid"} a parità di lavoro svolto.

QUESTO ENDPOINT NON SCRIVE NULLA NELL'ACCOUNT, e questa frase rappresenta l'intera differenza rispetto al flusso descritto in precedenza al §5.13. L'endpoint restituisce il codice di recupero che il server conserva già in deposito (§3.1); il client esegue quindi con esso l'ORDINARIA procedura descritta al §5.14 recover-rotate: verifica il codice, imposta una nuova passphrase, re-incapsula la DEK, genera un nuovo codice, lo deposita di nuovo, tutto in un'unica transazione. Senza i record delle chiavi, ciò che viene restituito è una semplice stringa. Una modifica futura che consentisse a questo percorso di toccare un verificatore o un record di chiave avrebbe ripristinato il flusso di appropriazione dell'account eliminato da ADR-0004, a prescindere dal nome assegnatogli.

I costi reali, dichiarati chiaramente anziché lasciati impliciti. Il ripristino funziona perché il gestore possiede il codice di recupero. Leggi il §3.1 e docs/adr/0005-organization-accounts-and-escrowed-recovery.md prima di decidere se fidarti di un'istanza gestita; la decisione riguarda il gestore, non la crittografia.

5.13 POST /v1/auth/verify-email: rimosso nella versione 0.5.0 e non ripristinato

Rimosso insieme al modulo di invio della posta nella versione 0.5.0, e il protocollo 2 non lo reintroduce anche se questo servizio invia nuovamente messaggi.

Non c'è più nulla da confermare: un account viene creato riscattando un invito INDIRIZZATO a una casella di posta (§5.8), quindi chi ha ricevuto il messaggio è la stessa persona che ha effettuato la registrazione. L'invito coincide con la verifica, e un secondo link chiederebbe solo di dimostrare due volte ciò che è già stato palesemente dimostrato una volta.

5.14 POST /v1/auth/recover, POST /v1/auth/recover-rotate e POST /v1/auth/change-passphrase

L'autenticatore tramite codice di recupero e le due rotazioni delle credenziali. recover-rotate e change-passphrase accettano la stessa struttura di dati perché compiono la stessa operazione; cambia solo la prova fornita.

POST /v1/auth/recover: non autenticato, limitato per IP e email. Richiesta {"email": "...", "recoveryAuthHash": "<base64, 32 bytes>"} → 200 {"account": AccountView, "tokens": {...}}.

La risposta è una normale sessione, volutamente non ridotta: chi detiene il codice di recupero è per definizione il proprietario dell'account, e un token limitato in "modalità di recupero" introdurrebbe una seconda superficie di autorizzazione priva di proprietà non già garantite dal codice.

jsonc
// POST /v1/auth/recover-rotate: unauthenticated, proof is the recovery code
{
  "email": "...",
  "recoveryAuthHash": "<the current recovery proof>",
  "newAuthHash": "<new>",
  "kdfDescriptor": {...},
  "keyRecords": [ ... ],
  "newRecoveryAuthHash": "<a new recovery proof>" | null,  // optional: rotate the code too
  "recoveryCode": "<the new code, in the clear>"           // REQUIRED whenever newRecoveryAuthHash is present
}

// POST /v1/auth/change-passphrase: bearer, proof is the current passphrase
{ "currentAuthHash": "...", "newAuthHash": "...", "kdfDescriptor": {...}, "keyRecords": [ ... ] }

Le voci in keyRecords sono {"kind": "passphrase" | "recovery", "kdfDescriptor": {...} | null, "wrappedDek": "<base64>"}, al massimo una per tipo, e rispettano le stesse regole del §5.4 (il descrittore di un record recovery deve essere null; quello di un record passphrase non deve esserlo).

change-passphrase restituisce 200 {"tokens": {...}}. recover-rotate restituisce 200 {"account": AccountView, "tokens": {...}}, perché chi chiama non ha una sessione e deve sapere a quale account ha appena avuto di nuovo accesso. Entrambi restituiscono una nuova coppia.

L'intero invio viene applicato in modo atomico. Il nuovo verificatore, il nuovo descrittore KDF dell'account, l'eventuale nuovo verificatore di recupero, il deposito di nuovo sigillato, i record delle chiavi inseriti o aggiornati, la revoca di ogni sessione attiva e la nuova coppia di chi chiama vengono salvati tutti insieme, oppure non ne viene salvato alcuno. Non si tratta di un dettaglio implementativo. Ogni stato intermedio costituisce un disastro a sé che l'utente non nota finché non prova a leggere il proprio diario: un verificatore senza il relativo record re-incapsulato consente l'accesso ma non decifra nulla, un record senza il proprio verificatore impedisce del tutto l'accesso, e un verificatore di recupero ruotato senza il relativo record lascia un codice che autentica ma poi non estrae nulla.

keyRecords deve essere presente, anche come []. Una chiave assente produce un 400, per la stessa ragione per cui expectedUpdatedAt è obbligatorio nel §5.4: l'assenza di dati non deve mai essere interpretata come un consenso all'interno di un percorso che può rendere inaccessibili i dati.

I tipi non inviati rimangono invariati. La modifica della passphrase re-incapsula la DEK con una nuova KEK_p; il record recovery continua a incapsulare la stessa DEK invariata e resta valido.

Si applicano quattro regole soltanto a recover-rotate:

  • È richiesto un record di chiave passphrase, e [] è un 400. A differenza di un cambio di passphrase, questo percorso ha necessariamente modificato KEK_p, quindi accettare un invio senza il nuovo incapsulamento creerebbe un account che accede perfettamente ma non decifra nulla.
  • La rotazione del codice di recupero segue la logica del tutto o niente, e nel protocollo 2 questa regola coinvolge TRE parti. newRecoveryAuthHash, un record di chiave recovery e recoveryCode devono arrivare insieme o non arrivare affatto; qualsiasi sottoinsieme è un 400. Ogni elemento mancante porta a un disastro diverso: un verificatore senza il record lascia un codice che si autentica ma non decifra nulla; un record senza il verificatore ne lascia uno che decifra ma non può accedere; e un ESCROW che conserva ancora il vecchio codice trasforma il ripristino successivo inviato via email (§5.12) in un messaggio con una credenziale che l'account non accetta più, scoperta proprio il giorno in cui serve.
  • La scrittura è un compare-and-swap sul verificatore di recupero corrispondente alla prova, riconfermato all'interno della transazione. Non si tratta dell'autenticazione, che è già avvenuta; serve a impedire che due recuperi concorrenti sovrascrivano una credenziale che è già stata confermata all'utente.
  • Un solo errore, quattro cause. Un indirizzo sconosciuto, un account che non ha mai impostato un codice di recupero, un codice errato e una rotazione che ha perso la race condition di compare-and-swap rispondono tutti con 401 con testo identico, dopo la stessa quantità di lavoro. Una race condition non deve essere distinguibile da un tentativo errato, e l'assenza di un secondo autenticatore non deve essere distinguibile da un account inesistente. Un account SUSPENDED fa eccezione: risponde 403 {"error":"account-suspended"}, e solo dopo il successo della prova.

change-passphrase è limitato nella frequenza per account, da qualsiasi indirizzo, nel bucket delete, che rotate-dek e la sovrascrittura di un record di chiave condividono (§5.4): chi chiama possiede già un token, e currentAuthHash è un tentativo che tale token non può dimostrare. Un account bloccato riceve 429 con Retry-After; un esito positivo svuota il bucket.

Entrambi gli endpoint di recupero condividono uno bucket di limitazione per (IP, email), e nessuno dei due lo azzera in caso di successo. Autenticano lo stesso segreto, quindi una quota separata per ciascuno dimezzerebbe il costo dei tentativi per indovinarlo, e un recupero legittimo avviene una volta sola, perciò nessun client onesto ha bisogno di recuperare la propria quota. A POST /v1/auth/reset/request si applica la stessa regola di limitazione.

Cosa può fare e cosa non può fare una rotazione. Ripristina accesso. Non può ripristinare dati, perché il server non ha mai posseduto una chiave. Un change-passphrase che invia keyRecords: [] lascia un account funzionante il cui blob resta permanentemente indecifrabile, ed è esattamente il motivo per cui recover-rotate rifiuta direttamente quell'invio. Un client conforme deve dichiararlo, in questi termini precisi, prima che l'utente confermi la procedura.

Se perdi la passphrase, il percorso di ripristino è descritto nel §5.12, e funziona perché il gestore conserva il codice in escrow (§3.1). Qui il protocollo 1 affermava che la perdita contemporanea di passphrase e codice chiudeva definitivamente l'account, senza che nessuno potesse più aprirlo. Questa frase ora è vera solo per un'istanza la cui SERVER_SECRET è andata anch'essa perduta, motivo per cui quel segreto deve essere salvato INSIEME al database, e perderlo ha conseguenze peggiori di quanto sembri.

La versione onesta del vecchio avviso riguarda il gestore, non la matematica. Un'istanza gestita può aprire qualsiasi account presente su di essa. Un'istanza in hosting autonomo coincide con il proprio gestore, quindi la vecchia garanzia resta valida per l'uso personale. Un client conforme segnala con quale delle due sta comunicando, prima che una persona vi inserisca un diario.

5.15 GET /v1/auth/account, PATCH /v1/auth/account e POST /v1/auth/delete

Tutti e tre al portatore.

AccountView è l'UNICA struttura dell'account in questo protocollo. Viene restituito da POST /signup, POST /login, GET /account, PATCH /account, POST /recover, POST /recover-rotate e dagli endpoint di amministrazione degli account, quindi un client dispone esattamente di un solo decodificatore di account:

json
{
  "id": 1,
  "email": "anna@example.org",
  "displayName": null,
  "role": "member",
  "dailyAiLimit": 200,
  "aiUsedToday": 3,
  "allowanceExpiresAt": null,
  "freeDailyAiLimit": 0,
  "capabilities": null,
  "trialScans": { "granted": 10, "left": 7 },
  "trialEndsAt": "2026-09-19T00:00:00.000Z",
  "suspendedAt": null,
  "invitesLeft": 5,
  "invitesNeedAPlan": false,
  "healthConsent": { "version": "2026-09-28", "at": "2026-09-04T10:11:12.000Z" },
  "createdAt": "2026-09-04T10:11:12.000Z"
}

Non contiene nulla di segreto e nulla può esserlo: nessun verificatore, nessun descrittore KDF, nessuna DEK incapsulata, nessun escrow, nessun token. Ogni campo rappresenta le informazioni personali dell'utente o i privilegi concessi da un gestore. aiUsedToday viene conteggiato a fronte di dailyAiLimit nel giorno UTC corrente; suspendedAt è non-null finché ogni chiamata autenticata risponde 403 account-suspended.

invitesLeft indica quanti inviti questo account può ancora inviare tramite POST /v1/auth/invites (§5.21), o null quando tale limite non lo riguarda. null, mai 0, per un amministratore: 0 si legge come "li hai usati tutti", e un amministratore non ne ha usato nessuno, perché genera gli account tramite l'API di amministrazione, che non è soggetta al limite né alla regola di re-invito. Un'istanza con instance.memberInvites: false invia null per lo stesso motivo: non esiste alcun limite, perché non esiste la route, e un 0 notificherebbe una quota esaurita che in realtà non è mai esistita. Un client può mostrarlo e NON DEVE usarlo per autorizzare le operazioni; il servizio rifiuta una sesta creazione a prescindere da cosa presupponga il client.

invitesNeedAPlan è true quando invitesLeft è 0 solo perché l'account è una prova di scansione che nessuno ha ancora pagato (§5.21), e false in ogni altro caso, inclusi un amministratore e un'istanza con instance.memberInvites: false. Un account è una prova di questo tipo quando include trialScans e il suo allowanceExpiresAt è null o già trascorso; un valore futuro per allowanceExpiresAt, che è ciò che il sistema di fatturazione scrive al momento del pagamento, riapre gli inviti e lascia inalterato trialScans. Il campo è additivo: un client che lo ignora legge invitesLeft: 0, il che rimane vero, e un client che lo legge può indicare che gli inviti si sbloccano con un piano invece di dire che sono esauriti.

allowanceExpiresAt è un istante ISO o null, e null indica che la quota di IA non ha una data di termine, come avviene su un'istanza in hosting autonomo. Da quell'istante in poi, il proxy del §5.19 risponde 403 allowance-expired. Regola l'accesso all'IA e a nient'altro: la sincronizzazione continua a funzionare oltre tale data, perché il diario appartiene all'account e un nuovo dispositivo deve poterlo scaricare. Un client può mostrare la data e non deve usarla per autorizzare le operazioni; la regola risiede nel proxy.

freeDailyAiLimit è il valore concessione gratuita permanente dell'account: unità AI per giorno UTC (§5.19) applicate ogni volta che non è attiva una finestra a pagamento, senza data di termine e senza vincoli sulle scansioni. 0 indica nessuna. L'ordine del proxy (§5.19) prevede prima una finestra a pagamento attiva (allowanceExpiresAt nel futuro, a dailyAiLimit), poi questa concessione, quindi la prova di scansione, in modo che un account con una concessione gratuita ripieghi su di essa al termine di una finestra a pagamento anziché perdere l'accesso all'AI. Un client che mostra un limite giornaliero visualizza questo ogni volta che non è attiva una finestra a pagamento. Il valore nella vista è quello applicato dal proxy: il limite dell'account se superiore a 0, altrimenti il valore predefinito dell'istanza (§5.19), altrimenti 0. Solo un operatore lo scrive (§5.20); le credenziali del gestore della fatturazione non possono farlo. Un client può visualizzarlo e NON DEVE autorizzare in base a questo. Il campo è additivo: un client che lo ignora decodifica la vista senza modifiche.

capabilities è l'elenco delle capability rispetto a cui il proxy verifica questo account (§5.19, "Capabilities"): il record dell'account, altrimenti defaultCapabilities dell'istanza (§5.6), altrimenti null. null significa nessun controllo, non "nulla": ogni funzionalità è accessibile, ovvero ogni account su un'istanza che non imposta valori predefiniti né record. [] significa nessuna funzionalità. Un client che riceve null DEVE considerare accessibile ogni funzionalità. Un client può visualizzarlo e NON DEVE autorizzare in base a questo: il proxy risponde 403 capability-required. Il campo è additivo: un client che lo ignora decodifica la vista senza modifiche. Le viste di amministrazione e fatturazione contengono invece il record dell'account (§5.20), dove null indica l'assenza di record.

trialScans è {"granted": n, "left": n} per un account con una prova delle scansioni, e null per uno senza, ovvero ogni account su un'istanza che non ne esegue alcuna. left è granted meno le scansioni usate, mai inferiore a 0. Un client può renderizzarlo insieme a NON DEVE autorizzare in base a questo: il proxy tiene il conteggio (§5.19), left è un'istantanea scattata al momento della generazione di questa vista, e ogni risposta veicolata dal proxy riporta il numero aggiornato in X-Trial-Scans-Left. Un valore futuro di allowanceExpiresAt rimuove il blocco delle scansioni, quindi un account a pagamento può comunque contenere questo campo.

trialEndsAt è un istante ISO, oppure null per un periodo di prova delle scansioni privo di data di fine e per un account senza alcun periodo di prova. Viene scritto una sola volta, quando la prova inizia con il riscatto (§5.8), su un'istanza che imposta un limite di giorni, ed è sempre una mezzanotte locale nel fuso orario di quell'istanza; un account creato prima che la sua istanza ne impostasse uno mantiene null, e la sua prova non viene mai accorciata a posteriori. Da quell'istante in poi il proxy risponde 403 trial-expired (§5.19), a meno che le scansioni gratuite non siano esaurite prima. Un client può visualizzarlo e NON DEVE autorizzare in base a questo. Un allowanceExpiresAt futuro lo rimuove esattamente come ripristina il conteggio delle scansioni. Il campo è additivo: un client che lo ignora decodifica la vista senza modifiche.

healthConsent è {"version": "<v>", "at": "<ISO instant>"} per un account con un consenso ai dati sanitari registrato, e null per uno senza: ogni account creato prima che la relativa istanza lo richiedesse, e ogni account su un'istanza che non ne richiede alcuno. version è il testo accettato dalla persona e at è l'orologio interno del servizio in quel momento. Un client confronta version con instance.healthConsent.version (§5.6) e lo chiede una volta quando differiscono o questo è null su un'istanza che lo richiede (§5.15.1). Il campo è additivo: un client che lo ignora decodifica la vista senza modifiche.

Gli endpoint di amministrazione degli account restituiscono la stessa struttura con l'aggiunta di due campi operatore, blob e keyRecordKinds (ADR-0001). Di conseguenza, un client che decodifica un AccountView da una risposta di amministrazione funziona senza modifiche e legge due campi che non aveva richiesto.

GET /v1/auth/account → 200 {"account": AccountView}.

PATCH /v1/auth/account accetta {"displayName": string | null} → 200 {"account": AccountView}. La chiave DEVE essere presente, anche come null: l'assenza della chiave produce un 400, la stessa regola seguita da keyRecords e expectedUpdatedAt, perché un PATCH che ignorasse silenziosamente un nome di campo errato rappresenterebbe una modifica che il client ritiene invece avvenuta.

Questo è l'unico campo che un account può modificare autonomamente. email rappresenta l'identità e cambia solo tramite un gestore; role e dailyAiLimit sono privilegi che un account non deve poter incrementare da solo; tutto ciò che riguarda l'autenticazione passa attraverso il §5.14.

POST /v1/auth/delete accetta {"authHash": "..."} e restituisce 204. La riautenticazione è richiesta anche se il chiamante possiede già un token valido: una sessione dimenticata su un dispositivo condiviso non deve essere sufficiente a distruggere irreversibilmente i dati di qualcuno. Un valore errato per authHash produce 401; i tentativi sono limitati per account nel bucket descritto in §5.4, e un account bloccato riceve 429 con Retry-After. Su un'istanza con un gestore della fatturazione (§5.22), superati entrambi i controlli, il servizio invia POST <PLANS_UPSTREAM_URL>/erase con X-Plans-Secret e X-Account-Id e nessun corpo, affinché il gestore della fatturazione annulli le sottoscrizioni dell'account prima che questo sia rimosso. Attende al massimo cinque secondi ed elimina qualsiasi cosa risponda il gestore della fatturazione; DELETE /v1/admin/accounts/:id fa lo stesso. Su un'istanza la cui API di posta è Pigeon (MAIL_API_URL termina con /v1/emails), una volta eliminato l'account il servizio invia anche POST <base>/v1/recipients/erase con {"email": "<address>"} e la chiave Bearer dell'API di posta, affinché Pigeon cancelli ogni copia dell'indirizzo in suo possesso. Quella chiamata non modifica mai la risposta: compie fino a tre tentativi, ritarda il 204 di due secondi al massimo, prosegue ogni tentativo ancora in sospeso dopo la risposta e, in caso di fallimento definitivo, registra una singola riga con un conteggio e uno stato o codice di errore senza alcun indirizzo. Nulla memorizza l'indirizzo per riprovare in seguito; il limite di conservazione di Pigeon funge da protezione finale. DELETE /v1/admin/accounts/:id fa lo stesso.

L'eliminazione rimuove l'account e, a cascata, ogni blob, record di chiave, token di ripristino e riga di utilizzo che gli appartengono. Non esiste eliminazione logica né periodo di tolleranza. Questo è il percorso di cancellazione self-service, ed è completo per costruzione anziché tramite un processo di pulizia che qualcuno deve ricordarsi di eseguire.

La stessa transazione inoltre revoca ogni invito inviato dall'account che risulta ancora in sospeso (§5.21). Un invito inviato da una persona applica i termini della porta di quella persona; uno lasciato in sospeso dopo la sua rimozione potrebbe essere ancora riscattato, e su una porta con prova a giorni la relativa quota decorre solo dal riscatto.

Su un'istanza che prevede una prova di scansione, la stessa transazione esegue anche rimuove l'indirizzo e il nome da ogni riga di invito relativa a quella casella postale e, se l'account disponeva di una prova, conserva un hash unidirezionale con chiave della casella postale affinché la regola di §5.8.3 di una sola prova per casella di posta persista all'eliminazione. L'hash viene conservato per TRIAL_HASH_RETENTION_DAYS (365 per impostazione predefinita) dopo l'eliminazione e viene poi rimosso da una pulizia oraria, dopodiché la stessa casella di posta può usufruire di nuovo di una prova. Un'istanza che non concede prove di scansione non conserva alcun hash. Nessun altro dato sulla persona viene conservato (§9.2). La base e il periodo sono indicati in docs/adr/0010-the-mailbox-hash-has-a-basis-and-an-end.md.

5.15.1 POST /v1/auth/account/health-consent: consenso esplicito ai dati sanitari

Bearer. Presente solo dove instance.healthConsent non è null; ovunque altrove il percorso risponde con il normale 404 per percorso sconosciuto, per tutti, con accesso effettuato o meno.

Perché un'istanza lo richiede. Un diario costituisce dati sanitari: alimenti, peso, digiuno. Su un'istanza gestita il gestore conserva il codice di recupero depositato (§3.1, ADR-0005) e può quindi aprire il diario, e la sua informativa sulla privacy indica il consenso esplicito ai sensi dell'Art. 9(2)(a) del GDPR come base giuridica. Il gestore deve poter dimostrare che il consenso è stato prestato, quando e per quale formulazione. Un'istanza in Self-hosting il cui gestore è la persona stessa non chiede nulla a nessuno e lascia HEALTH_CONSENT_VERSION non impostato.

Due modi in cui un consenso raggiunge un account, una sola versione. Il gestore imposta HEALTH_CONSENT_VERSION, una stringa breve da 1 a 32 lettere, cifre, ., _ o - (una data come 2026-09-28), e /health la pubblica come instance.healthConsent.version.

  • Un nuovo account dà il consenso nella fase di creazione dell'account: POST /v1/auth/signup include "healthConsent": {"version": "<v>"} e lo registra nella stessa istruzione dell'account (§5.8).
  • A Un account esistente che ne è privo, o con una versione precedente, viene chiesto una volta e dà il consenso qui.

Richiesto su ogni route dati. Finché non acconsente, a un account che non possiede la versione corrente dell'istanza viene rifiutato 403 {"error":"health-consent-required"}, lo stesso corpo su ogni route, dopo il controllo del bearer e prima che qualsiasi cosa venga archiviata, conteggiata o inviata:

Rifiutato a un account senza il consensoMotivo
Ogni route sotto /v1/sync tranne le due letture seguenti: il push dei blob, le scritture ed eliminazioni dei record di chiavi, rotate-dek, le condivisioni, la ricercaArchiviano o trasmettono il diario
POST /v1/chat/completions (§5.19), dopo la sospensione e prima della quotaIl corpo è una foto del piatto
POST /v1/feedback (§5.25), /v1/pulse/* (§5.23), /v1/push/* (§5.24)Ciascuno archivia qualcosa estratto dal diario
/v1/plans/* (§5.22), eccetto l'anonimo GET /v1/plans/pricesUtilizzo dell'account, non consenso o abbandono
PATCH /v1/auth/account, POST /v1/auth/invites (§5.21)Utilizzo dell'account, non consenso o abbandono
POST /v1/auth/change-passphrase (§5.14)La sua seconda metà riscrive il compartimento sul blob, operazione che viene rifiutata, quindi l'intera modifica attende
Aperto a un account senza il consensoMotivo
POST /v1/auth/login, POST /v1/auth/refresh, POST /v1/auth/logoutAccesso e disconnessione
GET /v1/auth/accountIl client lo legge per sapere che deve chiedere
POST /v1/auth/account/health-consent (questa route)Dove l'account acconsente
POST /v1/auth/delete (§5.15)L'eliminazione è il modo in cui un consenso viene rifiutato o revocato
GET /v1/sync/blob (§5.2), GET /v1/sync/key-records (§5.3)La propria copia. L'accesso su un nuovo dispositivo richiede entrambi prima che un client possa fare richieste, e un'esportazione su un nuovo dispositivo costituisce l'estrazione. Non viene memorizzato nulla
/health, GET /v1/plans/prices, POST /v1/legal/declarations, le route non autenticate di /v1/auth/* (da §5.7 a §5.14)Nessuna sessione, quindi nessun account a cui chiedere
/v1/admin/* (§5.20)Le credenziali dell'operatore stesso; le route del diario di un amministratore vengono rifiutate come quelle di chiunque altro

Il consenso a una formulazione precedente viene rifiutato come se non ci fosse. Il rifiuto cessa alla richiesta immediatamente successiva dopo che questa route risponde 200, sullo stesso token di accesso: il servizio legge la riga dell'account a ogni richiesta autenticata, come fa per una sospensione, e legge anche il consenso. Su un'istanza in cui instance.healthConsent è null, nulla di tutto questo rifiuta alcunché.

Il mittente delle notifiche push legge la tabella delle sottoscrizioni invece di una route, quindi applica la stessa regola in autonomia: un dispositivo registrato prima della richiesta dell'istanza mantiene la propria riga e non riceve nulla finché l'account non acconsente (§5.24).

Richiesta: {"version": "2026-09-28"} → 200 {"account": AccountView} (§5.15), con healthConsent impostato.

StatoSignificato
200{"account": AccountView}. Il consenso è registrato, ora o da una chiamata precedente con la stessa versione
400{"error":"health-consent-required"}: il corpo non contiene la stringa version, o non contiene quella pubblicata da /health; non viene registrato nulla
401Nessun token di accesso valido
403{"error":"account-suspended"}
404L'istanza non richiede alcun consenso

Quattro regole che un server conforme DEVE rispettare:

  1. La versione memorizzata è quella dell'istanza, mai quella del chiamante. Il valore version del corpo viene confrontato byte per byte con quello dell'istanza, senza trim e senza conversione del maiuscolo/minuscolo, e ciò che viene scritto è la stringa dell'istanza.
  2. L'istante corrisponde all'orologio del server. Un client non invia alcun orario, e nessuno verrebbe letto.
  3. Idempotente, e vince il primo istante. Una seconda chiamata con la versione già registrata non cambia nulla e risponde con lo stesso 200; at rimane il momento in cui la persona ha acconsentito la prima volta. Una versione diversa sostituisce entrambi, quindi una nuova formulazione porta con sé il proprio istante.
  4. La revoca è l'eliminazione. Nessuna route revoca un consenso. Una persona che revoca il consenso elimina l'account (POST /v1/auth/delete, §5.15), operazione che rimuove il diario e il consenso insieme alla riga. L'operatore legge il consenso nella vista account di amministrazione e nessuna route di amministrazione lo scrive: un consenso che un operatore potesse impostare per conto di qualcuno non proverebbe nulla.

health-consent-required è l'unico rifiuto di ciascun controllo del consenso, in due stati: 400 alla creazione dell'account e su questa route, dove il client mostra di nuovo la casella di spunta, e 403 su una route di dati, dove il client vi reindirizza la persona. La modifica di HEALTH_CONSENT_VERSION richiede nuovamente il consenso a ogni account, e da quel momento ogni route di dati rifiuta gli account che avevano accettato la vecchia formulazione fino a quando non accettano quella nuova; l'operatore la modifica quando cambia la formulazione e non altrimenti.

5.16 Condivisioni: /v1/sync/shares e /v1/sync/shared (ADR-0002)

Presente solo quando il deployment imposta SYNC_SHARING. Senza di esso, ogni percorso sottostante risponde con il consueto 404 di route sconosciuta a ogni chiamante, con credenziali o meno; il terminatore è montato prima dell'autenticazione, rendendo un'istanza non configurata indistinguibile da una in cui la funzionalità non è mai stata scritta.

Entrambe le parti indirizzano una condivisione tramite il id account della controparte, mai tramite un id di condivisione sintetico: l'identità stabile di una condivisione è la coppia (concedente, beneficiario), ed è ciò che sopravvive a una rotazione della DEK.

Lato concedente.

VerboPercorsoNote
PUT/shares/:granteeAccountId`{"wrappedDek": "<base64>", "recipientKeyFingerprint": "<string>", "expectedUpdatedAt": "<iso>" \null}. CAS exactly as §5.4: null asserts no share exists yet, any other value asserts the row last read had this updatedAt, and an **absent** key is a 400. 409 returns {"currentUpdatedAt": "<iso>" \null}`.
GET/sharesLe concessioni create dal concedente. Non restituisce mai wrappedDek: un blob indirizzato alla chiave di qualcun altro non ha alcuna utilità in questo contesto, quindi non viene trasmesso dove nessuno ne ha bisogno.
DELETE/shares/:granteeAccountId204, idempotente. Una eliminazione definitiva; non è presente alcun tombstone.

Lato beneficiario.

VerboPercorsoNote
GET/sharedCondivisioni indirizzate a questo chiamante, ciascuna con il proprio wrappedDek; solo questo chiamante può aprirlo.
GET/shared/:grantorAccountId/blob{"grantorAccountId": <int>, "blobVersion": <int>, "envelopeVersion": <int>, "ciphertext": "<base64>", "createdAt": "<iso>"}. grantorAccountId è obbligatorio: l'AAD del §3.2 lo vincola, quindi un beneficiario che ne sia sprovvisto non può decifrare affatto.
DELETE/shared/:grantorAccountId204, idempotente. Consente a un beneficiario di eliminare una condivisione a lui destinata.
  • L'interfaccia del beneficiario non prevede verbi di scrittura verso il concedente, e fornisce solo la riga di condivisione del chiamante stesso, il blob corrente del concedente e grantorAccountId. Mai i record di chiave del concedente, il descrittore KDF, il verificatore, l'escrow, l'email o il nome visualizzato. Un beneficiario in grado di ottenere la DEK cifrata di recovery del concedente si troverebbe a un solo codice di recupero forzato a forza bruta di distanza dall'autorità di rotazione su quell'account.
  • Solo il blob corrente. L'anello delle versioni conservate è un meccanismo di recupero per il proprietario, non una cronologia per il beneficiario.
  • L'autorizzazione è una lettura diretta della riga a ogni richiesta, mai memorizzata in cache. È ciò che rende una DELETE efficace a partire dalla chiamata immediatamente successiva.
  • Sconosciuto, esterno e mai inviato rispondono tutti con stesso 404. L'assenza di una condivisione non deve confermare l'esistenza di un account.

5.17 POST /v1/sync/rotate-dek: rotazione atomica della DEK (ADR-0002)

Bearer, come proprietario dell'account. Un invio, una transazione:

json
{
  "blob": { "baseVersion": 3, "envelopeVersion": 1, "ciphertext": "<base64>" },
  "keyRecords": [{ "kind": "passphrase", "kdfDescriptor": { "...": "..." }, "wrappedDek": "<base64>" }],
  "newRecoveryAuthHash": "<base64, 32 bytes>",
  "recoveryCode": "ABCDE-FGHJK-MNPQR-STVWX-YZ012-3456",
  "currentAuthHash": "<base64, 32 bytes>",
  "shares": [{ "granteeAccountId": 7, "wrappedDek": "<base64>", "recipientKeyFingerprint": "<string>" }]
}

Il client genera una nuova DEK, cifra nuovamente l'intero snapshot con essa, ne esegue nuovamente il wrap sotto entrambe le KEK e ne esegue il wrap per ogni condivisione che mantiene. Il servizio memorizza il risultato tutto o niente.

Presente in ogni distribuzione, a differenza del §5.16. La rotazione non fa parte della superficie di condivisione: riscrive il blob del chiamante e i suoi due record di chiavi, righe presenti in ogni account ovunque, ed è la risposta a qualsiasi sospetto di compromissione di una DEK (un backup ripristinato, un dispositivo smarrito) su un'istanza che non ha mai condiviso nulla. Vincolare l'unico meccanismo in grado di revocare una DEK compromessa a un flag non correlato lascerebbe tale gestore senza alcun modo per revocarla.

  • currentAuthHash è OBBLIGATORIO, e un token bearer da solo non esegue mai la rotazione. È il ramo di autenticazione della passphrase attuale (§3.1), verificato come lo verifica change-passphrase. L'assenza o un formato non valido generano un 400 che lo indica; un valore non corrispondente restituisce 401 {"error":"current passphrase is incorrect"} e non viene scritto nulla. Una rotazione scrive il verificatore di recupero accettato da POST /v1/auth/recover, quindi prima di questo campo un token rubato poteva impostare un proprio codice e accedere definitivamente con esso, indipendentemente da cosa facesse poi il proprietario con la propria passphrase. I tentativi sono limitati nella frequenza per account nel bucket descritto nel §5.4 (429 con Retry-After). La transazione verifica nuovamente che il verificatore della passphrase dell'account sia ancora quello corrispondente; una modifica della passphrase confermata nel frattempo rende la rotazione un 401, e non viene scritto nulla.
  • Ogni altra sessione viene revocata nella stessa transazione. La famiglia di token del chiamante sopravvive, così il dispositivo che esegue la rotazione resta connesso; qualsiasi altro token access e refresh dell'account cessa di funzionare. Si esegue una rotazione quando si ritiene che una chiave sia stata compromessa, e una sessione che le sopravvivesse costituirebbe la violazione.
  • Tutto o niente, in una sola transazione di database. Divieto 8 di ADR-0002: una rotazione o è atomica o non esiste, e nessuna sequenza di endpoint con commit individuali può essere documentata o utilizzata come tale. Un'applicazione parziale è il blocco "accede correttamente, non decifra nulla" che il §5.14 rifiuta già di consentire, con un partecipante in più: un record di chiave di cui viene rieseguito il wrap mentre la scrittura del blob perde il suo CAS blocca il proprietario, e una condivisione di cui viene rieseguito il wrap mentre la scrittura del blob perde il suo CAS blocca il medico.
  • blob è sottoposto a compare-and-swap su baseVersion, esattamente come nel §5.1. Un valore non aggiornato restituisce un 409 {"currentVersion": n} e non viene scritto assolutamente nulla.
  • newRecoveryAuthHash E recoveryCode sono OBBLIGATORI, e un invio privo di uno dei due genera un 400 che indica il campo. Una rotazione genera sempre un nuovo codice di recupero, perché il record di chiave recovery su cui riesegue il wrap è sigillato sotto una KEK derivata da quel codice; il server sostituisce quindi accounts.recovery_verifier e il deposito a garanzia (§3.1) all'interno della stessa transazione rispetto al blob, ai record di chiavi e alle condivisioni. Una rotazione che lasciasse entrambi sul codice VECCHIO produrrebbe un account il cui codice depositato autentica senza poi decifrare nulla, un problema latente dal momento in cui il codice di recupero è diventato il secondo autenticatore, e fatale quando un ripristino via email (§5.12) inizia a distribuire quel codice agli utenti. Il client non mostra il nuovo codice alla persona; entra nel deposito a garanzia e lì rimane.
  • Il server deriva autonomamente la prova di recupero. Esegue il ramo di autenticazione per il recupero del §3.1 sul valore canonico recoveryCode e calcola il nuovo verificatore a partire da QUELLO, così il verificatore e il deposito a garanzia descrivono sempre lo stesso codice. newRecoveryAuthHash è ancora richiesto e deve coincidere con la prova derivata; una mancata corrispondenza genera un 400 che lo indica, e non viene scritto nulla.
  • keyRecords deve includere ENTRAMBI i tipi. Un tipo mancante restituisce un 400, mai una rotazione parziale silenziosa: inviare solo il wrap passphrase lascerebbe il record recovery a racchiudere una DEK che non apre più nulla, quindi il codice di recupero consentirebbe comunque l'accesso all'account senza decifrarlo mai più. Ciascuna voce rispetta le regole del §5.4 (un descrittore recovery deve essere null, un descrittore passphrase non deve esserlo). Non esiste un expectedUpdatedAt per record: l'invio stesso rappresenta l'unità di concorrenza.
  • shares rappresenta l'elenco di ciò che va MANTENUTO, e ogni riga di condivisione non indicata al suo interno viene eliminata nella stessa transazione. Questo inverte il §5.14, dove un record di chiave non modificato viene mantenuto, intenzionalmente, poiché queste righe costituiscono l'autorizzazione di qualcun altro sul diario del chiamante e l'assenza di modifiche deve rappresentare l'impostazione predefinita sicura. shares: [] revoca quindi qualsiasi elemento, ed è valido; una chiave assente shares restituisce un 400, per il motivo per cui il §5.4 richiede che expectedUpdatedAt sia specificato esplicitamente. In una distribuzione priva di SYNC_SHARING l'elenco deve essere vuoto; un elenco non vuoto restituisce un 400, poiché dichiara uno stato che quell'istanza non può contenere.
  • Una condivisione indicata che non esiste restituisce un 400, interamente annullato con rollback, mai trattato come una concessione. Il destinatario potrebbe aver rimosso la propria parte; rileggi GET /v1/sync/shares e invia nuovamente.
  • Le versioni precedenti del blob conservate (§8) rimangono sigillate sotto la VECCHIA DEK e diventano dati inutilizzabili nel momento stesso in cui viene eseguito il commit della rotazione, illeggibili per chiunque, compreso il loro proprietario. Non vengono eliminate qui: la pulizia le rimuove entro i successivi cinque invii, e scartarle durante una rotazione eliminerebbe l'unica difesa del proprietario contro una scrittura errata del client nella stessa operazione.
StatoCorpo
200{"newVersion": 4, "keptShares": 1, "revokedShares": 2}
400{"error": "..."}: tipo di record di chiave mancante, campo assente o non valido, un newRecoveryAuthHash che non corrisponde alla prova del codice, elenco di mantenimento che cita una condivisione inesistente.
401{"error": "current passphrase is incorrect"}: currentAuthHash non corrispondeva, oppure la passphrase è cambiata durante la rotazione. Non è stato scritto nulla.
409{"currentVersion": 5}: il CAS del blob non è andato a buon fine. Non è stato scritto nulla.
413{"error": "..."}: il nuovo blob supera MAX_BLOB_BYTES.
429{"error": "..."} con Retry-After: i tentativi di inserimento della passphrase per questo account sono bloccati.

La rotazione è una revoca di Livello 2, e le regole di formulazione del §5.16 restano vincolanti. L'eliminazione di una riga di condivisione impedisce al server di servirla; la rotazione aggiunge che le voci future saranno sigillate con una chiave che la parte revocata non ha mai avuto. Nessuna delle due azioni recupera ciò che era già stato scaricato, e nessun client può affermare il contrario.

5.18 Contributi alla ricerca: /v1/sync/contributions e /v1/sync/study (ADR-0003)

Presente solo quando il deployment imposta SYNC_RESEARCH. Se assente, ogni percorso sottostante restituisce il normale 404 per route sconosciuta a qualsiasi chiamante, con o senza credenziali, con il terminatore montato a monte dell'autenticazione. Indipendente da SYNC_SHARING; nessun flag implica l'altro.

Lato collaboratore, con autenticazione come collaboratore:

VerboPercorsoNote
PUT/contributions/:studyAccountId{"pseudonym","schemaTier","body","contributionVersion"}. CAS su un valore contributionVersion monotonico. Il contributo è l'insieme di dati cumulativo per la finestra temporale, ricalcolato e inviato nuovamente per intero; il client conserva sempre l'origine, quindi questa riga è una proiezione, mai una copia primaria.
GET/contributionsLe iscrizioni del collaboratore stesso. Non restituisce mai body.
DELETE/contributions/:studyAccountIdRitiro. Una transazione: eliminazione definitiva della riga, inserimento di una tombstone con chiave basata sullo pseudonimo. 204, idempotente.

Lato studio, con autenticazione come account dello studio:

VerboPercorsoNote
GET/study/contributions{"pseudonym","contributionVersion","schemaTier","body","createdAt"} per riga. Nessun ID account, mai.
GET/study/withdrawalsPseudonimi che si sono ritirati, con timestamp. Il client dello studio deve eliminarli prima di presentare o esportare qualsiasi dato.

GET /study/contributions ripete studyAccountId una volta sola, al livello più alto della busta, non su ogni riga: è l'ID del chiamante stesso, che si è autenticato con esso, è identico per ogni riga e non è un identificatore del collaboratore. Il ricercatore ne ha bisogno per ricostruire l'AAD del §3.5, e su ogni singola riga sarebbe solo rumore.

Il compare-and-swap di contributionVersion. Il valore inviato è la nuova versione, non una base; si vincola nell'AAD, quindi deve essere il valore con cui è stato sigillato il testo cifrato. La regola è strettamente maggiore di quella memorizzata: un client che ricalcola e invia nuovamente l'intera proiezione non deve mai bloccarsi a causa di una versione che non ha mai lasciato il dispositivo. Una scrittura che perde produce 409 {"currentVersion": <int>}, coerentemente con la struttura del §5.1.

Il server convalida schemaTier rispetto ai livelli definiti da questo protocollo. Il nome del livello è un metadato, non contenuto (viaggia in chiaro e il server lo memorizza già) e senza questo controllo il divieto 1 di ADR-0003 ha valore solo sul client. Un livello sconosciuto produce 400.

Il server non convalida il formato dello pseudonimo, solo che sia presente e con dimensioni limitate. Non può verificarne uno (ciò richiederebbe la radice del collaboratore) e un controllo strutturale implicherebbe un'autorità che il server non possiede.

StatoQuando
400corpo non valido, schemaTier sconosciuto, contributionVersion assente
404studio sconosciuto, contributo sconosciuto e qualsiasi altro elemento non trovato: un unico percorso di codice
409contributionVersion non strettamente maggiore di quello memorizzato
413il contributo supera MAX_CONTRIBUTION_BYTES (256 KiB)

Un solo pseudonimo per studio, applicato dal database. Se due collaboratori inviassero lo stesso pseudonimo, si fonderebbero silenziosamente in un'unica serie di partecipanti, e un ricercatore analizzerebbe due persone come se fossero una sola senza alcun errore. Una collisione accidentale ha una probabilità di circa 2^-128, quindi il vincolo non dovrebbe mai attivarsi, ed è proprio questo il punto: rende la corruzione impossibile anziché improbabile.

La revoca cancella davvero i dati su questo lato. Un contributo che lo studio non ha ancora scaricato non raggiunge nessuno. Ciò che lo studio ha già scaricato non può essere recuperato: la tombstone contiene l'istruzione, e rispettarla è un obbligo etico che questo sistema dichiara ma non può imporre.

5.19 POST /v1/chat/completions: il proxy dell'IA

Presente solo se il gestore ha configurato una chiave upstream. Senza di essa, il percorso risponde con il consueto 404 di percorso sconosciuto, a chiunque, con o senza credenziali, e instance.ai è null durante l'handshake (§5.6). Un'implementazione di questo protocollo PUÒ omettere del tutto la rotta; un client DEVE leggere instance.ai prima di proporre una scansione, anziché verificare il percorso a tentativi.

Autenticato con il normale token di accesso dell'account (§4.1), controllato prima della lettura del corpo: una richiesta priva di token valido riceve 401 a prescindere dalle sue dimensioni o dal suo formato, e il servizio non la memorizza nel buffer né ne esegue il parsing. Il corpo è una richiesta di chat completion compatibile con OpenAI. Il servizio controlla che si tratti di un oggetto JSON, inoltra solo i campi presenti in un elenco di elementi consentiti (sotto), riscrive i pochi che stabiliscono il costo di una singola richiesta, limita ciò che una richiesta può includere e non rifiuta nulla a causa di un campo sconosciuto: lo scarta. La risposta è quella del provider, inoltrata con il rispettivo stato.

L'istanza decide quanto può costare una singola richiesta. Una singola chiave a monte può servire tutti gli account di un'istanza, e un conteggio giornaliero delle richieste non dice nulla sul costo di ciascuna. Di conseguenza, il servizio riscrive questi campi prima di inoltrarli, per ciascun account, senza mai rifiutare una richiesta a causa loro.

Vengono inoltrati solo questi campi di primo livello: model, messages, stream, stream_options, temperature, top_p, response_format, max_tokens, max_completion_tokens, reasoning e n. Ogni altro campo viene scartato, e il relativo nome (mai il valore) viene annotato nei log, così un client che invia un campo non riconosciuto dal servizio continua a funzionare. All'interno di messages, un messaggio conserva role, content e name; una parte del contenuto è text (con text) oppure image_url (con il solo url, scartando quindi detail), e un image_url il cui url non è un URI data:image/...;base64, viene scartato, poiché un URL remoto o un documento veicolato da un URI data rappresentano input non quantificati. Qualsiasi altro tipo di parte viene scartato.

CampoCosa riceve il provider
modelil modello del livello della richiesta, scelto dall'operatore: il livello il cui instradamento corrisponde al response_format.json_schema.name della richiesta, altrimenti il livello predefinito dell'istanza. Il modello del livello predefinito è quello pubblicato da instance.ai.model (§5.6). Un'istanza senza file dei livelli ha un solo livello, il cui modello è AI_ADVERTISED_MODEL. Quando l'operatore non ne ha indicato alcuno, il valore model del chiamante viene inviato senza modifiche.
max_tokens, max_completion_tokensal massimo AI_MAX_OUTPUT_TOKENS (valore predefinito 8192), oppure il limite inferiore specifico del livello della richiesta, se presente. Un valore superiore, o non numerico, diventa il limite. A un corpo che non contiene nessuno dei due viene applicato max_tokens.
reasoning.max_tokensal massimo lo stesso limite. reasoning.effort viene mantenuto, a meno che il livello della richiesta non imposti il proprio livello di impegno: in tal caso il servizio imposta quell'impegno e ignora reasoning.max_tokens del chiamante, poiché un fornitore accetta solo uno dei due parametri.
n1, quando presente.
usage, su un upstream OpenRouterscritto come {"include":true}, affinché la risposta riporti il conteggio dei token e il prezzo (sotto). Qualsiasi altro upstream non ne riceve alcuno. Il valore usage specificato dal chiamante viene rimosso.
usage, su un upstream OpenRouterscritto come {"include":true}, affinché la risposta riporti il conteggio dei token e il prezzo ("Costo di un completamento", sotto). Qualsiasi altro upstream non ne riceve alcuno. Il valore usage specificato dal chiamante viene rimosso.
qualsiasi campo non presente nell'elenco di elementi consentiti precedenterimosso, ad esempio models, route, plugins, web_search_options, prediction, tools.
provider, su un upstream OpenRouterriscritto come {"data_collection":"deny"}: solo endpoint che non memorizzano né usano la richiesta per l'addestramento. L'operatore può aggiungere "zdr":true e "only":[...] con "allow_fallbacks":false, tramite il livello della richiesta (routing) oppure tramite UPSTREAM_ZDR e UPSTREAM_PROVIDER_ONLY. Il parametro provider specificato dal chiamante non viene mai inoltrato. Qualsiasi altro upstream non riceve alcun campo provider, indipendentemente da cosa sia impostato.

Il livello appartiene all'operatore, mai al chiamante. L'operatore scrive i livelli in un file (AI_TIERS_FILE, consulta il README): ciascuno include un modello, il rispettivo instradamento del fornitore e, facoltativamente, un limite di output inferiore e un livello di impegno del ragionamento, mentre una mappa routes indirizza il nome di uno schema di output strutturato a un livello. Una richiesta il cui schema viene instradato ottiene quel livello, tutte le altre richieste ricevono il livello predefinito. Un chiamante può solo indicare uno schema, potendo così raggiungere unicamente un livello definito dall'operatore, e non imposta mai un modello o un fornitore. L'handshake (instance.ai.model, §5.6) indica il modello del livello predefinito.

Il tetto massimo si applica con o senza un modello. Un client che necessita di una risposta più lunga di quella consentita dal tetto ne riceve una troncata, e l'operatore aumenta AI_MAX_OUTPUT_TOKENS. Un'istanza in Self-hosting che vuole consentire ai propri utenti di scegliere il modello lascia AI_ADVERTISED_MODEL e AI_TIERS_FILE non impostati.

POST /v1/chat/completions
Authorization: Bearer <accessToken>
Content-Type: application/json
X-Intake-Id: 2f9d0b416c3a4e579f10a1b2c3d4e5f6

{ "model": "…", "messages": [ … ], "stream": true }

Tre proprietà che un'implementazione conforme DEVE rispettare, e ciascuna esiste perché il corpo della richiesta è una fotografia del cibo di qualcuno:

  1. La credenziale del chiamante viene sostituita, mai unita. Le intestazioni della richiesta upstream vengono COSTRUITE anziché copiate dalla richiesta in ingresso e sovrascritte. Una copia con sovrascrittura inoltra cookie, x-api-key e qualsiasi cosa il prossimo fornitore decida di leggere.
  2. Nessun corpo viene registrato nei log, in nessuna delle due direzioni. Né un prefisso, né un buffer decodificato, né un documento di errore. Ciò che può essere registrato nei log: l'ID di un account, lo stato dell'upstream, i conteggi dei byte, una durata e, letti da una risposta riuscita, i conteggi dei token e il prezzo segnalati dal provider e un nome del modello plausibile (sotto).
  3. Ogni stringa ricevuta dalla connessione upstream viene ripulita prima che raggiunga una riga di log o una risposta. Un fornitore che rifiuta una richiesta rimanda abitualmente indietro la richiesta nel corpo dell'errore, immagine inclusa.

Il costo di un completamento

Un provider che segnala l'utilizzo lo inserisce nella risposta. Su un upstream OpenRouter il servizio scrive "usage": {"include": true} nel corpo inoltrato (mai preso dal chiamante, il cui campo usage viene scartato come qualsiasi campo fuori dall'elenco di elementi consentiti); qualsiasi altro upstream non riceve tale campo, quindi i suoi corpi restano inalterati. Dopo che la risposta è stata inoltrata, il servizio registra, nella sua riga Proxied a completion, model, promptTokens, completionTokens e costMicroUsd (il valore usage.cost del provider, un prezzo in dollari espresso come numero intero di milionesimi di dollaro), ciascuno null quando la risposta non lo indicava. Aggiunge inoltre costMicroUsd al totale dell'istanza per il giorno UTC, ai_instance_days.cost_micro_usd, una somma senza alcun account associato, che resta 0 per un provider che non segnala alcun prezzo. I numeri vengono letti al passaggio della risposta, JSON o flusso inviato dal server, senza ritardare o modificare un singolo byte. Un corpo superiore a 1 MiB, una riga di flusso superiore a 64 KiB e qualsiasi campo che non sia un numero plausibile non vengono letti e producono null. Mai il testo della risposta. La mancata registrazione del costo viene annotata nei log e non fa mai fallire una richiesta già servita.

Cosa può includere una richiesta

L'output ha un limite massimo sopra, l'input è limitato qui. Misurata sul corpo ricevuto dal provider (dopo la allow list), una richiesta viene rifiutata con 400 prima di qualsiasi scansione richiesta, di qualsiasi prenotazione e di qualsiasi chiamata a monte, quindi senza consumare nulla, quando contiene:

LimitePredefinitolimit
parti image_url1image-parts
byte di testo in UTF-849152text-bytes
messaggi4messages

Il testo include le stringhe content, ogni parte text, ogni messaggio name e il response_format serializzato: uno schema è input letto dal modello. I byte dell'immagine stessa non sono testo. L'operatore imposta i limiti con AI_MAX_IMAGE_PARTS, AI_MAX_TEXT_BYTES e AI_MAX_MESSAGES, e il rifiuto indica il limite e il suo valore:

json
{ "error": "ai-request-too-large", "limit": "text-bytes", "max": 49152 }

I valori predefiniti corrispondono alla richiesta reale più grande di openplate con margine di scorta: una fotografia, due messaggi e circa 12 KB di testo.

Il limite del corpo

Il corpo della richiesta trasporta una fotografia, quindi il limite è dimensionato per una sola immagine: AI_MAX_REQUEST_BYTES, 8.000.000 di byte per impostazione predefinita. La codifica Base64 aumenta le dimensioni di un'immagine di 4/3, quindi può contenere un JPEG di circa 5,7 MiB, ossia la fotocamera di uno smartphone moderno alla qualità predefinita, che è quanto il client invia dopo la riduzione di scala.

È volutamente slegato da MAX_BLOB_BYTES (§8). Quello limita un diario memorizzato da questo servizio; questo limita un'immagine che viene solo inoltrata, e ricavare l'uno dall'altro rifiuterebbe qualsiasi fotografia reale.

Un corpo oltre il limite riceve 413, nel caso di un chiamante con token valido, un corpo non autenticato riceve 401 prima ancora di essere letto. Il corpo dell'errore su questa rotta ha la forma prevista da OpenAI, non il {"error": "<sentence>"} del §4, poiché il chiamante è un client compatibile con OpenAI che legge error.message da un oggetto:

json
{
  "error": {
    "message": "Request body exceeds the maximum accepted size of 8000000 bytes. The operator can raise AI_MAX_REQUEST_BYTES.",
    "type": "invalid_request_error",
    "code": "request_too_large"
  }
}

Un corpo che non sia un JSON valido riceve 400 nella stessa busta con "code": "invalid_json". Nessuno dei due cita di nuovo l'input ricevuto, per il motivo indicato dalla regola vincolante 2. Un'implementazione PUÒ invece rispondere con la forma del §4, ma un client scritto per un fornitore OpenAI non mostrerebbe assolutamente nulla invece di un errore.

Lo streaming è trasparente. Quando la richiesta lo richiede, il corpo della risposta viene inoltrato non appena arriva, con Cache-Control: no-cache, no-transform e senza Content-Length. Un servizio che usasse un buffer consegnerebbe comunque ogni byte, perciò un client non può notare alcuna differenza se non per la latenza che cercava di evitare.

La quota

Ogni account ha due limiti giornalieri, ciascuno espresso in unità per giorno UTC ed entrambi con valore predefinito pari a 0: dailyAiLimit, per la finestra a pagamento, e freeDailyAiLimit, per la concessione gratuita permanente (§5.15). La scelta di Quale si applica avviene a ogni richiesta, in questo ordine:

L'account possiedeConcessioneLimite su cui prenotare
allowanceExpiresAt successivo all'istante della richiesta, dailyAiLimit > 0finestra a pagamentodailyAiLimit
altrimenti freeDailyAiLimit > 0concessione gratuitafreeDailyAiLimit
altrimenti il valore DEFAULT_FREE_DAILY_AI_LIMIT dell'istanza > 0concessione gratuitatale valore predefinito
altrimenti dailyAiLimit è 0nessuno403 ai-not-allowed
altrimenti allowanceExpiresAt è impostato (quindi è trascorso)nessuno403 allowance-expired
altrimenti trialScans è impostatoperiodo di prova per le scansionidailyAiLimit
altrimenti (un limite, nessuna data, nessuna prova, nessuna concessione gratuita)nessuno403 ai-not-allowed

Il valore predefinito dell'istanza (2026-10-05). Un operatore può impostare DEFAULT_FREE_DAILY_AI_LIMIT. È l'assegnazione gratuita di ogni account il cui valore freeDailyAiLimit è 0: la stessa assegnazione nella stessa posizione dell'ordine, quindi una finestra a pagamento attiva prevale comunque, un limite personale viene mantenuto qualunque sia il valore predefinito, e un account con una prova delle scansioni ricade nel valore predefinito invece di consumare una scansione. Non ha scadenza e non ha un blocco delle scansioni. Non viene scritto su alcuna riga del database, quindi un operatore che lo riduce o lo rimuove modifica tutti gli account contemporaneamente. Un giorno esaurito corrisponde a 429 sotto, con Retry-After, e mai a 403 ai-not-allowed di un account privo di assegnazione. Un servizio senza valore predefinito si comporta esattamente come prima. Il valore predefinito non può essere impostato insieme alla prova delle scansioni: tale istanza rifiuta di avviarsi.

L'ultima riga è cambiata il 2026-09-30. Quella configurazione indicava una concessione permanente senza scadenza, ogni account che la possedeva è stato spostato su freeDailyAiLimit tramite una migrazione, e nessun componente la scrive più. La concessione gratuita non è mai vincolata alle scansioni e non scade mai, quindi una prova di scansione con una concessione gratuita non viene conteggiata.

Una richiesta prenota max(1, ceil(estimated input tokens / AI_UNIT_INPUT_TOKENS)) unità rispetto al limite scelto dall'ordine sopra, dove la stima, calcolata prima della chiamata, corrisponde ai byte di testo sopra indicati divisi per 4 più AI_IMAGE_INPUT_TOKENS (valore predefinito 1500) per immagine. Con il valore predefinito di AI_UNIT_INPUT_TOKENS pari a 8192, la scansione del piatto di openplate pesa 1 unità e una richiesta vicina al limite di testo pesa 2, quindi per l'applicazione un'unità equivale a una richiesta. Lo stesso peso viene scalato dai tetti dell'istanza riportati sotto, e restituito per intero, laddove previsto. Ogni risposta inoltrata tramite proxy riporta la posizione dell'account rispetto al limite applicato:

IntestazioneSignificato
X-Quota-UsedUnità consumate oggi, inclusa questa
X-Quota-LimitIl limite scelto dall'ordine sopra: dailyAiLimit, freeDailyAiLimit o il valore predefinito dell'istanza
X-Trial-Scans-LeftScansioni gratuite rimaste dopo questa richiesta, su un account a cui si applica il blocco delle scansioni (sotto). Assente altrimenti
StatoerrorQuando
401authentication requiredNessun token di accesso, oppure token scaduto o revocato
403ai-not-allowedL'account non possiede alcuna concessione (l'ordine sopra). Rifiutata prima che qualsiasi dato lasci l'host
403allowance-expiredallowanceExpiresAt è impostato e non successivo all'istante di arrivo della richiesta, e non è presente alcuna concessione gratuita. Rifiutata prima che qualsiasi dato lasci l'host e prima della scrittura di una riga di utilizzo
403trial-scans-spentLe scansioni gratuite dell'account sono esaurite e l'account non ha una data di quota. Rifiutato prima che qualsiasi elemento lasci l'host e prima che venga scritta una riga di utilizzo. X-Trial-Scans-Left: 0. Il corpo contiene "endedBy": "scans"
403trial-expiredIl valore trialEndsAt dell'account è impostato e non successivo al momento in cui la richiesta è arrivata, le sue scansioni non sono esaurite e l'account non ha una data di quota. Rifiutato prima che venga scritta qualsiasi riga. Il corpo contiene "endedBy": "days"
403capability-requiredLa richiesta indica una funzionalità che l'account non possiede (sotto). Il corpo contiene capability, l'etichetta mancante. Rifiutata prima di conteggiare alcunché e prima che qualsiasi dato lasci l'host
403account-suspendedL'account è sospeso (§5.9 usa lo stesso codice)
403health-consent-requiredL'istanza richiede il consenso per i dati sanitari e l'account non possiede la versione corrente (§5.15.1). Rifiutato prima che qualsiasi cosa lasci l'host, e prima che venga scritta una riga di utilizzo
400request body must be a JSON objectIl corpo non è un oggetto. L'input non viene mai ripetuto nella risposta
400ai-request-too-largeIl corpo contiene più parti immagine, byte di testo o messaggi rispetto a quanto consentito dall'istanza (sopra). Il corpo indica limit e max. Rifiutata prima della scrittura di qualsiasi riga
400feature-header-invalidX-Openplate-Feature è presente e non è un'etichetta, su un account verificato (sotto). Rifiutata prima che venga scritta qualsiasi riga
400intake-id-invalidX-Intake-Id è presente e non è composto da 16 a 64 caratteri di A-Z a-z 0-9 _ -. Rifiutato prima che venga scritta qualsiasi riga
409intake-in-flightUna richiesta precedente con lo stesso X-Intake-Id è ancora in corso, su un account a cui si applica il filtro scansioni (sotto). Non viene consumato nulla e non viene scritta alcuna riga
429una frase che indica l'istante di ripristinoLa quota non può coprire le unità di questa richiesta. Retry-After indica i secondi fino alla prossima mezzanotte UTC
429una frase che indica il limite al minutoPiù di AI_RATE_LIMIT_PER_MINUTE richieste in qualsiasi intervallo scorrevole di 60 s
503ai-instance-ceilingL'intera istanza ha consumato il proprio tetto giornaliero, oppure gli account con prova delle scansioni hanno consumato il loro. Retry-After indica i secondi fino alla mezzanotte UTC successiva

403 ai-not-allowed è un codice macchina perché un client DEVE diramare il flusso su di esso; significa "questo account non avrà mai successo qui finché un operatore non cambia qualcosa", che è un messaggio diverso da mostrare rispetto a "torna domani". I due 429 sono frasi perché non c'è nulla su cui diramare: li legge una persona.

403 allowance-expired è un codice macchina separato, ed è separato perché le due frasi non dicono la stessa cosa: "il tuo operatore non ti ha mai abilitato l'IA" e "il tuo tempo è scaduto" richiedono parole diverse e passaggi successivi diversi. Un client che li fondesse insieme direbbe a qualcuno a cui è scaduta la prova di chiedere a un amministratore una quota che aveva già. Entrambi i rifiuti avvengono prima della prenotazione, quindi un account che non ha ricevuto risposta non ha alcuna riga di utilizzo conteggiata a suo carico. La data viene confrontata come "non successiva": l'istante limite rifiuta anziché consentire. La sincronizzazione non subisce conseguenze su un account scaduto (§5.15).

403 trial-scans-spent è un codice macchina terzo, per un terzo messaggio: "hai esaurito le tue scansioni gratuite". Un client mostra l'offerta del piano corrispondente, non "chiedi al tuo amministratore" (ai-not-allowed) e non "il tuo tempo è scaduto" (allowance-expired). Un client precedente all'introduzione del codice legge un valore 403 sconosciuto, motivo per cui è separato anziché essere accorpato a uno dei due.

403 trial-expired è un quarto, per l'altro limite della prova delle scansioni: "i tuoi giorni gratuiti sono terminati". Non è allowance-expired, che indica l'esaurimento di una finestra a pagamento o assegnata; un client che scambiasse l'uno per l'altro direbbe a una persona pagante che la sua prova è terminata. Entrambi i rifiuti della prova riportano endedBy, "scans" o "days", così che un client possa indicare da un solo campo quale limite ha fatto terminare la prova:

json
{ "error": "trial-expired", "endedBy": "days" }

L'ordine dei rifiuti, che un server conforme DEVE mantenere: identità e sospensione; il consenso per i dati sanitari (health-consent-required, §5.15.1); l'assegnazione (la tabella sopra: ai-not-allowed o allowance-expired); viene letto il corpo, poi la capability (capability-required, feature-header-invalid, sotto); cosa contiene il corpo (ai-request-too-large); la forma di X-Intake-Id; poi, solo per l'assegnazione della prova delle scansioni (scansioni gratuite, data della quota nessuna e nessuna assegnazione gratuita), il limite di giorni (trial-expired, verificato solo quando le scansioni non sono esaurite, così le scansioni consumate mantengono il proprio codice) e l'acquisizione della scansione (intake-in-flight, trial-scans-spent). Una data futura rimuove entrambi: è una finestra a pagamento o assegnata, e i limiti della prova decidono solo in assenza totale di una data. Anche un'assegnazione gratuita rimuove entrambi. Seguono il tetto massimo per gli account in prova delle scansioni, il tetto massimo dell'istanza e la quota giornaliera, come descritto sotto.

Funzionalità

Una capability è una breve etichetta, come scan o recipes, per un tipo di richiesta AI. Un account ne contiene un elenco, e il proxy rifiuta una richiesta la cui funzionalità non appartiene all'account. Il servizio non sa perché un account possiede un'etichetta: l'elenco viene scritto da un operatore (§5.20), così come dalla credenziale di fatturazione, che può indicare capabilities e nessuno stato oltre agli altri due campi.

Un'etichetta è una lettera minuscola seguita da un massimo di 31 lettere minuscole, cifre o trattini (^[a-z][a-z0-9-]{0,31}$). Un elenco ne contiene al massimo 32, viene memorizzato senza duplicati e ordinato, e non può contenere none, che è riservata.

Il record proprio dell'account ha tre stati, e sono tre casi distinti. null indica nessun record, quindi decide il valore predefinito dell'istanza. [] è un record che non concede nulla. Un elenco concede esattamente quelle etichette. Il valore effettivo è il proprio record, altrimenti il valore DEFAULT_CAPABILITIES dell'istanza (pubblicato come instance.defaultCapabilities, §5.6), altrimenti null, e un valore null effettivo equivale a nessun controllo: ogni richiesta passa, e non viene rifiutata nemmeno un'intestazione malformata. Questo è ciò che un'istanza priva di configurazione ha sempre avuto. Se DEFAULT_CAPABILITIES non è impostata o è vuota, vale null. Il valore none corrisponde all'elenco vuoto, poiché i file compose inoltrano una variabile non impostata come stringa vuota.

Come una richiesta indica la propria funzionalità.

  • L'intestazione della richiesta X-Openplate-Feature: <label>. Un'intestazione che non è un'etichetta restituisce 400 feature-header-invalid. L'intestazione è una semplice dichiarazione del client, quindi da sola non protegge nulla.
  • L'output strutturato del corpo, response_format.json_schema.name. L'operatore può associare il nome di uno schema a un'etichetta con CAPABILITY_SCHEMA_MAP (coppie schemaName:label). Un corpo che richiede uno schema in elenco necessita di quell'etichetta qualunque cosa indichi l'intestazione, quindi un client che dichiara il falso nell'intestazione non ottiene alcun vantaggio. Quando entrambe le etichette mancano, l'etichetta riportata è quella dello schema.

Una richiesta che non indica alcuna funzionalità né alcuno schema in elenco non chiede nulla che il controllo possa confrontare, e viene accettata. La mappa degli schemi è ciò che rende applicabile una funzionalità anche contro un client che non invia alcuna intestazione.

Il rifiuto è 403 {"error": "capability-required", "capability": "<label>"}. Viene decisa dopo la quota e il corpo, e prima del conteggio giornaliero, dell'acquisizione della scansione, dei tetti massimi dell'istanza e del provider: una richiesta rifiutata non scrive alcuna riga di utilizzo, non consuma scansioni e non invia nulla all'upstream. A un account privo di una funzionalità viene quindi restituito 403 e mai 429, e a un account senza alcuna funzionalità AI viene comunque restituito prima ai-not-allowed. Un client gestisce i rami logici in base al codice: il significato è "questo account non avrà successo qui finché le sue capability non cambiano", non "riprova domani". Un client browser può inviare l'intestazione cross-origin: è presente nell'elenco CORS dei valori consentiti.

La prova delle scansioni

Un account può includere scansioni AI gratuite (AccountView.trialScans, §5.15) e una data di fine (AccountView.trialEndsAt), concesse dal periodo di prova dell'istanza (instance.trial, §5.6): un certo numero di scansioni o un certo numero di giorni, a seconda di quale condizione si verifichi prima. Una scansione corrisponde a una singola azione di IA avviata dalla persona, e una singola azione può tradursi in più di una richiesta a monte: un client può riprovare una volta senza response_format dopo il rifiuto di un provider. (Un tentativo di nuovo invio dopo un bearer non valido viene rifiutato dal controllo del bearer, §4.1, prima di qualsiasi richiesta.) Una scansione dà diritto a una risposta recapitata.

X-Intake-Id è il modo in cui un client indica quali richieste appartengono a una stessa azione. È opzionale, da 16 a 64 caratteri di A-Z a-z 0-9 _ - (un UUID con o senza trattini è valido), un ID nuovo per ogni azione della persona, riutilizzato da ogni tentativo successivo di quella stessa azione, e viene inviato solo a questo proxy, mai a un provider configurato dalla persona stessa. Il servizio:

  • riserva una scansione per un ID non ancora registrato, prima della chiamata a monte, in una singola istruzione in cui WHERE funge da limite, così che dieci richieste parallele su tre scansioni ne riservino tre;
  • rifiuta una richiesta con un id la cui richiesta precedente è ancora in corso con 409 intake-in-flight, senza consumare nulla: richieste sovrapposte sullo stesso id riceverebbero due risposte per una sola scansione. Un id torna utilizzabile una volta conclusa la sua richiesta. Una richiesta fallita ha restituito la sua scansione, quindi il nuovo tentativo la richiede di nuovo senza costi netti; una richiesta inviata dopo una risposta recapitata è una nuova azione con una nuova scansione, rifiutata con 403 trial-scans-spent se esaurite;
  • tratta una richiesta ancora in corso dopo 30 minuti come una richiesta interrotta senza conclusione, e permette alla richiesta successiva con lo stesso id di rilevare la sua scansione senza consumarne una nuova, così nessun id resta bloccato più a lungo;
  • associa ogni restituzione e ogni consegna alla prenotazione a cui appartiene, così una richiesta che fallisce in ritardo non restituisce mai una scansione prenotata da una richiesta più recente con lo stesso id;
  • serializza le richieste parallele con un nuovo id, così una sola di esse richiede una scansione e le altre sono 409 intake-in-flight;
  • tratta una richiesta con nessuna ID come un'azione autonoma, così che un client che non ne invia mai uno venga conteggiato correttamente per ogni azione a richiesta singola.

Gli ID vengono conservati per 24 ore e poi eliminati (§9.2). Non vengono mai registrati nei log.

Una richiesta che ha ottenuto nessuna risposta restituisce la propria scansione: la restituzione viene eseguita su ogni riga della tabella seguente tranne per un codice 2xx consegnato, e su ogni rifiuto successivo alla prenotazione (i tetti e la quota giornaliera). Questo differisce di proposito dall'unità giornaliera, riga per riga:

EsitoUnità giornalieraScansionePerché la scansione differisce, dove differisce
Connessione rifiutata / timeout degli headerrilasciatarilasciata
Upstream 4xxrilasciatarilasciata
Upstream 5xxspesorilasciataL'unità protegge la fattura: la generazione potrebbe essere stata eseguita. La scansione protegge la promessa che un tentativo non riuscito non costa nulla, e la persona non ha ricevuto risposta. Un ciclo di tentativi su un provider instabile resta limitato dall'unità giornaliera
Timeout del corpo / flusso interrotto dal providerspesorilasciataGli header sono arrivati, quindi il provider potrebbe fatturare; la persona non ha comunque ricevuto risposta
2xx a monte, poi il chiamante riagganciaspesospesoLa risposta stava arrivando
Upstream 2xxspesospeso
Un tetto massimo o la quota giornaliera rifiutano dopo la richiestanon preso, o rilasciatorilasciataLa richiesta non ha raggiunto nessuno

Il tetto dell'istanza

Un operatore PUÒ impostare un tetto sull'intera istanza, nella stessa unità della quota descritta sopra: unità per giorno UTC, complessive per tutti gli account (AI_INSTANCE_DAILY_LIMIT). Non impostarlo significa che non esiste alcun limite, che è quanto mantiene un'istanza in Self-hosting e quanto mantiene ogni installazione esistente.

Esiste perché ogni altro limite qui descritto è per account. Dieci account a 200 richieste al giorno equivalgono a 2000 richieste al giorno a carico della chiave del fornitore dell'operatore, quindi gli inviti moltiplicano gli account senza moltiplicare il limite.

Raggiunto il tetto, ogni account viene rifiutato, compreso chi non ha consumato nulla della propria quota, fino al successivo giorno UTC. Il rifiuto è 503 ai-instance-ceiling con Retry-After in secondi. Si tratta di un 503 anziché di un 429 o di un 403 perché non dipende né dal chiamante né dalla quota del chiamante: il servizio ha esaurito la capacità pagata dal suo operatore. Un client DEVE diramare il flusso su di esso, perché "l'operatore ha esaurito la capacità per oggi" è una schermata diversa da "hai esaurito le richieste per oggi", e solo la seconda riguarda la persona che la legge.

Le unità dell'istanza vengono scalate prima quelle dell'account, così un'istanza rifiutata non addebita mai nulla a nessuno, e vengono restituite ogni volta che vengono restituite quelle dell'account (la tabella sotto si applica a entrambe, riga per riga).

Il tetto non pubblicato su /health: si tratta del budget dell'operatore, e tale handshake non è autenticato. GET /v1/admin/stats lo segnala come aiInstanceDailyLimit, accanto al aiRequestsToday che delimita.

Gli account con prova di scansione possono avere un proprio tetto massimo (AI_TRIAL_INSTANCE_DAILY_LIMIT): unità per giorno UTC per tutti gli account a cui si applica il filtro scansioni. Rifiuta questi account, e soltanto questi, con lo stesso 503 ai-instance-ceiling. Quando è impostato, una richiesta della prova scansioni viene conteggiata solo rispetto a esso e mai rispetto a AI_INSTANCE_DAILY_LIMIT, che limita quindi ogni altro account, così il traffico di prova non può mai consumare la capacità necessaria agli account a pagamento. La spesa giornaliera massima verso il fornitore è la somma dei due valori. Quando non è impostato, le richieste della prova scansioni ricadono sul tetto dell'istanza come quelle di chiunque altro. Inoltre non viene pubblicato; GET /v1/admin/stats lo segnala come aiTrialInstanceDailyLimit, accanto a signup.trialRequestsToday.

Se il tetto di prova è impostato, una rete chiamante ne riceve una quota (AI_TRIAL_NETWORK_DAILY_LIMIT, un decimo del tetto di prova per impostazione predefinita, arrotondato per difetto, almeno 1): unità per giorno UTC che le richieste di scansione di prova possono consumare da una singola rete. Una rete è una /64 IPv6, o un indirizzo IPv4, secondo il conteggio dei limitatori di accesso. Una richiesta di scansione di prova da una rete che ha esaurito la propria quota riceve lo stesso 503 ai-instance-ceiling con lo stesso Retry-After, così il client non ha bisogno di gestire un caso a parte; non consuma scansioni né unità, e il provider non viene chiamato. Le richieste all'interno di una finestra a pagamento o di una concessione gratuita permanente non vengono mai conteggiate né rifiutate da questo limite. Le sue unità vengono ripristinate ogni volta che lo sono quelle del tetto di prova. Più utenti dietro lo stesso NAT carrier IPv4 condividono un unico raggruppamento; un chiamante IPv6 dispone di una propria /64. Il servizio non conserva alcun indirizzo a questo scopo: una riga per rete al giorno memorizza un hash con chiave (HMAC-SHA256 con TRIAL_ADDRESS_PEPPER) della rete e del giorno, e la riga viene cancellata il giorno successivo.

Cosa viene consumato e cosa viene restituito

Un'unità viene prenotata prima della chiamata a monte, mai conteggiata dopo di essa. Il conteggio a posteriori lascia una finestra in cui N richieste parallele leggono tutte il vecchio conteggio e passano tutte, e un client che riprova in caso di errore è proprio il client che le invia insieme.

EsitoUnitàMotivo
Connessione rifiutata / errore DNSrilasciataLa richiesta non ha mai lasciato questo host
Timeout degli header (ancora nessun byte)rilasciataNon ci è arrivato nulla; il nostro limite di tempo è scaduto prima che il provider rispondesse
Upstream 4xxrilasciataIl provider l'ha RIFIUTATA. Non ha raggiunto alcun modello, quindi nessuno l'ha fatturata, e addebitare il consumo all'account per un errore di configurazione dell'operatore permetterebbe a un proxy guasto di consumare l'intera quota di un'organizzazione in un minuto
Upstream 5xxspesoIl provider l'ha accettata ed è fallito durante la risposta. La generazione potrebbe essere partita. Annullare l'addebito qui creerebbe un ciclo infinito di tentativi gratuiti proprio verso il provider che sta fallendo
Timeout del corpo / flusso interrottospesoGli header sono già arrivati, quindi il provider ha eseguito la richiesta. Il fatto che non siamo riusciti a leggere la risposta è un problema nostro, non una ragione di rimborso
Upstream 2xxspesoOvviamente

Il servizio registra un intero per account per giorno UTC e nient'altro: nessun prompt, nessuna risposta, nessun nome di modello, nessun timestamp più preciso del giorno (§9.2).

5.20 L'API di amministrazione: /v1/admin

Superficie dell'operatore, non superficie del client. Un client openplate usa esattamente uno di questi endpoint, e solo quando l'account che ha effettuato l'accesso è un amministratore: la console mostrata dall'app a /admin. Un client alternativo può ignorare del tutto questa sezione.

Possono accedervi due credenziali, ed entrambe arrivano come un normale Authorization: Bearer:

  1. Il token statico dell'operatore (ADMIN_TOKEN), che continua a funzionare anche quando ogni account è bloccato.
  2. Un account il cui role è admin, usando il proprio token di accesso. Questo è ciò che mostra la console nell'app anziché in una shell.
  3. Un token di servizio con ambito limitato (BILLING_TOKEN). È una TERZA entità, non una seconda copia della prima: raggiunge tre route e tre campi ed è rifiutata ovunque altrove. Vedi "L'entità di fatturazione" sotto.

Senza nessuno configurato o corrispondente, l'intero sottoalbero risponde con lo stesso 404 restituito per qualsiasi percorso sconosciuto, a chiunque. Un'istanza in cui non è configurato nessuno dei due token non è distinguibile da una creata prima dell'esistenza di questa funzionalità. Un 401 in quel punto rivelerebbe che una credenziale esiste ed è solo bloccata. Configurare uno dei due token trasforma quel 404 nel 401 restituito per un valore errato.

EndpointAzione
GET /v1/admin/statsConteggi aggregati: account, blob, byte, record di chiavi, pendingInvites, admins, aiRequestsToday e il aiInstanceDailyLimit che lo limita (null se non c'è tetto massimo); aiTrialInstanceDailyLimit; e signup: inviti generati dalla porta di richiesta del §5.8.3 oggi e negli ultimi sette giorni, prove concesse negli ultimi sette giorni e richieste di prova di scansione di oggi
GET /v1/admin/ai/budgetIl budget della chiave del fornitore e la capacità AI odierna, vedi "Il budget AI" più sotto. 404 su un'istanza senza AI. Non raggiungibile con BILLING_TOKEN
GET /v1/admin/accountsUna pagina di AccountView, più total
GET /v1/admin/accounts/expiringUna pagina di { id, allowanceExpiresAt } per gli account la cui quota scade nel futuro, più total
GET /v1/admin/accounts/:idUn AccountView
GET /v1/admin/accounts/:id/activityUltimo accesso, e una voce per giorno UTC lungo una finestra delimitata
GET /v1/admin/activityLa stessa striscia giorno per giorno per un'intera pagina di account, nell'ordine dell'elenco
PATCH /v1/admin/accounts/:idrole, dailyAiLimit, allowanceExpiresAt (un istante ISO, oppure null per cancellarlo), freeDailyAiLimit (l'assegnazione gratuita permanente, un intero da 0 a 10000; non modificabile con BILLING_TOKEN), capabilities (l'elenco di funzionalità dell'account, un array di etichette, [] per un record che non concede nulla, oppure null per rimuovere il record lasciando decidere il valore predefinito dell'istanza; modificabile con BILLING_TOKEN, §5.19), trialScans (le scansioni gratuite concesse, un intero da 0 a 100, oppure null per revocare la prova delle scansioni; non influisce mai su quante ne sono state usate), suspended, displayName, label (la nota dell'operatore, vedi sotto, oppure null per cancellarla). Almeno uno richiesto
POST /v1/admin/accounts/:id/reset-mailAvvia il ripristino del §5.12 su iniziativa dell'operatore
DELETE /v1/admin/accounts/:idCancella l'account e tutto ciò che vi è collegato
GET /v1/admin/accounts/:id/blob/versionsOgni versione conservata del blob: numero, versione dell'envelope, conteggio dei byte, orario e il pin se presente. Mai il testo cifrato
POST /v1/admin/accounts/:id/blob/rollback{"targetVersion": n}. Rende di nuovo attuale quella versione ELIMINANDO ogni versione successiva (la shrink guard del §5.1, ADR-0009). Rifiuta una versione sconosciuta, la versione attuale, una versione dell'envelope non accettata da questa build e una riga da zero byte. È un rollback anziché un nuovo caricamento, perché l'AAD del §3.2 vincola blobVersion: reinserire vecchi byte come nuova versione produce qualcosa che nessun client può decifrare
GET /v1/admin/invitesUna pagina di inviti in sospeso, più total
POST /v1/admin/invitesNe genera uno (§5.8). Il token viene restituito una sola volta. "trial": true registra la prova scansioni dell'istanza invece di una quota: 400 su un'istanza che non ne prevede, e 400 accanto a un dailyAiLimit. Senza il campo, al momento del riscatto il valore dailyAiLimit della generazione diventa la concessione gratuita permanente dell'account (freeDailyAiLimit)
POST /v1/admin/trials/grant-lapsed{"trialDays": n, "apply": false, "excludeAccountIds": []}. Elenca, oppure con apply: true concede la prova di scansione dell'istanza a ogni membro la cui prova giornaliera di trialDays è terminata e non è mai stata spostata: la data della sua quota equivale ancora al suo riscatto più trialDays al millisecondo, cosa che solo un pagamento o un operatore modifica. Cancella la data e imposta il limite giornaliero della prova. Idempotente: un account a cui è stata concessa non viene mai più elencato. Risponde {"accountIds": [...], "applied": bool}
POST /v1/admin/invites/:id/resendUn NUOVO token sulla STESSA riga, e una nuova scadenza
DELETE /v1/admin/invites/:idRevoca un invito in sospeso
PATCH /v1/admin/settings`{"nutrientReferenceBasis": "dge" \"efsa" \"us"}. The instance-wide reference basis (§5.6). Required; anything else is 400 and NOTHING is written. Answers {"settings": {...}}` con i valori attualmente presenti nell'istanza
GET /v1/admin/feedbackUna pagina di stime segnalate (§5.25), dalla più recente: { id, accountId, hasImage, consentWordingVersion, createdAt } ciascuna, più total, limit e offset. Nessuna cifra e nessuna fotografia
GET /v1/admin/feedback/:idUna segnalazione: i campi dell'elenco, measurements esattamente come inviati dal dispositivo, e consent: { agreedAt, wordingVersion }
GET /v1/admin/feedback/:id/imageI byte della fotografia sotto il suo Content-Type memorizzato, con Cache-Control: no-store e X-Content-Type-Options: nosniff. 404 quando la segnalazione non ne ha alcuna. Ogni lettura viene registrata con l'id della segnalazione e con la credenziale richiedente
DELETE /v1/admin/feedback/:idElimina la fotografia, poi la segnalazione. 204, oppure 404 per un id sconosciuto

GET /v1/admin/ai/budget è il budget AI dell'operatore: quanto resta alla chiave del fornitore, e quanta capacità dell'istanza per oggi è stata usata.

json
{
  "day": "2026-09-30",
  "capacity": {
    "paid": { "used": 412, "limit": 2000 },
    "trial": { "used": 37, "limit": 500 }
  },
  "upstream": {
    "status": "ok",
    "limitUsd": 5,
    "remainingUsd": 3.94,
    "reset": "monthly",
    "usageDailyUsd": 0.12,
    "usageWeeklyUsd": 0.4,
    "usageMonthlyUsd": 1.06,
    "checkedAt": "2026-09-30T10:00:00.000Z"
  }
}
  • day è il giorno UTC su cui vengono calcolati i tetti. capacity è espresso in unità, i conteggi ponderati per dimensione riservati dal proxy. paid.used indica quanto è stato conteggiato rispetto a AI_INSTANCE_DAILY_LIMIT e trial.used quanto hanno consumato gli account della prova scansioni. Ciascun limit è il tetto configurato, oppure null se assente. Senza un tetto per la prova, le richieste di prova vengono conteggiate anche in paid.used.
  • upstream è null quando l'upstream non è OpenRouter. Altrimenti corrisponde alla lettura della chiave di GET /key di OpenRouter, in dollari: limitUsd e remainingUsd sono null per una chiave senza limite, e reset è "daily", "weekly", "monthly" o null per un limite che non si azzera mai. Una lettura fallita è {"status": "unavailable", "checkedAt": ...}, e capacity viene comunque riportato.
  • La lettura della chiave viene eseguita sul server con un timeout di 5 secondi, ed è servita dalla memoria per 60 secondi, o per 15 se fallita. Il corpo non contiene alcuna chiave, alcuna etichetta della chiave né altri dati inviati dal fornitore.
  • Nella stessa lettura, quando remainingUsd è inferiore a AI_BUDGET_ALERT_FRACTION (valore predefinito 0.2) di limitUsd, l'operatore riceve un'email per periodo di ripristino a MAIL_OPERATOR_EMAIL. Il servizio legge la chiave anche ogni 15 minuti, così l'invio dell'email non deve attendere che qualcuno apra la console.

PATCH è l'unica scrittura correlata all'autenticazione a disposizione di un operatore, ed è deliberatamente limitato. Non può impostare una passphrase e non esiste alcun endpoint in grado di farlo: la passphrase incapsula la chiave dei dati sul client, quindi una modifica delle credenziali lato server produrrebbe un account che accede ma non decifra nulla. Non può modificare il email di un account, perché l'indirizzo è ciò che l'invito ha verificato. Non può stampare un codice di recupero.

La sospensione revoca ogni sessione nello stesso effetto. Un solo suspended_at lascerebbe il telefono nella tasca di qualcuno a sincronizzare per un altro quarto d'ora, il che non corrisponde a ciò che un operatore intende con questo termine. La riattivazione non ripristina alcuna sessione; la persona accede di nuovo.

Un ACCOUNT amministratore non può sospendere, retrocedere o eliminare se stesso: 400, con {"error": "self-change"}. Un'organizzazione con un solo amministratore che esegue questa operazione blocca l'accesso all'intero albero a chiunque, e l'unico rimedio rimane una shell sul container. Il token statico è esente, perché non ha identità propria ed è la credenziale creata esattamente per questa situazione.

label è la nota personale dell'operatore su un account, come ad esempio "Beta supporter", oppure null per nessuno. Ogni account in GET /v1/admin/accounts e GET /v1/admin/accounts/:id include la chiave.

  • PATCH con {"label": "Beta supporter"} la imposta e {"label": null} la cancella. Il valore viene ripulito dagli spazi iniziali e finali, e una stringa che risulta vuota dopo questa pulizia la cancella, evitando così che venga memorizzata un'etichetta vuota.
  • Al massimo 40 caratteri, conteggiati come punti di codice Unicode, l'unità calcolata da char_length di Postgres. Un'etichetta più lunga, contenente un'interruzione di riga, una tabulazione o qualsiasi altro carattere di controllo, oppure qualsiasi valore che non sia una stringa o null, restituisce 400 e nulla viene scritto nel corpo. Un vincolo di controllo sulla colonna applica lo stesso limite, quindi anche uno strumento che vi scrive direttamente lo rispetta.
  • Un dato dell'operatore, mai un input di autorizzazione. Nessuna route la legge per prendere decisioni. L'GET /v1/auth/account dell'account non la contiene, l'account non può impostarla (PATCH /v1/auth/account legge solo displayName), e il principal di fatturazione non può né leggerla né scriverla.
  • pnpm core-api accounts set-label <id> "Beta supporter" la imposta e pnpm core-api accounts clear-label <id> la cancella.

GET /v1/admin/accounts/:id/activity risponde alla domanda con cui un operatore apre la console: questa persona sta ancora usando l'istanza. Legge ciò che il servizio memorizza già e non raccoglie nulla di nuovo.

json
{
  "accountId": 7,
  "lastSeenAt": "2026-09-06T18:30:00.000Z",
  "window": { "days": 90, "fromDay": "2026-06-10", "toDay": "2026-09-07" },
  "days": [
    { "day": "2026-06-10", "count": 0 },
    { "day": "2026-06-11", "count": 3 }
  ]
}
  • lastSeenAt è null per un account che non ha mai effettuato l'accesso, e viene scritto solo da un accesso e da un completamento tramite proxy, mai da un aggiornamento del token e mai da un controllo di sincronizzazione (§9.2). Viaggia sulla rete come timestamp; una frase relativa è una decisione di rendering che spetta al client.
  • days contiene ogni giorno nell'intervallo, in ordine, con count: 0 per i giorni privi di riga. Un giorno mancante e un giorno senza attività non devono sembrare uguali a chi legge la striscia.
  • ?days=N restringe l'intervallo. N deve essere un intero pari ad almeno 1, altrimenti la risposta è 400. A un intervallo superiore a 90 giorni si risponde con 90, e window riporta ciò che è stato effettivamente tracciato. Novanta corrisponde all'intervallo di conservazione sottostante, quindi una striscia più lunga conterrebbe solo zeri per le righe eliminate.
  • Un ID sconosciuto restituisce lo stesso 404 di qualsiasi altra rotta degli account, e l'intero albero è protetto dalle credenziali indicate sopra.

GET /v1/admin/activity risponde alla stessa domanda per un'intera pagina in una sola volta, perché un elenco di persone disegna una striscia accanto a ogni riga e richiedere i dati singolarmente per riga produce un problema di tipo N+1.

json
{
  "window": { "days": 7, "fromDay": "2026-09-02", "toDay": "2026-09-08" },
  "accounts": [{ "accountId": 2, "days": [{ "day": "2026-09-02", "count": 0 }] }],
  "total": 4
}
  • ?limit= e ?offset= si comportano esattamente come su GET /v1/admin/accounts: stessi valori predefiniti, stesso limite massimo, stesso 400 con la stessa frase. Questo è il contratto stabilito, non una coincidenza: un chiamante pagina i due endpoint di pari passo e disegna la striscia n accanto alla persona n, quindi accounts qui segue l'ordine restituito dall'elenco per la stessa pagina, e total è il total di quell'elenco.
  • ?days=N è l'intervallo dell'endpoint precedente, limitato allo stesso modo: un intero pari ad almeno 1 oppure un 400, a valori superiori a 90 si risponde con 90, e window riporta ciò che è stato tracciato.
  • Ogni account presente nella pagina compare, compreso uno che non ha mai effettuato una richiesta, il cui days è una sequenza di zeri. Un account omesso renderebbe identici i fatti "questa persona non ha fatto nulla" e "questa persona non era nella risposta", che è l'errore che il riempimento di zeri per giorno serve a evitare, al livello superiore.
  • Ciascuna voce è formata da accountId e days e nient'altro. L'indirizzo, il nome e la quota appartengono a GET /v1/admin/accounts, che il chiamante sta già leggendo.

Conservazione: i contatori di utilizzo sono mantenuti per 90 giorni. ai_usage_days conserva un intero per account per giorno UTC (§9.2). Una pulizia oraria interna al servizio cancella ogni riga più vecchia di 90 giorni, contando oggi, su ogni istanza e senza alcun intervento dell'operatore o voce di cron. L'eliminazione di un account ne rimuove i contatori e il relativo lastSeenAt nella stessa istruzione del resto della cancellazione, tramite ON DELETE CASCADE. Novanta è un unico numero in un unico punto: è la soglia di potatura della pulizia nonché la finestra temporale massima a cui l'endpoint indicato sopra può rispondere.

L'entità di fatturazione (BILLING_TOKEN). Un servizio di pagamento deve aggiornare due numeri e un elenco su un singolo account: il termine di una quota, il numero di richieste IA giornaliere acquistate e le etichette delle funzioni IA abilitate. Assegnargli il token operatore gli darebbe accesso a ogni indirizzo sull'istanza, al pulsante di eliminazione e alle fotografie segnalate, quindi la credenziale ha un ambito limitato all'origine. È facoltativa, non impostata per impostazione predefinita, e richiede lo stesso minimo di 24 caratteri del token operatore.

EndpointL'entità di fatturazione può
GET /v1/admin/accounts/expiringLeggere { id, allowanceExpiresAt } per gli account la cui data di fine è nel futuro, con paginazione basata sulla stessa combinazione di limit, offset e 400 di ogni altro endpoint paginato in questa sezione
GET /v1/admin/accounts/:idLeggi { id, allowanceExpiresAt, dailyAiLimit, capabilities } per quell'unico account, dove capabilities è il record dell'account stesso (null indica nessun record)
PATCH /v1/admin/accounts/:idScrivi allowanceExpiresAt, dailyAiLimit e capabilities, e null'altro. trialScans viene rifiutato come ogni altro campo: una credenziale che paga per una quota non distribuisce scansioni gratuite
  • Ogni altra route di questa sezione risponde a 403 con {"error": "service-scope"}, incluse le quattro route di segnalazione e qualsiasi route aggiunta successivamente alla stesura di questo documento. Il rifiuto avviene all'aggancio, prima dell'esecuzione di qualsiasi gestore e della lettura di qualsiasi riga, dunque non costituisce un oracolo per verificare l'esistenza di un account.
  • Un corpo di richiesta PATCH che indica qualsiasi altro campo riceve 403 con {"error": "service-scope-field"}, e nulla viene scritto, compresi i campi consentiti adiacenti. Uno scarto silenzioso farebbe sembrare riuscita un'operazione viziata da un difetto nel servizio di fatturazione.
  • Anche i valori sono vincolati al contesto. allowanceExpiresAt: null (una quota senza termine) e un valore di dailyAiLimit superiore al BILLING_MAX_DAILY_AI_LIMIT dell'istanza (predefinito 1000) generano un 403 con {"error": "service-scope-value"}, e nulla viene scritto. Le credenziali dell'operatore possono scriverli entrambi. capabilities accetta qualsiasi elenco valido e null, che rimuove il record e quindi non concede mai più del valore predefinito dell'istanza scelto dall'operatore. Un elenco non valido genera il consueto 400, per questa credenziale così come per un operatore.
  • Oltre a questo, dailyAiLimit viene convalidato esattamente come per un operatore. La credenziale non allenta alcuna convalida.
  • Le due letture sono proiezioni e mai un AccountView. Nessun indirizzo, nessun nome visualizzato, nessun ruolo, nessuna sospensione, nessun utilizzo, nessun blob. `GET

/v1/admin/accounts/expiring` seleziona due colonne nella query anziché filtrare una riga successivamente.

  • Un account eliminato e un identificativo sconosciuto sono lo stesso 404. La cancellazione qui avviene a cascata e non tramite tombstone (§9), perciò non resta nulla con cui distinguerli, e un deletedAt che questa route potrebbe restituire sarebbe la registrazione di una persona conservata dopo la cancellazione che l'ha rimossa. Entrambi i casi significano "interrompi l'addebito".
  • L'entità non possiede un'identità propria, quindi la regola di automodifica descritta sopra non può essere applicata: non può sospendere, retrocedere o eliminare nessuno, compresa se stessa, poiché nessuna di queste route è accessibile.

AccountView ha la stessa struttura restituita dal GET /v1/auth/account dell'account stesso (§5.15), con invitesLeft incluso e calcolato nello stesso modo, più aiUsedToday, e sull'interfaccia di amministrazione in aggiunta lastSeenAt, label, blob e keyRecordKinds. capabilities e freeDailyAiLimit sull'interfaccia di amministrazione sono il record PROPRIO dell'account (per capabilities, null indica nessun record), dove la vista dell'account riporta il valore effettivo. Vi compare anche healthConsent, ed è sola lettura qui: PATCH /v1/admin/accounts/:id non lo legge, perché un consenso che un operatore potesse impostare per conto di qualcuno non proverebbe nulla (§5.15.1). Contiene nessun verificatore, nessun descrittore KDF, nessun deposito a garanzia e nessun testo cifrato. Un blob viene riportato come conteggio di byte e timestamp. La motivazione è docs/adr/0001-an-admin-api-for-a-zero-knowledge-service.md, i cui divieti 1, 2, 3, 5 e 8 sono sostituiti da ADR-0005, ma non il divieto di includere segreti in una risposta.

5.21 POST /v1/auth/invites: un membro invita qualcuno

Bearer, limitato per indirizzo sorgente con ogni tentativo conteggiato. Presente solo quando il deployment imposta sia MEMBER_INVITE_DAILY_AI_LIMIT sia MEMBER_INVITE_ALLOWANCE_DAYS, oppure invece MEMBER_INVITE_TRIAL accanto alla prova di scansione dell'istanza; senza nessuno dei due, questo percorso risponde con l'ordinario 404 per percorso sconosciuto a ogni chiamante, autenticato o meno, e instance.memberInvites è false (§5.6).

Richiesta: {"email": "boris@example.org"}, e nient'altro.

json
{}

→ 202 con quel corpo, vuoto e fisso.

I termini sono quelli dell'istanza, mai del chiamante. L'account invitato riceve role: "member", la stessa durata dell'invito predefinita della generazione admin, e UNA tra due concessioni, mai entrambe: sotto la coppia di giorni, dailyAiLimit da MEMBER_INVITE_DAILY_AI_LIMIT e una allowanceExpiresAt del riscatto più MEMBER_INVITE_ALLOWANCE_DAYS scritta alla registrazione; sotto MEMBER_INVITE_TRIAL, la prova di scansione dell'istanza (§5.19) senza data. Un invito per un membro generato sotto la coppia di giorni e riscattato dopo il cambio dell'istanza riceve la prova di scansione, non una data. Un dailyAiLimit, un role o un expiresInDays nel corpo non viene rifiutato, semplicemente non viene letto. Questi tre SONO campi del corpo su POST /v1/admin/invites (§5.20), che è la differenza tra un membro e un operatore.

La risposta NON DEVE variare in base allo stato reale dell'indirizzo. Un nuovo indirizzo, un indirizzo che ha già un invito in sospeso e un indirizzo a cui è già associato un account restituiscono lo stesso 202 con il medesimo corpo. È la proprietà anti-enumerazione di §5.7 e §5.12 applicata all'unico endpoint con cui un membro punta alla casella postale di qualcun altro: chi inserisce l'indirizzo di un collega non deve scoprire da un codice di stato, da un corpo o da un'intestazione che quel collega è già registrato. Il codice 409 {"error":"an account already exists for this email"} dell'emissione da parte dell'amministratore fa eccezione, e solo perché è protetto dalle credenziali dell'operatore stesso.

Quando l'indirizzo è già associato a un account, il servizio invia via email a quella persona un breve messaggio invece di un invito. Il messaggio non contiene alcun link: un link di registrazione creerebbe un secondo account per chi ne possiede già uno, mentre un link di ripristino avvierebbe una reimpostazione della password che nessuno ha richiesto. Senza questa comunicazione l'invito svanirebbe nel nulla ed entrambe le persone rimarrebbero in attesa.

Un indirizzo che ha già riscattato un invito generato da un membro non ne riceve un secondo, e al chiamante viene comunque comunicato 202. La prova sopravvive all'account: la riga dell'invito conserva il suo indirizzo e il suo istante di riscatto quando uno dei due account viene eliminato, quindi un'auto-eliminazione seguita dal nuovo invito di un amico non costituisce una nuova quota. Su un'istanza con prova di scansione, l'eliminazione rimuove invece l'indirizzo dalla riga e conserva l'hash con chiave del §5.15, e la regola legge quell'hash. La generazione da parte di un operatore non è un invito causato da un membro e non viene mai trattenuta da questa regola.

Un invito in sospeso da un altro punto di accesso non viene toccato, e il chiamante riceve comunque 202. La generazione di un invito sostituisce l'invito in sospeso per quell'indirizzo, quindi senza questa regola un membro potrebbe revocare la notifica appena inviata da un operatore, dal punto di accesso per le richieste del §5.8.3 o da un altro membro, e imporre al suo posto le condizioni del proprio punto di accesso. Nessuna riga viene scritta e nessuna notifica viene inviata. Un membro PUÒ inviare di nuovo il proprio invito in sospeso, che sostituisce il precedente come prima. Il rifiuto è silenzioso anziché esplicito, perché un rifiuto esplicito rivelerebbe al chiamante che qualcun altro ha già invitato questa persona. Un amministratore che usa questa route è esente, come nella generazione via admin.

Quando un account viene eliminato, gli inviti inviati ancora in sospeso vengono revocati nella stessa transazione (§5.15). Quelli riscattati e scaduti vengono lasciati invariati, e continuano a non gravare su nessuno: chi ha invitato non esiste più.

Il limite complessivo è di cinque per account, per sempre, calcolato in base alle righe. Gli inviti ritirati e quelli scaduti contano: il limite si applica a quanti messaggi un account ha generato, non a quanti sono andati a buon fine. Superare questo limite restituisce 403 {"error":"member-invite-cap-reached"}, ed è l'unica informazione che questo endpoint rivela sull'account del chiamante, trattandosi di un dato che riguarda lui e nessun altro. Gli amministratori sono esentati, sia su questa rotta sia su quella amministrativa, che è il significato di invitesLeft: null (§5.15).

Una prova di scansione per cui nessuno ha pagato non invita nessuno. Ogni invito per un membro con MEMBER_INVITE_TRIAL è una nuova prova di scansione, quindi un account gratuito che potesse inviare inviti creerebbe altri account gratuiti. Un account che include trialScans e non ha alcun allowanceExpiresAt nel futuro risponde 403 {"error":"invites-need-a-plan"}, non scrive alcuna riga e non invia alcuna lettera. Una data futura sblocca la route, indipendentemente da chi l'abbia impostata: il sistema di fatturazione al momento del pagamento oppure un operatore. Il limite a vita viene verificato prima, quindi un account che ha esaurito la propria quota riceve member-invite-cap-reached, perché pagare non servirebbe. Anche in questo caso un amministratore è esente, e la creazione da parte dell'amministratore (§5.20) rimane invariata. invitesNeedAPlan nella vista dell'account (§5.15) indica la stessa cosa prima che la persona provi.

202 contiene inoltre nessun token e nessun link, a differenza dell'emissione dell'amministratore. Il chiamante non è l'operatore e non deve disporre del permesso di creare un account.

5.22 /v1/plans/*: il pass-through verso un gestore della fatturazione

Presente solo se l'operatore ha configurato un gestore della fatturazione. Senza di esso l'intero sottoalbero risponde con il normale percorso sconosciuto 404, a chiunque, con o senza credenziali, e instance.plans è false durante l'handshake (§5.6). Un'implementazione di questo protocollo PUÒ omettere del tutto il sottoalbero; un client DEVE verificare instance.plans prima di mostrare una schermata per i piani, anziché sondare direttamente il percorso.

Nulla di ciò che si trova dietro questo prefisso fa parte di questo protocollo. Le rotte, i corpi delle richieste e i corpi delle risposte appartengono al gestore della fatturazione, che è un servizio separato con un proprio ciclo di rilascio. Questo documento specifica unicamente cosa fa il gateway con una richiesta mentre la inoltra e con una risposta mentre torna indietro. È una scelta voluta: l'alternativa sarebbe un documento normativo inutilizzabile per chi si autogestisce l'infrastruttura, costretto a inseguire il calendario fiscale di terzi.

Autenticato con l'ordinario token di accesso dell'account (§4.1). Un chiamante anonimo riceve l'ordinario 401. L'unica eccezione è GET /v1/plans/prices, qui sotto: un percorso e un metodo, e nient'altro nel sottoalbero.

POST /v1/plans/order
Authorization: Bearer <accessToken>
Content-Type: application/json

{ "plan": "…", "locale": "…", "consentVersion": "…", "consents": { … } }

L'esempio ha scopo illustrativo: i percorsi del fatturatore appartengono a quest'ultimo. Il fatturatore di openplate gestisce GET /v1/plans/prices, GET /v1/plans/offer, POST /v1/plans/order, GET /v1/plans/me, il percorso del portale e POST /v1/plans/pending-change/cancel; il suo vecchio POST /v1/plans/checkout ora risponde con 410.

Cinque proprietà che un'implementazione conforme DEVE rispettare:

  1. Vengono inoltrati solo GET e POST. Qualsiasi altro metodo all'interno del sottoalbero restituisce 405 {"error":"plans-method-not-allowed"} con un'intestazione Allow, senza mai raggiungere il servizio a monte. Il gateway non conosce le rotte del gestore della fatturazione, quindi un proxy trasparente verso un servizio che gestisce lo stato degli abbonamenti finirebbe per trasformarsi in un tunnel a uso generico.
  2. Le intestazioni inoltrate vengono COSTRUITE, mai copiate e sovrascritte. Sono esattamente X-Account-Id ricavato dalla sessione convalidata, X-Account-Email letto dalla riga dell'account, X-Plans-Secret contenente il segreto condiviso e Content-Type in entrata. Copiare e poi sovrascrivere inoltra i cookie e qualsiasi altra cosa il client successivo decida di inviare.
  3. Le credenziali del chiamante non vengono mai inoltrate. È la regola su cui si regge l'intera architettura: inoltrare il token di accesso renderebbe il gestore della fatturazione un secondo punto in cui poter usare un token sottratto.
  4. L'identificatore dell'account appartiene alla sessione, e l'indirizzo appartiene alla riga. Un client che invia un proprio X-Account-Id o X-Account-Email non può influenzare ciò che legge il servizio a monte. Un accountId scelto dal browser rappresenta un difetto di autorizzazione, e un gestore della fatturazione che leggesse l'indirizzo di quell'account per precompilare il pagamento fungerebbe da oracolo per rivelare gli indirizzi.
  5. La risposta transita con il suo stato e il suo corpo JSON, e insieme a essa ritorna soltanto Content-Type. Un 402 o un 409 proveniente dal fatturatore è una risposta effettiva sul piano del chiamante e viene inoltrata come tale. Due dei percorsi del fatturatore di openplate ne mostrano il motivo. Un passaggio a un livello superiore viene fatturato e pagato immediatamente, quindi POST /v1/plans/order risponde a una carta rifiutata con 402 {"error":"payment-failed"} e il chiamante rimane al vecchio livello. Un passaggio a un livello inferiore viene programmato per la fine del periodo pagato, e POST /v1/plans/pending-change/cancel (senza corpo) lo annulla: 200 {"kept":{"plan":"…","tier":"…"}}, 409 {"error":"no-pending-change"} quando non c'è nulla di programmato (anche la risposta a una seconda chiamata), oppure 502 {"error":"pending-change-cancel-failed"} quando il fornitore di pagamento ha riscontrato un errore e la modifica rimane programmata. Il gateway inoltra ciascuna risposta senza modifiche e non aggiunge alcun percorso, codice o verifica proprietaria. Quel 502 è la risposta propria del fatturatore e non è uno dei codici plans-upstream-* del gateway indicati sotto. La risposta GET /v1/plans/me e la risposta per l'ordine 200 del passaggio inferiore programmato possono contenere pendingTier e pendingChangeAt, assenti se non c'è nulla di programmato; un client che non li riconosce li ignora.

Un servizio a monte che non risponde, va in timeout, restituisce dati non JSON o invia un corpo che supera la soglia di inoltro riceve 502 nell'involucro di §4 con un codice macchina: plans-upstream-unreachable, plans-upstream-timeout o plans-upstream-invalid. Un corpo della richiesta che supera la piccola soglia specifica del sottoalbero restituisce 413 {"error":"plans-request-too-large"}, che è un'affermazione diversa: il gestore della fatturazione funziona regolarmente, ma ciò che hai inviato non sarà mai accettato. Nessun corpo viene registrato nei log, in nessuna delle due direzioni; un rifiuto viene registrato nei log indicando solo lo stato e il percorso, senza null'altro.

La chiamata in uscita include un timeout esplicito. È breve, perché ogni rotta qui corrisponde a un pulsante appena premuto da qualcuno, e serve tanto a contenere il limite nascosto di 300 secondi di undici quanto a limitare l'attesa per un gestore della fatturazione lento.

Notifica di cancellazione (dal servizio al sistema di fatturazione). Prima che uno dei due percorsi di cancellazione (§5.15, §5.20) elimini un account, il servizio invia POST <PLANS_UPSTREAM_URL>/erase con esattamente X-Plans-Secret e X-Account-Id, e un corpo vuoto. Il sistema di fatturazione risponde 204 una volta annullata ogni sottoscrizione attiva dell'account, e 204 se non ce ne sono. La chiamata ha un timeout di cinque secondi. Un rifiuto, un timeout o un host irraggiungibile vengono registrati a livello error con l'id dell'account, e l'account viene eliminato comunque; la riconciliazione notturna del sistema di fatturazione rimane la protezione finale.

L'operatore configura PLANS_UPSTREAM_URL e PLANS_UPSTREAM_SECRET, entrambi o nessuno dei due. Un URL privo di segreto causa un rifiuto di avvio invece di un declassamento silenzioso: il segreto è l'unico elemento che attesta al sistema di fatturazione che l'ID account letto proviene da un gateway che ha autenticato qualcuno.

GET /v1/plans/prices: il listino prezzi, prima dell'accesso

Una schermata di registrazione indica il prezzo prima che chiunque possieda un token, quindi questo UNICO percorso, con questo UNICO metodo, è anonimo. Non è necessario alcun token. Un token inviato comunque non viene letto e non viene mai inoltrato, quindi un token scaduto o estraneo non può trasformare la lettura in un 401. Ogni altro percorso nel sottoalbero, e un POST o un HEAD su questo, risponde comunque a un chiamante anonimo con 401.

GET /v1/plans/prices

→ 200 con Cache-Control: public, max-age=300:

json
{
  "currency": "EUR",
  "plans": [
    { "key": "monthly", "interval": "month", "grossCents": 500 },
    { "key": "yearly", "interval": "year", "grossCents": 4000 }
  ]
}

Il corpo è quello del fatturatore e viene inoltrato senza essere letto, come ogni risposta in questo sottoalbero. L'esempio mostra ciò che serve il fatturatore di openplate: i piani che vende, ciascuno con l'importo addebitato per interval nell'unità minima di currency, tasse incluse, letto dal suo fornitore di pagamenti all'avvio. Le cifre sopra riportate sono un esempio, mai un listino prezzi.

Il gateway inoltra il corpo senza toccarlo, quindi il fatturatore può aggiungervi elementi. Il gateway analizza il corpo solo per verificare che sia JSON della dimensione consentita, e non legge né riscrive alcun campo. Un fatturatore che vende livelli può quindi aggiungere un array tiers accanto a currency e plans. Una voce di tiers ha la stessa struttura di una voce dell'array tiers dell'offerta del fatturatore (GET /v1/plans/offer): id, name, description, isSold, dailyAiLimit, capabilities e il proprio plans. L'offerta rappresenta il contratto del fatturatore e non fa parte di questo protocollo (vedi sopra), quindi la voce viene descritta qui solo affinché chi legge sappia cosa aspettarsi:

json
{
  "currency": "EUR",
  "plans": [
    { "key": "monthly", "interval": "month", "grossCents": 500 },
    { "key": "yearly", "interval": "year", "grossCents": 4000 }
  ],
  "tiers": [
    {
      "id": "tier-a",
      "name": "…",
      "description": "…",
      "isSold": true,
      "dailyAiLimit": 10,
      "capabilities": ["scan"],
      "plans": [
        { "key": "monthly", "interval": "month", "grossCents": 500 },
        { "key": "yearly", "interval": "year", "grossCents": 4000 }
      ]
    }
  ]
}

Un client DEVE ignorare ogni campo che non riconosce, al livello principale e all'interno di una voce, e NON DEVE rifiutare il corpo a causa di uno di essi. Un corpo privo di tiers rimane valido esattamente come prima, e un client che non legge mai tiers legge currency e plans nello stesso identico modo. Gli ID sono etichette scelte dal fatturatore (tier-a è un segnaposto), l'ordine di tiers è quello del fatturatore stesso, e gli importi ripetono le cifre dell'esempio precedente solo per rendere visibile la struttura.

Quattro proprietà distinguono questa route dal resto del sottoalbero:

  1. Viene inviato solo con X-Plans-Secret. Non c'è alcun account, quindi non ci sono né X-Account-Id né X-Account-Email, e nulla della richiesta in ingresso viene trasmesso: né un'intestazione, né la query string.
  2. Un 200 viene conservato per cinque minuti e servito dalla memoria, quindi un picco di lettori equivale a una sola chiamata al fatturatore. Un rifiuto da parte del fatturatore e una chiamata non riuscita vengono inoltrati come descritto sopra e non vengono conservati, quindi il lettore successivo richiede nuovamente.
  3. Un singolo indirizzo di origine può leggerlo 60 volte in qualsiasi finestra di un minuto. Un chiamante IPv6 conta come la sua /64, e un indirizzo IPv6 mappato su IPv4 conta come l'indirizzo IPv4 che include. La lettura successiva è 429 {"error":"plans-prices-rate-limited"} con Retry-After in secondi.
  4. Senza un sistema di fatturazione corrisponde al consueto percorso sconosciuto 404, come il resto del sottoalbero, e /health non pubblica nulla di nuovo al riguardo: un client che legge instance.plans sa già se chiedere.

5.23 /v1/pulse/*: il polso della community (ADR-0007)

Richiede il consenso esplicito sul dispositivo ed è disattivato finché una persona non lo attiva. Nulla di quanto presente qui deriva da un diario: nessun percorso di codice sul server ne decifra uno. Ciascun numero sottostante arriva come un piccolo delta da un dispositivo il cui proprietario ne ha fatto richiesta, e ADR-0007 indica esattamente cosa lascia il dispositivo e perché.

Quattro route, tutte dietro il consueto token di accesso dell'account (§4.1). Un chiamante anonimo riceve il normale 401.

POST /v1/pulse/meal
Authorization: Bearer <accessToken>
Idempotency-Key: 6f1c3a1e-9d7b-4a2f-8b31-0f4e9a2c7d55
Content-Type: application/json

{ "kcal": 1234, "protein": 33 }
POST /v1/pulse/photo
POST /v1/pulse/fasting
Authorization: Bearer <accessToken>
Idempotency-Key: <uuid>

Entrambi presentano un corpo vuoto.

GET /v1/pulse/today
Authorization: Bearer <accessToken>

200 {
  "day": "2026-09-12",
  "meals": 42,
  "photos": 17,
  "kcal": 68350,
  "protein": 2140,
  "contributors": 9,
  "fastingNow": 4
}

Sette proprietà che un'implementazione conforme DEVE rispettare:

  1. Il server riarrotonda e vincola i valori. kcal viene arrotondato al multiplo di 50 più vicino e vincolato tra 0 e 5000; protein viene arrotondato al multiplo di 5 più vicino e vincolato tra 0 e 500. Un dispositivo che invia una cifra esatta, un valore negativo o uno assurdo si allinea comunque sulla stessa griglia di tutti gli altri. Un corpo che non contiene due numeri finiti restituisce 400 {"error":"invalid request body"}.
  2. Ogni scrittura include un'intestazione Idempotency-Key, un uuid, e una richiesta che ne è priva restituisce 400 {"error":"idempotency key required"}. La chiave viene conservata 24 ore. Un reinvio entro tale finestra risponde con 200 {"duplicate": true} e non modifica nulla, rendendo così sicuro un replay offline o un nuovo tentativo.
  3. I limiti di frequenza sono per account: POST /v1/pulse/meal e POST /v1/pulse/photo uno al minuto ciascuno, POST /v1/pulse/fasting uno ogni 10 minuti. Il superamento del limite restituisce 429 con un'intestazione Retry-After in secondi e un corpo che non menziona alcun identificatore.
  4. Un heartbeat di digiuno è un upsert basato sull'ID account. Due heartbeat lasciano una sola riga, con la scadenza più recente. La riga scade 30 minuti dopo l'ultimo heartbeat, e fastingNow conteggia solo le righe non scadute. La presenza è associata all'account anziché anonima poiché sia la limitazione di frequenza sia la deduplicazione necessitano di un'identità, e un heartbeat anonimo potrebbe essere riprodotto per gonfiare il conteggio (ADR-0007).
  5. GET /v1/pulse/today viene servito da una cache in-process da cinque minuti, una sola voce per l'intera istanza, invalidata in base al tempo e mai da una scrittura. Un client la recupera al massimo ogni cinque minuti. Una scrittura effettuata entro tale finestra non è quindi visibile finché la voce non scade, un comportamento dichiarato esplicitamente invece di essere corretto: i numeri sono un segnale di vicinanza, non una conferma di ricezione.
  6. I totali giornalieri sono conservati per 30 giorni. pulse_days e le righe dei contributori correlate vengono eliminati da una pulizia oraria una volta superata quell'età, le righe di presenza scadute vengono rimosse assieme a loro, e le chiavi di idempotenza vengono eliminate dopo 24 ore.
  7. Le route di rilevamento registrano solo un codice di stato e un conteggio di byte. Mai l'ID account, e mai un valore ricavato dal corpo.

GET /v1/admin/stats (§5.20) segnala il polso odierno all'operatore come pulse: { meals, photos, kcal, protein, contributors, fastingNow }, che corrisponde allo stesso insieme di dati che ciascun membro può già leggere.

5.24 /v1/push/*: notifiche push web (ADR-0008)

*Disattivato a meno che l'operatore non abbia impostato tutte e tre le variabili `VAPID_ variables**, and then opt in per device. With none of them set the whole subtree answers the ordinary unknown-path 404 to everybody, credentialed or not, and GET /health reports instance.push: false`.

Il server non genera alcun testo per le notifiche. Ciascuna notifica push inviata contiene un solo campo:

json
{ "kind": "catch-up" }
{ "kind": "fast-target" }

Il dispositivo si attiva, legge il diario che solo lui può leggere e scrive le parole. Un client conforme DEVE poter mostrare qualcosa per entrambi i tipi senza che il payload gli comunichi nulla, perché il payload non lo farà mai.

Quattro route, tutte protette dall'ordinario token di accesso dell'account (§4.1). Un chiamante anonimo su un'istanza configurata riceve l'ordinario 401.

GET /v1/push/config
Authorization: Bearer <accessToken>

200 { "publicKey": "<VAPID application server key, base64url>" }
PUT /v1/push/subscriptions
Authorization: Bearer <accessToken>
Content-Type: application/json

{
  "endpoint": "https://push.example.org/f7a1…",
  "keys": { "p256dh": "<base64url>", "auth": "<base64url>" },
  "replaces": "https://push.example.org/older…",
  "timeZone": "Europe/Berlin",
  "locale": "de",
  "catchUpMinute": 480,
  "fastTargetEnabled": true
}

201 { "subscribed": true }   a device seen for the first time
200 { "subscribed": true }   the same endpoint again
PATCH /v1/push/subscriptions
{ "endpoint": "…", "timeZone": "…", "locale": "…", "catchUpMinute": 420, "fastTargetEnabled": false, "wakeAt": "2026-01-15T18:30:00Z" }

200 { "updated": true }
404 { "error": "no such subscription" }
DELETE /v1/push/subscriptions
{ "endpoint": "…" }

200 { "unsubscribed": true }

Nove proprietà che un'implementazione conforme DEVE rispettare:

  1. Una sottoscrizione è identificata dal suo endpoint, generato dal servizio push e univoco a livello globale. PUT è un upsert su di esso: un dispositivo che registra di nuovo lo stesso endpoint riceve 200 e mantiene il giorno del primo rilevamento.
  2. replaces indica l'endpoint sostituito da questa registrazione, e viene eliminato solo quando appartiene allo stesso account. La nuova registrazione di un service worker genera un nuovo endpoint senza disiscrivere quello precedente, quindi senza questo passaggio il record orfano rimarrebbe lì a rispondere 201 a nessuno all'infinito. Un replaces uguale a endpoint indica che il dispositivo sta specificando il proprio nome e non elimina nulla.
  3. timeZone è un nome IANA e viene convalidato in fase di scrittura. Un fuso orario sconosciuto è 400. L'intero allineamento dipende dall'orologio locale, perciò un fuso orario non interpretabile dal server comporterebbe una notifica all'ora sbagliata anziché un errore.
  4. catchUpMinute è un minuto del giorno locale, da 0 a 1439, oppure null per "nessun allineamento su questo dispositivo". null è l'impostazione predefinita silenziosa.
  5. wakeAt è un istante singolo, in formato ISO 8601, oppure null per azzerarlo. Il server invia l'avviso di obiettivo rapido non appena scade e azzera la colonna nella stessa scrittura, impedendo che venga attivato due volte. Un campo omesso in una PATCH lascia il valore invariato.
  6. L'allineamento viene inviato una sola volta al giorno LOCALE, quando l'orologio della sottoscrizione ha superato il proprio minuto e la notifica non è già partita oggi in quel fuso. Un giorno locale durante il cambio di ora dura 23 o 25 ore, quindi un intervallo in UTC non rispetta questa regola.
  7. Sette giorni di inattività lo sospendono. Una sottoscrizione la cui ultima registrazione o modifica della pianificazione risale a più di sette giorni locali fa non riceve alcun allineamento finché non si riconnette.
  8. Al massimo due push per sottoscrizione ogni giorno UTC. Un terzo push viene ignorato, mai accodato.
  9. Un 404 o un 410 dal servizio push elimina la riga. Nessun altro errore lo fa: un 400, un 401, un 403, un 429 e ogni 5xx sono temporanei o riguardano il mittente, e ripulire la tabella in base ad essi la svuoterebbe al primo inserimento errato di una chiave.
  10. Non viene inviato nulla a un account senza consenso. Quando instance.healthConsent è non-null (§5.6), il server non invia notifiche push a un account privo di quella specifica versione (§5.15.1), indipendentemente dalla pianificazione. Una notifica push trattenuta non registra alcun contrassegno, quindi un recupero ancora dovuto parte al tick successivo dopo che la persona ha acconsentito.

I topic di raggruppamento sono openplate-catchups e openplate-fast, il TTL è di 6 ore e l'urgenza è normale per l'allineamento e alta per l'obiettivo rapido. Un topic DEVE usare caratteri base64 sicuri per gli URL, al massimo 32, con una lunghezza che mai 1 mod 4: Apple decodifica il topic e risponde altrimenti con 400 BadWebPushTopic, mentre altri servizi push lo accettano, rendendo il problema invisibile su qualsiasi dispositivo diverso da un iPhone.

I cinque limiti (2026-09-30). Per evitare che un solo account blocchi ogni recapito o punti questo server verso un host interno:

  1. L'endpoint deve essere https, sulla porta predefinita, senza nome utente né password, presso un servizio push noto: fcm.googleapis.com, updates.push.services.mozilla.com e *.push.services.mozilla.com, web.push.apple.com e *.push.apple.com, *.notify.windows.com, più ogni host elencato da chi gestisce il server in PUSH_ENDPOINT_HOSTS. Qualsiasi altra cosa è 400 {"error":"endpoint must be an https URL at a known push service"} e non scrive nulla. Una riga memorizzata che non rispetta questa regola viene eliminata al tick successivo, non inviata.
  2. Un account mantiene al massimo 10 iscrizioni. Una registrazione successiva a tale limite elimina le altre righe più vecchie dell'account; la riga appena registrata viene sempre conservata.
  3. Un recapito si arrende dopo 10 secondi, e il tick invia a 8 endpoint alla volta, così un endpoint lento non rallenta nessun altro.
  4. Un recapito che fallisce con un valore diverso da 404 o 410 applica un backoff alla riga: il tentativo successivo avviene dopo un minuto, poi due, quattro e così via fino a un giorno. Dopo 15 fallimenti consecutivi, circa quattro giorni e mezzo, la riga viene eliminata. Un recapito andato a buon fine, e una nuova registrazione dell'endpoint, azzerano il conteggio.
  5. A un tick che supera il suo minuto non se ne aggiunge un secondo.

PUT su un endpoint appartenente a un altro account sposta la riga al chiamante. Questo è necessario: l'app riutilizza l'iscrizione esistente del browser e, quando l'azzeramento di un dispositivo non riesce a rimuoverla, l'account successivo su quel browser registra lo stesso endpoint. La riga del proprietario precedente smette quindi di risvegliare quel dispositivo, che è ciò che il nuovo proprietario desidera.

Nessuna route restituisce mai un endpoint o una chiave del dispositivo, e le route registrano nei log solo percorso, metodo, stato e conteggio dei byte: mai l'ID dell'account e mai l'endpoint, che è una capability.

GET /v1/admin/stats (§5.20) restituisce push: { subscriptions, sentToday } all'operatore, ossia due interi e mai una riga.

5.25 POST /v1/feedback: una stima segnalata (ADR-0006)

Presente solo quando il gestore ha impostato SYNC_FEEDBACK. Senza di esso il percorso risponde con l'ordinario percorso sconosciuto 404 a chiunque, con o senza credenziali, e GET /health non include alcun instance.feedback (§5.6). Un client DEVE leggere instance.feedback prima di proporre una segnalazione. DEVE indicare la finestra di conservazione dichiarata da quel campo e nessun'altra.

Questa è l'unica scrittura di questo protocollo che il server può leggere. Chi ritiene che una stima sia errata invia le cifre di quella voce. Se il dispositivo ha ancora la foto del piatto, invia anche quella. La persona deve prima acconsentire all'uscita di entrambe dal dispositivo. Il server le mantiene leggibili fino allo scadere della finestra. ADR-0006 spiega perché esiste questa eccezione.

Autenticato con la consueta token di accesso dell'account (§4.1). Un chiamante anonimo riceve il consueto 401.

POST /v1/feedback
Authorization: Bearer <accessToken>
Content-Type: application/json

{
  "idempotencyKey": "6f1c3a1e-9d7b-4a2f-8b31-0f4e9a2c7d55",
  "measurements": { "…": "…" },
  "consent": { "agreedAt": "2026-09-19T08:12:00.000Z", "wordingVersion": "1" },
  "image": { "contentType": "image/jpeg", "data": "<base64>" }
}

201 { "reportId": 17, "hasImage": true, "createdAt": "2026-09-19T08:12:03.000Z" }

Sette proprietà che un'implementazione conforme DEVE rispettare:

  1. Tutti i campi tranne image sono obbligatori. idempotencyKey va da 1 a 128 caratteri dopo il troncamento degli spazi, consent.wordingVersion va da 1 a 64 e consent.agreedAt è un istante temporale. Viene memorizzato come orologio proprio del dispositivo e mai corretto. Il createdAt del server gli sta accanto. measurements è un oggetto JSON di al massimo 16 KB una volta serializzato. Il server non ha alcuno schema a riguardo e NON DEVE definirne uno: il limite esiste affinché nessuno possa parcheggiare un diario nel campo. Qualsiasi altro corpo produce 400 {"error":"invalid request body"}, una frase per ogni campo.
  2. image è facoltativo, e l'assenza non costituisce un errore. Una chiave mancante o null indica l'assenza della fotografia. La cache delle foto del dispositivo potrebbe averla già rimossa, e vale comunque la pena esaminare le cifre. Quando è presente, è { "contentType", "data" }. contentType è image/jpeg, image/png o image/webp. Non è mai image/svg+xml, che può contenere script. data è una stringa base64 che si decodifica in un intervallo da 1 a 5.000.000 di byte. Una dimensione superiore produce 413, mentre se è vuota o di un altro tipo produce 400.
  3. Il limite per il corpo è FEEDBACK_MAX_REQUEST_BYTES, 8 MB per impostazione predefinita, e si applica solo a questa rotta. Si colloca al di sopra del limite dell'immagine poiché il base64 cresce di un terzo. Un corpo più grande produce 413 {"error":"request body exceeds the maximum accepted size"}.
  4. La chiave di idempotenza rende sicuro un nuovo tentativo. È univoco per account. Un secondo invio con una chiave esistente risponde con 200 restituendo la segnalazione memorizzata invece di 201. Non memorizza una seconda segnalazione e non viene conteggiato nel limite giornaliero. Scrive nuovamente la fotografia se ne è stata inclusa una. Questo corregge una segnalazione la cui prima scrittura della fotografia era stata interrotta.
  5. Un limite giornaliero per account, FEEDBACK_DAILY_LIMIT, 5 per impostazione predefinita, conteggiati per giorno UTC nella stessa transazione dell'inserimento. Se superato restituisce 429 {"error":"daily limit reached: 5 reports per day for this account"}, che non menziona alcun identificatore.
  6. Vengono memorizzati la fotografia, le cifre, la registrazione del consenso, l'id dell'account e l'orario di arrivo, e nient'altro. Non le intestazioni della richiesta, l'indirizzo IP, lo user agent o un identificatore del dispositivo.
  7. Una segnalazione e la sua fotografia vengono rimosse dopo instance.feedback.retentionDays (30 in questa implementazione, eliminati da una pulizia oraria), prima se un gestore elimina la segnalazione (§5.20), e insieme all'account.

6. Handshake di versione: obbligatorio, e con l'obbligo di fallire in modo restrittivo

Un client DEVE leggere questo documento dal servizio e verificarlo prima della sua prima sincronizzazione in una sessione.

Questo sostituisce un controllo di versione interno al processo, usato quando client e server venivano distribuiti come un unico elemento. Ora non è più così: un client e un servizio distribuiti possono differire di una versione in entrambe le direzioni, e chi usa il self-hosting può puntare un client attuale a un servizio aggiornato otto mesi prima. Nulla di questa situazione è rilevabile da un 200 riuscito su un push.

Regole:

  1. protocolVersion deve essere uguale a quello del client. Non "≥", non "più o meno compatibile".
  2. envelopeVersion deve essere uguale a quello del client.
  3. In caso di mancata corrispondenza, il client rifiuta la sincronizzazione e mostra all'utente quale delle due parti sia meno recente. Non esegue push, non esegue pull, non riprova e non degrada silenziosamente.
  4. Se l'handshake non è raggiungibile o è malformato, trattalo come una mancata corrispondenza. Un servizio non verificabile non è un servizio compatibile.

L'implementazione di riferimento è checkProtocolCompatibility() in entrambi i file protocol.ts, pura, totale, e restituisce una frase presentabile all'utente invece di un booleano.

Perché il rifiuto invece del best-effort: il blob è spesso l'unica copia dei dati dell'utente. Un client che invia con push una busta incorniciata diversamente da un servizio più recente, o ne decifra una che comprende solo a metà, può corrompere quella copia in modo irrecuperabile. Una sincronizzazione rifiutata è un inconveniente visibile; una sincronizzazione errata silenziosa è un incidente di perdita dati scoperto dopo settimane. Questo protocollo sceglie sempre l'inconveniente.

7. Politica di versionamento

  • PROTOCOL_VERSION copre endpoint, struttura di richieste e risposte, semantica dei codici di stato, schema di autenticazione e semantica CAS. Incrementalo per qualsiasi modifica incompatibile a questi elementi. Le modifiche puramente additive (un nuovo campo facoltativo nella risposta, un nuovo endpoint che i client meno recenti non chiamano mai) non lo incrementano.
  • ENVELOPE_VERSION copre unicamente la crittografia e l'incorniciatura del blob: cifrario, posizionamento dell'IV, codec di compressione, gestione dei tag. Incrementalo per uno qualsiasi di questi aspetti. Non incrementarlo per una modifica allo schema del payload.
  • payloadSchemaVersion è la versione dello schema dell'archivio locale del client. Viaggia attraverso questo protocollo come un intero opaco vincolato nell'AAD. Il server non lo interpreta mai e non influisce mai su nessuna delle due versioni precedenti.

I due numeri di versione sono volutamente indipendenti: cambiare la struttura della crittografia e modificare l'API HTTP sono tipi di cambiamento diversi con raggi di impatto differenti.

Margine di manovra pre-1.0. Fino al primo rilascio pubblico, le modifiche incompatibili possono essere introdotte senza il percorso di migrazione richiesto da un protocollo già rilasciato. Due sono state introdotte SENZA incrementare la versione: il passaggio dall'autenticazione tramite cookie a quella bearer e lo spostamento delle route di sincronizzazione da /api/sync a /v1/sync. Una terza, la rimozione dell'email in 0.5.0, è stata introdotta anch'essa senza incremento e non avrebbe dovuto esserlo (vedi sotto). Questo paragrafo viene eliminato al rilascio pubblico e, da quel momento in poi, le regole sopra descritte saranno seguite alla lettera.

La versione 0.5.0 ha modificato il contratto di autenticazione e NON ha incrementato la versione, e questo è l'errore registrato in questa sezione. Ha sostituito email con handle, rimosso verify-email e request-reset, e aggiunto recover e recover-rotate (§5.14). Poiché il numero è rimasto a 1, l'handshake del §6 non l'ha rilevato: un client precedente a 0.5.0 che inviava email riceveva un 400 che non poteva correggere, mentre i numeri di versione corrispondevano indicando che tutto andava bene.

0.6.0 incrementa PROTOCOL_VERSION a 2, e lo fa esattamente per questa ragione. Le modifiche appartengono alla stessa classe (il campo di autenticazione è di nuovo email, la registrazione richiede un invito indirizzato ed entrambi i record delle chiavi, signupMode ha lasciato l'handshake, AccountView ha sostituito il vecchio corpo dell'account e due endpoint di ripristino riutilizzano il §5.12), ma questa volta il §6 le rileva: un client che parla la versione 1 rifiuta di comunicare invece di funzionare a metà. Motivazione: docs/adr/0005-organization-accounts-and-escrowed-recovery.md.

8. Limiti di dimensione e piano di capacità

LimiteValoreApplicato da
Dimensione massima del blob2 MiB (MAX_BLOB_BYTES)Servizio (413), replicato lato client per un errore migliore
Versioni del blob conservateTre livelli, vedi sottoServizio, ripulito dopo ogni scrittura accettata
Record di chiavi per account2 (uno per kind)Servizio

La conservazione è a livelli (M224). Una versione viene conservata se QUALSIASI livello la conserva:

LivelloRegolaLimite massimo
RecentiLe versioni più recenti (BLOB_VERSION_RETENTION)5
GiornalieroLa versione più recente di ogni giorno del calendario UTC per BLOB_DAILY_RETENTION_DAYS14
Blocchi prima della riduzioneVersioni sostituite da una riduzione consistente confermata, per BLOB_PRE_SHRINK_PIN_DAYS, dalla più recente fino a BLOB_PRE_SHRINK_PIN_LIMIT14

Quindi al massimo 33 versioni, e perciò al massimo 66 MiB, per account. Il livello giornaliero si basa di proposito sui GIORNI del calendario e non sul conteggio: due dispositivi in un ciclo di merge generano versioni alla velocità consentita dalla rete, esaurendo in pochi minuti un livello basato sul conteggio. Il livello dei blocchi ha un limite massimo perché il blocco viene impostato su richiesta del client stesso.

Prima di M224 l'unica regola era il limite fisso di cinque versioni, una protezione davvero minima: un account con il diario azzerato da un difetto del client si poteva recuperare solo finché la versione valida si trovava ancora all'interno di una finestra temporale che due dispositivi possono consumare in un minuto.

Il limite critico di capacità, spiegato chiaramente. Un unico blob conserva l'archivio intero dell'account. Le voci del registro alimentare occupano circa 400-700 byte di JSON ciascuna prima della compressione, quindi un blob non compresso supererebbe i 2 MiB in circa 2-4 anni di registrazioni quotidiane. Non è un problema teorico, è una scadenza precisa.

ENVELOPE_VERSION 1 comprime il testo in chiaro con gzip, guadagnando circa un ordine di grandezza su un JSON così ripetitivo (gli stessi nomi di chiave su ciascuno di migliaia di record) e allontanando il limite critico abbastanza da non renderlo un problema imminente. Non lo elimina.

La soluzione pianificata, per non doverla definire sotto pressione: blob suddivisi in blocchi o per entità, molti piccoli testi cifrati con versioni indipendenti, invece di un unico monolite. Si tratta di una modifica sostanziale alla struttura e agli endpoint, quindi sarà un incremento di versione del protocollo, non una patch. Operativamente, il lavoro inizierà quando le dimensioni dei blob supereranno circa l'80% del limite massimo nei sistemi in uso, evento per cui il servizio registra un avviso (M128 spec 02). Il limite critico dovrebbe essere visibile molto prima che qualsiasi utente lo raggiunga.

9. Cosa sa il server

9.1 Cosa non può sapere

Il server non riceve mai la DEK, nessuna delle due KEK, né la passphrase. Riceve invece il codice di recupero alla registrazione e a ogni rotazione, e conserva tale codice sigillato (§3.1 e la voce di deposito fiduciario nel §9.2). Nessun percorso di codice in questo servizio deriva una chiave dal codice o decifra un blob. Per il codice stesso del servizio, la decifratura non è trattenuta, è indisponibile. Per chiunque detenga sia il database sia SERVER_SECRET, è disponibile. Questo significa il gestore di un'istanza gestita, o tu per conto tuo.

E continua a non poterne aggregare uno. Il polso della community del §5.23 sembra un conteggio dei pasti da parte del server, ma non lo è: nulla nel §9.2 deriva da un blob, e un'installazione in cui nessuno ha attivato il polso non conta assolutamente nulla. Le somme esistono perché i dispositivi i cui proprietari hanno dato il consenso le hanno inviate, motivo per cui il polso è elencato di seguito come qualcosa che il server conosce e non come qualcosa che calcola.

9.2 Cosa conosce

Massima trasparenza sui metadati, perché "cifrato end-to-end" viene spesso inteso come "il server non sa nulla":

  • Dimensione del blob, e quindi un'approssimazione di quanti dati contiene l'account. La compressione rende questo segnale più sfumato rispetto a prima, non nascosto.
  • Frequenza e tempistica di scrittura: quando un dispositivo sincronizza e con quale frequenza.
  • Numeri di versione: blobVersion, envelopeVersion e il numero di versioni conservate.
  • Parametri KDF e salt per il record della passphrase. Non sono segreti, esistono per essere forniti a un nuovo dispositivo prima del login.
  • Se un account ha completato la configurazione (ha record di chiavi) e se ha mai sincronizzato (ha un blob).
  • L'account stesso: un indirizzo email, un nome visualizzato facoltativo, un ruolo, una quota giornaliera di IA, un istante di sospensione, un verificatore di autenticazione (un hash con chiave di un hash con chiave della passphrase, vedi §5.8), un secondo verificatore con la stessa struttura sulla prova di recupero e i parametri KDF dell'account. L'indirizzo identifica una persona reale, una categoria di dati personali che la versione 0.5.0 aveva rimosso e che la 0.6.0 ha deliberatamente reintrodotto (ADR-0005): i membri di un'organizzazione vengono identificati dall'indirizzo a cui è arrivato il loro invito, perché è l'identificatore che ricorderanno ancora tra un mese.
  • Il CODICE DI RECUPERO dell'account, sigillato (accounts.recovery_code_escrow, §3.1). Questa è la voce dell'elenco su cui chi legge dovrebbe soffermarsi. È AES-256-GCM con una sottochiave di SERVER_SECRET, quindi un dump del database da solo non basta per aprirlo, ma il gestore di un'istanza gestita ha entrambi. Il gestore di un'istanza gestita può aprire qualsiasi account presente su di essa. Non tramite un endpoint e non tramite un percorso di codice di questo servizio, ma leggendo quella colonna con il segreto alla mano ed eseguendo la HKDF del client stesso. Un'istanza in self-hosting è gestita da te, quindi lì la promessa originale resta valida. Decidere se fidarsi di un'istanza ospitata equivale quindi a decidere se fidarsi del suo gestore.
  • Inviti in sospeso: per ciascuno, un indirizzo, un nome facoltativo, un ruolo e una quota, appartenenti a qualcuno che NON ha ancora un account e non ha fornito alcun consenso. La creazione è un'azione dell'operatore, e DELETE /v1/admin/invites/:id ritira la riga. Una riga generata dalla porta di richiesta del §5.8.3 viene contrassegnata come tale, così che un operatore possa contarle. Un invito completato perde l'indirizzo e il nome entro un'ora: revocata o scaduta su ogni istanza, riscossa su un'istanza con TRIAL_ADDRESS_PEPPER, che conserva solo l'hash con chiave descritto sotto. Senza il pepper una riga riscossa mantiene il proprio indirizzo, poiché la regola di reinvito dei membri del §5.21 lo legge.
  • La prova delle scansioni, su un'istanza che ne esegue una: le scansioni gratuite concesse e usate, due interi sulla riga dell'account. Solo per un account con prova di scansione, una riga per azione dell'IA: un id opaco scelto dal client, un orario, un conteggio delle richieste e se la risposta sia stata recapitata o meno, conservato 24 ore e poi eliminata, e mai registrata nei log. Ogni riga di invito riporta un hash unidirezionale con chiave della relativa casella di posta (HMAC-SHA256 sotto TRIAL_ADDRESS_PEPPER, un segreto detenuto solo dall'operatore, sulla chiave di prova del §5.8.3), mai una seconda copia dell'indirizzo.
  • Dopo l'eliminazione di un account, su un'istanza che prevede una prova delle scansioni: l'indirizzo e il nome vengono rimossi da ogni riga di invito relativa a quella casella di posta e, solo se l'account disponeva di una prova, viene conservato un solo hash con chiave della casella di posta, e nient'altro: nessun nome, nessun id e una sola data, l'istante dell'eliminazione, che permette di chiudere la riga. Questo impedisce alla stessa casella di posta di ottenere una seconda prova. Senza il segreto dell'operatore l'hash non può essere decifrato né confrontato con un elenco di indirizzi. Una procedura di pulizia lo elimina TRIAL_HASH_RETENTION_DAYS (365 per impostazione predefinita) dopo quell'istante, in base alla base giuridica dell'Art. 6(1)(f) GDPR (legittimo interesse a prevenire l'abuso delle scansioni gratuite, non esaminato da un legale, ADR-0010). Un'istanza che non concede prove delle scansioni non conserva alcun hash. Su un'istanza senza prova delle scansioni, le righe di invito mantengono il loro indirizzo dopo l'eliminazione, in conformità alla regola di reinvito dei membri del §5.21.
  • Utilizzo dell'IA: un intero per account per giorno UTC, conservato per 90 giorni e poi eliminato (§5.20). Un conteggio, mai un log: nessun prompt, nessuna risposta, nessun modello, nessun timestamp oltre al giorno. Un gestore può leggere i contatori di un account come una sequenza giorno per giorno (GET /v1/admin/accounts/:id/activity), che costituisce un metadato su quando una persona ha usato un'app per la salute ed è limitato esattamente per questo motivo.
  • Il polso della community, per gli account che lo hanno abilitato (§5.23, ADR-0007): totali giornalieri a livello di istanza per pasti, fotografie, calorie e grammi di proteine, una riga per account contributore al giorno e una riga di presenza a breve termine che indica che un account sta digiunando in questo momento. I totali non sono riconducibili a nessuno, mentre la riga del contributore e la riga di presenza lo sono, e indicano solo "questo account ha contribuito oggi" e "questo account sta digiunando". I totali giornalieri e le righe dei contributori vengono conservato per 30 giorni, la presenza scade 30 minuti dopo l'ultimo heartbeat e le route non registrano alcun id account. Chi non lo ha mai attivato non invia nulla e non compare in nessuna parte di questi dati.
  • Un'iscrizione push, per un dispositivo il cui proprietario ha attivato le notifiche (§5.24, ADR-0008): l'endpoint del servizio push, le due chiavi con cui cifra, una stringa di user agent limitata, un fuso orario IANA, una lingua, il minuto del giorno locale in cui è previsto un recupero, l'ultimo giorno locale in cui ne è partito uno, il giorno locale in cui il dispositivo è stato visto l'ultima volta, l'istante in cui ha chiesto di essere risvegliato e il conteggio di quanto inviato oggi. Nel loro insieme, questi dati indicano indicativamente quando questa persona è sveglia, indicativamente dove si trova nel mondo e, tramite wake_at, quando termina un suo digiuno. Quest'ultimo elemento coincide con la riga di presenza del battito, che indica che lo stesso digiuno è in corso; ADR-0008 dichiara esplicitamente la correlazione invece di lasciarla scoprire. Ciò che NON viene memorizzato è una singola parola del testo di una notifica: ogni notifica push trasporta una tipologia. La riga scompare quando il dispositivo annulla l'iscrizione, quando il servizio push lo disconosce o insieme all'account.
  • Un consenso per i dati sanitari, su un'istanza che ne richiede uno (§5.15.1): la versione del testo accettata dalla persona e l'istante in cui questo servizio l'ha registrata, due colonne nella riga dell'account. Attesta che la persona usa un'app per la salute e ha acconsentito al trattamento di tali dati da parte dell'operatore, il quale deve essere in grado di dimostrarlo. È visibile a un operatore (§5.20), nessuna route lo cancella, e viene rimosso insieme alla riga dell'account all'eliminazione.
  • L'ultima volta che una persona ha fatto qualcosa: accounts.last_seen_at, scritto a ogni accesso e da un completamento tramite proxy, e deliberatamente non da un rinnovo del token o da una richiesta periodica di sincronizzazione, quindi significa "qualcuno ha agito" anziché "un client era in esecuzione". È visibile a un operatore (§5.20) e viene eliminato insieme alla riga dell'account.
  • Dichiarazioni di legge (POST /v1/legal/declarations, una cancellazione o un recesso, su ogni istanza): il nome, l'indirizzo, il riferimento del contratto, il motivo e le date inserite dalla persona, l'orario di arrivo e l'eventuale account corrispondente. Viene conservato conservato fino alla fine del terzo anno solare successivo all'anno di ricezione, calcolato secondo il fuso orario Europe/Berlin, e poi cancellato dalla pulizia oraria: una dichiarazione ricevuta il 2026-09-21 viene eliminata a partire dal 2030-01-01 alle 00:00 a Berlino. L'eliminazione dell'account non lo cancella prima; la riga perde il suo identificativo account ma rimane memorizzata, poiché costituisce la registrazione di quanto dichiarato dalla persona. L'invio delle ricevute per email è limitato a tre per indirizzo normalizzato, a LEGAL_DECLARATION_RECEIPTS_PER_NETWORK_PER_DAY (10 per impostazione predefinita) per rete mittente e a LEGAL_DECLARATION_RECEIPTS_PER_DAY (200 per impostazione predefinita) per istanza. Ogni limite si applica a qualsiasi intervallo di 24 ore a ritroso. I totali vengono calcolati a partire da queste righe, tranne il conteggio di rete, che risiede in memoria. Una dichiarazione che supera la soglia viene comunque memorizzata, inoltrata e inviata all'operatore, e il 202 resta lo stesso. La ricevuta non ripete mai il nome, il riferimento del contratto o il motivo; soltanto la copia dell'operatore li contiene.
  • Metadati di sessione: quante sessioni attive esistono, quando ciascuna è stata creata e quando i token sono stati ruotati o revocati l'ultima volta. I valori dei token stessi sono memorizzati solo come digest.
  • Il grafo degli studi, su un'installazione con SYNC_RESEARCH impostato (§5.18): quale account contribuisce a quale studio, quando, con quale frequenza e quanto è grande ciascun contributo. Un arco qui indica che "i dati sanitari di questa persona sono nello studio Y", cioè dati personali legati alla salute della stessa classe dell'arco di assistenza descritto sotto. È inevitabile, e la prova è la revoca: cancellare la riga di un contributore richiede di individuarla, l'eliminazione dell'account deve propagarsi a cascata attraverso di essa, e sia il compare-and-swap sia il controllo degli abusi usano come chiave l'account. Uno schema che rendesse cieco il server ne romperebbe uno e l'analisi del traffico lo smaschererebbe comunque, quindi questo dato viene dichiarato anziché evitato a metà. Il ricercatore non riceve mai la mappatura (§5.18 non trasporta alcun id account), la revoca elimina definitivamente l'arco lasciando solo uno pseudonimo, e un'installazione senza questo flag non ha alcuna tabella per ospitare un grafo degli studi.
  • Il grafo di condivisione, su un'installazione con SYNC_SHARING impostato (§5.16): quale account ha concesso l'accesso in lettura a quale altro account, quando la concessione è stata effettuata e quando il beneficiario la esercita. Questo è un grafo delle relazioni, e una reale espansione di ciò che questo servizio sa, e nel contesto per cui la funzionalità è stata creata (un paziente e il suo dietista), un arco in quel grafo è di per sé un dato personale legato alla salute, perché indica che qualcuno è sotto cure mediche. È il minimo necessario per autorizzare la lettura; entrambe le parti acconsentono, poiché chi concede crea la riga e chi riceve può eliminare la propria parte; l'arco viene eliminato definitivamente alla revoca e scompare a cascata quando uno dei due account viene eliminato. Un'installazione che non imposta SYNC_SHARING non memorizza alcun grafo simile e non ha alcuna tabella in cui inserirlo.
  • Stime segnalate, su una distribuzione con SYNC_FEEDBACK impostato (§5.25, ADR-0006): le cifre di ogni voce che una persona ha scelto di segnalare, la foto del piatto quando il dispositivo ne aveva ancora una, l'id dell'account, la registrazione del consenso e l'orario di arrivo, tutti leggibili. Vengono conservati per instance.feedback.retentionDays, poi eliminati da una pulizia, e vengono rimossi insieme all'account. Un gestore può leggerli tramite il §5.20, e ogni lettura di una fotografia viene registrata. Una distribuzione senza il flag non ha alcuna segnalazione e alcuna fotografia da conservare.

Non ricavabile dai metadati sopra indicati: cosa è stato mangiato, quando, quanto o qualsiasi altra cosa all'interno del payload. Due voci sopra riportate lo rivelano. Il codice di recupero sigillato apre l'intero diario a chiunque detenga anche SERVER_SECRET, e una stima segnalata mostra la singola voce che contiene.

10. Implementare un server alternativo

Un server sincronizzazione conforme richiede, per intero:

  1. I quattro endpoint da §5.1 a §5.4 più l'handshake /health di §5.6. Il §5.5 è stato rimosso; un server non deve offrire l'eliminazione di un record di chiavi basata solo su bearer.
  2. CAS per account su blobVersion: atomico. L'implementazione di riferimento usa un indice UNIQUE (accountId, blobVersion) e tratta una violazione di univocità come un conflitto, invece di bloccare le righe; questo rimane corretto sotto READ COMMITTED ed è più semplice di SELECT ... FOR UPDATE. Qualsiasi meccanismo con la stessa garanzia va bene; una lettura seguita da scrittura senza atomicità è non.
  3. CAS per account e tipo sui record di chiavi tramite expectedUpdatedAt, con la stessa regola "il campo assente è un 400", e un controllo della passphrase (currentAuthHash) a ogni sovrascrittura.
  4. Eliminazione periodica per conservazione secondo i tre livelli di §8, e protezione dal restringimento di §5.1. Un server che accetta un restringimento considerevole non confermato distruggerà il diario di un account la prima volta che un client della build interessata perde il proprio archivio locale; un client scritto per un server che lo rifiuta, ma puntato verso uno che non lo fa, rimane silenziosamente privo di protezione.
  5. Memorizzazione byte per byte di ciphertext e wrappedDek. Non ricodificarli, normalizzarli, ripulirli o "correggerli" mai. Qualsiasi alterazione distrugge il tag GCM e con esso i dati dell'utente.

Inoltre, un server che implementa anche gli endpoint di account da §5.7 a §5.15 deve:

  1. Fornire un descrittore KDF stabile e dalla struttura verosimile per gli indirizzi sconosciuti (§5.7), eseguendo lo stesso carico di lavoro su entrambi i rami, e limitare le richieste dell'endpoint in base all'indirizzo sorgente. Un 404, un valore fittizio derivato in modo pigro o un endpoint senza limitazione di frequenza riaprono ciascuno l'oracolo di enumerazione che il resto dell'architettura chiude, tramite la risposta, il tempo di esecuzione o il volume.
  2. Memorizzare entrambi i verificatori come hash con chiave di authHash e recoveryAuthHash inviati, protetti da un segreto conservato fuori dal database. Mai il valore inviato in quanto tale, e mai in chiaro.
  3. Applicare le richieste di rotazione di §5.14 in modo atomico, incluso il deposito a garanzia risigillato, e revocare ogni sessione attiva a ciascuno degli eventi scatenanti in §4.2.
  4. Prendere l'indirizzo dell'account da INVITE durante la registrazione e mai dal corpo della richiesta (§5.8), e limitare recover, recover-rotate e reset/request su un unico bucket condiviso per (IP, email) che non viene mai azzerato in caso di successo. Un server che lascia indicare il proprio indirizzo al corpo della richiesta di registrazione ha rimosso l'unico elemento che lo verifica.
  5. Rispondere 202 a ogni reset/request dopo un carico di lavoro identico, e fare in modo che reset/open non scriva nulla nell'account (§5.12). Un ripristino che sostituisce un verificatore è la via per il furto dell'account che questo protocollo ha rimosso, a prescindere da come venga chiamato.
  6. Rifiutare un account sospeso all'accesso, al rinnovo e su ogni rotta bearer con 403 {"error":"account-suspended"}, esattamente questa stringa.
  7. Propagare a cascata l'eliminazione dell'account a blob, record di chiavi, token di ripristino e righe di utilizzo.
  8. Se implementa quell'endpoint, rispondi alla creazione di membri del §5.21 con UNA SOLA risposta per un nuovo indirizzo, per un indirizzo con un invito in sospeso e per un indirizzo associato a un account. Un server che risponde 409 nel terzo caso consegna a ogni membro un oracolo per scoprire chi altri si trova sull'istanza, e un server che risponde 500 quando il suo relay di posta è fuori servizio ne consegna uno più lento. Un server che non implementa gli inviti dei membri risponde sul percorso con il consueto 404 per percorso sconosciuto e segnala instance.memberInvites: false.

Un server conforme ha bisogno di nessuno tra: la crittografia nel §3, l'analisi JSON di qualsiasi payload o la conoscenza di cosa sia un registro alimentare.

11. Implementazione di un client alternativo

Oltre al §3 e al ciclo 409 del §5.1:

  • Esegui l'handshake del §6 prima della prima sincronizzazione e rifiuta l'operazione in caso di mancata corrispondenza.
  • Non salvare mai la passphrase, né la KEK né la DEK su alcuno storage persistente. Deriva allo sblocco, mantieni in memoria, elimina.
  • Esegui Argon2id fuori dal thread principale. A 64 MiB blocca visibilmente i telefoni di fascia bassa.
  • Genera il codice di recupero alla registrazione, cifra la DEK con quest'ultimo e invialo al server nel corpo della registrazione in modo da poterlo depositare in custodia (§3.1). Un client che salta questo passaggio crea un account che nessun ripristino può recuperare. Mostrare o meno il codice alla persona è una scelta del client; su un'istanza gestita lo scopo della custodia è proprio quello di non doverlo fare.
  • Indica a quale tipo di istanza la persona sta effettuando l'accesso prima che vi inserisca un diario. Su un'istanza gestita chi la gestisce conserva il codice custodito e può aprire l'account; su un'istanza self-hosted chi la gestisce è la persona stessa. Entrambe le soluzioni sono lecite; solo una corrisponde a ciò che un estraneo dà per scontato.
  • Leggi l'indirizzo da POST /v1/auth/invite-lookup (§5.8.2) e mostralo, invece di chiedere alla persona di digitarlo. Se non lo digita, non può sbagliare a inserirlo associandosi a un account irraggiungibile.
  • Deriva la prova di recupero sotto openplate-sync:recovery-auth:v1 e mai inviare KEK_r. I due elementi sono rami paralleli generati dallo stesso codice, e inviare il ramo KEK consegnerebbe al server un HMAC del valore che apre il diario (§3.1).
  • Dopo che POST /v1/auth/reset/open ha restituito il codice di recupero, esegui con esso il NORMALE recover-rotate del §5.14: una nuova passphrase, un record passphrase ricifrato, un nuovo codice, un record recovery ricifrato e il nuovo recoveryCode per la custodia. Interrompersi a metà lascia un account la cui custodia non corrisponde più al suo verificatore.
  • Tratta 404 da GET /blob come "nuovo account", non come un errore.
  • Invia authHash (il ramo HKDF auth del §3.1) e mai la passphrase, l'output di Argon2id o KEK_p. La derivazione del ramo errato non genera errori: l'autenticazione ha successo ma produce una chiave che non decifra nulla.
  • Recupera il descrittore KDF (§5.7) prima di derivare qualsiasi elemento su un nuovo dispositivo. Non dare per scontati i valori predefiniti; un account creato con parametri più elevati non deriverà correttamente a partire da essi.
  • Conserva il token di aggiornamento nello stesso livello di storage del token di accesso e mai riutilizzarne uno già usato: una replica revoca l'intera famiglia di token e disconnette l'utente (§4.2). Serializza gli aggiornamenti; due schede che usano contemporaneamente lo stesso token di aggiornamento appaiono a tutti gli effetti come un furto.
  • Su 401, aggiorna una volta e riprova una volta. A un secondo 401, indirizza l'utente all'accesso invece di entrare in un ciclo.

Modifica questa pagina su GitHub