Zum Inhalt springen
openplate

Auf dieser Seite

Der Core-Server

openplate-Sync-Protokoll

Das Übertragungs- und Schlüsselprotokoll, Version 2

Diese Seite wurde maschinell aus der englischen Dokumentation übersetzt.

Protokollversion: 2 · Umschlagversion: 1 · Status: vor 1.0, noch nichts ausgeliefert

Dies ist die normative Spezifikation des Übertragungsprotokolls zwischen einem openplate-Client und einem Core-Server. Sie ist so geschrieben, dass Dritte beide Seiten implementieren können, ohne unseren Code zu lesen: einen alternativen Client, der sich mit unserem gehosteten Dienst synchronisiert, oder einen alternativen Server, auf den sich ein openplate-Client mit CORE_URL ausrichten lässt.

Das maschinenlesbare Gegenstück liegt in zwei Dateien, die manuell synchron gehaltene Duplikate voneinander sind:

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

Jedes Repository enthält einen Unittest, der seine Konstanten gegen übertragene Literale prüft (tests/unit/protocol.test.ts und tests/unit/sync-engine/protocol.test.ts). Es gibt keine gemeinsame CI zwischen den Repositories, daher sind diese Tests das Einzige, was eine unbemerkte Protokollspaltung verhindert. Dieses Dokument ist normativ, das TypeScript ist dessen Abschrift.

1. Die Zusammenfassung in einem Absatz

Der Client hält alle Schlüssel. Er serialisiert seinen gesamten lokalen Speicher, komprimiert ihn mit gzip, verschlüsselt ihn mit AES-256-GCM unter einem Schlüssel, den der Server nie gesehen hat, und überträgt das Ergebnis als ein einziges opakes Blob. Der Server speichert Bytes, versioniert sie und weist Schreibvorgänge ab, die Daten eines anderen Geräts überschreiben würden. Er speichert außerdem zwei kleine Schlüsselsätze (denselben Datenverschlüsselungsschlüssel, verpackt unter zwei verschiedenen Schlüsselverschlüsselungsschlüsseln, von denen einer von der Passphrase der Person und einer von einem Wiederherstellungscode abgeleitet ist), damit ein zweites Gerät den Bootstrap durchführen kann. Kein Codepfad auf dem Server entschlüsselt irgendetwas davon. Der Server bewahrt jedoch den Wiederherstellungscode jedes Kontos auf, versiegelt unter einem eigenen Geheimnis (§3.1), sodass die Betreiber einer verwalteten Instanz alles besitzen, was zum Öffnen eines Tagebuchs nötig ist. §9 legt genau dar, was der Server weiß.

Die folgende Abbildung zeigt eine vollständige Sitzung. Der Versionsabgleich läuft zuerst und ist nicht unverbindlich: Bei einer Abweichung oder wenn ein Dienst nicht erreichbar ist, bricht der Client an dieser Stelle ab, statt eine Nachricht zu übertragen, die die Gegenseite möglicherweise anders interpretiert. § 6 legt diese Regel fest, § 5.7 bis § 5.9 beschreiben die dadurch abgesicherte Anmeldung und § 5.1 behandelt den Push einschließlich des Compare-and-Swap-Fehlschlags, den ein Client auflösen muss.

Eine Sitzung der Reihe nach: der Versionsabgleich, der die Synchronisierung bei jeder Abweichung verweigert, danach die Anmeldung, anschließend ein Compare-and-Swap-Push.
Quellcode des Diagramms
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

BegriffBedeutung
DEKDatenverschlüsselungsschlüssel (Data-encryption key). Zufällige 32 Bytes. Verschlüsselt den Blob. Verlässt den Client nie unverpackt.
KEKSchlüsselverschlüsselungsschlüssel (Key-encryption key). Packt den DEK ein. Es existieren zwei: abgeleitet aus der Passphrase und abgeleitet aus dem Wiederherstellungscode.
UmschlagDas Übertragungsformat des verschlüsselten Blobs: iv ‖ AES-256-GCM(gzip(JSON(payload))).
SchlüsselsatzEin eingepackter DEK, plus (nur bei Passphrasen) die KDF-Parameter, die zur erneuten Ableitung seines KEKs nötig sind.
blobVersionMonotoner Zähler pro Konto. Das Compare-and-Swap-Token.
KontoDie Isolationseinheit. Ein Konto hat höchstens einen aktuellen Blob und höchstens zwei Schlüsseldatensätze.
E-MailDie Kontokennung: eine kanonische Adresse (NFKC, getrimmt, in Kleinbuchstaben). Eindeutig pro Server.
EinladungEin Einmal-Recht, das an eine E-Mail ADRESSIERT ist, ausgestellt von einer Betreiberin oder einem Betreiber. Der einzige Weg hinein.
EscrowDer Wiederherstellungscode des Kontos, auf dem Server unter einem Unterschlüssel von SERVER_SECRET versiegelt.
Rolleadmin oder member. Das eigene Zugriffstoken eines Admins authentifiziert /v1/admin.

Protokoll 2 ersetzte das Handle durch eine E-Mail (ADR-0005). Die Handle aus Version 1, eine opake serverspezifische Kennung, die kein @ enthalten durfte, existiert nicht mehr: die Spalte, der Parser und die Regel. Ein Client, der Version 1 spricht, muss die Verbindung zu einem Version-2-Dienst verweigern, statt fehlerhaft weiterzulaufen, siehe § 6.

3. Kryptographie (clientseitig; der Server implementiert nichts davon)

Ein konformer Server benötigt diesen Abschnitt nicht; er dient dazu, dass ein alternativer Client kompatibel bleibt und dass prüfende Personen die Zusicherungen nachvollziehen können.

3.1 Schlüsselableitung

                          ┌─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)
  • Argon2id-Parameter (pro Konto im kdfDescriptor des Passphrase-Schlüsseldatensatzes und im kontoeigenen KDF-Deskriptor hinterlegt, damit sie später erhöht werden können, ohne bestehende Konten zu beschädigen): memorySizeKib: 65536 (64 MiB), iterations: 3, parallelism: 1, hashLength: 32. Salt: 16 Zufallsbytes.
  • HKDF-info-Labels sind eingefrorene Bytefolgen, UTF-8-kodiert. Sie sorgen für Domänentrennung, damit die abgeleiteten Werte kryptographisch unabhängig sind: - openplate-sync:passphrase-kek:v1 - openplate-sync:recovery-kek:v1 - openplate-sync:auth:v1 - openplate-sync:recovery-auth:v1
  • Der auth-Zweig ist das, was der Client als Passwort sendet. Es ist ein Geschwister von KEK_p, weder Elternteil noch Kind: Beide sind HKDF-Ausgaben über denselben Argon2id-Hash unter verschiedenen info-Labels, weshalb der Besitz des einen keine Informationen über das andere liefert. Das ist der einzige Grund, warum der Server Nutzer authentifizieren kann, für die er nichts entschlüsseln kann. authHash ist 32 Bytes groß, Base64 bei der Übertragung.
  • Der recovery-auth-Zweig ist das, was der Client sendet, um den Besitz des Wiederherstellungscodes nachzuweisen (§5.14). Es ist genau in dem Sinne ein Geschwister von KEK_r, wie authHash ein Geschwister von KEK_p ist, und es umfasst 32 Bytes, Base64 bei der Übertragung.
  • Das recovery-auth-Label ist niemals das recovery-kek-Label. Diese Domänentrennung ist tragend, keine bloße Ordnungsliebe. Der KEK-Zweig leitet den Schlüssel ab, der das Tagebuch öffnet; würde dieselbe Ausgabe auch an den Server gesendet, würde dieser Dienst einen HMAC des Materials speichern, das einen DEK entschlüsselt, und „der Betreiber kann deine Daten nicht lesen“ (eine Behauptung, die nur gilt, solange dem Betreiber der hinterlegte Wiederherstellungscode fehlt, §9.1) würde darauf beruhen, dass SHA-256 eine Einwegfunktion ist, statt darauf, dass der Betreiber den Wert nie besessen hat. Beide Labels sind eingefroren, keines wird aus dem anderen abgeleitet, und eine künftige Änderung an einem von beiden ist ein neues :v2-Label statt einer Neudefinition (ADR-0004).
  • Der Server speichert auch niemals authHash oder recoveryAuthHash. Er speichert HMAC-SHA-256(serverPepper, ...) von beiden, wobei das Pepper außerhalb der Datenbank gehalten wird. Siehe §5.8.
  • Der Wiederherstellungspfad überspringt Argon2id bewusst und verwendet ein leeres HKDF-Salt. Das ist korrekt, kein Versehen: RFC 5869 §3.1 erlaubt dies, wenn das Eingabeschlüsselmaterial bereits eine hohe Entropie aufweist, was bei einem 160-Bit-Zufallscode konstruktionsbedingt der Fall ist. Nur menschliche Passphrasen mit niedriger Entropie benötigen eine speicherintensive Streckung und ein echtes Salt.
  • Wiederherstellungscode: 20 Zufallsbytes (160 Bit), dargestellt in einem Crockford-Base32-Alphabet (0123456789ABCDEFGHJKMNPQRSTVWXYZ, ohne O, I, L, um Übertragungsfehler zu vermeiden) in Fünfergruppen. Kanonisch sind es 32 Zeichen ohne Gruppierung in Großbuchstaben; in dieser Form versiegelt der Server den Wert.
  • Der Wiederherstellungscode ist auf dem Server HINTERLEGT (Protokoll 2, ADR-0005). Der Client zeigt ihn der Person nicht mehr an: Er sendet den unverschlüsselten Schlüssel einmal im Registrierungs-Body, und der Server speichert iv(12) ‖ AES-256-GCM(escrowKey, code) ‖ tag(16) in accounts.recovery_code_escrow, wobei escrowKey ein dritter, eingefrorener HMAC-Unterschlüssel von SERVER_SECRET ist (openplate-sync:escrow-key:v1, neben dem Verifier-Pepper und dem Dummy-Deskriptor-Schlüssel). Ein per E-Mail versandter Reset (§5.12) gibt den Schlüssel an die kontoinhabende Person zurück, die damit die reguläre Rotation nach §5.14 durchführt. Der Betreiber einer verwalteten Instanz besitzt daher die Mittel, um ein Tagebuch zu öffnen. Das ändert das Wesen dieses Dienstes grundlegend; es wird hier offen dargelegt statt versteckt und in docs/adr/0005-organization-accounts-and-escrowed-recovery.md ausführlich begründet.
  • Die Hinterlegung bezieht sich auf den CODE, nicht auf KEK_r und nicht auf den DEK. Nichts auf dem Server leitet einen KEK ab, entpackt einen DEK oder hält einen solchen vor; der Code wird erst dann zu einem Schlüssel, wenn ein Client HKDF darauf anwendet. Das schützt nicht vor der betreibenden Person, die HKDF ebenfalls ausführen kann; es sorgt für einen Server, dessen Codepfad keine Entschlüsselung von Benutzerdaten enthält. Genau das macht die Zusicherung überprüfbar, statt nur ein Versprechen zu sein.
  • KEKs sind 256-Bit-AES-GCM-Schlüssel, die als nicht-extrahierbar importiert werden.

3.2 Der Umschlag

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 Zufallsbytes, für jede Verschlüsselung neu generiert, verpackt als führende Bytes von ciphertext. Es gibt in diesem gesamten Protokoll kein separates IV-Feld.
  • Tag: Das 16-Byte-GCM-Authentifizierungstag wird an den Geheimtext angehängt (WebCrypto-Konvention).
  • AAD ist die UTF-8-Codierung eines kanonischen JSON-Objekts mit fester Schlüsselreihenfolge:
    json
    {"accountId":<int>,"blobVersion":<int>,"payloadSchemaVersion":<int>}

    Diese Bindung verhindert Cut-and-Paste (das Einspielen eines Blobs in ein anderes Konto) und Rollbacks (das Einspielen einer älteren Version oder von Nutzdaten aus einem inkompatiblen Schema des lokalen Speichers). Ein Client muss beim Entschlüsseln exakt dieses Tripel vorlegen, sonst schlägt die Tag-Prüfung fehl; das ist das beabsichtigte Verhalten und kein Fehler, den man umgehen sollte.

  • Komprimierung (gzip, RFC 1952) wird auf den Klartext vor der Verschlüsselung angewendet. Da Geheimtext nicht komprimierbar ist, gilt: zuerst komprimieren oder gar nicht. Siehe §8 für die Hintergründe und §9.2 für die ehrliche Darlegung der dadurch preisgegebenen Informationen.
  • Struktur von Nutzlast (alles innerhalb von snapshot ist für dieses Protokoll opak):
    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" }]
      }
    }
  • Verpackter DEK: iv ‖ AES-256-GCM(key=KEK, plaintext=DEK), kein AAD: Ein verpackter DEK ist an keine bestimmte Blob-Version gebunden. Die Länge beträgt immer 12 + 32 + 16 = 60 Bytes.

3.3 Zusammenführungssemantik (clientseitig)

Konflikte werden pro Entität über (lamport, deviceId) gelöst: Ein höherer Lamport-Zähler gewinnt, Gleichstände werden über lexikografisches deviceId aufgelöst. Die Systemuhr des Geräts ist ausdrücklich nicht ordnende Instanz; sie weicht ab und ist über Geräte hinweg schlicht unzuverlässig. Ein Löschmerker nimmt am selben Vergleich teil wie ein bestehender Wert. Akzeptierter Kompromiss in Version 1: Letzter Schreiber gewinnt für den gesamten Datensatz, sodass bei einer gleichzeitigen Offline-Bearbeitung derselben selben-Entität auf zwei Geräten der ältere Schreibvorgang stillschweigend verloren geht. Keine Zusammenführung auf Feldebene, keine Benutzeroberfläche für Konflikte.

3.4 Die Share-Verpackung (ADR-0002)

Ein Share ist eine dritte Verpackung desselben DEK, adressiert an den öffentlichen Schlüssel eines anderen Kontos. Der Server speichert ihn, liefert ihn an das eine Konto aus, an das er adressiert ist, und besitzt dafür keinen Schlüssel; §9.1 bleibt durch diese Funktion unverändert.

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)
  • Die Länge beträgt 125 Bytes, immer. Beachte, dass dies eine andere Invariante gegenüber dem 60-Byte-verpackten DEK aus §3.2 ist: 60 für einen Schlüsselsatz, 125 für eine Freigabe. Sie liegen in verschiedenen Tabellen, und kein gemeinsamer Validierungspfad verzweigt anhand der Länge.
  • P-256, und die Kurve wird im Label benannt statt nur die Version, sodass eine zukünftige Konstruktion ein neues Label erhält, statt Unklarheit über :v1 zu schaffen.
  • Das leere HKDF-Salt ist korrekt, aus denselben Gründen nach RFC 5869 §3.1, die §3.1 bereits für den Wiederherstellungscode festhält: Das IKM ist eine frische ECDH-Ausgabe mit hoher Entropie, kein menschliches Geheimnis, das ein speicherintensives Dehnen erfordert.
  • Diese Verpackung enthält AAD; die verpackten DEKs aus §3.2 tun das nicht. Eine Schlüsselsatz-Verpackung ist auf eine Zeile beschränkt, die nur dem Eigentümer gehört, und kann nicht mit der eines anderen verwechselt werden. Eine Freigabe-Verpackung liegt in einer servergesteuerten Verknüpfungstabelle, wo dies möglich wäre: Durch die Bindung schlägt bei einer eingeschleusten Zeile die Tag-Prüfung fehl, statt dass sie in das falsche Tagebuch entschlüsselt wird.
  • Die AAD bindet den Schlüsselfingerprint des Empfängers, nicht die Konto-ID der empfangenden Person. Eine Ersetzung greift den Schlüssel an, daher benennt die Bindung den Schlüssel, und der Empfänger rekonstruiert die AAD aus einem lokal berechneten Fingerabdruck, sodass kein vom Server bereitgestellter Wert in den Vertrauenspfad gelangt.
  • recipientKeyFingerprint ist SHA-256 des rohen, unkomprimierten öffentlichen Schlüssels. Der Server speichert ihn als Pinning-Metadaten und bestätigt, liefert oder erzeugt nie einen öffentlichen Schlüssel; der verbindliche gepinnte Schlüssel liegt im eigenen verschlüsselten Snapshot der freigebenden Person.

Eine empfangende Person muss eine Probe-Entschlüsselung durchführen. Die Blob-AAD aus §3.2 bindet payloadSchemaVersion, was §7 als opaken Integer definiert, der niemals über die Leitung übertragen wird. Ein Eigentümer kennt seinen eigenen; ein Empfänger kennt den des Gewährers nicht. Ein Empfänger versucht die Entschlüsselung daher über alle Schemaversionen hinweg, die sein Build unterstützt, und übernimmt diejenige, deren GCM-Tag verifiziert wird. Das ist ressourcenschonend und entspricht dem beabsichtigten Verhalten; füge kein Klartext-Feld für die Schemaversion hinzu, um dies zu lösen.

3.5 Der Forschungsumschlag (ADR-0003)

Ein Beitrag ist ein reduzierter, zeitlich begrenzter Ausschnitt des Tagebuchs, verschlüsselt mit dem öffentlichen Schlüssel einer Studie. Es ist ein anderes Artefakt als eine Freigabe, kein enger gefasster Ausschnitt davon: andere Nutzdaten, anderer Schlüssel, anderer Lebenszyklus und kein DEK ist beteiligt; die Verpackung liegt direkt über den Nutzdaten.

Das Pseudonym. Ein zufälliger 256-Bit-Root pro Konto liegt im eigentümerprivaten Bereich, übersteht also eine Wiederherstellung und erreicht ein zweites Gerät.

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

Die Bytes sind fest vorgegeben, da eine unzureichend spezifizierte Verkettung zu zwei Implementierungen führt, die in einem Deployment voneinander abweichen. Die Bezeichnung besteht aus ihren UTF-8-Bytes ohne Terminator; studyAccountId ist 8 Bytes, vorzeichenlos, Big-Endian, immer acht, niemals ihr Dezimaltext und niemals eine Kodierung minimaler Länge. Die Ausgabe sind die ersten 16 Bytes des MAC im Crockford-Base32-Alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ (kein Prüfzeichen, keine Bindestriche), was genau 26 Großbuchstaben entspricht. Ein Client, der die Ableitung über die ASCII-Ziffern der ID vornimmt, erzeugt ein wohlgeformtes Pseudonym, das sich mit nichts verknüpfen lässt.

Stabil über alle Einreichungen einer beitragenden Person hinweg, nicht studienübergreifend verknüpfbar (HMAC-Ausgaben unter verschiedenen Nachrichten sind unabhängig) und durch niemanden ableitbar, der sowohl die Kontotabelle als auch eine Kohorte besitzt. H(accountId ‖ studyId) hätte die letztgenannte Eigenschaft nicht: Mit öffentlichen Eingaben lässt es sich durch Aufzählung umkehren.

Das Pseudonym schützt gegen die forschenden Person, nicht gegen den Server. Der Server authentifiziert den Push per Bearer-Token und kennt daher ohnehin das Konto hinter jeder Zeile; siehe §9.2.

Der Umschlag.

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

Ein neues, festes Label statt einer Version des Freigabe-Labels: anderer Zweck, dieselbe Begründung, die auch die Kurve in den Namen setzte.

Die AAD enthält keine Account-ID, und Antworten der Studienseite enthalten ebenfalls keine. Dies ist die bewusste Umkehrung von §5.16, wo grantorAccountId erforderlich ist, weil die AAD aus §3.2 es bindet. Jedes AAD-Feld hier kann von der forschenden Person vor der Entschlüsselung rekonstruiert werden: Vier davon werden in der Antwort übertragen, und den Fingerabdruck berechnet sie lokal aus ihrem eigenen Schlüssel.

Die Nutzlast ist eine feste Stufe, nach Name ausgewählt. Eine Studie wählt eine Stufe und ein Zeitfenster, sie gibt niemals eine Feldliste an. v1 definiert eine:

daily-intake:v1: eine Zeile pro Kalendertag im Intervall, mit date (Tagesgranularität, keine Zeitstempel), energyKcal, proteinG, carbsG, fatG, fiberG, loggedEntryCount. Die Anzahl existiert, weil eine forschende Person sonst "nichts gegessen" nicht von "nicht protokolliert" unterscheiden kann; es ist eine Anzahl, niemals die Einträge selbst.

Ein neues Feld erfordert eine Protokollrevision, nie eine Konfiguration. Siehe ADR-0003.

4. Transportkonventionen

  • Alle Request- und Response-Bodys sind application/json.
  • Binäre Felder (ciphertext, wrappedDek) sind Base64-Strings (Standard-Alphabet, mit Padding). Sie werden bewusst nicht als binärer Inhaltstyp gesendet: Jedes Feld jedes Requests soll für Selbsthoster lesbar sein, die ihre eigene Instanz debuggen.
  • Zeitstempel sind ISO-8601-UTC-Strings, z. B. 2026-08-04T10:11:12.000Z.
  • Ein Wiederherstellungscode auf der Leitung ist Crockford-Base32-TEXT, wo immer er vorkommt (signup.recoveryCode, recover-rotate.recoveryCode, rotate-dek.recoveryCode und die Response von reset/open). Ein Server MUSS ihn gruppiert oder ungruppiert akzeptieren und ihn in jedem Fall zu 32 Großbuchstaben ohne Leerzeichen und Bindestriche kanonisieren, DIESEN versiegeln und dieselbe kanonische Form von reset/open zurückgeben. Ein Code hat daher genau eine versiegelte Form, sodass ein erneuter Escrow nach einer Rotation mit dem vorherigen Stand vergleichbar ist, und ein Client, der den Code in Fünfergruppen anzeigt, genau das zurücksenden kann, was er gerendert hat. Ein konformer Client akzeptiert ebenfalls beide Formen.
  • Jeder Antwortkörper außerhalb von 2xx ist {"error": "<human-readable text>"}. Der Text dient nur Diagnosezwecken; Clients müssen anhand von Statuscodes verzweigen, niemals anhand der Nachricht.
  • Anfragen, die das Body-Limit überschreiten, werden mit 413 abgewiesen. Jede Routenfamilie unter /v1/sync hat ihr eigenes Limit, und keine Familie erbt das einer anderen: Die Blob- und Key-Datensätze verarbeiten das Blob-Limit in Base64 plus 4 KiB, rotate-dek das Blob-Limit in Base64 plus 64 KiB, die Share-Familie 8 KiB und die Research-Familie 512 KiB.
  • Eine authentifizierte Route prüft das Bearer-Token, bevor sie den Body liest. Ein Aufrufer ohne gültiges Token erhält 401, niemals 413, unabhängig von der Größe des Bodys.
  • Eine Quelladresse ist eine IPv4-Adresse oder ein IPv6-/64. Jede Drossel, die dieses Dokument pro IP oder pro Quelladresse nennt, zählt einen IPv6-Aufrufer anhand der ersten 64 Bits seiner Adresse, da ein einzelner Heimanschluss ein ganzes /64 umfasst. Eine IPv4-gemappte IPv6-Adresse (::ffff:a.b.c.d) zählt als die IPv4-Adresse, die sie enthält. Eine IPv4-Adresse zählt als sie selbst. Eine Anfrage, deren Adresse der Server nicht bestimmen kann, teilt sich einen gemeinsamen Bucket mit jeder anderen derartigen Anfrage.

4.1 Authentifizierung

Ein Bearer-Token in einem Authorization: Bearer <token>-Header. Keine Cookies, in keiner Richtung.

  • Access-Control-Allow-Origin: *, und Access-Control-Allow-Credentials wird niemals gesendet. Jeder openplate-Client (unserer, der eines Self-Hosters auf eigener Domain oder eine Drittanbieter-Implementierung) kann daher unabhängig vom Ursprung mit jeder Instanz dieses Dienstes kommunizieren.
  • Diese Kombination ist genau deshalb, weil sicher, dass es keine Ambient Credentials gibt. Eine bösartige Seite kann einen Cross-Origin-Request absetzen und erhält einen 401, da der Browser nichts automatisch anhängt. Das ist die CSRF-Eigenschaft, die Cookies fehlt, und der Grund, warum die weit offene Origin eine bewusste Entscheidung statt einer Abkürzung ist.
  • Nicht authentifizierte Aufrufer erhalten 401. Authentifizierte, aber nicht autorisierte Aufrufer erhalten 403. Ein konformer Server darf beides nicht vermischen.
  • Zwei 403 tragen einen fester Maschinencode, nach dem ein Client verzweigt: account-suspended auf jeder Bearer-Route und health-consent-required auf jeder Datenroute, die §5.15.1 nicht als offen aufführt. Sie sind die Ausnahme von „nach dem Status verzweigen, nie nach der Nachricht“, weil 403 allein sie weder voneinander noch von einer gewöhnlichen Verweigerung unterscheiden kann:

    | Status | Body | Bedeutung | Was der Client tut | | ------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | | 401 | beliebig | Kein gültiges Access-Token | Erneuert einmal, fordert die Person dann zum Anmelden auf | | 403 | {"error":"account-suspended"} | Eine Administration hat das Konto gesperrt (§5) | Meldet das; erneutes Anmelden hilft nicht | | 403 | {"error":"health-consent-required"} | Die Instanz verlangt eine Gesundheitsdaten-Einwilligung und das Konto besitzt nicht deren aktuelle Version (§5.15.1) | Bittet um die Einwilligung, versucht es dann erneut | | 403 | alles andere | Authentifiziert, für diese eine Anfrage nicht zulässig | Liest die eigene Tabelle des Endpunkts |

  • Jeder benutzerdefinierte Request-Header, den eine Route liest, wird in Access-Control-Allow-Headers genannt: Authorization, Content-Type, Idempotency-Key (§5.23) und X-Intake-Id (§5.19). Jeder benutzerdefinierte Response-Header, den ein Client liest, wird in Access-Control-Expose-Headers genannt: Retry-After, X-Trial-Scans-Left, X-Quota-Used und X-Quota-Limit. Ein Browser weigert sich, einen Header zu senden, den die erste Liste auslässt, und blendet einen aus, den die zweite Liste auslässt, ohne jeglichen Eintrag in einem Log.

Dies ersetzte ein Same-Origin-Sitzungscookie, das existierte, während die Handler-Kerne innerhalb der openplate-App eingebunden waren. Diese Änderung und die Verlegung der Sync-Routen von /api/sync nach /v1/sync sind vor 1.0 und erhöhen PROTOCOL_VERSION nicht: Es existieren keine Produktions-Blobs, es gibt keine Drittanbieter-Implementierungen und kein bereitgestellter Client kann dadurch beschädigt werden. Sobald dieses Dokument zusammen mit einem öffentlichen Release veröffentlicht wird, endet dieser Spielraum; siehe §7.

4.2 Token-Lebenszyklus

Zwei Token-Arten, beides opake Zufallszeichenketten, beide gespeichert nur als SHA-256-Digests. Eine gedumpte Token-Tabelle liefert nichts Wiederverwendbares, und ungestrecktes SHA-256 ist hier korrekt, da das Urbild aus 256 Bit Zufall besteht; es gibt kein Wörterbuch abzuarbeiten.

TokenLebensdauerZweck
access15 Min.Wird bei jeder Anfrage mitgeschickt. Kurzlebig, weil ein geleakter Token so lange nutzbar bleibt, wie er gilt.
refresh30 TageWird gegen ein neues Paar eingetauscht. Rotierend: Jede Nutzung verbraucht es.

Warum ein opakes Paar und kein JWT. Widerruf ist in diesem Protokoll tragend: Eine Passphrasenänderung und eine Wiederherstellungscode-Rotation müssen jede aktive Sitzung sofort ungültig machen, und genau das erwartet eine Person, die ihre Passphrase unter Verdacht ändert. Ein zustandsloser Token lässt sich ohne serverseitige Sperrliste, die einem datenbankgestützten opaken Token entspricht, nur ablaufen lassen, aber nie sofort sperren.

Warum überhaupt ein Paar. Der Client darf die Passphrase niemals dauerhaft speichern, daher kann er nicht im Hintergrund erneut einen Auth-Hash ableiten, um sich wieder anzumelden. Ein langlebiges, rotierendes Refresh-Token ist das Einzige, was eine unbemerkte erneute Authentifizierung in einem Design ermöglicht, bei dem der Server die Passphrase nie sieht.

Rotation und Erkennung von Wiederverwendung. Jedes Paar trägt eine Familie-Kennung, die Rotationen überdauert.

  • POST /v1/auth/refresh mit einem gültigen Refresh-Token widerruft diesen und liefert ein frisches Paar derselben Familie zurück.
  • Das Vorzeigen eines Refresh-Tokens, das bereits widerrufen ist, ist das Wiederverwendungssignal: Der legitime Client hat es rotiert, daher besitzt derjenige, der es jetzt vorzeigt, eine Kopie, die er nicht haben sollte. Die gesamte Familie wird widerrufen. Dies loggt den Angreifer und den echten Benutzer aus, was das korrekte Ergebnis ist; die Alternative hinterlässt einem Dieb eine funktionierende Sitzung.
  • Der Verbrauch entscheidet, nicht das Lesen. Zwei Anfragen mit demselben Refresh-Token können dieses beide als aktiv vorfinden. Nur eine von ihnen kann es verbrauchen (eine bedingte Aktualisierung von „live“ auf „revoked“), und die andere wird als Wiederverwendung beantwortet: 401, und die Familie wird widerrufen. Genau eine von zwei gleichzeitigen Aktualisierungen mit einem Token erhält also ein 200, und dieses Paar überlebt die Wiederverwendungs-Antwort der anderen nicht. Ein Client muss seine eigenen Aktualisierungen serialisieren (§11); ein zweiter Inhaber, der mit dem ersten konkurriert, ist genau der Fall, für den die Wiederverwendungserkennung existiert.
  • Access-Tokens aus früheren Rotationen bleiben bewusst unangetastet; sie laufen von selbst nach wenigen Minuten ab, und ein Widerruf beim Rotieren würde eine legitime, noch laufende Anfrage abbrechen.

Widerrufsauslöser. Jeder dieser Fälle widerruft alle ausstehenden access- und refresh-Tokens des Kontos:

  • POST /v1/auth/change-passphrase
  • POST /v1/auth/recover-rotate
  • POST /v1/sync/rotate-dek, außer der eigenen Familie des Aufrufers (§5.17)
  • Sperrung durch eine Administration
  • Kontolöschung (über Zeilenkaskade)

POST /v1/auth/logout widerruft eine Familie (dieses Gerät) und belässt die anderen Sitzungen des Kontos unangetastet.

Sitzungs-Tokens sind die einzige Variante in account_tokens. Bis 0.5.0 enthielt diese Tabelle auch zwei LINK-Einmalvarianten für Nachrichten: Eine bestätigte eine Adresse, die andere löste einen per Mail verschickten Wiederherstellungslink ein. Beide fielen mit dem Mailversand weg und kehrten nicht zurück. Protokoll 2 kennt gar keine Adressbestätigung mehr (die Einladung dient als Verifizierung, §5.8) und sein Link zum Zurücksetzen ersetzt keine Anmeldedaten (§5.12).

Zwei Berechtigungs-Tokens liegen außerhalb dieser Tabelle, und beide tragen ein Präfix, damit keines dort übermittelt werden kann, wo das andere hingehört:

TokenPräfixLebensdauerGespeichert inWozu es dient
Registrierungseinladungsi_7 dsignup_invitesErstellt GENAU EIN Konto unter der in der Einladung angegebenen Adresse.
Passwort-Resetsr_60 minpassword_resetsGibt den hinterlegten Wiederherstellungscode des Kontos genau einmal zurück.

Beide bestehen aus 256 Bit Zufallswerten, beide werden nur als SHA-256-Digest gespeichert und beide sind für den Einmalgebrauch bestimmt. Keiner von beiden wird jemals als Authorization: Bearer-Anmeldedaten akzeptiert, und ein Sitzungstoken wird nie an ihrer Stelle zugelassen: Das Präfix dient als Formatprüfung vor jedem Datenbankzugriff, und ein Abweisen erzeugt denselben generischen Fehler wie ein falsches Token, weshalb daraus kein Orakel entsteht.

Wenn Eine Sperrung widerruft Token ebenfalls. accounts.suspended_at gesetzt ist, widerruft dies alle ausstehenden access- und refresh-Token in derselben Transaktion, sodass eine Sperrung sofort wirksam wird und nicht erst beim Ablauf des aktuellen Zugriffstokens.

5. Endpunkte

Zwei Familien unter einem versionierten Namensraum:

FamiliePräfixAuth
Sync (§5.1 bis §5.5)/v1/sync (SYNC_API_PREFIX)Bearer, immer
Handshake (§5.6)/healthKeine
Konto (§5.7 bis §5.15)/v1/authGemischt: pro Endpunkt angegeben

Ein gesperrtes Konto wird überall abgelehnt. POST /login, POST /refresh, POST /recover, POST /recover-rotate, jede durch Bearer geschützte Route und der Admin-Baum antworten mit 403 {"error":"account-suspended"}, genau dieser Zeichenkette, damit ein Client sie erkennen und mitteilen kann, was passiert ist. Auf login und den Wiederherstellungspfaden läuft die Prüfung NACH Verifizierung der Anmeldedaten ab, sodass eine unbekannte Adresse weiterhin das gewohnte, ununterscheidbare 401 erhält.

Einem Konto ohne die Einwilligung der Instanz wird jede Datenroute verweigert. Wenn instance.healthConsent nicht null ist (§5.6), erhält ein Konto, das nicht genau diese Version besitzt, 403 {"error":"health-consent-required"} auf jeder Route, die etwas für es speichert, sendet oder verbraucht, und behält die Routen, die es braucht, um zuzustimmen, zu gehen und seine eigene Kopie wieder auszulesen. §5.15.1 listet beide auf. Eine Kontosperrung wird zuerst geprüft, ein gesperrtes Konto bekommt also account-suspended zu hören.

Pfade in §5.1 bis §5.5 sind relativ zu SYNC_API_PREFIX angegeben; alles andere ist absolut.

5.1 POST /blob: Push (Compare-and-Swap)

Anfrage:

json
{ "baseVersion": 3, "envelopeVersion": 1, "ciphertext": "<base64>", "shrinkAcknowledged": false }
  • baseVersion: die blobVersion, von der der Client annimmt, dass sie derzeit gespeichert ist. 0 deklariert: "Dieses Konto hat noch keinen Blob".
  • Der Schreibvorgang wird akzeptiert, nur wenn baseVersion der aktuellen Version des Kontos entspricht. Das ist das gesamte Nebenläufigkeitsmodell. Es gibt keinen Force-Push und keinen Schreibvorgang ohne If-Match.
  • shrinkAcknowledged: OPTIONAL, und ein Fehlen bedeutet false. Siehe die Verkleinerungsschutzfunktion unten.

Antworten:

StatusBodyBedeutung
200{"newVersion": 4}Akzeptiert. Der Blob liegt nun bei newVersion.
409{"currentVersion": 5}Race Condition verloren. Ein anderes Gerät hat zuerst geschrieben.
400{"error": "..."}baseVersion keine nicht-negative Ganzzahl, envelopeVersion keine positive Ganzzahl, ciphertext fehlt/kein Base64, oder leer, oder shrinkAcknowledged vorhanden und kein boolescher Wert.
400{"error": "...", "currentSizeBytes": 5310, "nextSizeBytes": 1588}Eine unbestätigte starke Verkleinerung. Es wurde nichts geschrieben. Siehe unten.
413{"error": "..."}Blob überschreitet MAX_BLOB_BYTES.
401/403{"error": "..."}Nicht authentifiziert / nicht erlaubt.

Die Verkleinerungsschutzfunktion (M224). Ein Push, dessen dekodierter ciphertext strikt unter der Hälfte des Werts size_bytes der gespeicherten Version (BLOB_SHRINK_ACK_RATIO) beträgt, wird mit 400 ABGEWIESEN, es sei denn, die Anfrage enthält "shrinkAcknowledged": true. Ein Konto, das noch keinen Blob enthält, wird nie abgewiesen; ein erster Push ist keine Löschung.

Es ist eine BESTÄTIGUNG, kein Urteil. Ein Client setzt es genau dann auf true, wenn er Löschungen aus einem Zustand sendet, dem er ausdrücklich vertraut, und ein Client, der diesen Anspruch nicht erheben kann, lässt das Feld weg und nimmt die Abweisung hin. Der Dienst hält Geheimtext und kann eine bewusste Löschung nicht von einem Client unterscheiden, der seinen lokalen Speicher verloren hat und glaubt, alles sei gelöscht worden; das sind dieselben Bytes. Also fragt er nach, und ein Client, der nichts mitteilt, erhält eine Abweisung statt einer Löschung.

Wenn eine bestätigte Verkleinerung akzeptiert WIRD, wird die Version unmittelbar davor für BLOB_PRE_SHRINK_PIN_DAYS vor dem Bereinigen geschützt (§8).

Das CAS wird ZUERST geprüft: Ein Push auf Basis eines veralteten baseVersion führt zum regulären 409, unabhängig von seiner Größe, weil es die Aufgabe dieses Clients ist, abzurufen und zusammenzuführen, und er nach diesem Schritt meistens nicht mehr schrumpft. Die Schutzfunktion greift nur bei einem Push, der andernfalls akzeptiert worden wäre.

Die Abweisung erfolgt mit 400 und bewusst NICHT mit 409: Ein 409 auf dieser Route bedeutet, dass ein anderes Gerät zuerst geschrieben hat, und erzwingt die unten stehende Wiederherstellungsschleife, die dieselben Bytes erneut senden würde. 413 wurde ebenfalls nicht verwendet: Die Anfrage ist nicht zu groß.

shrinkAcknowledged ist ein FELD IM BODY und darf niemals zu einem Header werden. Ein neuer benutzerdefinierter Request-Header muss im CORS-Access-Control-Allow-Headers des Dienstes benannt werden, sonst liest ein Browser den Preflight, sieht einen Header, den er nicht senden darf, und sendet die Anfrage überhaupt nicht, ohne jegliche Protokollzeile und ohne dass ein Test außerhalb des Browsers dies beobachten kann. Begründung: docs/adr/0009-a-shrinking-blob-is-acknowledged-or-refused.md.

Die 409-Wiederherstellungsschleife ist Pflichtverhalten für den Client, keine Optimierung: Rufe currentVersion ab, entschlüssele ihn, führe ihn mit dem lokalen Zustand zusammen (§3.3), verschlüssele ihn erneut mit den an neu blobVersion gebundenen AAD und übertrage ihn erneut mit baseVersion: currentVersion. Ein Client, der 409 als fatalen Fehler behandelt, lässt das Gerät des Nutzers dauerhaft asynchron zurück.

5.2 GET /blob: Pull

StatusBody
200{"blobVersion": 4, "envelopeVersion": 1, "ciphertext": "<base64>", "createdAt": "<iso>"}
404{"error": "..."}: Dieses Konto hat noch nie einen Blob übertragen. Kein Fehlerzustand; so sieht ein frisches Konto aus.

5.3 GET /key-records: List

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

Gibt {"records": []} für ein Konto zurück, das die Einrichtung noch nicht abgeschlossen hat. Höchstens ein Eintrag pro kind.

5.4 PUT /key-records/:kind: Erstellen oder Rotieren (Compare-and-Swap)

:kind ist passphrase oder recovery, alles andere ist 400.

Anfrage:

json
{
  "kdfDescriptor": { "...": "..." } | null,
  "wrappedDek": "<base64>",
  "expectedUpdatedAt": "<iso>" | null,
  "currentAuthHash": "<base64, 32 bytes>"
}
  • expectedUpdatedAt: null setzt „Es existiert noch kein Eintrag dieser Art“ voraus (Ersteinrichtung).
  • Jeder andere Wert setzt „Der zuletzt gelesene Eintrag hatte genau diesen Wert für updatedAt“ voraus (Rotation).
  • Der Schlüssel muss vorhanden sein. Ein fehlendes expectedUpdatedAt führt absichtlich zu einem 400: Ein Aufrufer darf die Nebenläufigkeitsprüfung nicht überspringen können, indem er ein Feld vergisst.
  • Ein Überschreiben weist die Passphrase nach. Wenn expectedUpdatedAt nicht null ist, ist currentAuthHash (der Auth-Zweig der aktuellen Passphrase, §3.1) ERFORDERLICH: fehlt er oder ist er fehlerhaft, ist das ein 400, der ihn benennt, und stimmt er nicht mit dem Konto überein, ist das 401 {"error":"current passphrase is incorrect"}, der Body, den change-passphrase sendet, ohne dass etwas geschrieben wird. Eine Neuerstellung (null) bleibt rein Bearer-basiert und ignoriert das Feld: Sie füllt während der Einrichtung einen leeren Platz, und das CAS lehnt sie ab, sobald ein Datensatz existiert. Das Ersetzen eines Wraps ersetzt das, was das Konto öffnet, und ein Bearer-Token allein darf dazu nicht in der Lage sein.
  • Rateversuche werden pro Konto gedrosselt, in einem gemeinsamen Bucket mit change-passphrase, delete und rotate-dek: Ein gesperrtes Konto erhält bei allen vier 429 mit Retry-After, von jeder Adresse aus. Ein Treffer leert den Bucket.

Validierung, jeweils 400:

  • leeres wrappedDek
  • kind: "recovery" mit einem kdfDescriptor, das nicht null ist (der Wiederherstellungspfad nutzt ausschließlich HKDF, es gibt keine zu speichernden Parameter)
  • kind: "passphrase" mit einem kdfDescriptor, das null ist

Antworten:

StatusBody
200Der gespeicherte Datensatz, gleiche Struktur wie ein Eintrag in GET /key-records.
400{"error": "..."}: die obige Validierung oder ein Überschreiben ohne wohlgeformtes currentAuthHash.
401{"error": "current passphrase is incorrect"}: ein Überschreiben, dessen currentAuthHash nicht übereinstimmte.
409`{"currentUpdatedAt": "<iso>" \"]}null}`: Die CAS-Zusicherung traf nicht zu.
429{"error": "..."} mit Retry-After: Passphrasen-Rateversuche für dieses Konto sind gesperrt.

5.5 DELETE /key-records/:kind: entfernt und nicht wiederhergestellt

Entfernt im September 2026. Der Pfad antwortet nun wie jeder unbekannte Pfad unter dem Präfix: 401 ohne Token, 403, wenn dem Konto die Zustimmung der Instanz fehlt, und andernfalls das gewöhnliche 404. Kein Client hat ihn aufgerufen, und das Löschen des einzig verbliebenen Key-Datensatzes machte jeden gespeicherten Blob allein mit einem Bearer-Token dauerhaft unentschlüsselbar.

Ein Key-Datensatz wird über §5.4 ersetzt, was die Passphrase nachweist, oder über eine Rotation (§5.14, §5.17), und wird nur zusammen mit dem Konto entfernt (§5.15).

Eine Freigabe (§5.16) zählt nicht als Schlüsseleintrag. Es ist kryptografisch eine dritte Umhüllung desselben DEK, aber es ist die Berechtigung einer anderen Person, von ihr widerrufbar, von dir nicht überprüfbar und abhängig von ihrer anhaltenden Kooperation und Redlichkeit. Kein Client darf jemals "Stelle deine Daten über deine Ernährungsfachkraft wieder her" als Wiederherstellungspfad anbieten.

5.6 GET /health: Versionsabgleich

Absichtlich nicht authentifiziert: Ein Client muss erkennen können, dass er inkompatibel ist, vor er Anmeldedaten besitzt, und eine Funktionsprüfung, die ein Token erfordert, würde lediglich den Status des Tokens melden.

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 beschreibt, was dieses Deployment ist und was es kann, und es ist optional: Ein Dienst, der älter als das Feld ist, lässt es weg, und ein Client, der es voraussetzt, würde sich weigern, mit jeder solchen Instanz zu sprechen. name ist die Bezeichnung des Betreibers für die Instanz, language ist eines von en, de, fr, it, es, tr (die sechs Sprachen, in denen seine E-Mails verfasst sind; ein Client zeigt dies an und verzweigt nie danach, daher ist eine siebte Sprache keine Protokolländerung), mail gibt an, ob die Instanz überhaupt Briefe versenden kann, memberInvites gibt an, ob ein normales Mitglied Personen hierher einladen darf (§5.21), openSignup gibt an, ob eine Person hier ein Konto anfordern darf (§5.8.3), signupCaptcha gibt an, was diese Anforderung benötigt, trial sichert die kostenlosen Scans zu, die ein neues Konto erhält (§5.19), plans gibt an, ob ein Abrechnungsdienst hinter dieser Instanz steht, sodass /v1/plans/* existiert (§5.22), push gibt an, ob diese Instanz Web-Push versenden kann, sodass /v1/push/* existiert (§5.24), healthConsent benennt die Einwilligung zu Gesundheitsdaten, die sie von jedem Konto einfordert (§5.15.1), nutrientReferenceBasis gibt an, wessen Mikronährstoff-Referenzwerte sie anzeigt, und ai ist null, wenn kein Upstream-Schlüssel konfiguriert ist. ai.model ist das Modell der Standardstufe der Instanz (§5.19): das Modell, an das der Proxy eine Anfrage sendet, es sei denn, der Betreiber hat das Schema dieser Anfrage an eine andere Stufe weitergeleitet. Es ist null, wenn der Betreiber keines angegeben hat und das eigene Modell des Aufrufers gesendet wird.

defaultCapabilities ist die Liste der Berechtigungen (§5.19, „Capabilities“), die ein Konto besitzt, wenn es keinen eigenen Datensatz hat, zum Beispiel ["scan", "recipes"]. Es ist immer vorhanden: null bedeutet, dass diese Instanz überhaupt keine Berechtigung prüft, sodass jede Funktion offensteht, was eine Self-Hosting-Instanz hat, die nichts setzt, und [] bedeutet, dass ein Konto keine besitzt, bis ein Datensatz dies festlegt. Ein Client, der keinen Schlüssel findet (jeder Dienst, der älter ist als das Feld), liest es als null. Es ist beschreibend, niemals eine Gewährung: Der Proxy entscheidet pro Anfrage anhand des kontoeigenen Datensatzes und dieses Standardwerts.

healthConsent ist die ausdrückliche Einwilligung zu Gesundheitsdaten, die diese Instanz von jedem Konto verlangt, {"version": "<v>"}, oder null, wenn sie keine verlangt, was der Standard beim Self-Hosting ist. Es ist eher null als fehlend, wie ai, und ein Client, der keinen Schlüssel findet (jeder Dienst, der älter als das Feld ist), liest es als null. Anders als der Rest dieses Blocks setzt der Dienst es setzt durch: Solange es nicht null ist, erfordert die Kontoerstellung die passende Einwilligung (§5.8), und jede Datenroute verweigert einem Konto, das nicht genau diese Version besitzt, den Zugriff mit 403 {"error":"health-consent-required"}, bis es auf §5.15.1 zustimmt. Ein Client, der eine Version vorfindet, fragt vor dem Synchronisieren nach und behandelt dieses 403 als dieselbe, nur verspätet gestellte Frage. Ein Client, der null vorfindet, blendet kein Kontrollkästchen für die Einwilligung ein, und der Dienst verweigert für ihn nichts.

push folgt genau plans: ein boolescher Wert, der nur besagt, ob eine Tür existiert. false bedeutet, dass der gesamte Teilbaum /v1/push mit dem gewöhnlichen 404 für unbekannte Pfade antwortet, sodass ein Client keine Benachrichtigungseinstellungen anzeigt. Es sagt nichts darüber aus, was ein Push enthält, da ein Push eine Art enthält und sonst nichts (§5.24).

plans ist ein Boolean und kein optionales Versprechen, was absichtlich das Gegenteil der Wahl ist, die instance.feedback unten trifft. Dieses Feld ist ein Versprechen darüber, was mit einem Foto geschieht, und eine Instanz, die nichts zu versprechen hat, lässt es weg. Dieses hier verspricht nichts: Es sagt nur aus, ob eine Tür existiert, was dieselbe Art von Aussage ist wie bei mail und memberInvites, daher ist false die ehrliche Antwort sowohl für eine Instanz ohne Abrechnungsdienst als auch für einen Dienst, der vor der Existenz des Feldes erstellt wurde.

openSignup ist ein boolean, wie memberInvites und plans: Es gibt nur an, ob ein Zugang existiert. true bedeutet, dass POST /v1/auth/signup-request eine Adresse entgegennimmt (§5.8.3); false und ein Dienst, der vor dem Feld gebaut wurde, bedeutet, dass der Pfad mit dem gewöhnlichen Unbekannter-Pfad-404 antwortet und ein Client seinen Einladungstext statt eines Registrierungsformulars anzeigt. Es ist beschreibend, niemals eine Berechtigung: Die Drosselungen, das Captcha, die abgelehnten Domains und die Begrenzung auf eine E-Mail pro Postfach und Tag verbleiben auf dem Dienst.

signupCaptcha ist nur vorhanden, solange openSignup gleich true ist und der Betreiber ein Captcha betreibt. provider ist heute turnstile; siteKey ist der öffentliche Site-Key von Cloudflare Turnstile, mit dem ein Client das Widget rendert und der keinerlei Berechtigung erteilt. Das vom Widget erzeugte Token wird als captchaToken im Registrierungs-Request übertragen. Fehlt das Feld, benötigt der Request kein Token.

trial ist ein Zusicherung, wie feedback unten, daher fehlt es, statt null zu sein auf einer Instanz, die keinen Scan-Testzeitraum anbietet. scans ist die Anzahl kostenloser KI-Scans, die ein neues Konto dort erhält (§5.19, „Der Scan-Testzeitraum“). days ist die Anzahl der Tage nach dem Registrierungstag, an deren Ende dieser Testzeitraum auch bei verbleibenden Scans abläuft, je nachdem, was zuerst eintritt (§5.8 gibt an, wohin dieses Ende fällt); auf einer Instanz, deren Testzeitraum kein Enddatum hat, ist es abwesend, niemals null, und ein Client, der kein days findet, gibt die Scans genau wie zuvor an und DARF KEINE Anzahl von Tagen angeben. Ein Client, der kein trial findet, DARF KEINE Anzahl kostenloser Scans nennen. Beide Zahlen sind diejenigen, die jeder Zugang zum Testzeitraum schreibt, veröffentlicht aus denselben Einstellungen (TRIAL_SCANS, TRIAL_DAYS), sodass der Satz, den eine Person vor der Registrierung liest, und die vom Proxy durchgesetzten Limits nicht auseinanderdriften können.

memberInvites ist beschreibend, niemals eine Zuteilung, wie alles andere in diesem Block. Ein Client liest es, um zu entscheiden, ob er ueberhaupt eine Einladungskarte anzeigt; er liest es niemals, um zu entscheiden, ob er erzeugen darf. false bedeutet, dass POST /v1/auth/invites den gewoehnlichen Pfad-unbekannt-Fehler 404 liefert, und true ueberlaesst das Lebenszeitlimit, die Wiedereinladungsregel und die Drossel weiterhin dem Dienst.

Es ist beschreibend, niemals verbindlich. mail: true verspricht nicht, dass ein Brief ankommt, und ai meldet, was die betreibende Person konfiguriert hat, statt irgendetwas zu gewähren; ein Konto mit dailyAiLimit: 0 erhält ein 403, ganz gleich, was hier steht.

nutrientReferenceBasis ist dge, efsa oder us: die Mikronährstoff-Referenzwerte welcher Organisation jeder Client auf dieser Instanz anzeigt, die Werte der deutschen DGE, der europäischen EFSA oder der US-amerikanischen NASEM. Es ist optional, sodass ein Dienst, der vor der Einführung des Feldes gebaut wurde, es weglässt und ein Client, der es nicht kennt, es ignoriert. Es ist eine Basis pro Instanz, niemals pro Sprache und niemals pro Person: Sprache und Referenzorganisation sind voneinander unabhängig, und ein Standardwert, der der Spracheinstellung folgt, wäre eine verkappte Basis pro Person.

Es ist auch das ein Feld in diesem Block, das ein Administrator ändern kann, während der Dienst läuft. Alles andere hier ist die Umgebung des Betreibers, unveränderlich bis zu einem erneuten Deployment; dieser Wert wird gespeichert, und PATCH /v1/admin/settings (§5.20) schreibt ihn. Ein Client liest ihn daher bei jedem Verbindungsaufbau, anstatt ihn für die Lebensdauer einer Installation zwischenzuspeichern. Ein Dienst MUSS diesen Pfad aus einer prozesslokalen Kopie des Werts bedienen und DARF NICHT seinen Speicher auslesen, um /health zu beantworten: Dies ist der Pfad für den Container-Healthcheck, der fortlaufend abgefragt wird, und ein Lesezugriff auf den Speicher an dieser Stelle macht aus einem kurzen Datenbankproblem einen Neustart.

instance.feedback ist das einzige Feld hier, das ein Versprechen statt einer Beschreibung ist, und es bildet die Ausnahme zum obigen Absatz. Eine Instanz, die gemeldete Schätzungen annimmt, behält ein Foto vom Essen einer Person, und sie veröffentlicht, für wie lange:

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

retentionDays ist die Zahl, nach der der eigene Bereinigungslauf des Dienstes löscht, veröffentlicht aus derselben Anbindung, damit der Satz, den ein Client einer Person vor der Übergabe eines Fotos anzeigt, und die anschließende Löschung nicht voneinander abweichen können.

Das Feld ist abwesend, niemals null, auf einer Instanz, die keine Meldungen annimmt. ai: null ist eine Angabe, die jede Instanz macht; dies ist ein Versprechen, und eine Instanz mit deaktivierter Funktion hat keines zu machen, also fügt sie gar keinen Schlüssel hinzu und bleibt ununterscheidbar von einer Instanz, die vor der Existenz dieses Feldes gebaut wurde, genau wie ihr /v1/feedback-Baum ununterscheidbar von einem bleibt, in dem die Funktion nie geschrieben wurde.

Ein Client, der kein angegebenes Zeitfenster vorfindet, DARF KEINES nennen. Er bietet keine Meldung an oder eine Formulierung, die keinen Zeitraum nennt; die Ausgabe einer Zahl aus einem lokalen Standardwert veröffentlicht gegenüber einer Person, die über das Senden eines Fotos entscheidet, ein Versprechen, das der Dienst nie gegeben hat.

signupMode ist in Protokoll 2 nicht mehr vorhanden, zusammen mit der Einstellung, die es beschrieb: Ein Konto wird ausschließlich durch das Einlösen einer Einladung erstellt, und openSignup gibt an, ob eine Person nach einer fragen darf (§5.8). Ein Dienst, der signupMode noch veröffentlicht, spricht Version 1.

notice ist die Mitteilung des Betreibers an jeden Client, und das Feld ist optional im exakt selben Sinne wie instance: Eine Instanz, die nichts mitzuteilen hat, lässt das Feld weg, und ein Client, der es nicht kennt, ignoriert es.

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 ist erforderlich, wenn das Feld vorhanden ist; url ist optional und, sofern vorhanden, eine absolute https:/http:-URL. Der Dienst begrenzt text auf 280 Zeichen und verweigert den Start bei längeren Werten, da /health auch der HEALTHCHECK-Pfad des Containers ist und kontinuierlich abgefragt wird.

Dies ist ein reiner Pull-Kanal. Er kann nicht feststellen, wer einen Hinweis gelesen hat: Wer die App öffnet, sieht ihn, und wer sie nicht öffnet, sieht ihn nicht. Es handelt sich nicht um einen Benachrichtigungsmechanismus, und man darf sich nicht darauf verlassen. Protokoll 2 stellt dem Dienst zwar zwei E-Mails bereit, die er versenden kann (eine Einladung und ein Passwort-Reset, §5.8 und §5.12), aber keine davon dient als Kanal für andere Zwecke: Wer als Betreiber seinen Benutzern etwas mitteilen möchte, verwaltet diese Kontaktliste selbst, außerhalb dieses Dienstes.

Ein Client MUSS text und url als feindselige Eingaben behandeln. Sie stammen von dem Server, den der Benutzer angegeben hat. Stelle text als reinen Text und niemals als Markup dar, und folge url erst nach expliziter Prüfung des Schemas.

5.7 POST /v1/auth/kdf: KDF-Deskriptor vor der Anmeldung

Nicht authentifiziert, IP-gedrosselt. Gibt das Argon2id-Salt und die Parameter zurück, die ein Gerät zur Ableitung von authHash benötigt, bevor es sich anmelden kann.

POST statt GET für einen reinen Lesevorgang: Ein GET platziert die Adresse in der Anforderungszeile und damit in Zugriffsprotokollen, Proxy-Protokollen, Referer-Headern und im Browserverlauf. Ein Endpunkt, dessen einziger Zweck darin besteht, nicht preiszugeben, wer ein Konto besitzt, sollte den angefragten Identifikator nicht streuen. Dieses Argument galt bereits für Handles; wird die Adresse erneut über das Netz übertragen, entscheidet dies über ein Datenleck.

Anfrage: {"email": "anna@example.org"} · Antwort 200:

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

Auch eine unbekannte Adresse erhält einen Deskriptor. Er wird deterministisch als HMAC(serverSecret, email) über die kanonische Adresse abgeleitet (§5.8), ist also über Anfragen hinweg stabil, identisch aufgebaut und wird über denselben Codepfad erzeugt. Ein 400 wird nur für Eingaben zurückgegeben, die überhaupt keine Adresse sein können. Weder der Wechsel zu Handles in M181 noch der Rückwechsel zu Adressen in M192 änderten eine Zeile der Ableitung: Sie läuft über eine opake Zeichenkette, und beides ist eine solche.

Das ist wichtiger, als es scheint. Ein Login, bei dem der Server die Passphrase nie sieht, erfordert einen unauthentifizierten, nach Bezeichnern aufgeschlüsselten Endpunkt, der vor der Authentifizierung antwortet; naiv umgesetzt ist das ein kostenloses, lautloses, ungedrosseltes Verzeichnis darüber, welche Adressen Konten besitzen. Stabilität ist ebenso tragend wie die Form: Ein zufälliger Dummy wäre unterscheidbar, wenn man zweimal fragt.

Ein konformer Server DARF für eine unbekannte Adresse WEDER 404, noch einen leeren Rumpf oder eine abweichende Struktur zurückgeben. Er muss außerdem:

  • Führe in beiden Zweigen dieselbe Arbeit aus. den Dummy bedingungslos ableiten, auch für bestehende Konten, die ihn nie verwenden, damit Treffer und Fehlschlag denselben Suchaufwand und denselben HMAC erfordern. Bei einer verzögerten Ableitung entsteht ein Zeitunterschied: Die Antwort verrät nichts, aber die Dauer ihrer Erzeugung tut es.
  • Über die kanonische Adresse ableiten, daher lassen sich zwei Schreibweisen einer unbekannten Adresse nicht anhand ihrer Deskriptoren unterscheiden.
  • Ratenbegrenzung nach Quelladresse, gibt 429 mit Retry-After zurück. Das ist die andere Hälfte derselben Verteidigung: Das verbleibende Timing-Signal ist statistischer Natur und entsteht erst durch viele Messungen pro Adresse. Wenn man diese Messungen verwehrt, schließt man die Lücke. Das Limit an die übermittelte Adresse zu binden wäre schlimmer als gar nichts, da das Testen vieler Adressen ist des Angriffs ist. Ein Zähler pro Adresse würde dem Angreifer also für jede Adresse, die er testen will, ein frisches Kontingent spendieren.

5.8 POST /v1/auth/signup

Nicht authentifiziert, IP-gedrosselt. Eine Einladung ist nach wie vor das Einzige, was ein Konto erstellt, auf jeder Instanz. Auf einer Instanz mit instance.openSignup: true darf eine Person nach einer an sich selbst adressierten Einladung FRAGEN (§5.8.3); was sie erhält, ist eine gewöhnliche Einladung, die hier genau wie eine vom Betreiber erstellte eingelöst wird. SIGNUP_MODE ist ein Startfehler, weil es keinen Modus zu setzen gibt: Der einzige Schalter ist, ob der Anfragezugang aus §5.8.3 existiert.

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 ist erforderlich, wo instance.healthConsent nicht-null ist und wird überall sonst ignoriert (§5.15.1). Sein version muss Byte für Byte dem der Instanz entsprechen. Ohne es lautet die Antwort 400 {"error":"health-consent-required"} und nichts wird erstellt oder verbraucht: Die Einladung bleibt einlösbar, die Person setzt also das Häkchen und sendet die Anfrage erneut ab. Die Prüfung läuft nach jedem anderen Feld, sodass eine fehlerhafte Einladung dennoch zuerst mit dem 403 unten beantwortet wird. Ein damit erstelltes Konto besitzt die Einwilligung ab seiner ersten Anfrage, daher verweigert ihm keine Datenroute den Zugriff; ein Client, der Konten auf einer solchen Instanz erstellt (eine Studien-Konsole, ein Befüllungswerkzeug), sendet das Feld ebenfalls, sonst wird seinem Konto jede Datenroute verweigert (§5.15.1).

Es gibt kein Feld email, und genau darum geht es. Die Adresse stammt aus der Einladungszeile innerhalb der Transaktion. Ein Rumpf kann kein Postfach beanspruchen, das die betreibende Person nicht angeschrieben hat. Genau das macht die Einladung selbst zur Adressbestätigung: Wer den Brief erhalten hat, löst ihn auch ein, weshalb es keinen Bestätigungslink gibt und danach nichts mehr zu bestätigen bleibt. role und dailyAiLimit stammen aus demselben Grund aus der Einladung: Ein Konto fragt niemals nach seinem eigenen Status.

recoveryAuthHash, recoveryCode und BEIDE Schlüsseldatensätze sind erforderlich. In Protokoll 1 war jedes davon optional, jetzt keines mehr:

  • Der Client zeigt der Person den Wiederherstellungscode nicht mehr an (§3.1), sodass ein ohne Hinterlegung erstelltes Konto durch kein Zurücksetzen wiederhergestellt werden kann, ohne dass die Person je davor gewarnt wurde.
  • Ein Datensatz vom Typ passphrase sorgt dafür, dass die Passphrase überhaupt etwas entschlüsseln kann; ohne ihn meldet sich das Konto an und liest nichts, und der Client hat die Passphrase verworfen, sobald dies bemerkt wird.
  • Ein Datensatz vom Typ recovery sorgt dafür, dass der hinterlegte Schlüssel ausgepackt werden kann; ohne ihn liefert ein Zurücksetzen per E-Mail Anmeldedaten, die sich authentifizieren und nichts öffnen, was erst an dem Tag auffällt, an dem man sie braucht.

recoveryCode wird als Crockford-Base32 aus 20 Bytes validiert (32 Zeichen, sobald Leerzeichen und Bindestriche entfernt sind und der Wert in Großbuchstaben vorliegt) und vor dem Versiegeln in diese Form gebracht. Es wird niemals protokolliert, in keiner Form und auf keinem Pfad.

StatusBedeutung
201{"account": AccountView, "tokens": {...}} (§5.15). Es wird immer eine Sitzung ausgestellt; es gibt nichts mehr zu bestätigen.
400Ein authHash, recoveryAuthHash oder recoveryCode mit der falschen Struktur; ein Deskriptor ohne 16-Byte-Salt und positive Argon2id-Parameter; oder keyRecords ohne Typangabe. {"error":"health-consent-required"}: Die Instanz verlangt eine Einwilligung und der Body enthält keine oder eine andere Version; die Einladung wird NICHT verbraucht.
403{"error":"invite-invalid"}: Die Einladung fehlt, ist fehlerhaft, gehört zu einem anderen Dienst, ist unbekannt, abgelaufen, widerrufen oder bereits eingelöst. Sieben Fälle, eine Antwort.
409Für die Adresse der Einladung existiert bereits ein Konto. Die Einladung wird NICHT verbraucht.
429Gedrosselt. Retry-After in Sekunden.

Der Server speichert HMAC-SHA-256(serverPepper, authHash) und dieselbe Konstruktion über recoveryAuthHash, nicht eine zweite langsame KDF über beiden. Der Client hat die speicherintensive Berechnung bereits bezahlt; ein erneutes Hashen auf dem Server würde keinen Brute-Force-Schutz hinzufügen (ein Angreifer im Besitz des Authentifizierungs-Hashs hat Argon2id bereits umgangen) und gleichzeitig ein DoS-Risiko durch Anmeldefluten schaffen, bei dem jeder Versuch 64 MiB belegt. Peppering erfüllt trotzdem seinen Zweck: Befindet sich das Pepper außerhalb der Datenbank, lässt sich ein Tabellenauszug weder gegen eine laufende Instanz einspielen noch offline gegen Schätzwerte prüfen.

Die gesamte Übermittlung wird in einer einzigen Transaktion festgeschrieben: Das Einlösen der Einladung, die Account-Zeile (mit der Einwilligung, wo die Instanz eine verlangt), das versiegelte Escrow und beide Schlüsseldatensätze. Jeder Halbzustand ist ein eigener Fehlschlag, den die Person nicht sehen kann, bis sie versucht, ihr eigenes Tagebuch zu lesen.

Der 409 ist das einzige Aufzählungsorakel in diesem Protokoll, und Protokoll 2 hat ihn fast vollständig neutralisiert. Er ist nur für jemanden erreichbar, der eine gültige Einladung besitzt, die genau an die Adresse ADRESSIERT war, die als belegt gemeldet wird. Er bestätigt also nur, was der Betreiber in die Nachricht geschrieben hat. In Protokoll 1 konnte der Inhaber einer Einladung mit einer einzigen Einladung beliebige Benutzernamen prüfen; das geht jetzt nicht mehr, weil die Adresse nicht frei wählbar ist. Die Einladung wird dabei nicht verbraucht; ein Betreiber, der versehentlich jemanden zweimal eingeladen hat, macht die gültige Einladung damit also nicht unbrauchbar. Vollständige Begründung: SECURITY.md.

5.8.1 Einladungen

Eine Einladung ist eine einmalige, ablaufende Zugriffsberechtigung adressiert an eine Person. Sie enthält die Adresse, mit der das Konto erstellt wird, die Namensvermutung des Betreibers, die Rolle und das tägliche KI-Kontingent. Unbekannte, fehlerhafte, fehlende, dienstfremde, abgelaufene, widerrufene und bereits eingelöste Token erzeugen alle DIESELBE Antwort 403 und denselben Textkörper, {"error":"invite-invalid"}: Eine Unterscheidung würde es einem Aufrufer ermöglichen zu testen, welche Token existieren, und würde offenlegen, dass ein Token einmal gültig war.

Was das Einlösen gewährt (30.09.2026), entschieden anhand der Einladungszeile, in dieser Reihenfolge: Eine Zeile mit Scan-Testphase gewährt diese Testphase (unten); eine von einem Mitglied veranlasste Zeile gewährt das Kontingent des Mitgliedszugangs (§5.21), die Scan-Testphase oder das Tage-Paar, selbst wenn die einladende Person ihr Konto inzwischen gelöscht hat, und überhaupt keine KI, wenn die Instanz Mitgliedseinladungen deaktiviert hat, seit der Brief verschickt wurde; jede andere Zeile, die Erstellung durch eine Betreuungsperson ohne Testphase oder eine offene Registrierung auf einer Instanz, die keine anbietet, gewährt ihr tägliches Kontingent als das dauerhaftes kostenloses Kontingent des Kontos (freeDailyAiLimit, §5.15) mit einem bezahlten dailyAiLimit von 0. Zuvor schrieb dieser letzte Fall ein dailyAiLimit ohne Datum, jene Form, für die §5.19 nichts mehr gewährt.

Ein Einladungstoken beginnt mit si_, und der Dienst weist alles ab, worauf das nicht zutrifft. Das Präfix bindet das Token an diesen Dienst und an diesen Endpunkt. Eine Person erhält eine Einladung per E-Mail neben einem Token zum Zurücksetzen des Passworts, das mit sr_ beginnt; ohne die Präfixe sind die beiden Zeichenfolgen austauschbar und können an den falschen Endpunkt gesendet werden. Die Prüfung ist ein Formatsprüfung vor dem Nachschlagen, der mit demselben Status und demselben Textkörper wie jede andere ungültige Einladung abgewiesen wird, sodass die Schranke kein Orakel öffnet. Sitzungstoken tragen kein Präfix und bleiben unverändert.

Das Erstellen ist POST /v1/admin/invites. Eine ältere PENDING-Einladung für dieselbe Adresse wird durch eine neue widerrufen, sodass es nie mehr als eine aktive Berechtigung pro Adresse gibt; eine Adresse, die bereits ein Konto besitzt, kann überhaupt nicht eingeladen werden (409). Die einzige Ausnahme ist der Anfragezugang aus §5.8.3, der eine ausstehende Einladung des Betreibers oder eines Mitglieds unberührt lässt, statt sie auf Geheiß eines Fremden zurückzuziehen.

Eine Einladung kann ein Scan-Testlauf tragen (trialScans, §5.19): Eine offene Registrierung, eine Admin-Erstellung mit "trial": true und, falls die Instanz dies nutzt, eine Mitglieder-Einladung schreiben die Zahl der Instanz in die Zeile, und das Einlösen kopiert sie auf das Konto. Wenn die Instanz auch ein Tageslimit setzt (instance.trial.days), trägt die Zeile dieses ebenfalls, und das Einlösen startet die Uhr: Der Tag der Einlösung zählt nicht mit, und das trialEndsAt des Kontos ist die lokale Mitternacht am Ende des days-ten Tages danach, in der Zeitzone der Instanz (TRIAL_TIME_ZONE, UTC, sofern der Betreiber keine festgelegt hat). Eine Einlösung am 2026-09-29 in Europe/Berlin, um 10:00 oder um 23:30 Uhr, mit vierzehn Tagen, endet am 2026-10-14 um 00:00 Uhr Berliner Zeit, 2026-10-13T22:00:00.000Z. Es ist eine Kalenderregel, keine Zeitdauer: Auch bei einer Zeitumstellung endet der letzte Tag um lokale Mitternacht. Die Zone wird beim Einlösen gelesen und nicht in die Zeile geschrieben, da sie die Grenze zwischen zwei Tagen verschiebt und niemals die Anzahl der Tage. Eine Zeile, die vor dem Vorhandensein des Tageslimits erstellt wurde, trägt keines, und das damit erstellte Konto hat kein Enddatum.

Eine Instanz kann einem normalen Mitglied auch gestatten, eine solche Einladung zu den Bedingungen der Instanz und ohne die in diesem Absatz durch 409 gemachten Angaben zu erzeugen. Das ist POST /v1/auth/invites, §5.21.

5.8.2 POST /v1/auth/invite-lookup

Nicht authentifiziert, ratenbegrenzt nach IP. Anfrage {"inviteToken": "si_…"}.

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

Der Client ruft ihn auf, wenn jemand den Link in der E-Mail öffnet, damit das Registrierungsformular die Adresse ANZEIGEN kann, an die das Schreiben ging, anstatt sie abzutippen. Das ist der ganze Sinn einer adressierten Einladung: Man kann die eigene Adresse nicht falsch in ein Konto eintippen, das dann niemand erreicht.

Mehr zeigt er nicht an. Die Rolle und das Kontingent, die die Einladung gewährt, fehlen ganz bewusst: Wer sich noch nicht registriert hat, muss nicht erfahren, dass die Administration ihn zum Admin gemacht hat, und jemand mit dem Link eines Fremden erst recht nicht.

Unbekannte, fehlerhafte, dienstfremde, abgelaufene, widerrufene und verbrauchte Tokens ergeben das GLEICHE 404 {"error":"invite-invalid"}, nach identischem Aufwand: Auf jedem Zweig wird das Token gehasht und die Tabelle abgefragt. Ein gültiger Abruf verbraucht nichts, sodass jemand, der den Link zweimal öffnet, weiterhin eine Einladung hat.

5.8.3 POST /v1/auth/signup-request: Eine Person bittet um ein Konto

Nicht authentifiziert. Nur vorhanden, wenn instance.openSignup gleich true ist; überall sonst antwortet der Pfad mit dem gewöhnlichen Unbekannter-Pfad-404. Eine Instanz benötigt konfigurierte E-Mail, um ihn zu öffnen, da die E-Mail die Adressprüfung darstellt.

Anfrage: {"email": "anna@example.org", "captchaToken": "…", "plan": "yearly", "tier": "tier-a", "locale": "de"}. captchaToken ist erforderlich, wenn instance.signupCaptcha vorhanden ist, und wird andernfalls ignoriert. plan, tier und locale sind optional und geben an, was die Person vor der Anfrage auf dem Registrierungsbildschirm ausgewählt hat; weitere Inhalte im Body werden nicht gelesen.

  • plan ist "monthly" oder "yearly". Wenn es eines davon ist, trägt der per E-Mail versandte Link &plan=<key> nach der Einladung.
  • tier ist die ID der Tarifstufe des Rechnungsstellers, zu der der Plan gehört (§5.22). Der Dienst kennt keine Liste von Tarifstufen, daher prüft er nur den Form: eine kleingeschriebene Kennzeichnung mit 1 bis 32 Zeichen, beginnend mit einem Buchstaben, gefolgt von Buchstaben, Ziffern und Bindestrichen (^[a-z][a-z0-9-]{0,31}$), exakt abgeglichen ohne Kürzen von Leerzeichen und ohne Umwandlung der Groß- und Kleinschreibung. Passt sie, enthält der per E-Mail versendete Link &tier=<id> nach &plan= (oder nach der Einladung, wenn kein Plan vorliegt). Der Dienst prüft nicht, ob der Rechnungssteller die Tarifstufe anbietet oder ob ein Plan enthalten war; ein Client entscheidet das beim Einlesen des Links.
  • locale ist eine der sechs Instanzsprachen (en, de, fr, it, es, tr), dieselbe Liste, die das Push-locale (§5.24) akzeptiert. Trifft das zu, enthält der per E-Mail versendete Link &lang=<code>, und die Benachrichtigung oder die Notiz für Kontoinhaber wird in dieser Sprache verfasst. Ohne ein gültiges locale werden beide in der Sprache der Instanz (instance.language) verfasst.
  • Ein fehlender Wert, null, ein Wert eines anderen Typs und jede andere Zeichenkette sind stillschweigend verworfen: niemals ein 400, und die nachfolgende Antwort ändert sich nicht. Für tier umfasst das Alpha (Groß- und Kleinschreibung), alpha (Füllzeichen), eine Kennzeichnung mit 33 Zeichen, 42, ein Objekt, ein Array sowie a&plan=monthly (das weder & noch = besitzt, um das Fragment bereitzustellen). Kein Feld wird gespeichert; jedes Feld reist im Link mit, sodass ein auf einem anderen Gerät geöffneter Link Plan und Tarifstufe weiterhin kennt. Die Notiz an die kontoinhabende Person enthält keinen Link und damit keines dieser Felder; lediglich ihre Sprache richtet sich nach locale.

Ein Link mit allen dreien lautet <client>/join#server=…&invite=si_…&plan=yearly&tier=tier-a&lang=de.

json
{}

→ 202 mit diesem Body, leer und unveraendert.

Für eine neue Adresse erstellt der Dienst eine gewöhnliche adressierte Einladung und versendet sie per E-Mail: Rolle member, die standardmäßige Einladungslebensdauer, kein Einladender und die Bedingungen der Instanz, die ihrem Scan-Testlauf entsprechen, wenn sie einen anbietet (§5.19), und andernfalls keine KI. Der per E-Mail versendete Link führt unverändert zu §5.8.2 und §5.8. Die Response DARF NICHT abhängig davon variieren, was für die Adresse zutrifft, wie in §5.21: Eine neue Adresse, eine Adresse mit bestehendem Konto, eine Adresse, für die bereits eine ausstehende Nachricht des Betreibers oder eines Mitglieds vorliegt, und ein Postfach, das heute bereits eine Nachricht erhalten hat, sind ein einziges 202 mit demselben Body. Die E-Mails stammen vom Zugang selbst, niemals ist es die Einladung oder der Hinweis aus §5.21, die besagen, jemand habe den Leser eingeladen: Eine neue Adresse erhält eine Nachricht mit dem Inhalt, dass sie oder jemand, der sie nutzt, um die Erstellung eines Kontos gebeten hat, samt dem einen Link, dessen Ablaufzeit und dem Hinweis, dass das Ignorieren der E-Mail nichts ändert; ein Kontoinhaber erhält einen Hinweis mit demselben Inhalt und der Information, dass kein zweites Konto erstellt wurde, ohne Link. Eine ausstehende E-Mail von einem anderen Zugang bleibt unberührt, sodass ein Fremder die Einladung eines Betreibers nicht durch Absenden der Adresse zurückziehen kann. Nur die E-Mails unterscheiden sich.

StatusBedeutung
202{}. Akzeptiert, unabhängig davon, was für die Adresse zutrifft
400{"error":"email-invalid"}: Keine Adresse. {"error":"email-domain-refused"}: Eine Adresse bei einem bekannten Wegwerf-E-Mail-Dienst, abgeglichen auf die Domain und jede ihrer übergeordneten Domains. {"error":"captcha-failed"}: Das Captcha-Token fehlt oder wurde abgelehnt; löse es erneut
404Die Instanz betreibt keine offene Registrierung
429Mehr als fünf Anfragen von einer Quelladresse in einer Stunde. Retry-After in Sekunden
503{"error":"captcha-unavailable"}: Der Captcha-Anbieter konnte nicht angefragt werden. Versuche es später erneut

Die 400 beschreiben den Request, niemals die Konten der Instanz: Eine Domain sagt nichts darüber aus, wer ein Konto besitzt, weshalb die Ablehnung einer solchen kein Orakel darstellt.

Zwei Drosselungen. Pro Quelladresse fünf Anfragen pro Stunde, wobei jeder Versuch zählt, was ein Skript begrenzt. Pro Postfach eine E-Mail pro Tag: Weitere Anfragen beantworten weiterhin 202 und senden nichts, sodass diese Begrenzung keinen Aufschluss darüber gibt, nach welchen Adressen jemand anderes gefragt hat. Der Postfachschlüssel ist die Testlauf-Schlüssel: Die kanonische Adresse (§5.8), bei der ein +tag aus dem lokalen Teil entfernt wurde, und für gmail.com sowie googlemail.com mit allen entfernten Punkten und der Domain als gmail.com geschrieben. anna+x@gmail.com, a.n.n.a@gmail.com und anna@gmail.com teilen sich einen Schlüssel; a.nna@example.org und anna@example.org tun dies nicht.

Ein Postfach, eine einzige Testphase, für immer. Ein Postfach, dessen Schlüssel bereits eine Einladung mit kostenlosen Scans eingelöst hat (egal in welcher Schreibweise), oder dessen Konto eine Testphase nutzte und gelöscht wurde, erhält eine Einladung mit der Testphase 0: Die Person erhält dennoch ein Konto, und der erste Scan liefert 403 trial-scans-spent zurück. Der Dienst erkennt das Postfach anhand eines Hashs mit Schlüssel seines Testschlüssels, niemals anhand einer gespeicherten Adresse (§9.2).

Auf keinem Zweig wird eine Adresse protokolliert. Ein Server DARF die übermittelte Adresse oder das Captcha-Token NICHT protokollieren.

5.9 POST /v1/auth/login

Nicht authentifiziert, mit zwei Drosseln. Beide zählen ein 401 und nichts anderes, und ein Erfolg setzt beide zurück.

  • Pro IP und E-Mail. Fünf Fehlversuche sind frei. Das verlangsamt Brute-Force von einer einzelnen Quelle, ohne dass jemand ein Opfer von einer anderen Adresse aus dem eigenen Konto aussperren kann.
  • Pro E-Mail, von jeder Adresse. Zwanzig Fehlversuche werden beantwortet; die einundzwanzigste Anfrage wird für eine Minute abgelehnt, und jeder weitere Fehlversuch verdoppelt die Sperre bis auf fünfzehn Minuten. Ein Bucket ohne Fehlversuch für fünfzehn Minuten startet von vorn. Das schränkt Angreifer ein, die Adressen durchwechseln. Eine Adresse ohne Konto wird genauso gezählt, sodass die Ablehnung keine Auskunft darüber gibt, ob das Konto existiert. Die Adresse wird so normalisiert, wie die Kontensuche sie normalisiert (§2), eine andere Schreibweise landet also im selben Bucket.

Beide Sperren liefern dasselbe 429 mit Retry-After, der längeren der beiden Wartezeiten.

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

400, wenn email keine plausible Adresse ist oder authHash nicht aus 32 Base64-dekodierten Bytes besteht: Die Anfrage erreicht die Anmeldedatenprüfung nie, daher liefert dieser Status keine Information darüber, ob das Konto existiert. 401 bei einem unbekannten Konto und bei einem falschen Auth-Hash, mit identischem Nachrichtentext und nach identischem Aufwand, da der Verifizierervergleich auf beiden Zweigen gegen einen Platzhalter voller Breite läuft. 403 {"error":"account-suspended"}, wenn das Konto gesperrt ist, geprüft NACH den Anmeldedaten, sodass nur jemand, der den Kontobesitz nachgewiesen hat, erfährt, warum die Tür verschlossen bleibt. 429 bei Drosselung.

5.10 POST /v1/auth/refresh

Nicht authentifiziert (das Refresh-Token ist der Nachweis). Anfrage {"refreshToken": "..."} → 200 {"tokens": {...}}. Siehe §4.2 zur Rotation und Wiederverwendungserkennung. Jeder Fehlschlag ist 401, außer bei einem gesperrten Konto, das 403 {"error":"account-suspended"} liefert und das vorgelegte Token NICHT verbraucht: Eine Sperre kann aufgehoben werden, und das Entwerten würde die Person von einem Gerät abmelden, das sie zurückerhält. Der eigene Statuscode verhindert, dass ein Client diesen Endpunkt endlos abfragt.

5.11 POST /v1/auth/logout

Bearer. 204. Widerruft die Token-Familie des Aufrufers: nur dieses Gerät.

5.12 POST /v1/auth/reset/request und POST /v1/auth/reset/open: das Zurücksetzen per Post

Diese Nummern wurden in 0.5.0 ausgemustert, als verify-email und request-reset mit dem Mailer wegfielen. Protokoll 2 verwendet sie wieder, und sie wiederzuverwenden statt zwei neue zu nehmen, ist Absicht: Was hier nun steht, ist die Antwort auf das, was vorher hier stand, und wer einem §5.12-Verweis aus einem Quelltextkommentar folgt, soll bei der Lösung landen statt auf einem Grabstein.

§5.12.1 POST /v1/auth/reset/request: nicht authentifiziert, gedrosselt nach (IP, E-Mail), bei Erfolg NIEMALS zurückgesetzt.

Anfrage {"email": "anna@example.org"} → 202 {}, immer.

202 für eine bekannte Adresse, eine unbekannte und eine fehlerhafte gleichermaßen. Ein konformer Server MUSS auf beiden Zweigen dieselbe Arbeit verrichten, bevor er antwortet: die Adresse nachschlagen, das Token erstellen, dessen Hash berechnen. Das Schreiben in den Speicher und das Senden, die nur eine bekannte Adresse erhält, DÜRFEN die Antwort NICHT verzögern: Der Referenzserver führt sie aus, nachdem das 202 gesendet wurde (seit 09.2026), und ein Fehler dort wird protokolliert, niemals zurückgegeben. Diese Symmetrie ist die gesamte Argumentation gegen Enumeration, und es ist dieselbe, die dieses Dokument zuvor als FEHLEND erfasste: Der alte request-reset verrichtete die aufwendige Arbeit nur für Adressen, die existierten, sodass sein Zeitverhalten verriet, was sein Inhalt verschwieg. Vor 09.2026 wartete dieser Server nur auf dem bekannten Zweig noch auf einen Schreib- und einen Sendevorgang.

Ein 400 wird nie zurückgegeben, nicht einmal für einen Wert, der offensichtlich keine Adresse ist: Der Statuscode würde sonst zum kostenlosen Orakel für das Format der Adressen dieser Instanz, und mit der Unterscheidung könnte ein Aufrufer ohnehin nichts Sinnvolles anfangen.

Das Token besteht aus 32 Zufallsbytes, base64url, mit dem Präfix sr_. Nur sein SHA-256-Hash wird gespeichert, in password_resets, mit einer TTL von 60 Minuten. Ein aktives Token pro Konto: Eine neue Anfrage markiert jede ältere, nicht verbrauchte Zeile in derselben Transaktion als verbraucht, sodass eine Person beim Hochscrollen im Posteingang den gestrigen Brief nicht einlösen kann. Diese Transaktionen werden pro Konto serialisiert (eine Zeilensperre auf dem Konto), sodass sich überlappende Anfragen dennoch genau ein gültiges Token hinterlassen.

Wenn Mail nicht konfiguriert ist, bewirkt das Senden nichts und der Endpunkt antwortet dennoch mit 202. Für die Nutzer eines Self-Hosters gibt es dann kein Zurücksetzen; die Abhilfe für den Betreiber ist POST /v1/admin/accounts/:id/reset-mail, was den Link zurückgibt.

§5.12.2 POST /v1/auth/reset/open: nicht authentifiziert, IP-gedrosselt.

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

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

Das Token wird in DERSELBEN Anweisung verbraucht, die es liest (UPDATE … WHERE consumed_at IS NULL AND expires_at > now RETURNING), zwei Anfragen mit demselben Token können also nicht beide beantwortet werden. Unbekannte, verbrauchte und abgelaufene Token ergeben nach identischer Arbeit EIN 404 {"error":"reset-invalid"}.

DIESER ENDPUNKT SCHREIBT NICHTS IN DAS KONTO, und dieser Satz macht den gesamten Unterschied zu dem Ablauf aus, den §5.13 früher dokumentierte. Er gibt den Wiederherstellungscode zurück, den der Server bereits treuhänderisch verwahrt (§3.1); der Client führt damit die GEWÖHNLICHE Zeremonie aus §5.14 recover-rotate durch: den Code nachweisen, eine neue Passphrase setzen, den DEK neu verpacken, einen neuen Code prägen, ihn erneut treuhänderisch hinterlegen, alles in einer Transaktion. Ohne die Schlüsseldatensätze liefert dieser Pfad eine Zeichenkette zurück. Eine künftige Änderung, die diesen Pfad einen Verifizierer oder Schlüsseldatensatz anfassen ließe, hätte den mit ADR-0004 entfernten Ablauf zur Kontoübernahme wiedererrichtet, wie auch immer er hieß.

Welche Kosten das verursacht, ausdrücklich benannt statt stillschweigend vorausgesetzt. Das Zurücksetzen funktioniert, weil die betreibende Person den Wiederherstellungscode verwahrt. Lies §3.1 und docs/adr/0005-organization-accounts-and-escrowed-recovery.md, bevor du dich entscheidest, einer gehosteten Instanz zu vertrauen; die Entscheidung gilt der betreibenden Person, nicht der Kryptografie.

5.13 POST /v1/auth/verify-email: in 0.5.0 entfernt und nicht wiederhergestellt

Mit dem Mailer in 0.5.0 weggefallen, und Protokoll 2 führt es nicht wieder ein, obwohl dieser Dienst wieder Mails versendet.

Es gibt nichts mehr zu bestätigen: Ein Konto entsteht durch das Einlösen einer Einladung, die an ein Postfach ADRESSIERT war (§5.8), die Person, die den Brief empfangen hat, ist also die Person, die sich registriert hat. Die Einladung ist die Bestätigung, und ein zweiter Link würde jemanden nur auffordern, zweimal zu beweisen, was bereits nachweisbar einmal getan wurde.

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

Der Wiederherstellungscode-Authentifikator und die beiden Zugangsdaten-Rotationen. recover-rotate und change-passphrase verwenden dieselbe Übermittlungsstruktur, weil sie dasselbe tun; nur der Nachweis unterscheidet sich.

POST /v1/auth/recover: nicht authentifiziert, gedrosselt nach IP und E-Mail. Anfrage {"email": "...", "recoveryAuthHash": "<base64, 32 bytes>"} → 200 {"account": AccountView, "tokens": {...}}.

Zurückgegeben wird eine normale Sitzung, ganz bewusst keine herabgestufte: Wer den Wiederherstellungscode besitzt, ist konstruktionsbedingt Kontoinhaber, und ein eingeschränktes „Wiederherstellungsmodus“-Token würde eine zweite Autorisierungsfläche einführen, die keine Eigenschaft besitzt, die der Code nicht ohnehin schon trägt.

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": [ ... ] }

keyRecords-Einträge sind {"kind": "passphrase" | "recovery", "kdfDescriptor": {...} | null, "wrappedDek": "<base64>"}, höchstens einer pro Art, und folgen denselben Regeln wie §5.4 (der Deskriptor eines recovery-Datensatzes muss null sein; der eines passphrase-Datensatzes darf es nicht sein).

change-passphrase liefert 200 {"tokens": {...}} zurück. recover-rotate liefert 200 {"account": AccountView, "tokens": {...}} zurück, weil der Aufrufer ohne Sitzung ankam und wissen muss, welches Konto er gerade wieder betreten hat. Beide geben ein frisches Paar zurück.

Die gesamte Übermittlung wird atomar angewendet. Neuer Verifizierer, neuer KDF-Deskriptor des Kontos, ein optional neuer Wiederherstellungsverifizierer, das neu versiegelte Treuhanddepot, per Upsert aktualisierte Schlüsseldatensätze, der Widerruf aller offenen Sitzungen und das neue Paar des Aufrufers werden entweder alle zusammen committet oder gar nicht. Das ist kein Implementierungsdetail. Jeder Zwischenzustand ist ein eigenes Desaster, das Nutzende erst bemerken, wenn sie ihr eigenes Tagebuch lesen wollen: Ein Verifizierer ohne seinen neu verpackten Datensatz loggt sich ein und entschlüsselt nichts, ein Datensatz ohne seinen Verifizierer kann sich überhaupt nicht einloggen, und ein rotierter Wiederherstellungsverifizierer ohne seinen Datensatz hinterlässt einen Code, der authentifiziert und danach nichts entpackt.

keyRecords muss vorhanden sein, selbst als []. Ein fehlender Schlüssel ist ein 400, aus demselben Grund, aus dem expectedUpdatedAt in §5.4 verlangt wird: Schweigen darf auf einem Pfad, der Daten stranden lassen kann, niemals als Zustimmung gewertet werden.

Arten, die nicht übermittelt werden, bleiben unangetastet. Eine Passphrasenänderung verpackt den DEK unter einem neuen KEK_p neu; der recovery-Datensatz verpackt weiterhin denselben, unveränderten DEK und bleibt gültig.

Vier Regeln gelten allein für recover-rotate:

  • Ein passphrase-Schlüsseldatensatz ist erforderlich, und [] ist ein 400. Anders als eine Passphrasenänderung hat dieser Pfad zwangsläufig KEK_p geändert; nähme man eine Übermittlung ohne das erneute Verpacken an, entstünde ein Konto, bei dem der Login perfekt funktioniert, das aber nichts entschlüsselt.
  • Das Rotieren des Wiederherstellungscodes ist ein Alles-oder-Nichts-Vorgang, und in Protokoll 2 ist das eine DREI-Wege-Regel. newRecoveryAuthHash, ein recovery-Schlüsseldatensatz und recoveryCode müssen zusammen ankommen oder gar nicht; jede Teilmenge ist ein 400. Jeder fehlende Teil ist ein eigenes Desaster: Ein Verifizierer ohne den Datensatz hinterlässt einen Code, der authentifiziert und nichts entpackt; ein Datensatz ohne den Verifizierer hinterlässt einen, der entpackt und sich nicht einloggen kann; und ein TREUHANDDEPOT, das noch den alten Code enthält, macht das nächste Zurücksetzen per Mail (§5.12) zu einem Brief mit Zugangsdaten, die das Konto nicht mehr akzeptiert, bemerkt erst an dem Tag, an dem man sie braucht.
  • Der Schreibvorgang ist ein Compare-and-Swap auf den Wiederherstellungsverifizierer, auf den der Nachweis passte, innerhalb der Transaktion erneut zugesichert. Das ist nicht die Authentifizierung, die bereits stattgefunden hat; es verhindert, dass zwei gleichzeitige Wiederherstellungen Zugangsdaten überschreiben, von denen den Nutzenden bereits bestätigt wurde, dass sie ihnen gehören.
  • Ein Fehler, vier Ursachen. Eine unbekannte Adresse, ein Konto, das nie einen Wiederherstellungscode gesetzt hat, ein falscher Code und eine Rotation, die dieses Compare-and-Swap-Rennen verloren hat, antworten alle mit 401 mit identischem Text nach identischer Arbeit. Ein Race-Condition-Konflikt darf nicht von einem falschen Rateversuch unterscheidbar sein, und ein fehlender zweiter Authentifikator nicht von einem fehlenden Konto. Ein GESPERRTES Konto ist die einzige Ausnahme: Es antwortet mit 403 {"error":"account-suspended"}, und das erst, nachdem der Nachweis erfolgreich war.

change-passphrase wird pro Konto gedrosselt, von jeder Adresse, im Bucket delete, rotate-dek und ein Überschreiben eines Schlüsseleintrags teilen sich (§5.4): Die aufrufende Instanz besitzt bereits ein Token, und currentAuthHash ist ein Rateversuch, den das Token nicht belegen kann. Ein gesperrtes Konto erhält 429 mit Retry-After; ein Erfolg leert das Bucket.

Beide Endpunkte für die Wiederherstellung teilen sich einen Drossel-Bucket pro (IP, E-Mail), und keiner von beiden setzt ihn bei Erfolg zurück. Sie authentifizieren dasselbe Geheimnis, daher würde ein getrenntes Kontingent für jeden die Kosten für das Erraten halbieren. Eine legitime Wiederherstellung geschieht einmal, also benötigt kein ehrlicher Client sein Kontingent zurück. POST /v1/auth/reset/request wird nach derselben Regel gedrosselt.

Was eine Rotation leisten kann und was nicht. Es stellt den Login wieder her. Es kann die Daten nicht wiederherstellen, da der Server nie einen Schlüssel besessen hat. Ein change-passphrase, das keyRecords: [] übermittelt, hinterlässt ein funktionierendes Konto, dessen Blob dauerhaft unentschlüsselbar ist, weshalb recover-rotate diese Übermittlung strikt verweigert. Ein konformer Client muss genau das in diesen Worten mitteilen, bevor sich der Nutzer für den Ablauf entscheidet.

Wenn die Passphrase verloren ist, ist §5.12 der Weg zurück, und es funktioniert, weil die betreibende Person den Code treuhänderisch verwahrt (§3.1). Protokoll 1 besagte hier, dass eine verlorene Passphrase zusammen mit einem verlorenen Code ein Konto endgültig beendete, sodass niemand es mehr öffnen konnte. Dieser Satz gilt jetzt nur noch für eine Instanz, deren SERVER_SECRET ebenfalls verloren ist. Deshalb muss dieses Geheimnis ZUSAMMEN MIT der Datenbank gesichert werden, und deshalb ist sein Verlust schlimmer, als es scheint.

Die ehrliche Form der alten Warnung betrifft die betreibende Partei, nicht die Mathematik. Eine verwaltete Instanz kann jedes Konto darauf öffnen. Eine selbst gehostete Instanz ist ihre eigene betreibende Partei, daher gilt das alte Versprechen für den persönlichen Fall. Ein konformer Client teilt mit, mit welcher der beiden Instanzen er spricht, bevor eine Person ein Tagebuch darin anlegt.

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

Alle drei Bearer.

AccountView ist die EINZIGE Kontostruktur in diesem Protokoll. Es wird von POST /signup, POST /login, GET /account, PATCH /account, POST /recover, POST /recover-rotate und den Endpunkten für Admin-Konten zurückgegeben, sodass ein Client genau einen Kontodecoder besitzt:

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

Es enthält nichts Geheimes und darf auch nichts enthalten: kein Verifizierer, kein KDF-Deskriptor, kein verpackter DEK, kein Treuhandgeheimnis, kein Token. Jedes Feld ist entweder die eigene Information der Person oder der Status, den eine betreibende Person ihr gewährt hat. aiUsedToday zählt am aktuellen UTC-Tag gegen dailyAiLimit; suspendedAt ist ungleich null, solange jeder authentifizierte Aufruf mit 403 account-suspended antwortet.

invitesLeft gibt an, wie viele Einladungen dieses Konto noch ueber POST /v1/auth/invites versenden darf (§5.21), oder null, wenn dieses Limit das Konto nicht betrifft. null, niemals 0, fuer einen Administrator: 0 liest sich als "du hast alle verbraucht", und ein Administrator hat keine verbraucht, da Administratoren ueber die Admin-API erzeugen, die vom Limit und der Wiedereinladungsregel ausgenommen ist. Eine Instanz mit instance.memberInvites: false sendet aus demselben Grund null: Es gibt dort kein Limit, weil es keine Route gibt, und eine 0 wuerde ein aufgebrauchtes Kontingent melden, das nie existiert hat. Ein Client darf es darstellen und DARF NICHT darauf autorisieren; der Dienst verweigert eine sechste Erzeugung unabhaengig davon, was ein Client glaubt.

invitesNeedAPlan ist true, wenn invitesLeft nur deshalb 0 ist, weil das Konto ein Scan-Testzeitraum ist, für den noch niemand bezahlt hat (§5.21), und in jedem anderen Fall false, ein Administrator und eine Instanz mit instance.memberInvites: false inbegriffen. Ein Konto ist ein solcher Testzeitraum, wenn es trialScans aufweist und sein allowanceExpiresAt null oder bereits abgelaufen ist; ein zukünftiges allowanceExpiresAt, das das Abrechnungssystem bei Zahlung einträgt, gibt Einladungen wieder frei und belässt trialScans. Das Feld wirkt additiv: Ein Client, der es ignoriert, liest invitesLeft: 0, was weiterhin zutrifft, und ein Client, der es auswertet, kann melden, dass Einladungen mit einem Tarif freigeschaltet werden, statt zu melden, dass sie vollständig aufgebraucht sind.

allowanceExpiresAt ist ein ISO-Zeitpunkt oder null, und null bedeutet, dass das KI-Kontingent kein Enddatum hat, was eine selbst gehostete Instanz so beibehaelt. Ab diesem Zeitpunkt antwortet der Proxy aus §5.19 mit 403 allowance-expired. Es regelt ausschliesslich die KI und nichts anderes: Der Sync funktioniert ueber das Datum hinaus weiter, da das Tagebuch dem Konto gehoert und ein neues Geraet in der Lage sein muss, es abzurufen. Ein Client darf das Datum darstellen und darf nicht darauf autorisieren; die Regel liegt beim Proxy.

freeDailyAiLimit ist das dauerhaftes kostenloses Kontingent des Kontos: KI-Einheiten pro UTC-Tag (§5.19), die gelten, wann immer kein bezahltes Zeitfenster aktiv ist, ohne Enddatum und ohne Scan-Schranke. 0 bedeutet keines. Die Reihenfolge des Proxys (§5.19) ist ein aktives bezahltes Zeitfenster (allowanceExpiresAt in der Zukunft, bei dailyAiLimit), dann diese Gewährung, dann der Scan-Testzeitraum, sodass ein Konto mit einer kostenlosen Gewährung auf diese zurückgreift, wenn ein bezahltes Fenster endet, statt KI-Zugriff zu verlieren. Ein Client, der ein Tageslimit anzeigt, zeigt dieses an, wann immer kein bezahltes Fenster aktiv ist. Der Wert in der Ansicht ist derjenige, den der Proxy durchsetzt: das kontoeigene Limit, wenn es über 0 liegt, andernfalls der Standardwert der Instanz (§5.19), andernfalls 0. Nur ein Betreiber schreibt es (§5.20); die Zugangsdaten des Abrechnungsdienstes können dies nicht. Ein Client darf es rendern und DARF NICHT darauf autorisieren. Das Feld ist additiv: Ein Client, der es ignoriert, dekodiert die Ansicht unverändert.

capabilities ist die Liste der Berechtigungen, gegen die der Proxy dieses Konto prüft (§5.19, „Capabilities“): der kontoeigene Datensatz, andernfalls das defaultCapabilities der Instanz (§5.6), andernfalls null. null bedeutet keine Prüfung, nicht „nichts“: Jede Funktion ist freigegeben, was für jedes Konto auf einer Instanz gilt, die weder Standardwert noch Datensatz setzt. [] bedeutet überhaupt keine Funktion. Ein Client, der null vorfindet, MUSS jede Funktion als verfügbar behandeln. Ein Client darf es rendern und DARF NICHT darauf autorisieren: Der Proxy antwortet mit 403 capability-required. Das Feld ist additiv: Ein Client, der es ignoriert, dekodiert die Ansicht unverändert. Die Admin- und Abrechnungsansichten enthalten stattdessen den kontoeigenen Datensatz (§5.20), wobei null bedeutet, dass kein Datensatz existiert.

trialScans ist {"granted": n, "left": n} für ein Konto mit einer Scan-Testphase und null für eines ohne, was auf jedes Konto auf einer Instanz zutrifft, die keine betreibt. left ist granted abzüglich der verbrauchten Scans, niemals unter 0. Ein Client darf dies und DARF NICHT darauf autorisieren darstellen: Der Proxy zählt (§5.19), left ist eine Momentaufnahme vom Erstellungszeitpunkt dieser Ansicht, und jede weitergeleitete Antwort enthält die aktuelle Zahl in X-Trial-Scans-Left. Ein zukünftiges allowanceExpiresAt hebt die Scan-Sperre auf, sodass ein zahlendes Konto dieses Feld weiterhin enthalten kann.

trialEndsAt ist ein ISO-Zeitpunkt, oder null für einen Scan-Testzeitraum ohne Enddatum und für ein Konto ganz ohne Testzeitraum. Er wird einmalig geschrieben, wenn der Testzeitraum beim Einlösen beginnt (§5.8), auf einer Instanz, die ein Tageslimit setzt, und er ist immer eine lokale Mitternacht in der Zeitzone dieser Instanz; Ein Konto, das vor dem Festlegen durch die Instanz erstellt wurde, behält null, und sein Testzeitraum wird nachträglich niemals verkürzt. Ab diesem Zeitpunkt antwortet der Proxy mit 403 trial-expired (§5.19), es sei denn, die kostenlosen Scans waren zuerst aufgebraucht. Ein Client darf ihn anzeigen und DARF NICHT darauf autorisieren. Ein künftiges allowanceExpiresAt hebt ihn genauso auf, wie es die Scan-Anzahl aufhebt. Das Feld ist additiv: Ein Client, der es ignoriert, dekodiert die Ansicht unverändert.

healthConsent ist {"version": "<v>", "at": "<ISO instant>"} für einen Account mit einer hinterlegten Einwilligung zu Gesundheitsdaten und null für einen ohne: jeder Account, der erstellt wurde, bevor seine Instanz danach fragte, und jeder Account auf einer Instanz, die keine verlangt. version ist der Wortlaut, dem die Person zugestimmt hat, und at ist die eigene Uhr des Dienstes zu diesem Zeitpunkt. Ein Client vergleicht version mit instance.healthConsent.version (§5.6) und fragt einmal nach, wenn sie sich unterscheiden oder dies auf einer Instanz, die danach verlangt, null ist (§5.15.1). Das Feld ist additiv: Ein Client, der es ignoriert, dekodiert die Ansicht unverändert.

Die Endpunkte für Admin-Konten geben dieselbe Struktur plus zwei Felder für die betreibende Partei zurück, blob und keyRecordKinds (ADR-0001). Ein Client, der ein AccountView aus einer Admin-Antwort decodiert, funktioniert daher unverändert und liest zwei Felder, die er nicht angefordert hat.

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

PATCH /v1/auth/account nimmt {"displayName": string | null} → 200 {"account": AccountView} entgegen. Der Schlüssel MUSS vorhanden sein, selbst als null: Ein fehlender Schlüssel ist ein 400, dieselbe Regel, der keyRecords und expectedUpdatedAt folgen, da ein PATCH, der bei einem falsch geschriebenen Feldnamen stillschweigend nichts tat, eine Änderung ist, von deren Durchführung der Client ausgeht.

Das ist das einzige Feld, das ein Konto über sich selbst ändern darf. email ist die Identität und ändert sich nur über eine betreibende Person; role und dailyAiLimit sind ein Status, den ein Konto nicht für sich selbst erhöhen können darf; alles Authentifizierungsbezogene läuft über §5.14.

POST /v1/auth/delete nimmt {"authHash": "..."} entgegen und gibt 204 zurück. Eine erneute Authentifizierung ist erforderlich, obwohl der Aufrufende bereits ein gültiges Token besitzt: Eine Sitzung, die auf einem geteilten Gerät zurückbleibt, darf nicht ausreichen, um die Daten einer Person unwiderruflich zu zerstören. Ein falsches authHash ergibt 401; Rateversuche werden pro Konto in dem in §5.4 beschriebenen Bucket gedrosselt, und ein gesperrtes Konto erhält 429 mit Retry-After. Auf einer Instanz mit Abrechnungsdienst (§5.22) sendet der Dienst, sobald beide Prüfungen bestanden sind, POST <PLANS_UPSTREAM_URL>/erase mit X-Plans-Secret und X-Account-Id ohne Body, damit der Abrechnungsdienst die Abonnements des Kontos kündigt, bevor das Konto verschwunden ist. Er wartet höchstens fünf Sekunden und löscht unabhängig von der Antwort des Abrechnungsdienstes; DELETE /v1/admin/accounts/:id verhält sich ebenso. Auf einer Instanz, deren Mail-API Pigeon ist (MAIL_API_URL endet auf /v1/emails), sendet der Dienst nach dem Löschen des Kontos außerdem POST <base>/v1/recipients/erase mit {"email": "<address>"} und dem Bearer-Schlüssel der Mail-API, damit Pigeon jede Kopie löscht, die es von der Adresse hält. Dieser Aufruf ändert die Antwort niemals: Er unternimmt bis zu drei Versuche, verzögert das 204 um höchstens zwei Sekunden, setzt jeden nach der Antwort noch ausstehenden Versuch fort und protokolliert bei einem endgültigen Fehlschlag eine einzelne Zeile mit Zähler und Status- oder Fehlercode, ohne Adresse. Nichts speichert die Adresse, um es später erneut zu versuchen; Pigeons Aufbewahrungsfrist ist die Absicherung. DELETE /v1/admin/accounts/:id verhält sich ebenso.

Das Löschen entfernt das Konto und kaskadierend jeden Blob, Schlüsseleintrag, jeden Reset-Token und jede Nutzungszeile, die ihm gehören. Es gibt kein Soft-Delete und keine Nachfrist. Dies ist der Pfad für die Selbstlöschung, und er ist konstruktionsbedingt vollständig, statt über einen Bereinigungsauftrag zu laufen, an dessen Ausführung jemand denken muss.

Dieselbe Transaktion zieht jede von dem Konto gesendete Einladung zurück, die noch offen ist auch (§5.21). Eine von einer Person gesendete Einladung enthält die Bedingungen des Zugangs dieser Person; bleibt eine Einladung nach deren Weggang offen, könnte sie immer noch eingelöst werden, und bei einem Tages-Testzugang beginnt ihr Kontingent erst bei der Einlösung.

Auf einer Instanz, die einen Scan-Testzeitraum bereitstellt, führt dieselbe Transaktion auch entfernt die Adresse und den Namen aus jeder Einladungszeile zu diesem Postfach aus, und, wenn das Konto einen Testzeitraum hatte, behält einen Einweg-Hash des Postfachs mit Schlüssel bei, damit die Regel aus §5.8.3 von einem Testzeitraum pro Postfach die Löschung überdauert. Der Hash wird nach der Löschung für TRIAL_HASH_RETENTION_DAYS (standardmäßig 365) aufbewahrt und dann durch einen stündlichen Durchlauf gelöscht, woraufhin dasselbe Postfach wieder einen Testzeitraum nutzen kann. Eine Instanz, die keinen Scan-Testzeitraum gewährt, speichert überhaupt keinen Hash. Nichts sonst über die Person wird aufbewahrt (§9.2). Die Grundlage und der Zeitraum sind in docs/adr/0010-the-mailbox-hash-has-a-basis-and-an-end.md festgehalten.

5.15.1 POST /v1/auth/account/health-consent: Ausdrückliche Einwilligung zu Gesundheitsdaten

Bearer. Nur vorhanden, wo instance.healthConsent nicht-null ist; überall sonst antwortet der Pfad mit dem gewöhnlichen 404 für unbekannte Pfade, für jeden, angemeldet oder nicht.

Warum eine Instanz danach fragt. Ein Tagebuch besteht aus Gesundheitsdaten: Mahlzeiten, Gewicht, Fasten. Auf einer verwalteten Instanz hält der Betreiber den hinterlegten Wiederherstellungscode (§3.1, ADR-0005) und kann das Tagebuch somit öffnen, und die Datenschutzerklärung benennt die ausdrückliche Einwilligung gemäß Art. 9 Abs. 2 lit. a DSGVO als Rechtsgrundlage. Der Betreiber muss nachweisen können, dass eingewilligt wurde, wann und zu welchem Wortlaut. Eine per Self-Hosting betriebene Instanz, deren Betreiber die Person selbst ist, fragt niemanden nach irgendetwas und lässt HEALTH_CONSENT_VERSION ungesetzt.

Zwei Wege, wie eine Einwilligung einen Account erreicht, eine Version. Der Betreiber setzt HEALTH_CONSENT_VERSION, eine kurze Zeichenkette aus 1 bis 32 Buchstaben, Ziffern, ., _ oder - (ein Datum wie 2026-09-28), und /health veröffentlicht dies als instance.healthConsent.version.

  • Ein neuer Account stimmt beim Schritt der Account-Erstellung zu: POST /v1/auth/signup transportiert "healthConsent": {"version": "<v>"} und zeichnet dies in derselben Anweisung wie den Account auf (§5.8).
  • Ein bestehender Account ohne diese, oder mit einer älteren Version, wird einmal gefragt und stimmt hier zu.

Erforderlich auf jeder Datenroute. Bis es zustimmt, wird einem Konto, das nicht die aktuelle Version der Instanz besitzt, 403 {"error":"health-consent-required"} verweigert, derselbe Body auf jeder Route, nach der Bearer-Prüfung und bevor etwas gespeichert, gezählt oder gesendet wird:

Einem Konto ohne die Einwilligung verweigertGrund
Jede Route unter /v1/sync bis auf die zwei Lesezugriffe unten: der Blob-Push, Schreib- und Löschvorgänge für Schlüsseldatensätze, rotate-dek, Freigaben, ForschungSie speichern das Tagebuch oder geben es weiter
POST /v1/chat/completions (§5.19), nach der Kontosperrung und vor dem KontingentDer Body ist ein Tellerfoto
POST /v1/feedback (§5.25), /v1/pulse/* (§5.23), /v1/push/* (§5.24)Jede speichert etwas, das aus dem Tagebuch stammt
/v1/plans/* (§5.22), außer dem anonymen GET /v1/plans/pricesDas Konto nutzen, nicht zustimmen oder verlassen
PATCH /v1/auth/account, POST /v1/auth/invites (§5.21)Das Konto nutzen, nicht zustimmen oder verlassen
POST /v1/auth/change-passphrase (§5.14)Die zweite Hälfte schreibt das Compartment auf dem Blob neu, was verweigert wird, sodass die gesamte Änderung wartet
Offen für ein Konto ohne die EinwilligungGrund
POST /v1/auth/login, POST /v1/auth/refresh, POST /v1/auth/logoutAn- und Abmelden
GET /v1/auth/accountDer Client liest es, um zu erfahren, dass er fragen muss
POST /v1/auth/account/health-consent (diese Route)Wo das Konto zustimmt
POST /v1/auth/delete (§5.15)Über das Löschen wird eine Einwilligung abgelehnt oder widerrufen
GET /v1/sync/blob (§5.2), GET /v1/sync/key-records (§5.3)Eine eigene Kopie. Die Anmeldung auf einem neuen Gerät erfordert beides, bevor ein Client anfragen kann, und ein Export auf einem neuen Gerät ist der Abruf. Nichts wird gespeichert
/health, GET /v1/plans/prices, POST /v1/legal/declarations, die unauthentifizierten /v1/auth/*-Routen (§5.7 bis §5.14)Keine Sitzung, also kein Konto, das angefragt werden könnte
/v1/admin/* (§5.20)Die eigenen Zugangsdaten des Betreibers; die Tagebuch-Routen eines Administrators werden wie die jedes anderen abgelehnt

Eine Einwilligung in eine ältere Formulierung wird wie gar keine abgelehnt. Die Ablehnung entfällt bei der allernächsten Anfrage, nachdem diese Route mit 200 geantwortet hat, beim selben Zugriffstoken: Der Dienst liest die Kontozeile bei jeder authentifizierten Anfrage aus, genau wie bei einer Sperrung, und die Einwilligung gleich mit. Auf einer Instanz, auf der instance.healthConsent den Wert null hat, lehnt hier nichts irgendetwas ab.

Der Push-Sender liest die Abonnement-Tabelle statt einer Route aus, wendet dieselbe Regel also eigenständig an: Ein Gerät, das vor der Abfrage durch die Instanz abonniert wurde, behält seine Zeile und empfängt nichts, bis das Konto zustimmt (§5.24).

Anfrage: {"version": "2026-09-28"} → 200 {"account": AccountView} (§5.15), mit gesetztem healthConsent.

StatusBedeutung
200{"account": AccountView}. Die Einwilligung ist erfasst, jetzt oder aus einem früheren Aufruf mit derselben Version
400{"error":"health-consent-required"}: Der Body enthält keine Zeichenkette version, oder nicht diejenige, die /health veröffentlicht; nichts wird erfasst
401Kein gültiges Access-Token
403{"error":"account-suspended"}
404Die Instanz verlangt keine Einwilligung

Vier Regeln, die ein konformer Server einhalten MUSS:

  1. Die gespeicherte Version ist die der Instanz, niemals die des Aufrufers. Das version des Bodys wird Byte für Byte mit dem der Instanz verglichen, ohne Trimmen und ohne Änderung der Groß- und Kleinschreibung, und geschrieben wird die Zeichenkette der Instanz.
  2. Der Zeitpunkt ist die Serveruhr. Ein Client sendet keine Zeit, und keine würde gelesen.
  3. Idempotent, und der erste Zeitpunkt gewinnt. Ein zweiter Aufruf mit der bereits erfassten Version ändert nichts und liefert dasselbe 200 zurück; at bleibt der Moment, in dem die Person erstmals zugestimmt hat. Eine andere Version ersetzt beides, sodass ein neuer Wortlaut seinen eigenen Zeitpunkt trägt.
  4. Widerruf bedeutet Löschung. Keine Route löscht eine Einwilligung. Eine Person, die widerruft, löscht das Konto (POST /v1/auth/delete, §5.15), was das Tagebuch und die Einwilligung mit der Zeile entfernt. Der Betreiber liest die Einwilligung in der Admin-Kontoansicht, und keine Admin-Route schreibt sie: Eine Einwilligung, die ein Betreiber im Namen einer anderen Person setzen könnte, würde nichts beweisen.

health-consent-required ist die einzige Ablehnung jeder Einwilligungsprüfung, in zwei Status: 400 bei der Kontoerstellung und auf dieser Route, wo der Client sein Kontrollkästchen erneut anzeigt, und 403 auf einer Datenroute, wo der Client die Person dorthin leitet. Durch das Ändern von HEALTH_CONSENT_VERSION wird jedes Konto erneut gefragt, und ab diesem Moment lehnt jede Datenroute die Konten ab, die der alten Formulierung zugestimmt hatten, bis sie der neuen zustimmen; ein Betreiber ändert dies, wenn sich der Wortlaut ändert, und sonst nicht.

5.16 Freigaben: /v1/sync/shares und /v1/sync/shared (ADR-0002)

Nur vorhanden, wenn das Deployment SYNC_SHARING setzt. Ohne dies beantwortet jeder Pfad unten die gewöhnliche unbekannte Route 404, für jeden Aufrufer, mit oder ohne Anmeldedaten; der Abschluss ist vor der Authentifizierung eingehängt, sodass eine unkonfigurierte Instanz von einer nicht zu unterscheiden ist, bei der die Funktion nie geschrieben wurde.

Beide Seiten adressieren eine Freigabe über die Konto-ID der Gegenseite, nie über eine synthetische Freigabe-ID: Die stabile Identität einer Freigabe ist das Paar (Gewährende, Empfangende), und das übersteht eine DEK-Rotation.

Seite der gewährenden Person.

VerbPfadHinweise
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/sharesDie eigenen Freigaben des Gewährenden. Gibt niemals wrappedDek zurück: Ein Blob, der an den Schlüssel einer anderen Person adressiert ist, nützt hier nichts, also wandert er nicht dorthin, wo ihn niemand braucht.
DELETE/shares/:granteeAccountId204, idempotent. Ein vollständiges Löschen, es gibt keinen Tombstone.

Empfängerseite.

VerbPfadHinweise
GET/sharedAn diesen Aufrufer adressierte Freigaben, jeweils mit eigenem wrappedDek; nur dieser Aufrufer kann sie öffnen.
GET/shared/:grantorAccountId/blob{"grantorAccountId": <int>, "blobVersion": <int>, "envelopeVersion": <int>, "ciphertext": "<base64>", "createdAt": "<iso>"}. grantorAccountId ist erforderlich: Die AAD aus §3.2 bindet ihn, sodass ein Empfänger ohne ihn überhaupt nicht entschlüsseln kann.
DELETE/shared/:grantorAccountId204, idempotent. Erlaubt einem Empfänger, eine an ihn gerichtete Freigabe zu verwerfen.
  • Die Empfängerschnittstelle hat keine Schreibverben gegen den Gewährenden und liefert nur die eigene Freigabezeile des Aufrufers, den aktuellen Blob des Gewährenden sowie grantorAccountId aus. Niemals die Schlüsseldatensätze, den KDF-Deskriptor, den Prüfwert, das Escrow, die E-Mail-Adresse oder den Anzeigenamen des Gewährenden. Ein Empfänger, der den mit dem recovery gewrappten DEK des Gewährenden abrufen könnte, bräuchte nur noch einen per Brute-Force ermittelten Wiederherstellungscode, um die Rotationsberechtigung über dieses Konto zu erlangen.
  • Nur der aktuelle Blob. Der vorgehaltene Versionsring dient der Wiederherstellung durch den Eigentümer, nicht als Zeitleiste für den Empfänger.
  • Die Autorisierung liest bei jeder Anfrage eine Live-Zeile und wird niemals zwischengespeichert. Dadurch wird ein DELETE direkt beim nächsten Aufruf wirksam.
  • Unbekannt, fremd und noch nie übertragen antworten alle mit selben 404. Das Fehlen einer Freigabe darf nicht bestätigen, dass ein Konto existiert.

5.17 POST /v1/sync/rotate-dek: atomare DEK-Rotation (ADR-0002)

Bearer, als Eigentümer des Kontos. Eine Übertragung, eine Transaktion:

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

Der Client erzeugt einen neuen DEK, verschlüsselt seinen gesamten Snapshot damit neu, wrappt ihn unter beiden KEKs neu und wrappt ihn für jede Freigabe neu, die er behält. Der Dienst speichert das Ergebnis Alles oder nichts.

Auf jedem Deployment vorhanden, anders als §5.16. Die Rotation gehört nicht zur Freigabeoberfläche: Sie schreibt den eigenen Blob des Aufrufers und dessen zwei eigene Schlüsseldatensätze neu, Zeilen, die auf jedem Konto überall existieren, und sie ist die Antwort auf jeden Verdacht, dass ein DEK auf einer Instanz durchgesickert ist (ein wiederhergestelltes Backup, ein verlorenes Gerät), auf der nie etwas geteilt wurde. Den einzigen Mechanismus, der einen kompromittierten DEK außer Dienst stellen kann, hinter einem unbezogenen Schalter zu verstecken, nähme einer solchen betreibenden Person jede Möglichkeit, ihn zu ersetzen.

  • currentAuthHash ist ERFORDERLICH, und ein Bearer-Token allein rotiert niemals. Es ist der Auth-Zweig der aktuellen Passphrase (§3.1), abgeglichen wie change-passphrase ihn abgleicht. Fehlt er oder ist er fehlerhaft, ist das ein 400, der ihn benennt; stimmt er nicht überein, ist es 401 {"error":"current passphrase is incorrect"} und nichts wird geschrieben. Eine Rotation schreibt den Wiederherstellungs-Verifier, den POST /v1/auth/recover akzeptiert; vor diesem Feld konnte ein gestohlenes Token also einen eigenen Code hinterlegen und sich damit dauerhaft anmelden, ganz gleich, was die Eigentümerin oder der Eigentümer danach mit der Passphrase tat. Rateversuche werden pro Konto in dem in §5.4 beschriebenen Bucket gedrosselt (429 mit Retry-After). Die Transaktion prüft erneut, ob der Passphrase-Verifier des Kontos noch dem abgeglichenen entspricht; änderte sich die Passphrase durch einen Commit dazwischen, führt das bei der Rotation zu 401, und nichts wird geschrieben.
  • Jede andere Sitzung wird in derselben Transaktion widerrufen. Die eigene Token-Familie des Aufrufenden bleibt erhalten, sodass das rotierende Gerät angemeldet bleibt; jedes andere access- und refresh-Token des Kontos verliert seine Gültigkeit. Eine Rotation wird ausgeführt, wenn die Kompromittierung eines Schlüssels vermutet wird, und eine Sitzung, die sie überlebt, wäre das Datenleck.
  • Alles oder nichts, in einer einzigen Datenbanktransaktion. ADR-0002 Verbot 8: Eine Rotation ist atomar oder sie existiert nicht, und keine Abfolge einzeln schreibender Endpunkte darf als eine solche dokumentiert oder verwendet werden. Eine unvollständige Anwendung ist der Zustand „Anmeldung klappt, entschlüsselt nichts“, den §5.14 bereits verweigert, mit einem weiteren Beteiligten: Ein Schlüsseldatensatz, der neu verpackt wurde, während der Blob-Schreibvorgang sein CAS verlor, lässt den Besitzer stranden, und eine Freigabe, die neu verpackt wurde, während der Blob-Schreibvorgang sein CAS verlor, lässt das medizinische Fachpersonal stranden.
  • blob wird per Compare-and-Swap anhand von baseVersion geprüft und aktualisiert, exakt wie in §5.1. Ein veralteter Wert führt zu einem 409 {"currentVersion": n} und es wird überhaupt nichts geschrieben.
  • newRecoveryAuthHash UND recoveryCode sind ERFORDERLICH, und eine Übermittlung, bei der eines von beiden fehlt, ist ein 400, das das Feld benennt. Eine Rotation prägt immer einen frischen Wiederherstellungscode, weil der recovery-Schlüsseldatensatz, den sie neu verpackt, unter einem aus diesem Code abgeleiteten KEK versiegelt ist; der Server ersetzt daher accounts.recovery_verifier und die Treuhandhinterlegung (§3.1) innerhalb derselben Transaktion wie den Blob, die Schlüsseldatensätze und die Freigaben. Eine Rotation, die diese beiden auf dem ALTEN Code beließe, erzeugte ein Konto, dessen treuhänderisch hinterlegter Code authentifizierte und anschließend nichts entpackte, latent ab dem Moment, in dem der Wiederherstellungscode zum zweiten Authentifikator wurde, und fatal, sobald ein Zurücksetzen per Post (§5.12) diesen Code an Personen aushändigte. Der Client zeigt der Person den neuen Code nicht an; er wandert in die Treuhandhinterlegung und bleibt dort.
  • Der Server leitet den Wiederherstellungsnachweis selbst ab. Sie führt den Wiederherstellungs-Auth-Zweig aus §3.1 über das kanonische recoveryCode aus und berechnet den neuen Verifier aus DIESEM, sodass der Verifier und der Escrow immer denselben Code beschreiben. newRecoveryAuthHash ist weiterhin erforderlich und muss dem abgeleiteten Nachweis entsprechen; eine Nichtübereinstimmung ist ein 400, der es benennt, und nichts wird geschrieben.
  • keyRecords muss BEIDE Arten enthalten. Eine fehlende Art ist ein 400, niemals eine stillschweigende Teilrotation: Würde nur die passphrase-Verpackung übertragen, verpackte der recovery-Eintrag weiterhin einen DEK, der nichts mehr öffnet, sodass der Wiederherstellungscode das Konto zwar noch anmeldet, aber nie wieder entschlüsselt. Jeder Eintrag folgt den Regeln aus §5.4 (ein recovery-Deskriptor muss null sein, ein passphrase-Deskriptor darf es nicht sein). Es gibt kein expectedUpdatedAt pro Eintrag: Die Übertragung selbst bildet die Einheit für die Nebenläufigkeit.
  • shares ist die Behalten-Liste, und jede Freigabezeile, die darin nicht genannt wird, wird in derselben Transaktion gelöscht. Dies kehrt §5.14 um, wo ein unberührter Schlüsseldatensatz bewusst behalten wird, da diese Zeilen die Berechtigung einer anderen Person auf das Tagebuch des Aufrufers darstellen und Schweigen der sichere Standard sein muss. shares: [] widerruft daher alles und ist gültig; ein Schlüssel nicht vorhanden shares ist ein 400, aus dem Grund, aus dem §5.4 das Ausschreiben von expectedUpdatedAt verlangt. Auf einer Bereitstellung ohne SYNC_SHARING muss die Liste leer sein; eine nicht-leere ist ein 400, da sie einen Zustand behauptet, den diese Instanz nicht halten kann.
  • Eine genannte Freigabe, die nicht existiert, ist ein 400, vollständig zurückgerollt, niemals als Erteilung behandelt. Die empfangende Partei hat ihre Seite möglicherweise verworfen; lies GET /v1/sync/shares erneut und übertrage neu.
  • Die behaltenen älteren Blob-Versionen (§8) bleiben unter dem ALTEN DEK versiegelt und werden in dem Moment zu totem Ballast, in dem eine Rotation festgeschrieben wird, unlesbar für jeden, einschließlich ihres Besitzers. Sie werden hier nicht gelöscht: Die Bereinigung entfernt sie innerhalb von fünf weiteren Übertragungen, und sie während einer Rotation zu verwerfen, nähme dem Besitzer die einzige Verteidigung gegen einen fehlerhaften Schreibvorgang des Clients in derselben Operation.
StatusBody
200{"newVersion": 4, "keptShares": 1, "revokedShares": 2}
400{"error": "..."}: eine fehlende Schlüsseleintragsart, ein fehlerhaftes oder fehlendes Feld, ein newRecoveryAuthHash, das nicht der Nachweis des Codes ist, eine Beibehaltungsliste, die eine nicht vorhandene Freigabe benennt.
401{"error": "current passphrase is incorrect"}: currentAuthHash stimmte nicht überein, oder die Passphrase wurde während der Rotation geändert. Es wurde nichts geschrieben.
409{"currentVersion": 5}: Das Blob-CAS griff nicht. Es wurde nichts geschrieben.
413{"error": "..."}: Der neue Blob überschreitet MAX_BLOB_BYTES.
429{"error": "..."} mit Retry-After: Passphrasen-Rateversuche für dieses Konto sind gesperrt.

Eine Rotation ist ein Widerruf der Stufe 2, und die Formulierungsregeln aus §5.16 gelten weiterhin. Das Löschen einer Freigabezeile stoppt die Bereitstellung durch den Server; eine Rotation sorgt zusätzlich dafür, dass zukünftige Einträge mit einem Schlüssel versiegelt werden, den die widerrufene Partei nie besaß. Beides holt bereits Heruntergeladenes nicht zurück, und kein Client darf etwas anderes behaupten.

5.18 Forschungsbeiträge: /v1/sync/contributions und /v1/sync/study (ADR-0003)

Nur vorhanden, wenn das Deployment SYNC_RESEARCH setzt. Fehlt dies, antwortet jeder Pfad darunter jedem Aufrufer, ob mit Zugangsdaten oder ohne, mit dem regulären 404 für unbekannte Routen, da der Endpunkt vor der Authentifizierung eingehängt ist. Unabhängig von SYNC_SHARING; keines der beiden Flags impliziert das andere.

Beitragsseite, authentifiziert als beitragende Person:

VerbPfadHinweise
PUT/contributions/:studyAccountId{"pseudonym","schemaTier","body","contributionVersion"}. CAS auf einem monotonen contributionVersion. Der Beitrag ist der kumulative Datensatz für das Zeitfenster, vollständig neu berechnet und neu übertragen; der Client hält immer die Quelle, daher ist diese Zeile eine Projektion, niemals eine primäre Kopie.
GET/contributionsDie eigenen Registrierungen der beitragenden Person. Gibt niemals body zurück.
DELETE/contributions/:studyAccountIdWiderruf. Eine Transaktion: Lösche die Zeile endgültig, füge einen mit dem Pseudonym indizierten Tombstone ein. 204, idempotent.

Studienseite, authentifiziert als Studienkonto:

VerbPfadHinweise
GET/study/contributions{"pseudonym","contributionVersion","schemaTier","body","createdAt"} pro Zeile. Niemals eine Kontokennung.
GET/study/withdrawalsPseudonyme, die widerrufen haben, mit Zeitstempeln. Der Studien-Client muss diese bereinigen, bevor er Daten anzeigt oder exportiert.

GET /study/contributions gibt studyAccountId einmalig auf der obersten Ebene des Umschlags zurück, nicht in jeder Zeile: Es ist die eigene ID des Aufrufers, er hat sich damit authentifiziert, sie ist für jede Zeile identisch und sie ist keine Beitragskennung. Der Forscher benötigt sie, um die AAD aus §3.5 neu aufzubauen, und in jeder Zeile wäre sie nur Rauschen.

Das contributionVersion Compare-and-Swap. Der übergebene Wert ist die neue Version, keine Basis; er bindet in die AAD ein und muss daher der Wert sein, unter dem der Geheimtext versiegelt wurde. Die Regel lautet strikt größer als die gespeicherte Version: Ein Client, der die gesamte Projektion neu berechnet und neu überträgt, darf niemals durch eine Version blockiert werden, die das Gerät nie verlassen hat. Ein unterlegener Schreibvorgang ist 409 {"currentVersion": <int>}, passend zur Form in §5.1.

Der Server validiert schemaTier anhand der Stufen, die dieses Protokoll definiert. Der Stufenname ist Metadaten, kein Inhalt (er wird im Klartext übertragen und der Server speichert ihn bereits), und ohne diese Prüfung greift Verbot 1 aus ADR-0003 nur auf dem Client. Eine unbekannte Stufe ist 400.

Der Server validiert das Format des Pseudonyms nicht, nur dass sie vorhanden und begrenzt ist. Er kann sie nicht verifizieren (dazu bräuchte er den Root-Schlüssel des Beitragenden), und eine Strukturprüfung würde eine Autorität vortäuschen, die er nicht besitzt.

StatusWenn
400Fehlerhafter Body, unbekanntes schemaTier, fehlendes contributionVersion
404unbekannte Studie, unbekannter Beitrag und jedes andere Nicht-Gefunden: ein einziger Codepfad
409contributionVersion nicht strikt größer als die gespeicherte Version
413Beitrag überschreitet MAX_CONTRIBUTION_BYTES (256 KiB)

Ein Pseudonym pro Studie, erzwungen durch die Datenbank. Wenn zwei Beitragende dasselbe Pseudonym übermitteln, würden sie lautlos zu einer einzigen Teilnehmerserie verschmelzen, und Forschende würden zwei Personen als eine analysieren, ohne dass ein Fehler auftritt. Eine zufällige Kollision liegt bei etwa 2^-128, daher sollte die Beschränkung nie auslösen, und genau das ist der Punkt: Sie macht die Datenkorruption unmöglich statt nur unwahrscheinlich.

Der Widerruf löscht auf dieser Seite tatsächlich. Ein Beitrag, den die Studie noch nicht abgerufen hat, erreicht niemanden. Was die Studie bereits abgerufen hat, lässt sich nicht zurückholen: Der Löschmarker transportiert die Anweisung, und sie zu respektieren ist eine ethische Verpflichtung, die dieses System zwar formuliert, aber nicht erzwingen kann.

5.19 POST /v1/chat/completions: Der KI-Proxy

Nur vorhanden, wenn der Betreiber einen Upstream-Schlüssel konfiguriert hat. Ohne diesen antwortet der Pfad mit dem gewöhnlichen Unbekannter-Pfad-Status 404, für jeden, ob authentifiziert oder nicht, und instance.ai ist beim Handshake null (§5.6). Eine Implementierung dieses Protokolls KANN die Route vollständig weglassen; ein Client MUSS instance.ai auslesen, bevor er einen Scan anbietet, anstatt den Pfad abzutasten.

Authentifiziert mit dem regulären Zugriffstoken des Kontos (§4.1), geprüft bevor der Rumpf gelesen wird: Eine Anfrage ohne gültiges Token ist 401, unabhängig von ihrer Größe oder Form, und der Dienst puffert oder parst sie nicht. Der Rumpf ist eine OpenAI-kompatible Chat-Completion-Anfrage. Der Dienst prüft, ob es sich um ein JSON-Objekt handelt, leitet nur die Felder auf einer Erlaubnisliste (unten) weiter, schreibt die wenigen um, die die Kosten einer einzelnen Anfrage festlegen, begrenzt, was eine einzelne Anfrage mitführen darf, und verweigert nichts wegen eines ihm unbekannten Feldes: Er verwirft es. Die Antwort ist die des Anbieters, weitergeleitet mit dessen Status.

Die Instanz bestimmt, was eine Anfrage kosten darf. Ein einziger Upstream-Schlüssel kann alle Konten auf einer Instanz bedienen, und eine tägliche Anzahl von Anfragen sagt nichts darüber aus, was eine einzelne Anfrage kostet. Daher schreibt der Dienst diese Felder vor der Weiterleitung für jedes Konto um und lehnt eine Anfrage deswegen niemals ab.

Nur diese Felder auf oberster Ebene werden weitergeleitet: model, messages, stream, stream_options, temperature, top_p, response_format, max_tokens, max_completion_tokens, reasoning und n. Jedes andere Feld wird verworfen und sein Name (niemals sein Wert) protokolliert, sodass ein Client, der ein dem Dienst unbekanntes Feld sendet, weiterhin funktioniert. Innerhalb von messages behält eine Nachricht role, content und name; ein Inhaltsteil ist text (mit text) oder image_url (nur mit url, sodass detail verworfen wird), und ein image_url, dessen url kein data:image/...;base64,-URI ist, wird verworfen, da eine entfernte URL oder ein Dokument hinter einem Daten-URI eine Eingabe ist, die niemand bemessen hat. Jeder andere Teiltyp wird verworfen.

FeldWas der Provider empfängt
modeldas Modell der Stufe der Anfrage, die Wahl des Betreibers: die Stufe, deren Route zum response_format.json_schema.name der Anfrage passt, andernfalls die Standardstufe der Instanz. Das Modell der Standardstufe ist das, was instance.ai.model veröffentlicht (§5.6). Eine Instanz ohne Stufendatei hat eine Stufe, deren Modell AI_ADVERTISED_MODEL ist. Wenn der Betreiber keines angegeben hat, wird das model des Aufrufers unverändert gesendet.
max_tokens, max_completion_tokenshöchstens AI_MAX_OUTPUT_TOKENS (Standardwert 8192) oder die eigene niedrigere Obergrenze der Stufe der Anfrage, falls sie eine hat. Ein Wert darüber oder ein Wert, der keine Zahl ist, wird zur Obergrenze. Ein Body, der keines von beidem enthält, bekommt max_tokens eingetragen.
reasoning.max_tokenshöchstens dieselbe Obergrenze. reasoning.effort wird beibehalten, es sei denn, die Stufe der Anfrage legt einen eigenen Aufwand fest: Dann schreibt der Dienst diesen Aufwand und verwirft das reasoning.max_tokens des Aufrufers, da ein Provider nur eines von beiden akzeptiert.
n1, falls vorhanden.
usage, an einem OpenRouter-Upstreamgeschrieben als {"include":true}, sodass die Antwort ihre Token-Zahlen und ihren Preis meldet (unten). Jeder andere Upstream erhält nichts. Das eigene usage des Aufrufers wird entfernt.
usage, an einem OpenRouter-Upstreamgeschrieben als {"include":true}, sodass die Antwort ihre Token-Zahlen und ihren Preis meldet („What a completion cost“, unten). Jeder andere Upstream erhält nichts. Das eigene usage des Aufrufers wird entfernt.
jedes Feld, das nicht auf der Erlaubnisliste oben stehtentfernt, zum Beispiel models, route, plugins, web_search_options, prediction, tools.
provider, an einem OpenRouter-Upstreamzurückgeschrieben als {"data_collection":"deny"}: nur Endpunkte, die die Anfrage nicht speichern oder für Trainingszwecke nutzen. Der Betreiber kann "zdr":true und "only":[...] mit "allow_fallbacks":false hinzufügen, über die Stufe der Anfrage (routing) oder über UPSTREAM_ZDR und UPSTREAM_PROVIDER_ONLY. Das eigene provider des Aufrufers wird niemals weitergeleitet. Jeder andere Upstream erhält kein provider-Feld, unabhängig davon, was festgelegt ist.

Die Stufe bestimmt der Betreiber, niemals der Aufrufer. Der Betreiber schreibt die Stufen in eine Datei (AI_TIERS_FILE, siehe die README): Jede enthält ein Modell, dessen Provider-Routing und optional eine niedrigere Ausgabe-Obergrenze und einen Reasoning-Aufwand, und eine routes-Zuordnung leitet den Namen eines Schemas für strukturierte Ausgaben an eine Stufe weiter. Eine Anfrage, deren Schema weitergeleitet wird, erhält diese Stufe, jede andere Anfrage erhält die Standardstufe. Ein Aufrufer kann nur ein Schema benennen, er kann also eine Stufe erreichen, die der Betreiber definiert hat, und keine andere, und er legt niemals ein Modell oder einen Provider fest. Der Handshake (instance.ai.model, §5.6) nennt das Modell der Standardstufe.

Die Obergrenze gilt mit oder ohne Modell. Ein Client, der eine längere Antwort benötigt, als die Obergrenze erlaubt, erhält eine abgeschnittene Antwort, und der Betreiber erhöht AI_MAX_OUTPUT_TOKENS. Eine selbstgehostete Instanz, bei der die Nutzer das Modell selbst wählen sollen, lässt AI_ADVERTISED_MODEL und AI_TIERS_FILE ungesetzt.

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

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

Drei Eigenschaften, die eine konforme Implementierung einhalten MUSS, und jede existiert, weil der Anfrage-Body ein Foto der Mahlzeit einer Person ist:

  1. Die Anmeldedaten des Aufrufers werden ersetzt, niemals zusammengeführt. Die Header der Upstream-Anfrage werden NEU ERSTELLT und nicht aus der eingehenden Anfrage kopiert und überschrieben. Ein Kopieren mit anschließendem Überschreiben leitet Cookies, x-api-key und alles weiter, was der nächste Anbieter auszulesen beschließt.
  2. Es wird kein Body protokolliert, in keiner Richtung. Kein Präfix, kein dekodierter Puffer, kein Fehlerdokument. Was protokolliert werden darf: eine Konto-ID, der Upstream-Status, Byte-Zahlen, eine Dauer und, aus einer erfolgreichen Antwort abgelesen, die vom Anbieter gemeldeten Token-Zahlen und der Preis sowie ein Modellname, der wie ein Modellname aussieht (siehe unten).
  3. Jede Zeichenkette, die vom Upstream-Netzwerk empfangen wurde, wird bereinigt, bevor sie eine Protokollzeile erreicht oder eine Antwort. Ein Anbieter, der eine Anfrage abweist, spiegelt die Anfrage routinemäßig im Fehler-Body wider, inklusive Bild.

Was eine Vervollständigung gekostet hat

Ein Anbieter, der den Verbrauch meldet, gibt ihn in der Antwort an. Bei einem OpenRouter-Upstream schreibt der Dienst "usage": {"include": true} in den weitergeleiteten Rumpf (wird nie vom Aufrufer übernommen, dessen eigenes Feld usage wie jedes Feld außerhalb der Positivliste verworfen wird), und jeder andere Upstream erhält kein solches Feld, sodass dessen Rümpfe unverändert bleiben. Nachdem die Antwort weitergeleitet wurde, protokolliert der Dienst in seiner Zeile Proxied a completion die Werte model, promptTokens, completionTokens und costMicroUsd (das Feld usage.cost des Anbieters, ein Preis in Dollar als ganzzahlige Millionstel eines Dollars), jeweils null, wenn die Antwort dazu keine Angabe enthielt. Er addiert außerdem costMicroUsd zur Gesamtsumme der Instanz für den UTC-Tag, ai_instance_days.cost_micro_usd, eine Summe ohne Kontobezug, die bei einem Anbieter, der keinen Preis meldet, 0 bleibt. Die Zahlen werden beim Durchlaufen der Antwort ausgelesen, sei es JSON oder ein Server-Sent-Stream, ohne ein Byte zu verzögern oder zu verändern. Ein Rumpf größer als 1 MiB, eine Stream-Zeile größer als 64 KiB und jedes Feld, das keine plausible Zahl ist, werden nicht gelesen und ergeben null. Niemals der Text der Antwort. Ein Fehler beim Erfassen der Kosten wird protokolliert und führt niemals dazu, dass eine bediente Anfrage fehlschlägt.

Was eine einzelne Anfrage mitführen darf

Die Ausgabe ist nach oben begrenzt; die Eingabe wird hier beschränkt. Gemessen am Body, den der Provider empfängt (nach der Allowlist), wird eine Anfrage vor jedem Scan-Anspruch, jeder Reservierung und jedem Upstream-Aufruf mit 400 abgelehnt, verbraucht also nichts, wenn sie Folgendes enthält:

GrenzeStandardwertlimit
image_url Teile1image-parts
UTF-8-Textbytes49152text-bytes
Nachrichten4messages

Text umfasst die content-Strings, jeden text-Teil, jede Nachrichten-name und das serialisierte response_format: Ein Schema ist eine Eingabe, die das Modell liest. Die eigentlichen Bytes des Bildes sind kein Text. Die Betreibenden legen die Grenzen mit AI_MAX_IMAGE_PARTS, AI_MAX_TEXT_BYTES und AI_MAX_MESSAGES fest, und die Ablehnung nennt die Grenze sowie ihren Wert:

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

Die Standardwerte entsprechen der größten echten openplate-Anfrage mit zusätzlichem Spielraum: ein Foto, zwei Nachrichten und etwa 12 KB Text.

Das Body-Limit

Der Anfrage-Body enthält ein Foto, daher ist das Limit auf eines ausgelegt: AI_MAX_REQUEST_BYTES, standardmäßig 8.000.000 Bytes. Base64 vergrößert ein Bild um den Faktor 4/3, das reicht also für ein JPEG von etwa 5,7 MiB, was einer modernen Smartphone-Kamera bei Standardqualität entspricht, und genau das sendet der Client nach dem Herunterskalieren.

Es ist bewusst unabhängig von MAX_BLOB_BYTES (§8). Jenes begrenzt ein Tagebuch, das dieser Dienst speichert; dieses begrenzt ein Bild, das er nur weiterleitet. Würde man das eine aus dem anderen ableiten, würde jedes echte Foto abgelehnt.

Ein Body über dem Limit führt bei Aufrufenden mit gültigem Token zu 413; ein unauthentifizierter Body führt zu 401, bevor er gelesen wird. Der Fehler-Body auf dieser Route folgt dem OpenAI-Format, nicht dem {"error": "<sentence>"} aus §4, da der Aufrufende ein OpenAI-kompatibler Client ist, der error.message aus einem Objekt ausliest:

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

Ein Body, der kein gültiges JSON ist, wird mit 400 in derselben Hülle mit "code": "invalid_json" beantwortet. Keines von beiden spiegelt die Eingabe wider, aus dem in der strikten Regel 2 genannten Grund. Eine Implementierung DARF stattdessen das Format aus §4 zurückgeben, aber ein für OpenAI-Anbieter geschriebener Client zeigt dann gar nichts statt eines Fehlers an.

Streaming wird direkt durchgereicht. Wenn die Anfrage es verlangt, wird der Response-Body direkt bei Eintreffen weitergeleitet, mit Cache-Control: no-cache, no-transform und ohne Content-Length. Ein puffernder Dienst würde trotzdem jedes Byte ausliefern, sodass ein Client den Unterschied nur an der Latenz bemerkt, die er eigentlich vermeiden wollte.

Das Kontingent

Jedes Konto hat zwei Tageslimits, jeweils in Einheiten pro UTC-Tag und standardmäßig jeweils 0: dailyAiLimit für das bezahlte Zeitfenster und freeDailyAiLimit für das dauerhafte kostenlose Kontingent (§5.15). Welches Welches gilt greift, wird pro Anfrage in dieser Reihenfolge entschieden:

Das Konto enthältZuteilungReserviertes Limit
allowanceExpiresAt nach dem Zeitpunkt der Anfrage, dailyAiLimit > 0Bezahltes ZeitfensterdailyAiLimit
sonst freeDailyAiLimit > 0Kostenloses KontingentfreeDailyAiLimit
andernfalls DEFAULT_FREE_DAILY_AI_LIMIT der Instanz > 0Kostenloses Kontingentdieser Standardwert
sonst ist dailyAiLimit 0nichts403 ai-not-allowed
sonst ist allowanceExpiresAt gesetzt (also abgelaufen)nichts403 allowance-expired
sonst ist trialScans gesetztScan-TestlaufdailyAiLimit
sonst (ein Limit, kein Datum, keine Testphase, kein kostenloses Kontingent)nichts403 ai-not-allowed

Der Instanz-Standardwert (2026-10-05). Ein Betreiber kann DEFAULT_FREE_DAILY_AI_LIMIT festlegen. Es ist die freie Zuteilung jedes Kontos, dessen eigener Wert freeDailyAiLimit gleich 0 ist: dieselbe Zuteilung an derselben Stelle der Reihenfolge, sodass ein laufendes bezahltes Zeitfenster weiterhin Vorrang hat, ein eigenes Limit unabhängig vom Standardwert erhalten bleibt und ein Konto mit einem Scan-Testzeitraum unter den Standardwert fällt, anstatt einen Scan zu verbrauchen. Es endet nie und hat keine Scan-Schranke. Es wird in keine Zeile geschrieben, sodass ein Betreiber, der es senkt oder entfernt, alle Konten auf einmal ändert. Ein aufgebrauchter Tag entspricht dem nachstehenden 429 mit Retry-After und niemals dem 403 ai-not-allowed eines Kontos ohne Zuteilung. Ein Dienst ohne Standardwert verhält sich exakt wie zuvor. Der Standardwert kann nicht neben dem Scan-Testzeitraum gesetzt werden: Eine solche Instanz verweigert den Start.

Die letzte Zeile änderte sich am 30.09.2026. Diese Form war früher ein dauerhaftes Kontingent ohne Ende; jedes Konto mit diesem Status wurde durch eine Migration auf freeDailyAiLimit umgestellt, und nichts schreibt diesen Wert mehr. Das kostenlose Kontingent ist nie scangebunden und endet nie, daher wird eine Scan-Testphase mit kostenlosem Kontingent nicht gezählt.

Eine Anfrage reserviert max(1, ceil(estimated input tokens / AI_UNIT_INPUT_TOKENS)) Einheiten auf das nach obiger Reihenfolge ermittelte Limit. Die Schätzung vor dem Aufruf entspricht den Textbytes oben geteilt durch 4 plus AI_IMAGE_INPUT_TOKENS (Standard 1500) pro Bild. Mit dem Standardwert für AI_UNIT_INPUT_TOKENS von 8192 wiegt ein Tellerfoto-Scan von openplate 1 Einheit und eine Anfrage nahe der Textgrenze 2 Einheiten, sodass für die App eine Einheit einer Anfrage entspricht. Dasselbe Gewicht wird von den unten genannten Instanzobergrenzen abgezogen und, sofern eine Rückgabe erfolgt, vollständig zurückerstattet. Jede weitergeleitete Antwort enthält den Stand des Kontos innerhalb des angewendeten Limits:

HeaderBedeutung
X-Quota-UsedHeute verbrauchte Einheiten, einschließlich dieser
X-Quota-LimitDas Limit, das die obige Reihenfolge ausgewählt hat: dailyAiLimit, freeDailyAiLimit oder der Instanz-Standardwert
X-Trial-Scans-LeftVerbleibende kostenlose Scans nach dieser Anfrage für ein Konto, für das die Scan-Sperre gilt (siehe unten). Andernfalls nicht vorhanden
StatuserrorWenn
401authentication requiredKein Zugriffstoken oder ein abgelaufenes oder widerrufenes Token
403ai-not-allowedDas Konto besitzt kein Kontingent (gemäß obiger Reihenfolge). Abgelehnt, bevor Daten den Host verlassen
403allowance-expiredallowanceExpiresAt ist gesetzt und liegt nicht nach dem Zeitpunkt, an dem die Anfrage eintraf, und es gibt kein kostenloses Kontingent. Abgelehnt, bevor Daten den Host verlassen und bevor eine Nutzungszeile geschrieben wird
403trial-scans-spentDie kostenlosen Scans des Kontos sind aufgebraucht und es hat kein Kontingentdatum. Wird abgelehnt, bevor Daten den Host verlassen und bevor eine Nutzungszeile geschrieben wird. X-Trial-Scans-Left: 0. Der Body enthält "endedBy": "scans"
403trial-expiredDas trialEndsAt des Kontos ist gesetzt und liegt nicht nach dem Zeitpunkt des Anfrageeingangs, seine Scans sind nicht aufgebraucht und es hat kein Kontingentdatum. Wird abgelehnt, bevor eine Zeile geschrieben wird. Der Body enthält "endedBy": "days"
403capability-requiredDie Anfrage nennt eine Funktion, die das Konto nicht besitzt (siehe unten). Der Rumpf enthält capability, die fehlende Kennung. Abgewiesen, bevor irgendetwas gezählt wird und bevor irgendetwas den Host verlässt
403account-suspendedDas Konto ist gesperrt (§5.9 verwendet denselben Code)
403health-consent-requiredDie Instanz verlangt eine Einwilligung für Gesundheitsdaten und das Konto hat nicht die aktuelle Version (§5.15.1). Abgelehnt, bevor irgendetwas den Host verlässt und bevor eine Nutzungszeile geschrieben wird
400request body must be a JSON objectDer Body ist kein Objekt. Die Eingabe wird niemals zurückgespiegelt
400ai-request-too-largeDer Body enthält mehr Bildteile, Textbytes oder Nachrichten, als die Instanz erlaubt (siehe oben). Der Body nennt limit und max. Abgelehnt, bevor eine Zeile geschrieben wird
400feature-header-invalidX-Openplate-Feature ist vorhanden und keine Kennung, bei einem Konto, das geprüft wird (siehe unten). Abgewiesen, bevor irgendeine Zeile geschrieben wird
400intake-id-invalidX-Intake-Id ist vorhanden und besteht nicht aus 16 bis 64 Zeichen aus A-Z a-z 0-9 _ -. Wird abgelehnt, bevor eine Zeile geschrieben wird
409intake-in-flightEine frühere Anfrage mit demselben X-Intake-Id wird noch verarbeitet, auf einem Konto, für das die Scan-Schranke gilt (unten). Es wird nichts verbraucht und keine Zeile geschrieben
429ein Satz, der den Zeitpunkt des Zurücksetzens nenntDas Kontingent reicht für die Einheiten dieser Anfrage nicht aus. Retry-After gibt die Sekunden bis zur nächsten UTC-Mitternacht an
429ein Satz, der das Limit pro Minute nenntMehr als AI_RATE_LIMIT_PER_MINUTE Anfragen in den jeweils letzten 60 s
503ai-instance-ceilingDie gesamte Instanz hat ihre Tagesobergrenze erreicht, oder die Konten mit Scan-Testphase haben ihre erreicht. Retry-After gibt die Sekunden bis zum nächsten UTC-Mitternacht an

403 ai-not-allowed ist ein Maschinencode, weil ein Client zwingend danach verzweigen MUSS; er bedeutet "dieses Konto wird hier niemals erfolgreich sein, bis eine Administration etwas ändert", was eine andere Meldung ist als "versuche es morgen wieder". Die beiden 429 sind Sätze, weil es nichts zu verzweigen gibt: Ein Mensch liest sie.

403 allowance-expired ist ein separat-Maschinencode, und er ist separat, weil die beiden Saetze nicht derselbe Satz sind: "Dein Betreiber hat dir nie KI zugewiesen" und "Deine Zeit ist abgelaufen" erfordern andere Worte und andere naechste Schritte. Ein Client, der sie zusammenfassen wuerde, wuerde jemandem, dessen Testphase abgelaufen ist, sagen, er solle einen Administrator um ein Kontingent bitten, das er bereits hatte. Beide Abweisungen erfolgen vor der Reservierung, sodass fuer ein Konto, das keine Antwort erhalten hat, keine Nutzungszeile gezaehlt wird. Das Datum wird als "nicht spaeter als" verglichen: Der Grenzzeitpunkt fuehrt zur Abweisung, nicht zur Erlaubnis. Der Sync bleibt bei einem abgelaufenen Konto unbeeintraechtigt (§5.15).

403 trial-scans-spent ist ein dritte-Maschinencode für einen dritten Satz: „Du hast deine kostenlosen Scans aufgebraucht“. Ein Client zeigt dafür das Tarifangebot an, nicht „Wende dich an deinen Administrator“ (ai-not-allowed) und nicht „Deine Zeit ist abgelaufen“ (allowance-expired). Ein Client, der älter als der Code ist, liest ein unbekanntes 403, weshalb er separat geführt und nicht mit einem der beiden zusammengelegt wird.

403 trial-expired ist ein viertens für das andere Limit des Scan-Testzeitraums: „Deine Testtage sind vorbei“. Es ist nicht allowance-expired, was ein ablaufendes bezahltes oder gewährtes Zeitfenster darstellt; ein Client, der das eine für das andere hielte, würde einer zahlenden Person mitteilen, dass ihr Testzeitraum abgelaufen sei. Beide Ablehnungen des Testzeitraums enthalten endedBy, "scans" oder "days", sodass ein Client anhand eines einzigen Feldes benennen kann, welches Limit den Testzeitraum beendet hat:

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

Die Reihenfolge der Ablehnungen, die ein konformer Server einhalten MUSS: Identität und Sperrung; die Einwilligung für Gesundheitsdaten (health-consent-required, §5.15.1); die Zuteilung (die obige Tabelle: ai-not-allowed oder allowance-expired); der Rumpf wird gelesen, und danach die Berechtigung (capability-required, feature-header-invalid, siehe unten); was der Rumpf mitführt (ai-request-too-large); die Form von X-Intake-Id; dann, nur für die Zuteilung des Scan-Testzeitraums (kostenlose Scans, Kontingentdatum kein und keine freie Zuteilung), das Tageslimit (trial-expired, wird nur abgefragt, wenn die Scans nicht aufgebraucht sind, sodass verbrauchte Scans ihren eigenen Code behalten) und der Scan-Anspruch (intake-in-flight, trial-scans-spent). Ein Datum in der Zukunft hebt beides auf: Es handelt sich um ein bezahltes oder gewährtes Zeitfenster, und die Limits des Testzeitraums greifen nur dann, wenn überhaupt kein Datum vorhanden ist. Eine freie Zuteilung hebt beides ebenfalls auf. Danach die Obergrenze für Konten im Scan-Testzeitraum, die Instanz-Obergrenze und das tägliche Kontingent, wie unten beschrieben.

Funktionen

Eine Berechtigung ist eine kurze Kennung, wie etwa scan oder recipes, für eine Art von KI-Anfrage. Ein Konto führt eine Liste davon, und der Proxy weist eine Anfrage ab, deren Funktion das Konto nicht besitzt. Der Dienst weiß nicht, warum ein Konto eine Kennung besitzt: Ein Betreiber schreibt die Liste (§5.20), und ebenso der Abrechnungs-Prinzipal, der capabilities und keinen Status über seine anderen beiden Felder hinaus festlegen kann.

Eine Kennung ist ein Kleinbuchstabe, gefolgt von bis zu 31 Kleinbuchstaben, Ziffern oder Bindestrichen (^[a-z][a-z0-9-]{0,31}$). Eine Liste fasst höchstens 32, wird dedupliziert und sortiert gespeichert und darf nicht none enthalten, was reserviert ist.

Der eigene Eintrag des Kontos hat drei Zustände, und es sind drei Zustände. null bedeutet keinen Eintrag, sodass der Instanz-Standardwert entscheidet. [] ist ein Eintrag, der nichts gewährt. Eine Liste gewährt genau diese Kennungen. Der effektive Wert ist der eigene Eintrag, andernfalls DEFAULT_CAPABILITIES der Instanz (veröffentlicht als instance.defaultCapabilities, §5.6), andernfalls null, und ein effektives null bedeutet keinerlei Prüfung: Jede Anfrage passiert, und nicht einmal ein fehlerhafter Header wird abgewiesen. Das ist der Zustand, den eine Instanz ohne jegliche Konfiguration schon immer hatte. Wenn DEFAULT_CAPABILITIES nicht gesetzt oder leer ist, gilt null. Das Wort none steht für die leere Liste, da die Compose-Dateien eine nicht gesetzte Variable als leere Zeichenkette weiterleiten.

Wie eine Anfrage ihre Funktion benennt.

  • Der Anfrage-Header X-Openplate-Feature: <label>. Ein Header, der keine Kennung ist, ist 400 feature-header-invalid. Der Header ist die eigene Angabe des Clients, schützt für sich allein also nichts.
  • Die strukturierte Ausgabe des Rumpfs, response_format.json_schema.name. Der Betreiber kann einen Schemanamen mit CAPABILITY_SCHEMA_MAP (schemaName:label Paare) an eine Kennung binden. Ein Rumpf, der ein aufgeführtes Schema anfordert, benötigt diese Kennung was auch immer der Header angibt, sodass ein Client, der im Header lügt, keinen Vorteil erlangt. Fehlen beide Kennungen, wird die Kennung des Schemas gemeldet.

Eine Anfrage, die weder eine Funktion noch ein aufgeführtes Schema nennt, verlangt nichts, was die Prüfung vergleichen könnte, und passiert. Die Schema-Zuordnung sorgt dafür, dass eine Funktion gegenüber einem Client durchgesetzt werden kann, der keinen Header sendet.

Die Abweisung ist 403 {"error": "capability-required", "capability": "<label>"}. Die Entscheidung fällt nach dem Kontingent und dem Rumpf, und zwar vor der Tageszählung, dem Scan-Anspruch, den Instanz-Obergrenzen und dem Anbieter: Eine abgewiesene Anfrage schreibt keine Nutzungszeile, verbraucht keinen Scan und sendet nichts an den Upstream. Einem Konto, dem eine Funktion fehlt, wird daher 403 und niemals 429 mitgeteilt, und ein Konto ganz ohne KI erhält dennoch zuerst ai-not-allowed. Ein Client verzweigt anhand des Codes: Er bedeutet „Dieses Konto hat hier keinen Erfolg, bis sich seine Berechtigungen ändern“, nicht „Versuche es morgen wieder“. Ein Browser-Client darf den Header domänenübergreifend senden: Er steht auf der CORS-Positivliste.

Die Scan-Testphase

Ein Konto kann kostenlose KI-Scans (AccountView.trialScans, §5.15) und ein Enddatum (AccountView.trialEndsAt) haben, die durch die Testphase der Instanz gewährt werden (instance.trial, §5.6): eine bestimmte Anzahl von Scans oder Tagen, je nachdem, was zuerst eintritt. Ein Scan ist eine einzelne von der Person gestartete KI-Aktion, und eine Aktion kann mehr als eine Upstream-Anfrage umfassen: Ein Client darf es nach einer Ablehnung durch den Anbieter einmal ohne response_format erneut versuchen. (Ein erneuter Versuch nach einem abgelaufenen Bearer-Token wird durch die Bearer-Prüfung, §4.1, vor jeder Beanspruchung abgelehnt.) Ein Scan kauft eine gelieferte Antwort.

Über X-Intake-Id teilt ein Client mit, welche Anfragen zu einer einzigen Aktion gehören. Es ist optional, 16 bis 64 Zeichen aus A-Z a-z 0-9 _ - (eine UUID mit oder ohne Bindestriche passt), eine frische ID pro Personenaktion, die bei jedem Wiederholungsversuch dieser Aktion wiederverwendet wird, und wird nur an diesen Proxy gesendet, niemals an einen von der Person selbst konfigurierten Anbieter. Der Dienst:

  • beansprucht vor dem Upstream-Aufruf einen Scan für eine ID, die er noch nicht gesehen hat, in einer einzigen Anweisung, deren Grenze WHERE ist, sodass zehn parallele Anfragen bei drei Scans drei beanspruchen;
  • lehnt eine Anfrage mit einer ID ab, deren frühere Anfrage wird noch verarbeitet mit 409 intake-in-flight ist, ohne etwas zu verbrauchen: Überlappende Anfragen zu einer ID würden zwei Antworten für einen Scan erhalten. Eine ID ist wieder nutzbar, sobald ihre Anfrage abgeschlossen ist. Eine fehlgeschlagene Anfrage hat ihren Scan zurückgegeben, sodass der Neuversuch ihn ohne zusätzliche Kosten beansprucht; eine Anfrage nach einer zugestellten Antwort ist eine neue Aktion mit einem neuen Scan, abgelehnt mit 403 trial-scans-spent, wenn keiner mehr übrig ist;
  • behandelt eine Anfrage, die nach 30 Minuten noch verarbeitet wird, als abgebrochen ohne Abschluss und lässt die nächste Anfrage mit dieser ID deren Scan ohne neuen Scan übernehmen, sodass keine ID länger blockiert bleibt;
  • verknüpft jede Rückgabe und jede Zustellung mit dem zugehörigen Anspruch, sodass eine spät fehlschlagende Anfrage niemals einen Scan zurückgibt, den bereits eine neuere Anfrage mit derselben ID beansprucht hat;
  • serialisiert parallele Anfragen mit derselben neuen ID, sodass genau eine von ihnen einen Scan beansprucht und die anderen 409 intake-in-flight sind;
  • behandelt eine Anfrage mit kein ID als eigene Aktion, sodass ein Client, der nie eine sendet, für jede Einzelanfrage-Aktion korrekt gezählt wird.

Die IDs werden 24 Stunden lang aufbewahrt und dann gelöscht (§9.2). Sie werden niemals protokolliert.

Eine Anfrage, die keine Antwort gibt ihren Scan zurück erhalten hat: Die Rückgabe erfolgt für jede Zeile der folgenden Tabelle außer bei einem zugestellten 2xx, sowie bei jeder Ablehnung nach der Beanspruchung (den Obergrenzen und dem Tageskontingent). Dies unterscheidet sich Zeile für Zeile bewusst von der Tageseinheit:

ErgebnisTageseinheitScanWarum der Scan abweicht, wo er es tut
Verbindung abgelehnt / Header-Timeoutfreigegebenfreigegeben
Upstream 4xxfreigegebenfreigegeben
Upstream 5xxverbrauchtfreigegebenDie Einheit schützt die Abrechnung: Die Generierung lief möglicherweise bereits. Der Scan schützt das Versprechen, dass ein fehlgeschlagener Versuch nichts kostet und die Person keine Antwort erhielt. Eine Wiederholungsschleife bei einem unzuverlässigen Provider wird dennoch durch die Tageseinheit begrenzt
Body-Timeout / Stream vom Provider abgebrochenverbrauchtfreigegebenHeader trafen ein, der Provider darf also abrechnen; die Person erhielt dennoch keine Antwort
Upstream-2xx, dann legt der Aufrufer aufverbrauchtverbrauchtDie Antwort war unterwegs
Upstream 2xxverbrauchtverbraucht
Eine Obergrenze oder das Tageskontingent lehnt nach dem Anspruch abnicht verbraucht oder freigegebenfreigegebenDie Anfrage erreichte niemanden

Die Instanz-Obergrenze

Ein Betreiber KANN eine Obergrenze für die gesamte Instanz festlegen, in derselben Einheit wie das Kontingent oben: Einheiten pro UTC-Tag, über alle Konten zusammen (AI_INSTANCE_DAILY_LIMIT). Nicht gesetzt bedeutet keine Obergrenze, was eine selbstgehostete Instanz und jedes bestehende Deployment so handhaben.

Sie existiert, weil jede andere Begrenzung hier pro Konto gilt. Zehn Konten mit 200 Anfragen pro Tag bedeuten 2000 Anfragen pro Tag gegen den Provider-Schluessel des Betreibers; Einladungen vervielfachen also die Konten, ohne die Begrenzung zu vervielfachen.

Wenn die Obergrenze erreicht ist, wird jedes Konto abgewiesen, einschliesslich eines Kontos, das noch nichts von seinem eigenen Kontingent verbraucht hat, bis zum naechsten UTC-Tag. Die Abweisung lautet 503 ai-instance-ceiling mit Retry-After in Sekunden. Es ist ein 503 statt eines 429 oder 403, da es weder die Schuld des Aufrufers noch das Kontingent des Aufrufers ist: Dem Dienst fehlt die Kapazitaet, fuer die sein Betreiber bezahlt hat. Ein Client MUSS darauf verzweigen, denn "dem Betreiber steht heute keine Kapazitaet mehr zur Verfuegung" ist ein anderer Bildschirm als "du hast heute keine Anfragen mehr", und nur der zweite betrifft die Person, die ihn liest.

Die Einheiten der Instanz werden vor denen des Kontos abgezogen, sodass eine abgelehnte Instanz niemals jemandem etwas berechnet, und sie werden zurückgegeben, wann immer die des Kontos zurückgegeben werden (die Tabelle unten gilt für beide, Zeile für Zeile).

Die Obergrenze wird nicht auf /health veroeffentlicht: Sie ist das Budget des Betreibers, und dieser Handshake ist unauthentifiziert. GET /v1/admin/stats meldet sie als aiInstanceDailyLimit, neben dem aiRequestsToday, das sie begrenzt.

Die Konten mit Scan-Testphase haben möglicherweise eine eigene Obergrenze (AI_TRIAL_INSTANCE_DAILY_LIMIT): Einheiten pro UTC-Tag über alle Konten, für die die Scan-Schranke gilt. Sie weist diese Konten, und nur diese, mit demselben 503 ai-instance-ceiling ab. Ist sie gesetzt, zählt eine Scan-Test-Anfrage nur gegen sie und niemals gegen AI_INSTANCE_DAILY_LIMIT, was dann jedes andere Konto begrenzt, damit Test-Traffic niemals Kapazität verbraucht, die zahlende Konten benötigen. Die Provider-Rechnung, die ein Tag erreichen kann, ist die Summe aus beiden. Wo sie nicht gesetzt ist, zählen Scan-Test-Anfragen wie alle anderen gegen die Instanz-Obergrenze. Sie wird auch nicht veröffentlicht; GET /v1/admin/stats meldet sie als aiTrialInstanceDailyLimit, neben signup.trialRequestsToday.

Wo die Test-Obergrenze gesetzt ist, erhält ein Aufrufernetzwerk einen Anteil davon (AI_TRIAL_NETWORK_DAILY_LIMIT, standardmäßig ein Zehntel der Test-Obergrenze, abgerundet, mindestens 1): Einheiten pro UTC-Tag, die Scan-Testanfragen aus einem Netzwerk belegen dürfen. Ein Netzwerk ist ein IPv6-/64 oder eine IPv4-Adresse, wie es die Anmeldedrosseln zählen. Eine Scan-Testanfrage aus einem Netzwerk, das sein Kontingent aufgebraucht hat, erhält denselben 503 ai-instance-ceiling mit demselben Retry-After, sodass ein Client keinen neuen Zweig benötigt; sie verbraucht keinen Scan und keine Einheit, und der Provider wird nicht aufgerufen. Anfragen innerhalb eines bezahlten Zeitfensters oder einer dauerhaften Freigabe werden dadurch weder gezählt noch abgewiesen. Ihre Einheiten werden zurückgegeben, wann immer jene der Test-Obergrenze zurückgegeben werden. Viele Personen hinter einem gemeinsamen IPv4-Carrier-NAT teilen sich einen Bucket; ein IPv6-Aufrufer besitzt ein eigenes /64. Der Dienst speichert dafür keine Adresse: Eine Zeile pro Netzwerk und Tag enthält einen Hash mit Schlüssel (HMAC-SHA256 unter TRIAL_ADDRESS_PEPPER) aus dem Netzwerk und dem Tag, und die Zeile wird am nächsten Tag gelöscht.

Was verbraucht und was zurückgegeben wird

Eine Einheit wird vorher reserviert dem Upstream-Aufruf gezählt, niemals danach. Ein Zählen danach öffnet ein Zeitfenster, in dem N parallele Anfragen alle den alten Zählerstand lesen und alle durchgehen, und ein Client, der bei Fehlern wiederholt, ist genau der Client, der sie gleichzeitig absendet.

ErgebnisEinheitGrund
Verbindung abgelehnt / DNS-FehlerfreigegebenDie Anfrage hat diesen Host nie verlassen
Header-Timeout (noch keine Bytes)freigegebenEs wurde nichts geliefert; unsere eigene Zeitgrenze lief ab, bevor der Anbieter antwortete
Upstream 4xxfreigegebenDer Anbieter hat sie ABGELEHNT. Sie hat kein Modell erreicht, niemand hat sie abgerechnet, und das Konto für die Fehlkonfiguration der Administration selbst zu belasten, würde dazu führen, dass ein defekter Proxy das gesamte Kontingent einer Organisation innerhalb einer Minute aufbraucht
Upstream 5xxverbrauchtDer Anbieter hat sie angenommen und scheiterte bei der Auslieferung. Die Generierung lief womöglich. Hier freizugeben wäre eine endlose kostenlose Wiederholungsschleife genau gegen den Anbieter, der gerade ausfällt
Body-Timeout / Stream abgebrochenverbrauchtHeader trafen bereits ein, der Anbieter hat sie also ausgeführt. Dass wir die Antwort nicht lesen konnten, ist unser Problem, kein Erstattungsgrund
Upstream 2xxverbrauchtOffensichtlich

Der Dienst erfasst eine Ganzzahl pro Konto pro UTC-Tag und nichts weiter: keinen Prompt, keine Antwort, keinen Modellnamen, keinen Zeitstempel genauer als der Tag (§9.2).

5.20 Die Admin-API: /v1/admin

Betreiber-Schnittstelle, keine Client-Schnittstelle. Ein openplate-Client nutzt genau einen dieser Endpunkte, und das nur, wenn das angemeldete Konto Administratorrechte hat: die Konsole, die die App unter /admin darstellt. Ein alternativer Client kann diesen Abschnitt vollständig ignorieren.

Zwei Zugangsdaten erreichen sie, und beide kommen als gewöhnliches Authorization: Bearer an:

  1. Das statische Betreiber-Token (ADMIN_TOKEN), das auch dann weiterfunktioniert, wenn jedes Konto gesperrt ist.
  2. Ein Konto, dessen role admin ist, unter Verwendung seines eigenen Zugriffstokens. Dadurch befindet sich die Konsole in der App und nicht in einer Shell.
  3. Ein berechtigungseingeschränkter Dienst-Token (BILLING_TOKEN). Es ist ein DRITTER Prinzipal, keine zweite Kopie des ersten: Er erreicht drei Routen und drei Felder und wird überall sonst abgewiesen. Siehe „Der Abrechnungs-Prinzipal“ unten.

Wenn nichts weder konfiguriert ist noch übereinstimmt, antwortet der gesamte Unterbaum jedem mit demselben 404 wie jeder unbekannte Pfad. Eine Instanz, bei der keines der Token konfiguriert wurde, ist nicht von einer zu unterscheiden, die vor der Existenz der Funktion erstellt wurde. Ein 401 an dieser Stelle würde ankündigen, dass ein Anmeldedatum existiert und lediglich gesperrt ist. Wird eines der Token gesetzt, wird aus diesem 404 das 401, das ein falscher Wert erhält.

EndpunktFunktion
GET /v1/admin/statsGesamtzahlen: Konten, Blobs, Bytes, Schlüsseldatensätze, pendingInvites, admins, aiRequestsToday und die aiInstanceDailyLimit, die sie begrenzt (null für keine Obergrenze); aiTrialInstanceDailyLimit; und signup: Einladungen, die das Anfragetor aus §5.8.3 heute und in den letzten sieben Tagen erzeugt hat, in den letzten sieben Tagen gewährte Testphasen und die heutigen Scan-Testphasen-Anfragen
GET /v1/admin/ai/budgetDas Budget des Provider-Schlüssels und die heutige KI-Kapazität, siehe „Das KI-Budget“ unten. 404 auf einer Instanz ohne KI. Nicht erreichbar mit BILLING_TOKEN
GET /v1/admin/accountsEine Seite von AccountView, plus total
GET /v1/admin/accounts/expiringEine Seite mit { id, allowanceExpiresAt } für Konten, deren Kontingent in der Zukunft endet, plus total
GET /v1/admin/accounts/:idEin AccountView
GET /v1/admin/accounts/:id/activityLetzte Anmeldung und ein Eintrag pro UTC-Tag über ein begrenztes Zeitfenster
GET /v1/admin/activityDerselbe tageweise Streifen für eine ganze SEITE von Konten, in der Reihenfolge der Liste
PATCH /v1/admin/accounts/:idrole, dailyAiLimit, allowanceExpiresAt (ein ISO-Zeitstempel oder null, um ihn zu leeren), freeDailyAiLimit (die dauerhafte kostenlose Zuteilung, eine Ganzzahl von 0 bis 10000; nicht beschreibbar mit BILLING_TOKEN), capabilities (die eigene Capability-Liste des Kontos, ein Array von Labels, [] für einen Datensatz, der nichts gewährt, oder null, um den Datensatz zu entfernen, sodass der Instanz-Standard entscheidet; beschreibbar mit BILLING_TOKEN, §5.19), trialScans (die gewährten kostenlosen Scans, eine Ganzzahl von 0 bis 100, oder null, um die Scan-Testphase zu entziehen; ändert nie, wie viele verwendet wurden), suspended, displayName, label (die Betreibernotiz, siehe unten, oder null, um sie zu leeren). Mindestens eines erforderlich
POST /v1/admin/accounts/:id/reset-mailStartet das Zurücksetzen aus §5.12 auf Initiative des Betreibers
DELETE /v1/admin/accounts/:idLöscht das Konto und alle daran hängenden Daten
GET /v1/admin/accounts/:id/blob/versionsJede behaltene Blob-Version: Nummer, Envelope-Version, Byte-Zahl, Zeit und der Pin, falls vorhanden. Niemals Geheimtext
POST /v1/admin/accounts/:id/blob/rollback{"targetVersion": n}. Macht diese Version wieder aktuell, indem JEDE Version darüber GELÖSCHT wird (Verkleinerungsschutzfunktion aus §5.1, ADR-0009). Weist eine unbekannte Version, die aktuelle Version, eine von diesem Build nicht akzeptierte Envelope-Version und eine Null-Byte-Zeile ab. Ein Rollback statt eines erneuten Uploads, da die AAD aus §3.2 blobVersion bindet: Das Wiedereinfügen alter Bytes als neue Version erzeugt etwas, das kein Client entschlüsseln kann
GET /v1/admin/invitesEine Seite ausstehender Einladungen, plus total
POST /v1/admin/invitesErstellt eines (§5.8). Das Token wird einmalig zurückgegeben. "trial": true schreibt den Scan-Test der Instanz statt eines Kontingents: 400 auf einer Instanz, die keinen ausführt, und 400 neben einem dailyAiLimit. Ohne das Feld wird das dailyAiLimit der Erstellung beim Einlösen zur bestehenden freien Bewilligung des Kontos (freeDailyAiLimit)
POST /v1/admin/trials/grant-lapsed{"trialDays": n, "apply": false, "excludeAccountIds": []}. Listet alle Mitglieder auf oder gewährt ihnen mit apply: true die Scan-Testphase der Instanz, deren Tagestestphase aus trialDays endete und nie verschoben wurde: Ihr Kontingentdatum entspricht auf die Millisekunde genau ihrer Einlösung plus trialDays, was nur eine Zahlung oder ein Betreiber ändert. Leert das Datum und setzt das Tageslimit der Testphase. Idempotent: Ein begünstigtes Konto wird nie wieder aufgelistet. Antwortet mit {"accountIds": [...], "applied": bool}
POST /v1/admin/invites/:id/resendEin NEUES Token in DERSELBEN Zeile und ein neues Ablaufdatum
DELETE /v1/admin/invites/:idZieht eine ausstehende Einladung zurück
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": {...}}` mit den aktuellen Werten der Instanz
GET /v1/admin/feedbackEine Seite gemeldeter Schätzungen (§5.25), die neuesten zuerst: jeweils { id, accountId, hasImage, consentWordingVersion, createdAt }, plus total, limit und offset. Keine Zahlen und kein Foto
GET /v1/admin/feedback/:idEine Meldung: die Listenfelder, measurements genau so, wie das Gerät sie gesendet hat, und consent: { agreedAt, wordingVersion }
GET /v1/admin/feedback/:id/imageDie Bytes des Fotos unter seinem gespeicherten Content-Type, mit Cache-Control: no-store und X-Content-Type-Options: nosniff. 404, wenn die Meldung keines enthält. Jeder Lesezugriff wird mit der Meldungs-ID und den anfragenden Zugangsdaten protokolliert
DELETE /v1/admin/feedback/:idLöscht das Foto, dann die Meldung. 204, oder 404 bei einer unbekannten ID

GET /v1/admin/ai/budget ist das KI-Budget des Betreibers: Was der Provider-Schlüssel noch übrig hat, und wie viel von der heutigen Instanz-Kapazität genutzt wird.

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 ist der UTC-Tag, für den die Obergrenzen zählen. capacity ist in Einheiten angegeben, die grössengewichteten Zähler, die der Proxy reserviert. paid.used ist, was gegen AI_INSTANCE_DAILY_LIMIT gezählt hat, und trial.used, was die Scan-Test-Konten verbraucht haben. Jedes limit ist die konfigurierte Obergrenze, oder null für keine. Ohne Test-Obergrenze zählen Test-Anfragen auch in paid.used.
  • upstream ist null, wenn der Upstream nicht OpenRouter ist. Andernfalls ist es der Schlüsselabruf von OpenRouters GET /key, in Dollar: limitUsd und remainingUsd sind null für einen Schlüssel ohne Limit, und reset ist "daily", "weekly", "monthly" oder null für ein Limit, das nie zurückgesetzt wird. Ein fehlgeschlagener Abruf ist {"status": "unavailable", "checkedAt": ...}, und capacity wird weiterhin gemeldet.
  • Der Schlüsselabruf läuft auf dem Server mit einem Timeout von 5 Sekunden und wird 60 Sekunden lang aus dem Speicher bedient, ein fehlgeschlagener 15 Sekunden lang. Der Body enthält keinen Schlüssel, kein Schlüssellabel und nichts sonst, was der Provider gesendet hat.
  • Beim selben Abruf, wenn remainingUsd unter AI_BUDGET_ALERT_FRACTION (Standard 0.2) von limitUsd liegt, erhält der Betreiber eine Mail pro Rücksetzzeitraum an MAIL_OPERATOR_EMAIL. Der Dienst liest den Schlüssel ausserdem alle 15 Minuten aus, damit die Mail nicht darauf wartet, dass jemand die Konsole öffnet.

PATCH ist der einzige auth-nahe Schreibzugriff, den ein Betreiber hat, und sie ist bewusst begrenzt. Sie kann keine Passphrase festlegen, und kein Endpunkt kann das: Die Passphrase verpackt den Datenschlüssel auf dem Client, daher würde eine serverseitige Anmeldedatenänderung ein Konto erzeugen, das sich zwar anmeldet, aber nichts entschlüsselt. Sie kann email eines Kontos nicht ändern, da die Einladung genau diese Adresse verifiziert hat. Sie kann keinen Wiederherstellungscode ausgeben.

Das Sperren widerruft im selben Schritt jede Sitzung. Ein suspended_at allein würde dazu führen, dass das Smartphone in der Tasche noch eine Viertelstunde lang weiter synchronisiert, was ein Betreiber mit dem Begriff nicht meint. Das Reaktivieren stellt keine Sitzung wieder her; die Person meldet sich erneut an.

Ein Administrator-KONTO kann sich nicht selbst sperren, herabstufen oder löschen: 400, mit {"error": "self-change"}. Eine Organisation mit nur einer zuständigen Person, die das tut, hat alle aus diesem Baum ausgesperrt, und die einzige Abhilfe ist eine Shell auf dem Container. Das statische Token ist davon ausgenommen, da es kein eigenes Benutzerkonto besitzt und genau für solche Situationen existiert.

label ist die eigene Notiz des Betreibers zu einem Konto, wie etwa "Beta supporter", oder null für keine. Jedes Konto in GET /v1/admin/accounts und GET /v1/admin/accounts/:id trägt den Schlüssel.

  • PATCH mit {"label": "Beta supporter"} setzt sie und {"label": null} leert sie. Der Wert wird getrimmt, und eine nach dem Trimmen leere Zeichenkette leert sie ebenfalls, sodass niemals eine leere Kennzeichnung gespeichert wird.
  • Höchstens 40 Zeichen, gezählt als Unicode-Codepunkte, also die Einheit, die Postgres-char_length zählt. Eine längere Kennzeichnung, eine mit Zeilenumbruch, Tabulator oder einem anderen Steuerzeichen, sowie alles, was keine Zeichenkette oder null ist, ergibt 400, und nichts aus dem Rumpf wird geschrieben. Eine Check-Constraint auf der Spalte erzwingt dieselbe Grenze, sodass auch ein Werkzeug, das direkt schreibt, sie einhält.
  • Ein Betreiber-Fakt, niemals eine Autorisierungs-Eingabe. Keine Route liest sie, um Entscheidungen zu treffen. Das GET /v1/auth/account des Kontos selbst trägt sie nicht, das Konto kann sie nicht setzen (PATCH /v1/auth/account liest nur displayName), und der Billing-Prinzipal kann sie weder lesen noch schreiben.
  • pnpm core-api accounts set-label <id> "Beta supporter" setzt sie und pnpm core-api accounts clear-label <id> leert sie.

GET /v1/admin/accounts/:id/activity beantwortet die Frage, mit der man als Betreiber die Konsole öffnet: Nutzt diese Person die Instanz noch? Es liest, was der Dienst bereits speichert, und erfasst nichts Neues.

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 ist null für ein Konto, das sich noch nie angemeldet hat, und wird nur durch ein Login und durch eine weitergeleitete Vervollständigung geschrieben, nie durch eine Token-Aktualisierung und nie durch ein Sync-Polling (§9.2). Es wird als Zeitstempel übertragen, eine relative Zeitangabe ist eine Darstellungsentscheidung und gehört in den Client.
  • days enthält der Reihe nach jeder Tag im Zeitfenster, mit count: 0 für einen Tag ohne Zeile. Ein fehlender Tag und ein inaktiver Tag dürfen für die lesende Person nicht gleich aussehen.
  • ?days=N grenzt das Zeitfenster ein. N muss eine Ganzzahl von mindestens 1 sein, sonst lautet die Antwort 400. Ein Zeitfenster von mehr als 90 Tagen wird mit 90 beantwortet, und window gibt an, was tatsächlich abgefragt wurde. 90 Tage ist die unten genannte Aufbewahrungsfrist, ein längeres Band könnte also nur Nullen für bereits gelöschte Zeilen enthalten.
  • Eine unbekannte ID liefert denselben 404 wie jede andere Kontoroute, und der gesamte Baum liegt hinter den obigen Zugangsdaten.

GET /v1/admin/activity beantwortet dieselbe Frage für eine ganze Seite auf einmal, weil eine Personenliste neben jeder Zeile einen Streifen zeichnet und eine Abfrage pro Zeile ein N+1-Problem ist.

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= und ?offset= verhalten sich genauso wie bei GET /v1/admin/accounts: dieselben Standardwerte, dieselbe Obergrenze, dasselbe 400 mit demselben Satz. Das ist die Schnittstellenvereinbarung, kein Zufall: Ein Aufrufer blättert synchron durch beide Endpunkte und zeichnet Streifen n neben Person n, daher folgt accounts hier der Reihenfolge, die diese Liste für dieselbe Seite zurückgibt, und total ist das total dieser Liste.
  • ?days=N ist das Zeitfenster des obigen Endpunkts, auf dieselbe Weise begrenzt: eine Ganzzahl von mindestens 1 oder ein 400, Werte über 90 werden mit 90 beantwortet, und window meldet, was gezeichnet wurde.
  • Jedes Konto auf der Seite erscheint, einschließlich Konten, die noch nie eine Anfrage gestellt haben und deren days ein Streifen aus Nullen ist. Ein weggelassenes Konto würde "diese Person war inaktiv" und "diese Person war nicht in der Antwort enthalten" zu derselben Tatsache machen, und genau diesen Fehler soll das Auffüllen mit Nullen pro Tag eine Ebene höher verhindern.
  • Jeder Eintrag besteht aus accountId sowie days und nichts anderem. Die Adresse, der Name und das Kontingent gehören zu GET /v1/admin/accounts, was der Aufrufer ohnehin bereits liest.

Aufbewahrung: Nutzungszähler werden 90 Tage lang aufbewahrt. ai_usage_days speichert eine Ganzzahl pro Konto und UTC-Tag (§9.2). Ein stündlicher Bereinigungslauf innerhalb des Dienstes löscht auf jeder Instanz alle Zeilen, die älter als 90 Tage sind (den heutigen Tag mitgerechnet), ganz ohne Eingriff der Betreiber oder einen Cron-Eintrag. Das Löschen eines Kontos entfernt dessen Zähler und seinen lastSeenAt im selben Befehl wie den Rest der Löschung, und zwar über ON DELETE CASCADE. 90 ist eine einzige Zahl an einer einzigen Stelle: Sie bestimmt, ab wann der Bereinigungslauf kürzt, und ist das längste Zeitfenster, das der obige Endpunkt beantworten kann.

Der Abrechnungs-Principal (BILLING_TOKEN). Ein Zahlungsdienst muss bei einem Konto zwei Zahlen und eine Liste ändern: das Ende eines Kontingents, die Anzahl der täglich damit gekauften KI-Anfragen und die Labels der damit aktivierten KI-Funktionen. Würde man ihm das Betreiber-Token geben, erhielte er jede Adresse auf der Instanz, die Löschtaste und die gemeldeten Fotos, daher wird der Berechtigungsnachweis stattdessen an der Tür im Umfang beschränkt. Er ist optional, standardmäßig nicht gesetzt und hat dieselbe Mindestlänge von 24 Zeichen wie das Betreiber-Token.

EndpunktDer Abrechnungs-Principal darf
GET /v1/admin/accounts/expiring{ id, allowanceExpiresAt } für Konten lesen, deren Enddatum in der Zukunft liegt, paginiert mit demselben limit-, offset- und 400-Satz wie jeder andere paginierte Endpunkt hier
GET /v1/admin/accounts/:idLies { id, allowanceExpiresAt, dailyAiLimit, capabilities } für dieses eine Konto, wobei capabilities der eigene Datensatz des Kontos ist (null bedeutet kein Datensatz)
PATCH /v1/admin/accounts/:idSchreibe allowanceExpiresAt, dailyAiLimit und capabilities, und sonst nichts. trialScans wird wie jedes andere Feld abgewiesen: Ein Berechtigungsnachweis, der für ein Kontingent bezahlt, verteilt keine kostenlosen Scans
  • Jede andere Route in diesem Abschnitt antwortet auf 403 mit {"error": "service-scope"}, einschließlich der vier Feedback-Routen und einschließlich jeder Route, die nach dem Verfassen dieses Textes hinzugefügt wurde. Die Abweisung erfolgt am Mount-Punkt, bevor irgendein Handler ausgeführt und bevor irgendeine Zeile gelesen wird, sodass sie kein Orakel dafür ist, ob ein Konto existiert.
  • Ein PATCH-Body, der ein anderes Feld nennt, wird 403 mit {"error": "service-scope-field"}, und nichts wird geschrieben, nicht einmal die erlaubten Felder daneben. Ein stillschweigendes Verwerfen würde dazu führen, dass ein Fehler im Abrechnungsdienst als Erfolg gewertet wird.
  • Die Werte sind ebenfalls bereichsbeschränkt. allowanceExpiresAt: null (ein Kontingent ohne Ende) und ein dailyAiLimit über dem BILLING_MAX_DAILY_AI_LIMIT der Instanz (Standard 1000) sind mit {"error": "service-scope-value"} 403, und es wird nichts geschrieben. Die Betreiber-Zugangsdaten dürfen beides schreiben. capabilities akzeptiert jede gültige Liste sowie null, was den Datensatz entfernt und somit nie mehr gewährt als den vom Betreiber gewählten Instanz-Standard. Eine fehlerhafte Liste führt zum üblichen 400, für diesen Berechtigungsnachweis genauso wie für einen Betreiber.
  • Darüber hinaus wird dailyAiLimit genau wie für einen Betreiber validiert. Die Anmeldedaten lockern keine Validierung.
  • Die beiden Lesevorgänge sind Projektionen und niemals ein AccountView. Keine Adresse, kein Anzeigename, keine Rolle, keine Sperre, keine Nutzung, kein Blob. `GET

/v1/admin/accounts/expiring` wählt zwei Spalten in der Abfrage aus, anstatt eine Zeile nachträglich zu filtern.

  • Ein gelöschtes Konto und eine unbekannte ID sind dasselbe 404. Eine Löschung ist hier eine Kaskade und kein Tombstone (§9), daher gibt es nichts mehr, womit man sie unterscheiden könnte, und ein deletedAt, das diese Route melden könnte, wäre ein Datensatz einer Person, der nach der Löschung aufbewahrt wurde, die sie entfernt hat. Beides bedeutet „Abrechnung stoppen“.
  • Der Principal hat kein Selbst, daher kann die obige Selbständerungsregel nicht für ihn gelten: Er kann niemanden sperren, herabstufen oder löschen, einschließlich sich selbst, da keine dieser Routen erreichbar ist.

AccountView hat dieselbe Struktur, die auch das konto-eigene GET /v1/auth/account zurückgibt (§5.15), invitesLeft enthalten und auf dieselbe Weise berechnet, plus aiUsedToday, und auf der Admin-Oberfläche plus lastSeenAt, label, blob und keyRecordKinds. capabilities und freeDailyAiLimit auf der Admin-Oberfläche sind der EIGENE Datensatz des Kontos (bei capabilities bedeutet null kein Datensatz), während die eigene Ansicht des Kontos den effektiven Wert meldet. healthConsent ist ebenfalls darauf enthalten und hier schreibgeschützt: PATCH /v1/admin/accounts/:id liest es nicht aus, da eine Einwilligung, die ein Betreiber stellvertretend für jemanden festlegen könnte, nichts beweisen würde (§5.15.1). Es enthält kein Verifizierer, kein KDF-Deskriptor, kein Treuhand-Schlüssel und kein Geheimtext. Ein Blob wird als Byte-Anzahl und Zeitstempel gemeldet. Die Begründung ist docs/adr/0001-an-admin-api-for-a-zero-knowledge-service.md, dessen Verbote 1, 2, 3, 5 und 8 durch ADR-0005 ersetzt werden, dessen Verbot von Geheimnissen in einer Antwort jedoch nicht.

5.21 POST /v1/auth/invites: Ein Mitglied laedt jemanden ein

Bearer, pro Quelladresse gedrosselt mit jeder Versuch gezaehlt. Nur vorhanden, wenn das Deployment sowohl MEMBER_INVITE_DAILY_AI_LIMIT als auch MEMBER_INVITE_ALLOWANCE_DAYS setzt, oder stattdessen MEMBER_INVITE_TRIAL neben der Scan-Testphase der Instanz; ohne beides antwortet dieser Pfad jedem Aufrufer, angemeldet oder nicht, mit dem gewöhnlichen Unbekannter-Pfad-404, und instance.memberInvites ist false (§5.6).

Anfrage: {"email": "boris@example.org"}, und nichts weiter.

json
{}

→ 202 mit diesem Body, leer und unveraendert.

Die Bedingungen bestimmt die Instanz, niemals der Aufrufer. Das eingeladene Konto erhält role: "member", dieselbe Einladungslebensdauer wie der Standard der Admin-Erzeugung, und EINE von zwei Gewährungen, niemals beide: unter dem Tagespaar dailyAiLimit von MEMBER_INVITE_DAILY_AI_LIMIT und ein allowanceExpiresAt aus Einlösung plus MEMBER_INVITE_ALLOWANCE_DAYS, geschrieben bei der Registrierung; unter MEMBER_INVITE_TRIAL die Scan-Testphase der Instanz (§5.19) ohne Datum. Eine Mitgliedereinladung, die unter dem Tagespaar erzeugt und nach dem Wechsel der Instanz eingelöst wurde, erhält die Scan-Testphase, kein Datum. Ein dailyAiLimit, ein role oder ein expiresInDays im Body wird nicht abgelehnt, sondern einfach nicht gelesen. Diese drei SIND Body-Felder bei POST /v1/admin/invites (§5.20), was den Unterschied zwischen einem Mitglied und einem Betreiber ausmacht.

Die Antwort DARF NICHT davon abhängen, was auf die Adresse zutrifft. Eine neue Adresse, eine Adresse mit einer ausstehenden Einladung und eine Adresse, die bereits zu einem Konto gehört, ergeben ein einziges 202 mit demselben Rumpf. Das ist die Anti-Enumeration-Eigenschaft aus §5.7 und §5.12, angewendet auf den einzigen Endpunkt, den ein Mitglied auf das Postfach einer anderen Person richtet: Wer die Adresse von Kollegen eingibt, darf weder aus einem Statuscode noch aus einem Rumpf oder einem Header erfahren, dass diese Kollegen bereits registriert sind. 409 {"error":"an account already exists for this email"} bei der Admin-Erstellung ist ausgenommen, und zwar nur deshalb, weil dieser Endpunkt hinter den Zugangsdaten der Betreiberperson liegt.

Gehört die Adresse bereits zu einem Konto, schickt der Dienst dieser Person eine kurze Benachrichtigung statt einer Einladung. Sie enthält keinen Link: Ein Beitrittslink würde ein zweites Konto für jemanden erstellen, der bereits eines hat, und ein Rücksetzlink wäre ein Zurücksetzen des Passworts, das niemand angefordert hat. Ohne diese Nachricht würde die Einladung stillschweigend verschwinden und beide Personen würden darauf warten.

Eine Adresse, die bereits eine von Mitgliedern ausgelöste Einladung eingelöst hat, erhält keine zweite, und dem Aufrufer wird weiterhin 202 mitgeteilt. Der Nachweis überdauert das Konto: Die Einladungszeile behält ihre Adresse und ihren Einlösezeitpunkt, wenn eines der beiden Konten gelöscht wird, sodass ein Selbstlöschen gefolgt von der erneuten Einladung durch einen Freund kein frisches Kontingent darstellt. Auf einer Instanz, die eine Scan-Testphase betreibt, entfernt das Löschen stattdessen die Adresse aus der Zeile und behält den Hash mit Schlüssel aus §5.15, und die Regel liest diesen Hash. Die Erzeugung durch einen Betreiber ist keine durch ein Mitglied veranlasste Einladung und wird durch diese Regel niemals zurückgehalten.

Eine ausstehende Einladung von einem anderen Tor bleibt unberührt, und der Aufrufer erhält weiterhin 202. Eine Erstellung ersetzt die ausstehende Einladung der Adresse; ohne diese Regel könnte ein Mitglied also die Einladung zurückziehen, die ein Betreiber, das Anfragetor aus §5.8.3 oder ein anderes Mitglied gerade gesendet hat, und die Bedingungen des eigenen Tors an deren Stelle setzen. Es wird keine Zeile geschrieben und keine Einladung gesendet. Ein Mitglied KANN die eigene ausstehende Einladung erneut senden, was sie wie bisher ersetzt. Die Ablehnung erfolgt still statt benannt, weil eine benannte Ablehnung dem Aufrufer verraten würde, dass bereits jemand anderes diese Person eingeladen hat. Ein Administrator, der diese Route nutzt, ist ausgenommen, wie bei der Admin-Erstellung.

Wird ein Konto gelöscht, werden die von ihm gesendeten Einladungen, die noch ausstehen, zurückgezogen in derselben Transaktion (§5.15). Eingelöste und abgelaufene Einladungen bleiben unverändert und zählen weiterhin gegen niemanden: Die einladende Person existiert nicht mehr.

Die Obergrenze über die gesamte Lebensdauer beträgt fünf pro Konto, jemals, gezählt als Tabellenzeilen. Zurückgezogene und abgelaufene Einladungen zählen mit: Die Obergrenze beschränkt, wie viele Benachrichtigungen ein Konto ausgelöst hat, nicht, wie viele davon erfolgreich waren. Ein Überschreiten führt zu 403 {"error":"member-invite-cap-reached"}, und das ist das Einzige, was dieser Endpunkt über das eigene Konto der aufrufenden Stelle verrät, eine Tatsache über sie selbst und über niemanden sonst. Eine Administratorperson ist davon ausgenommen, auf dieser Route wie auch auf der Admin-Route, was invitesLeft: null bedeutet (§5.15).

Ein Scan-Testzeitraum, für den noch niemand bezahlt hat, lädt niemanden ein. Jede Mitgliedereinladung unter MEMBER_INVITE_TRIAL ist ein neuer Scan-Testzeitraum, sodass ein kostenloses Konto, das einladen dürfte, weitere kostenlose Konten erzeugen würde. Ein Konto, das trialScans aufweist und kein allowanceExpiresAt in der Zukunft besitzt, antwortet mit 403 {"error":"invites-need-a-plan"}, schreibt keine Zeile und versendet keinen Brief. Ein zukünftiges Datum gibt die Route frei, unabhängig davon, wer es eingetragen hat: das Abrechnungssystem bei Zahlung oder ein Operator. Die Obergrenze über die Lebensdauer wird zuerst abgefragt, sodass ein Konto, das sein Kontingent aufgebraucht hat, member-invite-cap-reached erhält, da eine Bezahlung ihm nicht helfen würde. Ein Administrator ist auch hier ausgenommen, und das Admin-Minting (§5.20) bleibt unberührt. invitesNeedAPlan in der Kontoansicht (§5.15) besagt dasselbe, bevor die Person es versucht.

202 enthält im Gegensatz zur Admin-Erstellung ebenfalls weder Token noch Link. Die aufrufende Stelle ist nicht die Betreiberperson und darf keine Berechtigung besitzen, die ein Konto erstellt.

5.22 /v1/plans/*: die Weiterleitung an einen Abrechnungsdienst

Nur vorhanden, wenn der Betreiber einen Abrechnungsdienst konfiguriert hat. Ohne diesen antwortet der gesamte Unterbaum jedem, ob mit Zugangsdaten oder ohne, mit dem gewöhnlichen 404 für unbekannte Pfade, und instance.plans ist beim Handshake false (§5.6). Eine Implementierung dieses Protokolls KANN den Unterbaum vollständig weglassen; ein Client MUSS instance.plans lesen, bevor er eine Tür für Tarife anbietet, anstatt den Pfad abzufragen.

Nichts hinter diesem Präfix ist Teil dieses Protokolls. Die Routen, die Anfrage-Bodys und die Antwort-Bodys gehören dem Abrechnungsdienst, der ein separater Dienst mit eigenem Release-Zyklus ist. Dieses Dokument legt nur fest, was das Gateway mit einer Anfrage auf dem Hinweg und mit einer Antwort auf dem Rückweg tut. Das ist Absicht: Die Alternative ist ein normatives Dokument, das Selbst-Hoster nicht verwenden können und das sich mit dem Mehrwertsteuer-Kalender eines Fremden herumschlägt.

Authentifiziert mit dem regulären Zugriffstoken des Kontos (§4.1). Ein anonymer Aufrufer erhält das gewohnte 401. Die einzige Ausnahme ist GET /v1/plans/prices, unten: ein Pfad und eine Methode, und sonst nichts im Teilbaum.

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

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

Das Beispiel dient der Veranschaulichung: Die Routen des Rechnungsstellers bestimmt dieser selbst. Der Rechnungssteller von openplate bedient GET /v1/plans/prices, GET /v1/plans/offer, POST /v1/plans/order, GET /v1/plans/me, die Portal-Route und POST /v1/plans/pending-change/cancel; seine ältere POST /v1/plans/checkout liefert jetzt 410 zurück.

Fünf Eigenschaften, die eine konforme Implementierung einhalten MUSS:

  1. Nur GET und POST werden weitergeleitet. Jede andere Methode im Teilbaum wird 405 {"error":"plans-method-not-allowed"} mit einem Header Allow beantwortet und erreicht das Upstream-System nie. Das Gateway kennt die Routen des Abrechnungsdienstes nicht. Ein Proxy, der alles durchleitet, wäre daher ein universeller Tunnel in einen Dienst, der den Abonnementstatus verwaltet.
  2. Die weitergeleiteten Header werden NEU ERSTELLT, nie kopiert und überschrieben. Es handelt sich genau um X-Account-Id aus der aufgelösten Sitzung, die aus der Account-Zeile gelesene X-Account-Email, X-Plans-Secret mit dem gemeinsamen Geheimnis und den eingehenden Header Content-Type. Ein Ansatz nach dem Prinzip „Kopieren und Überschreiben“ leitet Cookies weiter und alles, was der nächste Client mitsendet.
  3. Die eigenen Zugangsdaten des Aufrufers werden nie weitergeleitet. Auf dieser Regel beruht der gesamte Aufbau: Würde das Zugriffstoken weitergeleitet, funktionierte ein gestohlenes Token auch beim Abrechnungsdienst.
  4. Die Account-ID stammt aus der Sitzung, und die Adresse stammt aus der Tabellenzeile. Sendet ein Client eigene Header wie X-Account-Id oder X-Account-Email, kann er damit nicht beeinflussen, was das Upstream-System liest. Eine accountId, die ein Browser wählen kann, ist ein Autorisierungsfehler, und ein Abrechnungsdienst, der die Adresse dieses Accounts liest, um einen Kassiervorgang vorauszufüllen, würde Adressen preisgeben.
  5. Die Antwort wird mit ihrem Status und ihrem JSON-Body durchgereicht, und nur Content-Type kommt mit ihr zurück. Ein 402 oder ein 409 vom Rechnungssteller ist eine echte Antwort zum Plan der aufrufenden Seite und wird unverändert weitergeleitet. Zwei Routen des Rechnungsstellers von openplate zeigen den Grund. Ein Upgrade wird sofort in Rechnung gestellt und bezahlt, daher antwortet POST /v1/plans/order bei einer abgelehnten Karte mit 402 {"error":"payment-failed"} und die aufrufende Seite bleibt auf der alten Tarifstufe. Ein Downgrade wird zum Ende des bezahlten Zeitraums vorgemerkt, und POST /v1/plans/pending-change/cancel (kein Body) nimmt es zurück: 200 {"kept":{"plan":"…","tier":"…"}}, 409 {"error":"no-pending-change"}, wenn nichts vorgemerkt ist (auch die Antwort auf einen zweiten Aufruf), oder 502 {"error":"pending-change-cancel-failed"}, wenn der Zahlungsanbieter ausfiel und die Änderung weiterhin vorgemerkt ist. Das Gateway leitet jede Antwort unverändert weiter und ergänzt weder eine eigene Route, noch einen Statuscode oder eine eigene Prüfung. Dieses 502 ist die eigene Antwort des Rechnungsstellers und gehört nicht zu den unten genannten plans-upstream-*-Codes des Gateways. Die Antwort GET /v1/plans/me und die 200-Auftragsantwort des gebuchten Downgrades können pendingTier und pendingChangeAt enthalten, die fehlen, wenn nichts vorgemerkt ist; ein Client, der sie nicht kennt, ignoriert sie.

Ein Upstream-System, das nicht erreichbar ist, in ein Timeout läuft, keine JSON-Antwort liefert oder dessen Antwort-Body die Relay-Obergrenze übersteigt, wird 502 in der Hülle nach §4 mit einem Maschinencode ausgeliefert: plans-upstream-unreachable, plans-upstream-timeout oder plans-upstream-invalid. Ein Request-Body, der die eigene kleine Obergrenze des Teilbaums übersteigt, führt zu 413 {"error":"plans-request-too-large"}. Das ist eine andere Aussage: Dem Abrechnungsdienst geht es gut, und was du gesendet hast, wird niemals akzeptiert. In keiner Richtung wird ein Body protokolliert; eine Ablehnung wird nur mit dem Status und dem Pfad protokolliert.

Der ausgehende Aufruf hat ein explizites Timeout. Es ist kurz, da jede Route hier einer Schaltfläche entspricht, die gerade jemand gedrückt hat. Es existiert genauso, um die versteckte Obergrenze von 300 Sekunden in undici zu begrenzen, wie um einen langsamen Abrechnungsdienst einzufangen.

Löschbenachrichtigung (Dienst an Rechnungsdienst). Bevor einer der Löschpfade (§5.15, §5.20) ein Konto löscht, sendet der Dienst POST <PLANS_UPSTREAM_URL>/erase mit genau X-Plans-Secret und X-Account-Id sowie einem leeren Body. Der Rechnungsdienst antwortet mit 204, sobald jedes aktive Abonnement dieses Kontos gekündigt ist, und mit 204, wenn keines existiert. Der Aufruf hat ein Fünf-Sekunden-Timeout. Eine Ablehnung, ein Timeout oder ein nicht erreichbarer Host wird unter error mit der Konto-ID protokolliert, und das Konto wird trotzdem gelöscht; der nächtliche Abgleich des Rechnungsdienstes bleibt die Absicherung.

Der Betreiber konfiguriert PLANS_UPSTREAM_URL und PLANS_UPSTREAM_SECRET, beides oder keines von beiden. Eine URL ohne Geheimnis führt zu einem Startabbruch statt zu einem stillen Downgrade: Das Geheimnis ist das Einzige, woran der Abrechnungsdienst erkennt, dass die gelesene Account-ID von einem Gateway stammt, das jemanden authentifiziert hat.

GET /v1/plans/prices: die Preisliste, vor der Anmeldung

Ein Registrierungsbildschirm nennt den Preis, bevor jemand ein Token besitzt, daher ist dieser EINE Pfad mit dieser EINEN Methode anonym. Es ist kein Token erforderlich. Ein dennoch gesendetes Token wird nicht gelesen und niemals weitergeleitet, sodass ein veraltetes oder fremdes Token den Lesevorgang nicht in ein 401 verwandeln kann. Jeder andere Pfad im Teilbaum sowie ein POST oder ein HEAD auf diesem beantworten einen anonymen Aufrufer weiterhin mit 401.

GET /v1/plans/prices

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

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

Der Body stammt vom Abrechnungssystem und wird ungelesen weitergeleitet, wie jede Antwort in diesem Teilbaum. Das Beispiel zeigt, was openplates Abrechnungssystem ausliefert: die Tarife, die es vertreibt, jeweils mit dem pro interval berechneten Betrag in der kleinsten Währungseinheit von currency, inklusive Steuern, beim Booten von seinem Zahlungsanbieter eingelesen. Die obigen Zahlen sind ein Beispiel, niemals eine Preisliste.

Das Gateway leitet den Body unberührt weiter, sodass der Rechnungssteller ihn erweitern kann. Das Gateway parst den Body nur, um zu prüfen, ob es sich um JSON der zulässigen Größe handelt; es liest oder überschreibt kein Feld. Ein Rechnungssteller, der Tarifstufen anbietet, darf daher neben currency und plans ein tiers-Array ergänzen. Ein Eintrag in tiers hat dieselbe Form wie ein Eintrag des tiers-Arrays im Angebot des Rechnungsstellers (GET /v1/plans/offer): id, name, description, isSold, dailyAiLimit, capabilities und ein eigenes plans. Das Angebot ist der Vertrag des Rechnungsstellers und nicht Teil dieses Protokolls (siehe oben); der Eintrag wird hier nur beschrieben, damit klar ist, was zu erwarten ist:

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

Ein Client MUSS jedes Feld ignorieren, das er nicht kennt, auf oberster Ebene sowie innerhalb eines Eintrags, und DARF den Body nicht wegen eines solchen Felds ablehnen. Ein Body ohne tiers ist genauso gültig wie zuvor, und ein Client, der tiers nie liest, liest currency und plans exakt wie zuvor. Die IDs sind vom Rechnungssteller gewählte Kennzeichnungen (tier-a ist ein Platzhalter), die Reihenfolge in tiers ist die eigene Reihenfolge des Rechnungsstellers, und die Beträge wiederholen die Zahlen des obigen Beispiels nur, um die Form zu verdeutlichen.

Vier Eigenschaften heben diese Route vom Rest des Teilbaums ab:

  1. Sie wird allein mit X-Plans-Secret versendet. Es gibt kein Konto, daher gibt es kein X-Account-Id und kein X-Account-Email, und nichts aus der eingehenden Anfrage wird übertragen: kein Header, kein Query-String.
  2. Ein 200 wird fünf Minuten lang vorgehalten und aus dem Speicher bedient, sodass eine Häufung von Lesern zu einem einzigen Aufruf an das Abrechnungssystem führt. Eine Ablehnung des Abrechnungssystems und ein fehlgeschlagener Aufruf werden wie oben beschrieben weitergeleitet und nicht zwischengespeichert, sodass der nächste Leser erneut anfragt.
  3. Eine Quelladresse darf ihn in jeder zurückliegenden Minute 60-mal lesen. Ein IPv6-Aufrufer zählt als sein /64, und eine IPv4-gemappte IPv6-Adresse als die IPv4-Adresse, die sie enthält. Der nächste Abruf ist 429 {"error":"plans-prices-rate-limited"} mit Retry-After in Sekunden.
  4. Ohne Rechnungsaussteller ist es der gewöhnliche Unbekannter-Pfad-404, wie der Rest des Teilbaums, und /health veröffentlicht dafür nichts Neues: Ein Client, der instance.plans liest, weiß bereits, ob er anfragen soll.

5.23 /v1/pulse/*: der Community-Puls (ADR-0007)

Opt-in auf dem Gerät, und aus, bis eine Person es einschaltet. Nichts hier stammt aus einem Tagebuch: Kein Codepfad auf dem Server entschlüsselt eines. Jede Zahl unten trifft als kleines Delta von einem Gerät ein, dessen Besitzer darum gebeten hat, und ADR-0007 legt genau dar, was das Gerät verlässt und warum.

Vier Routen, alle hinter dem gewöhnlichen Zugriffstoken des Kontos (§4.1). Ein anonymer Aufrufer erhält das gewöhnliche 401.

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

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

Beide tragen einen leeren Body.

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

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

Sieben Eigenschaften, die eine konforme Implementierung erfüllen MUSS:

  1. Der Server rundet erneut und begrenzt. kcal wird auf die nächsten 50 gerundet und auf 0 bis 5000 begrenzt; protein wird auf die nächsten 5 gerundet und auf 0 bis 500 begrenzt. Ein Gerät, das eine exakte Zahl, eine negative oder eine absurde Zahl sendet, landet trotzdem auf demselben Raster wie alle anderen. Ein Body, der nicht aus zwei endlichen Zahlen besteht, ist 400 {"error":"invalid request body"}.
  2. Jeder Schreibvorgang trägt einen Idempotency-Key-Header, eine UUID, und eine Anfrage ohne eine solche ist 400 {"error":"idempotency key required"}. Der Schlüssel wird 24 Stunden aufbewahrt. Eine Wiederholung innerhalb dieses Fensters antwortet mit 200 {"duplicate": true} und ändert nichts, was ein Offline-Replay oder einen Neuversuch sicher macht.
  3. Die Ratenbegrenzungen gelten pro Konto: POST /v1/pulse/meal und POST /v1/pulse/photo je einmal pro Minute, POST /v1/pulse/fasting einmal alle 10 Minuten. Über dem Limit bedeutet 429 mit einem Retry-After-Header in Sekunden und einem Body, der keinen Bezeichner nennt.
  4. Ein Fasten-Heartbeat ist ein Upsert auf die Konto-ID. Zwei Heartbeats hinterlassen eine Zeile mit dem späteren Ablaufzeitpunkt. Die Zeile läuft 30 Minuten nach dem letzten Heartbeat ab, und fastingNow zählt nur nicht abgelaufene Zeilen. Die Präsenz wird über das Konto und nicht anonym zugeordnet, da Ratenbegrenzung und Deduplizierung beide eine Identität benötigen und ein anonymer Heartbeat wiederholt werden könnte, um die Zahl künstlich zu erhöhen (ADR-0007).
  5. GET /v1/pulse/today wird aus einem fünfminütigen In-Memory-Cache bedient, ein Eintrag für die gesamte Instanz, entwertet durch Zeit und nie durch einen Schreibvorgang. Ein Client ruft ihn höchstens alle fünf Minuten ab. Ein innerhalb dieses Fensters getätigter Schreibvorgang ist daher erst sichtbar, wenn der Eintrag abläuft, was so gewollt und kein Fehler ist: Die Zahlen sind ein Zeichen von Gemeinschaft, keine Bestätigung.
  6. Tagessummen werden 30 Tage aufbewahrt. pulse_days und die Zeilen der Beitragenden daneben werden durch einen stündlichen Bereinigungslauf nach Überschreiten dieses Alters gelöscht, abgelaufene Präsenzzeilen werden mit ihnen entfernt, und Idempotenzschlüssel nach 24 Stunden.
  7. Die Puls-Routen protokollieren nur einen Statuscode und eine Byte-Anzahl. Nie die Konto-ID und nie ein Wert aus dem Body.

GET /v1/admin/stats (§5.20) meldet der betreibenden Person den heutigen Puls als pulse: { meals, photos, kcal, protein, contributors, fastingNow }, was dieselbe Menge ist, die jedes Mitglied bereits lesen kann.

5.24 /v1/push/*: Web-Push (ADR-0008)

*Aus, außer die betreibende Person hat alle drei `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`.

Der Server verfasst keinen Benachrichtigungstext. Jeder Push, den er sendet, besteht aus einem einzigen Feld:

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

Das Gerät wacht auf, liest das Tagebuch, das nur es lesen kann, und verfasst die Worte. Ein konformer Client MUSS in der Lage sein, für beide Arten etwas darzustellen, ohne dass die Nutzlast ihm etwas mitteilt, denn die Nutzlast tut dies nie.

Vier Routen, alle hinter dem gewöhnlichen Zugriffstoken des Kontos (§4.1). Ein anonymer Aufruf auf einer konfigurierten Instanz erhält die gewöhnliche 401.

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

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

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

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

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

200 { "unsubscribed": true }

Neun Eigenschaften, die eine konforme Implementierung einhalten MUSS:

  1. Ein Abonnement wird durch seinen Endpunkt identifiziert, den der Push-Dienst erzeugt hat und der global eindeutig ist. PUT ist ein Upsert darauf: Ein Gerät, das denselben Endpunkt erneut registriert, erhält 200 und behält den Tag, an dem es zuerst gesehen wurde.
  2. replaces benennt den Endpunkt, den diese Registrierung ersetzt, und er wird nur dann, wenn er zum selben Konto gehört gelöscht. Eine Neuregistrierung des Service Workers erzeugt einen neuen Endpunkt, ohne den alten abzubestellen, sodass die verwaiste Ressource ohne dies ewig dort bliebe und niemandem mit 201 antworten würde. Ein replaces gleich endpoint ist ein Gerät, das sich selbst benennt, und löscht nichts.
  3. timeZone ist ein IANA-Name und wird beim Schreibvorgang validiert. Eine unbekannte Zone ist 400. Der gesamte Nachholvorgang ist eine Frage der lokalen Uhr, daher wäre eine Zone, die der Server nicht lesen kann, eine Benachrichtigung zur falschen Stunde statt eines Fehlers.
  4. catchUpMinute ist eine Minute des lokalen Tages, 0 bis 1439, oder null für „kein Nachholen auf diesem Gerät“. null ist der stille Standardwert.
  5. wakeAt ist ein einmaliger Zeitpunkt, ISO 8601, oder null, um ihn zu leeren. Der Server sendet die schnelle Zielwarnung, wenn er vergeht, und leert die Spalte im selben Schreibvorgang, sodass sie nie zweimal auslösen kann. Ein fehlendes Feld in einem PATCH belässt ihn genau so, wie er war.
  6. Das Nachholen erfolgt einmal pro LOKALEM Tag, wenn die eigene Uhr des Abonnements dessen Minute überschritten hat und er dort heute noch nicht gesendet wurde. Ein lokaler Tag bei einer Zeitumstellung umfasst 23 oder 25 Stunden, daher ist eine UTC-Periode keine Implementierung dieser Regel.
  7. Sieben Tage Stille pausieren es. Ein Abonnement, dessen letzte Registrierung oder Planänderung mehr als sieben lokale Tage zurückliegt, erhält kein Nachholen, bis es zurückkehrt.
  8. Höchstens zwei Pushes pro Abonnement pro UTC-Tag. Ein dritter wird übersprungen, niemals eingereiht.
  9. Ein 404 oder ein 410 vom Push-Dienst löscht die Zeile. Nichts anderes tut dies: Ein 400, ein 401, ein 403, ein 429 und jedes 5xx sind flüchtig oder betreffen den Absender, und ein Bereinigen darauf würde die Tabelle leeren, sobald ein Schlüssel falsch eingefügt wurde.
  10. An ein Konto ohne Einwilligung geht nichts. Wenn instance.healthConsent nicht null ist (§5.6), sendet der Server unabhängig vom Zeitplan keinen Push an ein Konto, dem genau diese Version fehlt (§5.15.1). Ein zurückgehaltener Push hinterlässt keine Markierung, sodass ein noch ausstehendes Nachholen beim nächsten Takt erfolgt, nachdem die Person zugestimmt hat.

Die Collapse-Topics sind openplate-catchups und openplate-fast, die TTL beträgt 6 Stunden, und die Dringlichkeit ist normal für das Nachholen und hoch für das schnelle Ziel. Ein Topic MUSS aus URL-sicheren Base64-Zeichen bestehen, höchstens 32 davon, und eine Länge haben, die niemals 1 mod 4 ist: Apple decodiert das Topic und antwortet andernfalls mit 400 BadWebPushTopic, während andere Push-Dienste es akzeptieren, sodass der Fehler auf allem außer einem iPhone unsichtbar bleibt.

Die fünf Grenzen (30.09.2026). Damit ein einzelnes Konto nicht jede Zustellung blockieren oder diesen Server auf einen internen Host richten kann:

  1. Der Endpunkt muss https sein, auf dem Standard-Port, ohne Benutzername oder Passwort, bei einem bekannten Push-Dienst: fcm.googleapis.com, updates.push.services.mozilla.com und *.push.services.mozilla.com, web.push.apple.com und *.push.apple.com, *.notify.windows.com, plus alle Hosts, die die Betreiberin oder der Betreiber in PUSH_ENDPOINT_HOSTS aufführt. Alles andere ist 400 {"error":"endpoint must be an https URL at a known push service"} und schreibt nichts. Eine gespeicherte Zeile, die diese Regel verletzt, löscht der nächste Tick ungesendet.
  2. Ein Konto hält höchstens 10 Abonnements. Eine darüber hinausgehende Registrierung löscht die ältesten anderen Zeilen des Kontos; die gerade registrierte Zeile bleibt immer erhalten.
  3. Eine Zustellung bricht nach 10 Sekunden ab, und der Tick sendet an jeweils 8 Endpunkte gleichzeitig, sodass ein langsamer Endpunkt niemanden sonst aufhält.
  4. Eine Zustellung, die mit etwas anderem als 404 oder 410 fehlschlägt, setzt die Zeile zurück: Der nächste Versuch folgt eine Minute später, dann nach zwei, vier und so weiter bis zu einem Tag. Nach 15 Fehlschlägen in Folge, etwa viereinhalb Tagen, wird die Zeile gelöscht. Eine erfolgreiche Zustellung und eine Neuregistrierung des Endpunkts setzen den Zähler zurück.
  5. Läuft ein Tick länger als seine Minute, startet kein zweiter parallel dazu.

PUT auf einen Endpunkt, den ein anderes Konto hält, überträgt die Zeile an den Aufrufer. Das ist notwendig: Die App verwendet das bestehende Abonnement des Browsers wieder, und wenn das Löschen eines Geräts dieses nicht entfernen konnte, registriert das nächste Konto in diesem Browser denselben Endpunkt. Die Zeile des vorherigen Besitzers weckt dieses Gerät dann nicht mehr auf, was dem Wunsch des neuen Besitzers entspricht.

Keine Route gibt jemals einen Endpunkt oder einen Geräteschlüssel zurück, und die Routen protokollieren nur einen Pfad, eine Methode, einen Status und eine Byteanzahl: niemals die Konto-ID und niemals den Endpunkt, bei dem es sich um eine Capability handelt.

GET /v1/admin/stats (§5.20) meldet der betreibenden Person push: { subscriptions, sentToday }, was zwei Ganzzahlen und niemals eine Zeile darstellt.

5.25 POST /v1/feedback: eine gemeldete Schätzung (ADR-0006)

Nur vorhanden, wenn die Betreiber SYNC_FEEDBACK gesetzt haben. Ohne diesen Pfad antwortet die Route jedem, ob mit oder ohne Zugangsdaten, mit dem gewöhnlichen Pfad-unbekannt-Code 404, und GET /health enthält kein instance.feedback (§5.6). Ein Client MUSS instance.feedback lesen, bevor er eine Meldung anbietet. Er MUSS genau das Aufbewahrungsfenster angeben, das dieses Feld ankündigt, und kein anderes.

Dies ist der einzige Schreibvorgang in diesem Protokoll, den der Server lesen kann. Eine Person, die eine Schätzung für falsch hält, sendet die Zahlen dieses Eintrags. Hat das Gerät noch das Tellerfoto, sendet es dieses ebenfalls. Die Person muss zuvor zustimmen, dass beides das Gerät verlässt. Der Server hält beides lesbar, bis das Zeitfenster abläuft. ADR-0006 erklärt, warum diese Ausnahme existiert.

Authentifiziert mit dem normalen Zugriffstoken des Kontos (§4.1). Ein anonymer Aufrufer erhält das gewöhnliche 401.

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

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

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

Sieben Eigenschaften, die eine konforme Implementierung erfüllen MUSS:

  1. Jedes Feld außer image ist erforderlich. idempotencyKey umfasst nach dem Kürzen 1 bis 128 Zeichen, consent.wordingVersion 1 bis 64, und consent.agreedAt ist ein Zeitpunkt. Er wird gemäß der geräteeigenen Uhr gespeichert und nie korrigiert. Der Server-Wert createdAt steht daneben. measurements ist ein JSON-Objekt von höchstens 16 KB nach der Serialisierung. Der Server hat dafür kein Schema und DARF KEINES erhalten: Die Obergrenze verhindert, dass jemand ein Tagebuch in diesem Feld ablegt. Jeder andere Body ist 400 {"error":"invalid request body"}, ein Satz für jedes Feld.
  2. image ist optional, und Fehlen ist kein Fehler. Ein fehlender Schlüssel oder null bedeutet: kein Foto. Der Fotocache des Geräts hat es möglicherweise schon verworfen, und die Zahlen sind dennoch eine Überprüfung wert. Wenn vorhanden, ist es { "contentType", "data" }. contentType ist image/jpeg, image/png oder image/webp. Es ist niemals image/svg+xml, was Skripte enthalten kann. data ist base64, das zu 1 bis 5.000.000 Bytes decodiert. Größer führt zu 413, leer oder ein anderer Typ zu 400.
  3. Das Body-Limit ist FEEDBACK_MAX_REQUEST_BYTES, standardmäßig 8 MB, gilt nur für diese Route. Es liegt über der Bildobergrenze, da base64 um ein Drittel anwächst. Ein größerer Body führt zu 413 {"error":"request body exceeds the maximum accepted size"}.
  4. Der Idempotenzschlüssel macht erneute Versuche sicher. Es ist pro Konto eindeutig. Ein zweites Absenden mit einem bestehenden Schlüssel beantwortet der Server mit 200 und der gespeicherten Meldung statt mit 201. Er speichert keine zweite Meldung und rechnet dies nicht auf das Tageslimit an. Er schreibt das Foto erneut, falls eines enthalten war. Das repariert eine Meldung, deren erster Fotoschreibvorgang unterbrochen wurde.
  5. Ein Tageslimit pro Konto, FEEDBACK_DAILY_LIMIT, standardmäßig 5, gezählt pro UTC-Tag in derselben Transaktion wie der Insert. Ein Überschreiten führt zu 429 {"error":"daily limit reached: 5 reports per day for this account"}, das keinen Bezeichner nennt.
  6. Gespeichert werden das Foto, die Zahlen, der Zustimmungsdatensatz, die Konto-ID und die Ankunftszeit, und sonst nichts. Nicht die Request-Header, die IP-Adresse, der User-Agent oder eine Gerätekennung.
  7. Eine Meldung und ihr Foto verschwinden nach instance.feedback.retentionDays (30 in dieser Implementierung, gelöscht durch einen stündlichen Sweep), früher, wenn Betreiber die Meldung löschen (§5.20), und zusammen mit dem Konto.

6. Versions-Handshake: erforderlich und muss zwingend fehlschlagen, wenn inkompatibel

Ein Client MUSS dieses Dokument vom Dienst lesen und vor der ersten Synchronisierung einer Sitzung prüfen.

Dies ersetzt eine prozessinterne Versionsprüfung aus der Zeit, als Client und Server noch als ein einziges Artefakt ausgeliefert wurden. Das tun sie nicht mehr: Ein bereitgestellter Client und ein bereitgestellter Dienst können um einen Release in beide Richtungen voneinander abweichen, und ein Self-Hoster kann einen aktuellen Client auf einen Dienst richten, den er vor acht Monaten aktualisiert hat. An einem erfolgreichen 200 bei einem Push lässt sich diese Situation überhaupt nicht erkennen.

Regeln:

  1. protocolVersion muss gleich der des Clients sein. Nicht ">=", nicht "halbwegs kompatibel".
  2. envelopeVersion muss gleich dem des Clients entsprechen.
  3. Bei jeder Nichtübereinstimmung verweigert die Synchronisierung der Client und zeigt dem Benutzer an, welche Seite älter ist. Er führt keinen Push aus, keinen Pull, unternimmt keinen erneuten Versuch und verschlechtert das Verhalten nicht stillschweigend.
  4. Wenn der Handshake nicht erreichbar oder fehlerhaft formatiert ist, behandle ihn wie eine Nichtübereinstimmung. Ein nicht verifizierbarer Dienst ist kein kompatibler Dienst.

Die Referenzimplementierung ist checkProtocolCompatibility() in beiden protocol.ts-Dateien, seiteneffektfrei, vollständig definiert und gibt statt eines booleschen Werts einen für Nutzende lesbaren Satz zurück.

Warum Verweigerung statt Best-Effort: Das Blob ist oft die einzige Kopie der Daten, die der Nutzer hat. Wenn ein Client einen Umschlag hochlädt, den ein neuerer Dienst anders fasst, oder einen entschlüsselt, den er nur halb versteht, kann das diese Kopie unwiderruflich zerstören. Ein verweigerter Sync ist eine sichtbare Unannehmlichkeit, ein stillschweigend fehlerhafter Sync ist ein Datenverlust, den man erst Wochen später bemerkt. Dieses Protokoll entscheidet sich jedes Mal für die Unannehmlichkeit.

7. Versionierungsrichtlinie

  • PROTOCOL_VERSION umfasst Endpunkte, Formen von Anfragen und Antworten, die Semantik von Statuscodes, das Authentifizierungsschema und die CAS-Semantik. Erhöhe den Wert bei jeder inkompatiblen Änderung daran. Rein additive Änderungen (ein neues optionales Antwortfeld, ein neuer Endpunkt, den ältere Clients nie aufrufen) erhöhen ihn nicht.
  • ENVELOPE_VERSION betrifft ausschließlich die Kryptografie und das Framing des Blobs: Verschlüsselungsverfahren, Platzierung des IV, Kompressions-Codec, Handhabung der Tags. Erhöhe den Wert bei jeder Änderung daran. Erhöhe ihn Niemals bei Änderungen am Schema der Nutzdaten.
  • payloadSchemaVersion ist die Schemaversion des lokalen Speichers des Clients. Sie wird in diesem Protokoll als opake Ganzzahl übertragen, die in die AAD eingebunden ist. Der Server interpretiert sie nie, und sie beeinflusst keine der beiden obigen Versionen.

Die beiden Versionsnummern sind absichtlich voneinander unabhängig: Das Krypto-Framing umzubauen und die HTTP-API umzugestalten sind unterschiedliche Arten von Änderungen mit unterschiedlicher Tragweite.

Spielraum vor 1.0. Bis zum ersten öffentlichen Release können inkompatible Änderungen ohne den Migrationspfad vorgenommen werden, den ein veröffentlichtes Protokoll erfordern würde. Zwei wurden OHNE Erhöhung der Versionsnummer vorgenommen: der Wechsel von Cookie- zu Bearer-Authentifizierung und die Verlegung der Sync-Routen von /api/sync nach /v1/sync. Eine dritte Änderung, die Entfernung von E-Mail in 0.5.0, geschah ebenfalls ohne Versionserhöhung, was nicht hätte passieren dürfen (siehe unten). Dieser Absatz wird beim öffentlichen Release gelöscht, ab dann gelten die obigen Regeln buchstabengetreu.

0.5.0 änderte den Authentifizierungsvertrag und erhöhte die Version NICHT, und genau das war der Fehler, den dieser Abschnitt nun festhält. Es ersetzte email durch handle, entfernte verify-email sowie request-reset und fügte recover und recover-rotate hinzu (§5.14). Da die Nummer bei 1 blieb, erkannte der Handshake aus §6 dies nicht: Ein Client älter als 0.5.0, der email sendete, erhielt ein 400, das er nicht reparieren konnte, während die Versionsnummern übereinstimmten und ihm signalisierten, dass alles in Ordnung sei.

0.6.0 erhöht PROTOCOL_VERSION auf 2, und zwar genau aus diesem Grund. Die Änderungen gehören zur selben Kategorie (das Auth-Feld ist wieder email, die Registrierung erfordert eine adressierte Einladung und beide Schlüsseldatensätze, signupMode wurde aus dem Handshake entfernt, AccountView ersetzt den alten Account-Body, und zwei Reset-Endpunkte nutzen §5.12 wieder), aber diesmal greift §6: Ein Client mit Version 1 verweigert die Kommunikation, statt nur halb zu funktionieren. Begründung: docs/adr/0005-organization-accounts-and-escrowed-recovery.md.

8. Größenbeschränkungen und Kapazitätsplanung

GrenzwertWertErzwungen durch
Maximale Blob-Größe2 MiB (MAX_BLOB_BYTES)Dienst (413), clientseitig gespiegelt für eine bessere Fehlermeldung
Aufbewahrte Blob-VersionenDrei Stufen, siehe untenDienst, nach jedem akzeptierten Schreibvorgang bereinigt
Schlüsseldatensätze pro Account2 (einer pro kind)Dienst

Die Aufbewahrung ist gestuft (M224). Eine Version wird behalten, wenn IRGENDEINE Stufe sie behält:

StufeRegelObergrenze
KürzlichDie neuesten Versionen (BLOB_VERSION_RETENTION)5
TäglichDie neueste Version jedes UTC-Kalendertags für BLOB_DAILY_RETENTION_DAYS14
Pins vor dem SchrumpfenVersionen, die durch ein bestätigtes großes Schrumpfen ersetzt wurden, für BLOB_PRE_SHRINK_PIN_DAYS, neueste zuerst bis zu BLOB_PRE_SHRINK_PIN_LIMIT14

Also höchstens 33 Versionen, und daher höchstens 66 MiB, pro Konto. Die Tagesstufe gilt bewusst pro Kalendertag statt pro Anzahl: Zwei Geräte in einer Zusammenführungsschleife erzeugen Versionen so schnell, wie das Netzwerk es erlaubt, und eine anzahlbasierte Stufe ist dadurch in Minuten aufgebraucht. Die Pin-Stufe ist begrenzt, weil ein Pin auf eigene Behauptung eines Clients gesetzt wird.

Die pauschalen fünf waren vor M224 die gesamte Regel, und als Sicherheitsnetz war das dünn: Ein Konto, dessen Tagebuch durch einen Client-Fehler gelöscht wurde, ließ sich nur wiederherstellen, solange die intakte Version zufällig noch in einem Zeitfenster lag, das zwei Geräte in einer Minute aufbrauchen können.

Die Kapazitätsgrenze, klar benannt. Ein einzelnes Blob enthält den vollständig-Speicher des Kontos. Ernährungstagebucheinträge umfassen vor der Komprimierung jeweils etwa 400 bis 700 Byte JSON, sodass ein unkomprimiertes Blob nach etwa 2 bis 4 Jahren täglicher Protokollierung die 2-MiB-Grenze überschreiten würde. Das ist keine theoretische Sorge, sondern ein festes Datum.

ENVELOPE_VERSION 1 komprimiert den Klartext mit gzip. Bei derart repetitivem JSON (dieselben Schlüsselnamen in Tausenden von Datensätzen) bringt das ungefähr eine Größenordnung Gewinn und schiebt die Grenze weit genug hinaus, damit sie kurzfristig kein Problem darstellt. Die Grenze verschwindet dadurch nicht.

Die geplante Lösung, damit man sie nicht erst unter Zeitdruck erarbeiten muss: aufgeteilte oder entitätsbezogene Blobs, viele kleine Geheimtexte mit unabhängigen Versionen statt eines einzigen Monolithen. Das ist eine echte Änderung am Framing und an den Endpunkten, daher wird es ein Erhöhung der Protokollversion und kein Patch sein. Im Betrieb ist der Auslöser für diese Arbeit das Überschreiten von etwa 80 % der Obergrenze in der Praxis, wozu der Dienst eine Warnung protokolliert (M128-Spezifikation 02). Dieser Engpass sollte lange sichtbar sein, bevor irgendjemand ihn erreicht.

9. Was der Server weiß

9.1 Was er nicht wissen kann

Der Server erhält nie den DEK, keinen der beiden KEKs und nicht die Passphrase. Er erhält den Wiederherstellungscode bei der Registrierung und bei jeder Rotation und bewahrt diesen Code versiegelt auf (§3.1 und der Escrow-Eintrag in §9.2). Kein Codepfad in diesem Dienst leitet einen Schlüssel aus dem Code ab oder entschlüsselt ein Blob. Für den Code des Dienstes selbst ist eine Entschlüsselung unmöglich, nicht bloß verweigert. Wer sowohl die Datenbank als auch SERVER_SECRET besitzt, kann hingegen entschlüsseln. Das bedeutet: die Betreiber einer verwalteten Instanz oder du auf deiner eigenen.

Und er kann immer noch keines aggregieren. Der Community-Puls aus §5.23 sieht so aus, als zähle der Server Mahlzeiten, doch das tut er nicht: Nichts in §9.2 wird aus einem Blob abgeleitet, und ein Deployment, auf dem niemand den Puls aktiviert hat, zählt überhaupt nichts. Die Summen existieren, weil Geräte, deren Besitzer dem zugestimmt haben, sie gesendet haben. Deshalb wird der Puls unten als etwas aufgeführt, das der Server weiß, und nicht als etwas, das er berechnet.

9.2 Was er tatsächlich weiß

Offenheit bei den Metadaten, da „Ende-zu-Ende-verschlüsselt“ oft als „der Server weiß gar nichts“ missverstanden wird:

  • Blob-Größe, und somit ein Näherungswert dafür, wie viele Daten das Konto umfasst. Die Komprimierung macht dieses Signal unschärfer als zuvor, verbirgt es aber nicht.
  • Schreibhäufigkeit und Zeitpunkte: wann ein Gerät synchronisiert und wie oft.
  • Versionsnummern: blobVersion, envelopeVersion und die Anzahl der aufbewahrten Versionen.
  • KDF-Parameter und Salt für den Passphrasen-Datensatz. Das sind keine Geheimnisse; sie dienen dazu, an ein neues Gerät vor der Anmeldung ausgeliefert zu werden.
  • Ob ein Konto die Einrichtung abgeschlossen hat (Schlüsseldatensätze vorhanden) und ob es jemals synchronisiert hat (Blob vorhanden).
  • Das Konto selbst: eine E-Mail-Adresse, ein optionaler Anzeigename, eine Rolle, ein tägliches KI-Kontingent, ein Sperrzeitpunkt, ein Authentifizierungs-Prüfwert (ein mit Schlüssel versehener Hash eines mit Schlüssel versehenen Hashs der Passphrase, siehe §5.8), ein zweiter Prüfwert desselben Aufbaus über dem Wiederherstellungsnachweis und die KDF-Parameter des Kontos. Die Adresse bezeichnet eine reale Person, eine Kategorie personenbezogener Daten, die in 0.5.0 entfernt und in 0.6.0 bewusst wieder eingeführt wurde (ADR-0005): Personen einer Organisation werden über die Adresse identifiziert, an die ihre Einladung ging, da dies die Kennung ist, die sie auch in einem Monat noch kennen.
  • Der WIEDERHERSTELLUNGSCODE des Kontos, versiegelt (accounts.recovery_code_escrow, §3.1). Das ist der Eintrag in dieser Liste, an dem man innehalten sollte. Er ist mit AES-256-GCM unter einem Unterschlüssel von SERVER_SECRET verschlüsselt, sodass ein reiner Datenbankabzug ihn nicht öffnet, die Betreiberinstanz einer gemanagten Instanz verfügt jedoch über beides. Der Betreiber einer verwalteten Instanz kann jedes Konto darauf öffnen. Nicht über einen Endpunkt und nicht über irgendeinen Codepfad in diesem Dienst, sondern durch direktes Auslesen dieser Spalte mit dem vorhandenen Secret und dem Ausführen der HKDF des Clients. Eine selbst gehostete Instanz ist ihre eigene Betreiberinstanz, dort gilt das frühere Versprechen also weiterhin. Die Entscheidung, einer gehosteten Instanz zu vertrauen, ist daher eine Entscheidung über deren Betreiber.
  • Ausstehende Einladungen: jeweils eine Adresse, ein optionaler Name, eine Rolle und ein Kontingent von jemandem, der noch KEIN Konto hat und keine Einwilligung erteilt hat. Das Erzeugen ist eine Operator-Aktion, und DELETE /v1/admin/invites/:id zieht die Zeile zurück. Eine Zeile, die das Anfrageportal aus §5.8.3 erzeugt hat, ist als solche markiert, damit ein Operator sie zählen kann. Eine abgeschlossene Einladung verliert ihre Adresse und ihren Namen innerhalb einer Stunde: eine widerrufene oder abgelaufene Zeile auf jeder Instanz, eine eingelöste Zeile auf einer Instanz mit TRIAL_ADDRESS_PEPPER, die nur den unten stehenden Hash mit Schlüssel behält. Ohne den Pepper behält eine eingelöste Zeile ihre Adresse, weil die Regel zur erneuten Einladung von Mitgliedern aus §5.21 sie liest.
  • Die Scan-Testphase, auf einer Instanz, die eine betreibt: die gewährten und verbrauchten Gratis-Scans, zwei Ganzzahlen in der Kontozeile. Nur für ein Konto mit Scan-Testphase, eine Zeile pro KI-Aktion: eine vom Client gewählte, opake ID, eine Zeit, eine Anfrageanzahl und ob eine Antwort zugestellt wurde, 24 Stunden aufbewahrt und dann gelöscht, und nie protokolliert. Jede Einladungszeile trägt einen Einweg-Hash des Postfachs mit Schlüssel (HMAC-SHA256 unter TRIAL_ADDRESS_PEPPER, ein Geheimnis, das nur der Betreiber besitzt, über den Testphasenschlüssel aus §5.8.3), niemals eine zweite Kopie der Adresse.
  • Nachdem ein Konto gelöscht wurde, auf einer Instanz, die eine Scan-Testphase betreibt: Adresse und Name werden aus jeder Einladungszeile zu diesem Postfach entfernt, und nur wenn das Konto eine Testphase nutzte, ein einzelner Hash des Postfachs mit Schlüssel wird behalten, und nichts anderes: kein Name, keine ID und ein Datum, der Zeitpunkt der Löschung, welcher die Zeile enden lässt. Dadurch wird verhindert, dass dasselbe Postfach eine zweite Testphase erhält. Ohne das Betreiber-Geheimnis kann der Hash weder umgekehrt noch mit einer Adressliste abgeglichen werden. Ein Bereinigungslauf löscht ihn TRIAL_HASH_RETENTION_DAYS (standardmäßig 365) nach diesem Zeitpunkt, auf der Rechtsgrundlage von Art. 6 Abs. 1 lit. f DSGVO (ein berechtigtes Interesse an der Verhinderung des Missbrauchs kostenloser Scans, nicht juristisch geprüft, ADR-0010). Eine Instanz, die keine Scan-Testphase gewährt, speichert keinen Hash. Auf einer Instanz ohne Scan-Testphase behalten Einladungszeilen ihre Adresse nach einer Löschung für die Mitglieder-Wiedereinladungsregel aus §5.21.
  • KI-Nutzung: eine Ganzzahl pro Konto und UTC-Tag, wird 90 Tage lang aufbewahrt und dann gelöscht (§5.20). Ein Zähler, nie ein Protokoll: kein Prompt, keine Antwort, kein Modell, kein Zeitstempel über den Tag hinaus. Betreiber können die Zähler eines Kontos als tagesweises Band (GET /v1/admin/accounts/:id/activity) abrufen, was Metadaten darüber darstellt, wann eine Person eine Gesundheits-App genutzt hat, und genau deshalb begrenzt ist.
  • Der Community-Puls, für Konten, die ihn aktiviert haben (§5.23, ADR-0007): instanzweite Tagessummen von Mahlzeiten, Fotos, Kalorien und Gramm Protein, eine Zeile pro beitragendem Konto und Tag sowie eine kurzlebige Präsenzzeile, die besagt, dass ein Konto gerade fastet. Die Summen lassen sich niemandem zuordnen; die Beitragszeile und die Präsenzzeile schon, und sie besagen lediglich „dieses Konto hat heute beigetragen“ und „dieses Konto fastet“. Tagessummen und Beitragszeilen werden 30 Tage lang aufbewahrt, die Präsenz läuft 30 Minuten nach dem letzten Heartbeat ab, und die Routen protokollieren keine Konto-ID. Wer ihn nie aktiviert hat, sendet nichts und taucht darin nirgends auf.
  • Ein Push-Abonnement, für ein Gerät, dessen Besitzer Benachrichtigungen aktiviert hat (§5.24, ADR-0008): der Push-Dienst-Endpunkt, die beiden Schlüssel, mit denen er verschlüsselt, ein gekürzter User-Agent-String, eine IANA-Zeitzone, ein Gebietsschema, die Minute des lokalen Tages, zu der ein Nachholen fällig ist, der lokale Tag, an dem zuletzt eine Benachrichtigung rausging, der lokale Tag, an dem das Gerät zuletzt gesehen wurde, der Zeitpunkt, zu dem es geweckt werden wollte, und ein Zähler für das, was heute gesendet wurde. Zusammen verraten diese Angaben grob, wann diese Person wach ist, grob, wo auf der Welt sie sich befindet, und über wake_at, wann ein Fasten von ihr endet. Letzteres deckt sich mit der Präsenzzeile des Pulses, was besagt, dass dasselbe Fasten läuft; ADR-0008 benennt diese Korrelation, statt sie erst entdecken zu lassen. Was NICHT gespeichert wird, ist auch nur ein Wort des Texts irgendeiner Benachrichtigung: Jeder Push übermittelt lediglich eine Art. Die Zeile verschwindet, wenn sich das Gerät abmeldet, wenn der Push-Dienst es verwehrt, oder zusammen mit dem Konto.
  • Eine Gesundheitsdaten-Einwilligung, auf einer Instanz, die danach fragt (§5.15.1): die Version des Wortlauts, dem die Person zugestimmt hat, und der Zeitpunkt, an dem dieser Dienst dies erfasst hat, zwei Spalten in der Kontozeile. Es besagt, dass die Person eine Gesundheits-App nutzt und eingewilligt hat, dass der Operator diese Daten verarbeitet, was der Operator nachweisen können muss. Für einen Operator ist es sichtbar (§5.20), keine Route löscht es, und bei einer Löschung verschwindet es zusammen mit der Kontozeile.
  • Wann eine Person zuletzt aktiv war: accounts.last_seen_at, geschrieben durch ein Login und durch eine weitergeleitete Vervollständigung, und bewusst nicht durch eine Token-Aktualisierung oder ein Sync-Polling, sodass es bedeutet, dass jemand aktiv war, und nicht bloß, dass ein Client lief. Es ist für Betreiber einsehbar (§5.20) und wird beim Löschen der Kontozeile mit entfernt.
  • Gesetzliche Erklärungen (POST /v1/legal/declarations, eine Kündigung oder ein Widerruf, auf jeder Instanz): der Name, die Adresse, das Vertragszeichen, der Grund und die Daten, die die Person eingegeben hat, der Eingangszeitpunkt und das zugehörige Konto, falls vorhanden. Der Eintrag wird Aufbewahrung bis zum Ende des dritten Kalenderjahres nach dem Eingangsjahr, nach der Zeit in Europe/Berlin gerechnet, und danach durch den stündlichen Sweep gelöscht: Ein am 21.09.2026 eingegangener Eintrag wird ab dem 01.01.2030 um 00:00 Uhr in Berlin gelöscht. Das Löschen des Kontos löscht diesen Eintrag nicht vorzeitig; die Zeile verliert ihre Kontokennung und bleibt erhalten, da sie der Nachweis darüber ist, was die Person erklärt hat. Bestätigungsmails sind auf drei pro normalisierter Adresse, auf LEGAL_DECLARATION_RECEIPTS_PER_NETWORK_PER_DAY (standardmäßig 10) pro Absendernetzwerk und auf LEGAL_DECLARATION_RECEIPTS_PER_DAY (standardmäßig 200) pro Instanz begrenzt. Jedes Limit gilt für beliebige gleitende 24 Stunden. Die Gesamtzahlen werden aus diesen Zeilen ermittelt, mit Ausnahme des Netzwerkzählers, der im Arbeitsspeicher verbleibt. Eine Erklärung über einem Limit wird dennoch gespeichert, weitergeleitet und an den Betreiber gesendet, und der 202 ist derselbe. Die Bestätigung wiederholt niemals den Namen, das Vertragszeichen oder den Grund; nur die Kopie für den Betreiber enthält diese Angaben.
  • Sitzungsmetadaten: wie viele aktive Sitzungen existieren, wann jede einzelne erstellt wurde und wann Tokens zuletzt rotiert oder widerrufen wurden. Token-Werte selbst werden nur als Hashwerte gespeichert.
  • Der Studiengraph, auf einem Server mit gesetztem SYNC_RESEARCH (§5.18): welches Konto zu welcher Studie beiträgt, wann, wie oft und wie umfangreich jeder Beitrag ist. Eine Kante hier bedeutet "die Gesundheitsdaten dieser Person befinden sich in Studie Y", was gesundheitsnahe personenbezogene Daten derselben Kategorie wie die unten genannte Betreuungskante darstellt. Dies ist unvermeidbar, und der Widerruf belegt das: Das Löschen der Zeile eines Beitragenden erfordert deren Lokalisierung, eine Kontolöschung muss kaskadierend darüberlaufen, und sowohl das Compare-and-Swap als auch die Missbrauchskontrolle greifen auf das Konto zu. Ein Verfahren, das den Server blind dafür machen würde, würde mindestens eine dieser Funktionen stören, und eine Verkehrsanalyse würde die Verschleierung ohnehin wieder aufheben; deshalb wird dies offengelegt, statt halbe Ausweichwege zu suchen. Forschende erhalten diese Zuordnung niemals (§5.18 enthält keine Konto-ID), ein Widerruf löscht die Kante physisch und hinterlässt nur ein Pseudonym, und eine Installation ohne das gesetzte Flag besitzt gar keine Tabelle für einen Studiengraphen.
  • Der Freigabegraph, auf einem Deployment mit gesetztem SYNC_SHARING (§5.16): welches Konto welchem anderen Konto Lesezugriff gewährt hat, wann die Freigabe erfolgte und wann die berechtigte Person darauf zugreift. Das bildet einen Beziehungsgraphen und stellt eine echte Erweiterung dessen dar, was dieser Dienst weiß. In dem Szenario, für das die Funktion gebaut wurde (eine behandelte Person und deren Ernährungsberatung), ist eine Kante in diesem Graphen selbst ein gesundheitsnahes personenbezogenes Datum, da sie aussagt, dass sich jemand in Behandlung befindet. Es ist das absolute Minimum, um den Lesezugriff zu autorisieren; beide Seiten stimmen zu, da die freigebende Seite die Zeile anlegt und die berechtigte Seite sie auf ihrer Seite löschen kann; und die Kante wird beim Widerruf endgültig gelöscht und kaskadierend entfernt, sobald eines der Konten gelöscht wird. Ein Deployment, das SYNC_SHARING nicht setzt, speichert keinen solchen Graphen und besitzt dafür keine Tabelle.
  • Gemeldete Schätzungen, auf einem Deployment mit gesetztem SYNC_FEEDBACK (§5.25, ADR-0006): die Zahlen jedes Eintrags, den eine Person gemeldet hat, das Tellerfoto, sofern das Gerät noch eines hatte, die Konto-ID, der Zustimmungsdatensatz und die Ankunftszeit, alles im Klartext. Sie werden für instance.feedback.retentionDays aufbewahrt, dann von einem Sweep gelöscht, und sie verschwinden mit dem Konto. Betreiber können sie über §5.20 lesen, und jeder Lesezugriff auf ein Foto wird protokolliert. Ein Deployment ohne das Flag speichert weder Meldungen noch Fotos.

Aus den obigen Metadaten nicht erkennbar: was gegessen wurde, wann, wie viel oder sonstiges innerhalb der Payload. Zwei Einträge oben verraten es doch. Der versiegelte Wiederherstellungscode öffnet das gesamte Tagebuch für jeden, der auch SERVER_SECRET besitzt, und eine gemeldete Schätzung zeigt den einzelnen Eintrag, den sie enthält.

10. Einen alternativen Server implementieren

Ein konformer sync-Server benötigt vollständig:

  1. Die vier Endpunkte aus §5.1 bis §5.4 plus der /health-Handshake aus §5.6. §5.5 wurde entfernt; ein Server darf kein Löschen von Schlüsseldatensätzen nur über Bearer-Token anbieten.
  2. CAS pro Konto auf blobVersion: atomar. Die Referenzimplementierung nutzt einen UNIQUE (accountId, blobVersion)-Index und behandelt eine Eindeutigkeitsverletzung als Konflikt statt Zeilensperren zu verwenden; das bleibt unter READ COMMITTED korrekt und ist einfacher als SELECT ... FOR UPDATE. Jeder Mechanismus mit derselben Garantie ist zulässig; ein Lesen mit anschließendem Schreiben ohne Atomarität ist nicht.
  3. CAS pro Konto und Art auf Schlüsseldatensätzen über expectedUpdatedAt, mit derselben Regel „fehlendes Feld ist ein 400“, und einer Passphrasen-Prüfung (currentAuthHash) bei jedem Überschreiben.
  4. Aufbewahrungsbereinigung auf die drei Stufen aus §8 und der Schrumpfschutz aus §5.1. Ein Server, der ein unbestätigtes großes Schrumpfen akzeptiert, zerstört das Tagebuch eines Kontos beim ersten Mal, wenn ein Client des betroffenen Builds seinen lokalen Speicher verliert; ein Client, der für einen Server geschrieben wurde, der dies verweigert, und auf einen verwiesen wird, der dies nicht tut, ist unbemerkt ungeschützt.
  5. Bytegenaue Speicherung von ciphertext und wrappedDek. Kodiere sie niemals neu, normalisiere sie nicht, schneide sie nicht zu und "repariere" sie nicht. Jede Mutation zerstört das GCM-Tag und damit die Daten des Nutzers.

Ein Server, der zusätzlich die Konto-Endpunkte aus §5.7 bis §5.15 implementiert, muss außerdem:

  1. Liefere für unbekannte Adressen einen stabilen, realistisch geformten KDF-Deskriptor aus (§5.7), führe auf beiden Zweigen identische Berechnungen durch und begrenze die Anfragerate des Endpunkts nach Quelladresse. Ein 404, ein träge abgeleiteter Platzhalter oder ein ungedrosselter Endpunkt öffnet jeweils das Aufzählungsorakel wieder, das das restliche Design schließt, sei es über die Antwort, das Timing oder das Volumen.
  2. Speichere beide Prüfwerte als schlüsselgebundene Hashes der übermittelten Werte authHash und recoveryAuthHash mit einem Geheimnis, das außerhalb der Datenbank liegt. Speichere niemals den übermittelten Wert selbst und niemals im Klartext.
  3. Wende die Rotationsübermittlungen aus §5.14 atomar an, einschließlich des neu versiegelten Escrows, und widerrufe jede offene Sitzung bei jedem der Auslöser in §4.2.
  4. Übernimm die Adresse des Kontos bei der Registrierung aus dem INVITE und niemals aus dem Anfrage-Body (§5.8), und drossle recover, recover-rotate und reset/request über ein gemeinsames Token-Bucket pro (IP, E-Mail), das bei Erfolg nie geleert wird. Ein Server, der zulässt, dass ein Registrierungs-Body seine eigene Adresse bestimmt, hat das Einzige entfernt, was sie überprüft.
  5. Antworte bei jedem reset/request nach identischem Rechenaufwand mit 202, und sorge dafür, dass reset/open nichts in das Konto schreibt (§5.12). Ein Zurücksetzen, das einen Prüfwert ersetzt, ist der Weg zur Kontoübernahme, den dieses Protokoll gestrichen hat, ganz gleich, wie man es nennt.
  6. Ein gesperrtes Konto bei der Anmeldung, beim Token-Refresh und auf jeder Bearer-Route mit 403 {"error":"account-suspended"} abweisen, exakt mit dieser Zeichenkette.
  7. Lösche beim Entfernen eines Kontos kaskadierend auch Blobs, Schlüsseldatensätze, Reset-Tokens und Nutzungszeilen.
  8. Beantworte die Mitgliedererstellung aus §5.21 mit genau EINER Antwort für eine neue Adresse, eine Adresse mit ausstehender Einladung und eine Adresse mit bestehendem Konto, sofern dieser Endpunkt überhaupt implementiert ist. Ein Server, der für den dritten Fall 409 zurückgibt, liefert jedem Mitglied ein Orakel darüber, wer sonst noch auf der Instanz ist, und ein Server, der bei ausgefallenem Mail-Relay 500 antwortet, liefert ein langsameres Orakel. Ein Server, der Mitgliedereinladungen nicht implementiert, antwortet auf diesem Pfad mit dem gewöhnlichen 404 für unbekannte Pfade und meldet instance.memberInvites: false.

Ein konformer Server benötigt nichts von: der Kryptografie aus §3, dem Parsen von JSON beliebiger Payloads oder dem Wissen darüber, was ein Ernährungstagebuch ist.

11. Einen alternativen Client implementieren

Über §3 und die 409-Schleife aus §5.1 hinaus:

  • Führe den Handshake nach §6 vor dem ersten Synchronisieren durch und breche bei Nichtübereinstimmung ab.
  • Speichere die Passphrase, den KEK oder den DEK niemals in persistentem Speicher. Leite Schlüssel beim Entsperren ab, halte sie im Arbeitsspeicher und verwirf sie danach.
  • Führe Argon2id außerhalb des Hauptthreads aus. Bei 64 MiB frieren leistungsschwache Telefone spürbar ein.
  • Erstelle den Wiederherstellungscode bei der Registrierung, verschlüssele den DEK damit und sende ihn im Registrierungs-Body an den Server, damit er hinterlegt werden kann (§3.1). Überspringt ein Client diesen Schritt, erzeugt er ein Konto, das sich durch kein Zurücksetzen wiederherstellen lässt. Ob der Code der Person ANGEZEIGT wird, entscheidet der Client; bei einer verwalteten Instanz ist der Sinn der Hinterlegung, dass dies nicht nötig ist.
  • Zeige an, an welcher Art von Instanz sich die Person anmeldet, bevor sie ein Tagebuch darin anlegt. Bei einer verwalteten Instanz besitzt der Betreiber den hinterlegten Code und kann das Konto öffnen; bei einer selbst gehosteten Instanz ist die Person selbst der Betreiber. Beides ist legitim, aber Außenstehende gehen meist nur von einer der beiden Varianten aus.
  • Lies die Adresse aus POST /v1/auth/invite-lookup aus (§5.8.2) und zeige sie an, statt die Person zum Eintippen aufzufordern. Wer die Adresse gar nicht erst tippt, kann sich nicht vertippen und im falschen, unerreichbaren Konto landen.
  • Leite den Wiederherstellungsnachweis unter openplate-sync:recovery-auth:v1 ab und sende nie KEK_r. Beide sind Geschwister über demselben Code; sendest du den KEK-Zweig, erhält der Server einen HMAC des Werts, der das Tagebuch öffnet (§3.1).
  • Sobald POST /v1/auth/reset/open den Wiederherstellungscode zurückgibt, führe das NORMALE §5.14 recover-rotate damit aus: eine neue Passphrase, ein neu verpackter passphrase-Datensatz, ein neuer Code, ein neu verpackter recovery-Datensatz und das neue recoveryCode für die Hinterlegung. Brichst du auf halbem Weg ab, passt die Hinterlegung des Kontos nicht mehr zu dessen Verifizierer.
  • Behandle 404 von GET /blob als „neues Konto“, nicht als Fehler.
  • Sende authHash (den auth-HKDF-Zweig aus §3.1) und niemals die Passphrase, die Argon2id-Ausgabe oder KEK_p. Das Ableiten des falschen Zweigs geschieht unbemerkt: Die Authentifizierung klappt, erzeugt aber einen Schlüssel, der nichts entschlüsselt.
  • Rufe den KDF-Deskriptor ab (§5.7), bevor du auf einem neuen Gerät Schlüssel ableitest. Verlass dich nicht auf die Standardwerte; ein Konto mit hochgesetzten Parametern lässt sich damit nicht korrekt ableiten.
  • Bewahre das Refresh-Token auf derselben Speicherebene wie das Access-Token auf und verwende ein abgelaufenes nie wieder: Ein Replay widerruft die gesamte Familie und loggt den Benutzer aus (§4.2). Serialisiere Refreshes; zwei Tabs, die um dasselbe Refresh-Token konkurrieren, wirken exakt wie ein Diebstahl.
  • Aktualisiere bei 401 einmal und versuche es einmal erneut. Leite den Benutzer bei einem zweiten 401 zur Anmeldung weiter, statt eine Endlosschleife zu starten.

Diese Seite auf GitHub bearbeiten