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

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 +1330 -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 +25 -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
@@ -1,204 +0,0 @@
1
- // Process lifecycle: the in-flight request tracker, the `shuttingDown`
2
- // gate, the graceful-shutdown choreography, and the signal / error /
3
- // uncaught-exception handlers that drive it. Two-phase so the bits the
4
- // HTTP + WS planes need (`track`, `isShuttingDown`) exist BEFORE those
5
- // servers are built, while the teardown is `install()`ed afterward —
6
- // once the server objects, heartbeat timer, and reaper exist.
7
-
8
- import type { Server } from 'node:http'
9
- import type { WebSocketServer } from 'ws'
10
- import type { SseSession } from './sse-session.ts'
11
- import { errStack } from './util.ts'
12
-
13
- export type ShutdownDeps = {
14
- httpServer: Server
15
- wss: WebSocketServer
16
- heartbeatTimer: ReturnType<typeof setInterval>
17
- // Stops the periodic reaper AND awaits any in-flight sweep.
18
- stopReaper: () => Promise<void>
19
- // Live SSE+POST session iterator (mirrors `wss.clients` for the SSE
20
- // fallback transport). Walked alongside `wss.clients` to send the
21
- // 1001 close frame and apply the terminate-grace timer to both
22
- // transports uniformly.
23
- sseSessions: () => Iterable<SseSession>
24
- // App-specific teardown, run AFTER the in-flight drain: close the DB.
25
- // Swallows its own errors (a failed close shouldn't abort the exit).
26
- closeDb: () => Promise<void>
27
- }
28
-
29
- export type Lifecycle = {
30
- // Collects in-flight async handlers so `shutdown` can drain them
31
- // before closing the DB. Passed to the HTTP + WS planes.
32
- track: (promise: Promise<unknown>) => void
33
- // Read by the REST shutdown gate + the WS message loop to drop new
34
- // work once teardown began.
35
- isShuttingDown: () => boolean
36
- // Wire the graceful shutdown + the error / signal / process-catchall
37
- // handlers. Call once, after the server objects exist.
38
- install: (deps: ShutdownDeps) => void
39
- }
40
-
41
- export function createLifecycle(): Lifecycle {
42
- // In-flight async message handlers. `shutdown` awaits this set before
43
- // closing the DB so a SIGINT mid-save can't resume against a closed
44
- // handle (which would throw inside the commit's gated INSERT after
45
- // the client believed its save was committed).
46
- const inFlight = new Set<Promise<unknown>>()
47
- function track(promise: Promise<unknown>): void {
48
- inFlight.add(promise)
49
- // Trailing `.catch(() => {})` swallows the rejection that `.finally`
50
- // propagates through its returned promise — without it, a tracked
51
- // handler that rejects (or whose caller's catch handler itself
52
- // throws) trips the `unhandledRejection` catchall below and crashes
53
- // the process via `fireShutdown(1)`.
54
- promise.finally(() => inFlight.delete(promise)).catch(() => {})
55
- }
56
- let shuttingDown = false
57
- // Live exit code the in-progress shutdown will pass to `process.exit`.
58
- // Re-entry can ESCALATE it from 0 → 1 (e.g. a `wss.error` firing
59
- // during a SIGTERM-driven graceful shutdown shouldn't leave the
60
- // launcher seeing a clean exit code) but can never DE-escalate.
61
- // Audit round-13.
62
- let pendingExitCode = 0
63
-
64
- function install(deps: ShutdownDeps): void {
65
- const { httpServer, wss, heartbeatTimer, stopReaper, sseSessions, closeDb } = deps
66
-
67
- async function shutdown(exitCode: number = 0): Promise<void> {
68
- // Re-entry: don't restart the teardown, but escalate the pending
69
- // exit code if the new caller is non-zero (e.g. a wss.error during
70
- // a SIGTERM-driven graceful shutdown). Without this, an error
71
- // arriving mid-shutdown would silently exit 0 and the launcher
72
- // would record a clean stop.
73
- if (shuttingDown) {
74
- if (exitCode !== 0 && pendingExitCode === 0) pendingExitCode = exitCode
75
- return
76
- }
77
- shuttingDown = true
78
- pendingExitCode = exitCode
79
- console.log('Shutting down…')
80
- // Stop the heartbeat so a tick can't fire mid-shutdown and ping a
81
- // socket the close-loop below already started tearing down.
82
- clearInterval(heartbeatTimer)
83
- // Send a 1001 (going away) close frame to every open socket BEFORE
84
- // shutting the listener. Lets clients distinguish a server-initiated
85
- // graceful shutdown from a network drop, so they can skip their
86
- // reconnect backoff. Fire-and-forget — `process.exit` below would
87
- // force-kill any in-progress flush anyway. `try/catch` shrugs at
88
- // sockets already in CLOSING / CLOSED.
89
- for (const socket of wss.clients) {
90
- try { socket.close(1001, 'Server shutting down') } catch {}
91
- }
92
- // Same close frame on every SSE+POST session — the adapter
93
- // translates `close(1001, …)` into a structured `event: close`
94
- // record on the SSE stream so the client transport can read the
95
- // code and skip its reconnect backoff (parity with the WS path).
96
- for (const session of sseSessions()) {
97
- try { session.close(1001, 'Server shutting down') } catch {}
98
- }
99
- // Force-terminate any client that doesn't ack the close frame
100
- // within a short grace window. `wss.close()` waits for every client
101
- // to emit `'close'`, and `ws` only TCP-RSTs unresponsive peers
102
- // after its own ~30 s `closeTimeout`. A single dead/blackholed peer
103
- // would otherwise stretch SIGTERM/SIGINT response by that full
104
- // timeout. Audit round-11.
105
- const TERMINATE_GRACE_MS = 1_000
106
- const terminateTimer = setTimeout(() => {
107
- for (const socket of wss.clients) {
108
- const rs = socket.readyState
109
- if (rs === socket.OPEN || rs === socket.CLOSING) {
110
- try { socket.terminate() } catch {}
111
- }
112
- }
113
- // SSE sessions are tracked separately from `wss.clients`; apply
114
- // the same terminate-grace policy so a stranded SSE response
115
- // can't pin the HTTP keep-alive shutdown branch below.
116
- for (const session of sseSessions()) {
117
- const rs = session.readyState
118
- if (rs === session.OPEN || rs === session.CLOSING) {
119
- try { session.terminate() } catch {}
120
- }
121
- }
122
- // Same grace for HTTP keep-alive sockets that didn't respect the
123
- // `Connection: close` hint, which would otherwise hold
124
- // `httpServer.close()` until their TCP timeout.
125
- try { httpServer.closeAllConnections() } catch {}
126
- }, TERMINATE_GRACE_MS)
127
- // Don't keep the event loop alive solely for the grace timer.
128
- terminateTimer.unref?.()
129
- // Stop the periodic reaper AND wait for any in-flight sweep (incl.
130
- // the startup sweep) before the DB close — otherwise a
131
- // readdir / unlink would race a closed DB.
132
- await stopReaper()
133
- // Free idle HTTP keep-alive sockets up front so close() below
134
- // doesn't wait on them. Active in-flight requests still finish.
135
- try { httpServer.closeIdleConnections() } catch {}
136
- // Close http.Server first to stop accepting new upgrades + HTTP
137
- // requests. Guard with `.listening` because `close()` throws
138
- // ERR_SERVER_NOT_RUNNING when bind never succeeded (the http error
139
- // handler is the path that invoked shutdown in that case).
140
- if (httpServer.listening) {
141
- await new Promise<void>((resolve) => { httpServer.close(() => resolve()) })
142
- }
143
- await new Promise<void>((resolve) => { wss.close(() => resolve()) })
144
- clearTimeout(terminateTimer)
145
- // Drain in-flight handlers so a save that's mid-pipeline finishes
146
- // its commit before the DB closes. `handleSave` splits its
147
- // canonical/id/dup-precheck/verify/commit work across awaits, so
148
- // the window spans several yield points. `Promise.allSettled` so a
149
- // single handler rejection doesn't abort the drain.
150
- if (inFlight.size > 0) await Promise.allSettled([...inFlight])
151
- // App teardown AFTER the drain: close the DB.
152
- await closeDb()
153
- // Read `pendingExitCode` (not the parameter) so a re-entrant
154
- // `shutdown(1)` that landed during the drain wins over the original
155
- // `shutdown(0)`. See round-13 escalation note.
156
- process.exit(pendingExitCode)
157
- }
158
-
159
- // `.catch` defends against an unguarded `await` slipping into
160
- // `shutdown`: an unhandled rejection there would skip the non-zero
161
- // exit the launcher relies on.
162
- function fireShutdown(code: number): void {
163
- shutdown(code).catch((err) => {
164
- console.warn('shutdown error:', errStack(err))
165
- process.exit(code === 0 ? 1 : code)
166
- })
167
- }
168
-
169
- // Route bind / post-listen failures through `shutdown` so the
170
- // in-flight drain + DB close still run before exit. Without this,
171
- // `ws` re-emits the error as uncaughtException and the launcher sees
172
- // a confusing crash rather than the bind failure. Audit round-9 M2.
173
- httpServer.on('error', (err: Error) => {
174
- console.error('Server error:', errStack(err))
175
- fireShutdown(1)
176
- })
177
- // Symmetric with the http.Server error handler — route through
178
- // `fireShutdown(1)` so the launcher sees a non-zero exit, and the
179
- // re-entry escalation bumps `pendingExitCode` 0 → 1 for a `wss.error`
180
- // arriving mid-graceful-SIGTERM.
181
- wss.on('error', (err: Error) => {
182
- console.error('WS server error:', errStack(err))
183
- fireShutdown(1)
184
- })
185
- // Wrap signal handlers so the signal name (the listener's first arg)
186
- // doesn't bleed into shutdown's `exitCode`.
187
- process.on('SIGINT', () => fireShutdown(0))
188
- process.on('SIGTERM', () => fireShutdown(0))
189
- // Process-level catchalls so a stray rejection / uncaught exception
190
- // doesn't bypass `shutdown()` — Node 20+ exits on unhandled
191
- // rejection, which would skip the drain + DB close. Log forensically
192
- // and route through `fireShutdown(1)`. Audit round-11 observability.
193
- process.on('unhandledRejection', (reason) => {
194
- console.error('Unhandled rejection:', errStack(reason))
195
- fireShutdown(1)
196
- })
197
- process.on('uncaughtException', (err) => {
198
- console.error('Uncaught exception:', errStack(err))
199
- fireShutdown(1)
200
- })
201
- }
202
-
203
- return { track, isShuttingDown: () => shuttingDown, install }
204
- }
@@ -1,327 +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
- // Cross-instance pub/sub for live broadcasts. Fired alongside the
33
- // local `broadcast` after a successful commit so peers on OTHER
34
- // server instances see the new revision in real time. Carries only
35
- // `(tag, revisionId)` — the receiver re-fetches the row from
36
- // workspace_revision because the ciphertext can exceed the bus's
37
- // payload budget. Optional: a SQLite deployment passes a no-op.
38
- publishRevision: (tag: string, revisionId: string) => void
39
- subscribe: (socket: WebSocket, tag: string) => void
40
- getNonce: (socket: WebSocket) => string | undefined
41
- requiresAuth: (socket: WebSocket) => boolean
42
- sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
43
- workspaceExists: (tag: string) => Promise<boolean>
44
- // Objstore inventory snapshot for a workspace tag, as wire rows. The
45
- // `workspace-subscribed` ack carries it. Injected — the objstore store
46
- // has its own richer `Handle`, so index.ts wires `listLive(
47
- // objstoreHandle, tag).then(rows => rows.map(objectMetaWire))` rather
48
- // than coupling this module to that store type. Returns [] for a
49
- // triage-only tag.
50
- objstoreResources: (tag: string) => Promise<object[]>
51
- debug: boolean
52
- }
53
-
54
- export type SyncHandlers = {
55
- handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
56
- handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
57
- // Exported because the dispatcher's inflight-cap `busy` NACK path
58
- // emits a save-error too (the only emit site outside this module).
59
- sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
60
- }
61
-
62
- export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
63
- const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
64
-
65
- // Typed wrapper for the three `workspace-save-error` emit sites
66
- // (too-large at handleSave, stale-base after the catch-up, busy at
67
- // the inflight-cap drop). Forces `reason` to be a member of
68
- // `SaveErrorReason` so a typo or a server-side addition that didn't
69
- // update `common/save-error-reason.ts` fails at compile time rather
70
- // than turning into a wire-level surprise. The shared taxonomy is
71
- // pinned by `tests/save-error-reason-taxonomy.test.js`.
72
- function sendSaveError(
73
- socket: WebSocket,
74
- workspaceTag: string,
75
- base: string | null,
76
- reason: SaveErrorReason,
77
- ): void {
78
- // Runtime guard alongside the compile-time `SaveErrorReason` union —
79
- // covers the case where the `reason` argument is a variable (not a
80
- // string literal) and TS's narrowing can't enforce taxonomy
81
- // membership at the call site. Throws because a server emitting a
82
- // typo'd reason would be a wire-protocol break the client can't
83
- // recover from; better to fail fast in the test suite than to
84
- // silently land bytes the client coerces to `'rejected'`.
85
- if (!SAVE_ERROR_REASONS.has(reason)) {
86
- throw new Error(`sendSaveError: reason '${reason}' is not in SAVE_ERROR_REASONS — update common/save-error-reason.ts`)
87
- }
88
- send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
89
- }
90
-
91
- // Normalise `keyframe` on outbound chain entries to a strict boolean.
92
- // SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
93
- // raw rows; the wire contract (and the canonical signing payload)
94
- // uses strict `=== true` to mark keyframes. Forwarding the integer
95
- // shape works only because every shipping client coerces via
96
- // `Boolean(rev.keyframe)` before reconstructing the canonical bytes —
97
- // fragile if a future client (or test harness) ever strict-compares.
98
- // Convert once on the send side.
99
- function chainForWire(revisions: RevisionRow[]): WireRevision[] {
100
- return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
101
- }
102
-
103
- async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
104
- // `base` is `string | null`; null is the keyframe-root marker.
105
- if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validNonce(msg.nonce, MAX_FIELD_LEN) || !validCiphertextShape(msg.ciphertext) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.base != null && !validTagSigBase(msg.base, MAX_FIELD_LEN))) return
106
- // Compute canonical bytes + content-addressed id ONCE, then thread
107
- // both through the precheck → sig verify → commit pipeline:
108
- // 1. canonicalSave (sync, throws on lone-surrogate input)
109
- // 2. SHA-256 → id
110
- // 3. precheck: revisionExists → short-circuit ack on replay
111
- // (skips Ed25519 verify; closes the round-9 H1 CPU-DoS vector
112
- // where a passive observer floods captured saves)
113
- // 4. verifyEd25519 against the SAME canonical bytes the id was
114
- // hashed from — provably tied
115
- // 5. ciphertext size policy (post-sig so the explicit error only
116
- // reaches a legit signer)
117
- // 6. commitRevision — re-checks dup + base + inserts via a single
118
- // gated INSERT (dup gate + head-equals-base gate + the
119
- // server-assigned seq folded into one statement) — NO write
120
- // lock. The dup recheck, headFor, base-match and insert all
121
- // collapse into that one statement, whose head-check and
122
- // MAX(seq) read one snapshot; the `UNIQUE(workspace_tag, seq)`
123
- // PK rejects any racer that computed the same seq. So two
124
- // concurrent saves with the same `base` and different ids
125
- // can't both insert (the loser's `head IS base` gate fails →
126
- // `stale-base`, no chain fork even though UNIQUE is on id, not
127
- // base), and two concurrent same-id retransmits resolve to one
128
- // `inserted` + one `duplicate` with no UNIQUE throw escaping.
129
- // See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
130
- let canonical: Uint8Array<ArrayBuffer>
131
- try { canonical = canonicalSave(msg) } catch { return }
132
- const id = await computeRevisionIdFromCanonical(canonical)
133
- const tag = msg.workspaceTag
134
- if (await revisionExists(handle, tag, id)) {
135
- if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
136
- send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: msg.base ?? null, id })
137
- return
138
- }
139
- if (!await verifyEd25519(tag, canonical, msg.signature)) {
140
- if (debug) console.warn('reject save: bad signature', debugTag(tag))
141
- return
142
- }
143
- // Size policy — emit an explicit error so the client can surface
144
- // the failure to the user. Without this, an oversized save hangs
145
- // forever in the client's `pending` slot (no ack, no rebase).
146
- if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
147
- if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
148
- sendSaveError(socket, tag, msg.base == null || typeof msg.base !== 'string' ? null : msg.base, 'too-large')
149
- return
150
- }
151
- // Auth gate for the FIRST action against a workspace tag that
152
- // doesn't yet exist on the server (no rows in workspace_revision
153
- // AND none in workspace_object). Once any row lands, the workspace
154
- // is established and every signed action flows freely — access
155
- // control falls back to the Ed25519 signature for the rest of the
156
- // workspace's lifetime. Checked AFTER sig verify so the
157
- // `unauthorized` frame only reaches a legitimate signer; shape /
158
- // sig attacks still drop silently.
159
- //
160
- // RACE: `workspaceExists` reads at a different moment than the
161
- // commit's gated INSERT (a plain TOCTOU — there is no lock spanning
162
- // the two). Under concurrent saves on a fresh tag, an
163
- // unauthenticated socket whose `workspaceExists` observes "true"
164
- // (because an authenticated peer's commit landed between this
165
- // socket's check and its commit) skips the gate and commits as the
166
- // second writer. Accepted: the unauthenticated peer still had to
167
- // produce a valid Ed25519 signature (= holds the workspace seed),
168
- // and "two concurrent writes both authorising" is the worst case.
169
- // Tightening would require folding the gate into the commit
170
- // statement itself and is not worth the layer crossing for the
171
- // soft-policy guarantee.
172
- if (requiresAuth(socket) && !await workspaceExists(tag)) {
173
- if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
174
- sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: msg.base ?? null })
175
- return
176
- }
177
- // NOTE: Earlier revisions auto-subscribed the sending socket here.
178
- // That created a replay vector — a passive observer who captured
179
- // any single valid `workspace-save` frame could replay it from any
180
- // TCP connection forever to attach as a subscriber and silently
181
- // mirror every future encrypted broadcast for the workspace,
182
- // without ever holding the seed (the duplicate-id path returns
183
- // ack-only and doesn't reject the socket). Audit round-9 H1.
184
- //
185
- // The legitimate client always sends an explicit
186
- // `workspace-subscribe` (see `trySendSubscribe` in
187
- // `client/triage-sync.js` — fires on key derivation, on socket
188
- // open, on continuity-break recovery, on dismissError). The
189
- // subscribe path remains the only way to attach as a subscriber.
190
- const baseNorm = msg.base ?? null
191
- // `keyframe === true` is what canonicalSave bound the signature to
192
- // (strict equality); the signer's intent is unambiguous here.
193
- const keyframe = msg.keyframe === true
194
- const commit = await commitRevision(handle, {
195
- tag, id, base: baseNorm, keyframe,
196
- nonce: msg.nonce, ciphertext: msg.ciphertext, signature: msg.signature,
197
- })
198
- if (commit.kind === 'duplicate') {
199
- if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
200
- send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: baseNorm, id })
201
- return
202
- }
203
- if (commit.kind === 'stale-base') {
204
- // Client claimed a base that's no longer head. Catch-up chain is
205
- // computed OUTSIDE the lock — a concurrent commit landing between
206
- // lock-release and `chainFrom` only means the catch-up is fresher
207
- // than the recheck saw, which is benign (clients tolerate extra
208
- // revisions in the chain).
209
- //
210
- // Wire order: send `workspace-state` (catch-up) FIRST, then the
211
- // typed `workspace-save-error { reason: 'stale-base' }`. The
212
- // catch-up's handler clears `session.pending`; the subsequent
213
- // error frame's `handleSaveError` then early-returns on the
214
- // missing pending and does NOT mark the session errored — exactly
215
- // what we want, since stale-base is a recoverable race (client
216
- // rebases + re-saves). The typed frame is for protocol clarity
217
- // (debug surfaces / explicit rejection signal), not for triggering
218
- // an error transition. Audit follow-up to round-15 —
219
- // `sync-server-races.test.js:1105`.
220
- const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
221
- if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
222
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
223
- sendSaveError(socket, tag, baseNorm, 'stale-base')
224
- return
225
- }
226
- if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
227
- send(socket, {
228
- type: 'workspace-save-ack',
229
- workspaceTag: tag,
230
- base: baseNorm,
231
- id,
232
- })
233
- // Carry `keyframe` as a strict boolean on the broadcast wire —
234
- // peers strict-compare `=== true` (matching the canonical-payload
235
- // contract). The previous shape emitted `keyframe ? 1 : 0` which a
236
- // strict check would treat as non-keyframe, making a replayed
237
- // keyframe look like a regular delta on broadcast paths even though
238
- // the chain-fetch path (chainFrom → SQLite integer) DID round-trip
239
- // correctly.
240
- broadcast(tag, {
241
- type: 'workspace-state',
242
- workspaceTag: tag,
243
- revisions: [{
244
- base: baseNorm,
245
- id,
246
- keyframe,
247
- nonce: msg.nonce,
248
- ciphertext: msg.ciphertext,
249
- signature: msg.signature,
250
- }],
251
- }, socket)
252
- // Cross-instance fan-out. The bus payload carries only the revision
253
- // id — peers on OTHER instances re-fetch the row from
254
- // workspace_revision to compose their local `workspace-state`. Sized
255
- // for the bus's 8 KB payload budget, which can't carry a 2 MiB
256
- // ciphertext. SQLite mode passes a no-op; Neon mode publishes via
257
- // pg_notify. Best-effort: a dropped publish only means peers on
258
- // other instances miss the live push, but they still catch up via
259
- // the shared DB on their next subscribe / reconnect.
260
- publishRevision(tag, id)
261
- }
262
-
263
- async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
264
- if (typeof msg.workspaceTag !== 'string') return
265
- // Same `string | null` contract as `base` in handleSave. The signed
266
- // canonical uses `String(from)`, but the chain-lookup path
267
- // (`typeof msg.from === 'string' ? msg.from : null`) treats every
268
- // non-string as null — so a legit signer sending `from: { … }`
269
- // would silently take the keyframe-fallback path even though the
270
- // signature was over a different canonical shape. Reject at the
271
- // wire gate.
272
- if (msg.from != null && typeof msg.from !== 'string') return
273
- // The challenge nonce we issued on this socket is bound into the
274
- // signed canonical, blocking cross-connection replay of a captured
275
- // subscribe frame. A subscribe arriving before we sent the
276
- // challenge (impossible from the legitimate client) has no nonce to
277
- // verify against — drop. Audit round-9 H2.
278
- const nonce = getNonce(socket)
279
- if (typeof nonce !== 'string') return
280
- const ok = await verifySubscribeSig(msg, nonce)
281
- if (!ok) {
282
- if (debug) console.warn('reject subscribe: bad signature', debugTag(msg.workspaceTag))
283
- return
284
- }
285
- // Bail if the socket closed during the verify await. The close
286
- // handler's `unsubscribeAll(socket)` already ran (when there was
287
- // nothing to remove yet), and `subscribe()` below would add the
288
- // dead socket to `subscribers[tag]` — a permanent leak: broadcasts
289
- // no-op via `send`'s readyState gate, but the Set entry pins the
290
- // socket reference past close, blocking GC. Audit round-12.
291
- if (socket.readyState !== socket.OPEN) {
292
- if (debug) console.warn('reject subscribe: socket closed mid-verify', debugTag(msg.workspaceTag))
293
- return
294
- }
295
- const tag = msg.workspaceTag
296
- subscribe(socket, tag)
297
- // Explicit ack — distinguishes "the server processed my subscribe
298
- // and registered me as a peer" from "the WebSocket is open". A
299
- // client that sent a malformed / bad-sig subscribe never gets this;
300
- // a client that did gets one before the chain arrives. Lets the UI
301
- // surface a `connecting → online` transition based on real handshake
302
- // completion, not just socket state.
303
- //
304
- // The ack also carries the objstore inventory snapshot: the same
305
- // subscribe that registers this socket for objstore-put / -deleted
306
- // broadcasts seeds the client's initial inventory in one handshake.
307
- // The client keeps it live thereafter from those broadcasts.
308
- // Returns [] for a triage-only workspace. A failing inventory
309
- // lookup must NOT sink the subscribe — degrade to an empty snapshot
310
- // (broadcasts will fill it in) so the ack + chain still go out.
311
- let resources: object[] = []
312
- try { resources = await objstoreResources(tag) }
313
- catch (err) { if (debug) console.warn('subscribe: objstore inventory lookup failed', debugTag(tag), err) }
314
- send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
315
- // `from` is the last revision id the client claims to have applied —
316
- // now a base64url string, not an integer. We send only revisions
317
- // after that. Client lying about `from` just means they get a
318
- // smaller catch-up — their subsequent saves will reveal stale state
319
- // on the usual base-mismatch path. Null / missing → full chain.
320
- const fromId = typeof msg.from === 'string' ? msg.from : null
321
- const revisions = chainForWire(await chainFrom(handle, tag, fromId))
322
- if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
323
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
324
- }
325
-
326
- return { handleSave, handleSubscribe, sendSaveError }
327
- }
File without changes
File without changes
File without changes