Aller au contenu
openplate

Sur cette page

Le serveur central

Protocole de synchronisation d'openplate

Le protocole réseau et de gestion des clés, version 2

Cette page est traduite automatiquement à partir de la documentation en anglais.

Version du protocole : 2 · Version de l'enveloppe : 1 · Statut : pré-1.0, aucune version publiée

Voici la spécification normative du protocole réseau entre un client openplate et un serveur central. Elle est rédigée pour qu'un tiers puisse implémenter l'un ou l'autre côté sans lire notre code : concevoir un client alternatif qui se synchronise avec notre service hébergé, ou développer un serveur alternatif vers lequel un client openplate peut être orienté avec CORE_URL.

L'équivalent lisible par machine se trouve dans deux fichiers dupliqués et maintenus à la main :

DépôtFichier
openplate-coresrc/protocol.ts
openplateapp/lib/sync/engine/protocol.ts

Chaque dépôt possède un test unitaire qui valide ses constantes par rapport à des valeurs littérales retranscrites (tests/unit/protocol.test.ts et tests/unit/sync-engine/protocol.test.ts). Il n'y a pas de CI partagée entre les dépôts, ces tests sont donc le seul rempart contre une divergence silencieuse du protocole. Ce document fait foi ; le code TypeScript en est la retranscription.

1. Résumé en un paragraphe

Le client détient toutes les clés. Il sérialise l'intégralité de son stockage local, le compresse avec gzip, le chiffre en AES-256-GCM avec une clé que le serveur n'a jamais vue, et envoie le résultat sous la forme d'un blob opaque. Le serveur stocke les octets, gère leurs versions, et refuse les écritures qui écraseraient celles d'un autre appareil. Il stocke aussi deux petits enregistrements de clés (la même clé de chiffrement des données enveloppée sous deux clés de chiffrement de clés différentes, l'une dérivée de la phrase de passe de l'utilisateur et l'autre d'un code de récupération) pour qu'un second appareil puisse s'initialiser. Aucun chemin de code sur le serveur ne déchiffre quoi que ce soit. Le serveur conserve bien le code de récupération de chaque compte, scellé sous un secret qui lui est propre (§3.1), si bien que l'opérateur d'une instance gérée détient ce qu'il faut pour ouvrir un journal. Le §9 détaille exactement ce que le serveur sait.

Le schéma ci-dessous représente une session complète. La négociation de version s'exécute en premier, et elle n'est pas consultative : en cas de non-concordance, ou face à un service inaccessible, le client s'arrête là plutôt que d'envoyer une enveloppe que l'autre côté pourrait interpréter différemment. Le §6 énonce cette règle, les §5.7 à §5.9 décrivent la connexion qu'elle protège, et le §5.1 détaille l'envoi, y compris l'échec d'un compare-and-swap que le client doit savoir rattraper.

Une session, dans l'ordre : la négociation de version, qui refuse la synchronisation au moindre désaccord, puis la connexion, puis un envoi avec compare-and-swap.
Source du schéma
sequenceDiagram
    participant C as Client
    participant S as Core server
    C->>S: GET /health
    S-->>C: protocolVersion, envelopeVersion
    alt versions differ, or unreachable
        C->>C: refuse to sync
        Note over C: no push, no pull, no retry
    else versions equal
        C->>S: POST /v1/auth/kdf
        S-->>C: salt, Argon2id params
        C->>C: derive authHash, derive KEK
        C->>S: POST /v1/auth/login
        S-->>C: access token, refresh token
        C->>C: encrypt snapshot under DEK
        C->>S: POST /blob, baseVersion 3
        alt baseVersion matches
            S-->>C: 200, newVersion 4
        else another device wrote first
            S-->>C: 409, currentVersion 5
            C->>S: GET /blob
            C->>C: decrypt, merge, re-encrypt
            C->>S: POST /blob, baseVersion 5
            S-->>C: 200, newVersion 6
        end
    end

2. Terminologie

TermeSignification
DEKClé de chiffrement des données (Data-encryption key). 32 octets aléatoires. Chiffre le blob. Ne quitte jamais le client sans être encapsulée.
KEKClé de chiffrement de clé (Key-encryption key). Encapsule la DEK. Il en existe deux : dérivée de la phrase de passe et dérivée du code de récupération.
EnveloppeLe format réseau du blob chiffré : iv ‖ AES-256-GCM(gzip(JSON(payload))).
Enregistrement de cléUne DEK enveloppée, ainsi que (pour le type passphrase uniquement) les paramètres KDF nécessaires pour dériver à nouveau sa KEK.
blobVersionCompteur monotone par compte. Le jeton de compare-and-swap.
CompteL'unité d'isolation. Un compte possède au maximum un blob actuel et au maximum deux enregistrements de clé.
E-mailL'identifiant du compte : une adresse canonique (NFKC, nettoyée, en minuscules). Unique par serveur.
InvitationUne capacité à usage unique ADRESSÉE à un e-mail, générée par un opérateur. Le seul moyen d'entrer.
SéquestreLe code de récupération du compte, scellé sur le serveur sous une sous-clé de SERVER_SECRET.
Rôleadmin ou member. Le jeton d'accès propre d'un administrateur authentifie /v1/admin.

Le protocole 2 a remplacé l'identifiant par un e-mail (ADR-0005). Le Handle de la version 1, un identifiant opaque par serveur qui ne pouvait pas contenir d'@, a disparu : la colonne, l'analyseur et la règle. Un client utilisant la version 1 doit refuser de communiquer avec un service en version 2 plutôt que de fonctionner à moitié ; voir le §6.

3. Cryptographie (côté client ; le serveur n'en implémente rien)

Un serveur conforme n'a besoin de rien dans cette section ; elle figure ici pour qu'un client alternatif puisse interagir, et pour qu'un relecteur puisse vérifier les affirmations.

3.1 Dérivation de clés

                          ┌─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)
  • Paramètres Argon2id (enregistrés par compte dans le kdfDescriptor de l'enregistrement de clé par passphrase et dans le descripteur KDF propre au compte, afin de pouvoir les augmenter ultérieurement sans casser les comptes existants) : memorySizeKib: 65536 (64 Mio), iterations: 3, parallelism: 1, hashLength: 32. Sel : 16 octets aléatoires.
  • Les Étiquettes info HKDF sont des chaînes d'octets figées, encodées en UTF-8. Elles assurent la séparation de domaine pour que les valeurs dérivées soient cryptographiquement indépendantes : - openplate-sync:passphrase-kek:v1 - openplate-sync:recovery-kek:v1 - openplate-sync:auth:v1 - openplate-sync:recovery-auth:v1
  • La branche auth correspond à ce que le client envoie comme mot de passe. C'est un frère de KEK_p, ni un parent ni un enfant : tous deux sont des sorties HKDF sur le même hachage Argon2id sous différentes étiquettes info, si bien que la possession de l'un ne donne aucune information sur l'autre. C'est précisément pour cela que le serveur peut authentifier un utilisateur pour lequel il ne peut rien déchiffrer. authHash fait 32 octets, en base64 sur le réseau.
  • La branche recovery-auth correspond à ce que le client envoie pour prouver la possession du code de récupération (§5.14). C'est un frère de KEK_r exactement au même sens où authHash est un frère de KEK_p, et il fait 32 octets, en base64 sur le réseau.
  • L'étiquette recovery-auth n'est jamais l'étiquette recovery-kek. Cette séparation des domaines est structurelle, pas cosmétique. La branche KEK dérive la clé qui ouvre le journal ; si la même sortie était aussi envoyée au serveur, ce service stockerait un HMAC de l'élément qui déballe une DEK, et « l'opérateur ne peut pas lire tes données » (une affirmation qui ne tient que tant que l'opérateur n'a pas le code de récupération séquestré, §9.1) reposerait sur le fait que SHA-256 est à sens unique plutôt que sur le fait que l'opérateur n'a jamais détenu la valeur. Les deux étiquettes sont figées, aucune n'est dérivée de l'autre, et toute modification ultérieure de l'une d'elles constituera une nouvelle étiquette :v2 plutôt qu'une redéfinition (ADR-0004).
  • Le serveur ne stocke pas non plus authHash ou recoveryAuthHash. Il stocke HMAC-SHA-256(serverPepper, ...) de chacun, le poivre étant conservé hors de la base de données. Voir §5.8.
  • Le chemin de récupération omet délibérément Argon2id et utilise un sel HKDF vide. C'est intentionnel et non un oubli, la RFC 5869 §3.1 l'autorise lorsque le matériel de clé en entrée possède déjà une entropie élevée, ce qui est le cas par construction pour un code aléatoire de 160 bits. Seules les phrases de passe humaines à faible entropie nécessitent un étirement dur en mémoire et un véritable sel.
  • Code de récupération : 20 octets aléatoires (160 bits), représentés dans un alphabet base32 de style Crockford (0123456789ABCDEFGHJKMNPQRSTVWXYZ, sans O, I, L pour survivre à la transcription) par groupes de 5. Canoniquement, 32 caractères sans les séparateurs et en majuscules, c'est la forme que le serveur scelle.
  • Le code de récupération est SÉQUESTRE sur le serveur (protocole 2, ADR-0005). Le client ne l'affiche plus à la personne, il envoie le code brut une seule fois dans le corps de l'inscription, et le serveur stocke iv(12) ‖ AES-256-GCM(escrowKey, code) ‖ tag(16) dans accounts.recovery_code_escrow, où escrowKey est une troisième sous-clé HMAC figée de SERVER_SECRET (openplate-sync:escrow-key:v1, aux côtés du poivre du vérificateur et de la clé de descripteur factice). Une réinitialisation par e-mail (§5.12) transmet à nouveau le code au titulaire du compte, qui effectue ensuite avec lui la rotation ordinaire du §5.14. L'administrateur d'une instance gérée détient donc ce qu'il faut pour ouvrir un journal. C'est un réel changement dans la nature de ce service, il est indiqué ici plutôt que dissimulé, et il est pleinement justifié dans docs/adr/0005-organization-accounts-and-escrowed-recovery.md.
  • Le séquestre porte sur le CODE, non sur KEK_r ni sur la DEK. Rien sur le serveur ne dérive une KEK, ne déchiffre une DEK ou n'en conserve une, le code ne devient une clé qu'après le passage de HKDF par un client. Cela n'apporte aucun secret face à l'administrateur, qui peut aussi exécuter HKDF, cela garantit un serveur dont le chemin d'exécution de code ne contient aucun déchiffrement des données utilisateur, ce qui rend l'affirmation vérifiable plutôt que promise.
  • Les KEK sont des clés AES-GCM de 256 bits, importées comme non extractibles.

3.2 L'enveloppe

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 octets aléatoires, renouvelés à chaque chiffrement, intégrés comme les premiers octets de ciphertext. Il n'y a aucun champ IV distinct dans ce protocole.
  • Étiquette : l'étiquette d'authentification GCM de 16 octets est ajoutée à la fin du texte chiffré (convention de WebCrypto).
  • AAD est l'encodage UTF-8 d'un objet JSON canonique à ordre de clés fixe :
    json
    {"accountId":<int>,"blobVersion":<int>,"payloadSchemaVersion":<int>}

    Lier ces éléments neutralise le couper-coller (rejouer un blob sur un compte différent) et le retour arrière (rejouer une version plus ancienne, ou une charge utile issue d'un schéma de stockage local incompatible). Un client doit présenter le même triplet lors du déchiffrement, sinon la vérification d'étiquette échoue, ce qui constitue le comportement attendu et non une erreur à contourner.

  • Compression (gzip, RFC 1952) est appliqué au texte en clair avant le chiffrement. Le texte chiffré est incompressible, donc la compression se fait d'abord ou pas du tout. Voir le §8 pour comprendre l'importance de ce choix et le §9.2 pour une description honnête des fuites qu'il induit.
  • Forme de Charge utile (tout ce qui se trouve dans snapshot est opaque pour ce protocole) :
    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 enveloppée : iv ‖ AES-256-GCM(key=KEK, plaintext=DEK), aucun AAD : une DEK enveloppée n'est liée à aucune version particulière de blob. La longueur est toujours de 12 + 32 + 16 = 60 octets.

3.3 Sémantique de fusion (côté client)

Les conflits sont résolus par entité selon (lamport, deviceId) : le compteur de Lamport le plus élevé l'emporte, les ex æquo sont départagés par l'ordre lexicographique de deviceId. L'horloge interne de l'appareil n'est explicitement pas une autorité d'ordonnancement, elle dérive et est systématiquement fausse d'un appareil à l'autre. Une pierre tombale participe à la même comparaison qu'une valeur active. Compromis retenu pour la v1 : la dernière écriture de l'enregistrement entier l'emporte, donc une modification hors ligne concurrente de l'entité même sur deux appareils supprime silencieusement l'écriture la plus ancienne. Pas de fusion au niveau des champs, pas d'interface de conflit.

3.4 L'enveloppe de partage (ADR-0002)

Un partage est un troisième chiffrement de la même DEK, destiné à la clé publique d'un autre compte. Le serveur le stocke, le sert au seul compte auquel il s'adresse, et ne détient aucune clé pour le lire ; le §9.1 n'est pas modifié par cette fonctionnalité.

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 longueur est de 125 octets, toujours. Note qu'il s'agit d'un invariant différent de la DEK enveloppée de 60 octets du §3.2 : 60 pour un enregistrement de clé, 125 pour un partage. Ils résident dans des tables différentes et aucun chemin de validation commun ne bifurque sur la longueur.
  • P-256, et la courbe est nommée dans le libellé plutôt que seulement dans la version, de sorte qu'une future construction soit un nouveau libellé au lieu d'une ambiguïté sur :v1.
  • Le sel HKDF vide est correct, pour les mêmes raisons tirées du §3.1 de la RFC 5869 que le §3.1 consigne déjà pour le code de récupération : l'IKM est une sortie ECDH fraîche et à haute entropie, pas un secret humain nécessitant un étirement à mémoire coûteuse.
  • Cette enveloppe transporte des AAD ; les DEK enveloppées du §3.2 n'en ont pas. L'enveloppe d'un enregistrement de clé est délimitée par une ligne réservée au propriétaire et ne peut pas être confondue avec celle de quelqu'un d'autre. Une enveloppe de partage se trouve dans une table d'association contrôlée par le serveur, où ce risque existerait : la lier garantit qu'une ligne manipulée échoue au contrôle de son étiquette plutôt que de se déchiffrer dans le mauvais journal.
  • Les AAD lient l'empreinte de la clé du destinataire, et non l'identifiant de compte du bénéficiaire. La substitution vise la clé, c'est donc la clé que la liaison désigne, et le bénéficiaire reconstruit les AAD à partir d'une empreinte calculée localement, pour qu'aucune valeur fournie par le serveur n'entre dans la chaîne de confiance.
  • recipientKeyFingerprint est SHA-256 de la clé publique brute non compressée. Le serveur le stocke en tant que métadonnée d'épinglage et ne jamais n'approuve, ne sert ni ne génère de clé publique ; la clé épinglée qui fait foi réside dans l'instantané chiffré du concédant lui-même.

Un bénéficiaire doit procéder à un déchiffrement d'essai. Les AAD du blob au §3.2 lient payloadSchemaVersion, que le §7 définit comme un entier opaque qui n'apparaît jamais sur le réseau. Le propriétaire connaît le sien ; un bénéficiaire ne connaît pas celui du concédant. Le bénéficiaire tente donc de déchiffrer avec les versions de schéma supportées par son binaire et retient celle dont l'étiquette GCM est valide. C'est peu coûteux et c'est le comportement prévu ; n'ajoute pas de champ de version de schéma en clair pour résoudre cela.

3.5 L'enveloppe de contribution à la recherche (ADR-0003)

Une contribution est une tranche réduite du journal, bornée par des dates, chiffrée avec la clé publique d'une étude. C'est un artefact distinct d'un partage, et non une version plus restreinte : charge utile différente, clé différente, cycle de vie différent, et aucune DEK n'intervient ; l'enveloppe s'applique directement sur la charge utile.

Le pseudonyme. Une racine aléatoire de 256 bits par compte réside dans l'espace privé du propriétaire, elle survit donc à une restauration de secours et se transmet à un second appareil.

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

Les octets sont fixes, car une concaténation mal spécifiée donne deux implémentations qui divergent au sein d'un même déploiement. Le libellé correspond à ses octets UTF-8 sans terminateur ; studyAccountId est 8 octets, non signés, gros-boutiste, toujours huit, jamais son texte décimal et jamais un encodage de longueur minimale. Le résultat est formé par les 16 premiers octets du MAC dans l'alphabet Crockford base32 0123456789ABCDEFGHJKMNPQRSTVWXYZ (sans symbole de contrôle, sans traits d'union), soit exactement 26 caractères majuscules. Un client qui dérive sur les chiffres ASCII de l'identifiant produit un pseudonyme bien formé qui ne correspond à rien.

Stable à travers les soumissions d'un même contributeur, impossible à relier d'une étude à l'autre (les sorties HMAC sur des messages différents sont indépendantes), et impossible à dériver pour quiconque détient à la fois la table des comptes et une cohorte. H(accountId ‖ studyId) n'aurait pas cette dernière propriété : avec des entrées publiques, il s'inverse par énumération.

Le pseudonyme protège contre le chercheur, pas contre le serveur. Le serveur authentifie l'envoi par jeton porteur et connaît donc de toute façon le compte derrière chaque ligne ; voir §9.2.

L'enveloppe.

  (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)

Une nouvelle étiquette figée plutôt qu'une version de l'étiquette de partage, but différent, même raisonnement qui a mis la courbe dans le nom.

L'AAD ne contient aucun identifiant de compte, tout comme aucune réponse côté étude. C'est l'inversion délibérée du §5.16, où grantorAccountId est requis car l'AAD du §3.2 le lie. Ici, chaque champ de l'AAD peut être reconstruit par le chercheur avant le déchiffrement, quatre figurent dans la réponse, et l'empreinte qu'elle calcule localement à partir de sa propre clé.

La charge utile est un niveau fixe, sélectionné par nom. Une étude choisit un niveau et une fenêtre, elle ne fournit jamais de liste de champs. La v1 en définit un,

daily-intake:v1, une ligne par jour civil dans la fenêtre, avec date (précision au jour, pas d'horodatages), energyKcal, proteinG, carbsG, fatG, fiberG, loggedEntryCount. Ce décompte existe car un chercheur ne peut sinon distinguer "n'a rien mangé" de "n'a rien saisi", il s'agit d'un décompte, jamais des entrées.

Un nouveau champ constitue une révision du protocole, jamais une configuration. Voir ADR-0003.

4. Conventions de transport

  • Tous les corps de requête et de réponse sont en application/json.
  • Les champs binaires (ciphertext, wrappedDek) sont des chaînes base64 (alphabet standard, avec remplissage). Ils ne sont pas envoyés avec un type de contenu binaire, délibérément, chaque champ de chaque requête doit pouvoir être lu par un auto-hébergeur qui débogue sa propre instance.
  • Les horodatages sont des chaînes UTC ISO-8601, par exemple 2026-08-04T10:11:12.000Z.
  • Un code de récupération sur le réseau est au format TEXTE base32 de Crockford, où qu'il apparaisse (signup.recoveryCode, recover-rotate.recoveryCode, rotate-dek.recoveryCode, et dans la réponse de reset/open). Un serveur DOIT l'accepter groupé ou non groupé et, dans les deux cas, le canoniser en 32 caractères en majuscules sans espaces ni traits d'union, sceller CELA, et renvoyer cette même forme canonique depuis reset/open. Un code a donc une seule forme scellée, si bien qu'un nouveau séquestre après une rotation reste comparable à ce qui s'y trouvait auparavant, et un client qui affiche le code par groupes de cinq peut renvoyer ce qu'il a affiché. Un client conforme accepte aussi les deux formes.
  • Tout corps de réponse non-2xx est en {"error": "<human-readable text>"}. Le texte ne sert qu'au diagnostic, les clients doivent faire leurs branchements sur le code d'état, jamais sur le message.
  • Les requêtes dépassant la limite de taille du corps sont rejetées avec 413. Chaque famille de routes sous /v1/sync possède sa propre limite, et aucune famille n'hérite de celle d'une autre : les enregistrements de blobs et de clés acceptent le plafond de blob en base64 plus 4 Kio, rotate-dek le plafond de blob en base64 plus 64 Kio, la famille de partage 8 Kio et la famille de recherche 512 Kio.
  • Une route authentifiée vérifie le jeton porteur avant de lire le corps. Un client sans jeton valide reçoit 401, jamais 413, quelle que soit la taille du corps.
  • Une adresse source est une adresse IPv4 ou un /64 IPv6. Chaque limiteur que ce document qualifie par IP ou par adresse source décompte un appelant IPv6 d'après les 64 premiers bits de son adresse, car une connexion résidentielle dispose d'un /64 entier. Une adresse IPv6 mappée en IPv4 (::ffff:a.b.c.d) compte comme l'adresse IPv4 qu'elle transporte. Une adresse IPv4 compte pour elle-même. Une requête dont le serveur ne peut déterminer l'adresse partage un unique panier avec toutes les autres requêtes dans ce cas.

4.1 Authentification

Un jeton porteur dans un en-tête Authorization: Bearer <token>. Pas de cookies, dans un sens comme dans l'autre.

  • Access-Control-Allow-Origin: *, et Access-Control-Allow-Credentials n'est jamais envoyé. Tout client openplate (le nôtre, celui d'un auto-hébergeur sur son propre domaine, ou une implémentation tierce) peut donc communiquer avec n'importe quelle instance de ce service, quelle que soit son origine.
  • Cette combinaison est sûre précisément parce qu' il n'y a pas d'identifiant ambiant. Une page hostile peut émettre une requête cross-origin et recevra un 401, car le navigateur n'a rien à joindre automatiquement. C'est la propriété anti-CSRF qui manque aux cookies, et c'est la raison pour laquelle cette origine totalement ouverte est un choix réfléchi plutôt qu'un raccourci.
  • Les appelants non authentifiés reçoivent 401. Les appelants authentifiés mais non autorisés reçoivent 403. Un serveur conforme ne doit pas les confondre.
  • Deux 403 portent un code machine fixe sur lequel le client se branche : account-suspended sur chaque route avec jeton porteur, et health-consent-required sur chaque route de données que le §5.15.1 n'indique pas comme ouverte. Ils font exception à la règle « se brancher sur le statut, jamais sur le message », car 403 seul ne permet pas de les distinguer entre eux ou d'un refus ordinaire :

    | Statut | Corps | Signification | Ce que fait le client | | ------ | ----- | ------------- | --------------------- | | 401 | tout | Aucun jeton d'accès valide | Rafraîchit une fois, puis demande à la personne de se connecter | | 403 | {"error":"account-suspended"} | Un opérateur a suspendu le compte (§5) | L'indique, se reconnecter n'aidera pas | | 403 | {"error":"health-consent-required"} | L'instance demande un consentement aux données de santé et le compte ne détient pas sa version actuelle (§5.15.1) | Demande le consentement, puis réessaie | | 403 | tout autre élément | Authentifié, non autorisé pour cette requête précise | Lit la table spécifique du point de terminaison |

  • Chaque en-tête de requête personnalisé qu'une route lit figure dans Access-Control-Allow-Headers : Authorization, Content-Type, Idempotency-Key (§5.23) et X-Intake-Id (§5.19). Chaque en-tête de réponse personnalisé qu'un client lit figure dans Access-Control-Expose-Headers : Retry-After, X-Trial-Scans-Left, X-Quota-Used et X-Quota-Limit. Un navigateur refuse d'envoyer un en-tête absent de la première liste, et masque un en-tête absent de la seconde liste, sans rien consigner dans les journaux.

Cela a remplacé un cookie de session same-origin qui existait lorsque les cœurs des gestionnaires étaient montés au sein de l'application openplate. Cette modification, ainsi que le déplacement des routes de synchronisation de /api/sync vers /v1/sync, sont antérieures à la version 1.0 et n'incrémentent pas PROTOCOL_VERSION, aucun blob de production n'existe, il n'y a aucune implémentation tierce, et aucun client déployé ne peut en être perturbé. Dès que ce document sera publié avec une version publique, cette marge de manœuvre prendra fin, voir §7.

4.2 Cycle de vie des jetons

Deux sortes de jetons, tous deux des chaînes aléatoires opaques, tous deux stockés en uniquement sous forme de condensats SHA-256. Un export de la table des jetons ne livre rien de rejouable, et un SHA-256 non étiré convient parfaitement ici car l'antécédent apporte 256 bits d'aléa, il n'y a pas de dictionnaire à tester.

JetonDurée de vieRôle
access15 minTransmis à chaque requête. Court, car une fuite reste exploitable tant qu'il est valide.
refresh30 joursÉchangé contre une nouvelle paire. Tournant : chaque utilisation le consomme.

Pourquoi une paire opaque plutôt qu'un JWT. La révocation est un pilier de ce protocole : un changement de phrase secrète et un renouvellement du code de récupération doivent invalider chaque session en cours immédiatement, et c'est exactement ce qu'attend une personne qui modifie sa phrase secrète en cas de doute. Sans ajouter une liste de blocage côté serveur, qui revient au même qu'un jeton opaque stocké en base, un jeton sans état peut seulement expirer, jamais être invalidé.

Pourquoi utiliser une paire. Le client ne doit jamais persister la phrase secrète, il ne peut donc pas dériver de nouveau un auth-hash en arrière-plan pour se reconnecter. Un jeton d'actualisation rotatif à longue durée de vie est la seule chose qui rende possible une réauthentification silencieuse dans une architecture où le serveur ne voit jamais la phrase secrète.

Rotation et détection de réutilisation. Chaque paire porte un identifiant de famille qui survit aux rotations.

  • POST /v1/auth/refresh avec un jeton de rafraîchissement valide révoque ce jeton et renvoie une nouvelle paire au sein de la même famille.
  • Présenter un jeton de rafraîchissement déjà révoqué sert de signal de réutilisation : le client légitime l'a renouvelé, donc la personne qui le présente détient une copie indue. La famille entière est révoquée. Cela déconnecte l'attaquant ainsi que le véritable utilisateur, ce qui est le résultat attendu ; l'alternative laisserait une session active à un voleur.
  • C'est la consommation qui tranche, pas la lecture. Deux requêtes portant le même jeton de rafraîchissement peuvent toutes deux le trouver actif. Une seule d'entre elles peut le consommer (une mise à jour conditionnelle de « actif » à « révoqué »), et l'autre est traitée comme un réemploi : 401, ce qui révoque la famille. Ainsi, exactement un rafraîchissement concurrent sur deux avec un même jeton reçoit un 200, et cette paire ne survit pas à la réponse de réemploi de l'autre. Un client doit sérialiser ses propres rafraîchissements (§11) ; un second détenteur qui entre en concurrence avec le premier correspond précisément au scénario justifiant la détection de réemploi.
  • Les jetons d'accès créés lors de rotations antérieures sont délibérément laissés intacts ; ils expirent d'eux-mêmes en quelques minutes, et les révoquer pendant la rotation interromprait une requête légitime en cours.

Déclencheurs de révocation. Chacun de ces événements révoque tous les jetons access et refresh actifs du compte :

  • POST /v1/auth/change-passphrase
  • POST /v1/auth/recover-rotate
  • POST /v1/sync/rotate-dek, sauf la propre famille de l'appelant (§5.17)
  • suspension par un opérateur
  • suppression du compte (par cascade de lignes)

POST /v1/auth/logout révoque une famille (cet appareil) et ne touche pas aux autres sessions du compte.

Les jetons de session sont le seul type présent dans account_tokens. Jusqu'à la version 0.5.0, cette table contenait aussi deux types de jetons LINK à usage unique, créés pour être insérés dans un message : l'un confirmait une adresse, l'autre validait un lien de récupération envoyé par courriel. Les deux ont disparu avec le service d'envoi de courriels, sans retour possible. Le protocole 2 ne comporte aucune confirmation d'adresse (l'invitation fait office de vérification, §5.8) et son lien de réinitialisation ne remplace aucun identifiant (§5.12).

Deux jetons de capacité existent en dehors de cette table, et chacun porte un préfixe pour éviter d'envoyer l'un à la place de l'autre :

JetonPréfixeDurée de vieStocké dansCe qu'il permet
Invitation d'inscriptionsi_7 jsignup_invitesCrée UN compte, à l'adresse indiquée par l'invitation.
Réinitialisation du mot de passesr_60 minpassword_resetsRenvoie une seule fois le code de récupération du compte placé sous séquestre.

Tous deux comportent 256 bits d'aléa, ne sont stockés que sous forme d'empreinte SHA-256 et sont à usage unique. Aucun des deux n'est accepté comme identifiant Authorization: Bearer, et un jeton de session n'est jamais accepté à leur place : le préfixe sert de filtre de forme appliqué avant toute recherche, et son rejet produit le même échec générique qu'un jeton erroné, ce qui n'ajoute aucun oracle.

La définition de La suspension révoque aussi. accounts.suspended_at révoque chaque jeton access et refresh en cours au sein de la même transaction, de sorte qu'une suspension prend effet immédiatement plutôt qu'à l'expiration du jeton d'accès actuel.

5. Points de terminaison

Deux familles, sous un même espace de noms versionné :

FamillePréfixeAuth
Synchronisation (§5.1 à §5.5)/v1/sync (SYNC_API_PREFIX)Bearer, toujours
Négociation (§5.6)/healthAucune
Compte (§5.7 à §5.15)/v1/authMixte : précisé par point de terminaison

Un compte suspendu est refusé partout. POST /login, POST /refresh, POST /recover, POST /recover-rotate, chaque route protégée par Bearer ainsi que l'arborescence admin renvoient 403 {"error":"account-suspended"}, cette chaîne exacte, afin qu'un client puisse l'identifier et indiquer ce qui s'est passé. Sur login et les chemins de récupération, la vérification s'exécute APRÈS la validation de l'identifiant, si bien qu'une adresse inconnue reçoit toujours le code standard indiscernable 401.

Un compte sans le consentement de l'instance se voit refuser l'accès à toutes les routes de données. Lorsque instance.healthConsent est non-null (§5.6), un compte qui ne détient pas exactement cette version reçoit 403 {"error":"health-consent-required"} sur chaque route qui stocke, envoie ou consomme quelque chose pour lui, et conserve les routes dont il a besoin pour accepter, partir et relire sa propre copie. Le §5.15.1 liste les deux. La suspension étant vérifiée en premier, un compte suspendu reçoit account-suspended.

Les chemins indiqués aux §5.1 à §5.5 sont relatifs à SYNC_API_PREFIX ; tout le reste est absolu.

5.1 POST /blob : push (comparer et échanger)

Requête :

json
{ "baseVersion": 3, "envelopeVersion": 1, "ciphertext": "<base64>", "shrinkAcknowledged": false }
  • baseVersion : la blobVersion que le client pense être actuellement enregistrée. 0 affirme que « ce compte n'a pas encore de blob ».
  • L'écriture est acceptée uniquement si baseVersion est égale à la version actuelle du compte. C'est tout le modèle de concurrence. Il n'y a pas de push forcé ni d'écriture sans If-Match.
  • shrinkAcknowledged : OPTIONNEL, et l'absence équivaut à false. Voir la garde de rétrécissement plus bas.

Réponses :

StatutCorpsSignification
200{"newVersion": 4}Accepté. Le blob est maintenant à la newVersion.
409{"currentVersion": 5}Course perdue. Un autre appareil a écrit en premier.
400{"error": "..."}baseVersion n'est pas un entier positif ou nul, envelopeVersion n'est pas un entier strictement positif, ciphertext est absent, n'est pas du base64 ou est vide, ou shrinkAcknowledged est présent et n'est pas un booléen.
400{"error": "...", "currentSizeBytes": 5310, "nextSizeBytes": 1588}Un rétrécissement important non confirmé. Rien n'a été écrit. Voir plus bas.
413{"error": "..."}Le blob dépasse MAX_BLOB_BYTES.
401/403{"error": "..."}Non authentifié / non autorisé.

La garde de rétrécissement (M224). Un envoi dont le ciphertext décodé est strictement inférieur à la moitié de la size_bytes de la version stockée (BLOB_SHRINK_ACK_RATIO) est REFUSÉ avec 400 sauf si la requête contient "shrinkAcknowledged": true. Un compte sans blob n'est jamais refusé, un premier envoi ne constitue pas une suppression.

Il s'agit d'une CONFIRMATION, pas d'un verdict. Un client la définit à true précisément lorsqu'il émet des suppressions depuis un état auquel il fait totalement confiance, et un client incapable de l'affirmer omet le champ et reçoit le refus. Le service conserve du texte chiffré et ne peut pas distinguer une suppression délibérée d'un client qui a perdu son stockage local et pense que tout a été supprimé, les octets restent identiques. Il pose donc la question, et un client qui ne répond rien reçoit un refus plutôt qu'un effacement.

Lorsqu'un rétrécissement confirmé EST accepté, la version immédiatement précédente est protégée de la purge pendant BLOB_PRE_SHRINK_PIN_DAYS (§8).

Le CAS est vérifié EN PREMIER : un envoi basé sur un baseVersion obsolète produit le 409 habituel, quelle que soit sa taille, car le rôle de ce client est de récupérer et fusionner, et il ne rétrécit généralement plus après l'avoir fait. La garde ne concerne qu'un envoi qui aurait autrement été accepté.

Le refus est 400 et délibérément PAS 409 : un 409 sur cette route signifie « un autre appareil a écrit avant » et force la boucle de récupération ci-dessous, qui renverrait les mêmes octets. 413 n'a pas non plus été utilisé : la requête n'est pas trop volumineuse.

shrinkAcknowledged est un CHAMP DU CORPS et ne doit jamais devenir un en-tête. Tout nouvel en-tête de requête personnalisé doit figurer dans le Access-Control-Allow-Headers CORS du service, sinon un navigateur lit la requête préliminaire, voit un en-tête qu'il n'a pas le droit d'envoyer, et n'envoie jamais la requête, sans aucune ligne de journal nulle part et sans rien qu'un test hors navigateur puisse déceler. Raisonnement : docs/adr/0009-a-shrinking-blob-is-acknowledged-or-refused.md.

La boucle de récupération après un 409 est un comportement obligatoire du client, pas une optimisation : récupère currentVersion, déchiffre-le, fusionne-le avec l'état local (§3.3), rechiffre-le avec les AAD liées à la nouvelle blobVersion, puis pousse-le à nouveau avec baseVersion: currentVersion. Un client qui traite 409 comme une erreur fatale laissera l'appareil de l'utilisateur désynchronisé en permanence.

5.2 GET /blob : récupération

StatutCorps
200{"blobVersion": 4, "envelopeVersion": 1, "ciphertext": "<base64>", "createdAt": "<iso>"}
404{"error": "..."} : ce compte n'a jamais poussé de blob. Ce n'est pas un état d'erreur, c'est l'état normal d'un compte tout neuf.

5.3 GET /key-records : liste

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

Renvoie {"records": []} pour un compte dont la configuration n'est pas terminée. Au plus un enregistrement par kind.

5.4 PUT /key-records/:kind : création ou rotation (compare-and-swap)

:kind vaut passphrase ou recovery, toute autre valeur donne 400.

Requête :

json
{
  "kdfDescriptor": { "...": "..." } | null,
  "wrappedDek": "<base64>",
  "expectedUpdatedAt": "<iso>" | null,
  "currentAuthHash": "<base64, 32 bytes>"
}
  • expectedUpdatedAt: null affirme « aucun enregistrement de ce type n'existe encore » (première configuration).
  • Toute autre valeur affirme « le dernier enregistrement que j'ai lu avait exactement cette updatedAt » (rotation).
  • La clé doit être présente. Une absence de expectedUpdatedAt donne une 400, délibérément : l'appelant ne doit pas pouvoir contourner la vérification de concurrence en oubliant un champ.
  • Un écrasement prouve la connaissance de la phrase de passe. Lorsque expectedUpdatedAt n'est pas null, currentAuthHash (la branche d'authentification de la phrase de passe actuelle, §3.1) est REQUIS : son absence ou une forme incorrecte produit un 400 qui le nomme, et une valeur ne correspondant pas au compte produit 401 {"error":"current passphrase is incorrect"}, le corps que change-passphrase transmet, sans rien écrire. Une création (null) reste limitée au jeton porteur et ignore le champ : elle remplit un emplacement vide lors de la configuration, et le CAS la refuse dès qu'un enregistrement existe. Remplacer un wrap remplace ce qui déverrouille le compte, et un jeton porteur seul ne doit pas pouvoir le faire.
  • Les tentatives sont limitées par compte, dans un même panier avec change-passphrase, delete et rotate-dek : un compte verrouillé reçoit 429 avec Retry-After sur les quatre, depuis n'importe quelle adresse. Une concordance vide le panier.

Validation, tous les 400 :

  • wrappedDek vide
  • kind: "recovery" avec un kdfDescriptor non nul (le chemin de récupération utilise uniquement HKDF, il n'y a aucun paramètre à enregistrer)
  • kind: "passphrase" avec un kdfDescriptor nul

Réponses :

StatutCorps
200L'enregistrement stocké, avec la même structure qu'une entrée GET /key-records.
400{"error": "..."} : la validation ci-dessus, ou un écrasement sans currentAuthHash bien formé.
401{"error": "current passphrase is incorrect"} : un écrasement dont le currentAuthHash ne correspondait pas.
409`{"currentUpdatedAt": "<iso>" \null}` : l'assertion CAS n'a pas tenu.
429{"error": "..."} avec Retry-After : les tentatives de devinette de phrase de passe de ce compte sont verrouillées.

5.5 DELETE /key-records/:kind : retiré, et non restauré

Retiré en 2026-09. Le chemin répond désormais comme n'importe quel chemin inconnu sous ce préfixe : 401 sans jeton, 403 si le compte n'a pas le consentement de l'instance, et le 404 habituel sinon. Aucun client ne l'appelait, et la suppression du seul enregistrement de clé restant rendait définitivement indéchiffrable tout blob stocké avec un simple jeton porteur.

Un enregistrement de clé est remplacé via le §5.4, qui prouve la phrase de passe, ou via une rotation (§5.14, §5.17), et n'est supprimé qu'avec le compte (§5.15).

Un partage (§5.16) ne compte pas comme un enregistrement de clé. Du point de vue cryptographique, c'est un troisième chiffrement de la même DEK, mais c'est le droit d'accès d'une autre personne, qu'elle peut révoquer, que tu ne peux pas vérifier, et qui dépend de sa coopération continue ainsi que de son honnêteté. Aucun client ne doit jamais proposer « récupérer ses données via son diététicien » comme méthode de récupération.

5.6 GET /health : négociation de version

Non authentifié, délibérément : un client doit pouvoir découvrir qu'il est incompatible avant de posséder des identifiants, et une vérification d'état qui exigerait un jeton renseignerait sur le jeton.

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

instance décrit ce qu'est ce déploiement et ce qu'il sait faire, et il est optionnel : un service antérieur au champ l'omet, et un client qui l'exigerait refuserait de communiquer avec chacune de ces instances. name est le libellé donné par l'opérateur à l'instance, language est l'un parmi en, de, fr, it, es, tr (les six langues dans lesquelles ses e-mails sont rédigés ; un client l'affiche sans jamais créer de branche conditionnelle dessus, donc une septième langue ne change pas le protocole), mail indique s'il peut envoyer un courrier électronique, memberInvites indique si un membre ordinaire peut inviter des personnes ici (§5.21), openSignup indique si une personne peut demander un compte ici (§5.8.3), signupCaptcha précise ce que cette demande requiert, trial garantit les analyses gratuites attribuées à un nouveau compte (§5.19), plans indique si un système de facturation est adossé à cette instance pour que /v1/plans/* existe (§5.22), push indique si cette instance peut envoyer des notifications web push pour que /v1/push/* existe (§5.24), healthConsent nomme le consentement aux données de santé qu'elle demande à chaque compte (§5.15.1), nutrientReferenceBasis indique les valeurs de référence des micronutriments affichées, et ai vaut null si aucune clé en amont n'est configurée. ai.model est le modèle du palier par défaut de l'instance (§5.19) : le modèle auquel le proxy transmet une requête, sauf si l'opérateur a routé le schéma de cette requête vers un autre palier. Il vaut null si l'opérateur n'en a défini aucun et que le propre modèle de l'appelant est transmis.

defaultCapabilities est la liste des fonctionnalités (§5.19, « Fonctionnalités ») qu'un compte détient lorsqu'il n'a pas d'enregistrement propre, par exemple ["scan", "recipes"]. Sa valeur est toujours présent : null signifie que cette instance ne vérifie aucune fonctionnalité, donc chaque fonction est ouverte, ce qu'obtient une instance en Auto-hébergement qui ne définit rien, et [] signifie qu'un compte n'en possède aucune tant qu'un enregistrement ne l'indique pas. Un client qui ne trouve aucune clé (tout service antérieur au champ) l'interprète comme null. Elle est descriptive, jamais une attribution : le proxy décide par requête à partir de l'enregistrement propre au compte et de cette valeur par défaut.

healthConsent est le consentement explicite aux données de santé que cette instance demande à chaque compte, {"version": "<v>"}, ou null lorsqu'elle n'en demande aucun, ce qui est la valeur par défaut en Auto-hébergement. Il est null plutôt qu'absent, comme ai, et un client qui ne trouve aucune clé (tout service antérieur au champ) le lit comme null. Contrairement au reste de ce bloc, le service applique ce champ : tant qu'il est non-null, la création de compte exige le consentement correspondant (§5.8), et chaque route de données refuse un compte qui ne détient pas exactement cette version avec 403 {"error":"health-consent-required"} jusqu'à acceptation au §5.15.1. Un client qui trouve une version demande l'accord avant de synchroniser, et traite ce 403 comme la même question posée plus tard. Un client qui trouve null n'affiche aucune case à cocher de consentement, et le service ne refuse rien pour ce motif.

push suit exactement plans : un booléen qui dit uniquement si une porte existe. false signifie que toute la sous-arborescence /v1/push répond le 404 habituel de chemin inconnu, donc un client n'affiche aucun paramètre de notification. Cela ne dit rien de ce qu'une notification push contient, car une notification push contient un type et rien d'autre (§5.24).

plans est un booléen et non une promesse facultative, ce qui est à dessein le choix inverse de instance.feedback ci-dessous. Ce champ est une promesse sur le sort réservé à une photo, et une instance qui n'a rien à promettre l'omet. Celui-ci ne promet rien : il indique uniquement si un accès existe, ce qui est le même type d'affirmation que font mail et memberInvites, de sorte que false est la réponse honnête à la fois pour une instance sans service de facturation et pour un service construit avant l'existence du champ.

openSignup est un booléen, tout comme memberInvites et plans : il indique seulement si une porte d'accès existe. true signifie que POST /v1/auth/signup-request prend une adresse (§5.8.3) ; false, ainsi qu'un service déployé avant l'apparition du champ, signifie que la route renvoie l'habituel statut 404 pour une route inconnue, et un client affiche son texte d'invitation au lieu d'un formulaire d'inscription. Il est purement descriptif, jamais un droit accordé : la limitation de débit, le captcha, les domaines refusés et la limite d'un courriel par boîte et par jour restent appliqués par le service.

signupCaptcha est présent seulement quand openSignup vaut true et que l'opérateur active un captcha. provider est turnstile aujourd'hui ; siteKey est la clé de site publique de Cloudflare Turnstile, avec laquelle un client affiche le widget et qui n'accorde aucun droit. Le jeton généré par le widget transite via captchaToken dans la requête d'inscription. S'il est absent, la requête n'exige aucun jeton.

trial vaut promesse, tout comme feedback ci-dessous, donc absent plutôt que null sur une instance qui n'exécute pas d'essai d'analyse. scans est le nombre d'analyses IA gratuites qu'un nouveau compte y reçoit (§5.19, « L'essai d'analyse »). days est le nombre de jours après le jour de l'inscription à la fin duquel cet essai se termine même s'il reste des analyses, selon la première échéance (§5.8 précise où tombe cette fin) ; il vaut absent, jamais null, sur une instance dont l'essai n'a pas de date de fin, et un client qui ne trouve aucun days indique les analyses exactement comme avant et NE DOIT PAS mentionner de nombre de jours. Un client qui ne trouve aucun trial NE DOIT PAS indiquer un nombre de scans gratuits. Ces deux chiffres sont ceux qu'écrit chaque porte d'accès à l'essai, publiés à partir des mêmes paramètres (TRIAL_SCANS, TRIAL_DAYS), de sorte que la phrase qu'une personne lit avant de s'inscrire et les limites maintenues par le proxy ne puissent diverger.

memberInvites est descriptif, jamais une autorisation, comme tout le reste de ce bloc. Un client le lit pour décider s'il doit afficher une carte d'invitation, il ne le lit jamais pour décider s'il peut en créer une. false signifie que POST /v1/auth/invites répond le 404 habituel de chemin inconnu, et true laisse toujours la limite sur la durée de vie, la règle de réinvitation et la limitation de débit au service.

Il est descriptif, jamais normatif. mail: true ne garantit pas l'arrivée d'un courriel, et ai rapporte ce que l'opérateur a configuré plutôt que d'accorder un droit ; un compte avec dailyAiLimit: 0 reçoit un 403 quoi qu'indique ce champ.

nutrientReferenceBasis prend la valeur dge, efsa ou us : l'organisme dont chaque client sur cette instance affiche les valeurs de référence nutritionnelles, soit la DGE allemande, l'EFSA européenne ou les chiffres de la NASEM américaine. Il est optionnel, de sorte qu'un service compilé avant l'apparition du champ l'omet et qu'un client qui ne le connaît pas l'ignore. Il est une base par instance, jamais par langue et jamais par personne : la langue et l'organisme de référence sont indépendants, et une valeur par défaut suivant les paramètres régionaux reviendrait en réalité à une base individuelle.

C'est aussi le seul champ de ce bloc qu'un administrateur peut modifier pendant que le service s'exécute. Tout le reste dépend de l'environnement de l'opérateur, figé jusqu'au prochain redéploiement ; cette valeur-ci est stockée, et PATCH /v1/admin/settings (§5.20) la modifie. Un client la lit donc à chaque connexion au lieu de la mettre en cache pendant toute la durée de vie d'une installation. Un service DOIT répondre à ce chemin depuis une copie de la valeur locale au processus et NE DOIT PAS interroger son magasin pour répondre à /health : il s'agit du chemin du test d'état du conteneur, interrogé en continu, et une lecture du magasin à cet endroit transforme un hoquet de la base de données en redémarrage.

instance.feedback est le seul champ ici qui est une promesse plutôt qu'une description, et il fait exception au paragraphe ci-dessus. Une instance qui accepte les estimations signalées conserve une photographie de la nourriture de quelqu'un, et elle publie pendant combien de temps :

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

retentionDays est la valeur sur laquelle s'appuie le balayage de rétention propre au service pour supprimer les données, publiée depuis la même liaison, afin que la phrase affichée par un client à une personne avant qu'elle ne transmette une photographie et la suppression qui suit ne puissent pas diverger.

Le champ est absent, jamais null, sur une instance qui n'accepte aucun signalement. ai: null est une déclaration que chaque instance produit ; ici, il s'agit d'une promesse, et une instance dont la fonctionnalité est désactivée n'en a aucune à faire, elle n'ajoute donc aucune clé et reste indiscernable d'une instance construite avant l'existence de ce champ, tout comme son arborescence /v1/feedback reste indiscernable d'une instance où la fonctionnalité n'a jamais été écrite.

Un client qui ne trouve aucune fenêtre annoncée NE DOIT PAS en mentionner une. Il ne propose aucun signalement, ou une formulation qui ne mentionne aucune durée ; afficher un nombre issu d'une valeur par défaut locale revient à publier une promesse que le service n'a jamais faite, auprès d'une personne qui décide d'envoyer ou non une photographie.

signupMode est disparu dans le protocole 2, tout comme le réglage qu'il décrivait : un compte se crée uniquement en activant une invitation, et openSignup indique si une personne peut en demander une (§5.8). Un service qui publie encore signupMode utilise la version 1.

notice est le message de l'opérateur destiné à tous les clients, et il est optionnel exactement au même titre que instance : une instance sans message à transmettre omet le champ, et un client qui ne le connaît pas l'ignore.

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

text est requis dès que le champ est présent ; url est optionnel et constitue, s'il est présent, une URL https:/http: absolue. Le service limite text à 280 caractères et refuse de démarrer s'il est plus long, car /health est aussi le chemin du HEALTHCHECK du conteneur et fait l'objet d'un sondage continu.

Il s'agit d'un canal pull et rien d'autre. Il ne peut pas savoir qui a lu une annonce : la personne qui ouvre l'application la voit, et celle qui ne l'ouvre pas ne la voit pas. Ce n'est pas un mécanisme de notification et on ne doit pas s'y fier en tant que tel. Le protocole 2 accorde bien au service deux types de courriels qu'il peut envoyer (une invitation et une réinitialisation de mot de passe, §5.8 et §5.12), mais aucun ne sert à autre chose : l'opérateur qui doit annoncer quelque chose à ses utilisateurs gère lui-même cette liste de contacts, en dehors de ce service.

Un client DOIT traiter text et url comme des entrées hostiles. Ils proviennent du serveur vers lequel l'utilisateur pointe. Affiche text sous forme de texte brut et jamais comme du balisage, et n'ouvre url qu'après avoir explicitement vérifié son protocole.

5.7 POST /v1/auth/kdf : descripteur KDF pré-connexion

Non authentifié, limité par IP. Renvoie le sel et les paramètres Argon2id nécessaires à un appareil pour dériver authHash avant de pouvoir se connecter.

POST plutôt que GET, pour ce qui constitue une lecture : un GET place l'adresse dans la ligne de requête, et de là dans les journaux d'accès, les journaux de proxy, les en-têtes Referer et l'historique du navigateur. Un point de terminaison dont l'unique but est de ne pas révéler qui possède un compte ne doit pas disséminer l'identifiant demandé. Cet argument valait déjà pour un identifiant textuel ; avec le retour d'une adresse sur le réseau, c'est ce qui sépare une fuite d'une liste de diffusion.

Requête : {"email": "anna@example.org"} · Réponse 200 :

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

Une adresse inconnue reçoit aussi un descripteur. Il est dérivé de manière déterministe sous la forme HMAC(serverSecret, email) sur l'adresse canonique (§5.8), restant ainsi stable d'une requête à l'autre, de structure identique, et généré par le même chemin de code. Un 400 n'est renvoyé que pour une entrée qui ne ressemble en rien à une adresse. Ni le passage aux pseudonymes dans M181 ni le retour aux adresses dans M192 n'ont modifié une seule ligne de la dérivation : elle traite une chaîne opaque, et les deux en sont une.

Cela compte plus qu'il n'y paraît. Une connexion où le serveur ne voit jamais la phrase secrète exige un point de terminaison non authentifié, indexé par identifiant, qui répond avant l'authentification ; codé naïvement, c'est une liste gratuite, silencieuse et non régulable des adresses qui détiennent un compte. La stabilité est tout aussi structurelle que la forme, un faux jeton aléatoire se distinguerait en posant la question deux fois.

Un serveur conforme NE DOIT PAS renvoyer 404, un corps vide ou une structure différente pour une adresse inconnue. Il doit aussi :

  • Fais le même travail sur les deux branches. Dérive la valeur factice sans condition, y compris pour les comptes existants qui ne l'utiliseront jamais, afin qu'un succès et un échec coûtent la même recherche et le même HMAC. La dériver paresseusement laisse un écart temporel : la réponse ne dit rien, mais le temps nécessaire pour la produire le fait.
  • Dérive-la sur l'adresse canonique, pour qu'on ne puisse pas distinguer deux graphies d'une même adresse inconnue par leurs descripteurs.
  • Limite le débit par adresse source, en renvoyant 429 avec Retry-After. C'est l'autre volet de la même défense : le signal temporel résiduel est statistique, et n'émerge que d'un grand nombre d'échantillons par adresse. Refuser les échantillons permet de le supprimer. Calibrer la limite selon l'adresse soumise serait pire que tout, car sonder de nombreuses adresses est l'attaque, si bien qu'un compartiment par adresse offre un nouveau quota pour chaque adresse que l'attaquant veut tester.

5.8 POST /v1/auth/signup

Non authentifié, limitation de débit par IP. Une invitation reste le seul moyen de créer un compte, sur chaque instance. Sur une instance avec instance.openSignup: true, une personne peut DEMANDER une invitation adressée à elle-même (§5.8.3) ; ce qu'elle reçoit est une invitation ordinaire, activée ici exactement comme une invitation générée par un opérateur. SIGNUP_MODE empêche le démarrage, car il n'y a aucun mode à définir : le seul paramètre est l'existence de la porte d'accès aux requêtes du §5.8.3.

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

healthConsent est requis lorsque instance.healthConsent est non-null et ignoré partout ailleurs (§5.15.1). Son version doit être identique à celui de l'instance, octet par octet. Sans lui, la réponse est 400 {"error":"health-consent-required"} et rien n'est créé ni consommé : l'invitation reste utilisable, la personne coche donc la case et renvoie la requête. La vérification intervient après tous les autres champs, de sorte qu'une invitation mal formée répond toujours le 403 ci-dessous en premier. Un compte créé avec ce champ détient le consentement dès sa première requête, aucune route de données ne le refuse donc ; un client qui crée des comptes sur une telle instance (une console d'étude, un outil d'initialisation) envoie aussi ce champ, sinon son compte se voit refuser chaque route de données (§5.15.1).

Il n'y a pas de champ email, et c'est tout l'intérêt. L'adresse provient de la ligne d'invitation, au sein de la transaction. Un corps ne peut pas revendiquer une boîte aux lettres à laquelle l'opérateur n'a pas écrit, ce qui fait de l'invitation elle-même la vérification de l'adresse : la personne qui a reçu la lettre est celle qui l'utilise, il n'y a donc aucun lien de confirmation et rien à confirmer par la suite. role et dailyAiLimit proviennent de l'invitation pour la même raison : un compte ne réclame jamais son propre statut.

recoveryAuthHash, recoveryCode et les DEUX enregistrements de clés sont requis. Chacun était optionnel dans le protocole 1 et aucun ne l'est à présent :

  • Le client ne montre plus le code de récupération à la personne (§3.1), si bien qu'un compte créé sans séquestre ne pourra jamais être restauré par une réinitialisation, et son propriétaire n'en a jamais été averti.
  • Un enregistrement passphrase permet à la phrase secrète de déchiffrer les données ; sans lui, le compte se connecte et ne lit rien, et le client a déjà supprimé la phrase secrète au moment où il s'en rend compte.
  • Un enregistrement recovery permet de déverrouiller le code sous séquestre ; sans lui, une réinitialisation par e-mail fournit un identifiant qui authentifie et n'ouvre rien, ce qu'on découvre le jour où on en a besoin.

recoveryCode est validé comme du base32 de Crockford de 20 octets (32 caractères une fois les espaces et tirets supprimés et la valeur passée en majuscules) puis normalisé sous cette forme avant d'être scellé. Il n'est jamais journalisé, sous aucune forme, sur aucun chemin.

StatutSignification
201{"account": AccountView, "tokens": {...}} (§5.15). Une session est toujours délivrée, il ne reste rien à confirmer.
400Un authHash, recoveryAuthHash ou recoveryCode au format incorrect ; un descripteur sans sel de 16 octets et avec des paramètres Argon2id strictement positifs ; ou keyRecords sans type. {"error":"health-consent-required"} : l'instance demande un consentement et le corps n'en contient aucun, ou une autre version ; l'invitation n'est PAS consommée.
403{"error":"invite-invalid"} : l'invitation est manquante, mal formée, associée à un autre service, inconnue, expirée, révoquée ou déjà utilisée. Sept cas, une seule réponse.
409Un compte existe déjà pour l'adresse de l'invitation. L'invitation n'est PAS consommée.
429Débit limité. Retry-After en secondes.

Le serveur stocke HMAC-SHA-256(serverPepper, authHash), et la même construction sur recoveryAuthHash, pas une seconde KDF lente sur l'un ou l'autre. Le client a déjà supporté le coût mémoire difficile ; hacher à nouveau côté serveur n'ajouterait aucune résistance aux attaques par force brute (un attaquant détenant le auth-hash a déjà évité Argon2id) tout en créant un déni de service par saturation de connexions où chaque tentative monopolise 64 MiB. L'ajout d'un sel secret (pepper) remplit toujours son rôle : ce sel étant hors de la base de données, une table extraite ne peut être rejouée contre une instance active ni vérifiée hors ligne par déduction.

L'ensemble de la soumission est validé en une seule transaction : l'utilisation de l'invitation, la ligne du compte (avec le consentement, lorsque l'instance en demande un), le séquestre scellé et les deux enregistrements de clé. Chaque état incomplet est une catastrophe distincte que l'utilisateur ne peut voir avant d'essayer de lire son propre journal.

Le 409 est le seul oracle d'énumération de ce protocole, et le protocole 2 l'a rendu quasi insignifiant. Elle n'est accessible qu'à une personne détenant une invitation valide qui a été ADRESSÉE à l'adresse exacte signalée comme occupée, elle ne confirme donc que ce que l'opérateur a écrit sur la lettre. Dans le protocole 1, un détenteur d'invitation pouvait tester des identifiants arbitraires avec une seule invitation ; ce n'est plus possible désormais, car il ne choisit pas l'adresse. Elle ne consomme pas l'invitation, de sorte qu'un opérateur ayant invité quelqu'un deux fois par erreur n'a pas détruit l'invitation active. Raisonnement complet : SECURITY.md.

5.8.1 Invitations

Une invitation est une capacité à usage unique et à durée limitée adressée à une seule personne. Elle transporte l'adresse avec laquelle le compte sera créé, le nom présumé par l'administrateur, le rôle et le quota quotidien pour l'IA. Les jetons inconnus, malformés, manquants, destinés à un autre service, expirés, révoqués ou déjà utilisés produisent tous le MÊME 403 et le même corps, {"error":"invite-invalid"} : les distinguer permettrait à un client de sonder les jetons existants et révélerait qu'un jeton a existé par le passé.

Ce que l'utilisation confère (30-09-2026), déterminé par la ligne de l'invitation, dans cet ordre : une ligne avec un essai d'analyse donne cet essai (ci-dessous) ; une ligne issue d'un membre donne l'attribution de l'accès membre (§5.21), l'essai d'analyse ou la paire de jours, même si l'auteur de l'invitation a supprimé son compte depuis, et aucune IA si l'instance a désactivé les invitations de membres depuis l'envoi de la lettre ; toute autre ligne, création par un opérateur sans essai ou inscription ouverte sur une instance qui n'en propose aucun, donne son quota quotidien comme attribution gratuite permanente du compte (freeDailyAiLimit, §5.15) avec un dailyAiLimit payant de 0. Auparavant, ce dernier cas écrivait un dailyAiLimit sans date, forme pour laquelle le §5.19 n'attribue plus rien.

Un jeton d'invitation commence par si_, et le service refuse tout ce qui ne commence pas ainsi. Le préfixe lie le jeton à ce service et à ce point d'accès. Une personne reçoit une invitation par e-mail, à côté d'un jeton de réinitialisation de mot de passe qui commence par sr_ ; sans les préfixes, ces deux chaînes seraient interchangeables et l'une pourrait être envoyée au mauvais point d'accès. Cette vérification est un filtre de forme avant la recherche, rejeté avec le même statut et le même corps que toute autre mauvaise invitation, ce filtre n'ajoute donc aucun oracle. Les jetons de session ne portent aucun préfixe et restent inchangés.

La création est POST /v1/admin/invites. Une ancienne invitation PENDING pour la même adresse est révoquée par une nouvelle, donc il n'y a jamais plus d'une capacité active par adresse ; une adresse qui possède déjà un compte ne peut pas du tout être invitée (409). La seule exception concerne la porte d'accès aux requêtes du §5.8.3, qui conserve intacte une invitation en attente provenant de l'opérateur ou d'un membre plutôt que de la révoquer à la demande d'un inconnu.

Une invitation peut comporter un essai de scans (trialScans, §5.19) : l'inscription ouverte, une création par un administrateur avec "trial": true et, si l'instance le permet, une invitation de membre écrivent le numéro de l'instance sur la ligne, et l'activation le copie sur le compte. Lorsque l'instance définit également une limite en jours (instance.trial.days), la ligne la porte aussi, et l'activation déclenche le compte à rebours : le jour de l'activation ne compte pas, et le trialEndsAt du compte est minuit local à la fin du dayse jour suivant, dans le fuseau horaire de l'instance (TRIAL_TIME_ZONE, UTC sauf si l'opérateur en a défini un). Une activation le 2026-09-29 dans Europe/Berlin, à 10:00 ou à 23:30, avec quatorze jours, se termine le 2026-10-14 à 00:00 heure de Berlin, 2026-10-13T22:00:00.000Z. Il s'agit d'une règle de calendrier, non d'une durée : lors d'un changement d'heure, le dernier jour se termine toujours à minuit local. Le fuseau horaire est lu lors de l'activation et n'est pas écrit sur la ligne, car il déplace la frontière entre deux jours et jamais le nombre de jours. Une ligne créée avant que la limite de jours n'existe n'en porte aucune, et le compte qu'elle crée n'a pas de date de fin.

Une instance peut aussi autoriser un membre ordinaire à en créer une, selon les conditions de l'instance et sans aucune des divulgations que fait 409 dans ce paragraphe. C'est POST /v1/auth/invites, §5.21.

5.8.2 POST /v1/auth/invite-lookup

Non authentifié, débit limité par IP. Requête {"inviteToken": "si_…"}.

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

Le client l'appelle lorsqu'une personne ouvre le lien dans son e-mail, afin que le formulaire d'inscription puisse AFFICHER l'adresse à laquelle le message a été envoyé au lieu de lui demander de la saisir. C'est tout l'intérêt d'une invitation nominative : elle ne peut pas saisir par erreur sa propre adresse et créer un compte inaccessible.

Il n'affiche rien d'autre. Le rôle et le quota accordés par l'invitation sont délibérément absents : une personne qui ne s'est pas encore inscrite n'a pas à savoir que l'administrateur l'a nommée admin, et un client détenant le lien d'un inconnu a encore moins de raisons de le savoir.

Les jetons inconnus, malformés, destinés à un autre service, expirés, révoqués ou consommés renvoient UN SEUL 404 {"error":"invite-invalid"}, après un travail identique : le jeton est haché et la table est interrogée dans chaque branche. Une recherche valide ne consomme rien, donc une personne qui ouvre le lien deux fois a toujours son invitation.

5.8.3 POST /v1/auth/signup-request : une personne demande un compte

Non authentifié. Présent seulement quand instance.openSignup vaut true ; partout ailleurs, la route renvoie l'habituel statut 404 pour une route inconnue. Une instance a besoin d'un service de courriel configuré pour l'ouvrir, car le courriel sert de vérification d'adresse.

Requête : {"email": "anna@example.org", "captchaToken": "…", "plan": "yearly", "tier": "tier-a", "locale": "de"}. captchaToken est requis quand instance.signupCaptcha est présent, et ignoré sinon. plan, tier et locale sont facultatifs et indiquent ce que la personne a choisi sur l'écran d'inscription avant sa demande ; rien d'autre dans le corps n'est lu.

  • plan est "monthly" ou "yearly". Quand il correspond à l'une de ces valeurs, le lien envoyé par e-mail contient &plan=<key> après l'invitation.
  • tier est l'identifiant du niveau du facturateur auquel le forfait appartient (§5.22). Le service ne connaît aucune liste de niveaux, il évalue donc le forme et rien d'autre : un libellé en minuscules de 1 à 32 caractères, commençant par une lettre, suivie de lettres, de chiffres et de tirets (^[a-z][a-z0-9-]{0,31}$), comparé exactement sans rognage ni conversion de casse. S'il correspond, le lien envoyé par e-mail porte &tier=<id> après &plan= (ou après l'invitation s'il n'y a pas de forfait). Le service ne vérifie pas que le facturateur vend bien ce niveau, ni qu'un forfait l'accompagnait ; c'est un client qui en décide quand il lit le lien.
  • locale correspond à l'une des six langues de l'instance (en, de, fr, it, es, tr), soit la même liste que celle acceptée par le locale push (§5.24). Lorsqu'il s'agit de l'une d'elles, le lien envoyé par e-mail contient &lang=<code>, et le courrier, ou la note destinée au titulaire du compte, est rédigé dans cette langue. En l'absence d'un locale valide, les deux sont rédigés dans la langue de l'instance (instance.language).
  • Une valeur manquante, null, une valeur d'un autre type et toute autre chaîne sont ignorés silencieusement : jamais un 400, et la réponse ci-dessous ne change pas. Pour tier, cela couvre Alpha (casse), alpha (espaces de marge), un libellé de 33 caractères, 42, un objet, un tableau et a&plan=monthly (qui n'a ni & ni = pour fournir le fragment). Aucun champ n'est stocké ; chacun voyage dans le lien, afin qu'un lien ouvert sur un autre appareil connaisse toujours le forfait et le niveau. La note du titulaire du compte ne contient aucun lien, elle n'en porte donc aucun ; seule sa langue suit locale.

Un lien contenant les trois se lit <client>/join#server=…&invite=si_…&plan=yearly&tier=tier-a&lang=de.

json
{}

→ 202 avec ce corps, vide et invariable.

Pour une nouvelle adresse, le service génère une invitation ordinaire ciblée et l'envoie par courriel : rôle member, durée de validité par défaut de l'invitation, aucun auteur d'invitation, et les conditions de l'instance, à savoir son essai de scans lorsqu'elle en propose un (§5.19) et aucune IA sinon. Le lien envoyé par courriel mène vers §5.8.2 et §5.8, inchangés. La réponse NE DOIT PAS varier selon l'état réel de l'adresse, comme au §5.21 : une nouvelle adresse, une adresse qui possède déjà un compte, une adresse qui a déjà reçu un courriel en attente de l'opérateur ou d'un membre, et une boîte aux lettres ayant déjà reçu un courriel aujourd'hui obtiennent un même 202 avec un même corps. Les courriels sont spécifiques à cette porte d'accès, jamais l'invitation ou la notification du §5.21, qui indiquent que quelqu'un a invité le destinataire : une nouvelle adresse reçoit un courriel indiquant qu'elle, ou quelqu'un l'utilisant, a demandé à créer un compte, avec le lien unique, son expiration, et la précision qu'ignorer le message ne modifie rien ; un titulaire de compte reçoit une notification indiquant la même chose et qu'aucun second compte n'a été créé, sans aucun lien. Un courriel en attente provenant d'une autre porte d'accès est laissé intact, pour qu'un inconnu ne puisse pas annuler l'invitation d'un opérateur en soumettant l'adresse. Seuls les courriels diffèrent.

StatutSignification
202{}. Accepté, quel que soit l'état réel de l'adresse
400{"error":"email-invalid"} : ce n'est pas une adresse. {"error":"email-domain-refused"} : adresse appartenant à un service connu de courriels jetables, identifiée par son domaine et chacun de ses domaines parents. {"error":"captcha-failed"} : le jeton de captcha est manquant ou a été refusé ; résous-le à nouveau
404L'instance ne propose aucune inscription libre
429Plus de cinq requêtes depuis une même adresse source en une heure. Retry-After en secondes
503{"error":"captcha-unavailable"} : impossible de joindre le fournisseur de captcha. Réessaie plus tard

Les 400 décrivent la requête, jamais les comptes de l'instance : un nom de domaine ne révèle rien sur les titulaires d'un compte, donc en rejeter un ne constitue pas un oracle.

Deux limitations de débit. Par adresse source, cinq requêtes par heure en comptabilisant chaque tentative, ce qui borne l'action d'un script. Par boîte aux lettres, un courriel par jour : les requêtes suivantes renvoient toujours 202 sans rien envoyer, donc ce plafond ne permet pas de déduire quelles adresses ont été demandées par quelqu'un d'autre. La clé de la boîte aux lettres est clé d'essai : l'adresse canonique (§5.8) sans le +tag dans la partie locale, et pour gmail.com et googlemail.com sans aucun point et avec le domaine noté gmail.com. anna+x@gmail.com, a.n.n.a@gmail.com et anna@gmail.com partagent une même clé ; a.nna@example.org et anna@example.org ont des clés distinctes.

Une seule période d'essai par boîte de messagerie, à vie. Une boîte de messagerie dont la clé a déjà utilisé une invitation avec des analyses gratuites, quelle que soit sa graphie, ou dont le compte a bénéficié d'une période d'essai puis a été supprimé, reçoit une invitation avec une période d'essai fixée à 0 : la personne obtient quand même un compte, et la première analyse renvoie 403 trial-scans-spent. Le service reconnaît la boîte grâce au hachage à clé de sa clé d'essai, jamais par une adresse stockée (§9.2).

Aucune adresse n'est journalisée, sur aucune branche. Un serveur NE DOIT PAS enregistrer l'adresse soumise ni le jeton du captcha dans les journaux.

5.9 POST /v1/auth/login

Sans authentification, avec deux limiteurs. Les deux décomptent un 401 et rien d'autre, et un succès réinitialise les deux.

  • Par IP et e-mail. Cinq échecs sont accordés sans blocage. Cela ralentit une attaque par force brute issue d'une source unique sans permettre à quiconque de bloquer l'accès d'une victime à son propre compte depuis une autre adresse.
  • Par e-mail, depuis n'importe quelle adresse. Vingt échecs reçoivent une réponse ; la vingt-et-unième requête est refusée pendant une minute, et chaque échec supplémentaire double la durée du blocage jusqu'à quinze minutes. Un panier sans échec pendant quinze minutes repart de zéro. Cela restreint un attaquant qui ferait tourner ses adresses. Une adresse sans compte est décomptée de la même façon, afin que le refus n'indique pas si le compte existe. L'adresse est normalisée de la même manière que pour la recherche de compte (§2), une variante d'écriture utilise donc le même panier.

Chaque verrou correspond au même 429 avec Retry-After, en prenant le plus long des deux délais d'attente.

Requête {"email": "...", "authHash": "..."} → 200 {"account": AccountView, "tokens": {...}}.

400 lorsque email n'est pas une adresse plausible ou que authHash ne correspond pas à 32 octets décodés en base64 : la requête n'atteint jamais la vérification des identifiants, ce statut n'apporte donc aucune information sur l'existence du compte. 401 pour un compte inconnu et pour un hachage d'authentification erroné, avec un texte de corps identique et après un travail identique, car la comparaison du vérificateur s'exécute dans les deux branches contre un substitut de même taille. 403 {"error":"account-suspended"} lorsque le compte est suspendu, vérifié APRÈS les identifiants, de sorte que seule une personne ayant prouvé qu'elle possède le compte apprend pourquoi l'accès est refusé. 429 en cas de dépassement de quota.

5.10 POST /v1/auth/refresh

Non authentifié (le jeton de rafraîchissement sert d'identifiant). Requête {"refreshToken": "..."} → 200 {"tokens": {...}}. Voir le §4.2 pour la rotation et la détection de réutilisation. Tout échec renvoie 401, sauf pour un compte suspendu qui renvoie 403 {"error":"account-suspended"} et ne consomme PAS le jeton présenté : une suspension pouvant être levée, détruire le jeton déconnecterait la personne d'un appareil qu'elle est sur le point de récupérer. Ce statut distinct empêche le client de boucler indéfiniment sur ce point d'accès.

5.11 POST /v1/auth/logout

Bearer. 204. Révoque la famille de jetons de l'appelant : cet appareil uniquement.

5.12 POST /v1/auth/reset/request et POST /v1/auth/reset/open : la réinitialisation par e-mail

Ces numéros ont été retirés en 0.5.0, lorsque verify-email et request-reset ont disparu avec le module d'envoi d'e-mails. Le protocole 2 les réutilise, et ce choix de les réutiliser plutôt que d'en attribuer deux nouveaux est délibéré : ce qui se trouve ici répond désormais à ce qui s'y trouvait auparavant, et un lecteur suivant une référence à §5.12 depuis un commentaire dans le code source doit aboutir à une solution plutôt qu'à une impasse.

§5.12.1 POST /v1/auth/reset/request : non authentifié, débit limité par (IP, e-mail), JAMAIS réinitialisé en cas de succès.

Requête {"email": "anna@example.org"} → 202 {}, systématiquement.

202 de manière identique pour une adresse connue, une adresse inconnue ou une adresse mal formée. Un serveur conforme DOIT effectuer le même travail sur les deux branches avant de répondre : rechercher l'adresse, créer le jeton, en calculer l'empreinte. L'écriture en base et l'envoi, dont seule une adresse connue bénéficie, NE DOIVENT PAS retarder la réponse : le serveur de référence les exécute après l'envoi du 202 (depuis septembre 2026), et un échec à ce niveau est consigné dans les journaux, jamais renvoyé. Cette symétrie constitue tout l'argument anti-énumération, et c'est celle que ce document signalait auparavant comme MANQUANTE : l'ancien request-reset n'effectuait le travail coûteux que pour les adresses existantes, son temps de réponse révélait donc ce que son corps taisait. Avant septembre 2026, ce serveur attendait encore une écriture et un envoi uniquement sur la branche connue.

Un 400 n'est jamais renvoyé, pas même pour une valeur qui n'est manifestement pas une adresse : ce code de statut deviendrait un oracle gratuit sur le format des adresses hébergées par cette instance, et un appelant ne pourrait de toute façon rien faire d'utile de cette distinction.

Le jeton fait 32 octets aléatoires, en base64url, avec le préfixe sr_. Seule son empreinte SHA-256 est conservée, dans password_resets, avec un TTL de 60 minutes. Un seul jeton actif par compte : une nouvelle demande marque toute ligne antérieure non utilisée comme consommée, dans la même transaction, pour qu'une personne qui remonte dans sa boîte de réception ne puisse pas utiliser la lettre de la veille. Ces transactions sont sérialisées par compte (verrou de ligne sur le compte), de sorte que des requêtes qui se chevauchent ne laissent toujours qu'un seul jeton actif.

Quand les courriels ne sont pas configurés, l'envoi ne fait rien et le point de terminaison répond toujours 202. Les utilisateurs d'un auto-hébergeur n'ont alors aucune réinitialisation, le recours de l'opérateur est POST /v1/admin/accounts/:id/reset-mail, qui renvoie le lien.

§5.12.2 POST /v1/auth/reset/open : non authentifié, débit limité par IP.

Requête {"resetToken": "sr_…"} → 200 :

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

Le jeton est consommé dans la MÊME instruction qui le lit (UPDATE … WHERE consumed_at IS NULL AND expires_at > now RETURNING), deux requêtes portant un même jeton ne peuvent donc pas recevoir de réponse toutes les deux. Les jetons inconnus, consommés et expirés renvoient un SEUL 404 {"error":"reset-invalid"} après un travail identique.

CE POINT DE TERMINAISON N'ÉCRIT RIEN SUR LE COMPTE, et cette phrase résume toute la différence avec le flux que documentait le §5.13. Il restitue le code de récupération que le serveur détient déjà sous séquestre (§3.1), le client exécute ensuite avec lui la cérémonie ORDINAIRE du §5.14 recover-rotate : prouver le code, définir une nouvelle phrase de passe, ré-envelopper la DEK, créer un nouveau code, le placer à nouveau sous séquestre, en une seule transaction. Sans les enregistrements de clés, ce qui est renvoyé n'est qu'une chaîne de caractères. Une modification future permettant à ce chemin de toucher à un vérificateur ou à un enregistrement de clé aurait reconstruit le flux de prise de contrôle de compte qu'ADR-0004 a supprimé, quel que soit son nom.

Ce que cela coûte, explicité plutôt qu'implicite. La réinitialisation fonctionne parce que l'opérateur détient le code de récupération. Lis le §3.1 et docs/adr/0005-organization-accounts-and-escrowed-recovery.md avant de décider d'accorder ta confiance à une instance hébergée, la décision concerne l'opérateur, pas la cryptographie.

5.13 POST /v1/auth/verify-email : supprimé dans la 0.5.0, et non rétabli

Disparu avec l'expéditeur de courriels dans la 0.5.0, et le protocole 2 ne le réintroduit pas même si ce service envoie à nouveau des courriels.

Il ne reste rien à confirmer : un compte est créé en utilisant une invitation ADRESSÉE à une boîte aux lettres (§5.8), la personne qui a reçu le message est donc celle qui s'est inscrite. L'invitation constitue la vérification, et un second lien ne ferait que demander à quelqu'un de prouver deux fois ce qu'il a déjà manifestement fait une fois.

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

L'authentificateur par code de récupération, et les deux rotations d'identifiants. recover-rotate et change-passphrase prennent la même forme de soumission car ils font la même chose, seule la preuve diffère.

POST /v1/auth/recover : non authentifié, débit limité par IP ainsi que adresse courriel. Requête {"email": "...", "recoveryAuthHash": "<base64, 32 bytes>"} → 200 {"account": AccountView, "tokens": {...}}.

La réponse est une session ordinaire, délibérément sans restriction : le détenteur du code de récupération est le propriétaire du compte par construction, et un jeton restreint en « mode récupération » ajouterait une seconde surface d'autorisation sans apporter aucune propriété que le code ne porte pas déjà.

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

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

Les entrées keyRecords sont {"kind": "passphrase" | "recovery", "kdfDescriptor": {...} | null, "wrappedDek": "<base64>"}, au plus une par type, en respectant les mêmes règles que le §5.4 (le descripteur d'un enregistrement recovery doit être null, celui d'un enregistrement passphrase ne doit pas l'être).

change-passphrase renvoie 200 {"tokens": {...}}. recover-rotate renvoie 200 {"account": AccountView, "tokens": {...}}, car l'appelant est arrivé sans session et doit savoir dans quel compte il vient de rentrer. Les deux renvoient une paire toute neuve.

La soumission entière est appliquée de manière atomique. Nouveau vérificateur, nouveau descripteur KDF du compte, un vérificateur de récupération facultativement nouveau, le séquestre rescellé, les enregistrements de clés insérés ou mis à jour, la révocation de chaque session en cours, et la nouvelle paire de l'appelant sont tous validés ensemble, ou aucun ne l'est. Ce n'est pas un détail d'implémentation. Chaque état intermédiaire constitue un désastre distinct que l'utilisateur ne peut voir avant de tenter de lire son propre journal : un vérificateur sans son enregistrement ré-enveloppé se connecte et ne déchiffre rien, un enregistrement sans son vérificateur ne peut pas se connecter du tout, et un vérificateur de récupération renouvelé sans son enregistrement laisse un code qui s'authentifie puis ne déballe rien.

keyRecords doit être présent, même sous la forme []. Une clé absente est une 400, pour la même raison que expectedUpdatedAt est requis au §5.4 : le silence ne doit jamais être interprété comme un consentement sur un chemin qui peut bloquer des données.

Les types pas soumis restent intacts. Un changement de phrase de passe ré-enveloppe la DEK sous un nouveau KEK_p, l'enregistrement recovery enveloppe toujours la même DEK inchangée et reste valide.

Quatre règles s'appliquent à recover-rotate seul :

  • Un enregistrement de clé passphrase est requis, et [] est un 400. Contrairement à un changement de phrase de passe, ce chemin a nécessairement modifié KEK_p, accepter une soumission sans le ré-enveloppement créerait donc un compte qui se connecte parfaitement et ne déchiffre rien.
  • Le renouvellement du code de récupération est un tout ou rien, et dans le protocole 2, c'est une règle à TROIS volets. newRecoveryAuthHash, un enregistrement de clé recovery, et recoveryCode doivent arriver ensemble ou pas du tout, tout sous-ensemble constitue une 400. Chaque élément manquant est un désastre en soi : un vérificateur sans l'enregistrement laisse un code qui s'authentifie et ne déballe rien, un enregistrement sans le vérificateur en laisse un qui déballe et ne peut pas se connecter, et un SÉQUESTRE qui conserve encore l'ancien code transforme la prochaine réinitialisation envoyée par courriel (§5.12) en un message portant un identifiant que le compte n'accepte plus, ce que l'on découvre le jour où on en a besoin.
  • L'écriture est un compare-and-swap sur le vérificateur de récupération auquel la preuve correspondait, réaffirmé au sein de la transaction. Ce n'est pas l'authentification, qui a déjà eu lieu, c'est ce qui empêche deux récupérations simultanées d'écraser un identifiant dont on a déjà confirmé à l'utilisateur qu'il lui appartient.
  • Un échec, quatre causes. Une adresse inconnue, un compte qui n'a jamais défini de code de récupération, un code erroné et une rotation qui a perdu cette course de compare-and-swap répondent tous 401 avec un texte identique, après un travail identique. Une situation de compétition ne doit pas pouvoir se distinguer d'une tentative incorrecte, et l'absence d'un second facteur d'authentification ne doit pas pouvoir se distinguer d'un compte inexistant. Un compte SUSPENDED est la seule exception : il répond 403 {"error":"account-suspended"}, et seulement après que la preuve a réussi.

change-passphrase est limité par compte, depuis toute adresse, dans le compartiment que delete, rotate-dek et un écrasement d'enregistrement de clé partagent (§5.4) : son appelant détient déjà un jeton, et currentAuthHash est une tentative que ce jeton ne peut prouver. Un compte verrouillé reçoit 429 avec Retry-After ; un succès vide le compartiment.

Les deux points de terminaison de récupération partagent un seul compartiment de limitation par (IP, e-mail), et aucun ne le réinitialise en cas de succès. Ils authentifient le même secret, donc un quota séparé pour chacun réduirait de moitié le coût pour le deviner, et une récupération légitime n'arrive qu'une fois, donc aucun client honnête n'a besoin de récupérer son quota. POST /v1/auth/reset/request est limité selon la même règle.

Ce qu'une rotation peut et ne peut pas faire. Cela restaure l'accès. Cela ne peut pas restaurer les données, car le serveur n'a jamais détenu de clé. Un change-passphrase qui soumet keyRecords: [] laisse un compte fonctionnel dont le blob est définitivement indéchiffrable, et c'est exactement pourquoi recover-rotate refuse purement et simplement cette soumission. Un client conforme doit l'indiquer, en ces termes, avant que l'utilisateur ne valide le flux.

Si la phrase de passe est perdue, le §5.12 permet de la rétablir, et cela fonctionne parce que l'opérateur conserve le code sous séquestre (§3.1). Le protocole 1 disait ici qu'une phrase de passe perdue combinée à un code perdu condamnait définitivement un compte, sans que personne ne puisse l'ouvrir. Cette phrase n'est désormais vraie que pour une instance dont la valeur SERVER_SECRET est également perdue, c'est pourquoi ce secret doit être sauvegardé AVEC la base de données, et pourquoi sa perte est plus grave qu'il n'y paraît.

La version honnête de l'ancien avertissement concerne l'opérateur, pas les mathématiques. Une instance gérée peut ouvrir n'importe quel compte hébergé sur celle-ci. Une instance auto-hébergée est son propre opérateur, donc l'ancienne promesse s'applique pour le cas personnel. Un client conforme indique auquel des deux il s'adresse, avant qu'une personne n'y dépose un journal.

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

Tous les trois au porteur.

AccountView est l'UNIQUE structure de compte dans ce protocole. Il est renvoyé par POST /signup, POST /login, GET /account, PATCH /account, POST /recover, POST /recover-rotate et les points de terminaison de compte d'administration, donc un client n'a qu'un seul et unique décodeur de compte :

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

Rien de secret ne s'y trouve et rien ne peut s'y trouver : aucun vérificateur, aucun descripteur KDF, aucune DEK enveloppée, aucun séquestre, aucun jeton. Chaque champ constitue soit l'information propre de la personne, soit le statut qu'un opérateur lui a accordé. aiUsedToday est décompté de dailyAiLimit sur le jour UTC en cours ; suspendedAt est non-null tant que chaque appel authentifié répond 403 account-suspended.

invitesLeft indique combien d'invitations ce compte peut encore envoyer via POST /v1/auth/invites (§5.21), ou null lorsque cette limite ne le concerne pas. null, jamais 0, pour un administrateur : 0 se lit comme "tu les as toutes utilisées", et un administrateur n'en a utilisé aucune, car il les crée via l'API d'administration, qui échappe à la limite et à la règle de réinvitation. Une instance avec instance.memberInvites: false renvoie null pour la même raison : il n'y a pas de limite ici, car il n'y a pas de route, et un 0 annoncerait un quota épuisé qui n'a jamais existé. Un client peut l'afficher et NE DOIT PAS s'en servir pour autoriser, le service refuse une sixième création quoi qu'en pense le client.

invitesNeedAPlan vaut true quand invitesLeft vaut 0 uniquement parce que le compte est un essai de numérisation que personne n'a encore payé (§5.21), et false dans tous les autres cas, y compris pour un administrateur et une instance avec instance.memberInvites: false. Un compte correspond à un tel essai lorsqu'il porte trialScans et que son allowanceExpiresAt vaut null ou est déjà passé ; un allowanceExpiresAt futur, ce que le système de facturation inscrit lors du paiement, rouvre les invitations et laisse trialScans en place. Le champ est additif : un client qui l'ignore lit invitesLeft: 0, ce qui reste vrai, et un client qui le lit peut indiquer que les invitations s'ouvrent avec un abonnement plutôt que de dire qu'elles sont toutes utilisées.

allowanceExpiresAt est un instant ISO ou null, et null signifie que le quota pour l'IA n'a pas de date de fin, ce qu'utilise une instance auto-hébergée. À partir de cet instant, le proxy du §5.19 répond 403 allowance-expired. Il filtre l'IA et rien d'autre : la synchronisation continue de fonctionner après cette date, car le journal appartient au compte et un nouvel appareil doit pouvoir le récupérer. Un client peut afficher la date et ne doit pas s'en servir pour autoriser, c'est au niveau du proxy que vit la règle.

freeDailyAiLimit correspond au attribution gratuite permanente du compte : des unités d'IA par jour UTC (§5.19) qui s'appliquent dès lors qu'aucune période payante n'est active, sans date de fin ni restriction de numérisation. 0 correspond à aucun. L'ordre du proxy (§5.19) est une période payante active (allowanceExpiresAt dans le futur, à dailyAiLimit), puis cette attribution, puis l'essai de numérisation, de sorte qu'un compte doté d'une attribution gratuite bascule sur celle-ci quand une période payante se termine au lieu de perdre l'IA. Un client qui affiche une limite quotidienne affiche celle-ci chaque fois qu'aucune période payante n'est active. La valeur présente dans la vue est celle que le proxy applique : la limite propre du compte lorsqu'elle est supérieure à 0, sinon la valeur par défaut de l'instance (§5.19), sinon 0. Seul un opérateur l'écrit (§5.20) ; l'identifiant du facturateur ne le peut pas. Un client peut l'afficher et NE DOIT PAS autoriser avec cette valeur. Le champ est additif : un client qui l'ignore décode la vue sans modification.

capabilities est la liste des fonctionnalités par rapport auxquelles le proxy vérifie ce compte (§5.19, « Fonctionnalités ») : l'enregistrement propre du compte, sinon le defaultCapabilities de l'instance (§5.6), sinon null. null signifie aucune vérification, pas « rien » : chaque fonction est ouverte, ce qui correspond à chaque compte sur une instance qui ne définit ni valeur par défaut ni enregistrement. [] signifie aucune fonction du tout. Un client qui trouve null DOIT traiter chaque fonctionnalité comme disponible. Un client peut l'afficher et NE DOIT PAS autoriser avec cette valeur : le proxy répond 403 capability-required. Le champ est additif : un client qui l'ignore décode la vue sans modification. Les vues de gestion et de facturation comportent plutôt l'enregistrement propre au compte (§5.20), où null signifie aucun enregistrement.

trialScans vaut {"granted": n, "left": n} pour un compte qui dispose d'une période d'essai d'analyse, et null pour un compte sans essai, ce qui correspond à tous les comptes d'une instance qui n'en propose pas. left équivaut à granted moins les analyses utilisées, sans jamais descendre sous 0. Un client peut l'afficher ainsi que NE DOIT PAS autoriser avec cette valeur : le proxy compte (§5.19), left est un instantané pris lors de la génération de cette vue, et chaque réponse relayée par le proxy inclut la valeur à jour dans X-Trial-Scans-Left. Une date ultérieure de type allowanceExpiresAt débloque l'accès aux analyses, un compte payant peut donc toujours comporter ce champ.

trialEndsAt est un instant ISO, ou null pour un essai d'analyse sans date de fin et pour un compte sans aucun essai. Il est écrit une seule fois, lorsque l'essai commence à l'activation (§5.8), sur une instance qui fixe une limite en jours, et il correspond toujours à un minuit local dans le fuseau horaire de cette instance ; un compte créé avant que son instance n'en définisse un conserve null, et son essai n'est jamais raccourci après coup. À partir de cet instant, le proxy répond 403 trial-expired (§5.19), à moins que le quota d'analyses gratuites ne soit épuisé plus tôt. Un client peut l'afficher et NE DOIT PAS autoriser avec cette valeur. Un futur allowanceExpiresAt le lève exactement comme il lève le nombre d'analyses. Le champ est additif : un client qui l'ignore décode la vue sans modification.

healthConsent vaut {"version": "<v>", "at": "<ISO instant>"} pour un compte ayant un consentement aux données de santé enregistré, et null pour un compte qui n'en a pas : chaque compte créé avant que son instance ne le demande, et chaque compte sur une instance qui n'en demande aucun. version est la formulation acceptée par la personne et at est l'horloge propre au service à cet instant précis. Un client compare version avec instance.healthConsent.version (§5.6) et demande une confirmation unique lorsqu'ils diffèrent ou que cette valeur est null sur une instance qui le demande (§5.15.1). Le champ est additif : un client qui l'ignore décode la vue sans modification.

Les points de terminaison de compte d'administration renvoient la même structure plus deux champs opérateur, blob et keyRecordKinds (ADR-0001). Un client qui décode un AccountView à partir d'une réponse d'administration fonctionne donc sans modification et lit deux champs qu'il n'avait pas demandés.

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

PATCH /v1/auth/account prend {"displayName": string | null} → 200 {"account": AccountView}. La clé DOIT être présente, même sous la forme null : une clé absente constitue une erreur 400, la même règle que suivent keyRecords et expectedUpdatedAt, car un PATCH qui n'a rien fait en silence à cause d'un nom de champ mal orthographié est une modification que le client croit avoir effectuée.

C'est le seul champ qu'un compte peut modifier sur lui-même. email est l'identité et ne change que par l'intermédiaire d'un opérateur ; role et dailyAiLimit sont des statuts qu'un compte ne doit pas pouvoir s'attribuer lui-même ; tout ce qui touche à l'authentification passe par le §5.14.

POST /v1/auth/delete prend {"authHash": "..."} et renvoie 204. La réauthentification est requise même si l'appelant détient déjà un jeton valide : une session oubliée sur un appareil partagé ne doit pas suffire à détruire de manière irréversible les données de quelqu'un. Un authHash erroné donne 401 ; les tentatives sont régulées par compte dans le compartiment décrit au §5.4, et un compte verrouillé reçoit 429 avec Retry-After. Sur une instance dotée d'un facturateur (§5.22), une fois les deux vérifications réussies, le service envoie POST <PLANS_UPSTREAM_URL>/erase avec X-Plans-Secret et X-Account-Id et aucun corps, de sorte que le facturateur annule les abonnements du compte avant sa disparition. Il attend au maximum cinq secondes et supprime quelle que soit la réponse du facturateur ; DELETE /v1/admin/accounts/:id fait de même. Sur une instance dont l'API de messagerie est Pigeon (MAIL_API_URL se termine par /v1/emails), une fois le compte supprimé, le service envoie aussi POST <base>/v1/recipients/erase avec {"email": "<address>"} et la clé Bearer de l'API de messagerie, afin que Pigeon efface toutes les copies qu'il détient de l'adresse. Cet appel ne modifie jamais la réponse : il effectue jusqu'à trois tentatives, retarde le 204 de deux secondes au maximum, poursuit toute tentative encore due après la réponse, et en cas d'échec définitif, journalise une seule ligne avec un compte et un code de statut ou d'erreur, sans adresse. Rien ne conserve l'adresse pour réessayer plus tard ; la limite de rétention de Pigeon constitue le filet de sécurité. DELETE /v1/admin/accounts/:id fait de même.

La suppression retire le compte et, par cascade, chaque blob, enregistrement de clé, jeton de réinitialisation et ligne d'utilisation qui lui appartiennent. Il n'y a pas de suppression réversible ni de période de grâce. C'est la procédure d'effacement en libre-service, et elle est complète par conception plutôt que par une tâche de nettoyage que quelqu'un doit penser à exécuter.

La même transaction applique aussi retire toute invitation envoyée par le compte qui reste en attente (§5.21). Une invitation envoyée par une personne applique les conditions de l'accès de cette personne ; une invitation restée en attente après son départ pourrait encore être utilisée, et sur un accès d'essai temporaire, son quota ne débute qu'à l'utilisation.

Sur une instance qui propose un essai de numérisation, la même transaction effectue aussi supprime l'adresse et le nom de toutes les lignes d'invitation associées à cette boîte, et, quand le compte disposait d'un essai, garde un hachage à clé non réversible de la boîte pour que la règle d'un essai par boîte du §5.8.3 survive à la suppression. Le condensat est conservé pendant TRIAL_HASH_RETENTION_DAYS (365 par défaut) après la suppression puis est supprimé lors d'un nettoyage horaire, après quoi la même boîte peut à nouveau obtenir un essai. Une instance qui n'accorde aucun essai de numérisation ne conserve aucun condensat. Rien d'autre concernant la personne n'est conservé (§9.2). Le fondement et la durée sont stipulés dans docs/adr/0010-the-mailbox-hash-has-a-basis-and-an-end.md.

5.15.1 POST /v1/auth/account/health-consent : consentement explicite aux données de santé

Bearer. Présent uniquement lorsque instance.healthConsent est non-null ; partout ailleurs, le chemin renvoie l'erreur ordinaire 404 de chemin inconnu, à tout le monde, avec ou sans connexion.

Pourquoi une instance demande le consentement. Un journal contient des données de santé : repas, poids, jeûne. Sur une instance gérée, l'opérateur conserve le code de récupération séquestré (§3.1, ADR-0005) et peut donc ouvrir le journal, et sa politique de confidentialité cite le consentement explicite au titre de l'art. 9(2)(a) du RGPD comme base juridique. L'opérateur doit pouvoir prouver que le consentement a été donné, à quel moment, et pour quelle formulation. Une instance auto-hébergée dont l'opérateur est la personne elle-même ne demande rien à personne et laisse HEALTH_CONSENT_VERSION non défini.

Deux façons pour un consentement d'atteindre un compte, une version. L'opérateur définit HEALTH_CONSENT_VERSION, une chaîne courte de 1 à 32 lettres, chiffres, ., _ ou - (une date telle que 2026-09-28), et /health le publie sous le nom de instance.healthConsent.version.

  • Un nouveau compte donne son accord lors de l'étape de création du compte : POST /v1/auth/signup transporte "healthConsent": {"version": "<v>"} et l'enregistre dans la même instruction que le compte (§5.8).
  • Un compte existant qui ne l'a pas, ou qui possède une version plus ancienne, est sollicité une seule fois et donne son accord ici.

Requis sur chaque route de données. Tant qu'il n'a pas accepté, un compte qui ne détient pas la version actuelle de l'instance se voit refuser 403 {"error":"health-consent-required"}, avec le même corps sur chaque route, après la vérification du jeton porteur et avant que quoi que ce soit ne soit stocké, décompté ou envoyé :

Refusé à un compte sans le consentementPourquoi
Chaque route sous /v1/sync sauf les deux lectures ci-dessous : le téléversement de blob, les écritures et suppressions de registres de clés, rotate-dek, les partages, la rechercheElles stockent ou transmettent le journal
POST /v1/chat/completions (§5.19), après la suspension et avant le quotaLe corps est une photo d'assiette
POST /v1/feedback (§5.25), /v1/pulse/* (§5.23), /v1/push/* (§5.24)Chacune stocke des éléments issus du journal
/v1/plans/* (§5.22), sauf le GET /v1/plans/prices anonymeUtilisation du compte, sans accepter ni partir
PATCH /v1/auth/account, POST /v1/auth/invites (§5.21)Utilisation du compte, sans accepter ni partir
POST /v1/auth/change-passphrase (§5.14)Sa seconde moitié réécrit le compartiment sur le blob, ce qui est refusé, la modification entière attend donc
Ouvert à un compte sans le consentementPourquoi
POST /v1/auth/login, POST /v1/auth/refresh, POST /v1/auth/logoutConnexion et déconnexion
GET /v1/auth/accountLe client le lit pour savoir qu'il doit demander
POST /v1/auth/account/health-consent (cette route)Où le compte donne son accord
POST /v1/auth/delete (§5.15)La suppression est la façon de refuser ou de retirer un consentement
GET /v1/sync/blob (§5.2), GET /v1/sync/key-records (§5.3)Sa propre copie. Se connecter sur un nouvel appareil exige les deux avant qu'un client puisse demander, et un export sur un nouvel appareil constitue la récupération. Rien n'est stocké
/health, GET /v1/plans/prices, POST /v1/legal/declarations, les routes /v1/auth/* non authentifiées (§5.7 à §5.14)Pas de session, donc aucun compte à interroger
/v1/admin/* (§5.20)L'identifiant propre de l'opérateur ; les routes de journal d'un administrateur sont refusées comme celles de n'importe qui

Un consentement à une formulation plus ancienne est refusé comme une absence de consentement. Le refus est levé dès la requête qui suit la réponse 200 de cette route, avec le même jeton d'accès : le service lit la ligne du compte à chaque requête authentifiée, comme pour une suspension, et lit le consentement associé. Sur une instance où instance.healthConsent vaut null, rien ici ne refuse quoi que ce soit.

L'émetteur de notifications push lit la table des abonnements plutôt qu'une route, il applique donc la même règle de son côté : un appareil abonné avant que l'instance ne le demande conserve sa ligne et ne reçoit rien tant que le compte n'a pas accepté (§5.24).

Requête : {"version": "2026-09-28"} → 200 {"account": AccountView} (§5.15), avec healthConsent défini.

StatutSignification
200{"account": AccountView}. Le consentement est enregistré, maintenant ou depuis un appel antérieur avec la même version
400{"error":"health-consent-required"} : le corps ne contient pas la chaîne version, ou pas celle que /health publie ; rien n'est enregistré
401Aucun jeton d'accès valide
403{"error":"account-suspended"}
404L'instance ne demande aucun consentement

Quatre règles qu'un serveur conforme DOIT respecter :

  1. La version stockée est celle de l'instance, jamais celle de l'appelant. Le champ version du corps est comparé octet par octet avec celui de l'instance, sans suppression d'espaces ni conversion de casse, et la chaîne enregistrée est celle de l'instance.
  2. L'instant est celui de l'horloge du serveur. Un client n'envoie aucune heure, et aucune ne serait lue.
  3. Idempotent, et le premier instant l'emporte. Un second appel avec la version déjà enregistrée ne change rien et renvoie le même 200 ; at reste le moment où la personne a donné son accord pour la première fois. Une version différente remplace les deux, de sorte qu'une nouvelle formulation apporte son propre instant.
  4. La rétractation équivaut à la suppression. Aucune route n'annule un consentement. Une personne qui se rétracte supprime le compte (POST /v1/auth/delete, §5.15), ce qui emporte le journal et le consentement avec la ligne. L'opérateur lit le consentement dans la vue d'administration du compte et aucune route d'administration ne l'écrit : un consentement qu'un opérateur pourrait définir au nom de quelqu'un ne prouverait rien.

health-consent-required est l'unique refus de chaque vérification de consentement, sous deux statuts : 400 lors de la création du compte et sur cette route, où le client affiche à nouveau sa case à cocher, et 403 sur une route de données, où le client y redirige la personne. Modifier HEALTH_CONSENT_VERSION sollicite à nouveau chaque compte, et à partir de cet instant, chaque route de données refuse les comptes ayant accepté l'ancienne formulation jusqu'à ce qu'ils acceptent la nouvelle ; un opérateur le modifie lorsque la formulation change, et pas autrement.

5.16 Partages : /v1/sync/shares et /v1/sync/shared (ADR-0002)

Présent uniquement lorsque le déploiement définit SYNC_SHARING. Sans cela, chaque chemin ci-dessous répond l'habituel itinéraire inconnu 404, à tout appelant, authentifié ou non ; le terminateur est monté en amont de l'authentification, de sorte qu'une instance non configurée ne se distingue pas d'une instance où la fonctionnalité n'a jamais été codée.

Les deux parties désignent un partage par le l'identifiant de compte de la contrepartie, jamais par un identifiant synthétique de partage : l'identité stable d'un partage est la paire (cédant, bénéficiaire), et c'est ce qui survit à une rotation de DEK.

Côté cédant.

VerbeCheminRemarques
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/sharesLes partages accordés par le concédant lui-même. Ne renvoie jamais wrappedDek : un blob destiné à la clé d'un tiers n'a aucune utilité ici, il ne transite donc pas là où personne n'en a besoin.
DELETE/shares/:granteeAccountId204, idempotent. Une suppression définitive ; il n'y a pas de tombstone.

Côté bénéficiaire.

VerbeCheminRemarques
GET/sharedPartages destinés à cet appelant, chacun avec son wrappedDek ; seul cet appelant peut l'ouvrir.
GET/shared/:grantorAccountId/blob{"grantorAccountId": <int>, "blobVersion": <int>, "envelopeVersion": <int>, "ciphertext": "<base64>", "createdAt": "<iso>"}. grantorAccountId est requis : les AAD du §3.2 le lient, un bénéficiaire qui ne l'a pas ne peut donc rien déchiffrer.
DELETE/shared/:grantorAccountId204, idempotent. Permet à un bénéficiaire d'abandonner un partage qui lui est destiné.
  • La surface du bénéficiaire ne comporte aucun verbe d'écriture ciblant le concédant, et ne renvoie que la ligne de partage propre à l'appelant, le blob actuel du concédant et grantorAccountId. Jamais les enregistrements de clé, le descripteur KDF, le vérificateur, le séquestre, l'e-mail ou le nom d'affichage du concédant. Un bénéficiaire capable de récupérer la DEK du concédant chiffrée par son recovery n'aurait besoin que d'un code de récupération trouvé par force brute pour s'octroyer le droit de rotation sur ce compte.
  • Uniquement le blob actuel. L'anneau des versions conservées est un mécanisme de récupération réservé au propriétaire, pas une chronologie pour le bénéficiaire.
  • L'autorisation est une lecture de ligne en direct à chaque requête, jamais mise en cache. C'est ce qui rend une DELETE effective dès l'appel suivant.
  • Inconnu, étranger et jamais poussé renvoient tous le même 404. L'absence d'un partage ne doit pas confirmer l'existence d'un compte.

5.17 POST /v1/sync/rotate-dek : rotation atomique de la DEK (ADR-0002)

Bearer, en tant que propriétaire du compte. Une soumission, une transaction :

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

Le client génère une nouvelle DEK, rechiffre son instantané complet avec celle-ci, la rechiffre sous ses deux KEK et la rechiffre pour chaque partage qu'il conserve. Le service stocke le résultat tout ou rien.

Présent sur chaque déploiement, contrairement au §5.16. La rotation ne fait pas partie de la surface de partage : elle réécrit le blob propre à l'appelant et ses deux enregistrements de clé personnels, des lignes qui existent sur chaque compte partout, et elle constitue la réponse si l'on craint qu'une DEK a fuité (sauvegarde restaurée, appareil perdu) sur une instance qui n'a jamais rien partagé. Restreindre l'unique mécanisme capable de révoquer une DEK compromise derrière un indicateur sans rapport priverait cet exploitant de tout moyen d'en retirer une.

  • currentAuthHash est REQUIS, et un jeton porteur seul n'opère jamais de rotation. Il s'agit de la branche d'authentification de la phrase de passe actuelle (§3.1), validée comme la valide change-passphrase. Une valeur absente ou mal formée produit un 400 qui la nomme ; une valeur non concordante produit 401 {"error":"current passphrase is incorrect"} et rien n'est écrit. Une rotation enregistre le vérificateur de récupération accepté par POST /v1/auth/recover, donc avant l'introduction de ce champ, un jeton dérobé pouvait insérer son propre code et s'authentifier avec pour de bon, peu importe ce que le propriétaire faisait ensuite de sa phrase de passe. Les tentatives sont limitées par compte dans le compartiment décrit au §5.4 (429 avec Retry-After). La transaction s'assure à nouveau que le vérificateur de phrase de passe du compte correspond toujours à celui validé ; un changement de phrase de passe validé entre-temps transforme la rotation en un 401, et rien n'est écrit.
  • Toute autre session est révoquée dans la même transaction. La propre famille de jetons de l'appelant est préservée, pour que l'appareil effectuant la rotation reste connecté ; tout autre jeton access et refresh du compte cesse de fonctionner. Une rotation est effectuée lorsqu'une clé est soupçonnée d'avoir fuité, et une session qui lui survivrait constituerait cette fuite.
  • Tout ou rien, en une seule transaction de base de données. Interdiction 8 de l'ADR-0002 : une rotation est atomique ou elle n'existe pas, et aucune séquence de points de terminaison validant leurs modifications individuellement ne peut être documentée ou utilisée comme telle. Une application partielle correspond au blocage « connexion réussie, déchiffre rien » que le §5.14 refuse déjà d'autoriser, avec un intervenant de plus : un enregistrement de clé réenveloppé alors que l'écriture du blob a échoué à son CAS bloque le propriétaire, et un partage réenveloppé alors que l'écriture du blob a échoué à son CAS bloque le clinicien.
  • blob fait l'objet d'un compare-and-swap sur baseVersion, exactement comme au §5.1. Une valeur obsolète renvoie un 409 {"currentVersion": n} et absolument rien n'est écrit.
  • newRecoveryAuthHash ET recoveryCode sont REQUIS, et une soumission où l'un des deux manque renvoie une 400 qui nomme le champ. Une rotation génère toujours un nouveau code de récupération, car l'enregistrement de clé recovery qu'elle réencapsule est scellé sous une KEK dérivée de ce code ; le serveur remplace donc accounts.recovery_verifier et le séquestre (§3.1) au sein de la même transaction au même titre que le blob, les enregistrements de clés et les partages. Une rotation qui laissait ces deux éléments sur l'ANCIEN code produisait un compte dont le code sous séquestre authentifiait puis ne déchiffrait rien, un bogue latent dès lors que le code de récupération est devenu le second facteur d'authentification, et fatal dès qu'une réinitialisation envoyée par e-mail (§5.12) s'est mise à distribuer ce code aux personnes. Le client n'affiche pas le nouveau code à la personne ; il va dans le séquestre et y reste.
  • Le serveur dérive lui-même la preuve de récupération. Il exécute la branche d'authentification de récupération du §3.1 sur la valeur canonique recoveryCode et calcule le nouveau vérificateur à partir de CELA, afin que le vérificateur et le séquestre décrivent toujours le même code. newRecoveryAuthHash reste obligatoire et doit être égal à la preuve dérivée ; une divergence produit un 400 qui la nomme, et rien n'est écrit.
  • keyRecords doit contenir les DEUX types. Un type manquant constitue une 400, jamais une rotation partielle silencieuse : soumettre uniquement l'encapsulation passphrase laisserait l'enregistrement recovery encapsuler une DEK qui n'ouvre plus rien, de sorte que le code de récupération connecterait toujours le compte sans plus jamais pouvoir le déchiffrer. Chaque entrée respecte les règles du §5.4 (un descripteur recovery doit être null, un descripteur passphrase ne le doit pas). Il n'y a pas de expectedUpdatedAt par enregistrement : la soumission elle-même est l'unité de concurrence.
  • shares est la liste à CONSERVER, et chaque ligne de partage qui n'y figure pas est supprimée dans la même transaction. Cela inverse le §5.14, où un enregistrement de clé intact est conservé, délibérément, car ces lignes représentent le droit d'accès d'un tiers sur le journal de l'appelant et le silence doit être le choix par défaut sécurisé. shares: [] révoque donc tout, et est valide ; une clé absent shares constitue une 400, pour la raison qui impose au §5.4 d'écrire explicitement expectedUpdatedAt. Sur un déploiement sans SYNC_SHARING, la liste doit être vide ; une liste non vide constitue une 400, puisqu'elle affirme un état que cette instance ne peut pas conserver.
  • Un partage nommé qui n'existe pas constitue une 400, entièrement annulé, jamais traité comme un octroi. Le bénéficiaire a peut-être abandonné de son côté ; relis GET /v1/sync/shares et soumets à nouveau.
  • Les versions antérieures du blob conservées (§8) restent scellées sous l'ANCIENNE DEK et deviennent un poids mort dès qu'une rotation est validée, illisibles pour tout le monde, y compris leur propriétaire. Ils ne sont pas supprimés ici : le nettoyage les élimine en l'espace de cinq nouveaux pushs, et les supprimer lors d'une rotation priverait le propriétaire de son unique défense contre une mauvaise écriture du client au cours de la même opération.
StatutCorps
200{"newVersion": 4, "keptShares": 1, "revokedShares": 2}
400{"error": "..."} : un type d'enregistrement de clé manquant, un champ mal formé ou absent, un newRecoveryAuthHash qui n'est pas la preuve du code, une liste de conservation nommant un partage inexistant.
401{"error": "current passphrase is incorrect"} : currentAuthHash ne correspondait pas, ou la phrase de passe a changé pendant la rotation. Rien n'a été écrit.
409{"currentVersion": 5} : le CAS du blob a échoué. Rien n'a été écrit.
413{"error": "..."} : le nouveau blob dépasse MAX_BLOB_BYTES.
429{"error": "..."} avec Retry-After : les tentatives de devinette de phrase de passe de ce compte sont verrouillées.

La rotation correspond à une révocation de niveau 2, et les règles de formulation du §5.16 s'appliquent toujours. Supprimer une ligne de partage empêche le serveur de répondre ; effectuer une rotation garantit en plus que les futures entrées sont scellées avec une clé que la partie révoquée n'a jamais eue. Ni l'un ni l'autre ne récupère ce qui a déjà été téléchargé, et aucun client ne peut prétendre le contraire.

5.18 Contributions de recherche : /v1/sync/contributions et /v1/sync/study (ADR-0003)

Présent uniquement lorsque le déploiement définit SYNC_RESEARCH. S'il est absent, chaque chemin ci-dessous renvoie l'erreur 404 habituelle de route inconnue à tout appelant, avec ou sans identifiants, le point d'arrêt étant intercalé avant l'authentification. Indépendant de SYNC_SHARING ; aucun des deux drapeaux n'implique l'autre.

Côté contributeur, authentifié en tant que contributeur :

VerbeCheminRemarques
PUT/contributions/:studyAccountId{"pseudonym","schemaTier","body","contributionVersion"}. CAS sur un contributionVersion monotone. La contribution est le jeu de données cumulatif pour la fenêtre, recalculé et repoussé en entier ; le client détient toujours la source, cette ligne est donc une projection, jamais une copie primaire.
GET/contributionsLes propres inscriptions du contributeur. Ne renvoie jamais body.
DELETE/contributions/:studyAccountIdRetrait. Une seule transaction : suppression définitive de la ligne, insertion d'un marqueur de suppression indexé par pseudonyme. 204, idempotent.

Côté étude, authentifié avec le compte de l'étude :

VerbeCheminRemarques
GET/study/contributions{"pseudonym","contributionVersion","schemaTier","body","createdAt"} par ligne. Jamais d'identifiant de compte.
GET/study/withdrawalsPseudonymes qui se sont rétractés, avec horodatages. Le client de l'étude doit les purger avant de présenter ou d'exporter quoi que ce soit.

GET /study/contributions renvoie studyAccountId une fois, au niveau supérieur de l'enveloppe, pas sur chaque ligne : c'est l'identifiant propre de l'appelant, il s'est authentifié avec, il est identique pour chaque ligne et ce n'est pas un identifiant de contributeur. Le chercheur en a besoin pour reconstruire l'AAD du §3.5, et ligne par ligne, ce ne serait que du bruit.

Le compare-and-swap de contributionVersion. La valeur soumise la nouvelle version est-elle, pas une base ; elle se lie dans l'AAD, elle doit donc être la valeur sous laquelle le texte chiffré a été scellé. La règle est strictement supérieure à celle stockée : un client qui recalcule et repousse toute la projection ne doit jamais être bloqué par une version qui n'a jamais quitté l'appareil. Une écriture perdante est 409 {"currentVersion": <int>}, conformément au format du §5.1.

Le serveur valide schemaTier par rapport aux niveaux définis par ce protocole. Le nom du niveau est une métadonnée, pas du contenu (il circule en clair et le serveur le stocke déjà) et sans cette vérification, l'interdiction 1 de l'ADR-0003 n'a aucun effet en dehors du client. Un niveau inconnu correspond à 400.

Le serveur ne valide pas la forme du pseudonyme, seulement qu'il est présent et délimité. Il ne peut pas en vérifier un (cela nécessiterait la racine du contributeur) et un contrôle structurel impliquerait une autorité qu'il n'a pas.

StatutQuand
400corps malformé, schemaTier inconnu, contributionVersion absent
404étude inconnue, contribution inconnue et tout autre élément non trouvé : un seul chemin de code
409contributionVersion pas strictement supérieure à celle stockée
413la contribution dépasse MAX_CONTRIBUTION_BYTES (256 Kio)

Un pseudonyme par étude, imposé par la base de données. Deux contributeurs soumettant le même pseudonyme fusionneraient silencieusement en une seule série de participants, et un chercheur analyserait deux personnes comme une seule sans que rien n'échoue. Une collision accidentelle est d'environ 2^-128, donc la contrainte ne devrait jamais se déclencher, ce qui est le but : elle rend la corruption impossible plutôt qu'improbable.

La rétractation efface réellement de ce côté. Une contribution que l'étude n'a pas encore récupérée n'atteint personne. Ce que l'étude a déjà récupéré ne peut pas être repris : le tombstone porte la consigne, et la respecter est une obligation éthique que ce système énonce mais ne peut pas faire appliquer.

5.19 POST /v1/chat/completions : le proxy IA

Présent uniquement quand l'opérateur a configuré une clé amont. Sans cela, le chemin renvoie le 404 ordinaire pour un chemin inconnu, à tout le monde, avec ou sans identifiants, et instance.ai vaut null lors du handshake (§5.6). Une implémentation de ce protocole PEUT omettre complètement la route ; un client DOIT lire instance.ai avant de proposer un scan plutôt que de tester le chemin.

Authentifié avec le jeton d'accès ordinaire du compte (§4.1), vérifié avant que le corps ne soit lu : une requête sans jeton valide reçoit 401 quels que soient sa taille ou son format, et le service ne la met pas en mémoire tampon ni ne l'analyse. Le corps est une requête de chat-completion compatible avec OpenAI. Le service vérifie qu'il s'agit d'un objet JSON, ne transmet que les champs figurant sur une liste d'autorisation (ci-dessous), réécrit les quelques champs qui fixent le coût d'une requête, borne les données qu'une requête peut apporter, et ne rejette rien pour un champ inconnu : il l'ignore. La réponse est celle du fournisseur, transmise avec son code de statut.

L'instance détermine le coût maximal autorisé pour une requête. Une même clé en amont peut desservir tous les comptes d'une instance, et un décompte quotidien des requêtes n'indique rien sur le coût unitaire d'une requête. Le service réécrit donc ces champs avant transmission, pour chaque compte, sans jamais rejeter de requête pour ce motif.

Seuls ces champs de premier niveau sont transmis : model, messages, stream, stream_options, temperature, top_p, response_format, max_tokens, max_completion_tokens, reasoning et n. Tout autre champ est ignoré, et son nom (jamais sa valeur) est consigné dans les journaux, pour qu'un client transmettant un champ inconnu du service continue de fonctionner. À l'intérieur de messages, un message conserve role, content et name ; une partie de contenu est text (avec text) ou image_url (avec url seul, detail est donc retiré), et un image_url dont le url n'est pas une URI data:image/...;base64, est retiré, car une URL distante ou un document derrière une URI de données représente une entrée que personne n'a mesurée. Tout autre type de partie est ignoré.

ChampCe que le fournisseur reçoit
modelle modèle du palier de la requête, au choix de l'opérateur : le palier dont la route correspond au response_format.json_schema.name de la requête, sinon le palier par défaut de l'instance. Le modèle du palier par défaut est ce que instance.ai.model publie (§5.6). Une instance sans fichier de paliers possède un seul palier, dont le modèle est AI_ADVERTISED_MODEL. Si l'opérateur n'en a défini aucun, le model de l'appelant est transmis tel quel.
max_tokens, max_completion_tokensau maximum AI_MAX_OUTPUT_TOKENS (8192 par défaut), ou la limite inférieure propre au palier de la requête s'il en a une. Une valeur supérieure, ou non numérique, devient la limite. Un corps ne contenant aucun des deux reçoit max_tokens.
reasoning.max_tokensau maximum la même limite. reasoning.effort est conservé, sauf si le palier de la requête définit son propre niveau d'effort : le service applique alors cet effort et ignore le reasoning.max_tokens de l'appelant, car un fournisseur n'accepte que l'un des deux.
n1, s'il est présent.
usage, sur un serveur en amont OpenRouterécrit sous la forme {"include":true}, de sorte que la réponse indique ses décomptes de jetons et son prix (ci-dessous). Tout autre service en amont n'en reçoit aucun. Le propre usage de l'appelant est retiré.
usage, sur un serveur en amont OpenRouterécrit sous la forme {"include":true}, de sorte que la réponse indique ses décomptes de jetons et son prix (« Coût d'une complétion », ci-dessous). Tout autre service en amont n'en reçoit aucun. Le propre usage de l'appelant est retiré.
tout champ absent de la liste d'autorisation ci-dessusretirés, par exemple models, route, plugins, web_search_options, prediction, tools.
provider, sur un serveur en amont OpenRouterréécrit sous la forme {"data_collection":"deny"} : uniquement les points de terminaison qui ne stockent pas la requête et ne l'utilisent pas pour l'entraînement. L'opérateur peut ajouter "zdr":true et "only":[...] avec "allow_fallbacks":false, via le palier de la requête (routing) ou via UPSTREAM_ZDR et UPSTREAM_PROVIDER_ONLY. Le propre provider de l'appelant n'est jamais transféré. Tout autre service en amont ne reçoit aucun champ provider, peu importe la configuration.

Le palier appartient à l'opérateur, jamais à l'appelant. L'opérateur définit les paliers dans un fichier (AI_TIERS_FILE, voir le README) : chacun contient un modèle, son routage de fournisseur et, en option, une limite inférieure de sortie ainsi qu'un effort de raisonnement, et une table routes associe un nom de schéma de sortie structurée à un palier. Une requête dont le schéma est routé utilise ce palier, toutes les autres requêtes utilisent le palier par défaut. Un appelant ne peut désigner qu'un schéma, ce qui lui permet d'atteindre un palier défini par l'opérateur et aucun autre, sans jamais définir de modèle ni de fournisseur. La négociation (instance.ai.model, §5.6) nomme le modèle du palier par défaut.

Le plafond s'applique avec ou sans modèle. Un client nécessitant une réponse plus longue que ce que permet le plafond reçoit une réponse tronquée, et l'opérateur augmente AI_MAX_OUTPUT_TOKENS. Une instance en auto-hébergement qui souhaite laisser ses utilisateurs choisir le modèle ne renseigne ni AI_ADVERTISED_MODEL ni AI_TIERS_FILE.

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

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

Trois propriétés qu'une implémentation conforme DOIT respecter, et chacune existe parce que le corps de la requête est une photo de la nourriture de quelqu'un :

  1. L'identifiant de l'appelant est remplacé, jamais fusionné. Les en-têtes de la requête amont sont CONSTRUITS plutôt que copiés depuis la requête entrante puis écrasés. Une copie puis écrasement transmet les cookies, x-api-key, et tout ce que le fournisseur suivant décidera de lire.
  2. Aucun corps n'est consigné dans les logs, dans un sens comme dans l'autre. Pas un préfixe, pas un tampon décodé, pas un document d'erreur. Ce qui peut être journalisé : un identifiant de compte, le statut du serveur amont, des volumes d'octets, une durée et, lus depuis une réponse réussie, le nombre de jetons et le prix indiqués par le fournisseur ainsi qu'un nom de modèle qui ressemble à un nom de modèle (ci-dessous).
  3. Chaque chaîne reçue du réseau en amont est nettoyée avant qu'elle n'atteigne une ligne de log ou une réponse. Un fournisseur qui rejette une requête renvoie couramment la requête dans son corps d'erreur, image comprise.

Ce qu'a coûté un complètement

Un fournisseur qui rapporte l'utilisation l'inclut dans la réponse. Sur un serveur amont OpenRouter, le service écrit "usage": {"include": true} dans le corps transmis (jamais extrait de l'appelant, dont le propre champ usage est ignoré comme tout champ absent de la liste d'autorisation), et tout autre serveur amont ne reçoit aucun champ de ce type, si bien que ses corps restent tels quels. Une fois la réponse relayée, le service consigne, sur sa ligne Proxied a completion, model, promptTokens, completionTokens et costMicroUsd (le usage.cost du fournisseur, un prix en dollars, sous forme d'un nombre entier de millionièmes de dollar), chacun valant null lorsque la réponse ne le précisait pas. Il ajoute aussi costMicroUsd au total de l'instance pour la journée UTC, ai_instance_days.cost_micro_usd, une somme sans aucun compte rattaché, qui reste à 0 pour un fournisseur qui n'indique aucun prix. Les valeurs numériques sont lues au fil du passage de la réponse, qu'il s'agisse de JSON ou d'un flux d'événements envoyés par le serveur, sans délai ni modification du moindre octet. Un corps de plus de 1 Mo, une ligne de flux de plus de 64 Ko et tout champ qui n'est pas un nombre plausible ne sont pas lus et donnent null. Jamais le texte de la réponse. L'échec de l'enregistrement du coût est consigné et ne fait jamais échouer une requête qui a été servie.

Ce qu'une requête peut contenir

La sortie est plafonnée plus haut ; l'entrée est limitée ici. Mesurée sur le corps que reçoit le fournisseur (après la liste d'autorisation), une requête est refusée avec 400 avant toute revendication d'analyse, toute réservation et tout appel en amont, elle ne consomme donc rien, quand elle contient :

LimiteValeur par défautlimit
parties image_url1image-parts
octets de texte UTF-849152text-bytes
messages4messages

Le texte regroupe les chaînes content, chaque partie text, chaque name de message, et le response_format sérialisé : un schéma constitue une entrée lue par le modèle. Les octets propres à l'image ne sont pas du texte. L'opérateur définit les limites avec AI_MAX_IMAGE_PARTS, AI_MAX_TEXT_BYTES et AI_MAX_MESSAGES, et le refus indique la limite ainsi que sa valeur :

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

Les valeurs par défaut couvrent la plus grande requête réelle d'openplate avec de la marge : une photo, deux messages et environ 12 Ko de texte.

La limite de taille du corps

Le corps de la requête transporte une photo, la limite est donc dimensionnée pour cela : AI_MAX_REQUEST_BYTES, 8 000 000 octets par défaut. Le base64 augmentant la taille d'une image d'un facteur 4/3, cela permet d'acheminer un JPEG d'environ 5,7 Mio, soit la qualité par défaut de l'appareil photo d'un téléphone moderne, ce que le client envoie après réduction.

Elle est délibérément sans rapport avec MAX_BLOB_BYTES (§8). Cette valeur borne un journal que ce service stocke ; celle-ci borne une image qu'il ne fait que relayer, et déduire l'une de l'autre conduirait à rejeter toute véritable photo.

Un corps dépassant la limite reçoit un 413, pour un appelant muni d'un jeton valide ; un corps non authentifié reçoit un 401 avant toute lecture. Le corps d'erreur sur cette route adopte le format OpenAI, et non le {"error": "<sentence>"} du §4, car l'appelant est un client compatible OpenAI qui lit error.message dans un objet :

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

Un corps qui n'est pas du JSON valide renvoie 400 dans la même enveloppe avec "code": "invalid_json". Aucun des deux ne réinjecte l'entrée reçue, pour la raison énoncée dans la règle stricte 2. Une implémentation PEUT renvoyer le format du §4 à la place, mais un client conçu pour un fournisseur OpenAI n'affichera alors rien du tout au lieu d'une erreur.

Le streaming fonctionne en relais direct. Quand la requête le demande, le corps de la réponse est relayé au fil de l'eau, avec Cache-Control: no-cache, no-transform et sans Content-Length. Un service qui mettrait en mémoire tampon livrerait toujours chaque octet, de sorte qu'un client ne peut pas faire la différence, si ce n'est par la latence qu'il cherchait justement à éviter.

Le quota

Chaque compte possède deux limites quotidiennes, exprimées chacune en unités par jour UTC et ayant par défaut la valeur 0 : dailyAiLimit, celle de la période payante, et freeDailyAiLimit, celle du quota gratuit permanent (§5.15). Celle qui s'applique est déterminé pour chaque requête, dans cet ordre :

Le compte détientQuotaLimite sur laquelle réserver
allowanceExpiresAt postérieur à l'instant de la requête, dailyAiLimit > 0période payantedailyAiLimit
sinon freeDailyAiLimit > 0quota gratuitfreeDailyAiLimit
sinon DEFAULT_FREE_DAILY_AI_LIMIT de l'instance > 0quota gratuitcette valeur par défaut
sinon dailyAiLimit est 0aucun403 ai-not-allowed
sinon allowanceExpiresAt est défini (il est donc expiré)aucun403 allowance-expired
sinon trialScans est définiessai de scansdailyAiLimit
sinon (une limite, aucune date, aucun essai, aucun quota gratuit)aucun403 ai-not-allowed

La valeur par défaut de l'instance (2026-10-05). Un opérateur peut définir DEFAULT_FREE_DAILY_AI_LIMIT. C'est l'attribution gratuite accordée à chaque compte dont la valeur de freeDailyAiLimit est 0 : la même attribution au même rang de priorité, si bien qu'une période payée en cours l'emporte toujours, qu'une limite propre est conservée quelle que soit la valeur par défaut, et qu'un compte disposant d'un essai de scans bascule sous la valeur par défaut au lieu de consommer un scan. Elle n'expire jamais et ne comporte pas de restriction sur les scans. Elle n'est inscrite sur aucune ligne de la base, si bien qu'un opérateur qui la réduit ou la supprime modifie tous les comptes d'un coup. Une journée épuisée correspond à 429 ci-dessous, avec Retry-After, et jamais à 403 ai-not-allowed pour un compte sans attribution. Un service sans valeur par défaut se comporte exactement comme avant. La valeur par défaut ne peut pas être définie en même temps que l'essai de scans : une telle instance refuse de démarrer.

La dernière ligne a changé le 2026-09-30. Cette forme correspondait à un quota permanent sans fin ; chaque compte qui la détenait a été basculé vers freeDailyAiLimit par une migration, et plus rien ne l'écrit désormais. Le quota gratuit n'est jamais conditionné par l'analyse et ne prend jamais fin, donc un essai d'analyse avec un quota gratuit n'est pas décompté.

Une requête réserve max(1, ceil(estimated input tokens / AI_UNIT_INPUT_TOKENS)) unités sur la limite sélectionnée par l'ordre ci-dessus, où l'estimation, calculée avant l'appel, correspond aux octets de texte ci-dessus divisés par 4 plus AI_IMAGE_INPUT_TOKENS (1500 par défaut) par image. Avec la valeur par défaut AI_UNIT_INPUT_TOKENS de 8192, l'analyse d'assiette d'openplate pèse 1 unité et une requête proche de la limite de texte pèse 2 unités, donc pour l'application une unité équivaut à une requête. Le même poids est prélevé sur les plafonds de l'instance ci-dessous, puis restitué intégralement là où il doit l'être. Chaque réponse relayée indique la position du compte dans la limite appliquée :

En-têteSignification
X-Quota-UsedUnités consommées aujourd'hui, en incluant celle-ci
X-Quota-LimitLa limite retenue selon l'ordre ci-dessus : dailyAiLimit, freeDailyAiLimit ou la valeur par défaut de l'instance
X-Trial-Scans-LeftAnalyses gratuites restantes après cette requête, pour un compte soumis au contrôle d'accès aux analyses (ci-dessous). Absent dans le cas contraire
StatuterrorQuand
401authentication requiredAucun jeton d'accès, ou jeton expiré ou révoqué
403ai-not-allowedLe compte ne détient aucun quota (l'ordre ci-dessus). Refusé avant que quoi que ce soit ne quitte l'hôte
403allowance-expiredallowanceExpiresAt est défini et n'est pas postérieur à l'instant où la requête est arrivée, et il n'y a pas de quota gratuit. Refusé avant que quoi que ce soit ne quitte l'hôte, et avant l'écriture d'une ligne d'utilisation
403trial-scans-spentLes analyses gratuites du compte sont épuisées et il n'a pas de date de quota. Refusé avant que quoi que ce soit ne quitte l'hôte, et avant qu'une ligne d'utilisation ne soit écrite. X-Trial-Scans-Left: 0. Le corps contient "endedBy": "scans"
403trial-expiredLe champ trialEndsAt du compte est défini et n'est pas postérieur à l'instant où la requête est arrivée, ses analyses ne sont pas épuisées, et il n'a pas de date de quota. Refusé avant qu'une ligne ne soit écrite. Le corps contient "endedBy": "days"
403capability-requiredLa requête mentionne une fonctionnalité dont le compte ne dispose pas (ci-dessous). Le corps transmet capability, le libellé manquant. Requête refusée avant tout décompte et avant que quoi que ce soit ne quitte l'hôte
403account-suspendedLe compte est suspendu (le §5.9 utilise le même code)
403health-consent-requiredL'instance exige un consentement pour les données de santé et le compte ne dispose pas de sa version actuelle (§5.15.1). Refusé avant que quoi que ce soit ne quitte l'hôte, et avant qu'une ligne d'utilisation ne soit écrite
400request body must be a JSON objectLe corps n'est pas un objet. L'entrée reçue n'est jamais réinjectée
400ai-request-too-largeLe corps contient plus de parties d'image, d'octets de texte ou de messages que l'instance n'en autorise (ci-dessus). Le corps indique limit et max. Refusé avant l'écriture de toute ligne
400feature-header-invalidX-Openplate-Feature est présent et n'est pas un libellé, sur un compte contrôlé (ci-dessous). Requête refusée avant l'écriture de la moindre ligne
400intake-id-invalidX-Intake-Id est renseigné sans comporter de 16 à 64 caractères parmi A-Z a-z 0-9 _ -. Refusé avant d'enregistrer la moindre ligne
409intake-in-flightUne requête antérieure avec le même X-Intake-Id est toujours en cours de traitement, sur un compte auquel la barrière de scan s'applique (ci-dessous). Rien n'est débité et aucune ligne n'est écrite
429une phrase indiquant le moment de la réinitialisationLe quota ne peut pas couvrir les unités de cette requête. Retry-After correspond au nombre de secondes restantes jusqu'au prochain minuit UTC
429une phrase indiquant la limite par minutePlus de AI_RATE_LIMIT_PER_MINUTE requêtes au cours des 60 dernières secondes
503ai-instance-ceilingL'instance a atteint son plafond journalier global, ou les comptes à l'essai ont atteint le leur. Retry-After indique le nombre de secondes avant le prochain minuit UTC

403 ai-not-allowed est un code machine parce qu'un client DOIT faire un branchement dessus ; il signifie « ce compte ne réussira jamais ici tant qu'un opérateur ne modifie rien », ce qui n'est pas le même message à afficher que « reviens demain ». Les deux 429 sont des phrases car il n'y a aucun branchement à faire : c'est un humain qui les lit.

403 allowance-expired est un code machine distinct, et il est distinct car les deux phrases ne disent pas la même chose : "ton administrateur ne t'a jamais accordé l'IA" et "ton temps est écoulé" demandent des mots différents et des étapes suivantes différentes. Un client qui les confondrait dirait à quelqu'un dont la période d'essai a pris fin de demander à un administrateur un quota qu'il a déjà eu. Les deux refus ont lieu avant la réservation, de sorte qu'un compte qui n'a reçu aucune réponse ne voit aucune ligne d'utilisation imputée contre lui. La date est comparée selon le critère "pas après" : l'instant frontière refuse plutôt qu'il n'autorise. La synchronisation n'est pas affectée sur un compte expiré (§5.15).

403 trial-scans-spent est un code machine troisième, correspondant à une troisième phrase : "tu as utilisé tes analyses gratuites". Pour ce code, le client affiche l'offre d'abonnement, au lieu de "contacte ton administrateur" (ai-not-allowed) ou "ton temps est écoulé" (allowance-expired). Un client plus ancien que le code lit une valeur 403 inconnue, c'est pourquoi il reste distinct au lieu d'être intégré à l'un d'eux.

403 trial-expired correspond à quatrième, pour l'autre limite de l'essai de scans : "tes jours gratuits sont terminés". Il ne s'agit pas de allowance-expired, qui signale l'expiration d'une période payée ou accordée ; un client qui confondrait les deux indiquerait à une personne ayant payé que son essai est terminé. Les deux refus d'essai comportent endedBy, "scans" ou "days", afin qu'un client puisse identifier d'après un seul champ la limite qui a mis fin à l'essai :

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

L'ordre des refus, qu'un serveur conforme DOIT respecter : identité et suspension ; consentement aux données de santé (health-consent-required, §5.15.1) ; attribution (le tableau ci-dessus : ai-not-allowed ou allowance-expired) ; le corps est lu, puis la capacité (capability-required, feature-header-invalid, ci-dessous) ; le contenu entrant du corps (ai-request-too-large) ; la structure de X-Intake-Id ; puis, seulement pour l'attribution d'essai de scans (scans gratuits, date de quota à aucune et aucune attribution gratuite), la limite journalière (trial-expired, vérifiée uniquement quand les scans ne sont pas épuisés, afin que les scans consommés conservent leur propre code) et la réservation de scan (intake-in-flight, trial-scans-spent). Une date future lève les deux : c'est une période payée ou accordée, et les limites de l'essai ne s'appliquent qu'en l'absence totale de date. Une attribution gratuite lève aussi les deux. Viennent ensuite le plafond des comptes en essai de scans, le plafond de l'instance et le quota journalier, comme ci-dessous.

Fonctionnalités

Une capacité est un libellé court, comme scan ou recipes, qui désigne un type de requête IA. Un compte en possède une liste, et le proxy refuse toute requête dont le compte ne détient pas la fonctionnalité. Le service ignore pourquoi un compte possède un libellé : un opérateur écrit la liste (§5.20), tout comme l'identifiant de facturation, qui peut mentionner capabilities sans conférer d'autres droits au-delà de ses deux autres champs.

Un libellé est une lettre minuscule suivie d'au plus 31 lettres minuscules, chiffres ou tirets (^[a-z][a-z0-9-]{0,31}$). Une liste en contient au plus 32, stockée après déduplication et tri, et ne peut pas contenir none, qui est réservé.

L'enregistrement propre au compte a trois états, et ce sont trois états de fait distincts. null signifie aucun enregistrement, c'est donc la valeur par défaut de l'instance qui s'applique. [] est un enregistrement qui n'accorde rien. Une liste accorde précisément ces libellés. La valeur effective est l'enregistrement propre, sinon DEFAULT_CAPABILITIES de l'instance (publié sous instance.defaultCapabilities, §5.6), sinon null, et un null effectif équivaut à une absence totale de vérification : chaque requête passe, sans même refuser un en-tête mal formé. C'est le comportement qu'a toujours eu une instance qui ne configure rien. DEFAULT_CAPABILITIES non défini ou vide vaut null. Le mot none correspond à la liste vide, car les fichiers compose transmettent une variable non définie sous la forme d'une chaîne vide.

Comment une requête indique sa fonctionnalité.

  • L'en-tête de requête X-Openplate-Feature: <label>. Un en-tête qui n'est pas un libellé donne 400 feature-header-invalid. L'en-tête n'est que la déclaration du client, il ne protège donc rien à lui seul.
  • La sortie structurée du corps, response_format.json_schema.name. L'opérateur peut associer un nom de schéma à un libellé avec CAPABILITY_SCHEMA_MAP (paires schemaName:label). Un corps qui demande un schéma listé requiert ce libellé quoi que dise l'en-tête, si bien qu'un client qui ment dans l'en-tête n'y gagne rien. Quand les deux libellés sont absents, c'est le libellé du schéma qui est rapporté.

Une requête qui ne mentionne aucune fonctionnalité et aucun schéma listé ne demande rien que la vérification puisse comparer, et elle passe. La table des schémas permet d'imposer une fonctionnalité même face à un client qui n'envoie aucun en-tête.

Le refus correspond à 403 {"error": "capability-required", "capability": "<label>"}. La décision intervient après le quota et le corps, et avant le décompte journalier, la réservation de scan, les plafonds de l'instance et le fournisseur : une requête refusée n'écrit aucune ligne d'utilisation, ne consomme aucun scan et n'envoie rien au serveur amont. Ainsi, un compte auquel il manque une fonctionnalité reçoit 403 et jamais 429, et un compte sans aucun accès IA reçoit toujours ai-not-allowed en premier. Le client effectue un branchement sur le code : cela signifie "ce compte ne réussira pas ici tant que ses capacités ne changent pas", et non "reviens demain". Un client sur navigateur peut envoyer l'en-tête en cross-origin : il figure sur la liste d'autorisation CORS.

La période d'essai d'analyse

Un compte peut bénéficier d'analyses IA gratuites (AccountView.trialScans, §5.15), et d'une date de fin (AccountView.trialEndsAt), accordées par l'essai de l'instance (instance.trial, §5.6) : un certain nombre d'analyses ou un certain nombre de jours, selon la première échéance atteinte. Une analyse correspond à une action IA initiée par la personne, et une action peut représenter plusieurs requêtes vers l'amont : un client peut réessayer une fois sans response_format après un refus du fournisseur. (Une nouvelle tentative après un jeton porteur périmé est refusée par la vérification du porteur, §4.1, avant toute réservation.) Une analyse donne droit à une réponse livrée.

X-Intake-Id permet au client de préciser quelles requêtes forment une seule action. C'est optionnel, de 16 à 64 caractères de A-Z a-z 0-9 _ - (un UUID avec ou sans tirets convient), un nouvel identifiant par action de la personne, réutilisé par toutes les tentatives de cette même action, envoyé uniquement à ce proxy, jamais à un fournisseur configuré directement par la personne. Le service :

  • réserve une analyse pour un identifiant inédit avant l'appel amont, au sein d'une seule instruction dont WHERE forme la limite, si bien que dix requêtes parallèles sur trois analyses n'en consomment que trois ;
  • refuse une requête portant un identifiant dont une requête antérieure est toujours en cours de traitement avec 409 intake-in-flight, sans rien dépenser : des requêtes concurrentes sur un même identifiant recevraient deux réponses pour un seul scan. Un identifiant redevient utilisable une fois sa requête terminée. Une requête en échec a restitué son scan, la nouvelle tentative le réclame donc à nouveau sans coût supplémentaire, une requête intervenant après une réponse livrée constitue une nouvelle action consommant un nouveau scan, refusée avec 403 trial-scans-spent s'il n'en reste aucun,
  • traite une requête toujours en cours de traitement après 30 minutes comme ayant échoué sans aboutir, et permet à la requête suivante sur cet identifiant de reprendre son scan sans en consommer un nouveau, empêchant tout blocage d'un identifiant au-delà de cette durée,
  • associe chaque restitution et chaque remise à l'attribution correspondante, pour qu'une requête qui échoue tardivement ne renvoie jamais un scan déjà attribué à une requête plus récente sur le même id ;
  • sérialise les requêtes parallèles partageant un nouvel identifiant, de sorte qu'exactement l'une d'entre elles réserve un scan et que les autres soient 409 intake-in-flight,
  • traite une requête avec un identifiant aucune comme une action distincte, ce qui permet de compter correctement chaque action à requête unique pour un client qui n'en envoie jamais.

Les identifiants sont conservés pendant 24 heures puis supprimés (§9.2). Ils ne sont jamais journalisés.

Une requête qui a reçu l'absence de réponse restitue son analyse : la restitution s'applique à chaque ligne du tableau ci-dessous, sauf pour une réponse 2xx bien reçue, et à chaque refus intervenant après la réservation (les plafonds et le quota journalier). Cela diffère volontairement de l'unité quotidienne, ligne par ligne :

RésultatUnité journalièreNumérisationPourquoi l'analyse diffère, là où c'est le cas
Connexion refusée / expiration de l'en-têtelibéréelibérée
4xx amontlibéréelibérée
5xx amontdépenséelibéréeL'unité protège la facture : la génération a pu tourner. L'analyse protège la promesse qu'une tentative échouée ne coûte rien, et la personne n'a reçu aucune réponse. Une boucle de réessai sur un fournisseur instable reste bornée par l'unité journalière
Expiration du corps / flux interrompu par le fournisseurdépenséelibéréeLes en-têtes sont arrivés, le fournisseur peut donc facturer ; la personne n'a toujours reçu aucune réponse
2xx en amont, puis l'appelant raccrochedépenséedépenséeLa réponse était en route
2xx amontdépenséedépensée
Un plafond ou le quota journalier refuse après la réclamationnon pris, ou libérélibéréeLa requête n'a atteint personne

Le plafond de l'instance

Un opérateur PEUT définir un plafond sur l'ensemble de l'instance, exprimé dans la même unité que le quota ci-dessus : en unités par jour UTC, tous comptes confondus (AI_INSTANCE_DAILY_LIMIT). L'absence de valeur signifie qu'il n'y en a aucun, ce que conserve une instance en Auto-hébergement et ce que conserve chaque déploiement existant.

Il existe car toutes les autres limites ici sont par compte. Dix comptes à 200 requêtes par jour font 2000 requêtes par jour sur la clé de fournisseur de l'administrateur, les invitations multiplient donc les comptes sans multiplier la limite.

Quand le plafond est atteint, chaque compte est refusé, y compris un compte qui n'a rien consommé de son propre quota, jusqu'au prochain jour UTC. Le refus est 503 ai-instance-ceiling avec Retry-After en secondes. C'est un 503 plutôt qu'un 429 ou un 403 car ce n'est ni la faute de l'appelant ni le quota de l'appelant : le service n'a plus la capacité payée par son administrateur. Un client DOIT faire un embranchement dessus, car "l'administrateur n'a plus de capacité aujourd'hui" est un écran différent de "tu n'as plus de requêtes aujourd'hui", et seul le second concerne la personne qui lit.

Les unités de l'instance sont prélevées avant celles du compte, ainsi une instance refusée ne facture jamais personne, et elles sont restituées dès que celles du compte le sont (le tableau ci-dessous s'applique aux deux, ligne par ligne).

Le plafond n'est pas publié sur /health : c'est le budget de l'administrateur, et cette négociation n'est pas authentifiée. GET /v1/admin/stats l'indique sous la forme aiInstanceDailyLimit, à côté du aiRequestsToday qu'il plafonne.

Les comptes en essai d'analyse peuvent avoir leur propre plafond (AI_TRIAL_INSTANCE_DAILY_LIMIT) : unités par jour UTC sur l'ensemble des comptes auxquels la barrière de scan s'applique. Elle refuse ces comptes, et uniquement ceux-là, avec le même 503 ai-instance-ceiling. Lorsqu'il est configuré, une requête d'essai de scan est décomptée de celui-ci uniquement, et jamais de AI_INSTANCE_DAILY_LIMIT, qui limite ensuite tous les autres comptes, empêchant ainsi le trafic d'essai d'épuiser la capacité réservée aux comptes payants. La facture quotidienne maximale auprès du fournisseur correspond à la somme des deux. Lorsqu'il n'est pas défini, les requêtes d'essai de scan sont décomptées du plafond de l'instance comme celles de tous les autres. Il n'est pas publié non plus, GET /v1/admin/stats le renvoie sous la forme aiTrialInstanceDailyLimit, aux côtés de signup.trialRequestsToday.

Lorsque le plafond d'essai est défini, un réseau appelant en reçoit une part (AI_TRIAL_NETWORK_DAILY_LIMIT, un dixième du plafond d'essai par défaut, arrondi à l'inférieur, au moins 1) : unités par jour UTC que les requêtes d'essai d'analyse issues d'un même réseau peuvent consommer. Un réseau est un /64 IPv6, ou une seule adresse IPv4, selon le décompte des limiteurs de connexion. Une requête d'essai d'analyse provenant d'un réseau qui a épuisé sa part reçoit le même 503 ai-instance-ceiling avec le même Retry-After, de sorte qu'un client n'a besoin d'aucune nouvelle branche conditionnelle ; elle ne consomme ni analyse ni unité, et le fournisseur n'est pas appelé. Les requêtes couvertes par une période payante ou une attribution gratuite permanente ne sont jamais comptabilisées ni refusées par ce mécanisme. Ses unités sont restituées en même temps que celles du plafond d'essai. Plusieurs personnes derrière un même NAT opérateur IPv4 partagent un même panier ; un appelant IPv6 dispose de son propre /64. Le service ne conserve aucune adresse pour cela : une ligne par réseau et par jour stocke un hachage avec clé (HMAC-SHA256 sous TRIAL_ADDRESS_PEPPER) du réseau et du jour, et la ligne est supprimée le lendemain.

Ce qui est dépensé et ce qui est restitué

Une unité est réservée avant l'appel amont, jamais décomptée après. Le décompte a posteriori laisse une fenêtre pendant laquelle N requêtes en parallèle lisent toutes l'ancien total et passent toutes, et un client qui réessaie en cas d'erreur est précisément celui qui les déclenche en même temps.

RésultatUnitéPourquoi
Connexion refusée / échec DNSlibéréeLa requête n'a jamais quitté cet hôte
Délai d'attente des en-têtes dépassé (aucun octet reçu)libéréeRien ne nous a été renvoyé ; notre propre limite a expiré avant la réponse du fournisseur
4xx amontlibéréeLe fournisseur l'a REFUSÉE. Elle n'a atteint aucun modèle, personne ne l'a donc facturée, et débiter le compte pour une erreur de configuration de l'opérateur lui-même permettrait à un proxy défaillant de consommer tout le quota d'une organisation en une minute
5xx amontdépenséeLe fournisseur l'a acceptée et a échoué pendant le traitement. La génération a pu tourner. Libérer l'unité ici créerait une boucle de réessais infinis gratuits contre le fournisseur même qui flanche
Délai d'attente du corps dépassé / flux interrompudépenséeLes en-têtes sont déjà arrivés, le fournisseur l'a donc exécutée. Que nous n'ayons pas réussi à lire la réponse est notre problème, pas un motif de remboursement
2xx amontdépenséeÉvidemment

Le service enregistre un entier par compte et par jour UTC et rien d'autre : aucun prompt, aucune réponse, aucun nom de modèle, aucun horodatage plus précis que le jour (§9.2).

5.20 L'API d'administration : /v1/admin

Interface opérateur, pas interface client. Un client openplate n'utilise qu'un seul de ces points d'accès, et seulement si le compte connecté est administrateur : la console affichée par l'application à /admin. Un autre client peut ignorer complètement cette section.

Deux identifiants y ont accès, tous deux sous la forme d'un simple Authorization: Bearer :

  1. Le jeton d'opérateur statique (ADMIN_TOKEN), qui continue de fonctionner même quand tous les comptes sont bloqués.
  2. Un compte dont le role vaut admin, avec son propre jeton d'accès. C'est ce qui permet d'afficher la console dans l'application plutôt que dans un shell.
  3. Un jeton de service à portée restreinte (BILLING_TOKEN). Il s'agit d'un TROISIÈME intervenant, non d'une copie du premier : il n'accède qu'à trois routes et trois champs, et se voit rejeté partout ailleurs. Voir "L'intervenant de facturation" ci-dessous.

Lorsque aucun n'est ni configuré ni correspondant, tout le sous-arbre renvoie à tout le monde le même 404 que n'importe quel chemin inconnu. Une instance qui n'a configuré aucun des deux jetons est impossible à distinguer d'une instance construite avant l'existence de la fonctionnalité. Renvoyer 401 annoncerait qu'un identifiant existe et qu'il est simplement verrouillé. Définir l'un ou l'autre jeton transforme ce 404 en le 401 qu'obtient une mauvaise valeur.

Point de terminaisonFait
GET /v1/admin/statsTotaux agrégés : comptes, blobs, octets, enregistrements de clés, pendingInvites, admins, aiRequestsToday, et le aiInstanceDailyLimit qui le borne (null pour aucun plafond) ; aiTrialInstanceDailyLimit ; et signup : invitations créées par la porte de requête du §5.8.3 aujourd'hui et au cours des sept derniers jours, essais accordés au cours des sept derniers jours, et requêtes d'essai d'analyse du jour
GET /v1/admin/ai/budgetLe budget de la clé de fournisseur et la capacité IA du jour, voir « Le budget IA » ci-dessous. 404 sur une instance sans IA. Inaccessible avec BILLING_TOKEN
GET /v1/admin/accountsUne page de AccountView, plus total
GET /v1/admin/accounts/expiringUne page de { id, allowanceExpiresAt } pour les comptes dont le quota s'achève dans le futur, plus total
GET /v1/admin/accounts/:idUn AccountView
GET /v1/admin/accounts/:id/activityDernière connexion, et une entrée par jour UTC sur une période délimitée
GET /v1/admin/activityLa même frise jour par jour pour toute une PAGE de comptes, dans l'ordre de la liste
PATCH /v1/admin/accounts/:idrole, dailyAiLimit, allowanceExpiresAt (un instant ISO, ou null pour l'effacer), freeDailyAiLimit (l'attribution gratuite permanente, un entier de 0 à 10000, non modifiable avec BILLING_TOKEN), capabilities (la propre liste de capacités du compte, un tableau de libellés, [] pour un enregistrement qui n'accorde rien, ou null pour supprimer l'enregistrement afin que la valeur par défaut de l'instance décide, modifiable avec BILLING_TOKEN, §5.19), trialScans (les analyses gratuites accordées, un entier de 0 à 100, ou null pour retirer la période d'essai d'analyse, sans jamais modifier le nombre d'analyses utilisées), suspended, displayName, label (la note de l'opérateur, voir ci-dessous, ou null pour l'effacer). Au moins un champ est requis
POST /v1/admin/accounts/:id/reset-mailDéclenche la réinitialisation du §5.12 à l'initiative de l'opérateur
DELETE /v1/admin/accounts/:idSupprime le compte et tout ce qui s'y rattache
GET /v1/admin/accounts/:id/blob/versionsChaque version de blob conservée : numéro, version d'enveloppe, taille en octets, horodatage, et l'épingle si elle en a une. Jamais de texte chiffré
POST /v1/admin/accounts/:id/blob/rollback{"targetVersion": n}. Rend cette version à nouveau active en SUPPRIMANT toutes les versions supérieures (garde de rétrécissement du §5.1, ADR-0009). Refuse une version inconnue, la version actuelle, une version d'enveloppe non acceptée par cette version du binaire, et une ligne de zéro octet. Un retour en arrière plutôt qu'un nouvel envoi, car les AAD du §3.2 lient blobVersion : réinsérer d'anciens octets comme nouvelle version produit un résultat qu'aucun client ne peut déchiffrer
GET /v1/admin/invitesUne page d'invitations en attente, plus total
POST /v1/admin/invitesGénère un jeton (§5.8). Le jeton est renvoyé une fois. "trial": true enregistre l'essai de scan de l'instance au lieu d'un quota : 400 sur une instance qui n'en utilise aucun, et 400 à côté d'un dailyAiLimit. Sans ce champ, la valeur dailyAiLimit de la génération devient le don gratuit permanent du compte (freeDailyAiLimit) lors de l'activation
POST /v1/admin/trials/grant-lapsed{"trialDays": n, "apply": false, "excludeAccountIds": []}. Liste, ou avec apply: true accorde l'essai d'analyse de l'instance à, chaque membre dont l'essai de trialDays jours a pris fin et n'a jamais été déplacé : sa date de quota est toujours égale à son utilisation plus trialDays à la milliseconde près, ce que seuls un paiement ou un opérateur peuvent modifier. Efface la date et définit la limite quotidienne de l'essai. Idempotent : un compte accordé n'est plus jamais listé. Répond {"accountIds": [...], "applied": bool}
POST /v1/admin/invites/:id/resendUn NOUVEAU jeton sur la MÊME ligne, et une nouvelle expiration
DELETE /v1/admin/invites/:idRévoque une invitation en attente
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": {...}}` avec ce que l'instance contient désormais
GET /v1/admin/feedbackUne page d'estimations signalées (§5.25), de la plus récente à la plus ancienne : { id, accountId, hasImage, consentWordingVersion, createdAt } chacune, plus total, limit et offset. Aucun chiffre et aucune photographie
GET /v1/admin/feedback/:idUn signalement : les champs de la liste, measurements exactement tels que l'appareil les a envoyés, et consent: { agreedAt, wordingVersion }
GET /v1/admin/feedback/:id/imageLes octets de la photographie sous son Content-Type stocké, avec Cache-Control: no-store et X-Content-Type-Options: nosniff. 404 quand le signalement n'en comporte pas. Chaque lecture est journalisée avec l'identifiant du signalement et l'identifiant d'authentification utilisé
DELETE /v1/admin/feedback/:idSupprime la photographie, puis le signalement. 204, ou 404 pour un identifiant inconnu

GET /v1/admin/ai/budget est le budget IA de l'opérateur : le solde restant sur la clé du fournisseur, et la part consommée de la capacité quotidienne de l'instance.

json
{
  "day": "2026-09-30",
  "capacity": {
    "paid": { "used": 412, "limit": 2000 },
    "trial": { "used": 37, "limit": 500 }
  },
  "upstream": {
    "status": "ok",
    "limitUsd": 5,
    "remainingUsd": 3.94,
    "reset": "monthly",
    "usageDailyUsd": 0.12,
    "usageWeeklyUsd": 0.4,
    "usageMonthlyUsd": 1.06,
    "checkedAt": "2026-09-30T10:00:00.000Z"
  }
}
  • day est le jour UTC sur lequel portent les plafonds. capacity est exprimé en unités, les volumes pondérés par la taille que le proxy réserve. paid.used correspond à ce qui a été décompté de AI_INSTANCE_DAILY_LIMIT et trial.used à ce que les comptes d'essai de scan ont consommé. Chaque limit indique le plafond configuré, ou null en l'absence de limite. Sans plafond d'essai, les requêtes d'essai sont également décomptées dans paid.used.
  • upstream vaut null quand le service en amont n'est pas OpenRouter. Sinon, il s'agit du relevé de la clé GET /key d'OpenRouter, en dollars : limitUsd et remainingUsd valent null pour une clé sans limite, et reset vaut "daily", "weekly", "monthly" ou null pour une limite sans réinitialisation. Une lecture en échec renvoie {"status": "unavailable", "checkedAt": ...}, et capacity reste transmis.
  • La lecture de la clé s'exécute sur le serveur avec un délai d'expiration de 5 secondes, et reste servie depuis la mémoire pendant 60 secondes, ou 15 secondes en cas d'échec. Le corps ne contient aucune clé, aucun libellé de clé ni aucun autre élément transmis par le fournisseur.
  • Sur cette même lecture, lorsque remainingUsd descend sous AI_BUDGET_ALERT_FRACTION (valeur par défaut 0.2) de limitUsd, l'opérateur reçoit un courriel par période de réinitialisation à MAIL_OPERATOR_EMAIL. Le service lit également la clé toutes les 15 minutes, afin que l'envoi du courriel ne dépende pas de l'ouverture de la console par un tiers.

PATCH est la seule opération d'écriture liée à l'authentification dont dispose un opérateur, et elle est délibérément restreinte. Elle ne peut pas définir de phrase de passe, et aucun point de terminaison ne le permet : la phrase de passe enveloppe la clé de données sur le client, de sorte qu'une modification des identifiants côté serveur produirait un compte qui se connecte sans rien pouvoir déchiffrer. Elle ne peut pas changer le email d'un compte, car cette adresse a été vérifiée par l'invitation. Elle ne peut pas afficher de code de récupération.

La suspension révoque chaque session dans le même temps. Un suspended_at seul laisserait le téléphone dans la poche de l'utilisateur continuer à se synchroniser pendant un quart d'heure de plus, ce qui ne correspond pas à ce qu'un opérateur attend de ce terme. La réactivation ne restaure aucune session ; la personne se reconnecte.

Un COMPTE administrateur ne peut ni se suspendre, ni se rétrograder, ni se supprimer lui-même : 400, avec {"error": "self-change"}. Une organisation disposant d'un unique administrateur qui procède ainsi bloque l'accès à toute cette arborescence pour tout le monde, et le seul recours consiste à ouvrir un shell sur le conteneur. Le jeton statique est exempté, car il ne correspond à aucun profil et constitue précisément l'identifiant prévu pour cette situation.

label est la note propre à l'opérateur sur un compte, tel que "Beta supporter", ou null pour aucun. Chaque compte dans GET /v1/admin/accounts et GET /v1/admin/accounts/:id porte la clé.

  • PATCH avec {"label": "Beta supporter"} la définit et {"label": null} l'efface. La valeur est nettoyée des espaces aux extrémités, et une chaîne vide une fois nettoyée l'efface aussi, donc aucun libellé vide n'est jamais stocké.
  • Au maximum 40 caractères, comptés en points de code Unicode, l'unité que char_length de Postgres compte. Un libellé plus long, un libellé avec un saut de ligne, une tabulation ou tout autre caractère de contrôle, ainsi que tout ce qui n'est pas une chaîne ou null donne 400 et rien dans le corps n'est écrit. Une contrainte de vérification sur la colonne applique la même limite, donc un outil qui écrit directement s'y conforme aussi.
  • Un fait pour l'opérateur, jamais une entrée d'autorisation. Aucune route ne la lit pour prendre une décision. Le GET /v1/auth/account propre au compte ne la porte pas, le compte ne peut pas la définir (PATCH /v1/auth/account lit uniquement displayName), et l'entité de facturation ne peut ni la lire ni l'écrire.
  • pnpm core-api accounts set-label <id> "Beta supporter" le définit et pnpm core-api accounts clear-label <id> l'efface.

GET /v1/admin/accounts/:id/activity répond à la question avec laquelle un opérateur ouvre la console : cette personne utilise-t-elle encore l'instance. Il lit ce que le service stocke déjà et ne collecte rien de nouveau.

json
{
  "accountId": 7,
  "lastSeenAt": "2026-09-06T18:30:00.000Z",
  "window": { "days": 90, "fromDay": "2026-06-10", "toDay": "2026-09-07" },
  "days": [
    { "day": "2026-06-10", "count": 0 },
    { "day": "2026-06-11", "count": 3 }
  ]
}
  • lastSeenAt vaut null pour un compte qui ne s'est jamais connecté, et n'est écrit que lors d'une connexion et d'une complétion relayée, jamais lors d'un rafraîchissement de jeton ni d'un sondage de synchronisation (§9.2). Il transite sur le réseau sous la forme d'un horodatage ; une formulation relative relève de l'affichage et revient au client.
  • days contient chaque jour de la période, dans l'ordre, avec count: 0 pour un jour sans ligne. Un jour manquant et un jour sans activité ne doivent pas avoir la même apparence pour quiconque lit la frise.
  • ?days=N rétrécit la période. N doit être un entier d'au moins 1, sinon la réponse est 400. Une période de plus de 90 jours renvoie 90, et window indique ce qui a réellement été extrait. Quatre-vingt-dix correspond à la durée de rétention ci-dessous, donc une frise plus longue ne ferait qu'afficher des zéros pour des lignes déjà supprimées.
  • Un identifiant inconnu renvoie le même 404 que toutes les autres routes de compte, et l'arborescence entière est protégée par les identifiants ci-dessus.

GET /v1/admin/activity répond à cette même question pour une page entière en une seule fois, car une liste de personnes affiche une frise à côté de chaque ligne et faire une requête par ligne revient à un problème N+1.

json
{
  "window": { "days": 7, "fromDay": "2026-09-02", "toDay": "2026-09-08" },
  "accounts": [{ "accountId": 2, "days": [{ "day": "2026-09-02", "count": 0 }] }],
  "total": 4
}
  • ?limit= et ?offset= se comportent exactement comme sur GET /v1/admin/accounts : mêmes valeurs par défaut, même plafond, même 400 avec la même phrase. C'est le contrat, pas une coïncidence : un appelant pagine les deux points de terminaison de manière synchronisée et affiche la frise n à côté de la personne n, donc accounts ici suit l'ordre renvoyé par cette liste pour la même page, et total correspond au total de cette liste.
  • ?days=N correspond à la fenêtre du point de terminaison ci-dessus, bornée de la même façon : un entier d'au moins 1 ou un 400, une valeur supérieure à 90 renvoie 90, et window indique ce qui a été extrait.
  • Chaque compte de la page apparaît, y compris celui qui n'a jamais fait de requête, dont le days est une frise de zéros. Omettre un compte confondrait « cette personne n'a rien fait » et « cette personne n'était pas dans la réponse », ce qui est précisément l'erreur que le remplissage par des zéros au jour le jour vise à éviter, un niveau au-dessus.
  • Chaque entrée contient accountId et days, et rien d'autre. L'adresse, le nom et le quota appartiennent à GET /v1/admin/accounts, que l'appelant lit déjà.

Rétention : les compteurs d'utilisation sont conservés pendant 90 jours. ai_usage_days stocke un entier par compte et par jour UTC (§9.2). Un nettoyage horaire au sein du service supprime chaque ligne de plus de 90 jours, aujourd'hui compris, sur chaque instance, sans intervention d'un opérateur ni tâche cron. Supprimer un compte efface ses compteurs et son lastSeenAt dans la même instruction que le reste de la suppression, via ON DELETE CASCADE. Quatre-vingt-dix est une valeur unique à un seul endroit : c'est le seuil auquel le nettoyage purge et la période maximale à laquelle le point de terminaison ci-dessus peut répondre.

Le principal de facturation (BILLING_TOKEN). Un service de paiement doit modifier deux nombres et une liste sur un compte : la fin d'un quota, le nombre de requêtes d'IA par jour qu'il achète, et les libellés des fonctionnalités d'IA qu'il active. Lui donner le jeton d'opérateur lui donnerait chaque adresse sur l'instance, le bouton d'effacement et les photos signalées, le droit d'accès est donc restreint dès l'entrée. Il est facultatif, non défini par défaut, et exige le même minimum de 24 caractères que le jeton d'opérateur.

Point de terminaisonLe principal de facturation peut
GET /v1/admin/accounts/expiringLire { id, allowanceExpiresAt } pour les comptes dont la date de fin est dans le futur, paginé avec les mêmes termes limit, offset et 400 que tous les autres points de terminaison paginés d'ici
GET /v1/admin/accounts/:idLis { id, allowanceExpiresAt, dailyAiLimit, capabilities } pour ce compte précis, où capabilities est le propre enregistrement du compte (null correspond à aucun enregistrement)
PATCH /v1/admin/accounts/:idÉcris allowanceExpiresAt, dailyAiLimit et capabilities, et rien d'autre. trialScans est refusé comme tous les autres champs : un identifiant qui paie un quota ne distribue pas d'analyses gratuites
  • Toutes les autres routes de cette section répondent 403 avec {"error": "service-scope"}, y compris les quatre routes de retour d'expérience et y compris toute route ajoutée après la rédaction de ce texte. Le refus intervient au montage, avant qu'aucun gestionnaire ne s'exécute et avant qu'aucune ligne ne soit lue, ce n'est donc pas un oracle permettant de savoir si un compte existe.
  • Un corps de PATCH nommant tout autre champ est 403 avec {"error": "service-scope-field"}, et rien n'est écrit, pas même les champs autorisés à ses côtés. Une omission silencieuse ferait passer une anomalie du service de facturation pour un succès.
  • Les valeurs sont également limitées en portée. allowanceExpiresAt: null (un quota sans fin) et une valeur de dailyAiLimit supérieure au BILLING_MAX_DAILY_AI_LIMIT de l'instance (1000 par défaut) sont 403 avec {"error": "service-scope-value"}, et rien n'est écrit. Les identifiants de l'opérateur peuvent écrire les deux. capabilities accepte toute liste valide, ainsi que null, qui supprime l'enregistrement et n'accorde donc jamais plus que la valeur par défaut de l'instance choisie par l'opérateur. Une liste mal formée renvoie l'erreur ordinaire 400, pour cet identifiant comme pour un opérateur.
  • Au-delà, dailyAiLimit est validé exactement comme pour un opérateur. L'identifiant n'assouplit aucune validation.
  • Les deux lectures sont des projections et jamais un AccountView. Pas d'adresse, pas de nom d'affichage, pas de rôle, pas de suspension, pas d'utilisation, pas de blob. `GET

/v1/admin/accounts/expiring` sélectionne deux colonnes dans la requête au lieu de filtrer une ligne après coup.

  • Un compte supprimé et un identifiant inconnu renvoient le même 404. Ici, l'effacement fonctionne en cascade et non par marqueur d'effacement (§9), il ne reste donc rien pour les distinguer, et le deletedAt que cette route pourrait renvoyer constituerait une trace conservée d'une personne après l'effacement qui l'a supprimée. Tous deux signifient « cesser la facturation ».
  • Le principal n'a pas de profil propre, la règle ci-dessus sur l'auto-modification ne peut donc pas s'appliquer à lui : il ne peut suspendre, rétrograder ni supprimer personne, pas même lui-même, car aucune de ces routes n'est accessible.

AccountView a la même structure que ce que renvoie le propre GET /v1/auth/account du compte (§5.15), invitesLeft inclus et calculé de la même manière, plus aiUsedToday, et sur la surface d'administration plus lastSeenAt, label, blob et keyRecordKinds. capabilities et freeDailyAiLimit sur la surface d'administration sont le PROPRE enregistrement du compte (pour capabilities, null correspond à aucun enregistrement), où la vue propre au compte indique la valeur effective. healthConsent s'y trouve aussi, et il vaut lecture seule ici : PATCH /v1/admin/accounts/:id ne le lit pas, car un consentement qu'un opérateur pourrait définir au nom de quelqu'un d'autre ne prouverait rien (§5.15.1). Il contient aucun vérificateur, aucun descripteur KDF, aucun séquestre et aucun texte chiffré. Un blob est indiqué sous la forme d'un nombre d'octets et d'un horodatage. Le raisonnement est docs/adr/0001-an-admin-api-for-a-zero-knowledge-service.md, dont ADR-0005 remplace les interdictions 1, 2, 3, 5 et 8, mais pas l'interdiction de renvoyer des secrets dans une réponse.

5.21 POST /v1/auth/invites : un membre invite quelqu'un

Bearer, limité par adresse source avec chaque tentative comptée. Présent uniquement quand le déploiement définit à la fois MEMBER_INVITE_DAILY_AI_LIMIT et MEMBER_INVITE_ALLOWANCE_DAYS, ou à la place MEMBER_INVITE_TRIAL à côté de l'essai d'analyse de l'instance ; sans l'un ni l'autre, ce chemin renvoie le 404 habituel de chemin inconnu à tout appelant, connecté ou non, et instance.memberInvites vaut false (§5.6).

Requête : {"email": "boris@example.org"}, et rien d'autre.

json
{}

→ 202 avec ce corps, vide et invariable.

Les conditions sont celles de l'instance, jamais celles de l'appelant. Le compte invité reçoit role: "member", la même durée de vie d'invitation que la valeur par défaut créée par l'admin, et UNE attribution parmi deux, jamais les deux : sous la paire de jours, dailyAiLimit depuis MEMBER_INVITE_DAILY_AI_LIMIT et un allowanceExpiresAt égal à l'utilisation plus MEMBER_INVITE_ALLOWANCE_DAYS inscrit lors de l'inscription ; sous MEMBER_INVITE_TRIAL, l'essai d'analyse de l'instance (§5.19) sans date. Une invitation de membre créée sous la paire de jours et utilisée après le basculement de l'instance reçoit l'essai d'analyse, pas une date. Un dailyAiLimit, un role ou un expiresInDays dans le corps n'est pas refusé, il n'est simplement pas lu. Ces trois éléments SONT des champs du corps sur POST /v1/admin/invites (§5.20), ce qui constitue la différence entre un membre et un opérateur.

La réponse NE DOIT PAS varier selon l'état réel de l'adresse. Une nouvelle adresse, une adresse associée à une invitation en attente et une adresse associée à un compte existant reçoivent un seul 202 avec un seul corps. Il s'agit de la propriété anti-énumération des §5.7 et §5.12 appliquée à l'unique point d'entrée qu'un membre pointe vers la boîte aux lettres de quelqu'un d'autre : une personne qui saisit l'adresse de son collègue ne doit pas déduire d'un code d'état, d'un corps ou d'un en-tête que ce collègue est déjà présent. Le 409 {"error":"an account already exists for this email"} de création d'invitation administrateur en est exempté, uniquement parce qu'il se trouve derrière les identifiants de l'opérateur.

Quand l'adresse correspond déjà à un compte, le service envoie à cette personne un court message au lieu d'une invitation. Ce message ne contient aucun lien : un lien d'inscription créerait un second compte pour quelqu'un qui en possède déjà un, et un lien de réinitialisation constituerait une réinitialisation de mot de passe que personne n'a demandée. Sans ce courriel, l'invitation disparaîtrait en silence et les deux personnes continueraient de l'attendre.

Une adresse qui a déjà utilisé une invitation émise par un membre n'en reçoit pas une seconde, et l'appelant reçoit toujours 202. La preuve survit au compte : la ligne d'invitation conserve son adresse et son instant d'utilisation lorsque l'un ou l'autre compte est supprimé, de sorte qu'une auto-suppression suivie d'une réinvitation par un ami ne constitue pas un nouveau quota. Sur une instance qui exécute un essai d'analyse, la suppression retire plutôt l'adresse de la ligne et conserve le hachage à clé du §5.15, et la règle lit ce hachage. Une création par un opérateur n'est pas une invitation causée par un membre et n'est jamais retenue par cette règle.

Une invitation en attente venant d'une autre porte reste inchangée, et l'appelant reçoit toujours 202. Une création remplace l'invitation en attente de l'adresse, ainsi, sans cette règle, un membre pourrait révoquer le message qu'un opérateur, la porte de demande du §5.8.3 ou un autre membre vient d'envoyer, et lui substituer les conditions de sa propre porte. Aucune ligne n'est écrite et aucun message n'est envoyé. Un membre PEUT renvoyer sa propre invitation en attente, ce qui la remplace comme auparavant. Le refus est silencieux plutôt qu'explicite, car un refus explicite indiquerait à l'appelant que quelqu'un d'autre a déjà invité cette personne. Un administrateur utilisant cette route en est exempté, tout comme pour la création administrateur.

Lorsqu'un compte est supprimé, les invitations qu'il a envoyées et qui sont toujours en attente sont révoquées dans la même transaction (§5.15). Celles déjà utilisées ou expirées restent conservées en l'état, et ne sont plus imputées à personne : l'auteur de l'invitation a disparu.

Le plafond global est de cinq par compte au total, comptés en nombre de lignes. Les invitations révoquées et expirées comptent : le plafond porte sur le nombre de courriels déclenchés par un compte, pas sur le nombre de ceux qui ont abouti. Le dépasser renvoie 403 {"error":"member-invite-cap-reached"}, et c'est la seule information que ce point d'entrée révèle sur le propre compte de l'appelant, ce qui est un fait le concernant lui et personne d'autre. Un administrateur en est exempté, sur cette route comme sur celle d'administration, ce que signifie invitesLeft: null (§5.15).

Un essai de numérisation que personne n'a payé n'invite personne. Chaque invitation de membre sous MEMBER_INVITE_TRIAL constitue un nouvel essai de numérisation, donc un compte gratuit autorisé à inviter créerait d'autres comptes gratuits. Un compte qui porte trialScans et n'a aucun allowanceExpiresAt dans le futur renvoie 403 {"error":"invites-need-a-plan"}, n'écrit aucune ligne et n'envoie aucune lettre. Une date future ouvre la route, peu importe qui l'a inscrite : le système de facturation lors du paiement, ou un opérateur. La limite à vie est vérifiée en premier, donc un compte ayant épuisé son quota reçoit member-invite-cap-reached, car payer ne l'aiderait pas. Un administrateur en est ici aussi exempté, et la création par l'administrateur (§5.20) reste inchangée. invitesNeedAPlan sur la vue du compte (§5.15) indique la même chose avant que la personne n'essaie.

202 ne contient également aucun jeton ni aucun lien, contrairement à la création par l'administrateur. L'appelant n'est pas l'opérateur et ne doit pas détenir la capacité de créer un compte.

5.22 /v1/plans/* : le relais vers un service de facturation

Présent uniquement si l'opérateur a configuré un service de facturation. Sans cela, tout le sous-arbre renvoie à tout le monde, avec ou sans identifiants, le 404 habituel d'un chemin inconnu, et instance.plans vaut false lors de la négociation (§5.6). Une implémentation de ce protocole PEUT omettre entièrement le sous-arbre ; un client DOIT lire instance.plans avant de proposer un accès aux forfaits au lieu de tester le chemin.

Rien derrière ce préfixe ne fait partie de ce protocole. Les routes, les corps de requête et les corps de réponse appartiennent au service de facturation, qui est un service distinct doté de son propre cycle de publication. Ce document précise uniquement ce que la passerelle fait d'une requête à l'aller et d'une réponse au retour. C'est délibéré : l'alternative consisterait en un document normatif inutilisable par les auto-hébergeurs, suspendu aux variations du calendrier de TVA d'un tiers.

Authentifié avec le jeton d'accès ordinaire du compte (§4.1). Un appelant anonyme reçoit le 401 ordinaire. La seule exception est GET /v1/plans/prices, ci-dessous : un chemin et une méthode, et rien d'autre dans la sous-arborescence.

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

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

L'exemple est donné à titre indicatif : les routes du facturateur lui appartiennent. Le facturateur d'openplate sert GET /v1/plans/prices, GET /v1/plans/offer, POST /v1/plans/order, GET /v1/plans/me, la route du portail et POST /v1/plans/pending-change/cancel ; son ancienne POST /v1/plans/checkout répond désormais 410.

Cinq propriétés qu'une implémentation conforme DOIT respecter :

  1. Seuls GET et POST sont transmis. Toutes les autres méthodes de ce sous-arbre renvoient 405 {"error":"plans-method-not-allowed"} avec un en-tête Allow, et n'atteignent jamais le serveur amont. La passerelle ne connaît pas les routes du service de facturation, donc un proxy qui transmettrait tout équivaudrait à ouvrir un tunnel générique vers un service qui gère l'état des abonnements.
  2. Les en-têtes transmis sont CONSTRUITS, jamais copiés puis écrasés. Ce sont exactement X-Account-Id issu de la session résolue, X-Account-Email lu depuis la ligne du compte, X-Plans-Secret contenant le secret partagé, et le Content-Type entrant. Une copie suivie d'un écrasement transmettrait les cookies et tout ce que le client suivant déciderait d'envoyer.
  3. L'identifiant propre à l'appelant n'est jamais transmis. Tout ce mécanisme repose sur cette règle : transmettre le jeton d'accès permettrait d'utiliser un jeton volé auprès du service de facturation.
  4. L'identifiant de compte provient de la session, et l'adresse provient de la ligne correspondante. Un client qui envoie son propre X-Account-Id ou X-Account-Email ne peut pas influencer ce que lit le serveur amont. Un accountId qu'un navigateur peut choisir constitue une faille d'autorisation, et un service de facturation qui lirait l'adresse de ce compte pour préremplir un paiement révélerait indûment des adresses.
  5. La réponse est transmise avec son code d'état et son corps JSON, et seul Content-Type l'accompagne au retour. Un 402 ou un 409 venant du facturateur constitue une vraie réponse sur le forfait de l'appelant et est retransmis comme tel. Deux routes du facturateur d'openplate montrent pourquoi. Une montée en gamme est facturée et payée immédiatement, POST /v1/plans/order répond donc en cas de carte refusée 402 {"error":"payment-failed"} et l'appelant reste sur l'ancien niveau. Une rétrogradation est planifiée pour la fin de la période payée, et POST /v1/plans/pending-change/cancel (sans corps) l'annule : 200 {"kept":{"plan":"…","tier":"…"}}, 409 {"error":"no-pending-change"} si rien n'est planifié (également la réponse à un deuxième appel), ou 502 {"error":"pending-change-cancel-failed"} si le prestataire de paiement a échoué et que le changement reste planifié. La passerelle retransmet chaque réponse sans modification et n'ajoute aucune route, aucun code ni aucune vérification propre. Ce 502 est la propre réponse du facturateur et ne figure pas parmi les codes plans-upstream-* de la passerelle ci-dessous. La réponse GET /v1/plans/me et la réponse de commande 200 de la rétrogradation planifiée peuvent porter pendingTier et pendingChangeAt, absents quand rien n'est planifié ; un client qui ne les connaît pas les ignore.

Un serveur amont injoignable, qui expire, qui répond autre chose que du JSON ou dont le corps dépasse la limite de relais reçoit 502 dans l'enveloppe du §4 avec un code machine : plans-upstream-unreachable, plans-upstream-timeout ou plans-upstream-invalid. Un corps de requête dépassant la limite stricte de ce sous-arbre reçoit 413 {"error":"plans-request-too-large"}, ce qui signifie autre chose : le service de facturation fonctionne, mais ce que tu as envoyé ne sera jamais accepté. Aucun corps n'est consigné dans les journaux, dans un sens comme dans l'autre ; un refus est consigné avec le code d'état et le chemin, sans rien d'autre.

L'appel sortant a un délai d'expiration explicite. Il est court, car chaque route ici correspond à un bouton sur lequel quelqu'un vient d'appuyer, et il sert autant à limiter le plafond implicite de 300 secondes d'undici qu'à bloquer un service de facturation trop lent.

Notification d'effacement (du service à la facturation). Avant que l'une ou l'autre des procédures d'effacement (§5.15, §5.20) ne supprime un compte, le service transmet POST <PLANS_UPSTREAM_URL>/erase avec exactement X-Plans-Secret et X-Account-Id, et un corps vide. Le service de facturation répond 204 dès que chaque abonnement actif de ce compte est résilié, et 204 lorsqu'il n'en existe aucun. L'appel a un délai d'expiration de cinq secondes. Un refus, une expiration ou un hôte indisponible est consigné à error avec l'identifiant du compte, et le compte est supprimé malgré tout, la réconciliation nocturne du service de facturation servant de sécurité de dernier recours.

L'opérateur configure PLANS_UPSTREAM_URL et PLANS_UPSTREAM_SECRET, les deux ou aucun des deux. Une URL sans secret entraîne un refus de démarrer plutôt qu'une dégradation silencieuse : le secret est la seule preuve pour le service de facturation que l'identifiant de compte qu'il lit provient d'une passerelle qui a authentifié quelqu'un.

GET /v1/plans/prices : la grille tarifaire, avant la connexion

Un écran d'inscription indique le prix avant que quiconque ne détienne un jeton, donc ce SEUL chemin, avec cette SEULE méthode, est anonyme. Aucun jeton n'est requis. Un jeton envoyé malgré tout n'est pas lu et n'est jamais transmis, de sorte qu'un jeton expiré ou étranger ne peut pas transformer la lecture en un 401. Tout autre chemin de la sous-arborescence, ainsi qu'un POST ou un HEAD sur celui-ci, répond toujours 401 à un appelant anonyme.

GET /v1/plans/prices

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

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

Le corps provient du système de facturation et est relayé sans être lu, comme chaque réponse de cette sous-arborescence. L'exemple correspond à ce que sert le système de facturation d'openplate : les forfaits vendus, chacun avec le montant facturé par interval dans la plus petite unité monétaire de currency, taxes incluses, lu auprès de son prestataire de paiement au démarrage. Les chiffres ci-dessus sont un exemple, jamais une grille tarifaire.

La passerelle transmet le corps intact, le facturateur peut donc le compléter. La passerelle analyse le corps uniquement pour vérifier qu'il s'agit d'un JSON de la taille autorisée, et elle ne lit ni ne réécrit aucun champ. Un facturateur qui vend des niveaux peut donc ajouter un tableau tiers à côté de currency et plans. Une entrée de tiers a la même forme qu'une entrée du tableau tiers de l'offre du facturateur (GET /v1/plans/offer) : id, name, description, isSold, dailyAiLimit, capabilities et son propre plans. L'offre est le contrat du facturateur et ne fait pas partie de ce protocole (voir plus haut), l'entrée n'est donc décrite ici que pour que tu saches à quoi t'attendre :

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

Un client DOIT ignorer chaque champ qu'il ne connaît pas, au premier niveau et au sein d'une entrée, et NE DOIT PAS refuser le corps pour cela. Un corps sans tiers reste tout aussi valide qu'auparavant, et un client qui ne lit jamais tiers lit currency et plans exactement comme avant. Les identifiants sont des libellés choisis par le facturateur (tier-a sert de valeur générique), l'ordre de tiers est celui du facturateur, et les montants reprennent les chiffres de l'exemple ci-dessus uniquement pour faire apparaître la structure.

Quatre propriétés distinguent cette route du reste de la sous-arborescence :

  1. La requête part avec X-Plans-Secret seulement. Il n'y a pas de compte, donc pas de X-Account-Id ni de X-Account-Email, et rien de la requête entrante ne transite : ni un en-tête, ni la chaîne de requête.
  2. Un 200 est conservé pendant cinq minutes et servi depuis la mémoire, de sorte qu'une rafale de lecteurs ne génère qu'un seul appel vers le système de facturation. Un refus du système de facturation ou un appel en échec sont relayés comme ci-dessus et ne sont pas conservés, de sorte que le lecteur suivant refait une demande.
  3. Une adresse source peut la lire 60 fois par minute glissante. Un appelant IPv6 est décompté comme son /64, et une adresse IPv6 mappée en IPv4 comme l'adresse IPv4 qu'elle contient. La lecture suivante est 429 {"error":"plans-prices-rate-limited"} avec Retry-After en secondes.
  4. Sans système de facturation, c'est le 404 habituel pour un chemin inconnu, comme le reste du sous-arbre, et /health ne publie rien de nouveau pour cela : un client qui lit instance.plans sait déjà s'il doit demander.

5.23 /v1/pulse/* : le pouls communautaire (ADR-0007)

Activation volontaire sur l'appareil, et désactivé tant qu'une personne ne l'active pas. Rien ici ne provient d'un journal : aucun chemin de code sur le serveur n'en déchiffre un. Chaque chiffre ci-dessous arrive sous la forme d'un petit delta depuis un appareil dont le propriétaire l'a demandé, et ADR-0007 détaille exactement ce qui quitte l'appareil et pourquoi.

Quatre routes, toutes derrière le jeton d'accès habituel du compte (§4.1). Un appelant anonyme reçoit le 401 habituel.

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>

Les deux transportent un corps vide.

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

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

Sept propriétés qu'une implémentation conforme DOIT respecter :

  1. Le serveur arrondit à nouveau et applique les bornes. kcal est arrondi aux 50 les plus proches et borné entre 0 et 5000 ; protein est arrondi aux 5 les plus proches et borné entre 0 et 500. Un appareil qui envoie une valeur exacte, négative ou absurde atterrit tout de même sur la même grille que tout le monde. Un corps qui ne contient pas deux nombres finis renvoie 400 {"error":"invalid request body"}.
  2. Chaque écriture porte un en-tête Idempotency-Key, un uuid, et une requête sans cet identifiant renvoie 400 {"error":"idempotency key required"}. La clé est conservée 24 heures. Une répétition dans cette fenêtre renvoie 200 {"duplicate": true} et ne modifie rien, ce qui sécurise un rejeu hors ligne ou une nouvelle tentative.
  3. Les limites de débit s'appliquent par compte : POST /v1/pulse/meal et POST /v1/pulse/photo une par minute chacun, POST /v1/pulse/fasting une toutes les 10 minutes. Tout dépassement renvoie 429 avec un en-tête Retry-After en secondes et un corps qui ne mentionne aucun identifiant.
  4. Un battement de cœur de jeûne est un upsert sur l'identifiant du compte. Deux battements de cœur ne laissent qu'une seule ligne, avec l'expiration la plus tardive. La ligne expire 30 minutes après le dernier battement de cœur, et fastingNow ne compte que les lignes non expirées. La présence est associée à un compte plutôt qu'anonyme, car la limitation de débit et la déduplication nécessitent toutes deux une identité, et un battement de cœur anonyme pourrait être rejoué pour gonfler les chiffres (ADR-0007).
  5. GET /v1/pulse/today est servi depuis un cache en mémoire de cinq minutes, une seule entrée pour toute l'instance, invalidée par le temps et jamais par une écriture. Un client la récupère au maximum toutes les cinq minutes. Une écriture effectuée pendant cet intervalle reste donc invisible jusqu'à ce que l'entrée expire, ce qui est un choix assumé plutôt qu'un problème à corriger : ces chiffres signalent une présence, ils ne constituent pas un accusé de réception.
  6. Les cumuls journaliers sont conservés 30 jours. pulse_days et les lignes de contributeurs associées sont supprimées par un nettoyage horaire au-delà de cet âge, les lignes de présence expirées disparaissent avec elles, et les clés d'idempotence au bout de 24 heures.
  7. Les routes de pulsation enregistrent uniquement un code de statut et un nombre d'octets. Jamais l'identifiant du compte, et jamais une valeur issue du corps.

GET /v1/admin/stats (§5.20) signale la pulsation du jour à l'opérateur sous forme de pulse: { meals, photos, kcal, protein, contributors, fastingNow }, c'est-à-dire le même ensemble que chaque membre peut déjà consulter.

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

*Désactivé, sauf si l'opérateur a défini les trois variables `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`.

Le serveur n'écrit aucun texte de notification. Chaque notification push qu'il envoie ne contient qu'un seul champ :

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

L'appareil se réveille, lit le journal que lui seul peut déchiffrer et compose les mots. Un client conforme DOIT pouvoir afficher quelque chose pour l'un ou l'autre type sans que la charge utile ne lui indique quoi que ce soit, car elle ne le fera jamais.

Quatre routes, toutes protégées par le jeton d'accès habituel du compte (§4.1). Un appel anonyme sur une instance configurée reçoit la réponse 401 habituelle.

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

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

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

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

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

200 { "unsubscribed": true }

Neuf propriétés qu'une implémentation conforme DOIT respecter :

  1. Un abonnement est identifié par son endpoint, que le service de push a généré et qui est unique au monde. PUT effectue une insertion-mise à jour dessus : un appareil qui enregistre à nouveau le même endpoint reçoit 200 et conserve le jour de sa première apparition.
  2. replaces désigne le endpoint que cet enregistrement remplace, et il est supprimé uniquement s'il appartient au même compte. Un réenregistrement de service worker produit un nouvel endpoint sans désabonner l'ancien, si bien que sans cela, l'orphelin resterait là à renvoyer 201 à personne indéfiniment. Un replaces égal à endpoint correspond à un appareil qui se désigne lui-même et ne supprime rien.
  3. timeZone est un identifiant IANA, validé au moment de l'écriture. Un fuseau inconnu équivaut à 400. Tout le rattrapage dépend de l'horloge locale, donc un fuseau illisible pour le serveur entraînerait une notification à la mauvaise heure plutôt qu'une erreur.
  4. catchUpMinute est une minute de la journée locale, de 0 à 1439, ou null pour « aucun rattrapage sur cet appareil ». null est la valeur silencieuse par défaut.
  5. wakeAt est un instant ponctuel, au format ISO 8601, ou null pour l'effacer. Le serveur envoie l'alerte d'objectif rapide dès que l'instant est dépassé et vide la colonne lors de la même écriture, afin qu'elle ne se déclenche jamais deux fois. Un champ absent dans une requête PATCH la laisse telle quelle.
  6. Le rattrapage part une fois par jour LOCAL, dès que l'horloge propre à l'abonnement a dépassé sa minute et que la notification n'est pas déjà partie aujourd'hui à cet endroit. Une journée locale lors d'un changement d'heure dure 23 ou 25 heures, un intervalle UTC ne constitue donc pas une implémentation valide de cette règle.
  7. Sept jours de silence le mettent en pause. Un abonnement dont le dernier enregistrement ou changement de planning remonte à plus de sept jours locaux ne reçoit aucun rattrapage tant qu'il ne se manifeste pas à nouveau.
  8. Au maximum deux notifications push par abonnement et par jour UTC. Une troisième est ignorée, jamais mise en file d'attente.
  9. Un code 404 ou 410 renvoyé par le service de push supprime la ligne. Rien d'autre ne le fait : une erreur 400, 401, 403, 429 et chaque code 5xx sont transitoires ou concernent l'expéditeur, et purger sur ces critères viderait la table dès qu'une clé serait mal collée.
  10. Rien n'est envoyé à un compte sans consentement. Lorsque instance.healthConsent n'est pas null (§5.6), le serveur n'envoie aucune notification push à un compte qui ne dispose pas de cette version exacte (§5.15.1), peu importe la planification. Une notification push retenue n'enregistre aucune marque, donc un rattrapage encore dû part au prochain cycle une fois que la personne a accepté.

Les thèmes de regroupement sont openplate-catchups et openplate-fast, la durée de vie est de 6 heures, et la priorité est normale pour le rattrapage et haute pour l'objectif rapide. Un thème DOIT être composé de caractères base64 sans danger pour les URL, 32 au maximum, et d'une longueur qui n'est jamais égale à 1 modulo 4 : sinon, Apple décode le thème et répond 400 BadWebPushTopic, alors que les autres services de push l'acceptent, ce qui rend le défaut invisible sur tout ce qui n'est pas un iPhone.

Les cinq limites (2026-09-30). Pour qu'un compte ne puisse pas bloquer chaque envoi ni pointer ce serveur vers un hôte interne :

  1. Le point de terminaison doit être https, sur le port par défaut, sans identifiant ni mot de passe, chez un service push connu : fcm.googleapis.com, updates.push.services.mozilla.com et *.push.services.mozilla.com, web.push.apple.com et *.push.apple.com, *.notify.windows.com, ainsi que tout hôte renseigné par l'opérateur dans PUSH_ENDPOINT_HOSTS. Tout le reste est 400 {"error":"endpoint must be an https URL at a known push service"} et n'écrit rien. Une ligne stockée qui enfreint cette règle est supprimée au tick suivant, sans envoi.
  2. Un compte conserve au maximum 10 abonnements. Un enregistrement au-delà supprime les lignes les plus anciennes de ce compte ; la ligne venant d'être enregistrée est toujours conservée.
  3. Un envoi s'arrête après 10 secondes, et le tick envoie à 8 points de terminaison à la fois, pour qu'un point de terminaison lent ne retarde personne d'autre.
  4. Un envoi qui échoue avec autre chose que 404 ou 410 reporte la ligne : le prochain essai a lieu une minute plus tard, puis deux, quatre, et ainsi de suite jusqu'à un jour. Après 15 échecs de suite, soit environ quatre jours et demi, la ligne est supprimée. Un envoi réussi, tout comme un nouvel enregistrement du point de terminaison, remettent le compteur à zéro.
  5. Un tick qui dépasse sa minute n'en lance pas un second.

PUT sur un point de terminaison détenu par un autre compte transfère la ligne à l'appelant. C'est nécessaire : l'application réutilise l'abonnement déjà présent dans le navigateur, et si l'effacement d'un appareil n'a pas pu le libérer, le compte suivant sur ce navigateur enregistre le même point de terminaison. La ligne de l'ancien propriétaire cesse alors de réveiller cet appareil, ce qui correspond à ce que souhaite le nouveau propriétaire.

Aucune route ne renvoie jamais de endpoint ni de clé d'appareil, et les routes n'enregistrent qu'un chemin, une méthode, un statut et un nombre d'octets : jamais l'identifiant du compte et jamais le endpoint, qui constitue un droit d'accès.

GET /v1/admin/stats (§5.20) transmet push: { subscriptions, sentToday } à l'opérateur, c'est-à-dire deux entiers et jamais une ligne.

5.25 POST /v1/feedback : une estimation signalée (ADR-0006)

Présent seulement quand l'opérateur a défini SYNC_FEEDBACK. Sans lui, le chemin renvoie l'habituel 404 de chemin inconnu à tout le monde, avec ou sans authentification, et GET /health ne porte aucun instance.feedback (§5.6). Un client DOIT lire instance.feedback avant de proposer un signalement. Il DOIT indiquer la fenêtre de rétention que ce champ annonce, et aucune autre.

C'est la seule écriture de ce protocole que le serveur peut lire. Une personne qui estime qu'une estimation est fausse envoie les chiffres de cette entrée. Si l'appareil a encore la photo d'assiette, il l'envoie aussi. La personne doit d'abord accepter que les deux quittent l'appareil. Le serveur les garde lisibles jusqu'à expiration de la fenêtre. ADR-0006 explique la raison de cette exception.

Authentifié avec le jeton d'accès ordinaire du compte (§4.1). Un appel anonyme reçoit le 401 ordinaire.

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" }

Sept propriétés qu'une implémentation conforme DOIT respecter :

  1. Chaque champ sauf image est obligatoire. idempotencyKey compte de 1 à 128 caractères après nettoyage, consent.wordingVersion de 1 à 64, et consent.agreedAt est un instant. Il est stocké selon l'horloge propre de l'appareil et jamais corrigé. Le createdAt du serveur se trouve à côté. measurements est un objet JSON d'au plus 16 Ko une fois sérialisé. Le serveur n'a pas de schéma pour lui et NE DOIT PAS en créer un : cette limite existe pour que personne ne puisse loger un journal dans ce champ. Tout autre corps renvoie 400 {"error":"invalid request body"}, avec une phrase par champ.
  2. image est facultatif, et son absence n'est pas une erreur. Une clé manquante ou null signifie qu'il n'y a pas de photographie. Le cache photo de l'appareil l'a peut-être déjà supprimée, et les chiffres valent tout de même la peine d'être examinés. Quand elle est présente, c'est { "contentType", "data" }. contentType vaut image/jpeg, image/png ou image/webp. Il ne vaut jamais image/svg+xml, qui peut contenir des scripts. data est du base64 qui se décode en 1 à 5 000 000 d'octets. Plus volumineux renvoie 413, et vide ou un autre type renvoie 400.
  3. La limite du corps est de FEEDBACK_MAX_REQUEST_BYTES, 8 Mo par défaut, s'applique à cette route seulement. Il se situe au-dessus du plafond de l'image car le base64 augmente la taille d'un tiers. Un corps plus volumineux renvoie 413 {"error":"request body exceeds the maximum accepted size"}.
  4. La clé d'idempotence sécurise les nouvelles tentatives. Il est unique par compte. Un second envoi avec une clé existante renvoie 200 avec le signalement stocké au lieu de 201. Il ne stocke aucun second signalement et ne compte pas dans la limite quotidienne. Il réécrit la photographie si elle était fournie. Cela répare un signalement dont la première écriture de photographie a été interrompue.
  5. Une limite quotidienne par compte, FEEDBACK_DAILY_LIMIT, 5 par défaut, décompté par jour UTC dans la même transaction que l'insertion. Au-delà, c'est 429 {"error":"daily limit reached: 5 reports per day for this account"}, qui ne mentionne aucun identifiant.
  6. Ce qui est stocké est la photographie, les chiffres, l'enregistrement du consentement, l'identifiant du compte et l'heure d'arrivée, et rien d'autre. Pas les en-têtes de la requête, l'adresse IP, le user-agent ou un identifiant d'appareil.
  7. Un signalement et sa photographie disparaissent après instance.feedback.retentionDays (30 dans cette implémentation, supprimés par un nettoyage horaire), plus tôt quand un opérateur supprime le signalement (§5.20), et en même temps que le compte.

6. Négociation de version : obligatoire, et obligation d'échouer de manière sécurisée

Un client DOIT lire ce document depuis le service et le vérifier avant sa première synchronisation d'une session.

Cela remplace une vérification de version intégrée qui existait lorsque le client et le serveur formaient un seul et même livrable. Ce n'est plus le cas : un client déployé et un service déployé peuvent diverger d'une version dans un sens ou dans l'autre, et une personne qui s'auto-héberge peut faire pointer un client récent vers un service mis à jour huit mois plus tôt. Rien dans une telle situation n'est détectable à partir du succès d'un 200 lors d'un envoi.

Règles :

  1. protocolVersion doit être égal à celle du client. Pas « ≥ », pas « plus ou moins compatible ».
  2. envelopeVersion doit être égal à celle du client.
  3. En cas de discordance, le client refuse de synchroniser et indique à l'utilisateur quel côté est le plus ancien. Il n'envoie rien, ne récupère rien, ne réessaie pas et ne se dégrade pas silencieusement.
  4. Si le handshake est inaccessible ou malformé, traite la situation comme une discordance. Un service invérifiable n'est pas un service compatible.

L'implémentation de référence correspond à checkProtocolCompatibility() dans les deux fichiers protocol.ts, pure, totale, et renvoyant une phrase présentable à l'utilisateur plutôt qu'un booléen.

Pourquoi un refus plutôt qu'une tentative au mieux : le blob constitue souvent l'unique copie des données de l'utilisateur. Un client qui pousse une enveloppe tramée différemment par un service plus récent, ou qui déchiffre une enveloppe qu'il ne comprend qu'à moitié, risque de corrompre cette copie de manière irréversible. Une synchronisation refusée représente un désagrément visible ; une synchronisation silencieusement erronée constitue un incident de perte de données découvert des semaines plus tard. Ce protocole choisit systématiquement le désagrément.

7. Politique de versionnement

  • PROTOCOL_VERSION couvre les points d'accès, la forme des requêtes et réponses, la sémantique des codes de statut, le mécanisme d'authentification et la sémantique CAS. Incrémente-la pour tout changement incompatible sur ces éléments. Les modifications purement additives (un nouveau champ optionnel dans la réponse, un nouveau point d'accès jamais appelé par les anciens clients) ne l'incrémentent pas.
  • ENVELOPE_VERSION couvre uniquement la cryptographie et le tramage du blob : algorithme de chiffrement, position du vecteur d'initialisation (IV), codec de compression, gestion du tag. Incrémente-la pour l'un de ces cas. Ne l'incrémente Jamais pour une modification du schéma de la charge utile.
  • payloadSchemaVersion correspond à la version du schéma de stockage local du client. Elle transite par ce protocole sous forme d'entier opaque lié dans les AAD. Le serveur ne l'interprète jamais, et elle n'affecte aucune des deux versions ci-dessus.

Les deux numéros de version sont indépendants à dessein : repenser le tramage de la cryptographie et modifier la structure de l'API HTTP constituent des changements de nature différente, avec un rayon d'impact distinct.

Marge de manœuvre pré-1.0. Jusqu'à la première version publique, des changements incompatibles peuvent survenir sans le chemin de migration qu'exigerait un protocole publié. Deux d'entre eux ont eu lieu SANS incrément de version : le passage de l'authentification par cookie à l'authentification par jeton bearer, et le déplacement des routes de synchronisation de /api/sync vers /v1/sync. Un troisième, la suppression de l'adresse e-mail en 0.5.0, a également eu lieu sans incrément alors qu'il aurait fallu le faire (voir ci-dessous). Ce paragraphe sera supprimé lors de la publication, et les règles ci-dessus seront dès lors suivies à la lettre.

La version 0.5.0 a modifié le contrat d'authentification et n'a PAS incrémenté la version, et c'est l'erreur que cette section consigne à présent. Elle a remplacé email par handle, retiré verify-email et request-reset, puis ajouté recover et recover-rotate (§5.14). Comme le numéro est resté à 1, le handshake du §6 ne l'a pas détecté : un client antérieur à la version 0.5.0 envoyant email recevait un 400 impossible à corriger, alors que les numéros de version concordaient et lui indiquaient que tout allait bien.

La version 0.6.0 passe PROTOCOL_VERSION à 2, et le fait précisément pour cette raison. Les modifications sont de même nature (le champ d'authentification redevient email, l'inscription exige une invitation adressée et les deux enregistrements de clé, signupMode a quitté le handshake, AccountView a remplacé l'ancien corps de compte, et deux points d'accès de réinitialisation réutilisent le §5.12), mais cette fois le §6 les intercepte : un client parlant la version 1 refuse de communiquer plutôt que de fonctionner à moitié. Justification : docs/adr/0005-organization-accounts-and-escrowed-recovery.md.

8. Limites de taille et plan de capacité

LimiteValeurAppliquée par
Taille maximale du blob2 Mio (MAX_BLOB_BYTES)Service (413), reproduit côté client pour une erreur plus claire
Versions de blob conservéesTrois paliers, voir ci-dessousService, nettoyé après chaque écriture acceptée
Enregistrements de clé par compte2 (un par kind)Service

La rétention est échelonnée (M224). Une version est conservée si N'IMPORTE QUEL palier la conserve :

PalierRèglePlafond
RécentLes versions les plus récentes (BLOB_VERSION_RETENTION)5
QuotidienLa version la plus récente de chaque jour calendaire UTC pendant BLOB_DAILY_RETENTION_DAYS14
Épingles avant réductionVersions remplacées par une réduction importante acquittée, pour BLOB_PRE_SHRINK_PIN_DAYS, de la plus récente à la plus ancienne jusqu'à BLOB_PRE_SHRINK_PIN_LIMIT14

Soit au maximum 33 versions, et donc au maximum 66 MiB, par compte. Le palier quotidien fonctionne délibérément par JOUR calendaire plutôt que par nombre, deux appareils pris dans une boucle de fusion génèrent des versions aussi vite que le réseau le permet, et un palier fondé sur un décompte s'épuise alors en quelques minutes. Le palier d'épingles est plafonné, car une épingle est posée sur la seule affirmation d'un client.

La limite fixe de cinq versions constituait toute la règle avant M224, et ce filet de sécurité était bien mince, un compte dont le journal était effacé par un bug client ne pouvait être récupéré que tant que la bonne version se trouvait encore dans une fenêtre que deux appareils peuvent consumer en une minute.

La limite de capacité, formulée clairement. Un seul blob contient le stockage complet du compte. Les entrées du journal alimentaire représentent environ 400 à 700 octets de JSON chacune avant compression, si bien qu'un blob non compressé dépasserait 2 Mio en 2 à 4 ans d'utilisation quotidienne. Ce n'est pas un risque théorique, c'est une échéance.

ENVELOPE_VERSION 1 compresse le texte en clair avec gzip, ce qui fait gagner environ un ordre de grandeur sur un JSON aussi répétitif (les mêmes noms de clés sur des milliers d'enregistrements) et repousse la limite assez loin pour que ce ne soit pas un problème immédiat. Cela ne la supprime pas.

Le correctif prévu, pour ne pas le concevoir dans l'urgence : des blobs découpés en morceaux ou par entité, c'est-à-dire de nombreux petits textes chiffrés avec des versions indépendantes, au lieu d'un seul bloc monolithique. Cela modifie réellement la structure et les points de terminaison, ce sera donc un changement de version du protocole, pas un simple correctif. En pratique, le signal pour lancer ces travaux est le passage de la taille des blobs au-dessus d'environ 80 % du plafond sur le terrain, ce pour quoi le service consigne un avertissement (spécification M128 02). La limite devrait être visible bien avant qu'un utilisateur ne l'atteigne.

9. Ce que sait le serveur

9.1 Ce qu'il ne peut pas savoir

Le serveur ne reçoit jamais la DEK, ni l'une ni l'autre KEK, ni la phrase de passe. Il reçoit bien le code de récupération à l'inscription et à chaque rotation, et garde ce code scellé (§3.1 et l'entrée de séquestre au §9.2). Aucun chemin de code dans ce service ne dérive de clé à partir du code ni ne déchiffre de blob. Pour le code propre au service, le déchiffrement n'est pas retenu, il est indisponible. Pour quiconque détient à la fois la base de données et SERVER_SECRET, il est disponible. Cela signifie l'opérateur d'une instance gérée, ou toi en toute autonomie.

Et il ne peut toujours pas en agréger un. Le pouls communautaire du §5.23 ressemble à un décompte des repas par le serveur, mais il n'en est rien : aucun élément du §9.2 n'est dérivé d'un blob, et un déploiement où personne n'a activé le pouls ne compte absolument rien. Les totaux existent uniquement parce que les appareils dont les propriétaires ont donné leur accord les ont envoyés, ce qui explique pourquoi le pouls figure ci-dessous parmi les données connues du serveur et non parmi celles qu'il calcule.

9.2 Ce qu'il sait effectivement

Être transparent sur les métadonnées, car « chiffré de bout en bout » est souvent interprété comme « le serveur ne sait rien » :

  • Taille du blob, et par conséquent une estimation du volume de données que détient le compte. La compression rend ce signal plus flou qu'auparavant, mais ne le masque pas.
  • Fréquence et calendrier des écritures : quand un appareil se synchronise, et à quelle fréquence.
  • Numéros de version : blobVersion, envelopeVersion et le nombre de versions conservées.
  • Paramètres et sel de la KDF pour l'enregistrement de la phrase de passe. Ce ne sont pas des secrets, ils existent pour être transmis à un nouvel appareil avant la connexion.
  • Si un compte a terminé sa configuration (possède des enregistrements de clés) et s'il s'est déjà synchronisé (possède un blob).
  • Le compte lui-même : une adresse e-mail, un nom d'affichage facultatif, un rôle, un quota quotidien d'IA, une date et heure de suspension, un vérificateur d'authentification (un hachage avec clé d'un hachage avec clé de la phrase de passe, voir §5.8), un second vérificateur construit à l'identique sur la preuve de récupération, et les paramètres KDF du compte. L'adresse désigne une personne réelle, ce qui correspond à une catégorie de données personnelles retirée par la version 0.5.0 et réintroduite délibérément par la 0.6.0 (ADR-0005) : les membres d'une organisation sont identifiés par l'adresse à laquelle leur invitation est arrivée, car c'est l'identifiant dont ils se souviendront encore dans un mois.
  • Le CODE DE RÉCUPÉRATION du compte, scellé (accounts.recovery_code_escrow, §3.1). C'est l'élément de cette liste sur lequel un lecteur doit s'arrêter. Il est chiffré en AES-256-GCM sous une sous-clé de SERVER_SECRET, donc une simple copie de la base de données ne suffit pas à l'ouvrir, mais l'administrateur d'une instance gérée possède les deux. L'administrateur d'une instance gérée peut ouvrir n'importe quel compte présent sur celle-ci. Pas via un point de terminaison, ni via un chemin de code de ce service, mais en lisant cette colonne avec le secret en main et en exécutant la propre HKDF du client. Une instance auto-hébergée a son propre administrateur, donc l'engagement initial s'y applique. Décider de faire confiance ou non à une instance hébergée revient donc à décider de faire confiance à son administrateur.
  • Invitations en attente : pour chacune, une adresse, un nom optionnel, un rôle et un quota, appartenant à quelqu'un qui n'a AUCUN compte et n'a donné aucun consentement. En créer une est une action de l'opérateur, et DELETE /v1/admin/invites/:id retire la ligne. Une ligne créée par la porte de requête du §5.8.3 est marquée comme telle, afin qu'un opérateur puisse les compter. Une invitation terminée perd son adresse et son nom dans l'heure : une ligne révoquée ou expirée sur chaque instance, une ligne utilisée sur une instance avec TRIAL_ADDRESS_PEPPER, qui ne conserve que le hachage à clé ci-dessous. Sans le grain de sel, une ligne utilisée conserve son adresse, car la règle de réinvitation de membre du §5.21 la lit.
  • La période d'essai d'analyse, sur une instance qui en exécute un : les analyses gratuites accordées et utilisées, deux entiers sur la ligne du compte. Pour un compte en essai d'analyse uniquement, une ligne par action d'IA : un identifiant opaque choisi par le client, une heure, un décompte de requêtes et le fait qu'une réponse ait été remise ou non, conservé 24 heures puis supprimé, et jamais consigné. Chaque ligne d'invitation porte un hachage à sens unique à clé de son adresse de courriel (HMAC-SHA256 sous TRIAL_ADDRESS_PEPPER, un secret que seul l'opérateur détient, sur la clé d'essai du §5.8.3), jamais une seconde copie de l'adresse.
  • Après la suppression d'un compte, sur une instance qui propose une période d'essai d'analyse : l'adresse et le nom sont supprimés de chaque ligne d'invitation relative à cette boîte aux lettres, et, uniquement lorsque le compte disposait d'une période d'essai, un hachage à clé de l'adresse de courriel est conservé, et rien d'autre : aucun nom, aucun identifiant, et une seule date, l'instant de la suppression, ce qui permet à la ligne de se terminer. C'est ce qui empêche cette même boîte aux lettres d'obtenir une deuxième période d'essai. Sans le secret de l'opérateur, le hachage ne peut être inversé ni comparé à une liste d'adresses. Un nettoyage le supprime TRIAL_HASH_RETENTION_DAYS (365 par défaut) après cet instant, sur la base juridique de l'art. 6(1)(f) RGPD (un intérêt légitime à empêcher les abus des analyses gratuites, non validé par un juriste, ADR-0010). Une instance qui n'accorde aucune période d'essai d'analyse ne conserve aucun hachage. Sur une instance qui ne propose pas de période d'essai d'analyse, les lignes d'invitation conservent leur adresse après une suppression, pour la règle de réinvitation de membre du §5.21.
  • Utilisation de l'IA : un entier par compte et par jour UTC, conservé pendant 90 jours puis supprimé (§5.20). Un compteur, jamais un journal : aucun prompt, aucune réponse, aucun modèle, aucun horodatage au-delà du jour. Un opérateur peut lire les compteurs d'un compte sous la forme d'une frise quotidienne (GET /v1/admin/accounts/:id/activity), qui constitue une métadonnée sur les moments où une personne a utilisé une application de santé, et reste délimitée précisément pour cette raison.
  • Le pouls communautaire, pour les comptes qui l'ont activé (§5.23, ADR-0007) : les sommes journalières à l'échelle de l'instance pour les repas, les photos, les calories et les grammes de protéines, une ligne par compte contributeur et par jour, ainsi qu'une ligne de présence éphémère indiquant qu'un compte jeûne en ce moment. Les sommes ne peuvent être attribuées à personne ; la ligne de contribution et la ligne de présence le sont, et indiquent seulement « ce compte a contribué aujourd'hui » et « ce compte jeûne ». Les sommes journalières et les lignes de contribution sont gardées 30 jours, la présence expire 30 minutes après le dernier battement de cœur, et les routes ne journalisent aucun identifiant de compte. Une personne qui ne l'a jamais activé n'envoie rien et n'apparaît nulle part.
  • Un abonnement push, pour un appareil dont le propriétaire a activé les notifications (§5.24, ADR-0008) : le point de terminaison du service push, les deux clés de chiffrement, une chaîne d'agent utilisateur tronquée, un fuseau horaire IANA, une locale, la minute du jour local où un rattrapage est prévu, le jour local du dernier envoi, le jour local où l'appareil a été vu pour la dernière fois, l'instant où il a demandé à être réveillé, et un décompte de ce qui a été envoyé aujourd'hui. L'ensemble indique approximativement quand cette personne est éveillée, approximativement où elle se trouve dans le monde, et, via wake_at, quand son jeûne se termine. Ce dernier élément concorde avec la ligne de présence du pouls, qui indique que ce même jeûne est en cours ; ADR-0008 explicite cette corrélation au lieu de la laisser deviner. Ce qui n'est PAS stocké, c'est le moindre mot du texte d'une notification : chaque notification push transporte un type. La ligne disparaît quand l'appareil se désabonne, quand le service push le rejette, ou avec le compte.
  • Un consentement relatif aux données de santé, sur une instance qui en demande un (§5.15.1) : la version du texte acceptée par la personne et l'instant où ce service l'a enregistrée, deux colonnes sur la ligne du compte. Cela indique que la personne utilise une application de santé et a accepté que l'opérateur traite ces données, ce que l'opérateur doit pouvoir prouver. C'est visible par un opérateur (§5.20), aucune route ne l'efface, et cela disparaît avec la ligne du compte lors de la suppression.
  • Date et heure de la dernière action d'une personne : accounts.last_seen_at, écrit lors d'une connexion et d'une complétion relayée, et délibérément pas lors d'un rafraîchissement de jeton ou d'un sondage de synchronisation, ce qui signifie « quelqu'un a agi » plutôt que « un client tournait ». Il est visible pour un opérateur (§5.20) et disparaît avec la ligne du compte lors de la suppression.
  • Déclarations légales (POST /v1/legal/declarations, une résiliation ou une rétractation, sur chaque instance) : le nom, l'adresse, la référence du contrat, le motif et les dates saisis par la personne, l'heure de réception, et le compte correspondant, le cas échéant. Elle est conservé jusqu'à la fin de la troisième année civile suivant son arrivée, décomptée selon l'heure Europe/Berlin, puis supprimée par le nettoyage horaire : une déclaration reçue le 2026-09-21 est supprimée à partir du 2030-01-01 00:00 à Berlin. Supprimer le compte ne le supprime pas plus tôt ; la ligne perd son identifiant de compte et reste en place, car elle constitue la trace de ce que la personne a déclaré. L'envoi d'accusés de réception est plafonné à trois par adresse normalisée, à LEGAL_DECLARATION_RECEIPTS_PER_NETWORK_PER_DAY (10 par défaut) par réseau émetteur, et à LEGAL_DECLARATION_RECEIPTS_PER_DAY (200 par défaut) par instance. Chaque plafond s'applique sur toute période glissante de 24 heures. Les totaux sont calculés à partir de ces lignes, sauf le décompte par réseau, qui reste en mémoire. Une déclaration au-delà d'un plafond est malgré tout enregistrée, transmise, et envoyée à l'administrateur, et le 202 reste identique. L'accusé de réception ne répète jamais le nom, la référence du contrat, ni le motif ; seule la copie destinée à l'administrateur les contient.
  • Métadonnées de session : le nombre de sessions actives existantes, la date de création de chacune et la date de dernière rotation ou révocation des jetons. Les valeurs des jetons elles-mêmes ne sont stockées que sous forme de condensats.
  • Le graphe d'étude, sur un déploiement où SYNC_RESEARCH est défini (§5.18) : quel compte contribue à quelle étude, quand, à quelle fréquence et quelle est la taille de chaque contribution. Une arête indique ici « les données de santé de cette personne se trouvent dans l'étude Y », ce qui constitue des données personnelles indirectement liées à la santé, de la même catégorie que l'arête de soins ci-dessous. C'est inévitable, et le retrait en est la preuve : supprimer la ligne d'un contributeur nécessite de la localiser, la suppression d'un compte doit se propager en cascade dessus, et le compare-and-swap comme la lutte contre les abus s'appuient sur le compte. Un schéma qui masquerait ces données au serveur casserait l'un de ces éléments et l'analyse du trafic lèverait le masque de toute façon, c'est pourquoi cela est exposé plutôt qu'évité à moitié. Le chercheur ne reçoit jamais la correspondance (§5.18 ne transporte aucun identifiant de compte), le retrait supprime définitivement l'arête et ne laisse qu'un pseudonyme, et un déploiement sans ce drapeau ne possède aucune table pour stocker un graphe d'étude.
  • Le graphe de partage, sur un déploiement où SYNC_SHARING est défini (§5.16) : quel compte a accordé un accès en lecture à quel autre compte, quand l'accès a été accordé et quand le bénéficiaire l'exerce. Il s'agit d'un graphe de relations, et d'un réel élargissement de ce que ce service sait, et dans le cas pour lequel la fonctionnalité a été conçue (un patient et son diététicien), une arête de ce graphe constitue en soi une donnée personnelle indirectement liée à la santé, car elle indique qu'une personne fait l'objet d'un suivi médical. C'est le strict minimum pour autoriser la lecture ; les deux parties y consentent, puisque la personne qui accorde l'accès crée la ligne et que le bénéficiaire peut supprimer son côté ; enfin, l'arête est définitivement supprimée lors de la révocation et disparaît en cascade lors de la suppression de l'un des deux comptes. Un déploiement qui ne définit pas SYNC_SHARING ne stocke aucun graphe de ce type et ne dispose d'aucune table pour en accueillir un.
  • Estimations signalées, sur un déploiement avec SYNC_FEEDBACK activé (§5.25, ADR-0006) : les chiffres de chaque entrée qu'une personne a choisi de signaler, la photo d'assiette quand l'appareil en avait encore une, l'identifiant du compte, l'enregistrement du consentement et l'heure d'arrivée, tous lisibles. Ils sont conservés pendant instance.feedback.retentionDays, puis supprimés par un nettoyage, et ils disparaissent avec le compte. Un opérateur peut les lire via le §5.20, et chaque lecture d'une photographie est journalisée. Un déploiement sans cette option n'a aucun signalement ni aucune photographie à conserver.

Impossible à déduire des métadonnées ci-dessus : ce qui a été mangé, quand, en quelle quantité, ou quoi que ce soit d'autre dans la charge utile. Deux entrées ci-dessus le révèlent en revanche. Le code de récupération scellé ouvre tout le journal à quiconque détient aussi SERVER_SECRET, et une estimation signalée montre l'unique entrée qu'elle contient.

10. Implémenter un autre serveur

Un serveur sync conforme a besoin, au complet, des éléments suivants :

  1. Les quatre points de terminaison des §5.1 à §5.4 ainsi que la négociation /health du §5.6. Le §5.5 a été retiré ; un serveur ne doit pas proposer de suppression d'enregistrement de clé basée uniquement sur le bearer.
  2. Le CAS par compte sur blobVersion : atomique. L'implémentation de référence utilise un index UNIQUE (accountId, blobVersion) et traite une violation d'unicité comme un conflit, plutôt que d'utiliser le verrouillage de ligne ; cela reste correct sous READ COMMITTED et s'avère plus simple que SELECT ... FOR UPDATE. Tout mécanisme offrant la même garantie convient ; un mécanisme de lecture puis écriture sans atomicité est pas.
  3. CAS par compte et par type sur les enregistrements de clés via expectedUpdatedAt, avec la même règle « un champ absent est un 400 », et une vérification de la phrase de passe (currentAuthHash) à chaque écrasement.
  4. Le nettoyage par rétention selon les trois paliers du §8, et la protection contre les réductions du §5.1. Un serveur qui accepte une réduction importante non acquittée détruira le journal d'un compte la première fois qu'un client issu de la version logicielle touchée perdra son stockage local, un client conçu pour un serveur qui la refuse et dirigé vers un serveur qui l'accepte n'est plus protégé, sans la moindre alerte.
  5. Le stockage à l'octet près de ciphertext et de wrappedDek. Ne les réencode, ne les normalise, ne les tronque et ne les « corrige » jamais. Toute altération détruit l'étiquette GCM et, avec elle, les données de l'utilisateur.

De plus, un serveur qui implémente aussi les points de terminaison compte des §5.7 à §5.15 doit :

  1. Fournir un descripteur KDF stable et conforme au format attendu pour les adresses inconnues (§5.7), en effectuant un travail identique sur les deux branches, et limiter le débit du point de terminaison par adresse source. Un 404, un faux descripteur dérivé paresseusement ou un point de terminaison sans limitation de débit rouvrent chacun l'oracle d'énumération que le reste de la conception ferme, que ce soit par la réponse, par le temps de traitement ou par le volume.
  2. Stocker les deux vérificateurs sous forme de hachages avec clé des valeurs authHash et recoveryAuthHash soumises, à l'aide d'un secret conservé hors de la base de données. Ne stocke jamais la valeur soumise telle quelle, et jamais en texte clair.
  3. Appliquer les soumissions de rotation du §5.14 de manière atomique, y compris le séquestre rescellé, et révoquer chaque session en cours sur chacun des déclencheurs du §4.2.
  4. Récupérer l'adresse du compte depuis l'INVITE lors de l'inscription et jamais depuis le corps de la requête (§5.8), et limiter le débit de recover, recover-rotate et reset/request sur un panier unique partagé par (IP, email), qui n'est jamais réinitialisé en cas de succès. Un serveur qui laisse le corps d'une inscription désigner sa propre adresse supprime l'unique élément qui la vérifie.
  5. Renvoyer 202 à chaque reset/request après un travail identique, et faire en sorte que reset/open n'écrive rien sur le compte (§5.12). Une réinitialisation qui remplace un vérificateur constitue le vecteur de prise de contrôle de compte que ce protocole a supprimé, quel que soit le nom qu'on lui donne.
  6. Refuser un compte suspendu lors de la connexion, du rafraîchissement et sur chaque route bearer, avec 403 {"error":"account-suspended"}, cette chaîne exacte.
  7. Propager la suppression d'un compte en cascade aux blobs, aux enregistrements de clés, aux jetons de réinitialisation et aux lignes d'utilisation.
  8. Réponds au point de création de membre du §5.21 par UNE SEULE réponse pour une nouvelle adresse, une adresse avec une invitation en attente et une adresse associée à un compte, s'il implémente ce point d'entrée. Un serveur qui répond 409 pour le troisième cas offre à chaque membre un oracle pour savoir qui d'autre se trouve sur l'instance, et un serveur qui répond 500 quand son relais de messagerie est indisponible leur en offre un plus lent. Un serveur qui n'implémente pas les invitations par les membres répond l'habituel 404 de chemin inconnu sur la route et renvoie instance.memberInvites: false.

Un serveur conforme n'a besoin aucun des éléments suivants : la cryptographie du §3, l'analyse JSON de toute charge utile, ou savoir ce qu'est un journal alimentaire.

11. Implémenter un autre client

Au-delà du §3 et de la boucle 409 du §5.1 :

  • Exécute le handshake du §6 avant la première synchronisation et refuse en cas de non-concordance.
  • Ne persiste jamais la phrase de passe, l'une ou l'autre KEK, ou la DEK sur un stockage durable. Dérive au déverrouillage, conserve en mémoire, élimine.
  • Exécute Argon2id hors du thread principal. À 64 MiB, il fige visiblement les téléphones d'entrée de gamme.
  • Génère le code de récupération à l'inscription, enveloppe la DEK avec lui, et envoie-le au serveur dans le corps de l'inscription pour qu'il soit placé sous séquestre (§3.1). Un client qui omet cette étape crée un compte qu'aucune réinitialisation ne peut restaurer. Le MONTRER à l'utilisateur relève du choix du client, sur une instance managée, l'intérêt du séquestre est que ce n'est pas nécessaire.
  • Indique le type d'instance auquel la personne se connecte avant qu'elle n'y dépose un journal. Sur une instance managée, l'opérateur détient le code sous séquestre et peut ouvrir le compte, sur une instance auto-hébergée, l'opérateur est la personne elle-même. Les deux sont honnêtes, un seul correspond à ce qu'un inconnu suppose.
  • Lis l'adresse depuis POST /v1/auth/invite-lookup (§5.8.2) et affiche-la, plutôt que de demander à la personne de saisir la sienne. Elle ne risque pas de faire une faute de frappe menant à un compte inaccessible si elle ne la saisit jamais.
  • Dérive la preuve de récupération sous openplate-sync:recovery-auth:v1 et ne jamais envoie KEK_r. Les deux sont dérivés du même code, et envoyer la branche KEK donnerait au serveur un HMAC de la valeur qui ouvre le journal (§3.1).
  • Une fois que POST /v1/auth/reset/open a renvoyé le code de récupération, exécute l'recover-rotate ORDINAIRE du §5.14 avec lui : une nouvelle phrase de passe, un enregistrement passphrase ré-enveloppé, un nouveau code, un enregistrement recovery ré-enveloppé et le nouveau recoveryCode pour le séquestre. S'arrêter à mi-chemin laisse un compte dont le séquestre ne correspond plus à son vérificateur.
  • Traite 404 renvoyé par GET /blob comme un « nouveau compte », pas comme une erreur.
  • Envoie authHash (la branche HKDF auth du §3.1) et jamais la phrase de passe, la sortie d'Argon2id, ou KEK_p. Dériver la mauvaise branche est silencieux : l'authentification réussit et produit une clé qui ne déchiffre rien.
  • Récupère le descripteur KDF (§5.7) avant de dériver quoi que ce soit sur un nouvel appareil. Ne présume pas des valeurs par défaut, un compte créé avec des paramètres relevés ne se dérivera pas correctement avec celles-ci.
  • Conserve le jeton de rafraîchissement au même niveau de stockage que le jeton d'accès et ne jamais réutilise un jeton déjà consommé : un rejeu révoque toute la famille et déconnecte l'utilisateur (§4.2). Sérialise les rafraîchissements, deux onglets en compétition sur le même jeton de rafraîchissement ressemblent exactement à un vol.
  • Sur 401, rafraîchis une fois et réessaie une fois. Sur un deuxième 401, renvoie l'utilisateur vers la connexion plutôt que de boucler.

Modifier cette page sur GitHub