@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.
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1330 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +25 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
package/server/lifecycle.ts
DELETED
|
@@ -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
|
-
}
|
package/server/sync-handlers.ts
DELETED
|
@@ -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
|