@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/api/reap.ts +79 -0
  2. package/common/save-error-reason.ts +20 -7
  3. package/common/server-info.ts +30 -0
  4. package/out/brotli-fallback.js +1 -1
  5. package/out/client-admin.js +28 -0
  6. package/out/client-managed.js +1 -0
  7. package/out/client-sync.js +17 -10
  8. package/out/graph.js +5 -4
  9. package/out/index.html +43 -38
  10. package/out/prism.js +2 -2
  11. package/out/terminal.js +32 -28
  12. package/out/view.css +1 -1
  13. package/out/view.js +78 -51
  14. package/package.json +70 -49
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +16 -1
  17. package/server-e2e/bus-receiver.ts +95 -0
  18. package/server-e2e/cli.js +22 -0
  19. package/{server → server-e2e}/config.ts +21 -8
  20. package/{server → server-e2e}/db-neon.ts +41 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +15 -9
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +113 -135
  24. package/server-e2e/http.ts +266 -0
  25. package/{server → server-e2e}/hub.ts +27 -8
  26. package/{server → server-e2e}/index.ts +185 -52
  27. package/{server → server-e2e}/lifecycle.ts +36 -5
  28. package/server-e2e/npm-proxy.ts +348 -0
  29. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  30. package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
  31. package/{server → server-e2e}/objstore/blob.ts +24 -9
  32. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  33. package/{server → server-e2e}/objstore/handlers.ts +25 -15
  34. package/{server → server-e2e}/objstore/init.ts +52 -12
  35. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  36. package/server-e2e/objstore/rest-deny.ts +28 -0
  37. package/server-e2e/objstore/rest-mint.ts +224 -0
  38. package/{server → server-e2e}/objstore/rest.ts +119 -84
  39. package/{server → server-e2e}/objstore/sign.ts +105 -0
  40. package/{server → server-e2e}/objstore/store-neon.ts +19 -19
  41. package/{server → server-e2e}/objstore/store.ts +98 -118
  42. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  43. package/{server → server-e2e}/peer.ts +7 -9
  44. package/server-e2e/pubsub.ts +394 -0
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/server-e2e/sse-server.ts +384 -0
  47. package/server-e2e/sse-session.ts +216 -0
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/server-e2e/sync-handlers.ts +382 -0
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/server-e2e/ws-server.ts +276 -0
  52. package/strip-types-loader.js +94 -0
  53. package/server/http.ts +0 -142
  54. package/server/sync-handlers.ts +0 -311
  55. package/server/ws-server.ts +0 -245
  56. /package/{server → server-e2e}/config.example.json +0 -0
  57. /package/{server → server-e2e}/neon-driver.ts +0 -0
  58. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  59. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,382 @@
1
+ // Triage-sync protocol handlers: `workspace-save` and
2
+ // `workspace-subscribe`. Built once at boot with the DB handle plus
3
+ // the transport / auth / registry primitives it needs; the WS message
4
+ // dispatcher in index.ts calls the returned handlers. Kept out of the
5
+ // entrypoint so the protocol logic (the save pipeline, the
6
+ // subscribe/catch-up path) is one cohesive, testable unit.
7
+
8
+ import type { IncomingMessage, ServerResponse } from 'node:http'
9
+ import { Buffer } from 'node:buffer'
10
+ import type { WebSocket } from 'ws'
11
+ import { SAVE_ERROR_REASONS, type SaveErrorReason } from '../common/save-error-reason.ts'
12
+ import { type Handle, type RevisionRow, chainFrom, commitRevision, revisionExists } from './db.ts'
13
+ import { type SaveMsg, type SubscribeMsg, canonicalSave, computeRevisionIdFromCanonical, verifyEd25519, verifySubscribeSig } from './sign.ts'
14
+ import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, validTagSigBase } from './validation.ts'
15
+ import { debugTag } from './util.ts'
16
+ import type { UnauthorizedContext } from './auth.ts'
17
+
18
+ // Wire shape `chainForWire` produces from a `chainFrom` row, with
19
+ // `keyframe` normalised from the SQLite INTEGER 0/1 to a strict boolean.
20
+ type WireRevision = {
21
+ base: string | null
22
+ id: string
23
+ keyframe: boolean
24
+ nonce: string
25
+ ciphertext: string
26
+ signature: string
27
+ }
28
+
29
+ // Structured result of the shared save pipeline (`commitSave`), rendered
30
+ // per transport: WS → protocol frames; REST → JSON + HTTP status.
31
+ type SaveOutcome =
32
+ | { kind: 'rejected' } // malformed / bad sig → WS drop / REST 400
33
+ | { kind: 'ack'; id: string; base: string | null } // committed → save-ack / 200
34
+ | { kind: 'duplicate'; id: string; base: string | null } // replay → ack-only / 200
35
+ | { kind: 'too-large'; base: string | null } // ciphertext over cap → save-error / 413
36
+ | { kind: 'unauthorized'; base: string | null } // new-workspace gate → unauthorized / 401
37
+ | { kind: 'stale-base'; base: string | null; revisions: WireRevision[] } // conflict → state+error / 409
38
+
39
+ // Hard cap on a `POST /api/sync/save` JSON body. The save frame is the small
40
+ // fields + a base64 ciphertext capped at MAX_CIPHERTEXT_LEN (2 MiB); 4 MiB
41
+ // (the WS plane's maxPayload / the SSE plane's maxBodyBytes) leaves headroom
42
+ // for the envelope so the in-pipeline size policy — not the reader — decides
43
+ // `too-large`. Bounds the read so a hostile client can't stream an unbounded
44
+ // body into memory before the parse.
45
+ const SAVE_BODY_MAX = 4 * 1024 * 1024
46
+
47
+ // Read a JSON request body up to `SAVE_BODY_MAX`, returning the parsed value
48
+ // or null on overflow / parse failure / read error (mirrors the objstore
49
+ // mint reader). The REST save body is the only body this module reads.
50
+ async function readSaveBody(req: IncomingMessage): Promise<unknown> {
51
+ const chunks: Buffer[] = []
52
+ let total = 0
53
+ try {
54
+ for await (const chunk of req) {
55
+ const buf = chunk as Buffer
56
+ total += buf.length
57
+ if (total > SAVE_BODY_MAX) return null
58
+ chunks.push(buf)
59
+ }
60
+ } catch { return null }
61
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) }
62
+ catch { return null }
63
+ }
64
+
65
+ function respondJson(res: ServerResponse, status: number, obj: object): void {
66
+ res.writeHead(status, { 'content-type': 'application/json' })
67
+ res.end(JSON.stringify(obj))
68
+ }
69
+
70
+ export type SyncHandlersDeps = {
71
+ handle: Handle
72
+ send: (socket: WebSocket, msg: object) => void
73
+ broadcast: (tag: string, msg: object, except: WebSocket | null) => void
74
+ // Cross-instance pub/sub for live broadcasts. Fired alongside the
75
+ // local `broadcast` after a successful commit so peers on OTHER
76
+ // server instances see the new revision in real time. Carries only
77
+ // `(tag, revisionId)` — the receiver re-fetches the row from
78
+ // workspace_revision because the ciphertext can exceed the bus's
79
+ // payload budget. Optional: a SQLite deployment passes a no-op.
80
+ publishRevision: (tag: string, revisionId: string) => void
81
+ subscribe: (socket: WebSocket, tag: string) => void
82
+ getNonce: (socket: WebSocket) => string | undefined
83
+ requiresAuth: (socket: WebSocket) => boolean
84
+ // Whether an operator password is configured. The REST save plane's
85
+ // new-workspace gate (it has no socket to read operator-auth state from)
86
+ // collapses to `passwordConfigured && workspace-new` — the socket-less
87
+ // analog of `requiresAuth`, matching the objstore `restPutGate`.
88
+ passwordConfigured: boolean
89
+ sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
90
+ workspaceExists: (tag: string) => Promise<boolean>
91
+ // Objstore inventory snapshot for a workspace tag, as wire rows. The
92
+ // `workspace-subscribed` ack carries it. Injected — the objstore store
93
+ // has its own richer `Handle`, so index.ts wires `listLive(
94
+ // objstoreHandle, tag).then(rows => rows.map(objectMetaWire))` rather
95
+ // than coupling this module to that store type. Returns [] for a
96
+ // triage-only tag.
97
+ objstoreResources: (tag: string) => Promise<object[]>
98
+ debug: boolean
99
+ }
100
+
101
+ export type SyncHandlers = {
102
+ handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
103
+ // Session-independent REST save plane (`POST /api/sync/save`), wired into
104
+ // the HTTP dispatcher in server-e2e/http.ts. Runs the same pipeline as
105
+ // `handleSave` and renders the outcome as a JSON response.
106
+ handleSaveRest: (req: IncomingMessage, res: ServerResponse) => Promise<void>
107
+ handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
108
+ // Exported because the dispatcher's inflight-cap `busy` NACK path
109
+ // emits a save-error too (the only emit site outside this module).
110
+ sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
111
+ }
112
+
113
+ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
114
+ const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
115
+
116
+ // Typed wrapper for the three `workspace-save-error` emit sites
117
+ // (too-large at handleSave, stale-base after the catch-up, busy at
118
+ // the inflight-cap drop). Forces `reason` to be a member of
119
+ // `SaveErrorReason` so a typo or a server-side addition that didn't
120
+ // update `common/save-error-reason.ts` fails at compile time rather
121
+ // than turning into a wire-level surprise. The shared taxonomy is
122
+ // pinned by `tests/save-error-reason-taxonomy.test.js`.
123
+ function sendSaveError(
124
+ socket: WebSocket,
125
+ workspaceTag: string,
126
+ base: string | null,
127
+ reason: SaveErrorReason,
128
+ ): void {
129
+ // Runtime guard alongside the compile-time `SaveErrorReason` union —
130
+ // covers the case where the `reason` argument is a variable (not a
131
+ // string literal) and TS's narrowing can't enforce taxonomy
132
+ // membership at the call site. Throws because a server emitting a
133
+ // typo'd reason would be a wire-protocol break the client can't
134
+ // recover from; better to fail fast in the test suite than to
135
+ // silently land bytes the client coerces to `'rejected'`.
136
+ if (!SAVE_ERROR_REASONS.has(reason)) {
137
+ throw new Error(`sendSaveError: reason '${reason}' is not in SAVE_ERROR_REASONS — update common/save-error-reason.ts`)
138
+ }
139
+ send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
140
+ }
141
+
142
+ // Normalise `keyframe` to a strict boolean on outbound chain entries.
143
+ // SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
144
+ // raw rows; the wire contract (and canonical signing payload) uses
145
+ // strict `=== true`. Convert once on the send side — forwarding the
146
+ // integer relies on clients coercing via `Boolean()` before rebuilding
147
+ // canonical bytes, fragile against any client that strict-compares.
148
+ function chainForWire(revisions: RevisionRow[]): WireRevision[] {
149
+ return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
150
+ }
151
+
152
+ // Transport-agnostic save pipeline, shared by the WS `handleSave` renderer
153
+ // and the REST `handleSaveRest` renderer. Runs the full validate → precheck
154
+ // → sig-verify → size/auth gates → commit → broadcast pipeline and returns a
155
+ // structured `SaveOutcome` the caller renders for its transport. Two params
156
+ // abstract the transport:
157
+ // - `authRequired`: the new-workspace gate decision (WS: requiresAuth(
158
+ // socket); REST: passwordConfigured — a REST request can't be operator-
159
+ // authorised, so the gate collapses to "password set AND workspace new").
160
+ // - `except`: the broadcast exclusion — the originating socket on the WS
161
+ // path (so it isn't echoed its own save), or null on the REST path (the
162
+ // request isn't a subscriber socket; the originator's own echo lands on
163
+ // its subscription stream and is an idempotent no-op — applyChainToBase
164
+ // skips a revision whose id already equals the client's baseRevision,
165
+ // and a same-content re-apply converges. Matches objstore-deleted's
166
+ // `except: null`).
167
+ async function commitSave(msg: SaveMsg, authRequired: boolean, except: WebSocket | null): Promise<SaveOutcome> {
168
+ // `base` is `string | null`; null is the keyframe-root marker.
169
+ if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validNonce(msg.nonce, MAX_FIELD_LEN) || !validCiphertextShape(msg.ciphertext) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.base != null && !validTagSigBase(msg.base, MAX_FIELD_LEN))) return { kind: 'rejected' }
170
+ // Compute canonical bytes + content-addressed id ONCE, then thread
171
+ // both through the precheck → sig verify → commit pipeline:
172
+ // 1. canonicalSave (sync, throws on lone-surrogate input)
173
+ // 2. SHA-256 → id
174
+ // 3. precheck: revisionExists → short-circuit ack on replay
175
+ // (skips Ed25519 verify; closes the round-9 H1 CPU-DoS vector
176
+ // where a passive observer floods captured saves)
177
+ // 4. verifyEd25519 against the SAME canonical bytes the id was
178
+ // hashed from — provably tied
179
+ // 5. ciphertext size policy (post-sig so the explicit error only
180
+ // reaches a legit signer)
181
+ // 6. commitRevision — re-checks dup + base + inserts via a single
182
+ // gated INSERT (dup gate + head-equals-base gate + the
183
+ // server-assigned seq folded into one statement) — NO write
184
+ // lock. See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
185
+ let canonical: Uint8Array<ArrayBuffer>
186
+ try { canonical = canonicalSave(msg) } catch { return { kind: 'rejected' } }
187
+ const id = await computeRevisionIdFromCanonical(canonical)
188
+ const tag = msg.workspaceTag
189
+ const baseNorm = msg.base ?? null
190
+ if (await revisionExists(handle, tag, id)) {
191
+ if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
192
+ return { kind: 'duplicate', id, base: baseNorm }
193
+ }
194
+ if (!await verifyEd25519(tag, canonical, msg.signature)) {
195
+ if (debug) console.warn('reject save: bad signature', debugTag(tag))
196
+ return { kind: 'rejected' }
197
+ }
198
+ // Size policy — emit an explicit error so the client can surface
199
+ // the failure to the user. Without this, an oversized save hangs
200
+ // forever in the client's `pending` slot (no ack, no rebase).
201
+ if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
202
+ if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
203
+ return { kind: 'too-large', base: baseNorm }
204
+ }
205
+ // Auth gate for the FIRST action against a workspace tag that
206
+ // doesn't yet exist on the server. Checked AFTER sig verify so the
207
+ // `unauthorized` outcome only reaches a legitimate signer; shape /
208
+ // sig attacks still drop silently. The TOCTOU between `workspaceExists`
209
+ // and the commit's gated INSERT is an accepted soft-policy race — a
210
+ // racer still had to produce a valid Ed25519 signature (= holds the
211
+ // seed). Same gate the objstore put-begin applies.
212
+ if (authRequired && !await workspaceExists(tag)) {
213
+ if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
214
+ return { kind: 'unauthorized', base: baseNorm }
215
+ }
216
+ // Save does NOT subscribe the sender — that would be a replay vector
217
+ // (a captured frame replayed from any connection would attach as a
218
+ // subscriber without holding the seed). Explicit `workspace-subscribe`
219
+ // (signs the per-connection nonce) is the ONLY attach path. Round-9 H1.
220
+ //
221
+ // `keyframe === true` is what canonicalSave bound the signature to
222
+ // (strict equality); the signer's intent is unambiguous here.
223
+ const keyframe = msg.keyframe === true
224
+ const commit = await commitRevision(handle, {
225
+ tag, id, base: baseNorm, keyframe,
226
+ nonce: msg.nonce, ciphertext: msg.ciphertext, signature: msg.signature,
227
+ })
228
+ if (commit.kind === 'duplicate') {
229
+ if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
230
+ return { kind: 'duplicate', id, base: baseNorm }
231
+ }
232
+ if (commit.kind === 'stale-base') {
233
+ // Client claimed a base that's no longer head. The catch-up chain is
234
+ // computed OUTSIDE any lock — a concurrent commit landing here only
235
+ // means the catch-up is fresher (benign; clients tolerate extra
236
+ // revisions). The WS renderer sends `workspace-state` (catch-up) FIRST
237
+ // then the typed `stale-base` error; the REST renderer returns the
238
+ // chain in the 409 body. Either way the catch-up clears the client's
239
+ // pending and the error is a no-op on the now-missing pending (a
240
+ // recoverable race — client rebases + re-saves).
241
+ //
242
+ // The catch-up CAN be empty: a client holding a base from a chain
243
+ // this deployment no longer has (wiped / moved DB, SQLite→Neon
244
+ // migration — head=null, no keyframe, no rows) gets `revisions: []`.
245
+ // The client detects that shape (pending survives the catch-up) and
246
+ // answers with a full-state push re-anchored at base=null, which
247
+ // commits as the new chain root — see the client's
248
+ // `handleSaveError` stale-base branch.
249
+ const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
250
+ if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
251
+ return { kind: 'stale-base', base: baseNorm, revisions }
252
+ }
253
+ if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
254
+ // Carry `keyframe` as a strict boolean on the broadcast wire — peers
255
+ // strict-compare `=== true` (matching the canonical-payload contract).
256
+ broadcast(tag, {
257
+ type: 'workspace-state',
258
+ workspaceTag: tag,
259
+ revisions: [{
260
+ base: baseNorm,
261
+ id,
262
+ keyframe,
263
+ nonce: msg.nonce,
264
+ ciphertext: msg.ciphertext,
265
+ signature: msg.signature,
266
+ }],
267
+ }, except)
268
+ // Cross-instance fan-out. The bus payload carries only the revision id —
269
+ // peers on OTHER instances re-fetch the row. SQLite mode passes a no-op;
270
+ // Neon mode publishes via pg_notify. Best-effort.
271
+ publishRevision(tag, id)
272
+ return { kind: 'ack', id, base: baseNorm }
273
+ }
274
+
275
+ // WS renderer: run the shared pipeline against this socket and render the
276
+ // outcome as the protocol frames the client expects on its stream. A
277
+ // `rejected` outcome drops silently (matches the prior malformed/bad-sig
278
+ // behaviour). For every non-rejected outcome the tag was validated inside
279
+ // `commitSave`, so the cast to string is sound.
280
+ async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
281
+ const outcome = await commitSave(msg, requiresAuth(socket), socket)
282
+ if (outcome.kind === 'rejected') return
283
+ const tag = msg.workspaceTag as string
284
+ if (outcome.kind === 'unauthorized') { sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: outcome.base }); return }
285
+ if (outcome.kind === 'too-large') { sendSaveError(socket, tag, outcome.base, 'too-large'); return }
286
+ if (outcome.kind === 'stale-base') {
287
+ // State FIRST (its handler clears pending), then the typed error.
288
+ send(socket, { type: 'workspace-state', workspaceTag: tag, revisions: outcome.revisions })
289
+ sendSaveError(socket, tag, outcome.base, 'stale-base')
290
+ return
291
+ }
292
+ // ack | duplicate
293
+ send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: outcome.base, id: outcome.id })
294
+ }
295
+
296
+ // REST renderer: the session-independent `POST /api/sync/save` plane. Reads
297
+ // the save frame from the JSON body, runs the SAME pipeline (no socket;
298
+ // new-workspace gate = passwordConfigured; broadcast except = null), and
299
+ // maps the outcome to a JSON + HTTP status the client switches on. SSE-mode
300
+ // clients POST here so a save doesn't take over their event-stream; a 401
301
+ // routes them to the in-band frame (which runs the operator auth flow).
302
+ // Mounted + gated (same-origin, shutdown, idle-timeout) in server-e2e/http.ts.
303
+ async function handleSaveRest(req: IncomingMessage, res: ServerResponse): Promise<void> {
304
+ const body = await readSaveBody(req)
305
+ if (!body || typeof body !== 'object') { respondJson(res, 400, { reason: 'bad-request' }); return }
306
+ const outcome = await commitSave(body as SaveMsg, passwordConfigured, null)
307
+ if (outcome.kind === 'ack' || outcome.kind === 'duplicate') { respondJson(res, 200, { ok: true, id: outcome.id }); return }
308
+ if (outcome.kind === 'stale-base') { respondJson(res, 409, { reason: 'stale-base', revisions: outcome.revisions }); return }
309
+ if (outcome.kind === 'too-large') { respondJson(res, 413, { reason: 'too-large' }); return }
310
+ if (outcome.kind === 'unauthorized') { respondJson(res, 401, { reason: 'unauthorized' }); return }
311
+ respondJson(res, 400, { reason: 'bad-request' })
312
+ }
313
+
314
+ async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
315
+ // Mirror handleSave's wire gate: length-cap + base64url-alphabet on
316
+ // workspaceTag / signature / from BEFORE canonicalSubscribe UTF-8-
317
+ // encodes them and verifyEd25519 base64-decodes the signature.
318
+ // Without it a peer can submit up-to-maxPayload strings and force
319
+ // O(n) encode + decode per subscribe before the 32/64-byte length
320
+ // gates reject — a CPU-DoS surface handleSave is already hardened
321
+ // against. `from` keeps the `string | null` contract (null = full-
322
+ // chain catch-up); a legit signer's `from` is a base64url revision
323
+ // id, so any non-string / over-long / wrong-alphabet value can't be
324
+ // one the signature was computed over, and the chain-lookup path
325
+ // (`typeof msg.from === 'string' ? msg.from : null`) still treats a
326
+ // null / absent `from` as the keyframe-fallback.
327
+ if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.from != null && !validTagSigBase(msg.from, MAX_FIELD_LEN))) return
328
+ // The challenge nonce we issued on this socket is bound into the
329
+ // signed canonical, blocking cross-connection replay of a captured
330
+ // subscribe frame. A subscribe arriving before we sent the
331
+ // challenge (impossible from the legitimate client) has no nonce to
332
+ // verify against — drop. Audit round-9 H2.
333
+ const nonce = getNonce(socket)
334
+ if (typeof nonce !== 'string') return
335
+ const ok = await verifySubscribeSig(msg, nonce)
336
+ if (!ok) {
337
+ if (debug) console.warn('reject subscribe: bad signature', debugTag(msg.workspaceTag))
338
+ return
339
+ }
340
+ // Bail if the socket closed during the verify await. The close
341
+ // handler's `unsubscribeAll(socket)` already ran (when there was
342
+ // nothing to remove yet), and `subscribe()` below would add the
343
+ // dead socket to `subscribers[tag]` — a permanent leak: broadcasts
344
+ // no-op via `send`'s readyState gate, but the Set entry pins the
345
+ // socket reference past close, blocking GC. Audit round-12.
346
+ if (socket.readyState !== socket.OPEN) {
347
+ if (debug) console.warn('reject subscribe: socket closed mid-verify', debugTag(msg.workspaceTag))
348
+ return
349
+ }
350
+ const tag = msg.workspaceTag
351
+ subscribe(socket, tag)
352
+ // Explicit ack — distinguishes "the server processed my subscribe
353
+ // and registered me as a peer" from "the WebSocket is open". A
354
+ // client that sent a malformed / bad-sig subscribe never gets this;
355
+ // a client that did gets one before the chain arrives. Lets the UI
356
+ // surface a `connecting → online` transition based on real handshake
357
+ // completion, not just socket state.
358
+ //
359
+ // The ack also carries the objstore inventory snapshot: the same
360
+ // subscribe that registers this socket for objstore-put / -deleted
361
+ // broadcasts seeds the client's initial inventory in one handshake.
362
+ // The client keeps it live thereafter from those broadcasts.
363
+ // Returns [] for a triage-only workspace. A failing inventory
364
+ // lookup must NOT sink the subscribe — degrade to an empty snapshot
365
+ // (broadcasts will fill it in) so the ack + chain still go out.
366
+ let resources: object[] = []
367
+ try { resources = await objstoreResources(tag) }
368
+ catch (err) { if (debug) console.warn('subscribe: objstore inventory lookup failed', debugTag(tag), err) }
369
+ send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
370
+ // `from` is the last revision id the client claims to have applied —
371
+ // now a base64url string, not an integer. We send only revisions
372
+ // after that. Client lying about `from` just means they get a
373
+ // smaller catch-up — their subsequent saves will reveal stale state
374
+ // on the usual base-mismatch path. Null / missing → full chain.
375
+ const fromId = typeof msg.from === 'string' ? msg.from : null
376
+ const revisions = chainForWire(await chainFrom(handle, tag, fromId))
377
+ if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
378
+ send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
379
+ }
380
+
381
+ return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
382
+ }
@@ -8,6 +8,15 @@ import { randomBytes } from 'node:crypto'
8
8
  // is an Ed25519 public key; operator logs shouldn't carry it verbatim.
9
9
  export function debugTag(s: string): string { return `${s.slice(0, 12)}…` }
10
10
 
11
+ // Truncated view of a content hash / staging id for logs (objstore GC +
12
+ // 503 diagnostics). Same 12-char prefix convention as `debugTag`; named
13
+ // separately so call sites read as "this is a blob id, not a workspace
14
+ // tag". Tolerates a non-string (logs a placeholder) so a diagnostic
15
+ // path can't itself throw on bad input.
16
+ export function debugId(s: unknown): string {
17
+ return typeof s === 'string' ? `${s.slice(0, 12)}…` : '<no-id>'
18
+ }
19
+
11
20
  // 16 random bytes → 22 base64url chars (no padding). The shared shape
12
21
  // for per-socket challenge nonces and staging ids — collision is
13
22
  // 1/2^128. `isValidStagingId` in objstore/store.ts validates exactly