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

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.
@@ -0,0 +1,348 @@
1
+ // Same-origin proxy for the npm registry's bulk advisories endpoint
2
+ // (`POST https://registry.npmjs.org/-/npm/v1/security/advisories/bulk`).
3
+ // The UI's Advisories tab on stasis bundles fans out the bundle's
4
+ // (package → versions) map through this endpoint to enrich the view
5
+ // with upstream vulnerability data; calling the registry directly
6
+ // from the browser would cross-origin (the npm registry doesn't
7
+ // emit CORS headers for arbitrary callers), so the relay forwards
8
+ // the request body and answers with the upstream's JSON.
9
+ //
10
+ // The route is mounted at `/api/npm-advisories`. The same-origin
11
+ // gate runs inside `dispatchNpmAdvisories` below (server/http.ts
12
+ // calls the dispatcher directly without a pre-check), so any
13
+ // browser request from a foreign origin is rejected with 403 before
14
+ // we ever issue a fetch — non-browser callers that omit Origin are
15
+ // allowed (their trust boundary is the network, same posture as
16
+ // every other /api/* route).
17
+ //
18
+ // Request body is capped at `REQUEST_BODY_LIMIT` (the registry caps
19
+ // a single bulk lookup well under that); upstream response is
20
+ // buffered up to `RESPONSE_BODY_LIMIT` and then `JSON.parse`-asserted
21
+ // before we writeHead, so the client's `await res.json()` never
22
+ // chokes on a Cloudflare HTML 503 page or a captive-portal banner.
23
+ // A non-parseable upstream collapses to a 502 with a documented
24
+ // `{ error, upstreamStatus, upstreamContentType }` envelope.
25
+ //
26
+ // Outbound fetch carries an `AbortController` so a hung upstream
27
+ // (slow-loris response, TLS stall) tears down after
28
+ // `UPSTREAM_TIMEOUT_MS`, and a client that closes its connection
29
+ // mid-fetch propagates an abort through the same controller — so a
30
+ // stranded in-flight call doesn't block SIGTERM drain.
31
+
32
+ import type { IncomingMessage, ServerResponse } from 'node:http'
33
+ import { Buffer } from 'node:buffer'
34
+ import { errStack } from './util.ts'
35
+
36
+ type HasHeaders = { headers: IncomingMessage['headers'] }
37
+
38
+ export const NPM_ADVISORIES_PATH = '/api/npm-advisories'
39
+ const UPSTREAM_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'
40
+
41
+ // 1 MiB is generous: the bulk endpoint accepts `{ packageName:
42
+ // [versions] }` maps, and even a bundle with thousands of pinned
43
+ // versions serialises to well under this. Anything bigger is almost
44
+ // certainly malformed input or a probe.
45
+ const REQUEST_BODY_LIMIT = 1 * 1024 * 1024
46
+ // 4 MiB caps the upstream response. The advisories endpoint
47
+ // returns at most a few CVEs per package, so even a bundle with
48
+ // hundreds of affected packages comes in well under this — a
49
+ // runaway / hostile upstream gets cut off before we burn arbitrary
50
+ // memory buffering it.
51
+ const RESPONSE_BODY_LIMIT = 4 * 1024 * 1024
52
+ // Hard deadline on the upstream call. The bulk endpoint typically
53
+ // answers in well under a second; 30 s leaves plenty of headroom
54
+ // for a slow path but caps a hung TLS / slow-loris connection so
55
+ // a stranded fetch can't pin the inflight slot through SIGTERM
56
+ // drain. Triggered via AbortController.
57
+ const UPSTREAM_TIMEOUT_MS = 30_000
58
+
59
+ export type NpmProxyDeps = {
60
+ debug: boolean
61
+ }
62
+
63
+ export function matchNpmAdvisoriesRoute(url: string | undefined): boolean {
64
+ if (typeof url !== 'string') return false
65
+ return url.split('?', 1)[0] === NPM_ADVISORIES_PATH
66
+ }
67
+
68
+ export type DispatchDeps = {
69
+ isOriginAllowed: (req: HasHeaders) => boolean
70
+ isShuttingDown: () => boolean
71
+ debug: boolean
72
+ }
73
+
74
+ // Combined route-match + shutdown-gate + same-origin-gate + handler
75
+ // dispatch. Returns the in-flight Promise when the request matched
76
+ // (caller `track`s it so SIGTERM awaits drainage), or null when the
77
+ // route wasn't ours (caller falls through to the next branch).
78
+ export function dispatchNpmAdvisories(
79
+ req: IncomingMessage,
80
+ res: ServerResponse,
81
+ deps: DispatchDeps,
82
+ ): Promise<void> | null {
83
+ if (!matchNpmAdvisoriesRoute(req.url)) return null
84
+ if (deps.isShuttingDown()) {
85
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
86
+ res.end(JSON.stringify({ error: 'shutting-down' }))
87
+ return Promise.resolve()
88
+ }
89
+ if (!deps.isOriginAllowed(req)) {
90
+ res.writeHead(403, { 'content-type': 'application/json' })
91
+ res.end(JSON.stringify({ error: 'origin-denied' }))
92
+ return Promise.resolve()
93
+ }
94
+ return handleNpmAdvisories({ debug: deps.debug }, req, res).catch((err) => {
95
+ console.warn('npm-advisories handler error:', errStack(err))
96
+ if (res.headersSent) { try { res.destroy() } catch {} }
97
+ else {
98
+ try {
99
+ res.writeHead(500, { 'content-type': 'application/json' })
100
+ res.end(JSON.stringify({ error: 'internal' }))
101
+ } catch {}
102
+ }
103
+ })
104
+ }
105
+
106
+ // True iff the response is still in a writable state. `writableEnded`
107
+ // flips only after `res.end()` resolves; a client-disconnect tears
108
+ // the socket down asynchronously, leaving a brief window where
109
+ // `writableEnded` is still false but `destroyed` is true and any
110
+ // write throws ERR_STREAM_DESTROYED. Checking both keeps the
111
+ // after-disconnect log path quiet.
112
+ function canWrite(res: ServerResponse): boolean {
113
+ return !res.writableEnded && !res.destroyed
114
+ }
115
+
116
+ // Write a JSON error envelope, swallowing throws if the socket
117
+ // already closed mid-write. The outer try/catch is the belt to
118
+ // `canWrite`'s suspenders — a `destroyed` flip between the gate
119
+ // check and `res.end()` is rare but possible on a busy connection,
120
+ // and there's nothing useful to do besides drop the write.
121
+ function deny(res: ServerResponse, status: number, reason: string): void {
122
+ if (!canWrite(res)) return
123
+ try {
124
+ res.writeHead(status, { 'content-type': 'application/json' })
125
+ res.end(JSON.stringify({ error: reason }))
126
+ } catch {}
127
+ }
128
+
129
+ // Same shape as `deny` but for the richer multi-field envelopes
130
+ // (upstream-not-json / upstream-too-large) — these can't reuse
131
+ // `deny` because the body carries more than `{ error }`.
132
+ function writeJsonEnvelope(res: ServerResponse, status: number, body: object): void {
133
+ if (!canWrite(res)) return
134
+ try {
135
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
136
+ res.end(JSON.stringify(body))
137
+ } catch {}
138
+ }
139
+
140
+ // Drain the incoming request body into a single Buffer, rejecting
141
+ // once REQUEST_BODY_LIMIT is exceeded. We don't try to parse here —
142
+ // we forward the bytes verbatim so the registry sees exactly what
143
+ // the client sent (preserving key order / whitespace doesn't matter
144
+ // to the upstream, but parse-then-restringify is wasted work).
145
+ async function readRequestBody(req: IncomingMessage): Promise<Buffer | null> {
146
+ const chunks: Buffer[] = []
147
+ let received = 0
148
+ for await (const chunk of req) {
149
+ const buf = chunk as Buffer
150
+ received += buf.byteLength
151
+ if (received > REQUEST_BODY_LIMIT) return null
152
+ chunks.push(buf)
153
+ }
154
+ return Buffer.concat(chunks)
155
+ }
156
+
157
+ export async function handleNpmAdvisories(
158
+ deps: NpmProxyDeps,
159
+ req: IncomingMessage,
160
+ res: ServerResponse,
161
+ ): Promise<void> {
162
+ if (req.method !== 'POST') { deny(res, 405, 'method-not-allowed'); return }
163
+ // Single controller drives both the upstream deadline timer AND
164
+ // the client-disconnect propagation: a fetch hung past
165
+ // UPSTREAM_TIMEOUT_MS and a browser tab closed mid-fetch both end
166
+ // up aborting the same signal, which undici threads through into
167
+ // the body reader. Without this the inflight slot pinned by
168
+ // `track()` could outlive both a dead client and a wedged
169
+ // upstream, holding SIGTERM drain open.
170
+ //
171
+ // Install the listener + timer BEFORE the body read so a client
172
+ // disconnect during the upload window also flips the controller
173
+ // (and so a tail-end disconnect doesn't race the listener
174
+ // registration). The timer is overall budget — running across the
175
+ // body read + upstream call together is fine; the body read is
176
+ // bounded by REQUEST_BODY_LIMIT and finishes in ms.
177
+ //
178
+ // `res.on('close')` (NOT `req.on('close')`) is the right
179
+ // disconnect signal here: IncomingMessage's `close` fires when
180
+ // the REQUEST is fully drained — even on a clean POST that's
181
+ // followed by a healthy response — and would always trigger an
182
+ // abort the instant we finished reading the body. The
183
+ // ServerResponse's `close` event only fires when the underlying
184
+ // socket gets destroyed before `res.end()` completes, which is
185
+ // exactly the "browser tab closed mid-fetch" case we want to
186
+ // propagate. (Gate on `writableEnded` so a post-success close
187
+ // doesn't fire a no-op abort — itself a no-op on a settled
188
+ // controller, but skipping the log noise.)
189
+ const controller = new AbortController()
190
+ const timer = setTimeout(() => { try { controller.abort() } catch {} }, UPSTREAM_TIMEOUT_MS)
191
+ const onResClose = (): void => {
192
+ if (!res.writableEnded) controller.abort()
193
+ }
194
+ res.on('close', onResClose)
195
+ try {
196
+ let body: Buffer | null
197
+ try {
198
+ body = await readRequestBody(req)
199
+ } catch (err: unknown) {
200
+ // `for await (chunk of req)` throws on mid-upload connection
201
+ // drops (ECONNRESET / aborted). The client is already gone,
202
+ // so there's no useful response to write — and bubbling up
203
+ // to the dispatcher's catch would just emit a misleading
204
+ // "handler error" log. Same posture other handlers take for
205
+ // mid-body aborts: swallow + return.
206
+ if (deps.debug) console.warn('npm-advisories request body error:', errStack(err))
207
+ return
208
+ }
209
+ if (body === null) {
210
+ // Respond BEFORE destroying so the client sees the 413
211
+ // envelope (writeHead on a destroyed socket would silently
212
+ // drop). Then destroy: a bare `return` leaves the unread
213
+ // tail of the request body in the kernel buffer, which on
214
+ // an HTTP/1.1 keep-alive connection becomes the
215
+ // start-of-line for the NEXT request and corrupts request
216
+ // framing. Matches the sse-server.ts pattern
217
+ // (`{ error: 'too-large' }` then `req.destroy()`).
218
+ deny(res, 413, 'payload-too-large')
219
+ try { req.destroy() } catch {}
220
+ return
221
+ }
222
+ await handleNpmAdvisoriesInner(deps, body, res, controller.signal)
223
+ } finally {
224
+ clearTimeout(timer)
225
+ res.off('close', onResClose)
226
+ }
227
+ }
228
+
229
+ async function handleNpmAdvisoriesInner(
230
+ deps: NpmProxyDeps,
231
+ body: Buffer,
232
+ res: ServerResponse,
233
+ signal: AbortSignal,
234
+ ): Promise<void> {
235
+ let upstream: Response
236
+ try {
237
+ upstream = await fetch(UPSTREAM_URL, {
238
+ method: 'POST',
239
+ // Force JSON — the bulk endpoint requires it. Drop every
240
+ // client-supplied header to keep an upstream fingerprint from
241
+ // leaking through (cookies, auth, custom UA, ...). The
242
+ // registry's bulk endpoint doesn't need any of them for a
243
+ // public lookup.
244
+ headers: { 'content-type': 'application/json', 'accept': 'application/json' },
245
+ // Re-wrap as a plain Uint8Array — Buffer's underlying
246
+ // ArrayBufferLike type doesn't satisfy fetch's BodyInit
247
+ // narrowing (it can't statically rule out SharedArrayBuffer),
248
+ // but a copy through Uint8Array is zero-cost in practice and
249
+ // unambiguously typed.
250
+ body: new Uint8Array(body),
251
+ signal,
252
+ })
253
+ } catch (err: unknown) {
254
+ if (deps.debug) console.warn('npm-advisories upstream error:', errStack(err))
255
+ // Client already gone — `canWrite` (inside `deny`) gates the
256
+ // write so a destroyed / writableEnded socket doesn't trip
257
+ // ERR_STREAM_DESTROYED on the way out.
258
+ deny(res, 502, 'upstream-unreachable')
259
+ return
260
+ }
261
+ // Assert JSON on the upstream body. The Content-Type header is
262
+ // unreliable (Cloudflare in front of registry.npmjs.org strips it
263
+ // from some responses; a captive portal / WAF can declare HTML on
264
+ // a body that's actually JSON or vice-versa), so we don't lean on
265
+ // it — instead we buffer the body and parse. A successful
266
+ // JSON.parse is the strongest guarantee we can hand the UI's
267
+ // `await res.json()`. Buffering is bounded by
268
+ // `RESPONSE_BODY_LIMIT`; the advisories endpoint's payloads sit
269
+ // well under that.
270
+ const upstreamContentType = upstream.headers.get('content-type') ?? ''
271
+ let buffered: Buffer | null
272
+ try {
273
+ buffered = await readUpstreamBody(upstream)
274
+ } catch (err: unknown) {
275
+ if (deps.debug) console.warn('npm-advisories upstream body error:', errStack(err))
276
+ deny(res, 502, 'upstream-unreachable')
277
+ return
278
+ }
279
+ if (buffered === null) {
280
+ if (deps.debug) console.warn(`npm-advisories upstream too large: status=${upstream.status}`)
281
+ writeJsonEnvelope(res, 502, { error: 'upstream-too-large', upstreamStatus: upstream.status })
282
+ return
283
+ }
284
+ // Treat the body as UTF-8 — `JSON.parse` operates on a string and
285
+ // the registry's responses are always UTF-8 in practice. A
286
+ // non-UTF-8 byte sequence still decodes (with U+FFFD
287
+ // substitution); the subsequent JSON.parse fails and routes
288
+ // through the error branch.
289
+ const text = buffered.toString('utf8')
290
+ let parsed: unknown
291
+ try {
292
+ parsed = JSON.parse(text)
293
+ } catch {
294
+ if (deps.debug) console.warn(`npm-advisories upstream non-JSON: status=${upstream.status} ct=${upstreamContentType || '<none>'} bytes=${buffered.byteLength}`)
295
+ writeJsonEnvelope(res, 502, {
296
+ error: 'upstream-not-json',
297
+ upstreamStatus: upstream.status,
298
+ upstreamContentType: upstreamContentType || null,
299
+ })
300
+ return
301
+ }
302
+ if (!canWrite(res)) return
303
+ // Re-stringify rather than echoing `text` so the wire shape we
304
+ // emit is canonical (no upstream whitespace / BOM / trailing
305
+ // junk after the parsed value rides along), and so the client
306
+ // can rely on a single JSON document per response.
307
+ const out = JSON.stringify(parsed)
308
+ try {
309
+ res.writeHead(upstream.status, {
310
+ 'content-type': 'application/json',
311
+ 'cache-control': 'no-store',
312
+ 'content-length': Buffer.byteLength(out),
313
+ })
314
+ res.end(out)
315
+ } catch {}
316
+ }
317
+
318
+ // Buffer the upstream response body up to RESPONSE_BODY_LIMIT.
319
+ // Returns null if the cap is exceeded (caller maps to a 502
320
+ // `upstream-too-large`), or the cumulative Buffer otherwise. A
321
+ // transport error mid-read (e.g. AbortSignal fired by the deadline
322
+ // timer or by `req` close) throws — the caller catches it.
323
+ //
324
+ // `finally { reader.cancel() }` is load-bearing on the error and
325
+ // cap-exceeded paths: leaving the reader locked to the body holds
326
+ // the underlying undici TCP socket out of the connection pool until
327
+ // GC, and the cap-exceeded path explicitly needs to tear the
328
+ // transfer down so we don't keep buffering bytes we'll never use.
329
+ // On the clean-drain path (done:true), cancel() is a no-op.
330
+ async function readUpstreamBody(upstream: Response): Promise<Buffer | null> {
331
+ if (!upstream.body) return Buffer.alloc(0)
332
+ const reader = upstream.body.getReader()
333
+ const chunks: Uint8Array[] = []
334
+ let received = 0
335
+ try {
336
+ for (;;) {
337
+ const { done, value } = await reader.read()
338
+ if (done) break
339
+ if (!value) continue
340
+ received += value.byteLength
341
+ if (received > RESPONSE_BODY_LIMIT) return null
342
+ chunks.push(value)
343
+ }
344
+ return Buffer.concat(chunks)
345
+ } finally {
346
+ try { await reader.cancel() } catch {}
347
+ }
348
+ }
@@ -45,6 +45,12 @@ export type ObjstoreDeps = {
45
45
  secret: TokenSecret
46
46
  send: (socket: WebSocket, msg: object) => void
47
47
  broadcast: (tag: string, msg: object, except: WebSocket | null) => void
48
+ // Cross-instance pub/sub for objstore-deleted. Fired alongside the
49
+ // local `broadcast` after a successful delete so peers on OTHER
50
+ // server instances see the version drop in real time. Carries the
51
+ // full (tag, resourceTag, version) tuple inline — the workspace_object
52
+ // row is gone post-delete, so the bus payload IS the wire data.
53
+ publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
48
54
  getNonce: (socket: WebSocket) => string | undefined
49
55
  debug: boolean
50
56
  // Auth gate for the FIRST put-begin against a never-before-seen
@@ -188,6 +194,12 @@ async function handleDelete(deps: ObjstoreDeps, socket: WebSocket, msg: Objstore
188
194
  // `onDeleted` lets `session.onDeleted` fire for the session's
189
195
  // own deletes — pinned by `tests/objstore-client-races.test.js`.
190
196
  deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion }, null)
197
+ // Cross-instance fan-out (Neon mode). The workspace_object row is
198
+ // gone post-delete so the bus payload carries (tag, resourceTag,
199
+ // version) inline; the receiver builds its `objstore-deleted`
200
+ // broadcast directly from the bus envelope. SQLite mode publishes
201
+ // to a no-op.
202
+ deps.publishObjDeleted(tag, resourceTag, result.deletedVersion)
191
203
  if (deps.debug) console.log(`objstore delete → ${debugTag(tag)}/${resourceTag.slice(0, 8)}…`)
192
204
  }
193
205
 
@@ -20,6 +20,11 @@ export type ObjstoreInitDeps = {
20
20
  reapIntervalMs: number
21
21
  send: (socket: WebSocket, msg: object) => void
22
22
  broadcast: (tag: string, msg: object, except: WebSocket | null) => void
23
+ // Cross-instance pub/sub publishers. SQLite mode passes no-ops; Neon
24
+ // mode passes Postgres LISTEN/NOTIFY-backed implementations. See
25
+ // server/pubsub.ts for the bus design.
26
+ publishObjPut: (tag: string, resourceTag: string) => void
27
+ publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
23
28
  getNonce: (socket: WebSocket) => string | undefined
24
29
  debug: boolean
25
30
  // Auth gate for the FIRST objstore-put-begin against a workspace
@@ -56,12 +61,13 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
56
61
  const handlers = createObjstoreHandlers({
57
62
  handle, secret,
58
63
  send: deps.send, broadcast: deps.broadcast,
64
+ publishObjDeleted: deps.publishObjDeleted,
59
65
  getNonce: deps.getNonce, debug: deps.debug,
60
66
  ...(deps.authGate ? { authGate: deps.authGate } : {}),
61
67
  ...(deps.sendUnauthorized ? { sendUnauthorized: deps.sendUnauthorized } : {}),
62
68
  })
63
69
  const restDeps: ObjstoreRestDeps = {
64
- handle, secret, broadcast: deps.broadcast, debug: deps.debug,
70
+ handle, secret, broadcast: deps.broadcast, publishObjPut: deps.publishObjPut, debug: deps.debug,
65
71
  }
66
72
  // Re-entrancy guard for periodic + startup sweeps. Kicking the
67
73
  // startup sweep through the same `enqueueSweep` path means the
@@ -61,6 +61,13 @@ export type ObjstoreRestDeps = {
61
61
  handle: Handle
62
62
  secret: TokenSecret
63
63
  broadcast: (tag: string, msg: object, except: WebSocket | null) => void
64
+ // Cross-instance pub/sub for objstore-put. Fired alongside the local
65
+ // `broadcast` after a successful commitPut so peers on OTHER server
66
+ // instances see the new version in real time. Carries only
67
+ // `(tag, resourceTag)` — receivers re-fetch the live row from
68
+ // workspace_object for the full metadata (version, hash, length,
69
+ // signature). SQLite mode passes a no-op.
70
+ publishObjPut: (tag: string, resourceTag: string) => void
64
71
  debug: boolean
65
72
  }
66
73
 
@@ -286,6 +293,17 @@ async function handleRestPutBody(
286
293
  workspaceTag: route.tag,
287
294
  ...objectMetaWire(row),
288
295
  }, null)
296
+ // Cross-instance fan-out (Neon mode). The bus payload carries only
297
+ // (tag, resourceTag); peers on other instances re-fetch the live
298
+ // row from workspace_object to compose their local broadcast. The
299
+ // committed row is durable by here (commitPut's version-CAS already
300
+ // landed), so the receiver typically sees either THIS version or a
301
+ // STRICTLY newer one (also a valid broadcast — clients are
302
+ // idempotent on (resourceTag, version)). The receiver is allowed
303
+ // to find no live row at all if a subsequent delete races the
304
+ // notification; bus-receiver.ts drops that case silently. SQLite
305
+ // mode publishes to a no-op.
306
+ deps.publishObjPut(route.tag, route.resourceTag)
289
307
  if (deps.debug) console.log(`objstore put → ${route.tag.slice(0, 12)}…/${route.resourceTag.slice(0, 8)}… v${row.version}`)
290
308
  } finally {
291
309
  // Release the slot on every exit (success, commit failure, pipeline