@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (168) hide show
  1. package/api/reap.ts +17 -0
  2. package/cli.js +6 -0
  3. package/client/finding-link.js +305 -0
  4. package/client/linked-findings.d.ts +1 -0
  5. package/client/linked-findings.js +111 -0
  6. package/common/bundle-metadata.d.ts +10 -0
  7. package/common/bundle-metadata.js +177 -0
  8. package/common/bundle-reasons.d.ts +2 -0
  9. package/common/bundle-reasons.js +21 -0
  10. package/common/bundle-sources.d.ts +3 -0
  11. package/common/bundle-sources.js +284 -0
  12. package/common/bundle-stats.js +41 -0
  13. package/common/bundle-tabs.js +1 -0
  14. package/common/code-language.js +36 -0
  15. package/common/default-scan-models.ts +30 -0
  16. package/common/finding-id.js +47 -0
  17. package/common/github-pr.ts +56 -0
  18. package/common/managed/comments.ts +34 -0
  19. package/common/managed/permissions.ts +35 -0
  20. package/common/managed/report-content.ts +42 -0
  21. package/common/managed/report-filter.ts +108 -0
  22. package/common/managed/roles.ts +28 -0
  23. package/common/managed/routes.d.ts +2 -0
  24. package/common/managed/routes.js +121 -0
  25. package/common/managed/scan-models.ts +6 -0
  26. package/common/managed/triage.ts +83 -0
  27. package/common/save-error-reason.ts +20 -7
  28. package/common/scan-server.ts +13 -0
  29. package/common/server-info.ts +33 -0
  30. package/common/utf8.d.ts +3 -0
  31. package/common/utf8.js +45 -0
  32. package/out/brotli-fallback.js +3 -3
  33. package/out/client-managed-import.js +81 -0
  34. package/out/client-managed.js +110 -0
  35. package/out/client-sync.js +16 -13
  36. package/out/graph.js +30 -4
  37. package/out/index.html +55 -8
  38. package/out/prism.js +2 -2
  39. package/out/stasis.svg +45 -0
  40. package/out/terminal.js +273 -39
  41. package/out/view.css +1 -1
  42. package/out/view.js +198 -62
  43. package/package.json +179 -55
  44. package/report/index.js +254 -0
  45. package/report/src/finding-id.js +80 -0
  46. package/report/src/finding.js +312 -0
  47. package/report/src/labels.js +33 -0
  48. package/report/src/md-structure.js +471 -0
  49. package/report/src/md-text.js +167 -0
  50. package/report/src/meta.js +76 -0
  51. package/report/src/parse-codex.js +147 -0
  52. package/report/src/parse-deepsec.js +197 -0
  53. package/report/src/parse-deepview-fields.js +375 -0
  54. package/report/src/parse-deepview-md.js +185 -0
  55. package/report/src/parse-md-id.js +137 -0
  56. package/report/src/parse-md.js +322 -0
  57. package/report/src/parse-piolium-id.js +79 -0
  58. package/report/src/parse-piolium-rows.js +131 -0
  59. package/report/src/parse-piolium-tokens.js +175 -0
  60. package/report/src/parse-piolium.js +400 -0
  61. package/report/src/security.js +63 -0
  62. package/report/src/utf8.js +21 -0
  63. package/report/src/write-md-finding.js +273 -0
  64. package/report/src/write-md.js +291 -0
  65. package/server-common/database-config.ts +16 -0
  66. package/server-common/initialize.ts +18 -0
  67. package/server-common/npm-advisories.ts +101 -0
  68. package/{server → server-common}/origin.ts +5 -5
  69. package/server-common/reap.ts +48 -0
  70. package/server-common/scan-config.ts +19 -0
  71. package/server-common/standalone.ts +29 -0
  72. package/server-common/storage-log.ts +34 -0
  73. package/server-common/vercel-blob.ts +110 -0
  74. package/server-e2e/app.ts +485 -0
  75. package/{server → server-e2e}/auth.ts +5 -1
  76. package/{server → server-e2e}/bus-receiver.ts +9 -8
  77. package/{server → server-e2e}/cli.js +9 -4
  78. package/{server → server-e2e}/config.ts +54 -39
  79. package/{server → server-e2e}/db-neon.ts +2 -2
  80. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  81. package/{server → server-e2e}/db-stmt.ts +2 -2
  82. package/{server → server-e2e}/db.ts +96 -135
  83. package/{server → server-e2e}/http.ts +110 -12
  84. package/{server → server-e2e}/hub.ts +44 -14
  85. package/server-e2e/index.ts +17 -0
  86. package/server-e2e/lifecycle.ts +95 -0
  87. package/{server → server-e2e}/neon-driver.ts +2 -2
  88. package/{server → server-e2e}/npm-proxy.ts +11 -144
  89. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  90. package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
  91. package/{server → server-e2e}/objstore/blob.ts +24 -9
  92. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  93. package/{server → server-e2e}/objstore/handlers.ts +19 -20
  94. package/{server → server-e2e}/objstore/init.ts +53 -27
  95. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  96. package/server-e2e/objstore/rest-deny.ts +28 -0
  97. package/server-e2e/objstore/rest-mint.ts +224 -0
  98. package/{server → server-e2e}/objstore/rest.ts +110 -93
  99. package/{server → server-e2e}/objstore/sign.ts +105 -0
  100. package/{server → server-e2e}/objstore/store-neon.ts +10 -14
  101. package/{server → server-e2e}/objstore/store.ts +98 -118
  102. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  103. package/{server → server-e2e}/peer.ts +7 -9
  104. package/{server → server-e2e}/pubsub.ts +29 -36
  105. package/{server → server-e2e}/sign.ts +12 -14
  106. package/{server → server-e2e}/sse-server.ts +105 -73
  107. package/{server → server-e2e}/sse-session.ts +30 -16
  108. package/{server → server-e2e}/static.ts +36 -29
  109. package/server-e2e/sync-handlers.ts +408 -0
  110. package/{server → server-e2e}/util.ts +9 -0
  111. package/{server → server-e2e}/ws-server.ts +29 -23
  112. package/server-managed/activity.ts +231 -0
  113. package/server-managed/avatar-store.ts +51 -0
  114. package/server-managed/blob-store.ts +66 -0
  115. package/server-managed/blob-vercel.ts +125 -0
  116. package/server-managed/brotli.ts +10 -0
  117. package/server-managed/bundle-cache.ts +185 -0
  118. package/server-managed/bundle-catalog.ts +29 -0
  119. package/server-managed/bundle-store.ts +28 -0
  120. package/server-managed/bundle-summary-cache.ts +97 -0
  121. package/server-managed/bundle.ts +39 -0
  122. package/server-managed/cache-storage.ts +40 -0
  123. package/server-managed/cli.js +13 -0
  124. package/server-managed/combined.ts +46 -0
  125. package/server-managed/comments.ts +151 -0
  126. package/server-managed/config.ts +144 -0
  127. package/server-managed/content-access.ts +15 -0
  128. package/server-managed/crypto.ts +25 -0
  129. package/server-managed/db-methods.ts +1336 -0
  130. package/server-managed/db-neon.ts +171 -0
  131. package/server-managed/db-schema.ts +203 -0
  132. package/server-managed/db-table-names.ts +22 -0
  133. package/server-managed/db.ts +109 -0
  134. package/server-managed/github-app.ts +332 -0
  135. package/server-managed/github-metadata.ts +65 -0
  136. package/server-managed/github-oauth.ts +215 -0
  137. package/server-managed/github-pulls.ts +115 -0
  138. package/server-managed/http-response.ts +18 -0
  139. package/server-managed/http.ts +2043 -0
  140. package/server-managed/import-triage.ts +48 -0
  141. package/server-managed/index.ts +135 -0
  142. package/server-managed/public-workspace.ts +150 -0
  143. package/server-managed/repo-path.ts +21 -0
  144. package/server-managed/report-migration.ts +35 -0
  145. package/server-managed/report-query.ts +4 -0
  146. package/server-managed/report-response.ts +16 -0
  147. package/server-managed/report-sources.ts +154 -0
  148. package/server-managed/repository-discovery.ts +82 -0
  149. package/server-managed/repository-policy.ts +27 -0
  150. package/server-managed/session.ts +78 -0
  151. package/server-managed/slugs.ts +38 -0
  152. package/server-managed/sql-postgres.ts +30 -0
  153. package/server-managed/sql.ts +61 -0
  154. package/server-managed/static.ts +28 -0
  155. package/server-managed/storage.ts +34 -0
  156. package/server-managed/team-catalog.ts +7 -0
  157. package/server-managed/team-feed.ts +128 -0
  158. package/server-managed/team-reports.ts +156 -0
  159. package/server-managed/triage-response.ts +16 -0
  160. package/server-managed/uploads.ts +47 -0
  161. package/server-managed/workspace-shares.ts +150 -0
  162. package/server.ts +50 -0
  163. package/server/index.ts +0 -481
  164. package/server/lifecycle.ts +0 -204
  165. package/server/sync-handlers.ts +0 -327
  166. /package/{server → server-e2e}/config.example.json +0 -0
  167. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  168. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,408 @@
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 { IncomingMessage, ServerResponse } from 'node:http'
9
+ import { Buffer } from 'node:buffer'
10
+ import type { WebSocket } from 'ws'
11
+ import { SAVE_ERROR_REASONS, type SaveErrorReason } from '../common/save-error-reason.ts'
12
+ import { type Handle, type RevisionRow, chainFrom, commitRevision, revisionExists } from './db.ts'
13
+ import { type SaveMsg, type SubscribeMsg, canonicalSave, computeRevisionIdFromCanonical, verifyEd25519, verifySubscribeSig } from './sign.ts'
14
+ import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, validTagSigBase } from './validation.ts'
15
+ import { debugTag } from './util.ts'
16
+ import type { UnauthorizedContext } from './auth.ts'
17
+
18
+ // Wire shape `chainForWire` produces from a `chainFrom` row, with
19
+ // `keyframe` normalised from the SQLite INTEGER 0/1 to a strict boolean.
20
+ type WireRevision = {
21
+ base: string | null
22
+ id: string
23
+ keyframe: boolean
24
+ nonce: string
25
+ ciphertext: string
26
+ signature: string
27
+ }
28
+
29
+ type SubscriptionLookup = { resume: () => void }
30
+
31
+ // Structured result of the shared save pipeline (`commitSave`), rendered
32
+ // per transport: WS → protocol frames; REST → JSON + HTTP status.
33
+ type SaveOutcome =
34
+ | { kind: 'rejected' } // malformed / bad sig → WS drop / REST 400
35
+ | { kind: 'ack'; id: string; base: string | null } // committed → save-ack / 200
36
+ | { kind: 'duplicate'; id: string; base: string | null } // replay → ack-only / 200
37
+ | { kind: 'too-large'; base: string | null } // ciphertext over cap → save-error / 413
38
+ | { kind: 'unauthorized'; base: string | null } // new-workspace gate → unauthorized / 401
39
+ | { kind: 'stale-base'; base: string | null; revisions: WireRevision[] } // conflict → state+error / 409
40
+
41
+ // Hard cap on a `POST /api/sync/save` JSON body. The save frame is the small
42
+ // fields + a base64 ciphertext capped at MAX_CIPHERTEXT_LEN (2 MiB); 4 MiB
43
+ // (the WS plane's maxPayload / the SSE plane's maxBodyBytes) leaves headroom
44
+ // for the envelope so the in-pipeline size policy — not the reader — decides
45
+ // `too-large`. Bounds the read so a hostile client can't stream an unbounded
46
+ // body into memory before the parse.
47
+ const SAVE_BODY_MAX = 4 * 1024 * 1024
48
+
49
+ // Read a JSON request body up to `SAVE_BODY_MAX`, returning the parsed value
50
+ // or null on overflow / parse failure / read error (mirrors the objstore
51
+ // mint reader). The REST save body is the only body this module reads.
52
+ async function readSaveBody(req: IncomingMessage): Promise<unknown> {
53
+ const chunks: Buffer[] = []
54
+ let total = 0
55
+ try {
56
+ for await (const chunk of req) {
57
+ const buf = chunk as Buffer
58
+ total += buf.length
59
+ if (total > SAVE_BODY_MAX) return null
60
+ chunks.push(buf)
61
+ }
62
+ } catch { return null }
63
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) }
64
+ catch { return null }
65
+ }
66
+
67
+ function respondJson(res: ServerResponse, status: number, obj: object): void {
68
+ res.writeHead(status, { 'content-type': 'application/json' })
69
+ res.end(JSON.stringify(obj))
70
+ }
71
+
72
+ export type SyncHandlersDeps = {
73
+ handle: Handle
74
+ send: (socket: WebSocket, msg: object) => void
75
+ broadcast: (tag: string, msg: object, except: WebSocket | null) => void
76
+ // Cross-instance pub/sub for live broadcasts. Fired alongside the
77
+ // local `broadcast` after a successful commit so peers on OTHER
78
+ // server instances see the new revision in real time. Carries only
79
+ // `(tag, revisionId)` — the receiver re-fetches the row from
80
+ // workspace_revision because the ciphertext can exceed the bus's
81
+ // payload budget. Optional: a SQLite deployment passes a no-op.
82
+ publishRevision: (tag: string, revisionId: string) => void
83
+ subscribe: (socket: WebSocket, tag: string) => void
84
+ pauseBroadcasts: (socket: WebSocket) => () => void
85
+ getNonce: (socket: WebSocket) => string | undefined
86
+ requiresAuth: (socket: WebSocket) => boolean
87
+ // Whether an operator password is configured. The REST save plane's
88
+ // new-workspace gate (it has no socket to read operator-auth state from)
89
+ // collapses to `passwordConfigured && workspace-new` — the socket-less
90
+ // analog of `requiresAuth`, matching the objstore `restPutGate`.
91
+ passwordConfigured: boolean
92
+ sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
93
+ workspaceExists: (tag: string) => Promise<boolean>
94
+ // Objstore inventory snapshot for a workspace tag, as wire rows. The
95
+ // `workspace-subscribed` ack carries it. Injected — the objstore store
96
+ // has its own richer `Handle`, so index.ts wires `listLive(
97
+ // objstoreHandle, tag).then(rows => rows.map(objectMetaWire))` rather
98
+ // than coupling this module to that store type. Returns [] for a
99
+ // triage-only tag.
100
+ objstoreResources: (tag: string) => Promise<object[]>
101
+ debug: boolean
102
+ }
103
+
104
+ export type SyncHandlers = {
105
+ handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
106
+ // Session-independent REST save plane (`POST /api/sync/save`), wired into
107
+ // the HTTP dispatcher in server-e2e/http.ts. Runs the same pipeline as
108
+ // `handleSave` and renders the outcome as a JSON response.
109
+ handleSaveRest: (req: IncomingMessage, res: ServerResponse) => Promise<void>
110
+ handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
111
+ // Exported because the dispatcher's inflight-cap `busy` NACK path
112
+ // emits a save-error too (the only emit site outside this module).
113
+ sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
114
+ }
115
+
116
+ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
117
+ const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
118
+ // Only the newest lookup for a socket/tag may publish a snapshot.
119
+ // Versions cannot order different object incarnations (or deletions).
120
+ const subscriptionLookups = new WeakMap<WebSocket, Map<string, SubscriptionLookup>>()
121
+
122
+ // Typed wrapper for the three `workspace-save-error` emit sites
123
+ // (too-large at handleSave, stale-base after the catch-up, busy at
124
+ // the inflight-cap drop). Forces `reason` to be a member of
125
+ // `SaveErrorReason` so a typo or a server-side addition that didn't
126
+ // update `common/save-error-reason.ts` fails at compile time rather
127
+ // than turning into a wire-level surprise. The shared taxonomy is
128
+ // pinned by `tests/save-error-reason-taxonomy.test.js`.
129
+ function sendSaveError(
130
+ socket: WebSocket,
131
+ workspaceTag: string,
132
+ base: string | null,
133
+ reason: SaveErrorReason,
134
+ ): void {
135
+ // Runtime guard alongside the compile-time `SaveErrorReason` union —
136
+ // covers the case where the `reason` argument is a variable (not a
137
+ // string literal) and TS's narrowing can't enforce taxonomy
138
+ // membership at the call site. Throws because a server emitting a
139
+ // typo'd reason would be a wire-protocol break the client can't
140
+ // recover from; better to fail fast in the test suite than to
141
+ // silently land bytes the client coerces to `'rejected'`.
142
+ if (!SAVE_ERROR_REASONS.has(reason)) {
143
+ throw new Error(`sendSaveError: reason '${reason}' is not in SAVE_ERROR_REASONS — update common/save-error-reason.ts`)
144
+ }
145
+ send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
146
+ }
147
+
148
+ // Normalise `keyframe` to a strict boolean on outbound chain entries.
149
+ // SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
150
+ // raw rows; the wire contract (and canonical signing payload) uses
151
+ // strict `=== true`. Convert once on the send side — forwarding the
152
+ // integer relies on clients coercing via `Boolean()` before rebuilding
153
+ // canonical bytes, fragile against any client that strict-compares.
154
+ function chainForWire(revisions: RevisionRow[]): WireRevision[] {
155
+ return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
156
+ }
157
+
158
+ // Transport-agnostic save pipeline, shared by the WS `handleSave` renderer
159
+ // and the REST `handleSaveRest` renderer. Runs the full validate → precheck
160
+ // → sig-verify → size/auth gates → commit → broadcast pipeline and returns a
161
+ // structured `SaveOutcome` the caller renders for its transport. Two params
162
+ // abstract the transport:
163
+ // - `authRequired`: the new-workspace gate decision (WS: requiresAuth(
164
+ // socket); REST: passwordConfigured — a REST request can't be operator-
165
+ // authorised, so the gate collapses to "password set AND workspace new").
166
+ // - `except`: the broadcast exclusion — the originating socket on the WS
167
+ // path (so it isn't echoed its own save), or null on the REST path (the
168
+ // request isn't a subscriber socket; the originator's own echo lands on
169
+ // its subscription stream and is an idempotent no-op — applyChainToBase
170
+ // skips a revision whose id already equals the client's baseRevision,
171
+ // and a same-content re-apply converges. Matches objstore-deleted's
172
+ // `except: null`).
173
+ async function commitSave(msg: SaveMsg, authRequired: boolean, except: WebSocket | null): Promise<SaveOutcome> {
174
+ // `base` is `string | null`; null is the keyframe-root marker.
175
+ 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' }
176
+ // Compute canonical bytes + content-addressed id ONCE, then thread
177
+ // both through the precheck → sig verify → commit pipeline:
178
+ // 1. canonicalSave (sync, throws on lone-surrogate input)
179
+ // 2. SHA-256 → id
180
+ // 3. precheck: revisionExists → short-circuit ack on replay
181
+ // (skips Ed25519 verify; closes the round-9 H1 CPU-DoS vector
182
+ // where a passive observer floods captured saves)
183
+ // 4. verifyEd25519 against the SAME canonical bytes the id was
184
+ // hashed from — provably tied
185
+ // 5. ciphertext size policy (post-sig so the explicit error only
186
+ // reaches a legit signer)
187
+ // 6. commitRevision — re-checks dup + base + inserts via a single
188
+ // gated INSERT (dup gate + head-equals-base gate + the
189
+ // server-assigned seq folded into one statement) — NO write
190
+ // lock. See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
191
+ let canonical: Uint8Array<ArrayBuffer>
192
+ try { canonical = canonicalSave(msg) } catch { return { kind: 'rejected' } }
193
+ const id = await computeRevisionIdFromCanonical(canonical)
194
+ const tag = msg.workspaceTag
195
+ const baseNorm = msg.base ?? null
196
+ if (await revisionExists(handle, tag, id)) {
197
+ if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
198
+ return { kind: 'duplicate', id, base: baseNorm }
199
+ }
200
+ if (!await verifyEd25519(tag, canonical, msg.signature)) {
201
+ if (debug) console.warn('reject save: bad signature', debugTag(tag))
202
+ return { kind: 'rejected' }
203
+ }
204
+ // Size policy — emit an explicit error so the client can surface
205
+ // the failure to the user. Without this, an oversized save hangs
206
+ // forever in the client's `pending` slot (no ack, no rebase).
207
+ if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
208
+ if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
209
+ return { kind: 'too-large', base: baseNorm }
210
+ }
211
+ // Auth gate for the FIRST action against a workspace tag that
212
+ // doesn't yet exist on the server. Checked AFTER sig verify so the
213
+ // `unauthorized` outcome only reaches a legitimate signer; shape /
214
+ // sig attacks still drop silently. The TOCTOU between `workspaceExists`
215
+ // and the commit's gated INSERT is an accepted soft-policy race — a
216
+ // racer still had to produce a valid Ed25519 signature (= holds the
217
+ // seed). Same gate the objstore put-begin applies.
218
+ if (authRequired && !await workspaceExists(tag)) {
219
+ if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
220
+ return { kind: 'unauthorized', base: baseNorm }
221
+ }
222
+ // Save does NOT subscribe the sender — that would be a replay vector
223
+ // (a captured frame replayed from any connection would attach as a
224
+ // subscriber without holding the seed). Explicit `workspace-subscribe`
225
+ // (signs the per-connection nonce) is the ONLY attach path. Round-9 H1.
226
+ //
227
+ // `keyframe === true` is what canonicalSave bound the signature to
228
+ // (strict equality); the signer's intent is unambiguous here.
229
+ const keyframe = msg.keyframe === true
230
+ const commit = await commitRevision(handle, {
231
+ tag, id, base: baseNorm, keyframe,
232
+ nonce: msg.nonce, ciphertext: msg.ciphertext, signature: msg.signature,
233
+ })
234
+ if (commit.kind === 'duplicate') {
235
+ if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
236
+ return { kind: 'duplicate', id, base: baseNorm }
237
+ }
238
+ if (commit.kind === 'stale-base') {
239
+ // Client claimed a base that's no longer head. The catch-up chain is
240
+ // computed OUTSIDE any lock — a concurrent commit landing here only
241
+ // means the catch-up is fresher (benign; clients tolerate extra
242
+ // revisions). The WS renderer sends `workspace-state` (catch-up) FIRST
243
+ // then the typed `stale-base` error; the REST renderer returns the
244
+ // chain in the 409 body. Either way the catch-up clears the client's
245
+ // pending and the error is a no-op on the now-missing pending (a
246
+ // recoverable race — client rebases + re-saves).
247
+ //
248
+ // The catch-up CAN be empty: a client holding a base from a chain
249
+ // this deployment no longer has (wiped / moved DB, SQLite→Neon
250
+ // migration — head=null, no keyframe, no rows) gets `revisions: []`.
251
+ // The client detects that shape (pending survives the catch-up) and
252
+ // answers with a full-state push re-anchored at base=null, which
253
+ // commits as the new chain root — see the client's
254
+ // `handleSaveError` stale-base branch.
255
+ const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
256
+ if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
257
+ return { kind: 'stale-base', base: baseNorm, revisions }
258
+ }
259
+ if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
260
+ // Carry `keyframe` as a strict boolean on the broadcast wire — peers
261
+ // strict-compare `=== true` (matching the canonical-payload contract).
262
+ broadcast(tag, {
263
+ type: 'workspace-state',
264
+ workspaceTag: tag,
265
+ revisions: [{
266
+ base: baseNorm,
267
+ id,
268
+ keyframe,
269
+ nonce: msg.nonce,
270
+ ciphertext: msg.ciphertext,
271
+ signature: msg.signature,
272
+ }],
273
+ }, except)
274
+ // Cross-instance fan-out. The bus payload carries only the revision id —
275
+ // peers on OTHER instances re-fetch the row. SQLite mode passes a no-op;
276
+ // Neon mode publishes via pg_notify. Best-effort.
277
+ publishRevision(tag, id)
278
+ return { kind: 'ack', id, base: baseNorm }
279
+ }
280
+
281
+ // WS renderer: run the shared pipeline against this socket and render the
282
+ // outcome as the protocol frames the client expects on its stream. A
283
+ // `rejected` outcome drops silently (matches the prior malformed/bad-sig
284
+ // behaviour). For every non-rejected outcome the tag was validated inside
285
+ // `commitSave`, so the cast to string is sound.
286
+ async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
287
+ const outcome = await commitSave(msg, requiresAuth(socket), socket)
288
+ if (outcome.kind === 'rejected') return
289
+ const tag = msg.workspaceTag as string
290
+ if (outcome.kind === 'unauthorized') { sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: outcome.base }); return }
291
+ if (outcome.kind === 'too-large') { sendSaveError(socket, tag, outcome.base, 'too-large'); return }
292
+ if (outcome.kind === 'stale-base') {
293
+ // State FIRST (its handler clears pending), then the typed error.
294
+ send(socket, { type: 'workspace-state', workspaceTag: tag, revisions: outcome.revisions })
295
+ sendSaveError(socket, tag, outcome.base, 'stale-base')
296
+ return
297
+ }
298
+ // ack | duplicate
299
+ send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: outcome.base, id: outcome.id })
300
+ }
301
+
302
+ // REST renderer: the session-independent `POST /api/sync/save` plane. Reads
303
+ // the save frame from the JSON body, runs the SAME pipeline (no socket;
304
+ // new-workspace gate = passwordConfigured; broadcast except = null), and
305
+ // maps the outcome to a JSON + HTTP status the client switches on. SSE-mode
306
+ // clients POST here so a save doesn't take over their event-stream; a 401
307
+ // routes them to the in-band frame (which runs the operator auth flow).
308
+ // Mounted + gated (same-origin, shutdown, idle-timeout) in server-e2e/http.ts.
309
+ async function handleSaveRest(req: IncomingMessage, res: ServerResponse): Promise<void> {
310
+ const body = await readSaveBody(req)
311
+ if (!body || typeof body !== 'object') { respondJson(res, 400, { reason: 'bad-request' }); return }
312
+ const outcome = await commitSave(body as SaveMsg, passwordConfigured, null)
313
+ if (outcome.kind === 'ack' || outcome.kind === 'duplicate') { respondJson(res, 200, { ok: true, id: outcome.id }); return }
314
+ if (outcome.kind === 'stale-base') { respondJson(res, 409, { reason: 'stale-base', revisions: outcome.revisions }); return }
315
+ if (outcome.kind === 'too-large') { respondJson(res, 413, { reason: 'too-large' }); return }
316
+ if (outcome.kind === 'unauthorized') { respondJson(res, 401, { reason: 'unauthorized' }); return }
317
+ respondJson(res, 400, { reason: 'bad-request' })
318
+ }
319
+
320
+ async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
321
+ // Mirror handleSave's wire gate: length-cap + base64url-alphabet on
322
+ // workspaceTag / signature / from BEFORE canonicalSubscribe UTF-8-
323
+ // encodes them and verifyEd25519 base64-decodes the signature.
324
+ // Without it a peer can submit up-to-maxPayload strings and force
325
+ // O(n) encode + decode per subscribe before the 32/64-byte length
326
+ // gates reject — a CPU-DoS surface handleSave is already hardened
327
+ // against. `from` keeps the `string | null` contract (null = full-
328
+ // chain catch-up); a legit signer's `from` is a base64url revision
329
+ // id, so any non-string / over-long / wrong-alphabet value can't be
330
+ // one the signature was computed over, and the chain-lookup path
331
+ // (`typeof msg.from === 'string' ? msg.from : null`) still treats a
332
+ // null / absent `from` as the keyframe-fallback.
333
+ if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.from != null && !validTagSigBase(msg.from, MAX_FIELD_LEN))) return
334
+ // The challenge nonce we issued on this socket is bound into the
335
+ // signed canonical, blocking cross-connection replay of a captured
336
+ // subscribe frame. A subscribe arriving before we sent the
337
+ // challenge (impossible from the legitimate client) has no nonce to
338
+ // verify against — drop. Audit round-9 H2.
339
+ const nonce = getNonce(socket)
340
+ if (typeof nonce !== 'string') return
341
+ const ok = await verifySubscribeSig(msg, nonce)
342
+ if (!ok) {
343
+ if (debug) console.warn('reject subscribe: bad signature', debugTag(msg.workspaceTag))
344
+ return
345
+ }
346
+ // Bail if the socket closed during the verify await. The close
347
+ // handler's `unsubscribeAll(socket)` already ran (when there was
348
+ // nothing to remove yet), and `subscribe()` below would add the
349
+ // dead socket to `subscribers[tag]` — a permanent leak: broadcasts
350
+ // no-op via `send`'s readyState gate, but the Set entry pins the
351
+ // socket reference past close, blocking GC. Audit round-12.
352
+ if (socket.readyState !== socket.OPEN) {
353
+ if (debug) console.warn('reject subscribe: socket closed mid-verify', debugTag(msg.workspaceTag))
354
+ return
355
+ }
356
+ const tag = msg.workspaceTag
357
+ const lookups = subscriptionLookups.get(socket) ?? new Map<string, SubscriptionLookup>()
358
+ subscriptionLookups.set(socket, lookups)
359
+ const previous = lookups.get(tag)
360
+ const resume = deps.pauseBroadcasts(socket)
361
+ const lookup = { resume }
362
+ lookups.set(tag, lookup)
363
+ // Acquire the replacement's pause before releasing its predecessor so
364
+ // buffered broadcasts cannot overtake the replacement snapshot. A hung
365
+ // obsolete read must not keep the whole socket paused. The hub's release
366
+ // is idempotent, so the old finally can still call it when the read ends.
367
+ previous?.resume()
368
+ try {
369
+ subscribe(socket, tag)
370
+ // Explicit ack — distinguishes "the server processed my subscribe
371
+ // and registered me as a peer" from "the WebSocket is open". A
372
+ // client that sent a malformed / bad-sig subscribe never gets this;
373
+ // a client that did gets one before the chain arrives. Lets the UI
374
+ // surface a `connecting → online` transition based on real handshake
375
+ // completion, not just socket state.
376
+ //
377
+ // The ack also carries the objstore inventory snapshot: the same
378
+ // subscribe that registers this socket for objstore-put / -deleted
379
+ // broadcasts seeds the client's initial inventory in one handshake.
380
+ // The client keeps it live thereafter from those broadcasts.
381
+ // Returns [] for a triage-only workspace. A lookup failure is NOT an
382
+ // empty inventory: clients interpret absent resources as deletions.
383
+ // Reconnect to retry the complete handshake on either transport.
384
+ const resources = await objstoreResources(tag)
385
+ if (socket.readyState !== socket.OPEN || lookups.get(tag) !== lookup) return
386
+ // `from` is the last revision id the client claims to have applied —
387
+ // now a base64url string, not an integer. We send only revisions
388
+ // after that. Client lying about `from` just means they get a
389
+ // smaller catch-up — their subsequent saves will reveal stale state
390
+ // on the usual base-mismatch path. Null / missing → full chain.
391
+ const fromId = typeof msg.from === 'string' ? msg.from : null
392
+ const revisions = chainForWire(await chainFrom(handle, tag, fromId))
393
+ if (socket.readyState !== socket.OPEN || lookups.get(tag) !== lookup) return
394
+ if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
395
+ send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
396
+ send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
397
+ } catch (err) {
398
+ if (lookups.get(tag) !== lookup) return
399
+ if (debug) console.warn('subscribe: snapshot lookup failed', debugTag(tag), err)
400
+ socket.close(1011, 'subscription unavailable')
401
+ } finally {
402
+ if (lookups.get(tag) === lookup) lookups.delete(tag)
403
+ resume()
404
+ }
405
+ }
406
+
407
+ return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
408
+ }
@@ -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