Skip to content

Spec errata

Canonical specification

This page is ProjectInfo/spec-errata.md on docs/encryption-modes-spec, rendered at build time. That file is the source of truth; the site holds no copy of it.

Where the specifications in this repository and Server/docs/ disagree with the code that is running, as checked against the code on 2026-10-17. Unless an entry says otherwise, the code is what is in effect — a third-party client or server must follow the code's behaviour to interoperate.

This file is canonical for the discrepancies themselves. When one is fixed — in the spec or in the code — delete its entry in the same commit, and if it was the last entry for a published page, move that page's status to canonical in site/sources.yml.

Code references are repo/path:line at the time of the audit. CF is Client-Flutter/lib/features.

E2EE core

The Dart client is the de-facto protocol: the Go X3DH in Server/pkg/crypto/v1/x3dh.go is called only from tests and takes X25519 identity keys directly, so it does not work with the published Ed25519 bundles as it stands.

Parameters in effect

Item Value Code
Identity key (IK) Ed25519 (libsodium). Public 32 bytes; secret 64 bytes = seed ‖ public CF/e2ee/device_keys.dart:46
Signed prekey (SPK) X25519. Id is always 1; not rotated. Signature = Ed25519 over the raw 32-byte SPK public key. The server checks lengths 32/32/64 and verifies device_keys.dart:47-53; keyserver bundle/service.go:42-53
IK used for DH crypto_sign_ed25519_{pk,sk}_to_curve25519 CF/e2ee/x3dh.dart:106-107
X3DH key material DH1 = IK_A × SPK_B, DH2 = EK × IK_B, DH3 = EK × SPK_B, optional DH4 = EK × OPK_B, concatenated. No 32×0xFF prefix x3dh.dart:115-120
X3DH KDF HKDF-SHA256(ikm = KM, salt = 32 zero bytes, info = "zipp-chat-x3dh-v1", L = 32) x3dh.dart:6,123
HKDF RFC 5869: PRK = HMAC(key = salt, msg = ikm); T(i) = HMAC(PRK, T(i-1) ‖ info ‖ i) CF/crypto/crypto_engine.dart:216-234
KDF_RK HKDF(ikm = DH output, salt = RK, info = "zipp-chat-dr-ratchet-v1", L = 64) → [0:32] new RK, [32:64] chain key CF/e2ee/double_ratchet.dart:6,74-77
KDF_CK MK = HMAC-SHA256(key = CK, 0x01); next CK = HMAC-SHA256(key = CK, 0x02) double_ratchet.dart:80-84
Sender init Fresh DHs; (RK, CKs) = KDF_RK(SK, DH(DHs, SPK_B)); DHr = SPK_B double_ratchet.dart:86-106
Receiver init RK = SK; DHs = its own SPK key pair; DHr = none double_ratchet.dart:108-126
Ratchet header UTF-8 JSON {"dh":<b64 public key>,"pn":<int>,"n":<int>} — this key order, no whitespace double_ratchet.dart:131-132
Message AEAD XChaCha20-Poly1305-IETF, key = MK, random 24-byte nonce, AD = the header bytes exactly as received (never re-serialised). Wire: nonce(24) ‖ ciphertext ‖ tag(16) double_ratchet.dart:133-149
MAX_SKIP 1000 per chain (Nr + 1000 < until → reject), applied to pn and n. No overall cap; skipped keys do not expire. Stored as "<dh b64>:<n>" double_ratchet.dart:7,152-164
X3DH header JSON {"eph","spk_id","otp_id"(0 = none),"did","ik"(sender's Ed25519 IK)}. Receivers take IK_A from ik, and ignore did and spk_id x3dh.dart:58-64; CF/dm/dm_manager.dart:320-379
Base64 Standard alphabet, padded, everywhere. Key-server uploads also accept raw and URL-safe; backup and DM endpoints accept standard only keyserver bundle/handler.go:20-32
Bundle response A JSON array, one item per device: device_id, identity_key, signed_prekey, signed_prekey_id, signed_prekey_signature, and flat one_time_prekey + one_time_prekey_id, omitted (not null) when none is left. ?consume=false does not consume one. 404 when the user has no devices bundle/model.go:74-82; service.go:89-122
One-time prekeys Consumed oldest first. Uploads take 1-100 keys of 32 bytes; a duplicate id is silently ignored. The reference client tops up to 50 when fewer than 10 remain, on login bundle/repository.go:105,120-134; CF/e2ee/key_manager.dart:48-49,102-108
Device id Generated by the client: 32 lowercase hex characters, sent as device_id on login and register. The server accepts 8-64 of [A-Za-z0-9_-], defaults to "default", and returns it as the JWT did claim core/device/device_identity.dart:66-69; Server/pkg/middleware/device.go:15-22
DM envelope ciphertext = nonce ‖ ct for the primary peer device; ratchet_header = header JSON bytes; x3dh_header = X3DH JSON bytes or null; device_ciphertexts[] = {device_id, ciphertext} where each non-primary ciphertext is base64 of UTF-8 JSON {"ct":b64,"hdr":b64,"x3dh":{…}?} (so double-base64 on the wire, and x3dh is an embedded object here but bytes at top level). Primary = first peer device, sessions before fresh claims dm_manager.dart:116-150; data/backend/backend_api.dart:1366-1378
Own-device copy Key = HKDF(ikm = the 64-byte Ed25519 secret, salt = 32 zero bytes, info = "zipp-dm-self-v1", L = 32); crypto_secretbox (XSalsa20-Poly1305), nonce(24) ‖ ct, filed under the sender's own device_id (falling back to "default"). One key per identity, not per channel dm_manager.dart:22-25,61-62,439-468
Sessions Keyed by (peer user, channel, peer device). X3DH runs only when no session exists — a new x3dh_header on an existing session is ignored. Sessions are not in the PIN backup CF/dm/dm_session_store.dart:23-24; dm_manager.dart:318-324
Safety number fp = SHA-512(0x00 0x00 ‖ IK_pub(32) ‖ UTF-8 user id), then 5199 × fp = SHA-512(fp ‖ IK_pub); keep the first 30 bytes; six 5-byte big-endian chunks, each mod 100000, zero-padded to 5 digits. The byte-wise smaller fingerprint comes first → 60 digits, shown as 12 groups of 5. Computed per pair of devices (mine × each of theirs). User id = the server's UUID string CF/dm/safety_number.dart:21-80; dm_providers.dart:185-191
Trust Trust-on-first-use pin per (peer user, peer device), local only. A changed key blocks sending and shows a placeholder on receive dm_manager.dart:189-201,336-347
PIN Exactly 8 digits; 18-entry deny list in Server/pkg/crypto/v1/pin.go:27-34 pin.go:14-34; CF/e2ee/key_backup.dart:41-46
Backup master key Argon2id(UTF-8 PIN, 16-byte salt, t = 3, m = 64 MiB, p = 1, 32 bytes) pin.go:71-78; key_backup.dart:19-27
Backup verifier Argon2id(PIN ‖ ":verifier", same salt, t = 1, m = 16 MiB, p = 1, 16 bytes), stored raw pin.go:117-119
Backup blob AES-256-GCM, empty AD, nonce(12) ‖ ct ‖ tag(16). Plaintext, big-endian: u32 len ‖ IK secret(64) ‖ u32 len ‖ SPK secret(32) ‖ u32 n ‖ n × (u64 id ‖ u32 len ‖ OTP secret). The known-answer vector in e2ee.md is checked by the client's startup self-test pin.go:196-211; CF/crypto/crypto_selftest.dart:69,235
Restore The client fetches the blob with GET /api/keys/backup and re-derives the public keys (IK = secret[32:], SPK/OTP = scalarmult_base); SPK id resets to 1. Contains keys only — no sessions, trust pins or device id key_manager.dart:155-174; device_keys.dart:95-116

Discrepancies

  1. KDF_RK info string. e2ee.md:111 says "ratchet-root"; the code uses "zipp-chat-dr-ratchet-v1". The spec value does not interoperate.
  2. X3DH header field names. e2ee.md:68-73, api-docs/api/dm.md:68, Server/docs/client-technical-design.md:1633-1637 and Server/docs/api.md:2495-2499 use ephemeral_key / signed_prekey_id / one_time_prekey_id / sender_device_id and omit the identity key. The code uses eph / spk_id / otp_id / did / ik.
  3. Where IK_A comes from. e2ee.md:84 and client-technical-design.md:1649 say the receiver fetches it from the key server; the code takes it from the header's ik.
  4. Bundle response shape. api-docs/keyserver/bundles.md:44-56, data-types.md:376-379, client/e2ee-implementation.md:111,134 and client-technical-design.md:943 show a nested one_time_prekey: {id, key} or null and no device_id. Server/docs/api.md:2013-2027 shows a single object with nulls. The server returns an array with flat, omitted-when- absent fields. (The client's own PreKeyBundle.fromJson parses the nested form; it is unused.)
  5. Bundle auth. bundles.md:40 says no auth; every key-server route needs a JWT.
  6. Verifier derivation is described four different ways (e2eefunc.md:430,433, client/e2ee-implementation.md:436-439, client-technical-design.md:1742-1754, api.md:2536). None matches the code row above.
  7. Argon2 parallelism. e2eefunc.md:20,424, server/encryption.md:164, api-docs/keyserver/backup.md:25,50,54, client/e2ee-implementation.md:457, client-technical-design.md:1008,1036,1735 and api.md:2088,2125,2535 say p = 4. The code uses 1 (libsodium supports nothing else).
  8. Salt size. api.md:2084,2534 says 32 bytes; it is 16, and the server does not check it.
  9. Backup AD. client-technical-design.md:1751 says AD = user id; it is empty.
  10. Weak-PIN list. client/e2ee-implementation.md:496-498, backup.md:75-77 and client-technical-design.md:1717-1720 differ from pin.go:27-34, which is the list in effect.
  11. Restore flow. client/e2ee-implementation.md:468, e2eefunc.md:453-473, client-technical-design.md:1020-1045 and api.md:2542 restore with POST /api/keys/backup/restore, which is deprecated; the client uses GET /api/keys/backup, which neither backup.md nor api.md documents. api.md:2102 says the server runs the Argon2id KDF (it runs only the verifier); api.md:2120 lists pin_verifier in the response (it is not returned). The deprecated endpoint answers a lockout with 429 and locked_until, not 401.
  12. Own-device copy. e2eefunc.md:373-378,549, client/e2ee-implementation.md:344-352 and client/direct-messages.md:103 describe a per-channel key from the X25519 IK with the channel id as salt, info "zipp-chat-self-v1" and an AEAD. The code row above is what runs.
  13. Multi-device payload. e2ee.md:134, e2eefunc.md:392, client-technical-design.md:1688 and client/direct-messages.md:57,164 say the message key is encrypted per device and leave the format open. The code sends a complete ratchet message per device in the envelope above.
  14. Decryption and session keying. client/direct-messages.md:98-116, client/e2ee-implementation.md:26,43 and e2eefunc.md:95 key sessions per channel and say own-device messages cannot be decrypted. Sessions are per channel and device, and copies for the sender's other devices are ratchet messages like any other.
  15. One-time prekey deletion. e2eefunc.md:191 and client/e2ee-implementation.md:144 say the receiver deletes a used OTP secret. The client keeps it, so it stays in later backups.
  16. Signed prekey rotation. e2ee.md:26,526, bundles.md:27,31,66-73, client/e2ee-implementation.md:63 and client-technical-design.md:906,922 require weekly rotation with an incrementing id. There is no rotation and the id is 1. Adding it is not a drop-in change: secure-channel epoch grants are sealed to the SPK, not the IK (CF/e2ee/epoch_keys.dart:189-190,262), contrary to e2ee.md:146,159-161 and client/e2ee-implementation.md:379-389.
  17. One-time prekey batch. e2ee.md:517,525, api-docs/keyserver/prekeys.md:30,43, e2eefunc.md:117,127,139, client-technical-design.md:175,206,971 and api.md:2449,2555-2558 say 100 keys, refilled below 20. The client refills to 50 below 10. api.md:2047-2058 describes a 500-key cap per user; there is no cap, only 100 per upload, and counts are per device.
  18. Device id origin. e2ee.md:523, e2eefunc.md:94,137 and client/e2ee-implementation.md:42 say the device id is read from the JWT. The client generates it and sends it at login (api.md:246-266 is right).
  19. Safety numbers. e2ee.md:490 and encryption-modes.md:445 say one per peer; it is one per pair of devices.
  20. What a restore rebuilds. e2ee.md:362 and e2eefunc.md:470 say ratchet sessions can be rebuilt from the backup; sessions are not in it.
  21. DM cipher. client-technical-design.md:1589 and api.md:2443 say AES-256; DMs use XChaCha20-Poly1305.
  22. Server/docs/api.md §20 (lines 2460-2523) is wrong throughout: it sends DMs to /api/channels/{id}/messages as base64 content, skips the Ed25519→X25519 conversion and the HKDF salt and info, and has the receiver compute DH1 with EK instead of IK_A. DMs use /api/dm, which api.md does not document (see api-docs/api/dm.md).
  23. Stale states. e2eefunc.md:30,32,557 say multi-device uses only bundles[0] and secure channels are not implemented; e2eefunc.md:226 gives HKDF as salt || zero32; client-technical-design.md:1667 suggests a standard Double Ratchet library, which will not interoperate (custom info strings, JSON header, SPK as the first ratchet key).
  24. api-docs/api/dm.md:83 says limit is 1-100; over 100 becomes 50. It also omits the 403 returned when either side has blocked the other.

Channel encryption

Parameters in effect

Item Value Code
Mode values persistent, secure. The rename to server_escrowed / rolling_key was decided but not done. A mode cannot be changed after creation Server migrations/000013; services/api/internal/server/model.go:41-42
Default secure — column default since 000021, and the create handler turns any value other than persistent into secure migrations/000021; server/handler.go:236-237
Message envelope (both modes) DEK = 32 random bytes. ciphertext = AES-256-GCM(DEK, UTF-8 text, AD = none). encrypted_dek = AES-256-GCM(K, DEK, AD = UTF-8 kek_version). Each is nonce(12) ‖ ct ‖ tag(16), base64 on the wire Server/pkg/crypto/v1/envelope.go:15-33, aes.go:13-28; Client lib/features/e2ee/message_crypto.dart:36-57
K, persistent The channel KEK from Vault KV v2 secret/data/channels/<id>/kek/v1; kek_version is always "v1" (created lazily, not rotated) message/kek.go:59-80
K, secure The 32-byte epoch key; kek_version = "secure", plus epoch_id, which must be the newest epoch (400/409 otherwise) backend_providers.dart:1100,1263; message/service.go:99-111
Escrow key delivery (persistent) GET /api/channels/{id}/escrow-key → crypto_box_seal(KEK, the device's signed prekey) channel/service.go:63-113
Epoch grants (secure) crypto_box_seal(epoch key, the device's signed prekey) — the SPK, not the identity key — one per (user, device) CF/e2ee/epoch_keys.dart:255-265,349-358; migrations/000015
member_hash hex(SHA-256(concatenation of the sorted server member ids)) channel/service.go:312-317,361
Rotation (POST /api/channels/{id}/epoch) Requires epoch_num = current + 1 (409 otherwise), a matching member_hash, and a grant for every published device of every member. Clients rotate on MEMBER_JOIN / MEMBER_LEAVE and at channel creation; there is no server-side rotation channel/service.go:331-407; CF/gateway/gateway_providers.dart:358-415
Grant top-up A client that opens the current epoch seals it for members' devices that lack a grant epoch_keys.dart:160-176; channel/service.go:489-537
Old epochs GET /api/channels/{id}/epoch/{epochID} returns only existing grants; each message is opened with its own epoch_id channel/service.go:452-484
Moderation decrypt POST /api/messages/{id}/decrypt {reason} — Administrator only, refused for kek_version == "secure", audit row written first message/service.go:241-306
Attachments The same envelope under the channel or epoch key. The thumbnail (256 px JPEG, quality 75) is sealed under the same DEK. POST /api/media/sealed (JSON, base64); the download returns raw ciphertext with X-Encrypted-DEK, X-KEK-Version and X-Original-Content-Type headers backend_providers.dart:1146-1205; media/handler.go:413-510
Media limits 50 MB per file, 64 KiB thumbnail, declared thumbnail dimensions ≤ 512; per-user quota MEDIA_USER_QUOTA_BYTES (default 1 GiB, ciphertext only, thumbnails exempt) → 507 media/model.go:124-142; config/config.go:57
Avatars and banners Encrypted by the server under the media KEK (secret/media/kek), AD "media-dek" / "media-file" / "media-thumb", thumbnailed server-side media/service.go:238-288,453-480
Search chunk JSON {"v":1,"p":{token:[message ids]},"x":{message id: preview ≤ 160 chars}}, sealed with the message envelope. chunk_key = current epoch id (secure) or the upload time's YYYY-MM (persistent). Optimistic version counter, 409 on conflict, expected_version: 0 creates. Tokens: lower-cased, split on [^a-z0-9]+, length ≥ 2. Hits are verified by decrypting the messages (?ids=, up to 100) and requiring every query token search_index.dart:29-39,80-84; search_manager.dart:49-70,185-219
Voice room key 32 random bytes generated by the voice service, stored in Vault at secret/channels/<id>/voice_key, returned as room_key to anyone with Connect; replaced only when LiveKit reports room_finished voice/service.go:68-113,227-232
Voice frame encryption LiveKit BaseKeyProvider defaults: shared key, key index 0, setRawKey(base64Decode(room_key)), ratchet salt "LKFrameEncryptionKey", ratchet window 16, key ring 16. On web the frame key is PBKDF2-SHA256 (100 000 iterations) → AES-GCM-128. A join aborts if room_key is empty or the E2EE manager does not start CF/voice/voice_controller.dart:196-236

Discrepancies

  1. Secure-channel cipher. e2ee.md:209-237 and client/messaging.md:8,28-39,225-233 specify crypto_secretbox with content = base64(nonce ‖ ct), and e2ee.md:30-31 XChaCha20-Poly1305 for epoch and file keys. Both modes use the AES-256-GCM envelope above.
  2. What grants are sealed to. e2ee.md:146,158-161, client/messaging.md:148,185, server/encryption.md:120 and encryption-modes.md:147 say the identity key; it is the signed prekey (Server/docs/api.md:547 is right). See E2EE discrepancy 16 for why that blocks SPK rotation.
  3. encrypted_dek in secure channels. api.md:1243 (and the comment at message/model.go:107-110) says it is empty; the client sends the DEK wrapped under the epoch key.
  4. Edits after a rotation. The client encrypts an edit under the current epoch, but edit requests carry no epoch_id and the server keeps the original one, so an edit made after a rotation cannot be decrypted by readers. client/messaging.md:102 describes the intent.
  5. History for new members. encryption-modes.md:84 and e2ee.md:241 say a joiner gets none. Through grant top-up a joiner receives the current epoch, and so can read messages since the last rotation — which only happens when a member's client is online to do it (e2ee.md:197-207 half-says this).
  6. Voice encryption is undocumented, and described wrongly where it is mentioned: api.md:1586 calls the key "AES-256"; the frame key is AES-128. Nothing in ProjectInfo/ says the server generates and stores the key. client/voice.md:184 says LiveKit refuses publishing without Speak; the token always grants publishing.
  7. encryption-modes.md §9 is stale: per-device identities (000018), client-side thumbnails and the moderation decrypt route all exist. The §2 divergence table (lines 31-41) no longer applies, and §4 line 171 gives Argon2 p = 4 (it is 1). Lines 191-195 and 208-210 say server thumbnailing was removed; it remains for avatars and banners.
  8. Media docs. client/media-files.md, server/encryption.md:15-16,152 and api-docs/media/* describe key_type public/dm, public_dek, media_file_grants and XChaCha, none of which exist (key_type is server / channel, migration 000014). Server/docs/client-media.md:60,93,310 documents only the plaintext upload path; POST /api/media/sealed and GET /api/media/sealed/{id} are undocumented (only /sealed/thumb is, at api.md:1829). api.md:1817 says thumbnail dimensions are recorded; they are stored as NULL.
  9. Plaintext reads. client/messaging.md:7,47-49 and server/encryption.md:61 say the server returns plaintext; reads return only the envelope. (The deprecated plaintext content field is still accepted on send.)
  10. Stale code comments: server/model.go:42 ("channel key per member") and message/kek.go:8-9 ("server decrypts at read time").

REST API

Server/docs/api.md is the canonical REST reference and is accurate on encryption, devices, 2FA, push and sealed media. It does not yet document:

  • direct messages — /api/dm, /api/dm/{id}, /api/dm/{id}/messages, /api/dm/{id}/messages/{mid} (documented in api-docs/api/dm.md);
  • friends (5 routes) and blocks (3) under /api/users/@me/{friends,blocks};
  • bans (3); read state (GET /api/servers/{id}/read-states, GET/PUT /api/channels/{id}/read-state);
  • GET/PUT /api/channels/{id}/keys and POST /api/channels/{id}/epoch/{eid}/grants (mentioned at api.md:582);
  • GET /api/users/search, PATCH /api/auth/password, DELETE /api/users/@me, POST /api/reports/csam;
  • POST/GET /api/media/sealed and the older /media/* routes;
  • POST /api/auth/refresh has no section, and the register and login examples omit refresh_token, which both return. Login may instead return {two_factor_required, two_factor_token}.

Older notes under api-docs/ that are wrong and not published:

  • api-docs/api/channels.md:33,37 and messages.md:5-9 use the design names server_encrypted | rolling_key | peer_provided and a plaintext content send body. The wire values are persistent and secure, and any other value is silently treated as secure (server/handler.go:236-237). The send body is {ciphertext, encrypted_dek, kek_version, epoch_id, reply_to_id, attachment_ids}; content is deprecated.
  • api-docs/api/voice.md:19-24 documents room_name and no room_key. The response is {token, livekit_url, room_key}; a client built from that page would join calls unencrypted.
  • api-docs/api/device-tokens.md — the table was dropped in migration 000022; push uses /api/push/*.
  • api-docs/keyserver/bundles.md — see E2EE discrepancies 4 and 5.
  • api-docs/media/upload.md and files.md — the legacy upload flow, a nonexistent GET /media/raw/{key}, and a typo'd /api/channels/{}/msgs.
  • api-docs/api/auth.md — no 2FA, no device_id, and advice to keep tokens in localStorage.
  • api-docs/api/friends.md:70 documents POST /api/users/{id}/friends, which does not exist.

Gateway

websocket.md is not published: it lists 10 of the 23 events, with wrong scopes and shapes. The protocol in effect:

  • Connect: GET /gateway, JWT in the Authorization header or ?token=. A bad token gets 401 before the upgrade. Frames are at most 4 KiB; a slow client's 256-frame buffer drops silently.
  • Ops, client → server: subscribe, unsubscribe, typing, heartbeat. Server → client: hello (no data), event ({"op":"event","type":…,"data":…}), heartbeat_ack, error (services/api/internal/gateway/events.go:11-25).
  • Liveness is WebSocket ping/pong: the server pings every 54 s and drops the connection 60 s after the last pong. The application-level heartbeat only gets a heartbeat_ack back and does not keep a connection alive — websocket.md:57, server/gateway.md and client/gateway.md all say it does.
  • No resume, no session id. On every hello, discard cached messages, refetch over REST and subscribe again.
  • user.<your id> is subscribed automatically before hello; channels, servers and DMs need an explicit subscribe. unsubscribe ignores server_ids.
  • Events by subject. Channel: MESSAGE_CREATE, MESSAGE_UPDATE, MESSAGE_DELETE, REACTION_ADD, REACTION_REMOVE, PIN_UPDATE, TYPING_START, VOICE_STATE_UPDATE. Server: CHANNEL_CREATE, CHANNEL_UPDATE, CHANNEL_DELETE, ROLE_UPDATE, MEMBER_UPDATE, MEMBER_JOIN, MEMBER_LEAVE. User: DM_OPEN, FRIEND_REQUEST, FRIEND_ACCEPT, FRIEND_REMOVE. PRESENCE_UPDATE and USER_UPDATE go to the user and every server they share. MESSAGE_* carry the full REST message object.
  • server/gateway.md:93-99 gives the connection lifecycle in the wrong order and :123 the wrong Publish signature. client/gateway.md:138 says MESSAGE_CREATE carries encryption_mode; it does not.

Push notifications

push-notifications.md is canonical for the design, with these differences from the implementation:

  • PUT /api/push/registration returns {transport, encrypted, created_at, updated_at}; the encrypted field is not in the spec. POST /api/push/drain returns [] for an unregistered device. Device id default is rejected on every push route.
  • The spec says the client fetches from first_message_id; the client fetches the latest 50 messages per channel and slices.
  • UnifiedPush without encryption keys is described as acceptable; the client drops wakes it cannot decrypt (push_platform_io.dart:150).
  • The UnifiedPush instance name is zippchat:<apiBase>.
  • FCM SENDER_ID_MISMATCH is also treated as a dead token.
  • Operator variables: FCM_PROJECT_ID (api and worker), FCM_CREDENTIALS_JSON and PUSH_UP_ALLOW_PRIVATE (worker). server/deployment.md:118-122 lists FCM_CREDENTIALS_FILE and APNS_*, which nothing reads.
  • api-docs/api/device-tokens.md, server/services/worker.md:40-77 and server/database.md:438 describe the APNs device-token design that was replaced.

For reference, the wake payload is the constant {"v":"1","t":"wake"}. UnifiedPush wakes are RFC 8291 aes128gcm when the registration has keys, with TTL: 3600, Topic: wake and Urgency: high|normal; FCM wakes are data-only with collapse_key "wake", TTL 3600 s and priority HIGH or NORMAL. Priority is high when any pending message is a DM. Wakes coalesce per (registration, channel): at most one outstanding per 15 s, a re-wake after 15 minutes, and the registration is dropped after 10 failures.

Deployment docs

  • development.md (Tauri, npm, MinIO, VITE_*) and deployment.md (a nonexistent scripts/deploy.sh, a removed db-migrator Job, the wrong media KEK path — it is secret/media/kek — and Forgejo CI, where CI is Drone) are stale and not published.
  • server/deployment.md:149-166 says Vault is initialised separately; the docker-compose.yml it describes runs Vault in -dev mode and loses every key on restart.
  • Server/docs/production-setup.md (kubeadm, Redis 7, MinIO) and Server/DEPLOY.md:10 are stale; Server/docs/deployment-k3s.md is the deploy guide.