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:
| Repository | File |
|---|---|
openplate-core | src/protocol.ts |
openplate | app/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.
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
end2. Terminologia
| Termine | Significato |
|---|---|
| DEK | Data-encryption key (chiave di cifratura dei dati). 32 byte casuali. Cifra il blob. Non lascia mai il client in chiaro. |
| KEK | Key-encryption key (chiave di cifratura delle chiavi). Incapsula la DEK. Ne esistono due: una derivata dalla passphrase e una derivata dal codice di recupero. |
| Busta | Il formato di trasmissione del blob cifrato: iv ‖ AES-256-GCM(gzip(JSON(payload))). |
| Record di chiavi | Una DEK incapsulata, più (solo per il tipo con passphrase) i parametri KDF necessari per ricalcolare la relativa KEK. |
blobVersion | Contatore monotonico per account. Il token di compare-and-swap. |
| Account | L'unità di isolamento. Un account ha al massimo un blob corrente e al massimo due record di chiavi. |
| L'identificatore dell'account: un indirizzo canonico (NFKC, spazi rimossi, minuscolo). Univoco per server. | |
| Invito | Una capability monouso INDIRIZZATA a un'email, generata da un operatore. L'unico modo per accedere. |
| Deposito fiduciario | Il codice di recupero dell'account, sigillato sul server con una sottochiave di SERVER_SECRET. |
| Ruolo | admin 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
kdfDescriptordel 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
infosono 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 diKEK_p, non genitore e non figlia: entrambe sono output HKDF sullo stesso hash Argon2id sotto diverse etichetteinfo, 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 diKEK_resattamente nello stesso senso in cuiauthHashè sorella diKEK_p, ed è di 32 byte, in base64 durante la trasmissione. - L'etichetta
recovery-authnon è mai l'etichettarecovery-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:v2anziché una ridefinizione (ADR-0004). - Il server non memorizza mai nemmeno
authHashorecoveryAuthHash. MemorizzaHMAC-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, senzaO,I,Lper 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)inaccounts.recovery_code_escrow, doveescrowKeyè una terza sottochiave HMAC fissa diSERVER_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 indocs/adr/0005-organization-accounts-and-escrowed-recovery.md. - Il deposito riguarda il CODICE, non
KEK_re 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 di12 + 32 + 16 = 60byte.
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-256della 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 charactersI 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.recoveryCodee la risposta direset/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 dareset/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/syncha un limite dedicato, senza ereditare quello delle altre: i record blob e key usano il limite del blob in base64 più 4 KiB,rotate-dekil 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, mai413, 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: *, eAccess-Control-Allow-Credentialsnon 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 ricevono403. Un server conforme non deve confonderli. - Due
403contengono un codice macchina fisso su cui il client si dirama:account-suspendedsu qualsiasi route bearer, ehealth-consent-requiredsu 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é403da 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) eX-Intake-Id(§5.19). Ogni intestazione di risposta personalizzata letta da un client è indicata inAccess-Control-Expose-Headers:Retry-After,X-Trial-Scans-Left,X-Quota-UsedeX-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.
| Token | Durata | Scopo |
|---|---|---|
access | 15 min | Inviato a ogni richiesta. Breve, perché un token trapelato rimane utile per tutta la sua durata. |
refresh | 30 giorni | Scambiato 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/refreshcon 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 riceve200, 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-passphrasePOST /v1/auth/recover-rotatePOST /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:
| Token | Prefisso | Durata | Memorizzato in | Cosa consente |
|---|---|---|---|---|
| Invito di registrazione | si_ | 7 g | signup_invites | Crea UN account, all'indirizzo indicato nell'invito. |
| Reimpostazione password | sr_ | 60 min | password_resets | Restituisce 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:
| Famiglia | Prefisso | Autenticazione |
|---|---|---|
| Sincronizzazione (da §5.1 a §5.5) | /v1/sync (SYNC_API_PREFIX) | Bearer, sempre |
| Handshake (§5.6) | /health | Nessuno |
| Account (da §5.7 a §5.15) | /v1/auth | Misto: 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:
{ "baseVersion": 3, "envelopeVersion": 1, "ciphertext": "<base64>", "shrinkAcknowledged": false }baseVersion: lablobVersionche il client ritiene sia attualmente memorizzata.0asserisce "questo account non ha ancora alcun blob".- La scrittura viene accettata solo se
baseVersionequivale alla versione attuale dell'account. Questo costituisce l'intero modello di concorrenza. Non esiste alcun force-push e nessuna scrittura priva diIf-Match. shrinkAcknowledged: FACOLTATIVO, e la sua assenza indicafalse. Vedi la protezione dal restringimento più sotto.
Risposte:
| Stato | Corpo | Significato |
|---|---|---|
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
| Stato | Corpo |
|---|---|
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
{
"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:
{
"kdfDescriptor": { "...": "..." } | null,
"wrappedDek": "<base64>",
"expectedUpdatedAt": "<iso>" | null,
"currentAuthHash": "<base64, 32 bytes>"
}expectedUpdatedAt: nullasserisce "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
expectedUpdatedAtgenera un400, 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
expectedUpdatedAtnon ènull, il campocurrentAuthHash(il ramo di autenticazione della passphrase attuale, §3.1) è OBBLIGATORIO: se assente o non valido produce un errore400che lo indica espressamente, mentre se non corrisponde all'account produce401 {"error":"current passphrase is incorrect"}, il corpo inviato dachange-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,deleteerotate-dek: un account bloccato restituisce429conRetry-Afterper tutte e quattro le richieste, da qualsiasi indirizzo. Una corrispondenza corretta azzera il contatore.
Validazione, tutti 400:
wrappedDekvuotokind: "recovery"con unkdfDescriptornon nullo (il percorso di recupero usa solo HKDF; non ci sono parametri da registrare)kind: "passphrase"con unkdfDescriptornullo
Risposte:
| Stato | Corpo | |
|---|---|---|
200 | Il 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.
{
"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:
{
"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.
{
"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:
{
"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
429conRetry-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.
{
"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.
| Stato | Significato |
|---|---|
201 | {"account": AccountView, "tokens": {...}} (§5.15). Viene sempre rilasciata una sessione; non resta nulla da confermare. |
400 | Un 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. |
409 | Esiste già un account per l'indirizzo dell'invito. L'invito NON viene consumato. |
429 | Frequenza 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_…"}.
{ "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 pushlocale(§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 unlocalevalido, 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 un400, e la risposta sottostante non cambia. Pertierquesto includeAlpha(maiuscole/minuscole),alpha(spaziature), un'etichetta di 33 caratteri,42, un oggetto, un array ea&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 seguelocale.
Un link che contiene tutti e tre ha questo aspetto: <client>/join#server=…&invite=si_…&plan=yearly&tier=tier-a&lang=de.
{}→ 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.
| Stato | Significato |
|---|---|
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 |
404 | L'istanza non prevede la registrazione aperta |
429 | Più 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:
{ "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.
// 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[]è un400. A differenza di un cambio di passphrase, questo percorso ha necessariamente modificatoKEK_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 chiaverecoveryerecoveryCodedevono arrivare insieme o non arrivare affatto; qualsiasi sottoinsieme è un400. 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
401con 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: risponde403 {"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:
{
"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/signupinclude"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 consenso | Motivo |
|---|---|
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 ricerca | Archiviano o trasmettono il diario |
POST /v1/chat/completions (§5.19), dopo la sospensione e prima della quota | Il 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/prices | Utilizzo 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 consenso | Motivo |
|---|---|
POST /v1/auth/login, POST /v1/auth/refresh, POST /v1/auth/logout | Accesso e disconnessione |
GET /v1/auth/account | Il 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.
| Stato | Significato |
|---|---|
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 |
401 | Nessun token di accesso valido |
403 | {"error":"account-suspended"} |
404 | L'istanza non richiede alcun consenso |
Quattro regole che un server conforme DEVE rispettare:
- La versione memorizzata è quella dell'istanza, mai quella del chiamante. Il valore
versiondel 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. - L'istante corrisponde all'orologio del server. Un client non invia alcun orario, e nessuno verrebbe letto.
- Idempotente, e vince il primo istante. Una seconda chiamata con la versione già registrata non cambia nulla e risponde con lo stesso
200;atrimane 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. - 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.
| Verbo | Percorso | Note | ||
|---|---|---|---|---|
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 | /shares | Le 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/:granteeAccountId | 204, idempotente. Una eliminazione definitiva; non è presente alcun tombstone. |
Lato beneficiario.
| Verbo | Percorso | Note |
|---|---|---|
GET | /shared | Condivisioni 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/:grantorAccountId | 204, 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 direcoverydel 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
DELETEefficace 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:
{
"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 verificachange-passphrase. L'assenza o un formato non valido generano un400che lo indica; un valore non corrispondente restituisce401 {"error":"current passphrase is incorrect"}e non viene scritto nulla. Una rotazione scrive il verificatore di recupero accettato daPOST /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 (429conRetry-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 un401, 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
accesserefreshdell'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 subaseVersion, esattamente come nel §5.1. Un valore non aggiornato restituisce un409{"currentVersion": n}e non viene scritto assolutamente nulla.newRecoveryAuthHashErecoveryCodesono OBBLIGATORI, e un invio privo di uno dei due genera un400che indica il campo. Una rotazione genera sempre un nuovo codice di recupero, perché il record di chiaverecoverysu cui riesegue il wrap è sigillato sotto una KEK derivata da quel codice; il server sostituisce quindiaccounts.recovery_verifiere 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
recoveryCodee 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 un400che lo indica, e non viene scritto nulla. keyRecordsdeve includere ENTRAMBI i tipi. Un tipo mancante restituisce un400, mai una rotazione parziale silenziosa: inviare solo il wrappassphraselascerebbe il recordrecoverya 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 descrittorerecoverydeve esserenull, un descrittorepassphrasenon deve esserlo). Non esiste unexpectedUpdatedAtper record: l'invio stesso rappresenta l'unità di concorrenza.sharesrappresenta 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 assentesharesrestituisce un400, per il motivo per cui il §5.4 richiede cheexpectedUpdatedAtsia specificato esplicitamente. In una distribuzione priva diSYNC_SHARINGl'elenco deve essere vuoto; un elenco non vuoto restituisce un400, 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; rileggiGET /v1/sync/sharese 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.
| Stato | Corpo |
|---|---|
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:
| Verbo | Percorso | Note |
|---|---|---|
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 | /contributions | Le iscrizioni del collaboratore stesso. Non restituisce mai body. |
DELETE | /contributions/:studyAccountId | Ritiro. 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:
| Verbo | Percorso | Note |
|---|---|---|
GET | /study/contributions | {"pseudonym","contributionVersion","schemaTier","body","createdAt"} per riga. Nessun ID account, mai. |
GET | /study/withdrawals | Pseudonimi 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.
| Stato | Quando |
|---|---|
400 | corpo non valido, schemaTier sconosciuto, contributionVersion assente |
404 | studio sconosciuto, contributo sconosciuto e qualsiasi altro elemento non trovato: un unico percorso di codice |
409 | contributionVersion non strettamente maggiore di quello memorizzato |
413 | il 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.
| Campo | Cosa riceve il provider |
|---|---|
model | il 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_tokens | al 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_tokens | al 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. |
n | 1, quando presente. |
usage, su un upstream OpenRouter | scritto 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 OpenRouter | scritto 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 precedente | rimosso, ad esempio models, route, plugins, web_search_options, prediction, tools. |
provider, su un upstream OpenRouter | riscritto 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:
- 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-keye qualsiasi cosa il prossimo fornitore decida di leggere. - 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).
- 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:
| Limite | Predefinito | limit |
|---|---|---|
parti image_url | 1 | image-parts |
| byte di testo in UTF-8 | 49152 | text-bytes |
| messaggi | 4 | messages |
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:
{ "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:
{
"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 possiede | Concessione | Limite su cui prenotare |
|---|---|---|
allowanceExpiresAt successivo all'istante della richiesta, dailyAiLimit > 0 | finestra a pagamento | dailyAiLimit |
altrimenti freeDailyAiLimit > 0 | concessione gratuita | freeDailyAiLimit |
altrimenti il valore DEFAULT_FREE_DAILY_AI_LIMIT dell'istanza > 0 | concessione gratuita | tale valore predefinito |
altrimenti dailyAiLimit è 0 | nessuno | 403 ai-not-allowed |
altrimenti allowanceExpiresAt è impostato (quindi è trascorso) | nessuno | 403 allowance-expired |
altrimenti trialScans è impostato | periodo di prova per le scansioni | dailyAiLimit |
| altrimenti (un limite, nessuna data, nessuna prova, nessuna concessione gratuita) | nessuno | 403 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:
| Intestazione | Significato |
|---|---|
X-Quota-Used | Unità consumate oggi, inclusa questa |
X-Quota-Limit | Il limite scelto dall'ordine sopra: dailyAiLimit, freeDailyAiLimit o il valore predefinito dell'istanza |
X-Trial-Scans-Left | Scansioni gratuite rimaste dopo questa richiesta, su un account a cui si applica il blocco delle scansioni (sotto). Assente altrimenti |
| Stato | error | Quando |
|---|---|---|
401 | authentication required | Nessun token di accesso, oppure token scaduto o revocato |
403 | ai-not-allowed | L'account non possiede alcuna concessione (l'ordine sopra). Rifiutata prima che qualsiasi dato lasci l'host |
403 | allowance-expired | allowanceExpiresAt è 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 |
403 | trial-scans-spent | Le 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" |
403 | trial-expired | Il 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" |
403 | capability-required | La 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 |
403 | account-suspended | L'account è sospeso (§5.9 usa lo stesso codice) |
403 | health-consent-required | L'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 |
400 | request body must be a JSON object | Il corpo non è un oggetto. L'input non viene mai ripetuto nella risposta |
400 | ai-request-too-large | Il 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 |
400 | feature-header-invalid | X-Openplate-Feature è presente e non è un'etichetta, su un account verificato (sotto). Rifiutata prima che venga scritta qualsiasi riga |
400 | intake-id-invalid | X-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 |
409 | intake-in-flight | Una 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 |
429 | una frase che indica l'istante di ripristino | La quota non può coprire le unità di questa richiesta. Retry-After indica i secondi fino alla prossima mezzanotte UTC |
429 | una frase che indica il limite al minuto | Più di AI_RATE_LIMIT_PER_MINUTE richieste in qualsiasi intervallo scorrevole di 60 s |
503 | ai-instance-ceiling | L'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:
{ "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 restituisce400 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 conCAPABILITY_SCHEMA_MAP(coppieschemaName: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
WHEREfunge 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 con403 trial-scans-spentse 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:
| Esito | Unità giornaliera | Scansione | Perché la scansione differisce, dove differisce |
|---|---|---|---|
| Connessione rifiutata / timeout degli header | rilasciata | rilasciata | |
Upstream 4xx | rilasciata | rilasciata | |
Upstream 5xx | speso | rilasciata | L'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 provider | speso | rilasciata | Gli header sono arrivati, quindi il provider potrebbe fatturare; la persona non ha comunque ricevuto risposta |
2xx a monte, poi il chiamante riaggancia | speso | speso | La risposta stava arrivando |
Upstream 2xx | speso | speso | |
| Un tetto massimo o la quota giornaliera rifiutano dopo la richiesta | non preso, o rilasciato | rilasciata | La 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.
| Esito | Unità | Motivo |
|---|---|---|
| Connessione rifiutata / errore DNS | rilasciata | La richiesta non ha mai lasciato questo host |
| Timeout degli header (ancora nessun byte) | rilasciata | Non ci è arrivato nulla; il nostro limite di tempo è scaduto prima che il provider rispondesse |
Upstream 4xx | rilasciata | Il 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 5xx | speso | Il 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 interrotto | speso | Gli 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 2xx | speso | Ovviamente |
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:
- Il token statico dell'operatore (
ADMIN_TOKEN), che continua a funzionare anche quando ogni account è bloccato. - Un account il cui
roleèadmin, usando il proprio token di accesso. Questo è ciò che mostra la console nell'app anziché in una shell. - 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.
| Endpoint | Azione | ||
|---|---|---|---|
GET /v1/admin/stats | Conteggi 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/budget | Il 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/accounts | Una pagina di AccountView, più total | ||
GET /v1/admin/accounts/expiring | Una pagina di { id, allowanceExpiresAt } per gli account la cui quota scade nel futuro, più total | ||
GET /v1/admin/accounts/:id | Un AccountView | ||
GET /v1/admin/accounts/:id/activity | Ultimo accesso, e una voce per giorno UTC lungo una finestra delimitata | ||
GET /v1/admin/activity | La stessa striscia giorno per giorno per un'intera pagina di account, nell'ordine dell'elenco | ||
PATCH /v1/admin/accounts/:id | role, 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-mail | Avvia il ripristino del §5.12 su iniziativa dell'operatore | ||
DELETE /v1/admin/accounts/:id | Cancella l'account e tutto ciò che vi è collegato | ||
GET /v1/admin/accounts/:id/blob/versions | Ogni 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/invites | Una pagina di inviti in sospeso, più total | ||
POST /v1/admin/invites | Ne 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/resend | Un NUOVO token sulla STESSA riga, e una nuova scadenza | ||
DELETE /v1/admin/invites/:id | Revoca 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/feedback | Una 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/:id | Una segnalazione: i campi dell'elenco, measurements esattamente come inviati dal dispositivo, e consent: { agreedAt, wordingVersion } | ||
GET /v1/admin/feedback/:id/image | I 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/:id | Elimina 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.
{
"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.usedindica quanto è stato conteggiato rispetto aAI_INSTANCE_DAILY_LIMITetrial.usedquanto hanno consumato gli account della prova scansioni. Ciascunlimitè il tetto configurato, oppurenullse assente. Senza un tetto per la prova, le richieste di prova vengono conteggiate anche inpaid.used.upstreamènullquando l'upstream non è OpenRouter. Altrimenti corrisponde alla lettura della chiave diGET /keydi OpenRouter, in dollari:limitUsderemainingUsdsononullper una chiave senza limite, eresetè"daily","weekly","monthly"onullper un limite che non si azzera mai. Una lettura fallita è{"status": "unavailable", "checkedAt": ...}, ecapacityviene 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 aAI_BUDGET_ALERT_FRACTION(valore predefinito 0.2) dilimitUsd, l'operatore riceve un'email per periodo di ripristino aMAIL_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.
PATCHcon{"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_lengthdi 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 onull, restituisce400e 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/accountdell'account non la contiene, l'account non può impostarla (PATCH /v1/auth/accountlegge solodisplayName), e il principal di fatturazione non può né leggerla né scriverla. pnpm core-api accounts set-label <id> "Beta supporter"la imposta epnpm 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.
{
"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ènullper 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.dayscontiene ogni giorno nell'intervallo, in ordine, concount: 0per i giorni privi di riga. Un giorno mancante e un giorno senza attività non devono sembrare uguali a chi legge la striscia.?days=Nrestringe l'intervallo.Ndeve essere un intero pari ad almeno 1, altrimenti la risposta è400. A un intervallo superiore a 90 giorni si risponde con 90, ewindowriporta 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
404di 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.
{
"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 suGET /v1/admin/accounts: stessi valori predefiniti, stesso limite massimo, stesso400con la stessa frase. Questo è il contratto stabilito, non una coincidenza: un chiamante pagina i due endpoint di pari passo e disegna la striscianaccanto alla personan, quindiaccountsqui segue l'ordine restituito dall'elenco per la stessa pagina, etotalè iltotaldi quell'elenco.?days=Nè l'intervallo dell'endpoint precedente, limitato allo stesso modo: un intero pari ad almeno 1 oppure un400, a valori superiori a 90 si risponde con 90, ewindowriporta 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
accountIdedayse nient'altro. L'indirizzo, il nome e la quota appartengono aGET /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.
| Endpoint | L'entità di fatturazione può |
|---|---|
GET /v1/admin/accounts/expiring | Leggere { 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/:id | Leggi { id, allowanceExpiresAt, dailyAiLimit, capabilities } per quell'unico account, dove capabilities è il record dell'account stesso (null indica nessun record) |
PATCH /v1/admin/accounts/:id | Scrivi 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
403con{"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
PATCHche indica qualsiasi altro campo riceve403con{"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 didailyAiLimitsuperiore alBILLING_MAX_DAILY_AI_LIMITdell'istanza (predefinito 1000) generano un403con{"error": "service-scope-value"}, e nulla viene scritto. Le credenziali dell'operatore possono scriverli entrambi.capabilitiesaccetta qualsiasi elenco valido enull, che rimuove il record e quindi non concede mai più del valore predefinito dell'istanza scelto dall'operatore. Un elenco non valido genera il consueto400, per questa credenziale così come per un operatore. - Oltre a questo,
dailyAiLimitviene 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 undeletedAtche 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.
{}→ 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:
- Vengono inoltrati solo
GETePOST. Qualsiasi altro metodo all'interno del sottoalbero restituisce405 {"error":"plans-method-not-allowed"}con un'intestazioneAllow, 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. - Le intestazioni inoltrate vengono COSTRUITE, mai copiate e sovrascritte. Sono esattamente
X-Account-Idricavato dalla sessione convalidata,X-Account-Emailletto dalla riga dell'account,X-Plans-Secretcontenente il segreto condiviso eContent-Typein entrata. Copiare e poi sovrascrivere inoltra i cookie e qualsiasi altra cosa il client successivo decida di inviare. - 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.
- L'identificatore dell'account appartiene alla sessione, e l'indirizzo appartiene alla riga. Un client che invia un proprio
X-Account-IdoX-Account-Emailnon può influenzare ciò che legge il servizio a monte. UnaccountIdscelto 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. - La risposta transita con il suo stato e il suo corpo JSON, e insieme a essa ritorna soltanto
Content-Type. Un402o un409proveniente 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, quindiPOST /v1/plans/orderrisponde a una carta rifiutata con402 {"error":"payment-failed"}e il chiamante rimane al vecchio livello. Un passaggio a un livello inferiore viene programmato per la fine del periodo pagato, ePOST /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), oppure502 {"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. Quel502è la risposta propria del fatturatore e non è uno dei codiciplans-upstream-*del gateway indicati sotto. La rispostaGET /v1/plans/mee la risposta per l'ordine200del passaggio inferiore programmato possono contenerependingTierependingChangeAt, 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:
{
"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:
{
"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:
- Viene inviato solo con
X-Plans-Secret. Non c'è alcun account, quindi non ci sono néX-Account-IdnéX-Account-Email, e nulla della richiesta in ingresso viene trasmesso: né un'intestazione, né la query string. - Un
200viene 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. - 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"}conRetry-Afterin secondi. - Senza un sistema di fatturazione corrisponde al consueto percorso sconosciuto
404, come il resto del sottoalbero, e/healthnon pubblica nulla di nuovo al riguardo: un client che leggeinstance.planssa 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:
- Il server riarrotonda e vincola i valori.
kcalviene arrotondato al multiplo di 50 più vicino e vincolato tra 0 e 5000;proteinviene 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 restituisce400 {"error":"invalid request body"}. - Ogni scrittura include un'intestazione
Idempotency-Key, un uuid, e una richiesta che ne è priva restituisce400 {"error":"idempotency key required"}. La chiave viene conservata 24 ore. Un reinvio entro tale finestra risponde con200 {"duplicate": true}e non modifica nulla, rendendo così sicuro un replay offline o un nuovo tentativo. - I limiti di frequenza sono per account:
POST /v1/pulse/mealePOST /v1/pulse/photouno al minuto ciascuno,POST /v1/pulse/fastinguno ogni 10 minuti. Il superamento del limite restituisce429con un'intestazioneRetry-Afterin secondi e un corpo che non menziona alcun identificatore. - 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
fastingNowconteggia 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). GET /v1/pulse/todayviene 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.- I totali giornalieri sono conservati per 30 giorni.
pulse_dayse 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. - 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:
{ "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 againPATCH /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:
- 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 riceve200e mantiene il giorno del primo rilevamento. replacesindica 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 rispondere201a nessuno all'infinito. Unreplacesuguale aendpointindica che il dispositivo sta specificando il proprio nome e non elimina nulla.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.catchUpMinuteè un minuto del giorno locale, da 0 a 1439, oppurenullper "nessun allineamento su questo dispositivo".nullè l'impostazione predefinita silenziosa.wakeAtè un istante singolo, in formato ISO 8601, oppurenullper 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 unaPATCHlascia il valore invariato.- 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.
- 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.
- Al massimo due push per sottoscrizione ogni giorno UTC. Un terzo push viene ignorato, mai accodato.
- Un
404o un410dal servizio push elimina la riga. Nessun altro errore lo fa: un400, un401, un403, un429e ogni5xxsono temporanei o riguardano il mittente, e ripulire la tabella in base ad essi la svuoterebbe al primo inserimento errato di una chiave. - 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:
- 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.come*.push.services.mozilla.com,web.push.apple.come*.push.apple.com,*.notify.windows.com, più ogni host elencato da chi gestisce il server inPUSH_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. - 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.
- Un recapito si arrende dopo 10 secondi, e il tick invia a 8 endpoint alla volta, così un endpoint lento non rallenta nessun altro.
- Un recapito che fallisce con un valore diverso da
404o410applica 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. - 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:
- Tutti i campi tranne
imagesono obbligatori.idempotencyKeyva da 1 a 128 caratteri dopo il troncamento degli spazi,consent.wordingVersionva da 1 a 64 econsent.agreedAtè un istante temporale. Viene memorizzato come orologio proprio del dispositivo e mai corretto. IlcreatedAtdel 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 produce400 {"error":"invalid request body"}, una frase per ogni campo. imageè facoltativo, e l'assenza non costituisce un errore. Una chiave mancante onullindica 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/pngoimage/webp. Non è maiimage/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 produce413, mentre se è vuota o di un altro tipo produce400.- 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 produce413 {"error":"request body exceeds the maximum accepted size"}. - La chiave di idempotenza rende sicuro un nuovo tentativo. È univoco per account. Un secondo invio con una chiave esistente risponde con
200restituendo la segnalazione memorizzata invece di201. 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. - Un limite giornaliero per account,
FEEDBACK_DAILY_LIMIT, 5 per impostazione predefinita, conteggiati per giorno UTC nella stessa transazione dell'inserimento. Se superato restituisce429 {"error":"daily limit reached: 5 reports per day for this account"}, che non menziona alcun identificatore. - 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.
- 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:
protocolVersiondeve essere uguale a quello del client. Non "≥", non "più o meno compatibile".envelopeVersiondeve essere uguale a quello del client.- 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.
- 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_VERSIONcopre 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_VERSIONcopre 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à
| Limite | Valore | Applicato da |
|---|---|---|
| Dimensione massima del blob | 2 MiB (MAX_BLOB_BYTES) | Servizio (413), replicato lato client per un errore migliore |
| Versioni del blob conservate | Tre livelli, vedi sotto | Servizio, ripulito dopo ogni scrittura accettata |
| Record di chiavi per account | 2 (uno per kind) | Servizio |
La conservazione è a livelli (M224). Una versione viene conservata se QUALSIASI livello la conserva:
| Livello | Regola | Limite massimo |
|---|---|---|
| Recenti | Le versioni più recenti (BLOB_VERSION_RETENTION) | 5 |
| Giornaliero | La versione più recente di ogni giorno del calendario UTC per BLOB_DAILY_RETENTION_DAYS | 14 |
| Blocchi prima della riduzione | Versioni sostituite da una riduzione consistente confermata, per BLOB_PRE_SHRINK_PIN_DAYS, dalla più recente fino a BLOB_PRE_SHRINK_PIN_LIMIT | 14 |
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,envelopeVersione 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 diSERVER_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/:idritira 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 conTRIAL_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, aLEGAL_DECLARATION_RECEIPTS_PER_NETWORK_PER_DAY(10 per impostazione predefinita) per rete mittente e aLEGAL_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 il202resta 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_RESEARCHimpostato (§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_SHARINGimpostato (§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 impostaSYNC_SHARINGnon memorizza alcun grafo simile e non ha alcuna tabella in cui inserirlo.
- Stime segnalate, su una distribuzione con
SYNC_FEEDBACKimpostato (§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 perinstance.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:
- I quattro endpoint da §5.1 a §5.4 più l'handshake
/healthdi §5.6. Il §5.5 è stato rimosso; un server non deve offrire l'eliminazione di un record di chiavi basata solo su bearer. - CAS per account su
blobVersion: atomico. L'implementazione di riferimento usa un indiceUNIQUE (accountId, blobVersion)e tratta una violazione di univocità come un conflitto, invece di bloccare le righe; questo rimane corretto sottoREAD COMMITTEDed è più semplice diSELECT ... FOR UPDATE. Qualsiasi meccanismo con la stessa garanzia va bene; una lettura seguita da scrittura senza atomicità è non. - CAS per account e tipo sui record di chiavi tramite
expectedUpdatedAt, con la stessa regola "il campo assente è un400", e un controllo della passphrase (currentAuthHash) a ogni sovrascrittura. - 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.
- Memorizzazione byte per byte di
ciphertextewrappedDek. 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:
- 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. - Memorizzare entrambi i verificatori come hash con chiave di
authHasherecoveryAuthHashinviati, protetti da un segreto conservato fuori dal database. Mai il valore inviato in quanto tale, e mai in chiaro. - 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.
- Prendere l'indirizzo dell'account da INVITE durante la registrazione e mai dal corpo della richiesta (§5.8), e limitare
recover,recover-rotateereset/requestsu 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. - Rispondere
202a ognireset/requestdopo un carico di lavoro identico, e fare in modo chereset/opennon 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. - Rifiutare un account sospeso all'accesso, al rinnovo e su ogni rotta bearer con
403 {"error":"account-suspended"}, esattamente questa stringa. - Propagare a cascata l'eliminazione dell'account a blob, record di chiavi, token di ripristino e righe di utilizzo.
- 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
409nel terzo caso consegna a ogni membro un oracolo per scoprire chi altri si trova sull'istanza, e un server che risponde500quando 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 consueto404per percorso sconosciuto e segnalainstance.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:v1e mai inviareKEK_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/openha restituito il codice di recupero, esegui con esso il NORMALErecover-rotatedel §5.14: una nuova passphrase, un recordpassphrasericifrato, un nuovo codice, un recordrecoveryricifrato e il nuovorecoveryCodeper la custodia. Interrompersi a metà lascia un account la cui custodia non corrisponde più al suo verificatore. - Tratta
404daGET /blobcome "nuovo account", non come un errore. - Invia
authHash(il ramo HKDFauthdel §3.1) e mai la passphrase, l'output di Argon2id oKEK_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 secondo401, indirizza l'utente all'accesso invece di entrare in un ciclo.