chat / docs / UPGRADING-9-to-12.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
# Upgrading an app built on sueta v9 to the v12 base (libp2p + OrbitDB)

> **Historical document (TypeScript era).** This prompt is kept as history: it
> targets the v9–v12 TypeScript tree (`client/src/lib/` no longer exists), and
> the suite has since completed its migration to ClojureScript. Nothing below
> was updated for the CLJS tree — for current practice see `dev/docs/CLJS.md`
> (the migration canon) and `dev/docs/NEW-APP-PROMPT.md` (building on today's
> rooms stack).

Everything between the lines below is a **self-contained prompt**. If you built
an app on top of the sueta v9 lib (`client/src/lib/` from the v9 tree — the
custom WebSocket signaling + WebRTC mesh era), paste it into your coding agent
inside YOUR app's repository, fill in the two `<placeholders>`, and it will
migrate your app in place — preserving your app code, your payloads, and your
users' rooms. It is written for an agent, but it works as a human runbook too.

---

I built an application on top of the **sueta p2p stack, version 9** — the
app-agnostic lib copied from `client/src/lib/` of https://git.ardegazu.ro/chat.git
(custom WebSocket signaling via `Signaling`, WebRTC full-mesh via `Mesh`,
E2E crypto via `RoomCrypto`). I need you to **migrate my app to the v12 base**
(libp2p transport + OrbitDB replicated log). This is a migration, not a
rewrite: my app code on top of the lib must keep working, and my users must
keep their room links and (if applicable) their locally stored data.

My app's salt is: `<YOUR_APP_SALT, e.g. "drawings.example.org/v1">`
My app's lib copy lives at: `<PATH_TO_YOUR_COPY_OF_lib/>`

## Security continuity (what v12 keeps from v9)

- **Identities carry over untouched.** The identity is the Ed25519 seed in
  `localStorage["sueta:id"]`; v12 reads the same seed → same public key, same
  fingerprints, and the TOFU/verification store is format-unchanged. Upgrade:
  log entries are now SIGNED by that key, so history authorship is verified
  (v9 history replay was unauthenticated).
- **E2E model preserved.** Live traffic uses the identical v9 scheme
  (per-sender-per-session AES-GCM, structural nonce safety, same AADs).
  Relay blindness is strictly stronger: room traffic never touches the relay
  at all. The one shape change: at-rest log entries use room-wide HKDF keys
  with random IVs (a late joiner must decrypt entries whose author's session
  is gone) — same trust circle as v9, since membership always equaled keys;
  integrity under the shared key comes from the entry signatures. At-rest
  storage improves: v9 persisted history as plaintext in IndexedDB, v12
  stores only ciphertext blocks. Neither version has forward secrecy.
  Details: `docs/PROTOCOL.md` §2–3, §8.

## Ground rules

- **Migrate, don't start over.** My modules that consume the lib (UI, state,
  payload handlers) stay; only the lib underneath and the seam that wires it
  change. Work incrementally and keep the app building at each step.
- **This is a one-way cutover.** v9 and v12 peers cannot see each other (the
  transports are entirely different). That's expected. Before touching
  anything, `git tag pre-v12-migration` so the old state stays reachable, and
  plan to ship the migration as ONE release.
- **Room links keep working.** The URL-fragment secret is version-free; all
  network identifiers are derived from it. Do not change how links are made.

## Step 0 — Inventory (do this first, report before editing)

Grep my repo for every use of the v9 lib surface and list what you find:
`Signaling`, `Mesh`, `RoomCrypto.create`, `mesh.broadcast`, `mesh.sendTo`,
`mesh.broadcastBinary`, `mesh.sendBinaryTo`, `mesh.attachMedia`,
`mesh.detachMedia`, `mesh.openPeers`, `mesh.debugState`, `onChannelOpen`,
`peerJoined`, `peerLeft`, `onSignal`, `IceConfig`, `Identity`, and any
`hist-req`/`hist`-style history replay I built myself. This inventory decides
how much of steps 3–5 applies.

## Step 1 — Vendor the v12 lib

1. `git clone https://git.ardegazu.ro/chat.git /tmp/sueta-v12` (or use
   the GitHub-less mirror you already have; make sure `client/version.json`
   says 12).
2. Replace my lib copy with the v12 `client/src/lib/` — new files: `net.ts`,
   `log.ts`, `encryption.ts`, `orbit-identity.ts`, `media.ts`,
   `descriptor.ts`; changed: `crypto.ts`, `identity.ts`, `protocol.ts`,
   `selftest.ts`; **deleted: `signaling.ts`, `mesh.ts`** (their replacements
   are `net.ts` + `media.ts`). Also copy `client/src/types/orbitdb-core.d.ts`
   into my types.
3. Dependencies — add exactly this matrix to my package.json. **The libp2p
   2.x line is load-bearing**: gossipsub (latest, 14.x) silently fails to
   propagate subscriptions on libp2p 3.x, and OrbitDB 3.0.x targets helia 5.
   Do not "helpfully" bump majors:
   `libp2p@^2.10`, `@chainsafe/libp2p-gossipsub@^14.1.2`,
   `@chainsafe/libp2p-noise@^16`, `@chainsafe/libp2p-yamux@^7`,
   `@libp2p/webrtc@^5.2`, `@libp2p/websockets@^9.2`,
   `@libp2p/circuit-relay-v2@^3.2`, `@libp2p/identify@^3`, `@libp2p/ping@^2`,
   `@libp2p/bootstrap@^11`, `@libp2p/pubsub-peer-discovery@^11`,
   `@libp2p/crypto@5.1.8` (exact — 5.1.9+ jumps to interface 3),
   `@libp2p/peer-id@5.1.9` (exact), `@libp2p/interface@^2.11`,
   `@multiformats/multiaddr@^12.4`, `@orbitdb/core@^3.0.2`, `helia@^5.3`,
   `@helia/unixfs@^4`, `@helia/block-brokers@^4.2`, `@helia/routers@^3.1`,
   `blockstore-idb@^2.0.4` (2.x, NOT 4.x), `datastore-idb@^3`,
   `it-length-prefixed@^10`, `it-pipe@^3`, `it-pushable@^3`, `events@^3.3`.
4. Vite config: add `resolve.alias = { events: "events/events.js" }`
   (OrbitDB imports node:events), and if I use vite-plugin-pwa, raise
   `workbox.maximumFileSizeToCacheInBytes` to 4 MiB (the room chunk exceeds
   the 2 MiB default). Expect the network+log layer to add ~0.5 MB gzip —
   split it behind a dynamic `import()` at my lobby/entry seam the way the
   chat app's `main.ts`/`room.ts` split does.

## Step 2 — Bump my app salt to `/v2`

Change my salt's suffix (e.g. `"drawings.example.org/v1"` →
`"drawings.example.org/v2"`). Keep the exact same base string. This forks all
derived ids away from my v9 deployment (correct — the stacks can't interop
anyway) and, if my salt follows the `…/v1` → `…/v2` convention, the stock
legacy importer (step 5) can find my users' old local data by deriving the v1
roomId itself.

## Step 3 — The mechanical seam swap (Signaling + Mesh → Net)

The wiring in my boot code changes shape like this; my payload handlers do
not change at all:

```ts
// v9                                          v12
const rc = await RoomCrypto.create(secret, SALT);
                                               const session = await newSessionKey();
                                               const rc = await RoomCrypto.create(secret, SALT, session.peerId);

new Signaling(url, rc.roomId, rc.peerId, {...})   // ── gone entirely
new Mesh({ crypto, signaling, ice, myName, events })
                                               const cfg = await resolveRelayConfig(DEFAULTS);
                                               const ice = new IceConfig(cfg.turnCredsUrl, forceRelay);
                                               const net = await Net.create({
                                                 privateKey: session.privateKey, crypto: rc, ice,
                                                 relayMultiaddr: cfg.relayMultiaddr,
                                                 discoveryTopic: cfg.discoveryTopic,
                                                 myName, events });
```

Event and method mapping — apply it wherever the inventory found usage:

| v9 | v12 | Notes |
|---|---|---|
| `events.peers` / `peerJoined` / `peerLeft` / `signal` | — | internal to `Net` now (presence beacons + discovery); delete the wiring |
| `events.channelOpen(peer)` | `events.peerReady(peer)` | same meaning: connected + membership-proven, safe to `sendTo` |
| `events.message` / `events.binary` | unchanged | same decrypted payloads, same shapes |
| `events.peerState` / `peerGone` | unchanged | states: connecting/direct/relayed/disconnected |
| `signaling.status` callback | `events.status(up)` | now means "relay reachable" |
| `mesh.broadcast(obj)` | `net.broadcast(obj)` | now gossipsub on a room topic; still sealed once per sender |
| `mesh.sendTo` / `sendBinaryTo` / `broadcastBinary` | same names on `net` | now libp2p streams; backpressure is built in |
| `mesh.openPeers()` / `myId` / `debugState()` | same names on `net` | `myId` is now a base58 PeerId — anywhere I compared/stored 32-hex peerIds, treat as opaque string |
| `mesh.attachMedia` / `detachMedia` / `detachAllMedia`, `events.track` | `Media` class from `lib/media.ts` | only if my app does calls: construct `new Media({crypto, ice, net, myName, events:{track, mediaState}})`; identical semantics, SDP now rides a libp2p stream |
| `signaling.connect()` / `.close()` | `Net.create()` connects; `net.close()` | `beforeunload` → `net.close()` |
| `identity.assert(...)` | unchanged API | binding prefix is v2 internally; peers on v12 verify it as before |

DEFAULTS to bake (or point at my own infra — step 6):

```ts
const DEFAULTS = {
  relayMultiaddr: "/dns4/signal.ardegazu.ro/tcp/443/tls/ws/p2p/12D3KooWQzZvygPwd2F4JAqqf6tfBSJ29YtjzzftUyJ37RLCG5WT",
  turnCredsUrl: "https://signal.ardegazu.ro/turn-credentials",
  discoveryTopic: "_peer-discovery._p2p._pubsub",
};
```

For local dev, run the v12 repo's `deploy/relay/ --dev` (Node ≥ 22) and use
`/ip4/127.0.0.1/tcp/9090/ws/p2p/12D3KooWJU33yQvEdNF1AXcm5RWyLLyDKxyKkayRvDpJcJtYqhYt`.

After this step my app must build and work exactly as before, minus any
history replay I hand-rolled. Verify with two browser windows before moving on.

## Step 4 — Move durable state into the replicated log (recommended)

If my app has state that should survive reloads and reach late joiners
(anything I previously rebuilt with hist-req-style replay or localStorage
snapshots), put it in the room's OrbitDB log instead:

```ts
const helia = await openHelia(net.libp2p);            // one per session, IndexedDB-backed
const log = await RoomLog.open(helia, rc, identity);  // identity from lib/identity.ts, or null
log.onEntry = (e) => applyToMyState(e.hash, e.from, e.op);  // live + replicated, deduped
await log.loadTail(500);                              // replay this device's copy (works offline)
// writes: await log.append({ t: "...", ...myOp })
```

- Define my own op union: edit `LogOp` in my lib copy's `protocol.ts` to my
  app's operations (it's a vendored lib, that's the intended customization
  point). Keep ops small and idempotent to fold: entry hash = unique id,
  render order `(ts, hash)`, last-write-wins folds keyed by `(e.from, …)`
  using `e.clock` — copy the reaction-folding pattern from the chat app's
  `chat.ts` if I need toggles/undo that converge.
- Ephemeral traffic (cursors, typing, presence-ish things) stays on
  `net.broadcast` — never log it.
- Blobs: `log.putImage`/`getImage` store encrypted bytes as unixfs blocks
  replicated over bitswap; despite the name they carry any binary. Pin
  pruning is my policy (`pruneImages`).
- Everything in the log is E2E encrypted before it becomes blocks; a
  non-member with the DB address gets nothing and cannot inject — decrypt
  failure is rejection. `RoomLog` already handles the OrbitDB sharp edges
  (error listener before sync start, head-branch sweep, payload unwrap) —
  don't reimplement it.
- Add the chat app's `unhandledrejection` guard for "Could not decrypt" to my
  boot code (upstream OrbitDB quirk on the heads-exchange path — harmless,
  but noisy without the guard).

## Step 5 — My users' existing data

Their room links work untouched. For locally persisted v9 data:

- If I used the chat app's `history.ts` pattern (IndexedDB `sueta-history`
  keyed by v1 roomId): adapt `app/migrate.ts` from the v12 tree — on first v2
  open it derives the v1 roomId from the same secret, reads the old record,
  publishes it as `legacy` ops with `"v1:"+id` ids (so two devices racing the
  same import fold into one copy), and marks itself done in localStorage.
  Adapt the field mapping to my payloads; text-like content migrates, v9
  blobs generally aren't worth migrating.
- If I persisted app state in my own format: same recipe — one-shot read of
  the old store, publish as idempotent log ops with deterministic legacy ids,
  set a done-flag. Never delete the old data in the same release.
- `localStorage["sueta:id"]` (identity seed), `sueta:name`, `sueta:trust`,
  and my room list are format-unchanged — users keep identities and rooms.

## Step 6 — Infrastructure

- Easiest: keep the defaults above (sueta's public relay + TURN) — the relay
  is app-agnostic and learns nothing; my salt namespaces me.
- Own managed infra: an ird p2p app's hostname serves the same three things
  (libp2p relay WSS, `/turn-credentials`, `/.well-known/ap2p` descriptor) —
  point `DEFAULTS` at my hostname and make sure my app's **origin allowlist**
  includes my site origin AND my dev origins (`http://localhost:5173`,
  `:4173`) — the allowlist gates the libp2p websocket upgrade too, and a
  blocked origin looks like "relay never connects".
- Self-host: the v12 repo's `deploy/relay/` behind any TLS proxy + coturn;
  see its `docs/RELAY.md`.

## Step 7 — Verify (do not skip)

- The lib's `runSelfTest()` in dev boot must pass (covers the new at-rest
  crypto + dbName determinism).
- Two-browser test through the dev relay: join, exchange a live payload, and
  (if step 4 applies) kill one browser, write in the other, restart the first
  offline → its state must replay from disk; reconnect → both converge.
- Adapt my e2e to the patterns in the v12 repo's `client/e2e/mesh.e2e.mjs`:
  real Chromium via Playwright with
  `--disable-features=WebRtcHideLocalIpsWithMdns` (headless emulated browsers
  can't do WebRTC), spawn the dev relay as a child process, and copy the two
  privacy assertions verbatim — no plaintext in any relay WS frame, and
  **zero network requests to hosts outside my own infrastructure** (this
  guards the Helia public-gateway leak that `openHelia` pins off; if that
  assertion ever fires, someone re-enabled Helia's default block brokers).
- Check bundle output: my entry chunk should stay small with the network+log
  layer in a lazily imported chunk (~0.5 MB gzip is normal for that chunk).

## Step 8 — Ship

Single release, clear note to users: "old and new versions can't see each
other in the same room — everyone updates, links unchanged." If my app has an
update-banner mechanism, rely on it; if not, add a version poll first (see the
chat app's `main.ts` `setupUpdates`) so future cutovers are one tap.

Known sharp edges, so you don't rediscover them: the dependency matrix above
is pinned for real reasons (gossipsub↔libp2p interface mismatch fails
*silently*); Node ≥ 22 for the dev relay and e2e; the relay forwards ONLY the
discovery topic, so room gossip flows across member↔member connections
(presence converges after peers dial each other — a few seconds, that's
normal); libp2p PeerIds are ephemeral per page load BY DESIGN (durable
identity is the Ed25519 seed, never the PeerId).

---

That's the prompt. The reference implementation of every step is the chat app
itself in this repo — `client/src/room.ts` is the seam, `client/src/app/chat.ts`
the log projector, `client/src/app/migrate.ts` the data migration — and the
full protocol contract is `docs/PROTOCOL.md`.

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