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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/api/reap.ts +79 -0
  2. package/common/save-error-reason.ts +20 -7
  3. package/common/server-info.ts +30 -0
  4. package/out/brotli-fallback.js +1 -1
  5. package/out/client-admin.js +28 -0
  6. package/out/client-managed.js +1 -0
  7. package/out/client-sync.js +17 -10
  8. package/out/graph.js +5 -4
  9. package/out/index.html +43 -38
  10. package/out/prism.js +2 -2
  11. package/out/terminal.js +32 -28
  12. package/out/view.css +1 -1
  13. package/out/view.js +78 -51
  14. package/package.json +70 -49
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +16 -1
  17. package/server-e2e/bus-receiver.ts +95 -0
  18. package/server-e2e/cli.js +22 -0
  19. package/{server → server-e2e}/config.ts +21 -8
  20. package/{server → server-e2e}/db-neon.ts +41 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +15 -9
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +113 -135
  24. package/server-e2e/http.ts +266 -0
  25. package/{server → server-e2e}/hub.ts +27 -8
  26. package/{server → server-e2e}/index.ts +185 -52
  27. package/{server → server-e2e}/lifecycle.ts +36 -5
  28. package/server-e2e/npm-proxy.ts +348 -0
  29. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  30. package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
  31. package/{server → server-e2e}/objstore/blob.ts +24 -9
  32. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  33. package/{server → server-e2e}/objstore/handlers.ts +25 -15
  34. package/{server → server-e2e}/objstore/init.ts +52 -12
  35. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  36. package/server-e2e/objstore/rest-deny.ts +28 -0
  37. package/server-e2e/objstore/rest-mint.ts +224 -0
  38. package/{server → server-e2e}/objstore/rest.ts +119 -84
  39. package/{server → server-e2e}/objstore/sign.ts +105 -0
  40. package/{server → server-e2e}/objstore/store-neon.ts +19 -19
  41. package/{server → server-e2e}/objstore/store.ts +98 -118
  42. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  43. package/{server → server-e2e}/peer.ts +7 -9
  44. package/server-e2e/pubsub.ts +394 -0
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/server-e2e/sse-server.ts +384 -0
  47. package/server-e2e/sse-session.ts +216 -0
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/server-e2e/sync-handlers.ts +382 -0
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/server-e2e/ws-server.ts +276 -0
  52. package/strip-types-loader.js +94 -0
  53. package/server/http.ts +0 -142
  54. package/server/sync-handlers.ts +0 -311
  55. package/server/ws-server.ts +0 -245
  56. /package/{server → server-e2e}/config.example.json +0 -0
  57. /package/{server → server-e2e}/neon-driver.ts +0 -0
  58. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  59. /package/{server → server-e2e}/validation.ts +0 -0
@@ -1,311 +0,0 @@
1
- // Triage-sync protocol handlers: `workspace-save` and
2
- // `workspace-subscribe`. Built once at boot with the DB handle plus
3
- // the transport / auth / registry primitives it needs; the WS message
4
- // dispatcher in index.ts calls the returned handlers. Kept out of the
5
- // entrypoint so the protocol logic (the save pipeline, the
6
- // subscribe/catch-up path) is one cohesive, testable unit.
7
-
8
- import type { WebSocket } from 'ws'
9
- import { SAVE_ERROR_REASONS, type SaveErrorReason } from '../common/save-error-reason.ts'
10
- import { type Handle, type RevisionRow, chainFrom, commitRevision, revisionExists } from './db.ts'
11
- import { type SaveMsg, type SubscribeMsg, canonicalSave, computeRevisionIdFromCanonical, verifyEd25519, verifySubscribeSig } from './sign.ts'
12
- import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, validTagSigBase } from './validation.ts'
13
- import { debugTag } from './util.ts'
14
- import type { UnauthorizedContext } from './auth.ts'
15
-
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.
19
- type WireRevision = {
20
- base: string | null
21
- id: string
22
- keyframe: boolean
23
- nonce: string
24
- ciphertext: string
25
- signature: string
26
- }
27
-
28
- export type SyncHandlersDeps = {
29
- handle: Handle
30
- send: (socket: WebSocket, msg: object) => void
31
- broadcast: (tag: string, msg: object, except: WebSocket | null) => void
32
- subscribe: (socket: WebSocket, tag: string) => void
33
- getNonce: (socket: WebSocket) => string | undefined
34
- requiresAuth: (socket: WebSocket) => boolean
35
- sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
36
- workspaceExists: (tag: string) => Promise<boolean>
37
- // Objstore inventory snapshot for a workspace tag, as wire rows. The
38
- // `workspace-subscribed` ack carries it. Injected — the objstore store
39
- // has its own richer `Handle`, so index.ts wires `listLive(
40
- // objstoreHandle, tag).then(rows => rows.map(objectMetaWire))` rather
41
- // than coupling this module to that store type. Returns [] for a
42
- // triage-only tag.
43
- objstoreResources: (tag: string) => Promise<object[]>
44
- debug: boolean
45
- }
46
-
47
- export type SyncHandlers = {
48
- handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
49
- handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
50
- // Exported because the dispatcher's inflight-cap `busy` NACK path
51
- // emits a save-error too (the only emit site outside this module).
52
- sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
53
- }
54
-
55
- export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
56
- const { handle, send, broadcast, subscribe, getNonce, requiresAuth, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
57
-
58
- // Typed wrapper for the three `workspace-save-error` emit sites
59
- // (too-large at handleSave, stale-base after the catch-up, busy at
60
- // the inflight-cap drop). Forces `reason` to be a member of
61
- // `SaveErrorReason` so a typo or a server-side addition that didn't
62
- // update `common/save-error-reason.ts` fails at compile time rather
63
- // than turning into a wire-level surprise. The shared taxonomy is
64
- // pinned by `tests/save-error-reason-taxonomy.test.js`.
65
- function sendSaveError(
66
- socket: WebSocket,
67
- workspaceTag: string,
68
- base: string | null,
69
- reason: SaveErrorReason,
70
- ): void {
71
- // Runtime guard alongside the compile-time `SaveErrorReason` union —
72
- // covers the case where the `reason` argument is a variable (not a
73
- // string literal) and TS's narrowing can't enforce taxonomy
74
- // membership at the call site. Throws because a server emitting a
75
- // typo'd reason would be a wire-protocol break the client can't
76
- // recover from; better to fail fast in the test suite than to
77
- // silently land bytes the client coerces to `'rejected'`.
78
- if (!SAVE_ERROR_REASONS.has(reason)) {
79
- throw new Error(`sendSaveError: reason '${reason}' is not in SAVE_ERROR_REASONS — update common/save-error-reason.ts`)
80
- }
81
- send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
82
- }
83
-
84
- // Normalise `keyframe` on outbound chain entries to a strict boolean.
85
- // SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
86
- // raw rows; the wire contract (and the canonical signing payload)
87
- // uses strict `=== true` to mark keyframes. Forwarding the integer
88
- // shape works only because every shipping client coerces via
89
- // `Boolean(rev.keyframe)` before reconstructing the canonical bytes —
90
- // fragile if a future client (or test harness) ever strict-compares.
91
- // Convert once on the send side.
92
- function chainForWire(revisions: RevisionRow[]): WireRevision[] {
93
- return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
94
- }
95
-
96
- async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
97
- // `base` is `string | null`; null is the keyframe-root marker.
98
- 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
99
- // Compute canonical bytes + content-addressed id ONCE, then thread
100
- // both through the precheck → sig verify → commit pipeline:
101
- // 1. canonicalSave (sync, throws on lone-surrogate input)
102
- // 2. SHA-256 → id
103
- // 3. precheck: revisionExists → short-circuit ack on replay
104
- // (skips Ed25519 verify; closes the round-9 H1 CPU-DoS vector
105
- // where a passive observer floods captured saves)
106
- // 4. verifyEd25519 against the SAME canonical bytes the id was
107
- // hashed from — provably tied
108
- // 5. ciphertext size policy (post-sig so the explicit error only
109
- // reaches a legit signer)
110
- // 6. commitRevision — re-checks dup + base + inserts via a single
111
- // gated INSERT (dup gate + head-equals-base gate + the
112
- // server-assigned seq folded into one statement) — NO write
113
- // lock. The dup recheck, headFor, base-match and insert all
114
- // collapse into that one statement, whose head-check and
115
- // MAX(seq) read one snapshot; the `UNIQUE(workspace_tag, seq)`
116
- // PK rejects any racer that computed the same seq. So two
117
- // concurrent saves with the same `base` and different ids
118
- // can't both insert (the loser's `head IS base` gate fails →
119
- // `stale-base`, no chain fork even though UNIQUE is on id, not
120
- // base), and two concurrent same-id retransmits resolve to one
121
- // `inserted` + one `duplicate` with no UNIQUE throw escaping.
122
- // See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
123
- let canonical: Uint8Array<ArrayBuffer>
124
- try { canonical = canonicalSave(msg) } catch { return }
125
- const id = await computeRevisionIdFromCanonical(canonical)
126
- const tag = msg.workspaceTag
127
- if (await revisionExists(handle, tag, id)) {
128
- if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
129
- send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: msg.base ?? null, id })
130
- return
131
- }
132
- if (!await verifyEd25519(tag, canonical, msg.signature)) {
133
- if (debug) console.warn('reject save: bad signature', debugTag(tag))
134
- return
135
- }
136
- // Size policy — emit an explicit error so the client can surface
137
- // the failure to the user. Without this, an oversized save hangs
138
- // forever in the client's `pending` slot (no ack, no rebase).
139
- if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
140
- if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
141
- sendSaveError(socket, tag, msg.base == null || typeof msg.base !== 'string' ? null : msg.base, 'too-large')
142
- return
143
- }
144
- // Auth gate for the FIRST action against a workspace tag that
145
- // doesn't yet exist on the server (no rows in workspace_revision
146
- // AND none in workspace_object). Once any row lands, the workspace
147
- // is established and every signed action flows freely — access
148
- // control falls back to the Ed25519 signature for the rest of the
149
- // workspace's lifetime. Checked AFTER sig verify so the
150
- // `unauthorized` frame only reaches a legitimate signer; shape /
151
- // sig attacks still drop silently.
152
- //
153
- // RACE: `workspaceExists` reads at a different moment than the
154
- // commit's gated INSERT (a plain TOCTOU — there is no lock spanning
155
- // the two). Under concurrent saves on a fresh tag, an
156
- // unauthenticated socket whose `workspaceExists` observes "true"
157
- // (because an authenticated peer's commit landed between this
158
- // socket's check and its commit) skips the gate and commits as the
159
- // second writer. Accepted: the unauthenticated peer still had to
160
- // produce a valid Ed25519 signature (= holds the workspace seed),
161
- // and "two concurrent writes both authorising" is the worst case.
162
- // Tightening would require folding the gate into the commit
163
- // statement itself and is not worth the layer crossing for the
164
- // soft-policy guarantee.
165
- if (requiresAuth(socket) && !await workspaceExists(tag)) {
166
- if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
167
- sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: msg.base ?? null })
168
- return
169
- }
170
- // NOTE: Earlier revisions auto-subscribed the sending socket here.
171
- // That created a replay vector — a passive observer who captured
172
- // any single valid `workspace-save` frame could replay it from any
173
- // TCP connection forever to attach as a subscriber and silently
174
- // mirror every future encrypted broadcast for the workspace,
175
- // without ever holding the seed (the duplicate-id path returns
176
- // ack-only and doesn't reject the socket). Audit round-9 H1.
177
- //
178
- // The legitimate client always sends an explicit
179
- // `workspace-subscribe` (see `trySendSubscribe` in
180
- // `client/triage-sync.js` — fires on key derivation, on socket
181
- // open, on continuity-break recovery, on dismissError). The
182
- // subscribe path remains the only way to attach as a subscriber.
183
- const baseNorm = msg.base ?? null
184
- // `keyframe === true` is what canonicalSave bound the signature to
185
- // (strict equality); the signer's intent is unambiguous here.
186
- const keyframe = msg.keyframe === true
187
- const commit = await commitRevision(handle, {
188
- tag, id, base: baseNorm, keyframe,
189
- nonce: msg.nonce, ciphertext: msg.ciphertext, signature: msg.signature,
190
- })
191
- if (commit.kind === 'duplicate') {
192
- if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
193
- send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: baseNorm, id })
194
- return
195
- }
196
- if (commit.kind === 'stale-base') {
197
- // Client claimed a base that's no longer head. Catch-up chain is
198
- // computed OUTSIDE the lock — a concurrent commit landing between
199
- // lock-release and `chainFrom` only means the catch-up is fresher
200
- // than the recheck saw, which is benign (clients tolerate extra
201
- // revisions in the chain).
202
- //
203
- // Wire order: send `workspace-state` (catch-up) FIRST, then the
204
- // typed `workspace-save-error { reason: 'stale-base' }`. The
205
- // catch-up's handler clears `session.pending`; the subsequent
206
- // error frame's `handleSaveError` then early-returns on the
207
- // missing pending and does NOT mark the session errored — exactly
208
- // what we want, since stale-base is a recoverable race (client
209
- // rebases + re-saves). The typed frame is for protocol clarity
210
- // (debug surfaces / explicit rejection signal), not for triggering
211
- // an error transition. Audit follow-up to round-15 —
212
- // `sync-server-races.test.js:1105`.
213
- const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
214
- if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
215
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
216
- sendSaveError(socket, tag, baseNorm, 'stale-base')
217
- return
218
- }
219
- if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
220
- send(socket, {
221
- type: 'workspace-save-ack',
222
- workspaceTag: tag,
223
- base: baseNorm,
224
- id,
225
- })
226
- // Carry `keyframe` as a strict boolean on the broadcast wire —
227
- // peers strict-compare `=== true` (matching the canonical-payload
228
- // contract). The previous shape emitted `keyframe ? 1 : 0` which a
229
- // strict check would treat as non-keyframe, making a replayed
230
- // keyframe look like a regular delta on broadcast paths even though
231
- // the chain-fetch path (chainFrom → SQLite integer) DID round-trip
232
- // correctly.
233
- broadcast(tag, {
234
- type: 'workspace-state',
235
- workspaceTag: tag,
236
- revisions: [{
237
- base: baseNorm,
238
- id,
239
- keyframe,
240
- nonce: msg.nonce,
241
- ciphertext: msg.ciphertext,
242
- signature: msg.signature,
243
- }],
244
- }, socket)
245
- }
246
-
247
- async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
248
- if (typeof msg.workspaceTag !== 'string') return
249
- // Same `string | null` contract as `base` in handleSave. The signed
250
- // canonical uses `String(from)`, but the chain-lookup path
251
- // (`typeof msg.from === 'string' ? msg.from : null`) treats every
252
- // non-string as null — so a legit signer sending `from: { … }`
253
- // would silently take the keyframe-fallback path even though the
254
- // signature was over a different canonical shape. Reject at the
255
- // wire gate.
256
- if (msg.from != null && typeof msg.from !== 'string') return
257
- // The challenge nonce we issued on this socket is bound into the
258
- // signed canonical, blocking cross-connection replay of a captured
259
- // subscribe frame. A subscribe arriving before we sent the
260
- // challenge (impossible from the legitimate client) has no nonce to
261
- // verify against — drop. Audit round-9 H2.
262
- const nonce = getNonce(socket)
263
- if (typeof nonce !== 'string') return
264
- const ok = await verifySubscribeSig(msg, nonce)
265
- if (!ok) {
266
- if (debug) console.warn('reject subscribe: bad signature', debugTag(msg.workspaceTag))
267
- return
268
- }
269
- // Bail if the socket closed during the verify await. The close
270
- // handler's `unsubscribeAll(socket)` already ran (when there was
271
- // nothing to remove yet), and `subscribe()` below would add the
272
- // dead socket to `subscribers[tag]` — a permanent leak: broadcasts
273
- // no-op via `send`'s readyState gate, but the Set entry pins the
274
- // socket reference past close, blocking GC. Audit round-12.
275
- if (socket.readyState !== socket.OPEN) {
276
- if (debug) console.warn('reject subscribe: socket closed mid-verify', debugTag(msg.workspaceTag))
277
- return
278
- }
279
- const tag = msg.workspaceTag
280
- subscribe(socket, tag)
281
- // Explicit ack — distinguishes "the server processed my subscribe
282
- // and registered me as a peer" from "the WebSocket is open". A
283
- // client that sent a malformed / bad-sig subscribe never gets this;
284
- // a client that did gets one before the chain arrives. Lets the UI
285
- // surface a `connecting → online` transition based on real handshake
286
- // completion, not just socket state.
287
- //
288
- // The ack also carries the objstore inventory snapshot: the same
289
- // subscribe that registers this socket for objstore-put / -deleted
290
- // broadcasts seeds the client's initial inventory in one handshake.
291
- // The client keeps it live thereafter from those broadcasts.
292
- // Returns [] for a triage-only workspace. A failing inventory
293
- // lookup must NOT sink the subscribe — degrade to an empty snapshot
294
- // (broadcasts will fill it in) so the ack + chain still go out.
295
- let resources: object[] = []
296
- try { resources = await objstoreResources(tag) }
297
- catch (err) { if (debug) console.warn('subscribe: objstore inventory lookup failed', debugTag(tag), err) }
298
- send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
299
- // `from` is the last revision id the client claims to have applied —
300
- // now a base64url string, not an integer. We send only revisions
301
- // after that. Client lying about `from` just means they get a
302
- // smaller catch-up — their subsequent saves will reveal stale state
303
- // on the usual base-mismatch path. Null / missing → full chain.
304
- const fromId = typeof msg.from === 'string' ? msg.from : null
305
- const revisions = chainForWire(await chainFrom(handle, tag, fromId))
306
- if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
307
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
308
- }
309
-
310
- return { handleSave, handleSubscribe, sendSaveError }
311
- }
@@ -1,245 +0,0 @@
1
- // WebSocket runtime: the per-connection handler (Peer setup, message
2
- // dispatch, close/error) and the server-driven heartbeat sweep. Wired
3
- // once at boot onto the shared `wss` with the protocol handlers +
4
- // lifecycle hooks it dispatches to. Kept out of index.ts so the WS
5
- // message loop — the most concurrency-sensitive surface — is one unit.
6
-
7
- import type { WebSocket, WebSocketServer } from 'ws'
8
- import { Buffer } from 'node:buffer'
9
- import { decodeUtf8 } from '../common/utf8.js'
10
- import type { SaveErrorReason } from '../common/save-error-reason.ts'
11
- import { Peer, type PeerRegistry } from './peer.ts'
12
- import { errMsg, errStack, randomId } from './util.ts'
13
- import type { SaveMsg, SubscribeMsg } from './sign.ts'
14
- import type { AuthenticateMsg } from './auth.ts'
15
- import type { ObjstoreHandlers } from './objstore/handlers.ts'
16
- import type { ObjstoreDeleteMsg, ObjstoreFetchMsg, ObjstorePutBeginMsg } from './objstore/sign.ts'
17
-
18
- // Wire-message envelope as it lands post-`JSON.parse`. Every field is
19
- // `unknown` until a handler narrows it; the type just documents the
20
- // dispatch surface so call sites can pattern-match on `msg.type`.
21
- type IncomingMessage = {
22
- type?: unknown
23
- [k: string]: unknown
24
- }
25
-
26
- export type WsServerDeps = {
27
- wss: WebSocketServer
28
- peers: PeerRegistry
29
- send: (socket: WebSocket, msg: object) => void
30
- unsubscribeAll: (socket: WebSocket) => void
31
- handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
32
- handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
33
- handleAuthenticate: (socket: WebSocket, msg: AuthenticateMsg) => void
34
- sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
35
- objstore: ObjstoreHandlers
36
- track: (promise: Promise<unknown>) => void
37
- isShuttingDown: () => boolean
38
- maxInflightPerSocket: number
39
- heartbeatIntervalMs: number
40
- debug: boolean
41
- }
42
-
43
- // Wires `wss.on('connection')` + starts the heartbeat. Returns the
44
- // timer so shutdown can clear it.
45
- export function installWsServer(deps: WsServerDeps): { heartbeatTimer: ReturnType<typeof setInterval> } {
46
- const {
47
- wss, peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
48
- sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, heartbeatIntervalMs, debug,
49
- } = deps
50
-
51
- wss.on('connection', (socket: WebSocket, req) => {
52
- if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
53
- // One Peer holds this connection's state (challenge / authorized /
54
- // alive / inflight / tags). Created before any client frame can
55
- // arrive (`socket.on('message')` is wired below). The heartbeat
56
- // sweep flips `alive` false after each `ping()`; the `pong` listener
57
- // flips it back, and a socket still false on the next sweep is
58
- // terminated — the only thing closing FDs for an idle peer.
59
- const peer = new Peer(randomId())
60
- peers.set(socket, peer)
61
- socket.on('pong', () => { peer.alive = true })
62
- // Issue the per-connection challenge nonce BEFORE the client can
63
- // send anything that needs it. The client signs it into every
64
- // `workspace-subscribe` (see canonicalSubscribe in server/sign.ts);
65
- // a captured subscribe frame can't be replayed from a different
66
- // connection because that connection's nonce differs and the
67
- // signature won't verify against the new canonical bytes. Round-9 H2.
68
- send(socket, { type: 'challenge', nonce: peer.challenge })
69
- // Per-socket handlers are DELIBERATELY NOT serialized (vs the
70
- // client-side `messageQueue = messageQueue.then(...)` Promise
71
- // chain inside `client/triage-sync.ts:onTransportMessage`).
72
- // Each inbound frame spawns its own tracked async IIFE; two
73
- // frames from the same socket can interleave across `await`
74
- // boundaries inside the handlers. Per-resource correctness needs
75
- // no in-process lock: `commitRevision` resolves concurrent saves
76
- // via its single gated INSERT (one snapshot + the
77
- // `UNIQUE(workspace_tag, seq)` PK — see `server/db.ts`), and the
78
- // objstore handlers (`commitPut` / `beginPut` / `deleteObject`)
79
- // via the version compare-and-set + content-addressing (see
80
- // `server/objstore/store.ts`), backed by post-await
81
- // `readyState === OPEN` rechecks in every objstore handler. The
82
- // unbounded fan-out is capped by `maxInflightPerSocket` (see
83
- // also the `'busy'` NACK at the cap below). Concurrent dispatch
84
- // is intentional: it lets multi-workspace clients multiplex
85
- // saves + subscribes over one socket without HOL blocking.
86
- // Audit follow-up to round-15 concurrency review.
87
- socket.on('message', (data: Buffer, isBinary: boolean) => {
88
- // Drop new work once shutdown started — `wss.close()` stops new
89
- // CONNECTIONS but already-open sockets can still send messages.
90
- // Without this gate a message arriving between `wss.close()`
91
- // resolving and the `inFlight` snapshot would spawn a handler
92
- // that's NOT in the snapshot, then resume against the just-
93
- // closed DB and throw inside the commit's gated INSERT. Audit
94
- // round-9.
95
- if (isShuttingDown()) return
96
- // Wire protocol is JSON over text frames. A binary frame is
97
- // either a buggy client or someone probing — drop without
98
- // attempting to interpret it as text.
99
- if (isBinary) return
100
- let msg: IncomingMessage | null = null
101
- try {
102
- // `decodeUtf8` is fatal on invalid UTF-8 (vs `Buffer.toString`
103
- // which silently substitutes U+FFFD). The substitution path
104
- // would let mangled bytes pass JSON.parse only to fail
105
- // signature verification deeper in the handler — wasted work
106
- // and noisier logs. Fail at the gate.
107
- msg = JSON.parse(decodeUtf8(data)) as IncomingMessage
108
- } catch { return }
109
- if (!msg || typeof msg !== 'object') return
110
- const parsed: IncomingMessage = msg
111
- // Per-socket inflight cap. Each tracked handler keeps the socket
112
- // alive in `inFlight`, and a peer who keeps firing valid frames
113
- // can spawn unbounded handlers — growing SIGTERM drain time and
114
- // memory. Drop new work above the cap. Heartbeat ping is the
115
- // exception: it's stateless, synchronous, and we want to KEEP
116
- // responding so the peer doesn't drop the socket while we're
117
- // shedding load. Pings go through a fast inline `send(pong)`
118
- // path BELOW that doesn't bump the per-socket inflight counter,
119
- // so a ping-spam at the cap can't outrun the gate. Transport
120
- // audit `server/index.ts:590` + post-#58 audit follow-up.
121
- if (parsed.type === 'ping') {
122
- send(socket, { type: 'pong' })
123
- return
124
- }
125
- // `authenticate` runs synchronously (constant-time bytes compare
126
- // — no DB, no I/O), so it shares the same fast-inline path as
127
- // `ping` and bypasses the per-socket inflight counter. Keeping
128
- // it out of the IIFE pool means an unauthenticated client can
129
- // still complete the handshake when the socket is otherwise
130
- // saturated (matching the philosophy of the `busy` NACK path
131
- // for `workspace-save`: don't strand a recoverable handshake
132
- // behind the cap).
133
- if (parsed.type === 'authenticate') {
134
- handleAuthenticate(socket, parsed as AuthenticateMsg)
135
- return
136
- }
137
- if (peer.inflight >= maxInflightPerSocket) {
138
- if (debug) console.warn(`drop message: socket inflight ${peer.inflight} >= ${maxInflightPerSocket}`)
139
- // For workspace-save specifically, send a typed NACK so the
140
- // client's `pending` slot clears IMMEDIATELY instead of
141
- // hanging until the next heartbeat (~15–30s). Reason `busy`
142
- // is server-side overload; safe to retry. Same wire envelope
143
- // as the existing `too-large` save-error path.
144
- //
145
- // Wire-order interaction with `'stale-base'`: the cap-path
146
- // send is SYNCHRONOUS in this message callback and lands on
147
- // the wire BEFORE any handler IIFE's `await`-completed reply.
148
- // If an earlier in-flight `workspace-save` (frame F1) ends up
149
- // emitting a catch-up `workspace-state` + `'stale-base'` while
150
- // a later frame F2 hits the cap, the order is `'busy'`(F2) →
151
- // `workspace-state`(F1) → `'stale-base'`(F1). The client's
152
- // `handleSaveError` correlates on `base`, and the wire-order
153
- // trick documented in `common/save-error-reason.ts` (catch-up
154
- // clears `pending` before the stale-base frame's
155
- // `handleSaveError` runs) still holds.
156
- const rawBase = (parsed as SaveMsg).base
157
- const baseField: string | null = typeof rawBase === 'string' ? rawBase : null
158
- const rawTag = (parsed as SaveMsg).workspaceTag
159
- const tagField = typeof rawTag === 'string' ? rawTag : null
160
- if (parsed.type === 'workspace-save' && tagField != null) {
161
- // `sendSaveError` runs its taxonomy-guard before the wire
162
- // send and throws on an unknown reason. Every OTHER emit
163
- // site lives inside the `handler` IIFE's try/catch — this
164
- // cap path is the only one outside it. Mirror the same
165
- // forensic envelope so a future bad reason here surfaces
166
- // as `Handler error (type=workspace-save): …` rather than
167
- // escaping to ws's emitter as an uncaught.
168
- try {
169
- sendSaveError(socket, tagField, baseField, 'busy')
170
- } catch (err) {
171
- console.warn('Handler error (type=workspace-save):', errStack(err))
172
- }
173
- }
174
- return
175
- }
176
- peer.inflight += 1
177
- const handler = (async () => {
178
- try {
179
- if (parsed.type === 'workspace-save') await handleSave(socket, parsed as SaveMsg)
180
- else if (parsed.type === 'workspace-subscribe') await handleSubscribe(socket, parsed as SubscribeMsg)
181
- // Objstore control plane — bytes ride the REST plane via
182
- // tokens these handlers mint. The Objstore*Msg types are
183
- // weak shapes (every field `unknown`); the handlers narrow
184
- // each field through their own validators on entry.
185
- else if (parsed.type === 'objstore-put-begin') await objstore.handlePutBegin(socket, parsed as ObjstorePutBeginMsg)
186
- else if (parsed.type === 'objstore-delete') await objstore.handleDelete(socket, parsed as ObjstoreDeleteMsg)
187
- else if (parsed.type === 'objstore-fetch') await objstore.handleFetch(socket, parsed as ObjstoreFetchMsg)
188
- } catch (err) {
189
- // Forensic logging for unexpected throws — the handlers all
190
- // have internal narrow catches (e.g. signature reject paths);
191
- // anything reaching here is unexpected. Include the wire
192
- // `type` so an operator can correlate to a specific code
193
- // path, and prefer `.stack` over `.message` so the post-
194
- // mortem has the throw site.
195
- const typeStr = typeof parsed.type === 'string' ? parsed.type : '<unknown>'
196
- console.warn(`Handler error (type=${typeStr}):`, errStack(err))
197
- } finally {
198
- peer.inflight -= 1
199
- }
200
- })()
201
- track(handler)
202
- })
203
- socket.on('close', () => {
204
- // `unsubscribeAll` reads `peer.tags` (peer still registered), then
205
- // we drop the Peer. The Peer's state would GC once the socket is
206
- // unreachable, but `wss.clients` / `ws` internals hold the socket
207
- // strongly well past `close`, so the explicit delete frees it
208
- // immediately. Audit round-10 + round-13.
209
- unsubscribeAll(socket)
210
- peers.delete(socket)
211
- })
212
- // Surface socket-level errors instead of swallowing — these are
213
- // the signals operators want under abuse / network flakiness
214
- // (TLS handshake failures, frame-decode errors, ws-protocol
215
- // violations). The previous `() => {}` left every per-connection
216
- // failure invisible. `close` fires after `error` and runs the
217
- // unsubscribe cleanup, so logging here doesn't risk leaking.
218
- socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
219
- })
220
-
221
- // Periodic heartbeat sweep. Two-tick liveness window: a socket that
222
- // doesn't `pong` within `heartbeatIntervalMs` of our `ping` sees its
223
- // tracker flip to `false`; on the NEXT tick we terminate. `try/catch`
224
- // shrugs at any peer that races us into CLOSING / CLOSED (`ws.ping` /
225
- // `ws.terminate` throw in that state) — the close event handles
226
- // cleanup either way.
227
- //
228
- // `unref` so the timer alone doesn't keep the event loop alive
229
- // (parity with `terminateTimer` in shutdown). Cleared in shutdown so
230
- // a graceful SIGTERM doesn't fire one last ping race after wss.close.
231
- const heartbeatTimer = setInterval(() => {
232
- for (const ws of wss.clients) {
233
- const peer = peers.get(ws)
234
- if (peer?.alive === false) {
235
- try { ws.terminate() } catch {}
236
- continue
237
- }
238
- if (peer) peer.alive = false
239
- try { ws.ping() } catch {}
240
- }
241
- }, heartbeatIntervalMs)
242
- heartbeatTimer.unref?.()
243
-
244
- return { heartbeatTimer }
245
- }
File without changes
File without changes
File without changes
File without changes