@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,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-e2e/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
+ }
@@ -48,13 +48,13 @@ async function fsOpenLiveReader(dir: string, tag: string, contentHash: string):
48
48
  const path = liveFilePath(dir, tag, contentHash)
49
49
  let fh
50
50
  try { fh = await open(path, 'r') } catch (err: unknown) {
51
- if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable' }
51
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable', detail: 'fs-enoent' }
52
52
  throw err
53
53
  }
54
54
  let size: number
55
55
  try { size = (await fh.stat()).size } catch {
56
56
  await fh.close().catch(() => {})
57
- return { ok: false, reason: 'unavailable' }
57
+ return { ok: false, reason: 'unavailable', detail: 'fs-stat-failed' }
58
58
  }
59
59
  const stream = fh.createReadStream()
60
60
  let closed = false
@@ -90,12 +90,10 @@ export function openFsBlobBackend(dir: string): BlobBackend {
90
90
  // closed by Node's stream machinery on 'finish'.
91
91
  finalize: async () => {},
92
92
  // `destroy(err)` synchronously starts tearing the stream
93
- // down; the WriteStream emits 'close' on the next tick.
94
- // For the FS backend there's no remote upload to wait for,
95
- // so we resolve immediately — the REST layer awaits but
96
- // doesn't block on anything real here. eslint-disable for
97
- // the no-await-in-async — the function signature is
98
- // dictated by the BlobBackend contract.
93
+ // down; the WriteStream emits 'close' on the next tick. No
94
+ // remote upload to wait for on FS, so we resolve immediately
95
+ // — the REST layer awaits but doesn't block on anything real.
96
+ // Async signature is dictated by the BlobBackend contract.
99
97
  // eslint-disable-next-line require-await
100
98
  abort: async (err) => { writable.destroy(err as Error) },
101
99
  }
@@ -1,5 +1,5 @@
1
1
  // Vercel Blob Private Storage BlobBackend. Paired with the Neon DB
2
- // plane in `server/index.ts` when `BLOB_READ_WRITE_TOKEN` is set;
2
+ // plane in `server-e2e/index.ts` when `BLOB_READ_WRITE_TOKEN` is set;
3
3
  // the combination is the supported multi-replica deployment shape
4
4
  // (Neon for metadata, Vercel Blob for bytes — both serverless, both
5
5
  // HTTP-backed, no shared filesystem required).
@@ -119,21 +119,19 @@ type VercelBlobSdk = {
119
119
 
120
120
  // Recognise "blob is gone" errors uniformly across read/write/delete
121
121
  // paths so callers can treat them as success (delete) or
122
- // not-found (read). The SDK exposes BlobNotFoundError as a class
123
- // with `.name === 'BlobNotFoundError'`; checking the name string
124
- // avoids importing the class at the top level (which would force
125
- // the optional peer dep to resolve).
122
+ // not-found (read). The SDK exposes BlobNotFoundError as a class with
123
+ // `.name === 'BlobNotFoundError'`; checking the name string avoids
124
+ // importing the class at the top level (which would force the optional
125
+ // peer dep to resolve).
126
126
  //
127
- // Class-name check ONLY. The SDK's internal mapper translates every
128
- // API `not_found` code into BlobNotFoundError-by-name; a bare-404
129
- // transport leak doesn't reach here. A prior version of this
130
- // function had a `/does not exist|\b404\b/` fallback that
131
- // DANGEROUSLY matched BlobStoreNotFoundError's message "This store
132
- // does not exist." — a config fault (revoked token, deleted store)
133
- // would silently surface as every-blob-missing across reads and
134
- // unlinks, masking the fatal misconfiguration. The tight name check
135
- // lets BlobStoreNotFoundError / other classes propagate as real
136
- // exceptions.
127
+ // Class-name check ONLY — the SDK's internal mapper turns every API
128
+ // `not_found` into BlobNotFoundError-by-name, so a bare-404 transport
129
+ // leak doesn't reach here. A broader `/does not exist|\b404\b/` match
130
+ // is DANGEROUS: it also matches BlobStoreNotFoundError's "This store
131
+ // does not exist.", so a config fault (revoked token, deleted store)
132
+ // would silently surface as every-blob-missing across reads/unlinks,
133
+ // masking the fatal misconfiguration. The tight name check lets
134
+ // BlobStoreNotFoundError / other classes propagate as real exceptions.
137
135
  function isNotFound(err: unknown): boolean {
138
136
  if (err == null || typeof err !== 'object') return false
139
137
  const name = (err as { name?: unknown }).name
@@ -242,20 +240,17 @@ function buildOpenStagingWriter(sdk: VercelBlobSdk, token: string): BlobBackend[
242
240
  abortSignal: ac.signal,
243
241
  })
244
242
  // Defuse a possible unhandled-rejection if abort() is called
245
- // BEFORE finalize() (the REST layer's error path). Attach a
246
- // detached `.catch` on the original promise so an early
247
- // rejection has a handler; finalize() awaits `putPromise`
248
- // directly, which still re-throws the original rejection
249
- // (the .catch returns a separate chain that doesn't replace
250
- // putPromise's state).
243
+ // BEFORE finalize() (the REST error path). The detached `.catch`
244
+ // gives an early rejection a handler; finalize() still awaits
245
+ // `putPromise` directly and re-throws the original rejection (the
246
+ // .catch is a separate chain, not a replacement of putPromise).
251
247
  putPromise.catch(() => {})
252
248
  return {
253
249
  writable: pt,
254
250
  // Await the upload's completion. After pipeline(req, counter,
255
- // pt) resolves, pt has emitted 'end' on the read side and
256
- // `put` is finalising the last multipart part. Awaiting here
257
- // gives us the same "bytes durable" guarantee that
258
- // pipeline-to-WriteStream gives the FS backend.
251
+ // pt) resolves, pt has emitted 'end' and `put` is finalising the
252
+ // last multipart part. Awaiting here gives the same "bytes
253
+ // durable" guarantee pipeline-to-WriteStream gives the FS backend.
259
254
  finalize: async () => { await putPromise },
260
255
  // Await the SDK's put-promise settlement (rejected via the
261
256
  // AbortController). Without this await, a slow upload that
@@ -315,11 +310,11 @@ function buildPromoteStagingToLive(sdk: VercelBlobSdk, token: string): BlobBacke
315
310
  //
316
311
  // `allowOverwrite: true` because the content-addressed live
317
312
  // pathname `${tag}/${contentHash}.bin` can be (re)written by a
318
- // retried or racing promote of the same blob. The destination
319
- // bytes are identical by construction (the path IS the hash), so
320
- // the overwrite is idempotent — never a clobber of DIFFERENT
321
- // bytes. Without the flag, the SDK sends `x-allow-overwrite: 0`
322
- // and Vercel rejects any such re-promote with BlobAccessError.
313
+ // retried or racing promote of the same blob. Destination bytes
314
+ // are identical by construction (the path IS the hash), so the
315
+ // overwrite is idempotent — never a clobber of DIFFERENT bytes.
316
+ // Without the flag the SDK sends `x-allow-overwrite: 0` and
317
+ // Vercel rejects the re-promote with BlobAccessError.
323
318
  await sdk.copy(from, to, {
324
319
  access: 'private',
325
320
  allowOverwrite: true,
@@ -356,17 +351,55 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
356
351
  // origin truth. Origin fetch is the right default for a store
357
352
  // where freshness > latency.
358
353
  try { res = await sdk.get(path, { access: 'private', useCache: false, token }) } catch (err) {
359
- if (isNotFound(err)) return { ok: false, reason: 'not-found' }
354
+ // A missing blob HERE is never "the resource doesn't exist" — the
355
+ // REST layer (rest.ts openLiveSnapshot) already confirmed a live row
356
+ // whose (version, incarnation) matches the GET token before calling
357
+ // us. So BlobNotFoundError means the bytes for a still-live row are
358
+ // momentarily gone: the reaper GC'd a hash a racing version-bump just
359
+ // unreferenced, or Vercel's read-after-write / edge propagation hasn't
360
+ // caught up to a freshly-promoted private blob. That is the documented
361
+ // `unavailable` (HTTP 503) transient — reconciled by reaper /
362
+ // propagation, retried by the client — NOT a 404. Returning
363
+ // `not-found` would emit a 404 the FS backend never emits for the
364
+ // same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
365
+ // the client the resource is gone for good when it should refetch.
366
+ // See server-e2e/README.md's GET status table.
367
+ if (isNotFound(err)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
360
368
  throw err
361
369
  }
362
- if (res == null) return { ok: false, reason: 'not-found' }
370
+ // SDK returned null (no blob) — same "bytes missing for a live row"
371
+ // transient as the BlobNotFoundError branch above → `unavailable`, not
372
+ // `not-found`.
373
+ if (res == null) return { ok: false, reason: 'unavailable', detail: 'vercel-get-null' }
363
374
  // statusCode 304 doesn't reach here in practice — the REST
364
375
  // GET layer doesn't pass If-None-Match — but a future call
365
376
  // site could. Treat as unavailable rather than streaming a
366
377
  // null body.
367
- if (res.statusCode !== 200 || res.stream == null || res.blob.size == null) {
368
- return { ok: false, reason: 'unavailable' }
378
+ if (res.statusCode !== 200 || res.stream == null) {
379
+ return { ok: false, reason: 'unavailable', detail: `vercel-get-status-${res.statusCode}` }
369
380
  }
381
+ // `@vercel/blob@2.x`'s streaming `get()` for private blobs returns
382
+ // the body but does NOT populate `blob.size` nor pass a
383
+ // `content-length` header (verified empirically: get.size=0,
384
+ // content-length-hdr=null, while head.size reports the true count).
385
+ // The REST layer needs a size to set `content-length` and for the
386
+ // integrity check against the DB row, so on 0/null we fall back to
387
+ // head(). Two round-trips per private read until the SDK is fixed —
388
+ // small price vs. 503ing every read.
389
+ let size: number | null | undefined = res.blob?.size
390
+ if (size == null || size === 0) {
391
+ try {
392
+ const h = await sdk.head(path, { token })
393
+ size = (h as { size?: number })?.size
394
+ } catch (headErr) {
395
+ // Blob vanished between get() and the head() size fallback (a
396
+ // racing reaper GC) — still the "live row present, bytes gone"
397
+ // transient, so `unavailable` (503), matching the get() path above.
398
+ if (isNotFound(headErr)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
399
+ throw headErr
400
+ }
401
+ }
402
+ if (size == null) return { ok: false, reason: 'unavailable', detail: 'vercel-no-size' }
370
403
  // SDK returns a web ReadableStream<Uint8Array>; the REST layer
371
404
  // expects a Node Readable for pipeline(). Convert via
372
405
  // Readable.fromWeb — built-in and zero-copy where possible.
@@ -376,7 +409,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
376
409
  ok: true,
377
410
  reader: {
378
411
  stream: nodeStream,
379
- size: res.blob.size,
412
+ size,
380
413
  // eslint-disable-next-line require-await
381
414
  close: async () => {
382
415
  // Destroying the Node wrapper also cancels the underlying
@@ -460,7 +493,7 @@ export type VercelBlobBackendOptions = {
460
493
  // Vercel Blob R/W token, typically from BLOB_READ_WRITE_TOKEN.
461
494
  // The SDK also reads it from process.env, but passing it
462
495
  // explicitly here keeps the env-var → boot config path single-
463
- // sourced through server/index.ts (matches the Neon DATABASE_URL
496
+ // sourced through server-e2e/index.ts (matches the Neon DATABASE_URL
464
497
  // handling — env-read at boot, threaded as a parameter).
465
498
  token: string
466
499
  // Test seam: inject a stub of the @vercel/blob module to avoid
@@ -11,7 +11,7 @@
11
11
  // the Neon DB plane for multi-replica
12
12
  // deployments)
13
13
  //
14
- // Selected at boot in `server/index.ts` and passed to `openObjstore`
14
+ // Selected at boot in `server-e2e/index.ts` and passed to `openObjstore`
15
15
  // / `openNeonObjstore`. The DB-plane code in ./store.ts, ./rest.ts,
16
16
  // and ./reaper.ts goes through `handle.blob.*` and is backend-
17
17
  // agnostic — no `if (vercel) … else` branching in consumers.
@@ -83,13 +83,27 @@ export type LiveReader = {
83
83
  close(): Promise<void>
84
84
  }
85
85
 
86
- // `not-found` maps to HTTP 404 (the live blob is gone or never
87
- // existed); `unavailable` maps to HTTP 503 (transient backend issue
88
- // the reaper will eventually sort out). The REST layer uses this
89
- // discrimination to set the right status code.
86
+ // A missing blob is always `unavailable` (→ HTTP 503), never a 404. The
87
+ // byte plane has NO view of the metadata row, so it can't decide whether
88
+ // a resource "doesn't exist" — only whether specific bytes are present
89
+ // right now. The authoritative "this resource/version is gone" 404 is the
90
+ // REST layer's call, made from the live row BEFORE it opens a reader
91
+ // (rest.ts openLiveSnapshot). By the time `openLiveReader` runs the row is
92
+ // already confirmed, so an absent blob means a transient bytes/metadata
93
+ // desync the reaper (or store propagation) reconciles — exactly the
94
+ // `unavailable`/503 contract, which the client retries. Both backends MUST
95
+ // map a missing blob to `unavailable` (FS: ENOENT; Vercel: BlobNotFoundError
96
+ // / null get()). No 404-mapping variant exists here so that bug can't recur.
97
+ //
98
+ // `detail` is a short, NON-SENSITIVE machine tag for the specific cause
99
+ // (e.g. 'vercel-get-not-found', 'fs-enoent', 'vercel-no-size'). Every
100
+ // byte-side failure collapses to the same 503 on the wire, so a permanent
101
+ // loss (reaper GC'd the bytes) and a transient read fault are otherwise
102
+ // indistinguishable — the REST layer logs `detail` so an operator can tell
103
+ // them apart. Purely diagnostic; the REST status is unchanged.
90
104
  export type OpenLiveResult =
91
105
  | { ok: true; reader: LiveReader }
92
- | { ok: false; reason: 'not-found' | 'unavailable' }
106
+ | { ok: false; reason: 'unavailable'; detail?: string }
93
107
 
94
108
  export type BlobBackend = {
95
109
  // Per-workspace setup. FS creates the on-disk staging directory;
@@ -128,9 +142,10 @@ export type BlobBackend = {
128
142
  promoteStagingToLive(tag: string, stagingId: string, contentHash: string): Promise<boolean>
129
143
 
130
144
  // Open a streaming reader for the content-addressed live blob.
131
- // `not-found` lets the REST layer return 404; `unavailable` returns
132
- // 503 for a transient state (file/blob missing while the row still
133
- // exists — reaper will reconcile on the next sweep).
145
+ // Called only after the REST layer has confirmed the live row, so a
146
+ // missing blob is the transient "row present, bytes gone" state →
147
+ // `unavailable` (HTTP 503), which the reaper reconciles and the client
148
+ // retries. Never a 404 from here — see OpenLiveResult above.
134
149
  openLiveReader(tag: string, contentHash: string): Promise<OpenLiveResult>
135
150
 
136
151
  // Idempotent deletes. Backends MUST tolerate "already gone" as