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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.5",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT",
6
6
  "author": {
package/server/auth.ts CHANGED
@@ -31,6 +31,10 @@ export type AuthenticateMsg = { password?: unknown }
31
31
  export type Auth = {
32
32
  // True iff the gate is configured AND this socket hasn't authenticated.
33
33
  requiresAuth(socket: WebSocket): boolean
34
+ // Static "is a server password configured" flag — the socket-less
35
+ // predicate the REST put-begin gate needs (a REST request can never be
36
+ // operator-authorized, so `requiresAuth` collapses to this there).
37
+ passwordConfigured: boolean
34
38
  handleAuthenticate(socket: WebSocket, msg: AuthenticateMsg): void
35
39
  sendUnauthorized(socket: WebSocket, ctx: UnauthorizedContext): void
36
40
  }
@@ -106,5 +110,5 @@ export function createAuth(deps: {
106
110
  send(socket, { type: 'authenticated' })
107
111
  }
108
112
 
109
- return { requiresAuth, handleAuthenticate, sendUnauthorized }
113
+ return { requiresAuth, passwordConfigured: CONFIGURED_PASSWORD_HMAC != null, handleAuthenticate, sendUnauthorized }
110
114
  }
package/server/config.ts CHANGED
@@ -18,6 +18,7 @@ export type Config = {
18
18
  dbPath: string
19
19
  objstoreDir: string
20
20
  reapIntervalMs: number
21
+ reapDisabled: boolean
21
22
  maxInflightPerSocket: number
22
23
  debug: boolean
23
24
  neonUrl: string | null
@@ -78,6 +79,13 @@ Environment:
78
79
  DATABASE_URL + BLOB_READ_WRITE_TOKEN
79
80
  are set (bytes live in Vercel Blob).
80
81
  OBJSTORE_REAP_INTERVAL_MS orphan reaper period (default 600000)
82
+ OBJSTORE_REAP_DISABLED set '1' / 'true' to disable the orphan
83
+ reaper ENTIRELY — no boot sweep, no
84
+ periodic GC. Orphaned/superseded blobs and
85
+ stale staging rows then accumulate
86
+ unbounded; only set this if an external job
87
+ handles GC (e.g. a cron calling reapOrphans).
88
+ Default OFF (reaper runs).
81
89
  TRUST_PROXY set '1' / 'true' to honour X-Forwarded-
82
90
  Host / X-Forwarded-Proto when computing
83
91
  the same-origin gate's expected origin.
@@ -163,6 +171,11 @@ export function loadConfig(): Config {
163
171
  const objstoreDir = env['OBJSTORE_DIR'] ?? join(dirname(dbPath), 'objstore')
164
172
  // No practical upper bound beyond the safe-integer range.
165
173
  const reapIntervalMs = intEnv('OBJSTORE_REAP_INTERVAL_MS', 10 * 60 * 1000, 1, Number.MAX_SAFE_INTEGER)
174
+ // Hard off-switch for the orphan reaper (both the boot sweep AND the
175
+ // periodic timer). '1' / 'true' (case-insensitive) → disabled; anything
176
+ // else, including unset, leaves it ON. Same boolean shape as TRUST_PROXY.
177
+ const reapDisabledEnv = env['OBJSTORE_REAP_DISABLED']
178
+ const reapDisabled = reapDisabledEnv === '1' || reapDisabledEnv?.toLowerCase() === 'true'
166
179
  const debug = env['DEBUG'] === '1'
167
180
 
168
181
  const configPath = env['CONFIG_PATH'] ?? fileURLToPath(new URL('./config.json', import.meta.url))
@@ -189,7 +202,7 @@ export function loadConfig(): Config {
189
202
  const tokenSecret = tokenSecretB64 ? decodeTokenSecret(tokenSecretB64) : null
190
203
 
191
204
  return {
192
- port, host, dbPath, objstoreDir, reapIntervalMs, maxInflightPerSocket,
205
+ port, host, dbPath, objstoreDir, reapIntervalMs, reapDisabled, maxInflightPerSocket,
193
206
  debug, neonUrl, blobToken, tokenSecret, password,
194
207
  trustProxyEnv: env['TRUST_PROXY'],
195
208
  }
package/server/http.ts CHANGED
@@ -20,6 +20,12 @@ import { errStack } from './util.ts'
20
20
  // similar) can route `/api/*` → this process and `/*` → the static UI
21
21
  // bundle with a single location block.
22
22
  export const WS_UPGRADE_PATH = '/api/sync'
23
+ // Session-independent triage-sync save plane: `POST /api/sync/save`. The
24
+ // SSE-mode alternative to the in-band `workspace-save` frame — a save POSTed
25
+ // here commits + broadcasts WITHOUT taking over the client's SSE event-stream
26
+ // (each in-band POST forces a stream takeover; see sse-server.ts). Sibling of
27
+ // the WS upgrade path so one nginx `/api/*` block routes both.
28
+ export const SAVE_REST_PATH = '/api/sync/save'
23
29
  function isUpgradePath(url: string | undefined): boolean {
24
30
  if (typeof url !== 'string') return false
25
31
  // Strip `?…` so clients can carry build / debug tags. Exact match
@@ -42,12 +48,68 @@ export type HttpServerDeps = {
42
48
  isOriginAllowed: (req: HasHeaders) => boolean
43
49
  isShuttingDown: () => boolean
44
50
  track: (promise: Promise<unknown>) => void
51
+ // Handler for `POST /api/sync/save` (see SAVE_REST_PATH). Owns body parse +
52
+ // the save pipeline + JSON response; this module owns the gates (method,
53
+ // same-origin, shutdown, idle-timeout) and the graceful-drain tracking.
54
+ handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
45
55
  restPutIdleTimeoutMs: number
46
56
  debug: boolean
47
57
  }
48
58
 
59
+ // `POST /api/sync/save` dispatch. Returns the in-flight handler promise when
60
+ // the request matched the route (the caller `track`s it for graceful drain),
61
+ // or null when it's for a different route. The gate ladder — method →
62
+ // shutdown → same-origin → idle-timeout — mirrors the objstore REST branch; a
63
+ // gate rejection writes its own response and returns an already-settled
64
+ // promise. Kept out of `createHttpServer` so that dispatcher stays compact.
65
+ function dispatchSaveRest(
66
+ req: HttpRequest, res: ServerResponse,
67
+ deps: {
68
+ handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
69
+ isOriginAllowed: (req: HasHeaders) => boolean
70
+ isShuttingDown: () => boolean
71
+ restPutIdleTimeoutMs: number
72
+ debug: boolean
73
+ },
74
+ ): Promise<void> | null {
75
+ if (typeof req.url !== 'string' || req.url.split('?', 1)[0] !== SAVE_REST_PATH) return null
76
+ if (req.method !== 'POST') {
77
+ res.writeHead(405, { 'content-type': 'application/json', 'allow': 'POST', 'connection': 'close' })
78
+ res.end(JSON.stringify({ error: 'method-not-allowed' }))
79
+ return Promise.resolve()
80
+ }
81
+ // Shutdown gate — parity with the objstore REST branch (a POST on an
82
+ // existing keep-alive socket after SIGTERM but before close() drains).
83
+ if (deps.isShuttingDown()) {
84
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
85
+ res.end(JSON.stringify({ error: 'shutting-down' }))
86
+ return Promise.resolve()
87
+ }
88
+ // Same-origin gate — a hostile cross-origin page would carry an Origin
89
+ // header (browser-set on fetch); same-origin XHR may omit it (allowed).
90
+ if (!deps.isOriginAllowed(req)) {
91
+ res.writeHead(403, { 'content-type': 'application/json' })
92
+ res.end(JSON.stringify({ error: 'origin-denied' }))
93
+ return Promise.resolve()
94
+ }
95
+ // Idle-body timeout — a slow-loris trickling the JSON body would otherwise
96
+ // hold the connection indefinitely.
97
+ req.setTimeout(deps.restPutIdleTimeoutMs, () => {
98
+ if (deps.debug) console.warn(`sync-save REST idle ${deps.restPutIdleTimeoutMs}ms → abort`)
99
+ try { req.destroy(new Error('idle-timeout')) } catch {}
100
+ })
101
+ // Outer catch is the unhandled-rejection guard for a throw OUTSIDE the
102
+ // handler's own try/catch — logs and terminates the response so the TCP
103
+ // socket doesn't dangle (same policy as the objstore handleRest wrapper).
104
+ return deps.handleSaveRest(req, res).catch((err) => {
105
+ console.warn('sync-save REST handler error:', errStack(err))
106
+ if (res.headersSent) { try { res.destroy() } catch {} }
107
+ else { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })) } catch {} }
108
+ })
109
+ }
110
+
49
111
  export function createHttpServer(deps: HttpServerDeps): Server {
50
- const { wss, restDeps, sseServer, isOriginAllowed, isShuttingDown, track, restPutIdleTimeoutMs, debug } = deps
112
+ const { wss, restDeps, sseServer, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug } = deps
51
113
  // Static-file plane (see ./static.ts). The directory is the
52
114
  // `build.js build` output sibling to this file; the loader handles
53
115
  // enumeration, pre-compression, and ETag derivation. Plugged in after
@@ -76,6 +138,12 @@ export function createHttpServer(deps: HttpServerDeps): Server {
76
138
  // the lifecycle's sseSessions() close loop.
77
139
  if (sseServer.handle(req, res)) return
78
140
  }
141
+ // Triage-sync save REST plane (see SAVE_REST_PATH) — session-independent
142
+ // `POST /api/sync/save` so an SSE-mode save doesn't take over the
143
+ // event-stream. The dispatch helper owns the gate ladder; we `track` the
144
+ // returned in-flight promise so SIGTERM drains it (mirrors npm-advisories).
145
+ const saveP = dispatchSaveRest(req, res, { handleSaveRest, isOriginAllowed, isShuttingDown, restPutIdleTimeoutMs, debug })
146
+ if (saveP) { track(saveP); return }
79
147
  // npm advisories proxy — same-origin + shutdown gates live in the
80
148
  // helper so this dispatcher stays compact. `dispatchNpmAdvisories`
81
149
  // returns the in-flight promise (or null when the route didn't
@@ -109,14 +177,16 @@ export function createHttpServer(deps: HttpServerDeps): Server {
109
177
  res.end(JSON.stringify({ error: 'origin-denied' }))
110
178
  return
111
179
  }
112
- // PUT idle-body timeout — a slow-loris client trickling bytes
113
- // within the declared Content-Length holds the staging fd + an
114
- // inFlightSids slot indefinitely. `req.setTimeout` fires on
115
- // inactivity; we destroy the request, aborting the body pipeline.
180
+ // Idle-body timeout for the body-bearing REST methods — a slow-loris
181
+ // client trickling bytes holds the connection (and, for PUT, the
182
+ // staging fd + inFlightSids slot) indefinitely. PUT carries the raw
183
+ // blob within its declared Content-Length; POST carries the small
184
+ // fetch-mint JSON body. `req.setTimeout` fires on inactivity; we
185
+ // destroy the request, aborting the body pipeline.
116
186
  // Transport audit `server/objstore/rest.ts:218`.
117
- if (req.method === 'PUT') {
187
+ if (req.method === 'PUT' || req.method === 'POST') {
118
188
  req.setTimeout(restPutIdleTimeoutMs, () => {
119
- if (debug) console.warn(`REST PUT idle ${restPutIdleTimeoutMs}ms → abort`)
189
+ if (debug) console.warn(`REST ${req.method} idle ${restPutIdleTimeoutMs}ms → abort`)
120
190
  try { req.destroy(new Error('idle-timeout')) } catch {}
121
191
  })
122
192
  }
package/server/index.ts CHANGED
@@ -102,7 +102,7 @@ import { createBusReceiver } from './bus-receiver.ts'
102
102
  const config = loadConfig()
103
103
  const {
104
104
  port: PORT, host: HOST, dbPath: DB_PATH, objstoreDir: OBJSTORE_DIR,
105
- reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
105
+ reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS, reapDisabled: OBJSTORE_REAP_DISABLED,
106
106
  maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET, debug: DEBUG,
107
107
  neonUrl: NEON_URL, blobToken: BLOB_TOKEN, tokenSecret: TOKEN_SECRET,
108
108
  password: CONFIG_PASSWORD, trustProxyEnv: TRUST_PROXY_ENV,
@@ -275,7 +275,7 @@ if (NEON_URL) {
275
275
  // Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
276
276
  // handshake.
277
277
  const auth = createAuth({ peers, password: CONFIG_PASSWORD, send, debug: DEBUG })
278
- const { requiresAuth, handleAuthenticate, sendUnauthorized } = auth
278
+ const { requiresAuth, passwordConfigured, handleAuthenticate, sendUnauthorized } = auth
279
279
 
280
280
  // Triage-sync protocol handlers (see ./sync-handlers.ts). `getNonce`
281
281
  // resolves a socket's challenge nonce and is shared with the objstore
@@ -292,9 +292,9 @@ const publishObjDeleted = (tag: string, resourceTag: string, version: number): v
292
292
  pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version })
293
293
  }
294
294
 
295
- const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
295
+ const { handleSave, handleSaveRest, handleSubscribe, sendSaveError } = createSyncHandlers({
296
296
  handle, send, broadcast, publishRevision, subscribe, getNonce,
297
- requiresAuth, sendUnauthorized, workspaceExists,
297
+ requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists,
298
298
  // Folds the objstore inventory into the `workspace-subscribed` ack.
299
299
  // The objstore store keeps its own richer `Handle`, so we wire the
300
300
  // query here where both handles exist rather than coupling
@@ -305,6 +305,7 @@ const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
305
305
 
306
306
  const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper } = initObjstore({
307
307
  handle: objstoreHandle, reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
308
+ reapDisabled: OBJSTORE_REAP_DISABLED,
308
309
  send, broadcast, publishObjPut, publishObjDeleted,
309
310
  getNonce, debug: DEBUG,
310
311
  // Auth gate for the FIRST objstore-put-begin against a workspace
@@ -315,6 +316,11 @@ const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper
315
316
  // `unauthorized` frame and bails on `true`.
316
317
  authGate: async (socket, tag) => requiresAuth(socket) && !await workspaceExists(tag),
317
318
  sendUnauthorized,
319
+ // Socket-less analog of `authGate` for the REST put-begin mint: a REST
320
+ // request can never be operator-authorized, so the gate collapses to
321
+ // "password configured AND workspace new". A deny routes the client to
322
+ // its in-band WS put-begin fallback.
323
+ restPutGate: async (tag) => passwordConfigured && !await workspaceExists(tag),
318
324
  // `tokenSecret` is set only when OBJSTORE_TOKEN_SECRET was
319
325
  // provided in env (see TOKEN_SECRET resolution above). Omitted
320
326
  // → initObjstore mints a fresh per-process secret (fine for
@@ -361,10 +367,6 @@ const sseServer = installSseServer({
361
367
  // Mirror the WS `maxPayload` so the SSE plane can't accept frames
362
368
  // the WS plane would reject.
363
369
  maxBodyBytes: 4 * 1024 * 1024,
364
- // 90s idle ceiling. The client's JSON ping/pong is 15s; this is ~6
365
- // missed pings before we close — well past any transient network
366
- // hiccup, much shorter than the kernel's hours-long TCP keepalive.
367
- sessionIdleMs: 90_000,
368
370
  debug: DEBUG,
369
371
  })
370
372
 
@@ -375,7 +377,7 @@ const sseServer = installSseServer({
375
377
  // below.
376
378
  const httpServer = createHttpServer({
377
379
  wss, restDeps: objstoreRestDeps, sseServer, isOriginAllowed,
378
- isShuttingDown, track,
380
+ isShuttingDown, track, handleSaveRest,
379
381
  restPutIdleTimeoutMs: REST_PUT_IDLE_TIMEOUT_MS, debug: DEBUG,
380
382
  })
381
383
 
@@ -444,6 +446,7 @@ const closeDb = async (): Promise<void> => {
444
446
  installLifecycle({
445
447
  httpServer, wss, heartbeatTimer, stopReaper,
446
448
  sseSessions: sseServer.sessions,
449
+ sseKeepaliveTimer: sseServer.keepaliveTimer,
447
450
  closeDb,
448
451
  })
449
452
 
@@ -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/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
@@ -62,7 +66,7 @@ export function createLifecycle(): Lifecycle {
62
66
  let pendingExitCode = 0
63
67
 
64
68
  function install(deps: ShutdownDeps): void {
65
- const { httpServer, wss, heartbeatTimer, stopReaper, sseSessions, closeDb } = deps
69
+ const { httpServer, wss, heartbeatTimer, sseKeepaliveTimer, stopReaper, sseSessions, closeDb } = deps
66
70
 
67
71
  async function shutdown(exitCode: number = 0): Promise<void> {
68
72
  // Re-entry: don't restart the teardown, but escalate the pending
@@ -77,9 +81,9 @@ export function createLifecycle(): Lifecycle {
77
81
  shuttingDown = true
78
82
  pendingExitCode = exitCode
79
83
  console.log('Shutting down…')
80
- // Stop the heartbeat so a tick can't fire mid-shutdown and ping a
81
- // socket the close-loop below already started tearing down.
82
- 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)
83
87
  // Send a 1001 (going away) close frame to every open socket BEFORE
84
88
  // shutting the listener. Lets clients distinguish a server-initiated
85
89
  // graceful shutdown from a network drop, so they can skip their
@@ -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
@@ -364,19 +364,19 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
364
364
  // same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
365
365
  // the client the resource is gone for good when it should refetch.
366
366
  // See server/README.md's GET status table.
367
- if (isNotFound(err)) return { ok: false, reason: 'unavailable' }
367
+ if (isNotFound(err)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
368
368
  throw err
369
369
  }
370
370
  // SDK returned null (no blob) — same "bytes missing for a live row"
371
371
  // transient as the BlobNotFoundError branch above → `unavailable`, not
372
372
  // `not-found`.
373
- if (res == null) return { ok: false, reason: 'unavailable' }
373
+ if (res == null) return { ok: false, reason: 'unavailable', detail: 'vercel-get-null' }
374
374
  // statusCode 304 doesn't reach here in practice — the REST
375
375
  // GET layer doesn't pass If-None-Match — but a future call
376
376
  // site could. Treat as unavailable rather than streaming a
377
377
  // null body.
378
378
  if (res.statusCode !== 200 || res.stream == null) {
379
- return { ok: false, reason: 'unavailable' }
379
+ return { ok: false, reason: 'unavailable', detail: `vercel-get-status-${res.statusCode}` }
380
380
  }
381
381
  // `@vercel/blob@2.x`'s streaming `get()` for private blobs returns
382
382
  // the body but does NOT populate `blob.size` nor pass a
@@ -395,11 +395,11 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
395
395
  // Blob vanished between get() and the head() size fallback (a
396
396
  // racing reaper GC) — still the "live row present, bytes gone"
397
397
  // transient, so `unavailable` (503), matching the get() path above.
398
- if (isNotFound(headErr)) return { ok: false, reason: 'unavailable' }
398
+ if (isNotFound(headErr)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
399
399
  throw headErr
400
400
  }
401
401
  }
402
- if (size == null) return { ok: false, reason: 'unavailable' }
402
+ if (size == null) return { ok: false, reason: 'unavailable', detail: 'vercel-no-size' }
403
403
  // SDK returns a web ReadableStream<Uint8Array>; the REST layer
404
404
  // expects a Node Readable for pipeline(). Convert via
405
405
  // Readable.fromWeb — built-in and zero-copy where possible.
@@ -94,9 +94,16 @@ export type LiveReader = {
94
94
  // `unavailable`/503 contract, which the client retries. Both backends MUST
95
95
  // map a missing blob to `unavailable` (FS: ENOENT; Vercel: BlobNotFoundError
96
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.
97
104
  export type OpenLiveResult =
98
105
  | { ok: true; reader: LiveReader }
99
- | { ok: false; reason: 'unavailable' }
106
+ | { ok: false; reason: 'unavailable'; detail?: string }
100
107
 
101
108
  export type BlobBackend = {
102
109
  // Per-workspace setup. FS creates the on-disk staging directory;
@@ -18,6 +18,12 @@ export type ObjstoreInitDeps = {
18
18
  // the workspace_revision handle in server/db.ts.
19
19
  handle: Handle
20
20
  reapIntervalMs: number
21
+ // Hard off-switch (OBJSTORE_REAP_DISABLED). When true, NEITHER the boot
22
+ // sweep nor the periodic timer runs: `startupReap` resolves immediately
23
+ // and `stopReaper` is a no-op. Orphaned/superseded blobs and stale
24
+ // staging rows then accumulate unbounded — only safe if an external job
25
+ // handles GC. Omitted/false → reaper runs (the default). See config.ts.
26
+ reapDisabled?: boolean
21
27
  send: (socket: WebSocket, msg: object) => void
22
28
  broadcast: (tag: string, msg: object, except: WebSocket | null) => void
23
29
  // Cross-instance pub/sub publishers. SQLite mode passes no-ops; Neon
@@ -42,6 +48,12 @@ export type ObjstoreInitDeps = {
42
48
  // is not load-balancer-pinned). See server/index.ts boot logic
43
49
  // for the env var (`OBJSTORE_TOKEN_SECRET`).
44
50
  tokenSecret?: TokenSecret
51
+ // New-workspace operator gate for the REST put-begin mint — returns
52
+ // `true` to DENY (password configured AND workspace new). The
53
+ // connection-independent analog of `authGate`; the client falls back to
54
+ // the in-band WS put-begin on a deny. Omitted → open (never deny),
55
+ // matching `authGate`'s no-config default.
56
+ restPutGate?: (workspaceTag: string) => Promise<boolean>
45
57
  }
46
58
 
47
59
  export type ObjstoreInit = {
@@ -77,7 +89,10 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
77
89
  ...(deps.sendUnauthorized ? { sendUnauthorized: deps.sendUnauthorized } : {}),
78
90
  })
79
91
  const restDeps: ObjstoreRestDeps = {
80
- handle, secret, broadcast: deps.broadcast, publishObjPut: deps.publishObjPut, debug: deps.debug,
92
+ handle, secret, broadcast: deps.broadcast,
93
+ publishObjPut: deps.publishObjPut, publishObjDeleted: deps.publishObjDeleted,
94
+ restPutGate: deps.restPutGate ?? (() => Promise.resolve(false)),
95
+ debug: deps.debug,
81
96
  }
82
97
  // Re-entrancy guard for periodic + startup sweeps. Kicking the
83
98
  // startup sweep through the same `enqueueSweep` path means the
@@ -98,6 +113,16 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
98
113
  inFlight = p
99
114
  return p
100
115
  }
116
+ // Hard off-switch (OBJSTORE_REAP_DISABLED). Skip the boot sweep AND the
117
+ // periodic timer entirely: `startupReap` resolves immediately so the
118
+ // index.ts `await startupReap` gate is a no-op, and `stopReaper` has
119
+ // nothing to clear or drain. Loud, unconditional warning — with GC off,
120
+ // orphaned/superseded blobs and stale staging rows are never reclaimed
121
+ // (they accumulate until an external job, if any, collects them).
122
+ if (deps.reapDisabled) {
123
+ console.warn('objstore: reaper DISABLED (OBJSTORE_REAP_DISABLED) — orphaned/superseded blobs and stale staging will NOT be reclaimed')
124
+ return { handlers, restDeps, startupReap: Promise.resolve(), stopReaper: async () => {} }
125
+ }
101
126
  // Caller awaits this before accepting traffic so a fresh boot
102
127
  // can't hand out list / fetch / put-begin against a tag whose
103
128
  // on-disk state still has stranded files from a prior crash.
@@ -27,6 +27,7 @@
27
27
  // predicate can't match a row a concurrent upload just refreshed.
28
28
 
29
29
  import { type Handle, STAGING_TTL_MS_DEFAULT, isValidContentHash, isValidStagingId, isValidTag } from './store.ts'
30
+ import { debugId, debugTag } from '../util.ts'
30
31
 
31
32
  type StagingRow = {
32
33
  workspace_tag: string
@@ -67,12 +68,19 @@ type StagingRow = {
67
68
  // live-set re-read, not via mutual exclusion.
68
69
  async function gcBlobIfUnreferenced(
69
70
  handle: Handle, tag: string, hash: string, modifiedMs: number, now: number, grace: number,
70
- ): Promise<void> {
71
- if (!isValidContentHash(hash)) return
72
- if (now - modifiedMs < grace) return
71
+ ): Promise<boolean> {
72
+ if (!isValidContentHash(hash)) return false
73
+ if (now - modifiedMs < grace) return false
73
74
  const refs = await liveHashSet(handle, tag)
74
- if (refs.has(hash)) return
75
+ if (refs.has(hash)) return false
75
76
  await handle.blob.unlinkLive(tag, hash)
77
+ // Log EVERY live-blob deletion unconditionally (not behind `debug`):
78
+ // this is the only record that the GC removed bytes. A handful per
79
+ // sweep is normal (superseded versions aging out); a burst across many
80
+ // workspaces is the smoking gun for the "all uploaded data went
81
+ // missing" failure — pair it with the sweep summary in `reapOrphans`.
82
+ console.warn(`objstore-reaper: GC live blob ${debugTag(tag)}/${debugId(hash)} (unreferenced, age ${Math.round((now - modifiedMs) / 1000)}s ≥ grace ${Math.round(grace / 1000)}s)`)
83
+ return true
76
84
  }
77
85
 
78
86
  // The set of content hashes referenced by the workspace's live rows.
@@ -86,18 +94,22 @@ async function liveHashSet(handle: Handle, tag: string): Promise<Set<string>> {
86
94
  // snapshot we read up front can race a concurrent commit; the
87
95
  // reference re-read inside `gcBlobIfUnreferenced` (plus the grace
88
96
  // window) ensures we never unlink a blob a live row names.
89
- async function reapUnreferencedForTag(handle: Handle, tag: string, now: number, grace: number): Promise<void> {
90
- if (!isValidTag(tag)) return
97
+ // Returns the number of live blobs GC'd for this tag, so `reapOrphans`
98
+ // can surface a sweep-wide total (the headline signal for mass loss).
99
+ async function reapUnreferencedForTag(handle: Handle, tag: string, now: number, grace: number): Promise<number> {
100
+ if (!isValidTag(tag)) return 0
91
101
  const blobs = await handle.blob.listLiveBlobs(tag)
92
- if (blobs.length === 0) return
102
+ if (blobs.length === 0) return 0
93
103
  const referenced = await liveHashSet(handle, tag)
104
+ let gc = 0
94
105
  for (const { hash, modifiedMs } of blobs) {
95
106
  if (!isValidContentHash(hash)) continue
96
107
  // Referenced in our snapshot → skip the grace + re-read path
97
108
  // entirely; only unreferenced blobs need it.
98
109
  if (referenced.has(hash)) continue
99
- await gcBlobIfUnreferenced(handle, tag, hash, modifiedMs, now, grace)
110
+ if (await gcBlobIfUnreferenced(handle, tag, hash, modifiedMs, now, grace)) gc++
100
111
  }
112
+ return gc
101
113
  }
102
114
 
103
115
  // Drop staging rows older than the TTL and unlink their on-storage
@@ -172,9 +184,10 @@ export async function reapOrphans(handle: Handle, stagingTtlMs: number = STAGING
172
184
  const grace = stagingTtlMs
173
185
  // Pass 1: tags the live table knows about — GC unreferenced live
174
186
  // blobs (past the grace window) against the referenced-hash set.
187
+ let liveGc = 0
175
188
  const liveTagsRows = await handle.listLiveTags.all()
176
189
  const liveTags = liveTagsRows.map((r) => r.workspace_tag)
177
- for (const tag of liveTags) await reapUnreferencedForTag(handle, tag, now, grace)
190
+ for (const tag of liveTags) liveGc += await reapUnreferencedForTag(handle, tag, now, grace)
178
191
  // Whole-workspace deletes leave residue (dirs / blob-prefixes) the
179
192
  // live table no longer lists. Walk the backend's top-level workspace
180
193
  // listing to find them; for each straggler tag, GC its unreferenced
@@ -186,7 +199,14 @@ export async function reapOrphans(handle: Handle, stagingTtlMs: number = STAGING
186
199
  const liveSet = new Set(liveTags)
187
200
  for (const tag of topLevel) {
188
201
  if (liveSet.has(tag) || !isValidTag(tag)) continue
189
- await reapUnreferencedForTag(handle, tag, now, grace)
202
+ liveGc += await reapUnreferencedForTag(handle, tag, now, grace)
203
+ }
204
+ // Sweep-wide total. A nonzero count means the GC deleted live bytes
205
+ // this pass — logged unconditionally so "all data went missing"
206
+ // leaves an obvious server-side trail (a large count over a short
207
+ // window is the signature). Per-blob lines above carry which/why.
208
+ if (liveGc > 0) {
209
+ console.warn(`objstore-reaper: swept ${liveTags.length} live + ${topLevel.length} store tag(s); GC'd ${liveGc} live blob(s)`)
190
210
  }
191
211
  // Pass 2: stale staging rows + orphan staging blobs. The orphan
192
212
  // sweep does per-blob row lookups (no caller-side snapshot), so a