@preventive/triage 1.0.0-alpha.1 → 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 (56) 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 +16 -13
  8. package/out/graph.js +5 -4
  9. package/out/index.html +4 -15
  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 -52
  14. package/package.json +70 -54
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +5 -1
  17. package/{server → server-e2e}/bus-receiver.ts +8 -8
  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 +31 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +96 -135
  24. package/{server → server-e2e}/http.ts +97 -9
  25. package/{server → server-e2e}/hub.ts +7 -8
  26. package/{server → server-e2e}/index.ts +67 -43
  27. package/{server → server-e2e}/lifecycle.ts +14 -5
  28. package/{server → server-e2e}/npm-proxy.ts +1 -1
  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 +13 -15
  34. package/{server → server-e2e}/objstore/init.ts +47 -13
  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 +110 -93
  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 → server-e2e}/pubsub.ts +21 -31
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/{server → server-e2e}/sse-server.ts +105 -73
  47. package/{server → server-e2e}/sse-session.ts +30 -16
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/{server → server-e2e}/sync-handlers.ts +172 -117
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/{server → server-e2e}/ws-server.ts +29 -23
  52. package/strip-types-loader.js +94 -0
  53. /package/{server → server-e2e}/config.example.json +0 -0
  54. /package/{server → server-e2e}/neon-driver.ts +0 -0
  55. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  56. /package/{server → server-e2e}/validation.ts +0 -0
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // DeepView triage-sync relay server. WebSocket front-end, SQLite
3
3
  // backing store. Implements the protocol described in
4
- // `client/triage-sync.js` (and `server/sign.ts` for the canonical
4
+ // `client/triage-sync.js` (and `server-e2e/sign.ts` for the canonical
5
5
  // signature payloads):
6
6
  //
7
7
  // server → client challenge { nonce } — emitted on every
@@ -75,13 +75,14 @@
75
75
  import { type WebSocket, WebSocketServer } from 'ws'
76
76
  import { errMsg, errStack } from './util.ts'
77
77
  import type { PeerRegistry } from './peer.ts'
78
- import { LOOPBACK_HOSTS, createOriginGate } from './origin.ts'
78
+ import { LOOPBACK_HOSTS, createOriginGate } from '../server-common/origin.ts'
79
79
  import { createHub } from './hub.ts'
80
80
  import { createAuth } from './auth.ts'
81
81
  import { createSyncHandlers } from './sync-handlers.ts'
82
82
  import { WS_UPGRADE_PATH, createHttpServer } from './http.ts'
83
83
  import { installWsServer } from './ws-server.ts'
84
84
  import { SSE_OPEN_PATH, installSseServer } from './sse-server.ts'
85
+ import type { ServerInfo } from '../common/server-info.ts'
85
86
  import { createLifecycle } from './lifecycle.ts'
86
87
  import { loadConfig } from './config.ts'
87
88
  import { type Handle, openDb } from './db.ts'
@@ -97,20 +98,20 @@ import {
97
98
  import { createBusReceiver } from './bus-receiver.ts'
98
99
 
99
100
  // All external inputs (env vars + optional config.json) are parsed
100
- // and validated in ./config.ts. Destructure into the existing
101
- // uppercase names so the rest of this module reads unchanged.
101
+ // and validated in ./config.ts; destructure into the uppercase names
102
+ // the rest of this module uses.
102
103
  const config = loadConfig()
103
104
  const {
104
105
  port: PORT, host: HOST, dbPath: DB_PATH, objstoreDir: OBJSTORE_DIR,
105
- reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
106
+ reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS, reapDisabled: OBJSTORE_REAP_DISABLED,
106
107
  maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET, debug: DEBUG,
107
108
  neonUrl: NEON_URL, blobToken: BLOB_TOKEN, tokenSecret: TOKEN_SECRET,
108
109
  password: CONFIG_PASSWORD, trustProxyEnv: TRUST_PROXY_ENV,
109
110
  } = config
110
111
 
111
112
  // Same-origin gate for the WS upgrade and REST data plane (see
112
- // ./origin.ts). `TRUST_PROXY_ENV` (from config) also feeds the
113
- // boot-time misconfiguration fail-fast below.
113
+ // ./origin.ts). `TRUST_PROXY_ENV` also feeds the boot-time
114
+ // misconfiguration fail-fast below.
114
115
  const { trustProxy: TRUST_PROXY, isOriginAllowed } = createOriginGate(HOST, TRUST_PROXY_ENV)
115
116
 
116
117
  // Per-socket buffered-bytes cap. `socket.send` returns synchronously
@@ -120,28 +121,27 @@ const { trustProxy: TRUST_PROXY, isOriginAllowed } = createOriginGate(HOST, TRUS
120
121
  // MB of fan-out broadcasts in this buffer with no backpressure on
121
122
  // the broadcast loop. Drop the message when the buffer crosses the
122
123
  // cap; the heartbeat will eventually close a peer that never
123
- // drains. Transport audit `server/index.ts:225`.
124
+ // drains. Transport audit `server-e2e/index.ts:225`.
124
125
  const MAX_BUFFERED_BYTES = 16 * 1024 * 1024
125
126
  // Per-socket in-flight async-handler cap (MAX_INFLIGHT_PER_SOCKET,
126
127
  // env-validated in config). Each inbound text frame spawns a
127
128
  // `track(handler)` IIFE; an authorised peer firing valid frames could
128
129
  // otherwise grow the set without bound, stretching SIGTERM drain time.
129
130
  // Saves dropped at the cap surface as a typed `busy` NACK. Transport
130
- // audit `server/index.ts:590`.
131
+ // audit `server-e2e/index.ts:590`.
131
132
 
132
133
  // Per-connection state registry. One `Peer` per accepted socket holds
133
134
  // the challenge nonce, auth flag, heartbeat liveness, in-flight count,
134
- // and subscribed tags (see ./peer.ts) — replacing what were five
135
- // parallel per-socket WeakMaps. The connection handler holds the Peer
136
- // in a closure for the hot paths; cross-function call sites resolve it
137
- // via `peers.get(socket)`.
135
+ // and subscribed tags (see ./peer.ts). The connection handler holds
136
+ // the Peer in a closure for the hot paths; cross-function call sites
137
+ // resolve it via `peers.get(socket)`.
138
138
  const peers: PeerRegistry = new WeakMap()
139
139
 
140
140
  // REST PUT idle-body timeout. A slow-loris client trickling bytes
141
141
  // within the declared Content-Length holds the staging fd and an
142
142
  // inFlightSids slot until the global staging TTL reaps it. Aborting
143
143
  // the per-chunk-idle period closes that window. Transport audit
144
- // `server/objstore/rest.ts:218`.
144
+ // `server-e2e/objstore/rest.ts:218`.
145
145
  const REST_PUT_IDLE_TIMEOUT_MS = 30_000
146
146
 
147
147
  // Server-driven WS heartbeat. Every `HEARTBEAT_INTERVAL_MS` we walk
@@ -163,8 +163,8 @@ const REST_PUT_IDLE_TIMEOUT_MS = 30_000
163
163
  const HEARTBEAT_INTERVAL_MS = 30_000
164
164
 
165
165
  // Backend selection. Both planes (workspace_revision DB + the
166
- // v1.objstore byte store) are picked from config at boot. Two supported
167
- // pairings:
166
+ // v1.objstore byte store) are picked from config at boot. Two
167
+ // supported pairings:
168
168
  // 1. DATABASE_URL set → Neon (workspace_revision + objstore
169
169
  // tables) + Vercel Blob Private Storage (bytes). Requires
170
170
  // BLOB_READ_WRITE_TOKEN — fail fast at boot if missing, since
@@ -174,11 +174,10 @@ const HEARTBEAT_INTERVAL_MS = 30_000
174
174
  // process; the only pairing the SQLite plane supports.
175
175
  // The Neon / Vercel files import their peer deps lazily inside the
176
176
  // open functions, so static imports here are safe even on a SQLite-
177
- // only install where the optional peer deps aren't present. Branch
178
- // out explicitly (rather than via a ternary) so the SQLite path
179
- // keeps its `SqliteHandle` narrowing — `sqliteHandle.db` is typed
180
- // as a non-optional `DatabaseSync` and `openObjstore` accepts it
181
- // without a non-null assertion.
177
+ // only install where the optional peer deps aren't present. Explicit
178
+ // branch (not a ternary) so the SQLite path keeps its `SqliteHandle`
179
+ // narrowing — `sqliteHandle.db` is a non-optional `DatabaseSync` that
180
+ // `openObjstore` accepts without a non-null assertion.
182
181
  let handle: Handle
183
182
  let objstoreHandle: ObjstoreHandle
184
183
  let objstoreBanner: string
@@ -238,8 +237,7 @@ async function workspaceExists(tag: string): Promise<boolean> {
238
237
  }
239
238
 
240
239
  // WS fan-out hub: subscriber registry + backpressure-aware send /
241
- // broadcast (see ./hub.ts). Destructure into the existing names so the
242
- // handlers / dispatcher / objstore wiring below read unchanged.
240
+ // broadcast (see ./hub.ts).
243
241
  const hub = createHub({ peers, maxBufferedBytes: MAX_BUFFERED_BYTES, debug: DEBUG })
244
242
  const { send, broadcast, subscribe, unsubscribeAll, broadcastLocalRaw } = hub
245
243
 
@@ -276,9 +274,9 @@ if (NEON_URL) {
276
274
  }
277
275
 
278
276
  // Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
279
- // handshake. Destructure into the existing names for the wiring below.
277
+ // handshake.
280
278
  const auth = createAuth({ peers, password: CONFIG_PASSWORD, send, debug: DEBUG })
281
- const { requiresAuth, handleAuthenticate, sendUnauthorized } = auth
279
+ const { requiresAuth, passwordConfigured, handleAuthenticate, sendUnauthorized } = auth
282
280
 
283
281
  // Triage-sync protocol handlers (see ./sync-handlers.ts). `getNonce`
284
282
  // resolves a socket's challenge nonce and is shared with the objstore
@@ -295,9 +293,9 @@ const publishObjDeleted = (tag: string, resourceTag: string, version: number): v
295
293
  pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version })
296
294
  }
297
295
 
298
- const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
296
+ const { handleSave, handleSaveRest, handleSubscribe, sendSaveError } = createSyncHandlers({
299
297
  handle, send, broadcast, publishRevision, subscribe, getNonce,
300
- requiresAuth, sendUnauthorized, workspaceExists,
298
+ requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists,
301
299
  // Folds the objstore inventory into the `workspace-subscribed` ack.
302
300
  // The objstore store keeps its own richer `Handle`, so we wire the
303
301
  // query here where both handles exist rather than coupling
@@ -308,6 +306,7 @@ const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
308
306
 
309
307
  const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper } = initObjstore({
310
308
  handle: objstoreHandle, reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
309
+ reapDisabled: OBJSTORE_REAP_DISABLED,
311
310
  send, broadcast, publishObjPut, publishObjDeleted,
312
311
  getNonce, debug: DEBUG,
313
312
  // Auth gate for the FIRST objstore-put-begin against a workspace
@@ -318,10 +317,15 @@ const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper
318
317
  // `unauthorized` frame and bails on `true`.
319
318
  authGate: async (socket, tag) => requiresAuth(socket) && !await workspaceExists(tag),
320
319
  sendUnauthorized,
320
+ // Socket-less analog of `authGate` for the REST put-begin mint: a REST
321
+ // request can never be operator-authorized, so the gate collapses to
322
+ // "password configured AND workspace new". A deny routes the client to
323
+ // its in-band WS put-begin fallback.
324
+ restPutGate: async (tag) => passwordConfigured && !await workspaceExists(tag),
321
325
  // `tokenSecret` is set only when OBJSTORE_TOKEN_SECRET was
322
326
  // provided in env (see TOKEN_SECRET resolution above). Omitted
323
- // → initObjstore mints a fresh per-process secret (the pre-PR
324
- // behaviour, fine for single-replica).
327
+ // → initObjstore mints a fresh per-process secret (fine for
328
+ // single-replica).
325
329
  ...(TOKEN_SECRET ? { tokenSecret: TOKEN_SECRET } : {}),
326
330
  })
327
331
 
@@ -338,12 +342,17 @@ const wss = new WebSocketServer({ noServer: true, maxPayload: 4 * 1024 * 1024 })
338
342
  // every server object exists.
339
343
  const { track, isShuttingDown, install: installLifecycle } = createLifecycle()
340
344
 
345
+ // The sync protocol this build advertises — emitted as a `server-info` frame
346
+ // right after the challenge on every connection. This is the e2e boot, so it
347
+ // always advertises e2e.
348
+ const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null }
349
+
341
350
  // Shared per-connection dispatch surface. Both the WS plane
342
351
  // (installWsServer below) and the SSE+POST fallback (installSseServer
343
352
  // below) drive `setupPeerConnection` with this; one Peer per accepted
344
353
  // connection, one message-handler tree.
345
354
  const peerConnectionDeps = {
346
- peers, send, unsubscribeAll,
355
+ peers, serverInfo: SERVER_INFO, send, unsubscribeAll,
347
356
  handleSave, handleSubscribe, handleAuthenticate, sendSaveError, objstore,
348
357
  track, isShuttingDown,
349
358
  maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET,
@@ -364,10 +373,6 @@ const sseServer = installSseServer({
364
373
  // Mirror the WS `maxPayload` so the SSE plane can't accept frames
365
374
  // the WS plane would reject.
366
375
  maxBodyBytes: 4 * 1024 * 1024,
367
- // 90s idle ceiling. The client's JSON ping/pong is 15s; this is ~6
368
- // missed pings before we close — well past any transient network
369
- // hiccup, much shorter than the kernel's hours-long TCP keepalive.
370
- sessionIdleMs: 90_000,
371
376
  debug: DEBUG,
372
377
  })
373
378
 
@@ -377,8 +382,8 @@ const sseServer = installSseServer({
377
382
  // drain uses `track`. The WS connection handler is wired on `wss`
378
383
  // below.
379
384
  const httpServer = createHttpServer({
380
- wss, restDeps: objstoreRestDeps, sseServer, isOriginAllowed,
381
- isShuttingDown, track,
385
+ wss, restDeps: objstoreRestDeps, sseServer, serverInfo: SERVER_INFO, isOriginAllowed,
386
+ isShuttingDown, track, handleSaveRest,
382
387
  restPutIdleTimeoutMs: REST_PUT_IDLE_TIMEOUT_MS, debug: DEBUG,
383
388
  })
384
389
 
@@ -447,16 +452,35 @@ const closeDb = async (): Promise<void> => {
447
452
  installLifecycle({
448
453
  httpServer, wss, heartbeatTimer, stopReaper,
449
454
  sseSessions: sseServer.sessions,
455
+ sseKeepaliveTimer: sseServer.keepaliveTimer,
450
456
  closeDb,
451
457
  })
452
458
 
453
459
  // Bind only after the startup orphan sweep finishes — otherwise a
454
460
  // fresh boot could serve traffic against tags whose on-disk state
455
- // still has residue from a prior crash. Top-level await is fine
456
- // for an entry-point ESM module (no other module imports this for
457
- // its exports — the side effect IS the program). `startupReap`
458
- // already resolves on any error (the reaper's own catch logs the
459
- // failure unconditionally and returns void), so no outer `.catch`
460
- // is needed here.
461
+ // still has residue from a prior crash. `startupReap` already
462
+ // resolves on any error (the reaper's own catch logs the failure
463
+ // unconditionally and returns void), so no outer `.catch` is
464
+ // needed here.
461
465
  await startupReap
462
- httpServer.listen(PORT, HOST)
466
+
467
+ // Bind the HTTP/WS plane on the configured PORT/HOST. Exported so a
468
+ // launcher (server-e2e/cli.js — the triage-server bin) or any `import`er can
469
+ // start serving. The top-level `await startupReap` above means the server
470
+ // is fully ready — DB open, DDL bootstrapped, objstore reaper swept — by
471
+ // the time the import resolves.
472
+ export function start(): void {
473
+ httpServer.listen(PORT, HOST)
474
+ }
475
+
476
+ export { httpServer, wss }
477
+
478
+ // Library mode: when this module is `import`ed (rather than run as the
479
+ // entry script) skip the auto-start so consumers can own the bind — e.g.
480
+ // wrap CF Access / framework-preset shims around the assembled
481
+ // `httpServer`, or just call `start()` when ready. Direct invocation via
482
+ // `node server-e2e/index.ts` (or the `triage-server` bin) still listens.
483
+ if (import.meta.main) {
484
+ start()
485
+ }
486
+
@@ -14,6 +14,10 @@ export type ShutdownDeps = {
14
14
  httpServer: Server
15
15
  wss: WebSocketServer
16
16
  heartbeatTimer: ReturnType<typeof setInterval>
17
+ // The SSE keepalive-sweep timer (server-e2e/sse-server.ts). Cleared on
18
+ // shutdown alongside `heartbeatTimer` so a tick can't write a keepalive
19
+ // comment to a session the close loop below is already tearing down.
20
+ sseKeepaliveTimer: ReturnType<typeof setInterval>
17
21
  // Stops the periodic reaper AND awaits any in-flight sweep.
18
22
  stopReaper: () => Promise<void>
19
23
  // Live SSE+POST session iterator (mirrors `wss.clients` for the SSE
@@ -46,7 +50,12 @@ export function createLifecycle(): Lifecycle {
46
50
  const inFlight = new Set<Promise<unknown>>()
47
51
  function track(promise: Promise<unknown>): void {
48
52
  inFlight.add(promise)
49
- promise.finally(() => inFlight.delete(promise))
53
+ // Trailing `.catch(() => {})` swallows the rejection that `.finally`
54
+ // propagates through its returned promise — without it, a tracked
55
+ // handler that rejects (or whose caller's catch handler itself
56
+ // throws) trips the `unhandledRejection` catchall below and crashes
57
+ // the process via `fireShutdown(1)`.
58
+ promise.finally(() => inFlight.delete(promise)).catch(() => {})
50
59
  }
51
60
  let shuttingDown = false
52
61
  // Live exit code the in-progress shutdown will pass to `process.exit`.
@@ -57,7 +66,7 @@ export function createLifecycle(): Lifecycle {
57
66
  let pendingExitCode = 0
58
67
 
59
68
  function install(deps: ShutdownDeps): void {
60
- const { httpServer, wss, heartbeatTimer, stopReaper, sseSessions, closeDb } = deps
69
+ const { httpServer, wss, heartbeatTimer, sseKeepaliveTimer, stopReaper, sseSessions, closeDb } = deps
61
70
 
62
71
  async function shutdown(exitCode: number = 0): Promise<void> {
63
72
  // Re-entry: don't restart the teardown, but escalate the pending
@@ -72,9 +81,9 @@ export function createLifecycle(): Lifecycle {
72
81
  shuttingDown = true
73
82
  pendingExitCode = exitCode
74
83
  console.log('Shutting down…')
75
- // Stop the heartbeat so a tick can't fire mid-shutdown and ping a
76
- // socket the close-loop below already started tearing down.
77
- clearInterval(heartbeatTimer)
84
+ // Stop both periodic timers (WS heartbeat + SSE keepalive) so neither
85
+ // fires mid-shutdown against a peer the close-loop is tearing down.
86
+ for (const timer of [heartbeatTimer, sseKeepaliveTimer]) clearInterval(timer)
78
87
  // Send a 1001 (going away) close frame to every open socket BEFORE
79
88
  // shutting the listener. Lets clients distinguish a server-initiated
80
89
  // graceful shutdown from a network drop, so they can skip their
@@ -8,7 +8,7 @@
8
8
  // the request body and answers with the upstream's JSON.
9
9
  //
10
10
  // The route is mounted at `/api/npm-advisories`. The same-origin
11
- // gate runs inside `dispatchNpmAdvisories` below (server/http.ts
11
+ // gate runs inside `dispatchNpmAdvisories` below (server-e2e/http.ts
12
12
  // calls the dispatcher directly without a pre-check), so any
13
13
  // browser request from a foreign origin is rejected with 403 before
14
14
  // we ever issue a fetch — non-browser callers that omit Origin are
@@ -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
@@ -0,0 +1,74 @@
1
+ // Anti-replay guard for the REST fetch-mint endpoint (POST
2
+ // /api/objstore/{tag}/{res}, see ./rest.ts). The WS fetch handshake binds
3
+ // the per-connection challenge nonce so a captured frame can't be
4
+ // replayed; the REST mint has no connection, so it binds a client
5
+ // timestamp instead and this guard supplies the matching freshness +
6
+ // dedup the nonce gave for free:
7
+ //
8
+ // - FRESHNESS: reject a request whose `ts` is outside ±`windowMs` of
9
+ // server time (an old captured request, or a future-dated one).
10
+ // - DEDUP: within the window, a signature is accepted at most once — a
11
+ // captured-and-replayed request (same `ts` ⇒ same signature) is
12
+ // rejected on the second presentation.
13
+ //
14
+ // Memory: each accepted signature is held for `windowMs` then pruned.
15
+ // Entries share a uniform TTL and the Map preserves insertion order, so
16
+ // the oldest entries expire first and a cheap front-prune (amortised
17
+ // O(1) per entry) keeps the set bounded; a hard `maxEntries` cap drops
18
+ // the oldest beyond it as a flood backstop.
19
+ //
20
+ // Scope: per-process. In a multi-replica deployment a captured request
21
+ // could be replayed once per replica that hasn't seen its signature yet
22
+ // (bounded by the replica count, within the window) — acceptable because
23
+ // the mint only ever yields a short-TTL GET token over AEAD ciphertext
24
+ // the relay can't read. A shared-store dedup (Redis/Neon) would close
25
+ // that residual gap if ever needed.
26
+
27
+ export type FetchMintVerdict = 'ok' | 'stale' | 'replay'
28
+
29
+ export type FetchMintGuard = {
30
+ // `signature` is the request's Ed25519 signature (unique per
31
+ // (tag, res, ts) tuple, so it doubles as the dedup key). `ts` is the
32
+ // client epoch-ms timestamp the signature commits to; `now` is the
33
+ // server clock (injectable for tests).
34
+ admit: (signature: string, ts: number, now?: number) => FetchMintVerdict
35
+ size: () => number
36
+ }
37
+
38
+ export const DEFAULT_FETCH_MINT_WINDOW_MS = 60_000
39
+ export const DEFAULT_FETCH_MINT_MAX_ENTRIES = 50_000
40
+
41
+ export function createFetchMintGuard(
42
+ { windowMs = DEFAULT_FETCH_MINT_WINDOW_MS, maxEntries = DEFAULT_FETCH_MINT_MAX_ENTRIES }:
43
+ { windowMs?: number; maxEntries?: number } = {},
44
+ ): FetchMintGuard {
45
+ // signature → expiry (ms). Insertion-ordered; uniform TTL ⇒ the head is
46
+ // always the soonest to expire.
47
+ const seen = new Map<string, number>()
48
+
49
+ function admit(signature: string, ts: number, now: number = Date.now()): FetchMintVerdict {
50
+ // Freshness first — a stale/future request never touches the cache, so
51
+ // it can't be used to grow the set.
52
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > windowMs) return 'stale'
53
+ // Front-prune expired entries (contiguous at the head under the
54
+ // uniform TTL). Breaks at the first live entry.
55
+ for (const [key, exp] of seen) {
56
+ if (exp > now) break
57
+ seen.delete(key)
58
+ }
59
+ // Post-prune, any remaining entry is live, so a hit is a genuine replay.
60
+ if (seen.has(signature)) return 'replay'
61
+ seen.set(signature, now + windowMs)
62
+ // Flood backstop: drop the oldest beyond the cap. Those are the
63
+ // closest to expiry anyway; dropping them only shortens their dedup
64
+ // window (still freshness-gated).
65
+ while (seen.size > maxEntries) {
66
+ const oldest = seen.keys().next().value
67
+ if (oldest === undefined) break
68
+ seen.delete(oldest)
69
+ }
70
+ return 'ok'
71
+ }
72
+
73
+ return { admit, size: () => seen.size }
74
+ }