@preventive/triage 1.0.0-alpha.1 → 1.0.0-alpha.11

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 (56) 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 +16 -13
  8. package/out/graph.js +5 -4
  9. package/out/index.html +4 -15
  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 +89 -52
  14. package/package.json +70 -54
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +5 -1
  17. package/{server → server-e2e}/bus-receiver.ts +8 -8
  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 +31 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +96 -135
  24. package/{server → server-e2e}/http.ts +97 -9
  25. package/{server → server-e2e}/hub.ts +7 -8
  26. package/{server → server-e2e}/index.ts +67 -43
  27. package/{server → server-e2e}/lifecycle.ts +14 -5
  28. package/{server → server-e2e}/npm-proxy.ts +1 -1
  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 +13 -15
  34. package/{server → server-e2e}/objstore/init.ts +47 -13
  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 +110 -93
  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 → server-e2e}/pubsub.ts +21 -31
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/{server → server-e2e}/sse-server.ts +105 -73
  47. package/{server → server-e2e}/sse-session.ts +30 -16
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/{server → server-e2e}/sync-handlers.ts +172 -117
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/{server → server-e2e}/ws-server.ts +29 -23
  52. package/strip-types-loader.js +94 -0
  53. /package/{server → server-e2e}/config.example.json +0 -0
  54. /package/{server → server-e2e}/neon-driver.ts +0 -0
  55. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  56. /package/{server → server-e2e}/validation.ts +0 -0
@@ -5,6 +5,8 @@
5
5
  // entrypoint so the protocol logic (the save pipeline, the
6
6
  // subscribe/catch-up path) is one cohesive, testable unit.
7
7
 
8
+ import type { IncomingMessage, ServerResponse } from 'node:http'
9
+ import { Buffer } from 'node:buffer'
8
10
  import type { WebSocket } from 'ws'
9
11
  import { SAVE_ERROR_REASONS, type SaveErrorReason } from '../common/save-error-reason.ts'
10
12
  import { type Handle, type RevisionRow, chainFrom, commitRevision, revisionExists } from './db.ts'
@@ -13,9 +15,8 @@ import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, va
13
15
  import { debugTag } from './util.ts'
14
16
  import type { UnauthorizedContext } from './auth.ts'
15
17
 
16
- // `chainForWire` accepts the row shape from `chainFrom` (where
17
- // `keyframe` is the SQLite INTEGER 0 / 1) and returns the same fields
18
- // with `keyframe` normalised to a strict boolean for the wire.
18
+ // Wire shape `chainForWire` produces from a `chainFrom` row, with
19
+ // `keyframe` normalised from the SQLite INTEGER 0/1 to a strict boolean.
19
20
  type WireRevision = {
20
21
  base: string | null
21
22
  id: string
@@ -25,6 +26,47 @@ type WireRevision = {
25
26
  signature: string
26
27
  }
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
+
28
70
  export type SyncHandlersDeps = {
29
71
  handle: Handle
30
72
  send: (socket: WebSocket, msg: object) => void
@@ -39,6 +81,11 @@ export type SyncHandlersDeps = {
39
81
  subscribe: (socket: WebSocket, tag: string) => void
40
82
  getNonce: (socket: WebSocket) => string | undefined
41
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
42
89
  sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
43
90
  workspaceExists: (tag: string) => Promise<boolean>
44
91
  // Objstore inventory snapshot for a workspace tag, as wire rows. The
@@ -53,6 +100,10 @@ export type SyncHandlersDeps = {
53
100
 
54
101
  export type SyncHandlers = {
55
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>
56
107
  handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
57
108
  // Exported because the dispatcher's inflight-cap `busy` NACK path
58
109
  // emits a save-error too (the only emit site outside this module).
@@ -60,7 +111,7 @@ export type SyncHandlers = {
60
111
  }
61
112
 
62
113
  export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
63
- const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
114
+ const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
64
115
 
65
116
  // Typed wrapper for the three `workspace-save-error` emit sites
66
117
  // (too-large at handleSave, stale-base after the catch-up, busy at
@@ -88,21 +139,34 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
88
139
  send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
89
140
  }
90
141
 
91
- // Normalise `keyframe` on outbound chain entries to a strict boolean.
142
+ // Normalise `keyframe` to a strict boolean on outbound chain entries.
92
143
  // SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
93
- // raw rows; the wire contract (and the canonical signing payload)
94
- // uses strict `=== true` to mark keyframes. Forwarding the integer
95
- // shape works only because every shipping client coerces via
96
- // `Boolean(rev.keyframe)` before reconstructing the canonical bytes —
97
- // fragile if a future client (or test harness) ever strict-compares.
98
- // Convert once on the send side.
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.
99
148
  function chainForWire(revisions: RevisionRow[]): WireRevision[] {
100
149
  return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
101
150
  }
102
151
 
103
- async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
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> {
104
168
  // `base` is `string | null`; null is the keyframe-root marker.
105
- 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
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' }
106
170
  // Compute canonical bytes + content-addressed id ONCE, then thread
107
171
  // both through the precheck → sig verify → commit pipeline:
108
172
  // 1. canonicalSave (sync, throws on lone-surrogate input)
@@ -117,77 +181,43 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
117
181
  // 6. commitRevision — re-checks dup + base + inserts via a single
118
182
  // gated INSERT (dup gate + head-equals-base gate + the
119
183
  // server-assigned seq folded into one statement) — NO write
120
- // lock. The dup recheck, headFor, base-match and insert all
121
- // collapse into that one statement, whose head-check and
122
- // MAX(seq) read one snapshot; the `UNIQUE(workspace_tag, seq)`
123
- // PK rejects any racer that computed the same seq. So two
124
- // concurrent saves with the same `base` and different ids
125
- // can't both insert (the loser's `head IS base` gate fails →
126
- // `stale-base`, no chain fork even though UNIQUE is on id, not
127
- // base), and two concurrent same-id retransmits resolve to one
128
- // `inserted` + one `duplicate` with no UNIQUE throw escaping.
129
- // See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
184
+ // lock. See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
130
185
  let canonical: Uint8Array<ArrayBuffer>
131
- try { canonical = canonicalSave(msg) } catch { return }
186
+ try { canonical = canonicalSave(msg) } catch { return { kind: 'rejected' } }
132
187
  const id = await computeRevisionIdFromCanonical(canonical)
133
188
  const tag = msg.workspaceTag
189
+ const baseNorm = msg.base ?? null
134
190
  if (await revisionExists(handle, tag, id)) {
135
191
  if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
136
- send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: msg.base ?? null, id })
137
- return
192
+ return { kind: 'duplicate', id, base: baseNorm }
138
193
  }
139
194
  if (!await verifyEd25519(tag, canonical, msg.signature)) {
140
195
  if (debug) console.warn('reject save: bad signature', debugTag(tag))
141
- return
196
+ return { kind: 'rejected' }
142
197
  }
143
198
  // Size policy — emit an explicit error so the client can surface
144
199
  // the failure to the user. Without this, an oversized save hangs
145
200
  // forever in the client's `pending` slot (no ack, no rebase).
146
201
  if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
147
202
  if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
148
- sendSaveError(socket, tag, msg.base == null || typeof msg.base !== 'string' ? null : msg.base, 'too-large')
149
- return
203
+ return { kind: 'too-large', base: baseNorm }
150
204
  }
151
205
  // Auth gate for the FIRST action against a workspace tag that
152
- // doesn't yet exist on the server (no rows in workspace_revision
153
- // AND none in workspace_object). Once any row lands, the workspace
154
- // is established and every signed action flows freely — access
155
- // control falls back to the Ed25519 signature for the rest of the
156
- // workspace's lifetime. Checked AFTER sig verify so the
157
- // `unauthorized` frame only reaches a legitimate signer; shape /
158
- // sig attacks still drop silently.
159
- //
160
- // RACE: `workspaceExists` reads at a different moment than the
161
- // commit's gated INSERT (a plain TOCTOU — there is no lock spanning
162
- // the two). Under concurrent saves on a fresh tag, an
163
- // unauthenticated socket whose `workspaceExists` observes "true"
164
- // (because an authenticated peer's commit landed between this
165
- // socket's check and its commit) skips the gate and commits as the
166
- // second writer. Accepted: the unauthenticated peer still had to
167
- // produce a valid Ed25519 signature (= holds the workspace seed),
168
- // and "two concurrent writes both authorising" is the worst case.
169
- // Tightening would require folding the gate into the commit
170
- // statement itself and is not worth the layer crossing for the
171
- // soft-policy guarantee.
172
- if (requiresAuth(socket) && !await workspaceExists(tag)) {
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)) {
173
213
  if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
174
- sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: msg.base ?? null })
175
- return
214
+ return { kind: 'unauthorized', base: baseNorm }
176
215
  }
177
- // NOTE: Earlier revisions auto-subscribed the sending socket here.
178
- // That created a replay vector — a passive observer who captured
179
- // any single valid `workspace-save` frame could replay it from any
180
- // TCP connection forever to attach as a subscriber and silently
181
- // mirror every future encrypted broadcast for the workspace,
182
- // without ever holding the seed (the duplicate-id path returns
183
- // ack-only and doesn't reject the socket). Audit round-9 H1.
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.
184
220
  //
185
- // The legitimate client always sends an explicit
186
- // `workspace-subscribe` (see `trySendSubscribe` in
187
- // `client/triage-sync.js` — fires on key derivation, on socket
188
- // open, on continuity-break recovery, on dismissError). The
189
- // subscribe path remains the only way to attach as a subscriber.
190
- const baseNorm = msg.base ?? null
191
221
  // `keyframe === true` is what canonicalSave bound the signature to
192
222
  // (strict equality); the signer's intent is unambiguous here.
193
223
  const keyframe = msg.keyframe === true
@@ -197,46 +227,32 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
197
227
  })
198
228
  if (commit.kind === 'duplicate') {
199
229
  if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
200
- send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: baseNorm, id })
201
- return
230
+ return { kind: 'duplicate', id, base: baseNorm }
202
231
  }
203
232
  if (commit.kind === 'stale-base') {
204
- // Client claimed a base that's no longer head. Catch-up chain is
205
- // computed OUTSIDE the lock — a concurrent commit landing between
206
- // lock-release and `chainFrom` only means the catch-up is fresher
207
- // than the recheck saw, which is benign (clients tolerate extra
208
- // revisions in the chain).
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).
209
241
  //
210
- // Wire order: send `workspace-state` (catch-up) FIRST, then the
211
- // typed `workspace-save-error { reason: 'stale-base' }`. The
212
- // catch-up's handler clears `session.pending`; the subsequent
213
- // error frame's `handleSaveError` then early-returns on the
214
- // missing pending and does NOT mark the session errored — exactly
215
- // what we want, since stale-base is a recoverable race (client
216
- // rebases + re-saves). The typed frame is for protocol clarity
217
- // (debug surfaces / explicit rejection signal), not for triggering
218
- // an error transition. Audit follow-up to round-15 —
219
- // `sync-server-races.test.js:1105`.
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.
220
249
  const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
221
250
  if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
222
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
223
- sendSaveError(socket, tag, baseNorm, 'stale-base')
224
- return
251
+ return { kind: 'stale-base', base: baseNorm, revisions }
225
252
  }
226
253
  if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
227
- send(socket, {
228
- type: 'workspace-save-ack',
229
- workspaceTag: tag,
230
- base: baseNorm,
231
- id,
232
- })
233
- // Carry `keyframe` as a strict boolean on the broadcast wire —
234
- // peers strict-compare `=== true` (matching the canonical-payload
235
- // contract). The previous shape emitted `keyframe ? 1 : 0` which a
236
- // strict check would treat as non-keyframe, making a replayed
237
- // keyframe look like a regular delta on broadcast paths even though
238
- // the chain-fetch path (chainFrom → SQLite integer) DID round-trip
239
- // correctly.
254
+ // Carry `keyframe` as a strict boolean on the broadcast wire — peers
255
+ // strict-compare `=== true` (matching the canonical-payload contract).
240
256
  broadcast(tag, {
241
257
  type: 'workspace-state',
242
258
  workspaceTag: tag,
@@ -248,28 +264,67 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
248
264
  ciphertext: msg.ciphertext,
249
265
  signature: msg.signature,
250
266
  }],
251
- }, socket)
252
- // Cross-instance fan-out. The bus payload carries only the revision
253
- // id — peers on OTHER instances re-fetch the row from
254
- // workspace_revision to compose their local `workspace-state`. Sized
255
- // for the bus's 8 KB payload budget, which can't carry a 2 MiB
256
- // ciphertext. SQLite mode passes a no-op; Neon mode publishes via
257
- // pg_notify. Best-effort: a dropped publish only means peers on
258
- // other instances miss the live push, but they still catch up via
259
- // the shared DB on their next subscribe / reconnect.
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.
260
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' })
261
312
  }
262
313
 
263
314
  async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
264
- if (typeof msg.workspaceTag !== 'string') return
265
- // Same `string | null` contract as `base` in handleSave. The signed
266
- // canonical uses `String(from)`, but the chain-lookup path
267
- // (`typeof msg.from === 'string' ? msg.from : null`) treats every
268
- // non-string as null — so a legit signer sending `from: { … }`
269
- // would silently take the keyframe-fallback path even though the
270
- // signature was over a different canonical shape. Reject at the
271
- // wire gate.
272
- if (msg.from != null && typeof msg.from !== 'string') return
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
273
328
  // The challenge nonce we issued on this socket is bound into the
274
329
  // signed canonical, blocking cross-connection replay of a captured
275
330
  // subscribe frame. A subscribe arriving before we sent the
@@ -323,5 +378,5 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
323
378
  send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
324
379
  }
325
380
 
326
- return { handleSave, handleSubscribe, sendSaveError }
381
+ return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
327
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
@@ -16,6 +16,7 @@ import { Buffer } from 'node:buffer'
16
16
  import type { IncomingMessage as HttpRequest } from 'node:http'
17
17
  import { decodeUtf8 } from '../common/utf8.js'
18
18
  import type { SaveErrorReason } from '../common/save-error-reason.ts'
19
+ import type { ServerInfo } from '../common/server-info.ts'
19
20
  import { Peer, type PeerRegistry } from './peer.ts'
20
21
  import { errMsg, errStack, randomId } from './util.ts'
21
22
  import type { SaveMsg, SubscribeMsg } from './sign.ts'
@@ -36,6 +37,9 @@ type IncomingMessage = {
36
37
  // SSE path doesn't need them.
37
38
  export type PeerConnectionDeps = {
38
39
  peers: PeerRegistry
40
+ // The mode this server advertises, emitted as the first `server-info`
41
+ // frame on every connection (see setupPeerConnection).
42
+ serverInfo: ServerInfo
39
43
  send: (socket: WebSocket, msg: object) => void
40
44
  unsubscribeAll: (socket: WebSocket) => void
41
45
  handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
@@ -55,33 +59,36 @@ export type WsServerDeps = PeerConnectionDeps & {
55
59
  }
56
60
 
57
61
  // Per-connection setup: create Peer, send challenge, wire message /
58
- // close / error / pong listeners. Shared between the WS connection
59
- // handler below and the SSE+POST adapter in ./sse-server.ts. The
60
- // `socket` parameter is the WebSocket-shaped surface (a real `ws`
61
- // `WebSocket`, or an `SseSession` cast through `as unknown as
62
- // WebSocket`); the function only uses the subset both expose.
62
+ // close / error / pong listeners. `socket` is the WebSocket-shaped
63
+ // surface — a real `ws` `WebSocket`, or an `SseSession` cast through
64
+ // `as unknown as WebSocket`; only the subset both expose is used.
63
65
  export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: PeerConnectionDeps): void {
64
66
  const {
65
- peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
67
+ peers, serverInfo, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
66
68
  sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, debug,
67
69
  } = deps
68
70
  if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
69
- // One Peer holds this connection's state (challenge / authorized /
70
- // alive / inflight / tags). Created before any client frame can
71
- // arrive (`socket.on('message')` is wired below). The heartbeat
72
- // sweep flips `alive` false after each `ping()`; the `pong` listener
73
- // flips it back, and a socket still false on the next sweep is
74
- // terminated — the only thing closing FDs for an idle peer.
71
+ // One Peer holds this connection's state, created before any client
72
+ // frame can arrive (`socket.on('message')` is wired below). The
73
+ // heartbeat sweep's two-tick liveness check (see below) is the only
74
+ // thing closing FDs for an otherwise-idle peer.
75
75
  const peer = new Peer(randomId())
76
76
  peers.set(socket, peer)
77
77
  socket.on('pong', () => { peer.alive = true })
78
- // Issue the per-connection challenge nonce BEFORE the client can
79
- // send anything that needs it. The client signs it into every
80
- // `workspace-subscribe` (see canonicalSubscribe in server/sign.ts);
81
- // a captured subscribe frame can't be replayed from a different
82
- // connection because that connection's nonce differs and the
78
+ // Issue the per-connection challenge nonce FIRST — before the client can
79
+ // send anything that needs it, AND as frame #0 so clients/tests that
80
+ // positionally read the first frame as the challenge keep working. The
81
+ // client signs it into every `workspace-subscribe` (see canonicalSubscribe
82
+ // in server-e2e/sign.ts); a captured subscribe frame can't be replayed from
83
+ // a different connection because that connection's nonce differs and the
83
84
  // signature won't verify against the new canonical bytes. Round-9 H2.
84
85
  send(socket, { type: 'challenge', nonce: peer.challenge })
86
+ // Then advertise the sync protocol (the mode probe), right after the
87
+ // challenge — the WS plane and the SSE fallback share this path. A client
88
+ // uses it to detect the mode, cache it, and refuse a cross-mode switch.
89
+ // Unauthenticated + mode-agnostic; an older client ignores it (predicate
90
+ // readers skip it; the workspaceTag demux drops it).
91
+ send(socket, { type: 'server-info', ...serverInfo })
85
92
  // Per-socket handlers are DELIBERATELY NOT serialized (vs the
86
93
  // client-side `messageQueue = messageQueue.then(...)` Promise
87
94
  // chain inside `client/triage-sync.ts:onTransportMessage`).
@@ -90,10 +97,10 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
90
97
  // boundaries inside the handlers. Per-resource correctness needs
91
98
  // no in-process lock: `commitRevision` resolves concurrent saves
92
99
  // via its single gated INSERT (one snapshot + the
93
- // `UNIQUE(workspace_tag, seq)` PK — see `server/db.ts`), and the
100
+ // `UNIQUE(workspace_tag, seq)` PK — see `server-e2e/db.ts`), and the
94
101
  // objstore handlers (`commitPut` / `beginPut` / `deleteObject`)
95
102
  // via the version compare-and-set + content-addressing (see
96
- // `server/objstore/store.ts`), backed by post-await
103
+ // `server-e2e/objstore/store.ts`), backed by post-await
97
104
  // `readyState === OPEN` rechecks in every objstore handler. The
98
105
  // unbounded fan-out is capped by `maxInflightPerSocket` (see
99
106
  // also the `'busy'` NACK at the cap below). Concurrent dispatch
@@ -133,7 +140,7 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
133
140
  // shedding load. Pings go through a fast inline `send(pong)`
134
141
  // path BELOW that doesn't bump the per-socket inflight counter,
135
142
  // so a ping-spam at the cap can't outrun the gate. Transport
136
- // audit `server/index.ts:590` + post-#58 audit follow-up.
143
+ // audit `server-e2e/index.ts:590` + post-#58 audit follow-up.
137
144
  if (parsed.type === 'ping') {
138
145
  send(socket, { type: 'pong' })
139
146
  return
@@ -228,9 +235,8 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
228
235
  // Surface socket-level errors instead of swallowing — these are
229
236
  // the signals operators want under abuse / network flakiness
230
237
  // (TLS handshake failures, frame-decode errors, ws-protocol
231
- // violations). The previous `() => {}` left every per-connection
232
- // failure invisible. `close` fires after `error` and runs the
233
- // unsubscribe cleanup, so logging here doesn't risk leaking.
238
+ // violations). `close` fires after `error` and runs the unsubscribe
239
+ // cleanup, so logging here doesn't risk leaking.
234
240
  socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
235
241
  }
236
242
 
@@ -0,0 +1,94 @@
1
+ // Module customization hook that erases TypeScript types using Node's
2
+ // built-in `module.stripTypeScriptTypes()` — the same eraser that powers
3
+ // Node's unflagged `.ts` execution, exposed here as an explicit loader so
4
+ // this package can run its own `.ts` source after it's installed as a
5
+ // dependency. Built on Node's bundled eraser, it needs no third-party dep
6
+ // (cf. `amaro`, which is just the library Node already bundles for this).
7
+ //
8
+ // Why this is needed at all — Node DISABLES built-in `.ts` stripping for
9
+ // any file under `node_modules` (it throws ERR_UNSUPPORTED_NODE_MODULES_
10
+ // TYPE_STRIPPING), which is exactly where this package's `.ts` files live
11
+ // once it's a dependency. A registered `load` hook is not bound by that
12
+ // rule, so it restores `.ts` execution from inside `node_modules`.
13
+ //
14
+ // Registered as an entry point — `@preventive/triage/strip-types-loader` —
15
+ // so consumers can run the server (or import `@preventive/triage/server`)
16
+ // with the hook active:
17
+ // node --import @preventive/triage/strip-types-loader <entry>
18
+ //
19
+ // `registerHooks` (synchronous, in-thread) is used over the async,
20
+ // worker-thread `module.register`: stripping is a pure synchronous string
21
+ // transform, so there's no reason to round-trip every load through a
22
+ // worker — and the read below is synchronous too.
23
+ //
24
+ // We must read + strip the source OURSELVES rather than delegate to
25
+ // `nextLoad`: under `node_modules`, `nextLoad`'s default format detection
26
+ // is what throws ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, before our
27
+ // hook ever sees the source. Reading the bytes and short-circuiting with
28
+ // an explicit format sidesteps that gate.
29
+ //
30
+ // `mode: 'strip'` (types elided, no enum/namespace transform) suffices —
31
+ // the repo is `erasableSyntaxOnly` (tsconfig.json). Strip mode replaces
32
+ // each elided type with equal-width whitespace, so line AND column
33
+ // positions survive 1:1 and stack traces stay exact without a source map.
34
+
35
+ import { isUtf8 } from 'node:buffer'
36
+ import { readFileSync } from 'node:fs'
37
+ import { registerHooks, stripTypeScriptTypes } from 'node:module'
38
+ import { fileURLToPath } from 'node:url'
39
+
40
+ // `.ts` / `.mts` -> ESM, `.cts` -> CommonJS. The `kind` group is the
41
+ // optional `m`/`c` infix; absent (plain `.ts`) defaults to ESM, which is
42
+ // correct for this `"type": "module"` package.
43
+ const TS_RE = /\.(?<kind>[cm])?ts$/u
44
+
45
+ // Defence-in-depth: assert strip mode only ERASED — every output character
46
+ // is either unchanged, replaced by whitespace, or replaced by a semicolon
47
+ // (swc's ASI guard — swc-project/swc#9331). This enforces at runtime what
48
+ // tsconfig's `erasableSyntaxOnly` asserts at type-check time: nothing but
49
+ // type syntax was removed, so the bytes Node runs can't silently diverge
50
+ // from the bytes we wrote.
51
+ //
52
+ // The comparison is per-CODE-POINT, not per-byte. swc preserves byte
53
+ // offsets by swapping each elided multi-byte character for a *same-width*
54
+ // Unicode whitespace char — e.g. an em-dash (U+2014) in a type-level
55
+ // comment becomes U+2002 EN SPACE, not ASCII 0x20 — so a raw-byte check
56
+ // would false-positive on the many such comments in this codebase. `\s`
57
+ // matches those Unicode spaces, and strip preserves code-unit length too,
58
+ // so string indices stay aligned.
59
+ const WHITESPACE = /\s/u
60
+
61
+ function assertOnlyErased(url, source, stripped) {
62
+ if (source.length !== stripped.length) {
63
+ throw new Error(`strip-types-loader: ${url} changed length ${source.length} -> ${stripped.length}; not a pure type erasure`)
64
+ }
65
+ for (let i = 0; i < stripped.length; i++) {
66
+ const ch = stripped[i]
67
+ if (ch === source[i] || ch === ';' || WHITESPACE.test(ch)) continue
68
+ throw new Error(`strip-types-loader: ${url} char ${i} changed ${JSON.stringify(source[i])} -> ${JSON.stringify(ch)}; not a pure type erasure`)
69
+ }
70
+ }
71
+
72
+ registerHooks({
73
+ load(url, context, nextLoad) {
74
+ const match = TS_RE.exec(new URL(url).pathname)
75
+ if (match) {
76
+ const bytes = readFileSync(fileURLToPath(url))
77
+ // Assert the source is valid UTF-8 before decoding: a mis-encoded
78
+ // file (latin-1, UTF-16, a stray binary) would otherwise decode with
79
+ // U+FFFD replacement chars and we'd strip / execute silent garbage.
80
+ if (!isUtf8(bytes)) {
81
+ throw new Error(`strip-types-loader: ${url} is not valid UTF-8`)
82
+ }
83
+ const raw = bytes.toString('utf8')
84
+ const stripped = stripTypeScriptTypes(raw, { mode: 'strip' })
85
+ assertOnlyErased(url, raw, stripped)
86
+ return {
87
+ format: match.groups.kind === 'c' ? 'commonjs' : 'module',
88
+ source: stripped,
89
+ shortCircuit: true,
90
+ }
91
+ }
92
+ return nextLoad(url, context)
93
+ },
94
+ })
File without changes
File without changes
File without changes