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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/hub.ts CHANGED
@@ -102,12 +102,11 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
102
102
  }
103
103
 
104
104
  function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
105
- // Snapshot before iterating — `send`'s try/catch swallows
106
- // socket.send errors, but a socket transitioning to CLOSED
105
+ // Snapshot before iterating — a socket transitioning to CLOSED
107
106
  // mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
108
- // which mutates `set` while we're walking it. The snapshot keeps a
109
- // future refactor (different collection, async send) from silently
110
- // skipping subscribers. Audit M4 round-3.
107
+ // mutating `set` while we walk it. The snapshot also keeps a future
108
+ // refactor (different collection, async send) from silently skipping
109
+ // subscribers. Audit M4 round-3.
111
110
  for (const s of [...set]) {
112
111
  if (s === except) continue
113
112
  sendRaw(s, payload)
package/server/index.ts CHANGED
@@ -97,8 +97,8 @@ import {
97
97
  import { createBusReceiver } from './bus-receiver.ts'
98
98
 
99
99
  // 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.
100
+ // and validated in ./config.ts; destructure into the uppercase names
101
+ // the rest of this module uses.
102
102
  const config = loadConfig()
103
103
  const {
104
104
  port: PORT, host: HOST, dbPath: DB_PATH, objstoreDir: OBJSTORE_DIR,
@@ -109,8 +109,8 @@ const {
109
109
  } = config
110
110
 
111
111
  // 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.
112
+ // ./origin.ts). `TRUST_PROXY_ENV` also feeds the boot-time
113
+ // misconfiguration fail-fast below.
114
114
  const { trustProxy: TRUST_PROXY, isOriginAllowed } = createOriginGate(HOST, TRUST_PROXY_ENV)
115
115
 
116
116
  // Per-socket buffered-bytes cap. `socket.send` returns synchronously
@@ -131,10 +131,9 @@ const MAX_BUFFERED_BYTES = 16 * 1024 * 1024
131
131
 
132
132
  // Per-connection state registry. One `Peer` per accepted socket holds
133
133
  // 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)`.
134
+ // and subscribed tags (see ./peer.ts). The connection handler holds
135
+ // the Peer in a closure for the hot paths; cross-function call sites
136
+ // resolve it via `peers.get(socket)`.
138
137
  const peers: PeerRegistry = new WeakMap()
139
138
 
140
139
  // REST PUT idle-body timeout. A slow-loris client trickling bytes
@@ -163,8 +162,8 @@ const REST_PUT_IDLE_TIMEOUT_MS = 30_000
163
162
  const HEARTBEAT_INTERVAL_MS = 30_000
164
163
 
165
164
  // Backend selection. Both planes (workspace_revision DB + the
166
- // v1.objstore byte store) are picked from config at boot. Two supported
167
- // pairings:
165
+ // v1.objstore byte store) are picked from config at boot. Two
166
+ // supported pairings:
168
167
  // 1. DATABASE_URL set → Neon (workspace_revision + objstore
169
168
  // tables) + Vercel Blob Private Storage (bytes). Requires
170
169
  // BLOB_READ_WRITE_TOKEN — fail fast at boot if missing, since
@@ -174,11 +173,10 @@ const HEARTBEAT_INTERVAL_MS = 30_000
174
173
  // process; the only pairing the SQLite plane supports.
175
174
  // The Neon / Vercel files import their peer deps lazily inside the
176
175
  // 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.
176
+ // only install where the optional peer deps aren't present. Explicit
177
+ // branch (not a ternary) so the SQLite path keeps its `SqliteHandle`
178
+ // narrowing — `sqliteHandle.db` is a non-optional `DatabaseSync` that
179
+ // `openObjstore` accepts without a non-null assertion.
182
180
  let handle: Handle
183
181
  let objstoreHandle: ObjstoreHandle
184
182
  let objstoreBanner: string
@@ -238,8 +236,7 @@ async function workspaceExists(tag: string): Promise<boolean> {
238
236
  }
239
237
 
240
238
  // 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.
239
+ // broadcast (see ./hub.ts).
243
240
  const hub = createHub({ peers, maxBufferedBytes: MAX_BUFFERED_BYTES, debug: DEBUG })
244
241
  const { send, broadcast, subscribe, unsubscribeAll, broadcastLocalRaw } = hub
245
242
 
@@ -276,7 +273,7 @@ if (NEON_URL) {
276
273
  }
277
274
 
278
275
  // Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
279
- // handshake. Destructure into the existing names for the wiring below.
276
+ // handshake.
280
277
  const auth = createAuth({ peers, password: CONFIG_PASSWORD, send, debug: DEBUG })
281
278
  const { requiresAuth, handleAuthenticate, sendUnauthorized } = auth
282
279
 
@@ -320,8 +317,8 @@ const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper
320
317
  sendUnauthorized,
321
318
  // `tokenSecret` is set only when OBJSTORE_TOKEN_SECRET was
322
319
  // 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).
320
+ // → initObjstore mints a fresh per-process secret (fine for
321
+ // single-replica).
325
322
  ...(TOKEN_SECRET ? { tokenSecret: TOKEN_SECRET } : {}),
326
323
  })
327
324
 
@@ -452,11 +449,29 @@ installLifecycle({
452
449
 
453
450
  // Bind only after the startup orphan sweep finishes — otherwise a
454
451
  // 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.
452
+ // still has residue from a prior crash. `startupReap` already
453
+ // resolves on any error (the reaper's own catch logs the failure
454
+ // unconditionally and returns void), so no outer `.catch` is
455
+ // needed here.
461
456
  await startupReap
462
- httpServer.listen(PORT, HOST)
457
+
458
+ // Bind the HTTP/WS plane on the configured PORT/HOST. Exported so a
459
+ // launcher (server/cli.js — the triage-server bin) or any `import`er can
460
+ // start serving. The top-level `await startupReap` above means the server
461
+ // is fully ready — DB open, DDL bootstrapped, objstore reaper swept — by
462
+ // the time the import resolves.
463
+ export function start(): void {
464
+ httpServer.listen(PORT, HOST)
465
+ }
466
+
467
+ export { httpServer, wss }
468
+
469
+ // Library mode: when this module is `import`ed (rather than run as the
470
+ // entry script) skip the auto-start so consumers can own the bind — e.g.
471
+ // wrap CF Access / framework-preset shims around the assembled
472
+ // `httpServer`, or just call `start()` when ready. Direct invocation via
473
+ // `node server/index.ts` (or the `triage-server` bin) still listens.
474
+ if (import.meta.main) {
475
+ start()
476
+ }
477
+
@@ -46,7 +46,12 @@ export function createLifecycle(): Lifecycle {
46
46
  const inFlight = new Set<Promise<unknown>>()
47
47
  function track(promise: Promise<unknown>): void {
48
48
  inFlight.add(promise)
49
- promise.finally(() => inFlight.delete(promise))
49
+ // Trailing `.catch(() => {})` swallows the rejection that `.finally`
50
+ // propagates through its returned promise — without it, a tracked
51
+ // handler that rejects (or whose caller's catch handler itself
52
+ // throws) trips the `unhandledRejection` catchall below and crashes
53
+ // the process via `fireShutdown(1)`.
54
+ promise.finally(() => inFlight.delete(promise)).catch(() => {})
50
55
  }
51
56
  let shuttingDown = false
52
57
  // Live exit code the in-progress shutdown will pass to `process.exit`.
@@ -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
  }
@@ -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/README.md's GET status table.
367
+ if (isNotFound(err)) return { ok: false, reason: 'unavailable' }
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' }
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) {
378
+ if (res.statusCode !== 200 || res.stream == null) {
368
379
  return { ok: false, reason: 'unavailable' }
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' }
399
+ throw headErr
400
+ }
401
+ }
402
+ if (size == null) return { ok: false, reason: 'unavailable' }
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
@@ -83,13 +83,20 @@ 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.
90
97
  export type OpenLiveResult =
91
98
  | { ok: true; reader: LiveReader }
92
- | { ok: false; reason: 'not-found' | 'unavailable' }
99
+ | { ok: false; reason: 'unavailable' }
93
100
 
94
101
  export type BlobBackend = {
95
102
  // Per-workspace setup. FS creates the on-disk staging directory;
@@ -128,9 +135,10 @@ export type BlobBackend = {
128
135
  promoteStagingToLive(tag: string, stagingId: string, contentHash: string): Promise<boolean>
129
136
 
130
137
  // 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).
138
+ // Called only after the REST layer has confirmed the live row, so a
139
+ // missing blob is the transient "row present, bytes gone" state →
140
+ // `unavailable` (HTTP 503), which the reaper reconciles and the client
141
+ // retries. Never a 404 from here — see OpenLiveResult above.
134
142
  openLiveReader(tag: string, contentHash: string): Promise<OpenLiveResult>
135
143
 
136
144
  // Idempotent deletes. Backends MUST tolerate "already gone" as
@@ -68,11 +68,11 @@ function urlPathFor(tag: string, resourceTag: string): string {
68
68
  // Shared gate every objstore handler runs after its message-specific
69
69
  // field checks: fetch the socket's challenge nonce, verify the signed
70
70
  // message against it, then re-confirm the socket is still OPEN — the
71
- // close handler may have fired during the verify await, and attaching
72
- // to / replying on a closed socket is the half-handshake leak case
73
- // (PR #4 review F4). Returns true when the caller may proceed.
74
- // Centralising the post-await readyState recheck keeps that
75
- // easy-to-forget invariant in one auditable place.
71
+ // close handler may have fired during the verify await, and replying
72
+ // on a closed socket is the half-handshake leak case (PR #4 review
73
+ // F4). Centralising the post-await readyState recheck keeps that
74
+ // invariant in one auditable place. Returns true when the caller may
75
+ // proceed.
76
76
  async function verified<M extends { workspaceTag?: unknown }>(
77
77
  deps: ObjstoreDeps, socket: WebSocket, msg: M, label: string,
78
78
  verify: (m: M, nonce: string) => Promise<boolean>,
@@ -96,12 +96,10 @@ async function handlePutBegin(deps: ObjstoreDeps, socket: WebSocket, msg: Objsto
96
96
  // signature to fail. Cheaper to reject up-front, and consistent
97
97
  // with `verifyObjstorePutSig`'s `isSafeNonNegativeInt` gate.
98
98
  if (!Number.isSafeInteger(msg.expectedLength) || (msg.expectedLength as number) < 0 || (msg.expectedLength as number) > MAX_CONTENT_LENGTH) return
99
- // Symmetric with `handleDelete`'s prevVersion gate (line 116) and
100
- // `verifyObjstorePutSig`'s `isSafeIntOrNull` (sign.ts:119). Without
101
- // this, a non-safe-integer `prevVersion` (NaN, 2^53+1, ...) would
102
- // pass the typeof check below and reach sig verify, burning a
103
- // hash + Ed25519 round-trip on a guaranteed-fail input. Input-
104
- // validation audit `server/objstore/handlers.ts:76`.
99
+ // Same up-front reject for `prevVersion` (symmetric with handleDelete
100
+ // and `verifyObjstorePutSig`'s `isSafeIntOrNull`): a non-safe-integer
101
+ // (NaN, 2^53+1, ...) would pass the typeof check below and reach sig
102
+ // verify, burning a hash + Ed25519 round-trip on a guaranteed fail.
105
103
  if (msg.prevVersion != null && (typeof msg.prevVersion !== 'number' || !Number.isSafeInteger(msg.prevVersion))) return
106
104
  if (!await verified(deps, socket, msg, 'put-begin', verifyObjstorePutSig)) return
107
105
  const tag = msg.workspaceTag
@@ -110,8 +108,8 @@ async function handlePutBegin(deps: ObjstoreDeps, socket: WebSocket, msg: Objsto
110
108
  // workspace tag (no rows in workspace_revision AND none in
111
109
  // workspace_object). Mirrors handleSave in server/index.ts; runs
112
110
  // AFTER sig verify so `unauthorized` only reaches a legitimate
113
- // signer. The gate is config-driven (server/config.json
114
- // `password`) and is a no-op when no password is configured.
111
+ // signer. Config-driven (server/config.json `password`), no-op when
112
+ // unconfigured.
115
113
  if (deps.authGate && deps.sendUnauthorized && await deps.authGate(socket, tag)) {
116
114
  if (socket.readyState !== socket.OPEN) return
117
115
  if (deps.debug) console.warn(`reject objstore-put-begin: unauthorized (new workspace ${debugTag(tag)})`)
@@ -56,6 +56,16 @@ export type ObjstoreInit = {
56
56
  }
57
57
 
58
58
  export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
59
+ // Fail loud on a lopsided auth config. The put-begin gate in
60
+ // handlers.ts only fires when BOTH authGate and sendUnauthorized are
61
+ // present (it needs the reporter to emit the `unauthorized` frame),
62
+ // so wiring authGate WITHOUT sendUnauthorized silently fails OPEN —
63
+ // unauthenticated first-writes to unknown workspaces would be accepted
64
+ // despite the operator's intent to gate them. Reject at boot rather
65
+ // than regress access control silently.
66
+ if (deps.authGate && !deps.sendUnauthorized) {
67
+ throw new Error('initObjstore: authGate requires sendUnauthorized (the put-begin gate needs it to emit the unauthorized frame)')
68
+ }
59
69
  const handle = deps.handle
60
70
  const secret = deps.tokenSecret ?? newTokenSecret()
61
71
  const handlers = createObjstoreHandlers({
@@ -93,15 +103,14 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
93
103
  // on-disk state still has stranded files from a prior crash.
94
104
  const startupReap = enqueueSweep()
95
105
  // Jittered start of the periodic timer. Multi-replica deploys
96
- // (Neon + Vercel Blob) commonly boot N replicas in tight lock-
97
- // step (deploy rollout, cluster restart) and would otherwise
98
- // sync every replica's reaper at the same wall-clock tick,
99
- // hammering the DB + blob store with N×readdir+lock-acquire
100
- // bursts. A random first-interval delay deconcurrencies the
101
- // cluster without changing the long-term sweep cadence.
102
- // Jitter range is 0…1× reapIntervalMs (i.e., the next sweep
103
- // happens at [interval, 2×interval] after boot); subsequent
104
- // sweeps stay at exactly `reapIntervalMs` apart.
106
+ // (Neon + Vercel Blob) commonly boot N replicas in tight lock-step
107
+ // (deploy rollout, cluster restart), which would otherwise sync every
108
+ // replica's reaper to the same wall-clock tick and hammer the DB +
109
+ // blob store with N×readdir+lock-acquire bursts. A random
110
+ // first-interval delay spreads the cluster out without changing the
111
+ // long-term cadence. Jitter is 0…1× reapIntervalMs (first sweep at
112
+ // [interval, 2×interval] after boot); subsequent sweeps stay exactly
113
+ // `reapIntervalMs` apart.
105
114
  let reapTimer: ReturnType<typeof setInterval> | null = null
106
115
  const jitterMs = Math.floor(Math.random() * deps.reapIntervalMs)
107
116
  const firstTimer = setTimeout(() => {