@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.5

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.
@@ -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'
@@ -24,6 +26,47 @@ type WireRevision = {
24
26
  signature: string
25
27
  }
26
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
+
27
70
  export type SyncHandlersDeps = {
28
71
  handle: Handle
29
72
  send: (socket: WebSocket, msg: object) => void
@@ -38,6 +81,11 @@ export type SyncHandlersDeps = {
38
81
  subscribe: (socket: WebSocket, tag: string) => void
39
82
  getNonce: (socket: WebSocket) => string | undefined
40
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
41
89
  sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
42
90
  workspaceExists: (tag: string) => Promise<boolean>
43
91
  // Objstore inventory snapshot for a workspace tag, as wire rows. The
@@ -52,6 +100,10 @@ export type SyncHandlersDeps = {
52
100
 
53
101
  export type SyncHandlers = {
54
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/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>
55
107
  handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
56
108
  // Exported because the dispatcher's inflight-cap `busy` NACK path
57
109
  // emits a save-error too (the only emit site outside this module).
@@ -59,7 +111,7 @@ export type SyncHandlers = {
59
111
  }
60
112
 
61
113
  export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
62
- 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
63
115
 
64
116
  // Typed wrapper for the three `workspace-save-error` emit sites
65
117
  // (too-large at handleSave, stale-base after the catch-up, busy at
@@ -97,9 +149,24 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
97
149
  return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
98
150
  }
99
151
 
100
- 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> {
101
168
  // `base` is `string | null`; null is the keyframe-root marker.
102
- 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' }
103
170
  // Compute canonical bytes + content-addressed id ONCE, then thread
104
171
  // both through the precheck → sig verify → commit pipeline:
105
172
  // 1. canonicalSave (sync, throws on lone-surrogate input)
@@ -114,72 +181,43 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
114
181
  // 6. commitRevision — re-checks dup + base + inserts via a single
115
182
  // gated INSERT (dup gate + head-equals-base gate + the
116
183
  // server-assigned seq folded into one statement) — NO write
117
- // lock. The dup recheck, headFor, base-match and insert all
118
- // collapse into that one statement, whose head-check and
119
- // MAX(seq) read one snapshot; the `UNIQUE(workspace_tag, seq)`
120
- // PK rejects any racer that computed the same seq. So two
121
- // concurrent saves with the same `base` and different ids
122
- // can't both insert (the loser's `head IS base` gate fails →
123
- // `stale-base`, no chain fork even though UNIQUE is on id, not
124
- // base), and two concurrent same-id retransmits resolve to one
125
- // `inserted` + one `duplicate` with no UNIQUE throw escaping.
126
- // See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
184
+ // lock. See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
127
185
  let canonical: Uint8Array<ArrayBuffer>
128
- try { canonical = canonicalSave(msg) } catch { return }
186
+ try { canonical = canonicalSave(msg) } catch { return { kind: 'rejected' } }
129
187
  const id = await computeRevisionIdFromCanonical(canonical)
130
188
  const tag = msg.workspaceTag
189
+ const baseNorm = msg.base ?? null
131
190
  if (await revisionExists(handle, tag, id)) {
132
191
  if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
133
- send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: msg.base ?? null, id })
134
- return
192
+ return { kind: 'duplicate', id, base: baseNorm }
135
193
  }
136
194
  if (!await verifyEd25519(tag, canonical, msg.signature)) {
137
195
  if (debug) console.warn('reject save: bad signature', debugTag(tag))
138
- return
196
+ return { kind: 'rejected' }
139
197
  }
140
198
  // Size policy — emit an explicit error so the client can surface
141
199
  // the failure to the user. Without this, an oversized save hangs
142
200
  // forever in the client's `pending` slot (no ack, no rebase).
143
201
  if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
144
202
  if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
145
- sendSaveError(socket, tag, msg.base == null || typeof msg.base !== 'string' ? null : msg.base, 'too-large')
146
- return
203
+ return { kind: 'too-large', base: baseNorm }
147
204
  }
148
205
  // Auth gate for the FIRST action against a workspace tag that
149
- // doesn't yet exist on the server (no rows in workspace_revision
150
- // AND none in workspace_object). Once any row lands, the workspace
151
- // is established and every signed action flows freely — access
152
- // control falls back to the Ed25519 signature for the rest of the
153
- // workspace's lifetime. Checked AFTER sig verify so the
154
- // `unauthorized` frame only reaches a legitimate signer; shape /
155
- // sig attacks still drop silently.
156
- //
157
- // RACE: `workspaceExists` reads at a different moment than the
158
- // commit's gated INSERT (a plain TOCTOU — there is no lock spanning
159
- // the two). Under concurrent saves on a fresh tag, an
160
- // unauthenticated socket whose `workspaceExists` observes "true"
161
- // (because an authenticated peer's commit landed between this
162
- // socket's check and its commit) skips the gate and commits as the
163
- // second writer. Accepted: the unauthenticated peer still had to
164
- // produce a valid Ed25519 signature (= holds the workspace seed),
165
- // and "two concurrent writes both authorising" is the worst case.
166
- // Tightening would require folding the gate into the commit
167
- // statement itself and is not worth the layer crossing for the
168
- // soft-policy guarantee.
169
- 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)) {
170
213
  if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
171
- sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: msg.base ?? null })
172
- return
214
+ return { kind: 'unauthorized', base: baseNorm }
173
215
  }
174
- // Save does NOT subscribe the sending socket — that would be a
175
- // replay vector: a passive observer who captured one valid
176
- // `workspace-save` frame could replay it from any TCP connection to
177
- // attach as a subscriber and mirror every future encrypted
178
- // broadcast, without holding the seed (the duplicate-id path returns
179
- // ack-only, doesn't reject the socket). Explicit `workspace-subscribe`
180
- // (signs the per-connection challenge nonce) is the ONLY attach path.
181
- // Audit round-9 H1.
182
- const baseNorm = msg.base ?? null
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
+ //
183
221
  // `keyframe === true` is what canonicalSave bound the signature to
184
222
  // (strict equality); the signer's intent is unambiguous here.
185
223
  const keyframe = msg.keyframe === true
@@ -189,43 +227,24 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
189
227
  })
190
228
  if (commit.kind === 'duplicate') {
191
229
  if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
192
- send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: baseNorm, id })
193
- return
230
+ return { kind: 'duplicate', id, base: baseNorm }
194
231
  }
195
232
  if (commit.kind === 'stale-base') {
196
- // Client claimed a base that's no longer head. Catch-up chain is
197
- // computed OUTSIDE the lock — a concurrent commit landing between
198
- // lock-release and `chainFrom` only means the catch-up is fresher
199
- // than the recheck saw, which is benign (clients tolerate extra
200
- // revisions in the chain).
201
- //
202
- // Wire order: send `workspace-state` (catch-up) FIRST, then the
203
- // typed `workspace-save-error { reason: 'stale-base' }`. The
204
- // catch-up's handler clears `session.pending`; the subsequent
205
- // error frame's `handleSaveError` then early-returns on the
206
- // missing pending and does NOT mark the session errored — exactly
207
- // what we want, since stale-base is a recoverable race (client
208
- // rebases + re-saves). The typed frame is for protocol clarity
209
- // (debug surfaces / explicit rejection signal), not for triggering
210
- // an error transition. Audit follow-up to round-15 —
211
- // `sync-server-races.test.js:1105`.
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).
212
241
  const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
213
242
  if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
214
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
215
- sendSaveError(socket, tag, baseNorm, 'stale-base')
216
- return
243
+ return { kind: 'stale-base', base: baseNorm, revisions }
217
244
  }
218
245
  if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
219
- send(socket, {
220
- type: 'workspace-save-ack',
221
- workspaceTag: tag,
222
- base: baseNorm,
223
- id,
224
- })
225
246
  // Carry `keyframe` as a strict boolean on the broadcast wire — peers
226
- // strict-compare `=== true` (matching the canonical-payload
227
- // contract). An integer 0/1 here would make a keyframe look like a
228
- // regular delta on broadcast paths.
247
+ // strict-compare `=== true` (matching the canonical-payload contract).
229
248
  broadcast(tag, {
230
249
  type: 'workspace-state',
231
250
  workspaceTag: tag,
@@ -237,16 +256,51 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
237
256
  ciphertext: msg.ciphertext,
238
257
  signature: msg.signature,
239
258
  }],
240
- }, socket)
241
- // Cross-instance fan-out. The bus payload carries only the revision
242
- // id — peers on OTHER instances re-fetch the row from
243
- // workspace_revision to compose their local `workspace-state`. Sized
244
- // for the bus's 8 KB payload budget, which can't carry a 2 MiB
245
- // ciphertext. SQLite mode passes a no-op; Neon mode publishes via
246
- // pg_notify. Best-effort: a dropped publish only means peers on
247
- // other instances miss the live push, but they still catch up via
248
- // the shared DB on their next subscribe / reconnect.
259
+ }, except)
260
+ // Cross-instance fan-out. The bus payload carries only the revision id —
261
+ // peers on OTHER instances re-fetch the row. SQLite mode passes a no-op;
262
+ // Neon mode publishes via pg_notify. Best-effort.
249
263
  publishRevision(tag, id)
264
+ return { kind: 'ack', id, base: baseNorm }
265
+ }
266
+
267
+ // WS renderer: run the shared pipeline against this socket and render the
268
+ // outcome as the protocol frames the client expects on its stream. A
269
+ // `rejected` outcome drops silently (matches the prior malformed/bad-sig
270
+ // behaviour). For every non-rejected outcome the tag was validated inside
271
+ // `commitSave`, so the cast to string is sound.
272
+ async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
273
+ const outcome = await commitSave(msg, requiresAuth(socket), socket)
274
+ if (outcome.kind === 'rejected') return
275
+ const tag = msg.workspaceTag as string
276
+ if (outcome.kind === 'unauthorized') { sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: outcome.base }); return }
277
+ if (outcome.kind === 'too-large') { sendSaveError(socket, tag, outcome.base, 'too-large'); return }
278
+ if (outcome.kind === 'stale-base') {
279
+ // State FIRST (its handler clears pending), then the typed error.
280
+ send(socket, { type: 'workspace-state', workspaceTag: tag, revisions: outcome.revisions })
281
+ sendSaveError(socket, tag, outcome.base, 'stale-base')
282
+ return
283
+ }
284
+ // ack | duplicate
285
+ send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: outcome.base, id: outcome.id })
286
+ }
287
+
288
+ // REST renderer: the session-independent `POST /api/sync/save` plane. Reads
289
+ // the save frame from the JSON body, runs the SAME pipeline (no socket;
290
+ // new-workspace gate = passwordConfigured; broadcast except = null), and
291
+ // maps the outcome to a JSON + HTTP status the client switches on. SSE-mode
292
+ // clients POST here so a save doesn't take over their event-stream; a 401
293
+ // routes them to the in-band frame (which runs the operator auth flow).
294
+ // Mounted + gated (same-origin, shutdown, idle-timeout) in server/http.ts.
295
+ async function handleSaveRest(req: IncomingMessage, res: ServerResponse): Promise<void> {
296
+ const body = await readSaveBody(req)
297
+ if (!body || typeof body !== 'object') { respondJson(res, 400, { reason: 'bad-request' }); return }
298
+ const outcome = await commitSave(body as SaveMsg, passwordConfigured, null)
299
+ if (outcome.kind === 'ack' || outcome.kind === 'duplicate') { respondJson(res, 200, { ok: true, id: outcome.id }); return }
300
+ if (outcome.kind === 'stale-base') { respondJson(res, 409, { reason: 'stale-base', revisions: outcome.revisions }); return }
301
+ if (outcome.kind === 'too-large') { respondJson(res, 413, { reason: 'too-large' }); return }
302
+ if (outcome.kind === 'unauthorized') { respondJson(res, 401, { reason: 'unauthorized' }); return }
303
+ respondJson(res, 400, { reason: 'bad-request' })
250
304
  }
251
305
 
252
306
  async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
@@ -316,5 +370,5 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
316
370
  send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
317
371
  }
318
372
 
319
- return { handleSave, handleSubscribe, sendSaveError }
373
+ return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
320
374
  }
package/server/util.ts CHANGED
@@ -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