chat / docs / PROTOCOL.md
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
# sueta p2p protocol — v2

The contract between the browser client (`client/src/sueta/` plus the
`ardegazu.rooms.*` core from rooms-kit) and the network it
runs on: a libp2p **circuit relay** (any willing node; ird's managed relay in
production, `deploy/relay/` for dev/self-host) plus an OrbitDB replicated log.
Everything here is app-agnostic except the **app salt**, which namespaces each
application built on this stack.

v2 replaced the v1 custom WebSocket signaling + datachannel mesh wholesale.
There is no version negotiation: v1 and v2 clients opening the same link land
in disjoint id spaces (the salt forks every derivation) and never see each
other. Room links themselves are version-free — the fragment secret carries
no version — so links survive the cutover. The last /ws-protocol release is
tagged `v1-ws-final`.

## 1. Identities & secrets

| Thing | Form | Lifetime | Who sees it |
|---|---|---|---|
| room secret | 32 random bytes, base64url unpadded (43 ch) in the URL **fragment** `#<secret>` | as long as the link is shared | only room members (fragments never leave the browser) |
| `roomId` | HKDF(secret, info=`"roomid"`) → 32 bytes → base64url (43 ch) | derived | local namespacing (storage keys, AADs) |
| room topic | `"sueta/2/" + b64url(HKDF(secret, info="topic|room"))` | derived | relay + connected peers (gossipsub subscriptions are visible; one-way) |
| db name | b64url(HKDF(secret, info=`"dbname"`)) | derived | becomes the OrbitDB address (below) |
| `peerId` | fresh libp2p Ed25519 key per page load → base58 PeerId (`12D3Koo…`) | one session | relay + every libp2p peer it meets |
| identity seed | 32-byte Ed25519 seed, base64url (43 ch), `localStorage["sueta:id"]` | permanent | only room members (§7b) |
| app salt | UTF-8 string, e.g. `"chat.ardegazu.ro/v2"` | constant per app | baked into the client |

The network `peerId` is deliberately ephemeral — the relay cannot link a
person across page loads. The durable identity travels only inside encrypted
payloads and log entries. Different apps on the same relay use different
salts, so identical secrets never collide across apps.

## 2. Key derivation (WebCrypto)

```
IKM  = importKey("raw", secretBytes, "HKDF")
HK(info, salt=UTF8(appSalt)), all HKDF-SHA-256:
  roomId    = deriveBits(info="roomid")        -> 32 bytes -> base64url
  roomTopic = deriveBits(info="topic|room")    -> 32 bytes -> "sueta/2/"+base64url
  dbName    = deriveBits(info="dbname")        -> 32 bytes -> base64url
  kSig(P)   = deriveKey (info="signal|"+P)     -> AES-GCM-256, non-extractable
  kMsg(P)   = deriveKey (info="msg|"+P)        -> AES-GCM-256, non-extractable
  kEntry    = deriveKey (info="log-entry")     -> AES-GCM-256, non-extractable
  kPayload  = deriveKey (info="log-payload")   -> AES-GCM-256, non-extractable
  kImg      = deriveKey (info="img")           -> AES-GCM-256, non-extractable
  mbxRoom   = deriveBits(info="mailbox|room")  -> 32 bytes -> base64url      (§9)
  kMbx      = deriveKey (info="mailbox-blob")  -> AES-GCM-256, non-extractable (§9)
```

Two key families with different lifetimes:

- **Session keys** `kSig(P)`/`kMsg(P)` encrypt LIVE traffic **sent by** peer
  `P` (P = the base58 libp2p PeerId). Per-sender and per-session — no two
  parties ever encrypt under the same key, so counter IVs are safe (§3).
- **Room-wide at-rest keys** `kEntry`/`kPayload`/`kImg`/`kMbx` encrypt the
  replicated log (and mailbox-sealed blobs, §9). Any member must decrypt
  entries whose author's session is long gone, so these are shared — and
  therefore use random IVs (§3).

## 3. Sealing (AES-GCM)

**Sealer** (session keys): IV = `4-byte random epoch || 8-byte BE counter`.
A Sealer owns (key, epoch, counter); no API accepts a caller-supplied IV —
nonce reuse is structurally impossible.

**AtRestCipher** (room-wide keys): IV = 12 random bytes per encryption. Safe
by the birthday bound for far more entries than a room will ever hold
(collision ≈ N²/2⁹⁷; NIST's cap for random IVs is 2³² messages). Same
no-caller-IV discipline.

### Envelopes

```json
{ "v": 1, "iv": "<b64>", "ct": "<b64, ciphertext||tag>" }   // live JSON (Sealer)
```

```
[ 0x01 | 12-byte IV | ciphertext||tag ]    // live binary (Sealer)
[ 0x02 | 12-byte IV | ciphertext||tag ]    // at-rest (AtRestCipher)
```

### AAD (associated data)

| Channel | AAD |
|---|---|
| call signaling (kSig) | `UTF8("v1|" + roomId + "|" + from + "|" + to + "|sig")` |
| live payloads (kMsg) | `UTF8("v1|" + roomId + "|" + from + "|msg")` |
| log entries (kEntry) | `UTF8("v2|" + roomId + "|entry")` |
| log payloads (kPayload) | `UTF8("v2|" + roomId + "|payload")` |
| image blobs (kImg) | `UTF8("v2|" + roomId + "|img")` |
| mailbox-sealed blobs (kMbx) | `UTF8("v2|" + roomId + "|mbx")` |

AAD binds ciphertexts to room, sender, direction/domain — replay across
rooms/peers/contexts fails authentication. **Any decryption failure ⇒ drop
silently.** This one rule is the whole trust model (see §4b, §6b).

## 4. Network: libp2p

Stack: `webSockets` (to the relay) + `webRTC` (browser↔browser) +
`circuitRelayTransport`, `noise` encryption, `yamux` muxing, `gossipsub`,
`identify`, `ping`. The client listens on `/p2p-circuit` and `/webrtc`.

### Joining a room

1. Dial the relay's multiaddr (baked default, overridable via
   `VITE_RELAY_MULTIADDR`; refreshed at boot from the relay host's
   `/.well-known/ap2p` descriptor when reachable).
2. Subscribe to the shared **peer-discovery topic**
   (`_peer-discovery._p2p._pubsub`) — the one topic the relay itself forwards —
   and to the room topic.
3. Dial every discovered peer over the bare circuit
   (`<relay>/p2p-circuit/p2p/<peer>`) — no ICE, no TURN allocation, no
   candidates revealed. On contact each side sends a DIRECTED sealed presence
   beacon over the msg stream (§4c; directed streams run on limited
   connections, gossipsub does not): decrypting it is the membership proof.
   Only once a peer proves membership (§4b) is the WebRTC upgrade dialed
   (`…/p2p-circuit/webrtc/p2p/<peer>`); its ICE uses the same TURN
   credentials as calls, so the upgrade succeeds even behind hard NATs.
4. Gossipsub then runs **member↔member over the direct connections only**:
   the relay does not forward room topics, and gossipsub never binds to
   circuit connections — so an established mesh (and any call) keeps flowing
   if the relay dies. While a pair is still circuit-only (the transient
   `relayed` state — or a lasting one if the upgrade can't complete: hard
   NAT with TURN down), their DIRECTED frames (presence handshake, messages,
   call signaling) transit the relay as opaque noise-encrypted circuit
   traffic — contents unreadable, volume/timing visible to the operator,
   bounded by the relay's circuit limits (library defaults: 2 min / 128 KiB
   per relayed connection); topic broadcasts require the direct link.

### 4a. Presence (replaces the v1 roster frames)

Presence beacons ride the room topic, sealed with `kMsg`:

```ts
{ kind:"presence", op:"beacon"|"bye", name, ts }   // beacon every 10 s + on join/wake
```

Roster rule: a peer is *present* iff something it sent decrypted within the
last 30 s. Gossipsub's subscriber lists are used only as dial hints — a
subscription is a claim, membership proof is decryptability.

### 4b. Trust note (v1 §3 rule, transplanted)

The relay (or any client of it) can introduce arbitrary peers — discovery is
relay-wide, so clients briefly dial peers from other rooms of the same app
(managed relays are dedicated per app, confirmed by the operator; a shared
self-hosted relay may also introduce other apps' peers). Those dials are
**circuit-only** (§4 step 3): a stranger never receives ICE candidates (no
IP exposure beyond what the relay already sees) and costs no TURN allocation
— under `&relay` this is what keeps stranger dials from exhausting the
per-user coturn quota and starving call media. A peer that produces no
decryptable payload within **20 s** of first contact is disconnected and
forgotten. Conversely, ANY payload that decrypts under the room keys is proof
of membership (this also covers directed messages arriving before the
sender's first beacon) — and triggers the WebRTC upgrade.

### 4c. Directed streams

`/sueta/2/msg/1.0` — length-prefixed frames `[0x00|JSON envelope]` (kMsg) or
`[0x01|binary envelope]`; one persistent outbound stream per (peer, protocol).
`/sueta/2/call-sig/1.0` — call signaling (§7c): frames are kSig-sealed JSON
envelopes of the v1 `SignalPayload` shape (`offer`/`answer`/`ice` + name).

### 4d. Resilience

- Relay loss never touches member↔member connections or calls — only new joins
  need it. The client redials with jittered backoff (500 ms · 2ⁿ, cap 15 s),
  pings the relay every 25 s (2 failures ⇒ hang up + redial), and re-kicks on
  visibilitychange/pageshow/online.
- Frames over 256 KiB are rejected; per-connection limits are the relay
  operator's business.

### TURN credentials

`GET <turn-credentials-url>` (default `https://signal.ardegazu.ro/turn-credentials`,
overridable via `VITE_TURN_CREDS_URL`) → coturn REST scheme, unchanged from v1:

```json
{ "username": "<opaque>", "credential": "<opaque>", "ttl": 3600,
  "urls": ["stun:…", "turn:…?transport=udp", "turn:…?transport=tcp"] }
```

Used by BOTH the libp2p WebRTC transport (connection upgrades) and the call
media pcs. `#<secret>&relay` forces `iceTransportPolicy:"relay"` everywhere.

## 5. The replicated log (OrbitDB events DB on Helia)

Everything durable — messages, threads, reactions, images — is an operation
appended to one encrypted append-only log per room and folded into UI state
identically on every member. **Replication IS history sync**; the device
blockstore (IndexedDB) IS persistence. Reopening a room offline replays your
device's copy; joining replays other members'. A room's history survives as
long as any member's device holds it.

### Deterministic address

Every member independently computes the same OrbitDB address:

```
db = orbitdb.open(dbName, { type: "events",
                            AccessController: IPFSAccessController({ write: ["*"] }),
                            encryption: { replication: kEntry, data: kPayload } })
```

The address is the CID of the manifest `{name, type, accessController}` — a
pure function of the secret-derived `dbName` and a constant AC — so create and
open converge with no address exchange. (The AC must be passed explicitly; the
default embeds the creator's identity and would fork addresses.)

### Access control = encryption

`write:["*"]` + decrypt-or-drop: the `replication` hook seals the ENTIRE entry
(clock, author key, identity ref, signature, links, payload) with `kEntry`
before it becomes a content-addressed block; `data` additionally seals the
payload with `kPayload`. Only secret-holders can produce entries members
accept — a non-member who learns the address can fetch and push opaque blocks,
but its entries fail AEAD and are rejected. Entry hashes are computed over
ciphertext, so ids are stable across replicas and dedupe is free.

The access controller is a hardened wrapper around OrbitDB's IPFS controller
(`ardegazu.rooms.lib.access` —
`rooms-kit/src/ardegazu/rooms/lib/access.cljs`): upstream verifies the entry
signature against `entry.key`
and the referenced identity's internal consistency separately, but never binds
the two, so one member could author entries attributed to another member's
identity. The wrapper additionally requires `entry.key` to BE the referenced
identity's signing key and rejects the entry otherwise. The wrapper's manifest
address is byte-identical to the vanilla controller's ({type:"ipfs", write} is
all that's hashed), so hardened and older clients open the same database;
`write` must stay `["*"]` or the address forks. Clients older than v13 accept
forged-authorship entries and render the forged badge — upgrade.

### Operations (post-decryption payloads)

Message id = entry hash. `thread`/`target` reference entry hashes.

```ts
{ t:"chat",  ts, name, text, thread? }
{ t:"img",   ts, name, cid, mime, bytes, w, h, thread? }   // cid → encrypted unixfs blob
{ t:"react", ts, name, target, emoji, op:"add"|"remove" }
{ t:"name",  ts, name }                                     // reserved
{ t:"call",  ts, name }                                     // "started a call" system line
{ t:"legacy", ts, msgs:[{lid, from, name, ts, text?, thread?, rx?}] }  // one-shot v1 import
```

- **Ordering**: rendered by `(ts, entryHash)` — deterministic on every replica
  with no coordination. Lamport clocks are used for exactly one thing:
- **Reactions** fold per `(author, target, emoji)` by `(clock, hash)`,
  last-op-wins. A single author's ops are causally ordered, so removals
  replicate exactly like adds (v1's one-shot sync could resurrect removed
  reactions; that bug is structurally gone).
- **Images**: sender re-encodes on canvas (HEIC → JPEG/WebP, EXIF+GPS stripped,
  ≤1600 px, ≤2 MiB), seals the blob whole with `kImg`, stores it as unixfs
  blocks, pins, and logs the CID. Members fetch over bitswap — from anyone who
  has it, no live sender needed. Each device keeps the newest **20** images
  per room fetched+pinned; older ones are unpinned and render as expired
  placeholders (the log entry with dimensions remains).
- **Legacy import**: on first v2 open, a device holding v1 history publishes it
  as `legacy` ops with ids `"v1:"+lid` — racing devices' imports fold into
  no-ops. v1 image blobs are not migrated. ⚠ Injection surface: `legacy` ops
  are accepted from ANY member at ANY time and render as badge-less, freely
  backdated lines — a member can fabricate "pre-v2 history" forever. Apps
  built on this stack that never had a v1 (or are past their migration
  window) should drop the `legacy` op from their projector entirely.
- Bounds: 1000 messages in memory; initial replay = newest 500 entries; rooms
  are LRU-bound by the room list (50). Text entries are small; the log grows
  append-only by design.

## 6. Live payloads (non-logged, on the room topic / msg streams)

Ephemeral things that must not enter the log:

```ts
{ kind:"presence", op:"beacon"|"bye", name, ts }            // §4a
{ kind:"hello", name, idPub?, idSig? }                       // directed, on peer-ready
{ kind:"name",  name, idPub?, idSig? }                       // broadcast on rename
{ kind:"call",  op:"join"|"state"|"leave", audio, video, seq, ts, name }  // §7c
{ kind:"roomsync", rooms:[{s,label,ts,lts?}], gone:[{s,ts}] } // directed ONLY, see below
```

**roomsync — same-identity room-list sync.** Two devices holding the same
Ed25519 identity seed sync their room directories when they meet in a shared
room. Trigger: a peer's verified `hello`/`name` assertion (§7b) carries an
`idPub` equal to our own — that proof binds the identity key to the sender's
noise-authenticated session PeerId, so a member cannot replay another device's
assertion. Each side then sends one directed `roomsync` per peer session.

Rules:

- **Directed only, never broadcast**: room secrets ride the pairwise-encrypted
  msg stream. A broadcast would be decryptable by every room member; the relay
  only ever sees noise ciphertext (§4).
- **Identity-gated receive**: accepted only from a peer whose *verified*
  assertion pub equals our own (awaiting any in-flight verification), at most
  once per peer session. Anything else is dropped silently.
- **Validated field by field**: 43-char base64url secrets, 40-char labels
  (control chars stripped), timestamps clamped to `now + 2 min`; fresh objects
  are rebuilt — nothing received is persisted as-is. Caps: 50 rooms, 100
  tombstones.
- **Merge**: union by secret; visits resolve by newest `ts`, labels by newest
  `lts` (set on rename, so a rename beats a mere visit). Forgetting a room
  writes a tombstone `{s, ts}`: a tombstone newer than an entry's last visit
  removes it everywhere; an entry newer than a tombstone deletes the
  tombstone; the currently-open room is never tombstoned (being there is a
  rejoin, which bumps `ts` above the tombstone to defeat clock skew).

Consequence: the identity seed now grants the room list too — anyone holding
the seed who shares one room with one of your live devices learns your other
rooms. This is a deliberate extension of the seed's existing power (it already
grants your authorship everywhere).

**Second transport — the sealed self channel.** The same payload also rides
ardegazu-social-kit's `fsync` envelope (its `app` section, ns-gated) through
the identity's own mailbox inbox: after any local list change the client
deposits its merged list (debounced, hash-gated, 7-day TTL), and other devices
holding the seed fold it on their next boot — so devices that never meet in a
live room still converge. Same validation and merge as above; the envelope is
signed-then-sealed to the identity's own keys (secrets never reach anyone
else), and the kit (≥1.2.0) applies an fsync only when it was sent by the
receiving identity itself — a stranger sealing into the publicly-derivable
inbox is dropped. The directed in-room `roomsync` stays as-is.

## 7b. Persistent identity (Ed25519)

Same seed, same fingerprints, same TOFU as v1 — with authorship upgraded:

- The 43-char seed lives in `localStorage["sueta:id"]` / a password manager.
- **Log authorship**: a custom OrbitDB identity provider (type `"sueta"`) makes
  every log entry chain to the durable key — the provider id IS the base64url
  public key. Entry signatures are verified by every replica before an entry
  is accepted, AND (v13+) the entry's signing key must be the referenced
  identity's own key (§5) — without that binding any member could forge
  another member's authorship. Identity refs travel inside encrypted entries;
  the identity *records* themselves are plaintext content-addressed blocks
  whose confidentiality rests on their hashes being learnable only from
  decrypted entries. `mine` rendering follows the key, not the device.
  Without a sueta identity, entries chain to OrbitDB's default per-device
  `publickey` identity: stable per-device authorship (working `mine`/reaction
  semantics) but no fingerprint badge or TOFU.
- **Session binding** (roster): `hello`/`name` carry
  `sig = Ed25519(seed, UTF8("sueta-id|v2|" + appSalt + "|" + roomId + "|" + peerId))`
  binding the durable identity to this session's libp2p PeerId. Cannot be
  replayed into another app, room, or session.
- Fingerprints: first 24 bits of SHA-256(pub) as 4 emoji, full hex for careful
  comparison. TOFU per display name with loud key-change warnings; human
  out-of-band verification; a ✓ that follows the key, stored locally.
- No identity ⇒ everything still works, rendered identity-less.

## 7c. Room calls (audio/video)

One call per room. Membership/mute state are live payloads (§6). Media does
NOT ride libp2p (its WebRTC transport is data-only): each in-call pair runs a
dedicated **media-only RTCPeerConnection** — own ICE/TURN, DTLS-SRTP — whose
offers/answers/ICE travel as kSig-sealed envelopes over
`/sueta/2/call-sig/1.0`. Consequences:

- An established call keeps flowing if the relay — or the whole libp2p path —
  dies; signaling is only needed to (re)negotiate.
- Perfect negotiation, politeness = smaller base58 PeerId; one transceiver per
  kind per pair reused forever; mute = `replaceTrack(null)` (no renegotiation);
  full detach flips transceivers inactive; `failed` → `restartIce()` then
  deterministic rebuild by the smaller PeerId after 10 s.
- Tracks attach only between pairs where BOTH announced membership — room
  members outside the call exchange zero media.
- Media has no second app-layer envelope (same as v1): its E2E property is
  per-pair DTLS-SRTP. Membership/mute state IS app-layer sealed.
- Limits: UI warns past 8 in a call, caps cameras at 4, video senders capped
  at 450 kbps. The MEDIA pc's own candidate pair (not libp2p's) decides the
  relayed-pair cap.

## 8. What the infrastructure can and cannot see

**The relay** sees: your IP, an ephemeral PeerId per page load, gossipsub
subscription topic strings (the opaque room topic and the DB address), and
noise-encrypted frames during joins — plus, for pairs stuck in the `relayed`
state (§4 step 4), the volume/timing of their circuit traffic. It can never
read presence, messages, names, SDP, or blocks — everything inside is
app-layer sealed on top of transport encryption. With the offline mailbox off
it stores nothing; with it on (§9), the node additionally holds the room's
mailbox blobs — sealed entry/image/identity blocks it cannot read — for up to
the retention window (currently 168 h / 512 MB per room), and sees the
mailbox room id (its own derivation, unlinkable to the room topic or DB
address without the secret), blob counts/sizes/timing, and the depositing/
replaying IPs.

**Other clients of the same relay** (new in v2): members of OTHER ROOMS of
this app see ephemeral PeerIds on the discovery topic and may be briefly
dialed by you (the managed relay is dedicated per app, so no other apps ever
appear; on a shared self-hosted relay, other apps' clients would too). Dials
to them stay on the relay circuit — they never receive your ICE candidates,
so they learn nothing beyond your ephemeral PeerId: no decryptable payload,
no IP, dropped in 20 s (§4b).

**A non-member who learns the DB address** (requires a member leaking it):
can observe encrypted block counts/sizes/timing and push garbage that members
reject. Content, authorship, and structure stay opaque.

**TURN** sees encrypted packets and IPs, as in v1. **Your device** stores the
log as ciphertext blocks in IndexedDB; the room list in localStorage still
holds secrets in the clear (same honest caveat as v1 — protecting at rest
against someone holding your browser profile would add nothing).

## 9. Offline mailbox (v16)

Replication needs two members online at once — a message posted to an empty
room reaches nobody until its author overlaps with another member. The relay
node's **mailbox** closes that gap: a per-room, bearer-token-gated,
store-and-forward cache of opaque blobs with SHA-256 dedup, replay cursors,
and bounded retention (currently 168 h / 512 MB ring buffer per room). It is
a SECOND TRANSPORT for the same bytes replication carries — the log stays the
single source of truth, and the node stores only ciphertext it cannot read.

**Feature detection**: the relay host's `/.well-known/ap2p` descriptor gains a
`mailbox` block (`endpoint`, `mint_endpoint`, `max_message_kb`); absent block
⇒ feature silently off, wire-compatible both ways with v10+. Dev/self-host
override: `VITE_MAILBOX_URL` + `VITE_MAILBOX_CREDS_URL`.

**Identity toward the node**: `mbxRoom` (§2) is the server-visible room id —
its own derivation, so the node cannot correlate its mailbox store with the
gossip topics or DB addresses it also sees. Browsers mint a bearer token at
`mint_endpoint?room=<mbxRoom>&ttl=<sec>` (origin-checked, ≤24 h) and cache it
until 5 min before expiry.

### Blob envelope

One mailbox message per block, deterministic so the node's per-room SHA-256
dedup collapses every re-send:

```
[ 0x01 | CID bytes | block bytes ]            raw block
[ 0x02 | kMbx-seal( CID bytes | block bytes ) ] sealed block (identity records)
```

Raw blocks are already ciphertext: kEntry-sealed log entries (byte-identical
on every replica — dedup works across devices) and kImg-sealed unixfs image
blocks. Identity records are the one plaintext block type the log references
(§7b): deposited raw they would hand the node the durable Ed25519 pubkey, so
they ride sealed under `kMbx` (random IV ⇒ never deduped; one ~300 B blob per
author-device per room, sent once). The CID is length-self-delimiting
(`CID.decodeFirst`); a replayer MUST re-hash the block bytes against the CID's
multihash (sha2-256 only) before anything touches its blockstore.

### Deposit rules

- **Author-only**: each device deposits exactly what it created — its own
  entries (as appended, from log storage, verbatim), its own images' DAG
  blocks, and its own identity record (once). Nobody re-uploads others'
  history; the ring buffer holds each block once regardless.
- Deposits flow through a persistent queue (localStorage, uncapped — it holds
  only this author's own undeposited items, and the earlier ≤500 drop-oldest
  silently discarded the oldest of them; a failed queue persist is surfaced to
  the user instead of swallowed) so an author who posts offline — or closes
  the tab before the upload lands — deposits on the next visit. Server dedup
  absorbs re-sends.
- Flush is serialized with exponential backoff (1 s·2ⁿ, cap 5 min) on
  429/507/network; 413 drops the item (and its image group); 401/403 re-mints
  once. Wake kicks: page show / online / visibility, and after every replay.
- When the mailbox is on, images are chunked to fit one blob per frame:
  `fixedSize(min((max_message_kb − 1) · 1024, 61440))`. Only affects new
  images; the CID travels in the entry either way.

### Replay rules

- Cursor per room+device (`after=<seq>`); absent ⇒ replay from the beginning
  (new-device bootstrap). `seq` is an opaque server-ordered string — never
  numeric-parse it; compare by (length, lexicographic). The cursor advances
  only past fully-processed pages; re-processing is idempotent.
- **Two-phase**: every page's blocks are verified (envelope prefix → optional
  kMbx open → CID re-hash) and stored first; joins run only after the last
  page, in seq order. A join therefore never waits on the network.
- Joining uses the SAME verification as live replication: `Entry.decode` is
  decrypt-or-drop under kEntry/kPayload, and `joinEntry` re-runs the signature
  check and the hardened authorship binding (§5). A mailbox can inject nothing
  a live peer couldn't.
- Before each join, a local-only reachability pre-check walks `next ∪ refs`
  down to entries already in the log and requires every hop's block AND its
  author's identity record to be local — otherwise OrbitDB would fall back to
  bitswap (30 s/block against a peer who isn't there). Entries that fail are
  **deferred**: projected display-only into the UI (unverified-author styling,
  deduped by hash against a later real join) and kept on a persistent retry
  list, re-attempted at boot and whenever live replication delivers entries
  (the backfill that unblocks them).
- **Gap surfacing**: cursor < `oldest_seq`, or a non-empty retry list, means
  the ring buffer expired blobs this device never fetched — surface a notice;
  the history converges anyway once any member holding it comes online.
- Ingested entry blocks are pinned via the oplog storage; ingested image
  roots are pinned once their DAG is fully local (a mailbox image may have no
  online source, so unlike bitswap-fetched images they must survive gc). The
  newest-20 image window (§5) unpins them like any other image.

### What this changes about §8

Nothing about content: the node holds kEntry/kImg/kMbx ciphertext and learns
only the mailbox room id, blob sizes, deposit/replay timing, and IPs. What it
DOES change: room history (still sealed) now sits on infrastructure for up to
the retention window even while every member is offline — an operator
subpoenaed during that window can hand over ciphertext and traffic metadata,
where pre-v16 there was nothing to hand over. Members who prefer the old
property run with the mailbox disabled (it is off unless the descriptor
advertises it).

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/chat.git