@preventive/triage 1.0.0-alpha.4 → 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.
@@ -52,6 +52,24 @@ import { errMsg, randomId } from './util.ts'
52
52
  // path so the same `location` block routes both.
53
53
  export const SSE_OPEN_PATH = '/api/sync/sse'
54
54
 
55
+ // Cadence of the server-driven keepalive sweep: every tick we write a `:`
56
+ // comment to each open session's downstream so intermediary proxies don't
57
+ // idle-close it (nginx et al. default to a ~60s read timeout). This is the
58
+ // server's own liveness upkeep — the client no longer POSTs a periodic ping
59
+ // (which forced a stream takeover every tick).
60
+ //
61
+ // Reaping model (replaces the old POST-driven idle timer): a session is
62
+ // dropped on its downstream response `close` — clean disconnect, or a
63
+ // half-open socket the per-session TCP keepalive forces closed (see
64
+ // SseSession.SOCKET_KEEPALIVE_MS) — NOT by this sweep. We intentionally
65
+ // trust connection-level liveness. The one topology this can't see is a
66
+ // buffering / TLS-terminating proxy that holds the upstream open after the
67
+ // real client vanished (keepalive then probes the proxy hop, not the
68
+ // client); such a session lingers until `maxSessions`, the hard backstop.
69
+ // This is the same exposure the WS heartbeat already has (its ping only
70
+ // proves the proxy↔server hop too), not a new class of leak.
71
+ const KEEPALIVE_SWEEP_MS = 30_000
72
+
55
73
  export type SseServerDeps = {
56
74
  // The WS dispatch is the cohesive unit; SSE just provides another
57
75
  // transport into it. Closure over the same handler / hub / objstore
@@ -70,12 +88,6 @@ export type SseServerDeps = {
70
88
  // the per-frame budget is the same as the WS plane after the
71
89
  // dispatcher splits them.
72
90
  maxBodyBytes: number
73
- // Idle timeout for a session with no inbound POSTs. Detects the
74
- // wandered-off browser tab the WS heartbeat sweep handles via
75
- // ping/pong on real sockets. The client's JSON ping/pong heartbeat
76
- // (every 15s) is the steady-state liveness signal; this is the
77
- // hard ceiling.
78
- sessionIdleMs: number
79
91
  debug: boolean
80
92
  }
81
93
 
@@ -86,6 +98,9 @@ export type SseServer = {
86
98
  // Iterates active sessions. Lifecycle's graceful-shutdown loop
87
99
  // reads this to close SSE sessions alongside WS clients.
88
100
  sessions: () => Iterable<SseSession>
101
+ // The keepalive-sweep timer. Lifecycle clears it on shutdown (parity
102
+ // with the WS heartbeat timer) so a tick can't fire mid-teardown.
103
+ keepaliveTimer: ReturnType<typeof setInterval>
89
104
  }
90
105
 
91
106
  // Inbound POST body. Every field optional — an empty-body POST is a
@@ -98,7 +113,7 @@ type SseBody = {
98
113
  }
99
114
 
100
115
  export function installSseServer(deps: SseServerDeps): SseServer {
101
- const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes, sessionIdleMs, debug } = deps
116
+ const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes, debug } = deps
102
117
 
103
118
  // Active SSE sessions, keyed by the random session id `createSession`
104
119
  // mints on the first POST that lacks a `?id=` (or whose id this
@@ -108,26 +123,9 @@ export function installSseServer(deps: SseServerDeps): SseServer {
108
123
  // mint a fresh session instead, so a multi-replica deployment doesn't
109
124
  // require sticky LB routing to recover.
110
125
  const sessions = new Map<string, SseSession>()
111
- // Per-session idle timer handle. Reset on every inbound POST; fires
112
- // after `sessionIdleMs` of silence to close a stranded session.
113
- const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
114
-
115
- function armIdleTimer(sid: string, session: SseSession): void {
116
- clearTimeout(idleTimers.get(sid))
117
- if (sessionIdleMs <= 0) return
118
- const t = setTimeout(() => {
119
- if (debug) console.warn(`sse: session ${sid.slice(0, 8)}… idle ${sessionIdleMs}ms → close`)
120
- try { session.terminate() } catch {}
121
- }, sessionIdleMs)
122
- t.unref?.()
123
- idleTimers.set(sid, t)
124
- }
125
126
 
126
127
  function dropSession(sid: string): void {
127
128
  sessions.delete(sid)
128
- const t = idleTimers.get(sid)
129
- if (t) clearTimeout(t)
130
- idleTimers.delete(sid)
131
129
  }
132
130
 
133
131
  function writeSseHeaders(res: ServerResponse): void {
@@ -149,7 +147,7 @@ export function installSseServer(deps: SseServerDeps): SseServer {
149
147
  res.write('retry: 1000\n\n')
150
148
  }
151
149
 
152
- function createSession(res: ServerResponse, req: HttpRequest): { sid: string; session: SseSession } | null {
150
+ function createSession(res: ServerResponse, req: HttpRequest): SseSession | null {
153
151
  if (sessions.size >= maxSessions) {
154
152
  if (debug) console.warn(`sse: refused open — sessions ${sessions.size} >= ${maxSessions}`)
155
153
  return null
@@ -158,7 +156,6 @@ export function installSseServer(deps: SseServerDeps): SseServer {
158
156
  const sid = randomId()
159
157
  const session = new SseSession(res)
160
158
  sessions.set(sid, session)
161
- armIdleTimer(sid, session)
162
159
  session.on('close', () => { dropSession(sid) })
163
160
  // Announce the continuation token BEFORE the dispatcher emits its
164
161
  // `challenge` frame so the client latches the id first and the
@@ -171,7 +168,7 @@ export function installSseServer(deps: SseServerDeps): SseServer {
171
168
  // `setupPeerConnection`'s first action is the protocol `challenge`
172
169
  // frame, on the default-named SSE channel.
173
170
  setupPeerConnection(session as unknown as WebSocket, req, peerDeps)
174
- return { sid, session }
171
+ return session
175
172
  }
176
173
 
177
174
  // Drives a POST body's `password` and `frames` through the shared
@@ -268,19 +265,16 @@ export function installSseServer(deps: SseServerDeps): SseServer {
268
265
  // through to createSession with `res` still header-virgin, so
269
266
  // the new session can writeSseHeaders without ERR_HTTP_HEADERS_SENT.
270
267
  let session: SseSession | null = null
271
- let sid: string | null = null
272
268
  if (sidFromUrl) {
273
269
  const existing = sessions.get(sidFromUrl)
274
270
  if (existing && existing.readyState === existing.OPEN && existing.attachResponse(res)) {
275
271
  writeSseHeaders(res)
276
272
  session = existing
277
- sid = sidFromUrl
278
- armIdleTimer(sid, session)
279
273
  }
280
274
  }
281
275
  if (!session) {
282
- const created = createSession(res, req)
283
- if (!created) {
276
+ session = createSession(res, req)
277
+ if (!session) {
284
278
  // Cap exceeded; createSession already logged. Response
285
279
  // headers not yet written by writeSseHeaders, so send a
286
280
  // 503 JSON instead.
@@ -288,8 +282,6 @@ export function installSseServer(deps: SseServerDeps): SseServer {
288
282
  res.end(JSON.stringify({ error: 'too-many-sessions' }))
289
283
  return
290
284
  }
291
- session = created.session
292
- sid = created.sid
293
285
  }
294
286
  dispatchBody(session, body)
295
287
  // Do NOT res.end() — the response stays open as the session's
@@ -323,7 +315,22 @@ export function installSseServer(deps: SseServerDeps): SseServer {
323
315
  return true
324
316
  }
325
317
 
326
- return { handle, sessions: () => sessions.values() }
318
+ // Server-driven keepalive sweep. Writes a `:` comment to every open
319
+ // session's downstream so proxies don't idle-close it. `unref` so it can't
320
+ // by itself hold the event loop open (parity with the WS heartbeat timer);
321
+ // skipped during shutdown so a tick can't write to a session the close
322
+ // loop is tearing down. Dead-session reaping is the response `close` event
323
+ // (see SseSession.wireResponse + the per-session TCP keepalive), NOT this
324
+ // sweep — so a half-open client is dropped without ever POSTing.
325
+ const keepaliveTimer = setInterval(() => {
326
+ if (isShuttingDown()) return
327
+ for (const session of sessions.values()) {
328
+ try { session.ping() } catch {}
329
+ }
330
+ }, KEEPALIVE_SWEEP_MS)
331
+ keepaliveTimer.unref?.()
332
+
333
+ return { handle, sessions: () => sessions.values(), keepaliveTimer }
327
334
  }
328
335
 
329
336
  // Bare-bones query parse for `id=<base64url>`. Avoids URLSearchParams
@@ -28,6 +28,20 @@ import { EventEmitter } from 'node:events'
28
28
  import type { Buffer } from 'node:buffer'
29
29
  import type { ServerResponse } from 'node:http'
30
30
 
31
+ // TCP keepalive idle delay for the downstream socket. With the client no
32
+ // longer POSTing a periodic ping (see client/sync/socket-transport.ts), a
33
+ // QUIET session whose client vanished without a FIN (crash, NAT/idle drop)
34
+ // has no application-level liveness signal — so we lean on the kernel:
35
+ // after this much idle the kernel starts probing, and a dead half-open
36
+ // socket surfaces as a `close`/`error` here. Node's `setKeepAlive` sets
37
+ // only TCP_KEEPIDLE (the delay to the FIRST probe), not the probe
38
+ // interval/count — so full teardown is this delay PLUS the OS's
39
+ // TCP_KEEPINTVL × TCP_KEEPCNT (~minutes on Linux defaults), still bounded
40
+ // and far below the kernel's hours-long default-off behaviour. `maxSessions`
41
+ // is the hard backstop. Behind a TLS-terminating / buffering proxy this
42
+ // probes the proxy hop, not the client — see the reaping note in sse-server.ts.
43
+ const SOCKET_KEEPALIVE_MS = 30_000
44
+
31
45
  export class SseSession extends EventEmitter {
32
46
  static readonly CONNECTING = 0
33
47
  static readonly OPEN = 1
@@ -62,6 +76,10 @@ export class SseSession extends EventEmitter {
62
76
  // spurious session 'error' that operators read as a real transport
63
77
  // failure on a healthy session.
64
78
  private wireResponse(res: ServerResponse): void {
79
+ // Probe half-open downstreams at the kernel level (see SOCKET_KEEPALIVE_MS).
80
+ // A failed probe trips the `close`/`error` handlers below, which is how a
81
+ // dead-but-quiet SSE client is reaped now that there's no client ping.
82
+ try { res.socket?.setKeepAlive(true, SOCKET_KEEPALIVE_MS) } catch {}
65
83
  res.on('close', () => {
66
84
  if (res !== this.currentRes) return // current-response guard (see above)
67
85
  if (this.readyState === SseSession.CLOSED) return
@@ -142,9 +160,8 @@ export class SseSession extends EventEmitter {
142
160
  // double-emit), which means without the explicit emit here neither
143
161
  // sse-server's dropSession cleanup nor setupPeerConnection's
144
162
  // unsubscribeAll/peers.delete would run on any server-initiated
145
- // teardown — sessions / hub.subscribers / idleTimers would leak per
146
- // close. The wireResponse guard then ensures the later async fire
147
- // is a no-op.
163
+ // teardown — the sessions map / hub.subscribers would leak per close.
164
+ // The wireResponse guard then ensures the later async fire is a no-op.
148
165
  close(code?: number, reason?: string): void {
149
166
  if (this.readyState === SseSession.CLOSED) return
150
167
  const res = this.currentRes
@@ -173,11 +190,14 @@ export class SseSession extends EventEmitter {
173
190
  this.emit('close')
174
191
  }
175
192
 
176
- // The heartbeat sweep ping()s every WS client to detect dead sockets
177
- // via the unanswered-pong path. SSE has no `pong` equivalent, so we
178
- // write a comment line that keeps the channel alive across proxies
179
- // without expecting a reply. The per-session idle timeout in
180
- // sse-server.ts owns the dead-client detection.
193
+ // Server-driven keepalive, called on the SSE keepalive sweep in
194
+ // sse-server.ts. SSE has no `pong` equivalent, so we write a `:` comment
195
+ // line — ignored by the client parser — that keeps the downstream from
196
+ // being idle-closed by intermediary proxies (nginx et al. default to a
197
+ // ~60s read timeout). Dead-client detection is the response `close` event
198
+ // (clean disconnect, or a half-open socket the TCP keepalive in
199
+ // `wireResponse` forces closed), NOT this write: a `:`-comment write to a
200
+ // half-open socket just buffers, it doesn't synchronously throw.
181
201
  ping(): void {
182
202
  if (this.readyState !== SseSession.OPEN) return
183
203
  const res = this.currentRes
@@ -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
  }