@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.6

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.4",
3
+ "version": "1.0.0-alpha.6",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -54,10 +54,13 @@
54
54
  "server/objstore/blob-fs.ts",
55
55
  "server/objstore/blob-vercel.ts",
56
56
  "server/objstore/blob.ts",
57
+ "server/objstore/fetch-mint-guard.ts",
57
58
  "server/objstore/fs.ts",
58
59
  "server/objstore/handlers.ts",
59
60
  "server/objstore/init.ts",
60
61
  "server/objstore/reaper.ts",
62
+ "server/objstore/rest-deny.ts",
63
+ "server/objstore/rest-mint.ts",
61
64
  "server/objstore/rest.ts",
62
65
  "server/objstore/sign.ts",
63
66
  "server/objstore/store-neon.ts",
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
@@ -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
+ }
@@ -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.
@@ -0,0 +1,28 @@
1
+ // Shared failure-response helpers for the objstore REST plane
2
+ // (./rest.ts + ./rest-mint.ts). A uniform `{ error: <reason> }` JSON
3
+ // envelope so clients parse one shape; the 409 variant additionally
4
+ // carries the live version/incarnation so a PUT / put-begin can rebase.
5
+ // Reasons (400/401/403/404/405/409/410/411/500/503) are documented in
6
+ // server/README.md and the client decides recovery from the code —
7
+ // probe-distinguishing defense isn't a goal: the richer PUT/GET reasons are
8
+ // reachable only after the bearer-token check, while the public surfaces (an
9
+ // unauthed GET/PUT, or the signature-authed POST mint) expose only
10
+ // 400/401/404 — none of which gives a probe signal it couldn't already
11
+ // enumerate.
12
+
13
+ import type { ServerResponse } from 'node:http'
14
+
15
+ export function deny(res: ServerResponse, status: number, body: string): void {
16
+ res.writeHead(status, { 'content-type': 'application/json' })
17
+ res.end(JSON.stringify({ error: body }))
18
+ }
19
+
20
+ // Variant of `deny` that augments the envelope with the live row's
21
+ // `currentVersion` + `currentIncarnation` so a 409'd PUT / put-begin can
22
+ // rebase onto the right precondition token rather than retry blindly
23
+ // against an occupied slot. Symmetric with the WS plane's
24
+ // `objstore-conflict` frame.
25
+ export function denyConflict(res: ServerResponse, currentVersion: number | null, currentIncarnation: string | null): void {
26
+ res.writeHead(409, { 'content-type': 'application/json' })
27
+ res.end(JSON.stringify({ error: 'conflict', currentVersion, currentIncarnation }))
28
+ }