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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/api/reap.ts +79 -0
  2. package/common/save-error-reason.ts +20 -7
  3. package/common/server-info.ts +30 -0
  4. package/out/brotli-fallback.js +1 -1
  5. package/out/client-admin.js +28 -0
  6. package/out/client-managed.js +1 -0
  7. package/out/client-sync.js +17 -10
  8. package/out/graph.js +5 -4
  9. package/out/index.html +43 -38
  10. package/out/prism.js +2 -2
  11. package/out/terminal.js +32 -28
  12. package/out/view.css +1 -1
  13. package/out/view.js +78 -51
  14. package/package.json +70 -49
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +16 -1
  17. package/server-e2e/bus-receiver.ts +95 -0
  18. package/server-e2e/cli.js +22 -0
  19. package/{server → server-e2e}/config.ts +21 -8
  20. package/{server → server-e2e}/db-neon.ts +41 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +15 -9
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +113 -135
  24. package/server-e2e/http.ts +266 -0
  25. package/{server → server-e2e}/hub.ts +27 -8
  26. package/{server → server-e2e}/index.ts +185 -52
  27. package/{server → server-e2e}/lifecycle.ts +36 -5
  28. package/server-e2e/npm-proxy.ts +348 -0
  29. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  30. package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
  31. package/{server → server-e2e}/objstore/blob.ts +24 -9
  32. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  33. package/{server → server-e2e}/objstore/handlers.ts +25 -15
  34. package/{server → server-e2e}/objstore/init.ts +52 -12
  35. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  36. package/server-e2e/objstore/rest-deny.ts +28 -0
  37. package/server-e2e/objstore/rest-mint.ts +224 -0
  38. package/{server → server-e2e}/objstore/rest.ts +119 -84
  39. package/{server → server-e2e}/objstore/sign.ts +105 -0
  40. package/{server → server-e2e}/objstore/store-neon.ts +19 -19
  41. package/{server → server-e2e}/objstore/store.ts +98 -118
  42. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  43. package/{server → server-e2e}/peer.ts +7 -9
  44. package/server-e2e/pubsub.ts +394 -0
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/server-e2e/sse-server.ts +384 -0
  47. package/server-e2e/sse-session.ts +216 -0
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/server-e2e/sync-handlers.ts +382 -0
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/server-e2e/ws-server.ts +276 -0
  52. package/strip-types-loader.js +94 -0
  53. package/server/http.ts +0 -142
  54. package/server/sync-handlers.ts +0 -311
  55. package/server/ws-server.ts +0 -245
  56. /package/{server → server-e2e}/config.example.json +0 -0
  57. /package/{server → server-e2e}/neon-driver.ts +0 -0
  58. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  59. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,266 @@
1
+ // HTTP plane: the REST byte-transfer routing (`/api/objstore/...`), the
2
+ // static UI bundle, the SSE+POST fallback transport for the sync
3
+ // protocol, and the WebSocket upgrade gate. Built once at boot with
4
+ // the WS server plus the lifecycle hooks it needs (`track` to drain
5
+ // in-flight requests on shutdown, `isShuttingDown` to gate new ones).
6
+ // The WS *connection* handler is wired on `wss` separately in
7
+ // index.ts; this module only owns the upgrade handshake.
8
+
9
+ import { type IncomingMessage as HttpRequest, type Server, type ServerResponse, createServer } from 'node:http'
10
+ import { Buffer } from 'node:buffer'
11
+ import { fileURLToPath } from 'node:url'
12
+ import type { WebSocketServer } from 'ws'
13
+ import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
14
+ import { type ObjstoreRestDeps, handleRest, matchRoute } from './objstore/rest.ts'
15
+ import { SSE_OPEN_PATH, type SseServer } from './sse-server.ts'
16
+ import { dispatchNpmAdvisories } from './npm-proxy.ts'
17
+ import { loadStatic } from './static.ts'
18
+ import { errStack } from './util.ts'
19
+
20
+ // `/api/*` is reserved for backend traffic so a fronting nginx (or
21
+ // similar) can route `/api/*` → this process and `/*` → the static UI
22
+ // bundle with a single location block.
23
+ export const WS_UPGRADE_PATH = '/api/sync'
24
+ // Session-independent triage-sync save plane: `POST /api/sync/save`. The
25
+ // SSE-mode alternative to the in-band `workspace-save` frame — a save POSTed
26
+ // here commits + broadcasts WITHOUT taking over the client's SSE event-stream
27
+ // (each in-band POST forces a stream takeover; see sse-server.ts). Sibling of
28
+ // the WS upgrade path so one nginx `/api/*` block routes both.
29
+ export const SAVE_REST_PATH = '/api/sync/save'
30
+ function isUpgradePath(url: string | undefined): boolean {
31
+ if (typeof url !== 'string') return false
32
+ // Strip `?…` so clients can carry build / debug tags. Exact match
33
+ // otherwise — `/api/sync/` (trailing slash) doesn't pass.
34
+ return url.split('?', 1)[0] === WS_UPGRADE_PATH
35
+ }
36
+
37
+ const NOT_FOUND_BODY = JSON.stringify({ error: 'not-found' })
38
+
39
+ type HasHeaders = { headers: HttpRequest['headers'] }
40
+
41
+ export type HttpServerDeps = {
42
+ wss: WebSocketServer
43
+ restDeps: ObjstoreRestDeps
44
+ // This server's `server-info` (mode advertisement), served as JSON at
45
+ // GET /api/config so a client can detect the protocol without a sync
46
+ // connection (the WS connect frame stays the source of truth).
47
+ serverInfo: ServerInfo
48
+ // SSE+POST fallback. Owns its own session map + lifecycle; we just
49
+ // give it first crack at requests that match `SSE_OPEN_PATH`. Same
50
+ // same-origin / shutdown gates run BEFORE the dispatch so the SSE
51
+ // plane inherits them.
52
+ sseServer: SseServer
53
+ isOriginAllowed: (req: HasHeaders) => boolean
54
+ isShuttingDown: () => boolean
55
+ track: (promise: Promise<unknown>) => void
56
+ // Handler for `POST /api/sync/save` (see SAVE_REST_PATH). Owns body parse +
57
+ // the save pipeline + JSON response; this module owns the gates (method,
58
+ // same-origin, shutdown, idle-timeout) and the graceful-drain tracking.
59
+ handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
60
+ restPutIdleTimeoutMs: number
61
+ debug: boolean
62
+ }
63
+
64
+ // `POST /api/sync/save` dispatch. Returns the in-flight handler promise when
65
+ // the request matched the route (the caller `track`s it for graceful drain),
66
+ // or null when it's for a different route. The gate ladder — method →
67
+ // shutdown → same-origin → idle-timeout — mirrors the objstore REST branch; a
68
+ // gate rejection writes its own response and returns an already-settled
69
+ // promise. Kept out of `createHttpServer` so that dispatcher stays compact.
70
+ function dispatchSaveRest(
71
+ req: HttpRequest, res: ServerResponse,
72
+ deps: {
73
+ handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
74
+ isOriginAllowed: (req: HasHeaders) => boolean
75
+ isShuttingDown: () => boolean
76
+ restPutIdleTimeoutMs: number
77
+ debug: boolean
78
+ },
79
+ ): Promise<void> | null {
80
+ if (typeof req.url !== 'string' || req.url.split('?', 1)[0] !== SAVE_REST_PATH) return null
81
+ if (req.method !== 'POST') {
82
+ res.writeHead(405, { 'content-type': 'application/json', 'allow': 'POST', 'connection': 'close' })
83
+ res.end(JSON.stringify({ error: 'method-not-allowed' }))
84
+ return Promise.resolve()
85
+ }
86
+ // Shutdown gate — parity with the objstore REST branch (a POST on an
87
+ // existing keep-alive socket after SIGTERM but before close() drains).
88
+ if (deps.isShuttingDown()) {
89
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
90
+ res.end(JSON.stringify({ error: 'shutting-down' }))
91
+ return Promise.resolve()
92
+ }
93
+ // Same-origin gate — a hostile cross-origin page would carry an Origin
94
+ // header (browser-set on fetch); same-origin XHR may omit it (allowed).
95
+ if (!deps.isOriginAllowed(req)) {
96
+ res.writeHead(403, { 'content-type': 'application/json' })
97
+ res.end(JSON.stringify({ error: 'origin-denied' }))
98
+ return Promise.resolve()
99
+ }
100
+ // Idle-body timeout — a slow-loris trickling the JSON body would otherwise
101
+ // hold the connection indefinitely.
102
+ req.setTimeout(deps.restPutIdleTimeoutMs, () => {
103
+ if (deps.debug) console.warn(`sync-save REST idle ${deps.restPutIdleTimeoutMs}ms → abort`)
104
+ try { req.destroy(new Error('idle-timeout')) } catch {}
105
+ })
106
+ // Outer catch is the unhandled-rejection guard for a throw OUTSIDE the
107
+ // handler's own try/catch — logs and terminates the response so the TCP
108
+ // socket doesn't dangle (same policy as the objstore handleRest wrapper).
109
+ return deps.handleSaveRest(req, res).catch((err) => {
110
+ console.warn('sync-save REST handler error:', errStack(err))
111
+ if (res.headersSent) { try { res.destroy() } catch {} }
112
+ else { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })) } catch {} }
113
+ })
114
+ }
115
+
116
+ export function createHttpServer(deps: HttpServerDeps): Server {
117
+ const { wss, restDeps, sseServer, serverInfo, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug } = deps
118
+ // Static-file plane (see ./static.ts). The directory is the
119
+ // `build.js build` output sibling to this file; the loader handles
120
+ // enumeration, pre-compression, and ETag derivation. Plugged in after
121
+ // the `/api/objstore/...` REST branch.
122
+ const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)))
123
+
124
+ const httpServer = createServer((req: HttpRequest, res: ServerResponse) => {
125
+ // Static mode probe: GET /api/config → this server's `server-info` as JSON.
126
+ // A client uses it to detect the protocol up front; the WS connect frame
127
+ // stays the source of truth and catches a later mode change. Public, no body.
128
+ if (typeof req.url === 'string' && req.url.split('?', 1)[0] === CONFIG_PATH) {
129
+ if (req.method !== 'GET') {
130
+ res.writeHead(405, { 'content-type': 'application/json', allow: 'GET' })
131
+ res.end(JSON.stringify({ error: 'method-not-allowed' }))
132
+ return
133
+ }
134
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
135
+ res.end(JSON.stringify(serverInfo))
136
+ return
137
+ }
138
+ // SSE+POST fallback plane. Same-origin gate first (Origin header
139
+ // is set by the browser on cross-origin EventSource + fetch, so
140
+ // a hostile origin would surface here just like it does on the
141
+ // WS upgrade and REST plane). The sseServer.handle() function
142
+ // returns true iff it matched and consumed the request; on false
143
+ // we fall through to the REST / static / 404 ladder below.
144
+ if (typeof req.url === 'string' && req.url.split('?', 1)[0] === SSE_OPEN_PATH) {
145
+ if (!isOriginAllowed(req)) {
146
+ res.writeHead(403, { 'content-type': 'application/json' })
147
+ res.end(JSON.stringify({ error: 'origin-denied' }))
148
+ return
149
+ }
150
+ // sseServer.handle owns its own shutdown / cap / 503 ladder.
151
+ // Each POST's response stays open as the session's downstream
152
+ // channel until the next POST takes over — the response is
153
+ // long-lived even though the request is one-shot — and the
154
+ // shutdown gate inside sseServer prevents accepting new POSTs
155
+ // once shuttingDown latches, so outstanding sessions drain via
156
+ // the lifecycle's sseSessions() close loop.
157
+ if (sseServer.handle(req, res)) return
158
+ }
159
+ // Triage-sync save REST plane (see SAVE_REST_PATH) — session-independent
160
+ // `POST /api/sync/save` so an SSE-mode save doesn't take over the
161
+ // event-stream. The dispatch helper owns the gate ladder; we `track` the
162
+ // returned in-flight promise so SIGTERM drains it (mirrors npm-advisories).
163
+ const saveP = dispatchSaveRest(req, res, { handleSaveRest, isOriginAllowed, isShuttingDown, restPutIdleTimeoutMs, debug })
164
+ if (saveP) { track(saveP); return }
165
+ // npm advisories proxy — same-origin + shutdown gates live in the
166
+ // helper so this dispatcher stays compact. `dispatchNpmAdvisories`
167
+ // returns the in-flight promise (or null when the route didn't
168
+ // match); the lifecycle's `track` awaits it so SIGTERM drains
169
+ // outstanding upstream fetches.
170
+ const npmP = dispatchNpmAdvisories(req, res, { isOriginAllowed, isShuttingDown, debug })
171
+ if (npmP) { track(npmP); return }
172
+ if (matchRoute(req.url) != null) {
173
+ // Shutdown gate. The WS plane gates new messages on `shuttingDown`;
174
+ // REST handlers go through a separate path and must mirror it.
175
+ // Without this, a REST PUT arriving on an existing keep-alive
176
+ // socket AFTER SIGTERM but BEFORE `httpServer.close()` finishes
177
+ // draining could land in `withCommitLock`, acquire a lease, and
178
+ // finish its `finally { release() }` AFTER the shutdown's
179
+ // `heldLeaseCount` snapshot — leaving an orphan lock row that pins
180
+ // the key until TTL expiry. The 503 + `shutting-down` reason tells
181
+ // the client to retry against a different replica. Transport
182
+ // audit + multi-replica shutdown ordering review.
183
+ if (isShuttingDown()) {
184
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
185
+ res.end(JSON.stringify({ error: 'shutting-down' }))
186
+ return
187
+ }
188
+ // Same-origin gate. Token IS the auth on REST, but a hostile origin
189
+ // that holds a valid token (e.g. via XSS that read a freshly-minted
190
+ // one) would PUT with its own Origin header — caught here.
191
+ // Same-origin XHR/fetch may omit Origin; that path is allowed (see
192
+ // `isOriginAllowed`). Transport audit `server-e2e/objstore/rest.ts:103`.
193
+ if (!isOriginAllowed(req)) {
194
+ res.writeHead(403, { 'content-type': 'application/json' })
195
+ res.end(JSON.stringify({ error: 'origin-denied' }))
196
+ return
197
+ }
198
+ // Idle-body timeout for the body-bearing REST methods — a slow-loris
199
+ // client trickling bytes holds the connection (and, for PUT, the
200
+ // staging fd + inFlightSids slot) indefinitely. PUT carries the raw
201
+ // blob within its declared Content-Length; POST carries the small
202
+ // fetch-mint JSON body. `req.setTimeout` fires on inactivity; we
203
+ // destroy the request, aborting the body pipeline.
204
+ // Transport audit `server-e2e/objstore/rest.ts:218`.
205
+ if (req.method === 'PUT' || req.method === 'POST') {
206
+ req.setTimeout(restPutIdleTimeoutMs, () => {
207
+ if (debug) console.warn(`REST ${req.method} idle ${restPutIdleTimeoutMs}ms → abort`)
208
+ try { req.destroy(new Error('idle-timeout')) } catch {}
209
+ })
210
+ }
211
+ // Track so SIGTERM mid-upload/download awaits handleRest before the
212
+ // DB close. The outer `.catch` is the unhandled-rejection guard for
213
+ // a stray throw OUTSIDE handleRest's internal try/catch blocks —
214
+ // Node 20+ defaults `--unhandled-rejections=throw`, which would
215
+ // crash the server. Logs and terminates the response so the TCP
216
+ // socket doesn't dangle.
217
+ const p = handleRest(restDeps, req, res).catch((err) => {
218
+ console.warn('REST handler error:', errStack(err))
219
+ if (res.headersSent) { try { res.destroy() } catch {} }
220
+ else { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })) } catch {} }
221
+ })
222
+ track(p)
223
+ return
224
+ }
225
+ if (handleStatic(req, res)) return
226
+ // `Connection: close` so an HTTP/1.1 keep-alive client doesn't hold
227
+ // the socket open expecting more requests on a server that only
228
+ // serves a small REST surface.
229
+ res.writeHead(404, { 'content-type': 'application/json', 'connection': 'close' })
230
+ res.end(NOT_FOUND_BODY)
231
+ })
232
+
233
+ httpServer.on('upgrade', (req, socket, head) => {
234
+ // RFC 6455: the WS upgrade IS an HTTP request; reject with a normal
235
+ // HTTP response so a misconfigured client sees the JSON body instead
236
+ // of ECONNRESET. `socket.end(body)` flushes before sending FIN.
237
+ if (!isUpgradePath(req.url)) {
238
+ socket.end(
239
+ 'HTTP/1.1 404 Not Found\r\n' +
240
+ 'Content-Type: application/json\r\n' +
241
+ `Content-Length: ${Buffer.byteLength(NOT_FOUND_BODY)}\r\n` +
242
+ 'Connection: close\r\n\r\n' +
243
+ NOT_FOUND_BODY,
244
+ )
245
+ return
246
+ }
247
+ // Same-origin gate. The WS upgrade IS a cross-origin-reachable
248
+ // surface in the browser; without this any tab can open a session to
249
+ // a 127.0.0.1 relay and probe handler shape / burn verify CPU.
250
+ // Browser WS handshakes always carry Origin (RFC 6455); non-browser
251
+ // clients omit it and are allowed (network is their trust boundary).
252
+ if (!isOriginAllowed(req)) {
253
+ socket.end(
254
+ 'HTTP/1.1 403 Forbidden\r\n' +
255
+ 'Content-Type: application/json\r\n' +
256
+ 'Content-Length: 26\r\n' +
257
+ 'Connection: close\r\n\r\n' +
258
+ '{"error":"origin-denied"}\n',
259
+ )
260
+ return
261
+ }
262
+ wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req) })
263
+ })
264
+
265
+ return httpServer
266
+ }
@@ -19,8 +19,18 @@ export type Hub = {
19
19
  // `except: null` is the REST-originated path — byte transfer landed
20
20
  // via HTTP, not a particular WS socket, so it hits every subscriber.
21
21
  // WS-originated broadcasts pass the originator so it doesn't see its
22
- // own message echoed back.
22
+ // own message echoed back. Local-only: callers that ALSO want a
23
+ // cross-instance fan-out are expected to publish to the pubsub bus
24
+ // alongside this call (server-e2e/pubsub.ts). The hub deliberately stays
25
+ // ignorant of the bus so its transport invariants (backpressure cap,
26
+ // stringify-once, terminate-on-overflow) are unchanged.
23
27
  broadcast(tag: string, msg: object, except: WebSocket | null): void
28
+ // Broadcasts an ALREADY-SERIALISED payload to every local subscriber
29
+ // for `tag`. Used by the pubsub bus receiver
30
+ // (server-e2e/bus-receiver.ts, wired up from server-e2e/index.ts) to relay
31
+ // a remote-instance event into this instance's fan-out. No `except`:
32
+ // the originator is on a different instance by construction.
33
+ broadcastLocalRaw(tag: string, payload: string): void
24
34
  }
25
35
 
26
36
  export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number; debug: boolean }): Hub {
@@ -61,7 +71,7 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
61
71
  // kernel yet — a slow / blackholed peer accumulates them unboundedly
62
72
  // during fan-out broadcasts. Drop above the cap and terminate the
63
73
  // socket so the heartbeat doesn't keep it alive on ping/pong while
64
- // every broadcast piles up. Transport audit `server/index.ts:225`.
74
+ // every broadcast piles up. Transport audit `server-e2e/index.ts:225`.
65
75
  if (socket.bufferedAmount > maxBufferedBytes) {
66
76
  if (debug) console.warn(`drop broadcast: socket buffered ${socket.bufferedAmount}B > cap`)
67
77
  try { socket.terminate() } catch {}
@@ -82,17 +92,26 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
82
92
  // catch-up with a multi-MB ciphertext × N subscribers, per-recipient
83
93
  // JSON.stringify would dominate CPU; this is the cheap win.
84
94
  const payload = JSON.stringify(msg)
85
- // Snapshot before iterating — `send`'s try/catch swallows
86
- // socket.send errors, but a socket transitioning to CLOSED
95
+ fanOut(set, payload, except)
96
+ }
97
+
98
+ function broadcastLocalRaw(tag: string, payload: string): void {
99
+ const set = subscribers.get(tag)
100
+ if (!set) return
101
+ fanOut(set, payload, null)
102
+ }
103
+
104
+ function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
105
+ // Snapshot before iterating — a socket transitioning to CLOSED
87
106
  // mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
88
- // which mutates `set` while we're walking it. The snapshot keeps a
89
- // future refactor (different collection, async send) from silently
90
- // skipping subscribers. Audit M4 round-3.
107
+ // mutating `set` while we walk it. The snapshot also keeps a future
108
+ // refactor (different collection, async send) from silently skipping
109
+ // subscribers. Audit M4 round-3.
91
110
  for (const s of [...set]) {
92
111
  if (s === except) continue
93
112
  sendRaw(s, payload)
94
113
  }
95
114
  }
96
115
 
97
- return { subscribe, unsubscribeAll, send, sendRaw, broadcast }
116
+ return { subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
98
117
  }