@preventive/triage 1.0.0-alpha.4 → 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.4",
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
@@ -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.
@@ -41,6 +41,8 @@ import {
41
41
  } from './store.ts'
42
42
  import type { LiveReader } from './blob.ts'
43
43
  import { type TokenSecret, extractBearer, verifyToken } from './tokens.ts'
44
+ import { deny, denyConflict } from './rest-deny.ts'
45
+ import { handleRestMint } from './rest-mint.ts'
44
46
  import { debugId, debugTag, errMsg, errStack } from '../util.ts'
45
47
 
46
48
  // Server-side fault codes that should surface as 500 `io-error`
@@ -66,6 +68,18 @@ export type ObjstoreRestDeps = {
66
68
  // workspace_object for the full metadata (version, hash, length,
67
69
  // signature). SQLite mode passes a no-op.
68
70
  publishObjPut: (tag: string, resourceTag: string) => void
71
+ // Cross-instance pub/sub for objstore-deleted — fired alongside the local
72
+ // `broadcast` after a successful REST delete mint, so peers on OTHER
73
+ // instances drop the resource in real time. Carries (tag, resourceTag,
74
+ // version) inline (the row is gone post-delete). SQLite mode passes a no-op.
75
+ publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
76
+ // New-workspace operator gate for the REST put-begin mint — the
77
+ // connection-independent analog of the WS path's `authGate`. Returns
78
+ // `true` to DENY (a password is configured AND the workspace is
79
+ // never-before-seen), which routes the client to its in-band WS
80
+ // put-begin fallback (REST has no socket auth state to consult).
81
+ // No-config default is open (never deny), matching the WS authGate.
82
+ restPutGate: (workspaceTag: string) => Promise<boolean>
69
83
  debug: boolean
70
84
  }
71
85
 
@@ -116,34 +130,21 @@ export function matchRoute(url: string | undefined): RouteMatch | null {
116
130
  return { tag: tag!, resourceTag: resourceTag! }
117
131
  }
118
132
 
119
- function deny(res: ServerResponse, status: number, body: string): void {
120
- // Uniform `{ error: <reason> }` JSON envelope for every failure so
121
- // clients parse one shape. Status + reason are NOT intentionally
122
- // indistinguishable across causes — 401/404/405/410/411/500 each map
123
- // to a documented reason in server/README.md and the client decides
124
- // recovery from the code. Probe-distinguishing defense isn't a goal:
125
- // every reason is reachable only after the route + bearer-token check
126
- // passes (or as 401/404 from the public surface), so a probe gains no
127
- // signal it couldn't otherwise enumerate.
128
- res.writeHead(status, { 'content-type': 'application/json' })
129
- res.end(JSON.stringify({ error: body }))
130
- }
131
-
132
- // Variant of `deny` that augments the JSON envelope with the live
133
- // row's `currentVersion` + `currentIncarnation` so a REST PUT 409 lets
134
- // the caller rebase onto the right precondition token. Without this the
135
- // client only learns the slot is occupied — not at what (version,
136
- // incarnation) — and retries blindly against a non-empty slot, looping
137
- // indefinitely against a live row. Symmetric with the WS plane's
138
- // `objstore-conflict` envelope.
139
- function denyConflict(res: ServerResponse, currentVersion: number | null, currentIncarnation: string | null): void {
140
- res.writeHead(409, { 'content-type': 'application/json' })
141
- res.end(JSON.stringify({ error: 'conflict', currentVersion, currentIncarnation }))
142
- }
143
-
144
133
  export async function handleRest(deps: ObjstoreRestDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
145
134
  const route = matchRoute(req.url)
146
135
  if (!route) { deny(res, 404, 'not-found'); return }
136
+ // POST = REST mint (fetch or put-begin, by body `op`; signature-authed
137
+ // via the JSON body, no bearer token). Dispatched before the bearer-token
138
+ // gate below, which guards the token-authed GET/PUT byte transfers.
139
+ if (req.method === 'POST') {
140
+ try { await handleRestMint(deps, req, res, route) }
141
+ catch (err: unknown) {
142
+ if (deps.debug) console.warn('objstore POST error:', errStack(err))
143
+ if (res.headersSent) res.destroy()
144
+ else deny(res, 500, 'internal')
145
+ }
146
+ return
147
+ }
147
148
  const token = extractBearer(req.headers['authorization'])
148
149
  if (!token) { deny(res, 401, 'unauthorized'); return }
149
150
  const payload = verifyToken(deps.secret, token)
@@ -10,6 +10,18 @@ import { isValidIncarnation } from './store.ts'
10
10
  const OBJSTORE_PUT_DOMAIN = 'deepview-objstore.v1.put'
11
11
  const OBJSTORE_DELETE_DOMAIN = 'deepview-objstore.v1.delete'
12
12
  const OBJSTORE_FETCH_DOMAIN = 'deepview-objstore.v1.fetch'
13
+ // REST fetch-mint domain — MUST match the client's FETCH_REST_DOMAIN
14
+ // (client/sync/objstore-crypto.ts). Distinct from OBJSTORE_FETCH_DOMAIN so
15
+ // a WS-fetch signature can't be replayed against the REST mint endpoint.
16
+ const OBJSTORE_FETCH_REST_DOMAIN = 'deepview-objstore.v1.fetch-rest'
17
+ // REST put-begin domain — MUST match the client's PUT_REST_DOMAIN. Distinct
18
+ // from OBJSTORE_PUT_DOMAIN so a WS put-begin signature can't be replayed
19
+ // against the REST mint endpoint.
20
+ const OBJSTORE_PUT_REST_DOMAIN = 'deepview-objstore.v1.put-rest'
21
+ // REST delete domain — MUST match the client's DELETE_REST_DOMAIN. Distinct
22
+ // from OBJSTORE_DELETE_DOMAIN so a WS delete signature can't be replayed
23
+ // against the REST mint endpoint.
24
+ const OBJSTORE_DELETE_REST_DOMAIN = 'deepview-objstore.v1.delete-rest'
13
25
 
14
26
  // Wire shapes the verifiers accept. Fields land here post-
15
27
  // `JSON.parse`, so every value starts life as `unknown` — strict
@@ -95,6 +107,38 @@ function canonicalObjstoreFetch(msg: ObjstoreFetchMsg, connectionNonce: string):
95
107
  ].join('\n'))
96
108
  }
97
109
 
110
+ // REST fetch-mint canonical. Binds a client epoch-ms timestamp (string-
111
+ // encoded to match the client) in place of the connection nonce; the REST
112
+ // handler enforces the freshness window + replay dedup.
113
+ function canonicalObjstoreFetchRest(workspaceTag: string, resourceTag: string, ts: number): Uint8Array<ArrayBuffer> {
114
+ return encodeUtf8([
115
+ OBJSTORE_FETCH_REST_DOMAIN,
116
+ workspaceTag,
117
+ resourceTag,
118
+ String(ts),
119
+ ].join('\n'))
120
+ }
121
+
122
+ // REST put-begin canonical — the WS `canonicalObjstorePut` fields in the
123
+ // same order/coercion (intOrEmpty / strOrEmpty), under the put-rest domain
124
+ // and binding the client `ts` in place of the connection nonce.
125
+ function canonicalObjstorePutRest(
126
+ workspaceTag: string, resourceTag: string,
127
+ prevVersion: number | null, prevIncarnation: string | null,
128
+ contentHash: string, expectedLength: number, ts: number,
129
+ ): Uint8Array<ArrayBuffer> {
130
+ return encodeUtf8([
131
+ OBJSTORE_PUT_REST_DOMAIN,
132
+ workspaceTag,
133
+ resourceTag,
134
+ intOrEmpty(prevVersion),
135
+ strOrEmpty(prevIncarnation),
136
+ contentHash,
137
+ String(expectedLength),
138
+ String(ts),
139
+ ].join('\n'))
140
+ }
141
+
98
142
  // `Number.isSafeInteger` rather than `Number.isInteger`: JSON numbers
99
143
  // are IEEE-754 and integers above 2^53-1 aren't precisely
100
144
  // representable. Accepting non-safe integers would let a signed
@@ -162,3 +206,64 @@ export function verifyObjstoreFetchSig(msg: ObjstoreFetchMsg, connectionNonce: u
162
206
  if (typeof msg.resourceTag !== 'string') return Promise.resolve(false)
163
207
  return verifyObjstoreSig(msg, connectionNonce, (nonce) => canonicalObjstoreFetch(msg, nonce))
164
208
  }
209
+
210
+ // Verify a REST fetch-mint signature. Unlike the WS verifiers this takes
211
+ // the already-validated fields directly (the REST handler parsed +
212
+ // range-checked `ts` and `signature`) rather than a wire `msg` + socket
213
+ // nonce. The workspaceTag IS the Ed25519 public key, so verification is
214
+ // fully self-contained — no session or stored key needed. A throw from
215
+ // the canonical build (lone surrogate, etc.) is treated as a verify
216
+ // failure, never an escaping exception.
217
+ export function verifyObjstoreFetchRestSig(
218
+ workspaceTag: string, resourceTag: string, ts: number, signature: string,
219
+ ): Promise<boolean> {
220
+ let payload: Uint8Array<ArrayBuffer>
221
+ try { payload = canonicalObjstoreFetchRest(workspaceTag, resourceTag, ts) }
222
+ catch { return Promise.resolve(false) }
223
+ return verifyEd25519(workspaceTag, payload, signature)
224
+ }
225
+
226
+ // Verify a REST put-begin signature. Same self-contained shape as
227
+ // `verifyObjstoreFetchRestSig` — the caller (rest.ts) has already
228
+ // range-checked the put fields + `ts`. The workspaceTag IS the pubkey.
229
+ export function verifyObjstorePutBeginRestSig(
230
+ fields: { workspaceTag: string; resourceTag: string; prevVersion: number | null; prevIncarnation: string | null; contentHash: string; expectedLength: number },
231
+ ts: number, signature: string,
232
+ ): Promise<boolean> {
233
+ let payload: Uint8Array<ArrayBuffer>
234
+ try {
235
+ payload = canonicalObjstorePutRest(
236
+ fields.workspaceTag, fields.resourceTag, fields.prevVersion, fields.prevIncarnation,
237
+ fields.contentHash, fields.expectedLength, ts,
238
+ )
239
+ } catch { return Promise.resolve(false) }
240
+ return verifyEd25519(fields.workspaceTag, payload, signature)
241
+ }
242
+
243
+ // REST delete-mint canonical. WS `canonicalObjstoreDelete` fields, under the
244
+ // delete-rest domain, binding the client `ts` in place of the nonce.
245
+ function canonicalObjstoreDeleteRest(
246
+ workspaceTag: string, resourceTag: string,
247
+ prevVersion: number | null, prevIncarnation: string | null, ts: number,
248
+ ): Uint8Array<ArrayBuffer> {
249
+ return encodeUtf8([
250
+ OBJSTORE_DELETE_REST_DOMAIN,
251
+ workspaceTag,
252
+ resourceTag,
253
+ intOrEmpty(prevVersion),
254
+ strOrEmpty(prevIncarnation),
255
+ String(ts),
256
+ ].join('\n'))
257
+ }
258
+
259
+ // Verify a REST delete-mint signature. Self-contained (workspaceTag IS the
260
+ // pubkey); the caller (rest.ts) has range-checked `ts` + the prev pair.
261
+ export function verifyObjstoreDeleteRestSig(
262
+ fields: { workspaceTag: string; resourceTag: string; prevVersion: number | null; prevIncarnation: string | null },
263
+ ts: number, signature: string,
264
+ ): Promise<boolean> {
265
+ let payload: Uint8Array<ArrayBuffer>
266
+ try { payload = canonicalObjstoreDeleteRest(fields.workspaceTag, fields.resourceTag, fields.prevVersion, fields.prevIncarnation, ts) }
267
+ catch { return Promise.resolve(false) }
268
+ return verifyEd25519(fields.workspaceTag, payload, signature)
269
+ }