@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,394 @@
1
+ // Cross-instance pub/sub for real-time WS broadcasts. The triage-sync
2
+ // fan-out is an in-memory subscriber map (server-e2e/hub.ts) by design — it
3
+ // routes a commit only to peers on the SAME instance. A multi-instance
4
+ // deployment behind a load balancer needs commit-landed-on-A to reach
5
+ // peers-on-B with the same latency the hub gives same-instance peers.
6
+ //
7
+ // SQLite mode is single-process by construction (the local FS objstore
8
+ // can't back two writers), so it ships a no-op PubSub.
9
+ //
10
+ // Neon mode uses Postgres LISTEN/NOTIFY on a dedicated long-lived
11
+ // WebSocket connection (the `Client` form of `@neondatabase/serverless`,
12
+ // session-bound and notification-aware — the HTTP `neon()` callable used
13
+ // for normal queries is stateless and can't LISTEN). Each instance:
14
+ // - At start: opens a Client, LISTENs on the bus channel, dispatches
15
+ // notifications. Reconnects on transport failure with backoff.
16
+ // - On publish*: fire-and-forget `SELECT pg_notify(channel, payload)`.
17
+ // - Filters its own notifications by a per-process random sender id —
18
+ // Postgres delivers NOTIFY back to publishers that LISTEN on the
19
+ // same channel, and a local broadcast already happened before the
20
+ // bus publish, so re-broadcasting our own would echo.
21
+ //
22
+ // Postgres NOTIFY caps the payload at ~8 KB by default (NAMEDATALEN-
23
+ // derived; can't be raised on a managed endpoint). The triage
24
+ // `workspace-state` envelope carries a ciphertext up to MAX_CIPHERTEXT_LEN
25
+ // (2 MiB), so the workspace-revision channel ships only `(tag, revisionId)`
26
+ // and the receiver re-fetches the row from the shared workspace_revision
27
+ // table to construct the wire broadcast. Objstore-put broadcasts likewise
28
+ // ship `(tag, resourceTag)` and the receiver re-fetches from
29
+ // workspace_object. Objstore-deleted broadcasts inline the (tag,
30
+ // resourceTag, version) tuple — the row is gone from the DB, so the
31
+ // payload IS the wire data.
32
+
33
+ import { randomBytes } from 'node:crypto'
34
+ import { errStack } from './util.ts'
35
+
36
+ // One bus channel for all three message kinds; the receiver discriminates
37
+ // on the `kind` field. Single LISTEN keeps the Client wiring trivial and
38
+ // avoids a `kind`-per-channel decision tree. Channel name doubles as the
39
+ // SQL identifier we LISTEN on, so it MUST stay a valid Postgres
40
+ // identifier (no quoting / special chars). Exported so tests stay in
41
+ // sync with the production channel name (one constant, one source).
42
+ export const CHANNEL = 'triage_bus'
43
+
44
+ // Sender id is a per-process random value stamped into every outbound
45
+ // payload so the LISTENing connection on the SAME process can skip its
46
+ // own notifications. 12 bytes / 16 chars base64url — collision odds
47
+ // across any realistic cluster size are astronomical.
48
+ function newSenderId(): string {
49
+ return randomBytes(12).toString('base64url')
50
+ }
51
+
52
+ // JSON-encoded NOTIFY payloads. Each kind documents the minimum info
53
+ // the receiver needs:
54
+ // - 'rev': workspace-state broadcast. `id` is the revision id; the
55
+ // receiver SELECTs the full row by (tag, id) — the payload size
56
+ // budget can't carry the ciphertext.
57
+ // - 'objput': objstore-put broadcast. `res` is the resource tag; the
58
+ // receiver SELECTs the live row by (tag, res) for the rest of the
59
+ // metadata fields.
60
+ // - 'objdel': objstore-deleted broadcast. `ver` is the deleted version
61
+ // — inline because the row is gone from workspace_object after the
62
+ // delete commit.
63
+ export type BusMessage =
64
+ | { kind: 'rev'; tag: string; id: string }
65
+ | { kind: 'objput'; tag: string; res: string }
66
+ | { kind: 'objdel'; tag: string; res: string; ver: number }
67
+
68
+ // Receiver wired up by the hub layer (see server-e2e/index.ts). Each handler
69
+ // runs once per remote message; failures are logged but don't crash the
70
+ // LISTEN loop — a missed broadcast surfaces to clients on reconnect
71
+ // (chain re-pull). Async because the workspace-revision handler does a
72
+ // DB lookup before broadcasting.
73
+ export type BusHandler = (msg: BusMessage) => Promise<void>
74
+
75
+ export type PubSub = {
76
+ // Resolves once LISTEN is active (Client connected + LISTEN
77
+ // acknowledged). Implementations should auto-reconnect on transport
78
+ // failure — publishes during the down window drop on the floor.
79
+ start: (onMessage: BusHandler) => Promise<void>
80
+ publish: (msg: BusMessage) => void
81
+ stop: () => Promise<void>
82
+ }
83
+
84
+ export function createNoopPubSub(): PubSub {
85
+ return {
86
+ // eslint-disable-next-line require-await
87
+ start: async () => {},
88
+ publish: () => {},
89
+ // eslint-disable-next-line require-await
90
+ stop: async () => {},
91
+ }
92
+ }
93
+
94
+ // Minimal structural shape of the `Client` form of
95
+ // `@neondatabase/serverless` — pg-compatible, with `notification` events
96
+ // and a connect/end lifecycle. We declare only the surface we touch so
97
+ // the optional peer dep stays optional (no top-level static type
98
+ // imports). The full driver type set is much larger; this slice is what
99
+ // the LISTEN loop relies on.
100
+ export type NeonClient = {
101
+ connect: () => Promise<void>
102
+ query: (text: string, params?: readonly unknown[]) => Promise<unknown>
103
+ end: () => Promise<void>
104
+ on: (event: 'notification', listener: (msg: { channel: string; payload?: string }) => void) => void
105
+ // The driver also emits 'error' on transport failures we need to
106
+ // observe to drive reconnection.
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ once?: (event: string, listener: (...args: any[]) => void) => void
109
+ }
110
+
111
+ export type NeonClientCtor = new (connectionString: string) => NeonClient
112
+
113
+ export type NeonPubSubDeps = {
114
+ // Factory returning a fresh Client. Pulled out as a dep so:
115
+ // (a) the optional peer dep stays optional (callers import lazily),
116
+ // (b) tests can swap in a PGlite-backed shim that exercises the
117
+ // publish + LISTEN loop on a single connection.
118
+ newClient: () => NeonClient
119
+ debug: boolean
120
+ // Initial-connect backoff seed and cap. Defaults are reasonable for
121
+ // production; tests override with small values to keep the suite fast.
122
+ reconnectBaseMs?: number
123
+ reconnectCapMs?: number
124
+ }
125
+
126
+ // Mutable state of one `createNeonPubSub` instance. Held in a single
127
+ // object so the LISTEN-loop helpers (`tryConnect` / `connectAndListen` /
128
+ // `reconnect`) can be defined at module scope rather than nested inside
129
+ // the factory — keeps `createNeonPubSub` itself under the 80-line cap.
130
+ type NeonState = {
131
+ newClient: () => NeonClient
132
+ debug: boolean
133
+ baseMs: number
134
+ capMs: number
135
+ senderId: string
136
+ // The current Client. Assigned EARLY in `tryConnect` (before
137
+ // `c.connect()` is awaited) so two invariants hold:
138
+ // (a) `c.once('error', ...)`'s `state.client === c` gate passes
139
+ // for errors that fire between `c.connect()` resolving and
140
+ // `c.query('LISTEN …')` resolving (the error listener can
141
+ // only attach AFTER connect returns, so errors strictly
142
+ // DURING connect are still observed via the connect Promise
143
+ // rejecting — the gate matters for the LISTEN window).
144
+ // (b) `stop()` can read `state.client` and call `c.end()` to abort
145
+ // an in-flight `await c.connect()` / `await c.query('LISTEN …')`
146
+ // — without this, a network blackhole during handshake makes
147
+ // SIGTERM hang indefinitely on the connect await.
148
+ // Cleared by `tryConnect`'s catch on failure, and by `stop()` /
149
+ // `reconnect()` when they replace the client.
150
+ client: NeonClient | null
151
+ handler: BusHandler | null
152
+ stopped: boolean
153
+ // Tracks the in-flight (re)connect attempt so `stop()` can await it —
154
+ // otherwise a SIGTERM mid-reconnect would race the Client teardown
155
+ // and leak the underlying WebSocket.
156
+ connectAttempt: Promise<void> | null
157
+ // Reconnect retry counter, reset to 0 after a successful LISTEN.
158
+ attempt: number
159
+ // Set while the loop is parked in `await sleep(delay)` during a
160
+ // reconnect backoff. `stop()` calls it (if present) to kick the
161
+ // loop out IMMEDIATELY rather than waiting up to `capMs` (30 s
162
+ // default) for the timer to fire. Cleared when the sleep returns.
163
+ cancelSleep: (() => void) | null
164
+ // In-flight bus-message handler promises. `dispatchNotification`
165
+ // fires handlers fire-and-forget, but `stop()` awaits this set before
166
+ // returning so the lifecycle's `handle.close()` (which runs after
167
+ // `pubsub.stop()` — see closeDb in server-e2e/index.ts) can't race a
168
+ // handler mid-`handle.revisionById.get` / `getLive`.
169
+ pendingHandlers: Set<Promise<void>>
170
+ }
171
+
172
+ // Notification dispatch. Filters foreign channels (defensive) and our
173
+ // own publish round-trip (Postgres NOTIFY delivers to publishers too).
174
+ function dispatchNotification(state: NeonState, n: { channel: string; payload?: string }): void {
175
+ if (n.channel !== CHANNEL) return
176
+ if (typeof n.payload !== 'string') return
177
+ let parsed: { sender?: unknown; kind?: unknown } & Record<string, unknown>
178
+ try { parsed = JSON.parse(n.payload) as typeof parsed }
179
+ catch { return }
180
+ if (parsed.sender === state.senderId) return
181
+ const msg = parseBusMessage(parsed)
182
+ if (!msg) return
183
+ const fn = state.handler
184
+ if (!fn) return
185
+ // Fire-and-forget: a slow handler can't block the Client's
186
+ // notification dispatch (which would queue further notifications
187
+ // behind it). Errors are logged but don't kill the loop — a missed
188
+ // broadcast surfaces to clients on reconnect via the chain re-pull.
189
+ // Tracked in `state.pendingHandlers` so `stop()` drains in-flight
190
+ // handlers before the lifecycle closes the DB handle (see that
191
+ // field's doc).
192
+ const promise: Promise<void> = fn(msg).catch((err) => {
193
+ console.warn('pubsub: handler error:', errStack(err))
194
+ }).finally(() => { state.pendingHandlers.delete(promise) })
195
+ state.pendingHandlers.add(promise)
196
+ }
197
+
198
+ // Single connect attempt. Resolves once LISTEN is registered, rejects
199
+ // on transport / LISTEN failure. Assigns `state.client = c` EAGERLY
200
+ // (before awaiting `c.connect()`) for the invariants on
201
+ // `NeonState.client`.
202
+ async function tryConnect(state: NeonState): Promise<void> {
203
+ const c = state.newClient()
204
+ state.client = c
205
+ try {
206
+ await c.connect()
207
+ c.on('notification', (n) => dispatchNotification(state, n))
208
+ // Transport-level error → reconnect trigger. Defer to a microtask
209
+ // so the current notification (if any) finishes before we replace
210
+ // the client. The `state.client === c` gate skips stale error
211
+ // events from PREVIOUS clients we've already torn down.
212
+ c.once?.('error', (err: Error) => {
213
+ if (state.debug) console.warn('pubsub: client error:', errStack(err))
214
+ queueMicrotask(() => { if (state.client === c) void reconnect(state) })
215
+ })
216
+ await c.query(`LISTEN ${CHANNEL}`)
217
+ } catch (err) {
218
+ // Clear `state.client` only if it still points at OUR client — a
219
+ // racing `stop()` may have already null'd it and ended the
220
+ // half-connected socket; don't clobber that. The `c.end()` below
221
+ // may then be a redundant second call, but pg-style Client.end()
222
+ // is idempotent (and the try/catch absorbs any rejection).
223
+ if (state.client === c) state.client = null
224
+ try { await c.end() } catch {}
225
+ throw err
226
+ }
227
+ }
228
+
229
+ // Outer (re)connect loop. Exits silently on `state.stopped`; otherwise
230
+ // retries with exponential backoff after a connect / LISTEN failure.
231
+ // `tryConnect` owns `state.client`, so this loop only counts attempts
232
+ // and runs the backoff sleep.
233
+ async function connectAndListen(state: NeonState): Promise<void> {
234
+ // oxlint-disable-next-line no-unmodified-loop-condition
235
+ while (!state.stopped) {
236
+ try {
237
+ await tryConnect(state)
238
+ state.attempt = 0
239
+ if (state.debug) console.log(`pubsub: LISTEN ${CHANNEL} (sender ${state.senderId})`)
240
+ return
241
+ } catch (err) {
242
+ if (state.stopped) return
243
+ state.attempt += 1
244
+ const delay = Math.min(state.capMs, state.baseMs * 2 ** Math.min(state.attempt - 1, 8))
245
+ console.warn(`pubsub: connect failed (attempt ${state.attempt}), retrying in ${delay}ms:`, errStack(err))
246
+ await cancellableSleep(state, delay)
247
+ }
248
+ }
249
+ }
250
+
251
+ // Sleep that `stop()` can wake up. Without the cancel hook, a SIGTERM
252
+ // landing mid-backoff would stall shutdown for up to `capMs` (30 s
253
+ // default) waiting on the timer. We stash the cancel callback on the
254
+ // shared state object so `stop()` can fire it; the loop's
255
+ // `if (state.stopped) return` then exits on the next turn. The
256
+ // `settled` flag guards the timer-vs-`stop()` wake-up race: resolves
257
+ // are idempotent at runtime, but it also satisfies oxlint's stricter
258
+ // `no-multiple-resolved` and documents the mutual exclusion.
259
+ function cancellableSleep(state: NeonState, ms: number): Promise<void> {
260
+ return new Promise((resolve) => {
261
+ let settled = false
262
+ const finish = (): void => {
263
+ if (settled) return
264
+ settled = true
265
+ state.cancelSleep = null
266
+ resolve()
267
+ }
268
+ const timer = setTimeout(finish, ms)
269
+ timer.unref?.()
270
+ state.cancelSleep = () => { clearTimeout(timer); finish() }
271
+ })
272
+ }
273
+
274
+ async function reconnect(state: NeonState): Promise<void> {
275
+ if (state.stopped) return
276
+ const old = state.client
277
+ state.client = null
278
+ if (old) { try { await old.end() } catch {} }
279
+ if (state.connectAttempt) return // a connect is already running
280
+ state.connectAttempt = connectAndListen(state).finally(() => { state.connectAttempt = null })
281
+ await state.connectAttempt
282
+ }
283
+
284
+ // Neon-mode PubSub. Holds ONE Client for LISTEN (long-lived WebSocket)
285
+ // and reuses it for publish (`pg_notify` from the same session — no
286
+ // need for a separate write connection, and binds the publish-vs-notify
287
+ // ordering: a NOTIFY a peer publishes RIGHT AFTER a save commit is
288
+ // guaranteed to follow the commit in WAL order from the peer's POV).
289
+ //
290
+ // Reconnection: on transport error the loop retries with exponential
291
+ // backoff. Publishes during the down window are dropped — they're a
292
+ // best-effort fan-out, not a durability mechanism. The DB is the
293
+ // source of truth; a client whose peer missed a live broadcast catches
294
+ // up via the chain on its next subscribe.
295
+ export function createNeonPubSub(deps: NeonPubSubDeps): PubSub {
296
+ const state: NeonState = {
297
+ newClient: deps.newClient, debug: deps.debug,
298
+ baseMs: deps.reconnectBaseMs ?? 1_000,
299
+ capMs: deps.reconnectCapMs ?? 30_000,
300
+ senderId: newSenderId(),
301
+ client: null, handler: null, stopped: false,
302
+ connectAttempt: null, attempt: 0, cancelSleep: null,
303
+ pendingHandlers: new Set(),
304
+ }
305
+ return {
306
+ start: async (onMessage) => {
307
+ state.handler = onMessage
308
+ state.connectAttempt = connectAndListen(state)
309
+ await state.connectAttempt.finally(() => { state.connectAttempt = null })
310
+ },
311
+ publish: (msg) => publish(state, msg),
312
+ stop: async () => {
313
+ state.stopped = true
314
+ // Null `handler` BEFORE the awaits so any notification that
315
+ // sneaks in (between `c.end()` and the socket actually closing)
316
+ // sees no handler and drops in `dispatchNotification`.
317
+ state.handler = null
318
+ // Kick the loop out of its backoff sleep IMMEDIATELY rather than
319
+ // letting `stop()` block for up to `capMs` (30 s default) on the
320
+ // timer. The loop's `if (state.stopped) return` runs on the next
321
+ // turn and exits cleanly.
322
+ state.cancelSleep?.()
323
+ // End the current client to abort an in-flight handshake (cancels
324
+ // a hung `await c.connect()` / `await c.query('LISTEN …')` so
325
+ // `stop()` doesn't hang on a Neon WS blackhole) OR close an
326
+ // established LISTEN session. With `tryConnect`'s eager assign,
327
+ // `state.client` covers both cases via the same field.
328
+ const c = state.client
329
+ state.client = null
330
+ if (c) { try { await c.end() } catch {} }
331
+ // Now wait for the (now-aborted-if-applicable) connect attempt to
332
+ // unwind through its catch and resolve.
333
+ if (state.connectAttempt) { try { await state.connectAttempt } catch {} }
334
+ // Drain in-flight bus-message handlers BEFORE returning. The
335
+ // lifecycle teardown runs `pubsub.stop()` and THEN
336
+ // `handle.close()` (see closeDb in server-e2e/index.ts); a handler
337
+ // still in `handle.revisionById.get` / `getLive` would otherwise
338
+ // throw against a closed DB. `allSettled` so one handler's
339
+ // rejection doesn't abort the drain.
340
+ if (state.pendingHandlers.size > 0) {
341
+ await Promise.allSettled([...state.pendingHandlers])
342
+ }
343
+ },
344
+ }
345
+ }
346
+
347
+ function publish(state: NeonState, msg: BusMessage): void {
348
+ const c = state.client
349
+ if (!c) {
350
+ if (state.debug) console.warn('pubsub: publish dropped (no client):', msg.kind, msg.tag.slice(0, 12))
351
+ return
352
+ }
353
+ // `pg_notify(text, text)` is the parameter-bound form of NOTIFY —
354
+ // the bare `NOTIFY` statement doesn't accept params. The envelope
355
+ // carries the sender id so the LISTENing connection on this same
356
+ // process filters its own publishes (Postgres delivers NOTIFY back
357
+ // to publishers too).
358
+ const envelope = JSON.stringify({ sender: state.senderId, ...msg })
359
+ // Fire-and-forget. A failed publish only means peers on OTHER
360
+ // instances miss THIS event — local fan-out already happened before
361
+ // this call. Log + continue (the bus is a best-effort accelerator,
362
+ // not a durability layer).
363
+ c.query(`SELECT pg_notify($1, $2)`, [CHANNEL, envelope]).catch((err) => {
364
+ if (state.debug) console.warn('pubsub: publish error:', errStack(err))
365
+ })
366
+ }
367
+
368
+ // Narrow a parsed JSON object into a typed BusMessage. Rejects anything
369
+ // missing required fields, with non-string tag/id/res, or a non-integer
370
+ // version. Defensive against bus poisoning by a peer running an older /
371
+ // custom build.
372
+ function parseBusMessage(raw: Record<string, unknown>): BusMessage | null {
373
+ const tag = raw['tag']
374
+ const kind = raw['kind']
375
+ if (typeof tag !== 'string') return null
376
+ if (kind === 'rev') {
377
+ const id = raw['id']
378
+ if (typeof id !== 'string') return null
379
+ return { kind: 'rev', tag, id }
380
+ }
381
+ if (kind === 'objput') {
382
+ const res = raw['res']
383
+ if (typeof res !== 'string') return null
384
+ return { kind: 'objput', tag, res }
385
+ }
386
+ if (kind === 'objdel') {
387
+ const res = raw['res']
388
+ const ver = raw['ver']
389
+ if (typeof res !== 'string') return null
390
+ if (typeof ver !== 'number' || !Number.isSafeInteger(ver)) return null
391
+ return { kind: 'objdel', tag, res, ver }
392
+ }
393
+ return null
394
+ }
@@ -55,15 +55,6 @@ export type SubscribeMsg = {
55
55
  // `Uint8Array<ArrayBuffer>` (not `<ArrayBufferLike>`) so the bytes
56
56
  // thread directly into `crypto.subtle.digest` — `BufferSource`
57
57
  // rejects SharedArrayBuffer-backed views.
58
- //
59
- // NOTE: `verifySaveSigAndCanonical` is a test-friendly composition
60
- // helper. Production `handleSave` in `server/index.ts` does NOT
61
- // call it — it composes `canonicalSave` +
62
- // `computeRevisionIdFromCanonical` + `verifyEd25519` separately so
63
- // the dup-precheck (`revisionExists`) can fire BEFORE the Ed25519
64
- // verify, closing the round-9 H1 CPU-DoS vector where a passive
65
- // observer floods captured saves. The wrapper exists for unit
66
- // tests that don't need the precheck ordering.
67
58
  export type VerifyResult =
68
59
  | { ok: true; canonical: Uint8Array<ArrayBuffer> }
69
60
  | { ok: false; canonical: null }
@@ -101,7 +92,7 @@ export function canonicalSave(
101
92
  // confusing diff in the canonical bytes rather than a clean drop.
102
93
  // Mirrors the `isSafeNonNegativeInt` rigor that the objstore
103
94
  // canonical builders apply. Input-validation audit
104
- // `server/sign.ts:88`.
95
+ // `server-e2e/sign.ts:88`.
105
96
  if (typeof workspaceTag !== 'string') throw new TypeError('canonicalSave: workspaceTag must be string')
106
97
  if (typeof nonce !== 'string') throw new TypeError('canonicalSave: nonce must be string')
107
98
  if (typeof ciphertext !== 'string') throw new TypeError('canonicalSave: ciphertext must be string')
@@ -120,11 +111,18 @@ function canonicalSubscribe(
120
111
  { workspaceTag, from }: SubscribeMsg,
121
112
  connectionNonce: string,
122
113
  ): Uint8Array<ArrayBuffer> {
123
- const fromStr = from == null ? '' : String(from)
114
+ // Strict `from` typing, mirroring canonicalSave's `base` check: `from`
115
+ // is `string | null`. A non-string non-null value would otherwise
116
+ // coerce via `String(...)` to canonical bytes the client could never
117
+ // reproduce (123 → "123", {} → "[object Object]"), so a signature
118
+ // computed over that coercion would verify against a malformed wire
119
+ // shape. verifySubscribeSig wraps this call in try/catch → clean drop.
120
+ if (from != null && typeof from !== 'string') throw new TypeError('canonicalSubscribe: from must be string or null')
121
+ const fromStr = from == null ? '' : from
124
122
  return encodeUtf8([SUBSCRIBE_DOMAIN, workspaceTag as string, fromStr, connectionNonce].join('\n'))
125
123
  }
126
124
 
127
- // Exported because the v1.objstore signing module (server/objstore/sign.ts)
125
+ // Exported because the v1.objstore signing module (server-e2e/objstore/sign.ts)
128
126
  // reuses it for its four verifiers — same workspaceTag-as-pubkey contract,
129
127
  // same domain-separated canonical bytes. Keeping the WebCrypto plumbing
130
128
  // in one place avoids drift between the triage-sync and objstore verify
@@ -159,7 +157,7 @@ export async function verifyEd25519(
159
157
  // `{ ok: false, canonical: null }` on bad shape / bad sig;
160
158
  // `{ ok: true, canonical: <bytes> }` on success.
161
159
  //
162
- // NOT used by `server/index.ts handleSave`. Production composes
160
+ // NOT used by `server-e2e/index.ts handleSave`. Production composes
163
161
  // the smaller helpers (`canonicalSave`, `computeRevisionIdFromCanonical`,
164
162
  // `verifyEd25519`) separately so the dup-precheck via
165
163
  // `revisionExists` can fire BEFORE Ed25519-verify and skip the
@@ -206,7 +204,7 @@ export async function computeRevisionIdFromCanonical(canonical: Uint8Array<Array
206
204
 
207
205
  // `connectionNonce` is the per-socket challenge the server issued
208
206
  // to the originating connection (see `Peer.challenge` in
209
- // server/peer.ts). The client signs a canonical that includes the
207
+ // server-e2e/peer.ts). The client signs a canonical that includes the
210
208
  // nonce; verifying against the SAME nonce here is what blocks
211
209
  // cross-connection replay of a captured subscribe frame. Audit
212
210
  // round-9 H2.