@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/api/reap.ts +79 -0
  2. package/common/save-error-reason.ts +20 -7
  3. package/common/server-info.ts +30 -0
  4. package/out/brotli-fallback.js +1 -1
  5. package/out/client-admin.js +28 -0
  6. package/out/client-managed.js +1 -0
  7. package/out/client-sync.js +17 -10
  8. package/out/graph.js +5 -4
  9. package/out/index.html +43 -38
  10. package/out/prism.js +2 -2
  11. package/out/terminal.js +32 -28
  12. package/out/view.css +1 -1
  13. package/out/view.js +78 -51
  14. package/package.json +70 -49
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +16 -1
  17. package/server-e2e/bus-receiver.ts +95 -0
  18. package/server-e2e/cli.js +22 -0
  19. package/{server → server-e2e}/config.ts +21 -8
  20. package/{server → server-e2e}/db-neon.ts +41 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +15 -9
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +113 -135
  24. package/server-e2e/http.ts +266 -0
  25. package/{server → server-e2e}/hub.ts +27 -8
  26. package/{server → server-e2e}/index.ts +185 -52
  27. package/{server → server-e2e}/lifecycle.ts +36 -5
  28. package/server-e2e/npm-proxy.ts +348 -0
  29. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  30. package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
  31. package/{server → server-e2e}/objstore/blob.ts +24 -9
  32. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  33. package/{server → server-e2e}/objstore/handlers.ts +25 -15
  34. package/{server → server-e2e}/objstore/init.ts +52 -12
  35. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  36. package/server-e2e/objstore/rest-deny.ts +28 -0
  37. package/server-e2e/objstore/rest-mint.ts +224 -0
  38. package/{server → server-e2e}/objstore/rest.ts +119 -84
  39. package/{server → server-e2e}/objstore/sign.ts +105 -0
  40. package/{server → server-e2e}/objstore/store-neon.ts +19 -19
  41. package/{server → server-e2e}/objstore/store.ts +98 -118
  42. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  43. package/{server → server-e2e}/peer.ts +7 -9
  44. package/server-e2e/pubsub.ts +394 -0
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/server-e2e/sse-server.ts +384 -0
  47. package/server-e2e/sse-session.ts +216 -0
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/server-e2e/sync-handlers.ts +382 -0
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/server-e2e/ws-server.ts +276 -0
  52. package/strip-types-loader.js +94 -0
  53. package/server/http.ts +0 -142
  54. package/server/sync-handlers.ts +0 -311
  55. package/server/ws-server.ts +0 -245
  56. /package/{server → server-e2e}/config.example.json +0 -0
  57. /package/{server → server-e2e}/neon-driver.ts +0 -0
  58. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  59. /package/{server → server-e2e}/validation.ts +0 -0
@@ -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
+ }
@@ -45,6 +45,12 @@ export type ObjstoreDeps = {
45
45
  secret: TokenSecret
46
46
  send: (socket: WebSocket, msg: object) => void
47
47
  broadcast: (tag: string, msg: object, except: WebSocket | null) => void
48
+ // Cross-instance pub/sub for objstore-deleted. Fired alongside the
49
+ // local `broadcast` after a successful delete so peers on OTHER
50
+ // server instances see the version drop in real time. Carries the
51
+ // full (tag, resourceTag, version) tuple inline — the workspace_object
52
+ // row is gone post-delete, so the bus payload IS the wire data.
53
+ publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
48
54
  getNonce: (socket: WebSocket) => string | undefined
49
55
  debug: boolean
50
56
  // Auth gate for the FIRST put-begin against a never-before-seen
@@ -62,11 +68,11 @@ function urlPathFor(tag: string, resourceTag: string): string {
62
68
  // Shared gate every objstore handler runs after its message-specific
63
69
  // field checks: fetch the socket's challenge nonce, verify the signed
64
70
  // message against it, then re-confirm the socket is still OPEN — the
65
- // close handler may have fired during the verify await, and attaching
66
- // to / replying on a closed socket is the half-handshake leak case
67
- // (PR #4 review F4). Returns true when the caller may proceed.
68
- // Centralising the post-await readyState recheck keeps that
69
- // 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.
70
76
  async function verified<M extends { workspaceTag?: unknown }>(
71
77
  deps: ObjstoreDeps, socket: WebSocket, msg: M, label: string,
72
78
  verify: (m: M, nonce: string) => Promise<boolean>,
@@ -90,22 +96,20 @@ async function handlePutBegin(deps: ObjstoreDeps, socket: WebSocket, msg: Objsto
90
96
  // signature to fail. Cheaper to reject up-front, and consistent
91
97
  // with `verifyObjstorePutSig`'s `isSafeNonNegativeInt` gate.
92
98
  if (!Number.isSafeInteger(msg.expectedLength) || (msg.expectedLength as number) < 0 || (msg.expectedLength as number) > MAX_CONTENT_LENGTH) return
93
- // Symmetric with `handleDelete`'s prevVersion gate (line 116) and
94
- // `verifyObjstorePutSig`'s `isSafeIntOrNull` (sign.ts:119). Without
95
- // this, a non-safe-integer `prevVersion` (NaN, 2^53+1, ...) would
96
- // pass the typeof check below and reach sig verify, burning a
97
- // hash + Ed25519 round-trip on a guaranteed-fail input. Input-
98
- // 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.
99
103
  if (msg.prevVersion != null && (typeof msg.prevVersion !== 'number' || !Number.isSafeInteger(msg.prevVersion))) return
100
104
  if (!await verified(deps, socket, msg, 'put-begin', verifyObjstorePutSig)) return
101
105
  const tag = msg.workspaceTag
102
106
  const resourceTag = msg.resourceTag
103
107
  // Auth gate for the FIRST action against a never-before-seen
104
108
  // workspace tag (no rows in workspace_revision AND none in
105
- // workspace_object). Mirrors handleSave in server/index.ts; runs
109
+ // workspace_object). Mirrors handleSave in server-e2e/index.ts; runs
106
110
  // AFTER sig verify so `unauthorized` only reaches a legitimate
107
- // signer. The gate is config-driven (server/config.json
108
- // `password`) and is a no-op when no password is configured.
111
+ // signer. Config-driven (server-e2e/config.json `password`), no-op when
112
+ // unconfigured.
109
113
  if (deps.authGate && deps.sendUnauthorized && await deps.authGate(socket, tag)) {
110
114
  if (socket.readyState !== socket.OPEN) return
111
115
  if (deps.debug) console.warn(`reject objstore-put-begin: unauthorized (new workspace ${debugTag(tag)})`)
@@ -188,6 +192,12 @@ async function handleDelete(deps: ObjstoreDeps, socket: WebSocket, msg: Objstore
188
192
  // `onDeleted` lets `session.onDeleted` fire for the session's
189
193
  // own deletes — pinned by `tests/objstore-client-races.test.js`.
190
194
  deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion }, null)
195
+ // Cross-instance fan-out (Neon mode). The workspace_object row is
196
+ // gone post-delete so the bus payload carries (tag, resourceTag,
197
+ // version) inline; the receiver builds its `objstore-deleted`
198
+ // broadcast directly from the bus envelope. SQLite mode publishes
199
+ // to a no-op.
200
+ deps.publishObjDeleted(tag, resourceTag, result.deletedVersion)
191
201
  if (deps.debug) console.log(`objstore delete → ${debugTag(tag)}/${resourceTag.slice(0, 8)}…`)
192
202
  }
193
203
 
@@ -218,7 +228,7 @@ async function handleFetch(deps: ObjstoreDeps, socket: WebSocket, msg: ObjstoreF
218
228
  // the reaper's TTL pass within `STAGING_TTL_MS_DEFAULT` — no per-
219
229
  // socket bookkeeping is needed on disconnect today. If that ever
220
230
  // changes, wire a `cleanupSocket(socket)` into both this bundle and
221
- // server/index.ts's close handler.
231
+ // server-e2e/index.ts's close handler.
222
232
 
223
233
  export type ObjstoreHandlers = {
224
234
  handlePutBegin: (s: WebSocket, m: ObjstorePutBeginMsg) => Promise<void>
@@ -15,11 +15,22 @@ import { errStack } from '../util.ts'
15
15
 
16
16
  export type ObjstoreInitDeps = {
17
17
  // Pre-opened storage handle, sharing the SQLite connection with
18
- // the workspace_revision handle in server/db.ts.
18
+ // the workspace_revision handle in server-e2e/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
29
+ // Cross-instance pub/sub publishers. SQLite mode passes no-ops; Neon
30
+ // mode passes Postgres LISTEN/NOTIFY-backed implementations. See
31
+ // server-e2e/pubsub.ts for the bus design.
32
+ publishObjPut: (tag: string, resourceTag: string) => void
33
+ publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
23
34
  getNonce: (socket: WebSocket) => string | undefined
24
35
  debug: boolean
25
36
  // Auth gate for the FIRST objstore-put-begin against a workspace
@@ -34,9 +45,15 @@ export type ObjstoreInitDeps = {
34
45
  // process behaviour). Multi-replica deployments MUST supply a
35
46
  // shared secret here so a token minted on one replica's WS plane
36
47
  // validates on another replica's REST plane (the WS-to-REST hop
37
- // is not load-balancer-pinned). See server/index.ts boot logic
48
+ // is not load-balancer-pinned). See server-e2e/index.ts boot logic
38
49
  // for the env var (`OBJSTORE_TOKEN_SECRET`).
39
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>
40
57
  }
41
58
 
42
59
  export type ObjstoreInit = {
@@ -51,17 +68,31 @@ export type ObjstoreInit = {
51
68
  }
52
69
 
53
70
  export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
71
+ // Fail loud on a lopsided auth config. The put-begin gate in
72
+ // handlers.ts only fires when BOTH authGate and sendUnauthorized are
73
+ // present (it needs the reporter to emit the `unauthorized` frame),
74
+ // so wiring authGate WITHOUT sendUnauthorized silently fails OPEN —
75
+ // unauthenticated first-writes to unknown workspaces would be accepted
76
+ // despite the operator's intent to gate them. Reject at boot rather
77
+ // than regress access control silently.
78
+ if (deps.authGate && !deps.sendUnauthorized) {
79
+ throw new Error('initObjstore: authGate requires sendUnauthorized (the put-begin gate needs it to emit the unauthorized frame)')
80
+ }
54
81
  const handle = deps.handle
55
82
  const secret = deps.tokenSecret ?? newTokenSecret()
56
83
  const handlers = createObjstoreHandlers({
57
84
  handle, secret,
58
85
  send: deps.send, broadcast: deps.broadcast,
86
+ publishObjDeleted: deps.publishObjDeleted,
59
87
  getNonce: deps.getNonce, debug: deps.debug,
60
88
  ...(deps.authGate ? { authGate: deps.authGate } : {}),
61
89
  ...(deps.sendUnauthorized ? { sendUnauthorized: deps.sendUnauthorized } : {}),
62
90
  })
63
91
  const restDeps: ObjstoreRestDeps = {
64
- handle, secret, broadcast: deps.broadcast, 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,
65
96
  }
66
97
  // Re-entrancy guard for periodic + startup sweeps. Kicking the
67
98
  // startup sweep through the same `enqueueSweep` path means the
@@ -82,20 +113,29 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
82
113
  inFlight = p
83
114
  return p
84
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
+ }
85
126
  // Caller awaits this before accepting traffic so a fresh boot
86
127
  // can't hand out list / fetch / put-begin against a tag whose
87
128
  // on-disk state still has stranded files from a prior crash.
88
129
  const startupReap = enqueueSweep()
89
130
  // Jittered start of the periodic timer. Multi-replica deploys
90
- // (Neon + Vercel Blob) commonly boot N replicas in tight lock-
91
- // step (deploy rollout, cluster restart) and would otherwise
92
- // sync every replica's reaper at the same wall-clock tick,
93
- // hammering the DB + blob store with N×readdir+lock-acquire
94
- // bursts. A random first-interval delay deconcurrencies the
95
- // cluster without changing the long-term sweep cadence.
96
- // Jitter range is 0…1× reapIntervalMs (i.e., the next sweep
97
- // happens at [interval, 2×interval] after boot); subsequent
98
- // sweeps stay at exactly `reapIntervalMs` apart.
131
+ // (Neon + Vercel Blob) commonly boot N replicas in tight lock-step
132
+ // (deploy rollout, cluster restart), which would otherwise sync every
133
+ // replica's reaper to the same wall-clock tick and hammer the DB +
134
+ // blob store with N×readdir+lock-acquire bursts. A random
135
+ // first-interval delay spreads the cluster out without changing the
136
+ // long-term cadence. Jitter is 0…1× reapIntervalMs (first sweep at
137
+ // [interval, 2×interval] after boot); subsequent sweeps stay exactly
138
+ // `reapIntervalMs` apart.
99
139
  let reapTimer: ReturnType<typeof setInterval> | null = null
100
140
  const jitterMs = Math.floor(Math.random() * deps.reapIntervalMs)
101
141
  const firstTimer = setTimeout(() => {
@@ -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
@@ -109,7 +121,7 @@ async function reapStaleStagingRows(handle: Handle, now: number, stagingTtlMs: n
109
121
  // Push the staleness filter into SQL so the
110
122
  // `workspace_object_staging_begun_at_idx` index handles the scan.
111
123
  // The snapshot is O(stale-rows) cluster-wide. DB-layout audit
112
- // `server/objstore/store.ts`.
124
+ // `server-e2e/objstore/store.ts`.
113
125
  const staleBefore = now - stagingTtlMs
114
126
  const staging = await handle.listAllStaging.all(staleBefore) as StagingRow[]
115
127
  for (const s of staging) {
@@ -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
@@ -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-e2e/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
+ }
@@ -0,0 +1,224 @@
1
+ // REST endpoints for the objstore plane — the single-round-trip,
2
+ // SSE-session-independent alternative to the WS `objstore-fetch` /
3
+ // `objstore-put-begin` / `objstore-delete` handshakes (so these ops can't
4
+ // be interrupted by an SSE replica hop). One route, `POST
5
+ // /api/objstore/{tag}/{res}`, with a JSON body `{ op, ts, signature, ... }`:
6
+ // the workspace signs the request (the workspaceTag IS the Ed25519 pubkey,
7
+ // so verification is self-contained — no socket, no stored key), the server
8
+ // verifies it + freshness/replay-guards (the connection-nonce stand-in).
9
+ // fetch/put return the SAME token shape the WS path sends, for the UNCHANGED
10
+ // bearer-token GET / PUT byte transfers; delete mutates in place and returns
11
+ // `{ deletedVersion }` (no token — there are no bytes to move). See
12
+ // server-e2e/README.md "REST endpoints & tokens".
13
+
14
+ import type { IncomingMessage, ServerResponse } from 'node:http'
15
+ import { Buffer } from 'node:buffer'
16
+
17
+ import type { ObjstoreRestDeps, RouteMatch } from './rest.ts'
18
+ import { deny, denyConflict } from './rest-deny.ts'
19
+ import { MAX_CONTENT_LENGTH, beginPut, deleteObject, getLive, isValidContentHash, isValidIncarnation, isValidSignature, objectMetaWire } from './store.ts'
20
+ import { mintGetToken, mintPutToken } from './tokens.ts'
21
+ import { verifyObjstoreDeleteRestSig, verifyObjstoreFetchRestSig, verifyObjstorePutBeginRestSig } from './sign.ts'
22
+ import { createFetchMintGuard } from './fetch-mint-guard.ts'
23
+
24
+ // Per-process freshness + replay guard for the REST POSTs (fetch, put-begin,
25
+ // delete). They have no connection nonce to bind, so they bind a client
26
+ // timestamp — see ./fetch-mint-guard.ts. The three ops' signatures are
27
+ // globally unique (distinct canonical domains), so one guard dedups all three.
28
+ const mintGuard = createFetchMintGuard()
29
+
30
+ // Hard cap on a mint JSON body. The put-begin body (op, ts, signature,
31
+ // prevVersion, prevIncarnation, expectedLength, contentHash) is the larger
32
+ // of the two and still only a few hundred bytes; 4 KiB is comfortable
33
+ // headroom. Bounds the read so a hostile client can't stream an unbounded
34
+ // body into memory before the parse.
35
+ const MINT_BODY_MAX = 4096
36
+
37
+ // Read a small JSON request body up to `maxBytes`, returning the parsed
38
+ // value or null on overflow / parse failure / read error. Used only by
39
+ // the mint POST; the PUT body is the raw blob and streams to disk via a
40
+ // different path.
41
+ async function readJsonBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
42
+ const chunks: Buffer[] = []
43
+ let total = 0
44
+ try {
45
+ for await (const chunk of req) {
46
+ const buf = chunk as Buffer
47
+ total += buf.length
48
+ if (total > maxBytes) return null
49
+ chunks.push(buf)
50
+ }
51
+ } catch { return null }
52
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) }
53
+ catch { return null }
54
+ }
55
+
56
+ // Extract + range-check the auth fields common to both mint ops. Gates the
57
+ // signature to the wire shape (`isValidSignature`, matching the WS plane) so
58
+ // an obviously-malformed sig is rejected up front rather than burning an
59
+ // Ed25519 verify — and, since this runs before `mintGuard.admit`, a bad sig
60
+ // never reaches the replay cache regardless.
61
+ function parseMintAuth(body: object): { ts: number; signature: string } | null {
62
+ const ts = (body as { ts?: unknown }).ts
63
+ const signature = (body as { signature?: unknown }).signature
64
+ if (!Number.isSafeInteger(ts) || (ts as number) < 0) return null
65
+ if (!isValidSignature(signature)) return null
66
+ return { ts: ts as number, signature }
67
+ }
68
+
69
+ // Dispatch the mint by body `op`. Auth-proxy friendly: the signature rides
70
+ // the JSON body (not a header or cookie, so it never collides with a
71
+ // cookie-based proxy), and the response is JSON — no redirect, no token in
72
+ // any URL. Client note: a replayed signature is rejected, so a retry MUST
73
+ // re-sign with a fresh `ts` rather than resend the same body.
74
+ export async function handleRestMint(
75
+ deps: ObjstoreRestDeps, req: IncomingMessage, res: ServerResponse, route: RouteMatch,
76
+ ): Promise<void> {
77
+ const body = await readJsonBody(req, MINT_BODY_MAX)
78
+ if (!body || typeof body !== 'object') { deny(res, 400, 'bad-request'); return }
79
+ const op = (body as { op?: unknown }).op
80
+ if (op === 'fetch') { await handleRestFetchMint(deps, res, route, body); return }
81
+ if (op === 'put') { await handleRestPutBegin(deps, res, route, body); return }
82
+ if (op === 'delete') { await handleRestDelete(deps, res, route, body); return }
83
+ deny(res, 400, 'bad-request')
84
+ }
85
+
86
+ // op:'fetch' — mirrors the WS `objstore-fetch` → `objstore-fetch-token`
87
+ // handshake; returns `{ ...meta, urlPath, token, expiresAt }` for the
88
+ // UNCHANGED bearer-token GET.
89
+ async function handleRestFetchMint(
90
+ deps: ObjstoreRestDeps, res: ServerResponse, route: RouteMatch, body: object,
91
+ ): Promise<void> {
92
+ const auth = parseMintAuth(body)
93
+ if (!auth) { deny(res, 400, 'bad-request'); return }
94
+ // Verify the signature BEFORE touching the replay guard so a bad-sig
95
+ // request can't consume cache space. The signature commits to THIS `ts`.
96
+ if (!await verifyObjstoreFetchRestSig(route.tag, route.resourceTag, auth.ts, auth.signature)) {
97
+ deny(res, 401, 'unauthorized'); return
98
+ }
99
+ // Freshness window + single-use dedup (the connection-nonce stand-in).
100
+ // 'stale'/'replay' are both opaque 401s — the client re-signs with a
101
+ // fresh `ts` and retries.
102
+ if (mintGuard.admit(auth.signature, auth.ts) !== 'ok') { deny(res, 401, 'unauthorized'); return }
103
+ const row = await getLive(deps.handle, route.tag, route.resourceTag)
104
+ if (!row) { deny(res, 404, 'not-found'); return }
105
+ const { token, exp } = mintGetToken(deps.secret, route.tag, route.resourceTag, row.version, row.incarnation)
106
+ res.writeHead(200, { 'content-type': 'application/json' })
107
+ res.end(JSON.stringify({
108
+ ...objectMetaWire(row),
109
+ urlPath: `/api/objstore/${route.tag}/${route.resourceTag}`,
110
+ token,
111
+ expiresAt: exp,
112
+ }))
113
+ }
114
+
115
+ // op:'put' — mirrors the WS `handlePutBegin`: verify the signed put fields,
116
+ // freshness/replay-guard, the new-workspace operator gate, then `beginPut`
117
+ // (advisory prev-check + staging row) and mint the put-token. Returns
118
+ // `{ stagingId, urlPath, token, expiresAt }` for the UNCHANGED bearer-token
119
+ // PUT; `409 conflict` (with current version/incarnation to rebase on) or
120
+ // `403 workspace-full` map `beginPut`'s refusals.
121
+ //
122
+ // New-workspace gate: REST can't read a socket's operator-auth state, so
123
+ // when a password is configured AND the workspace is never-before-seen this
124
+ // returns 401 — the client falls back to the in-band WS put-begin (which
125
+ // runs the operator auth flow). No-op for password-less deployments or
126
+ // existing workspaces (the common case).
127
+ async function handleRestPutBegin(
128
+ deps: ObjstoreRestDeps, res: ServerResponse, route: RouteMatch, body: object,
129
+ ): Promise<void> {
130
+ const auth = parseMintAuth(body)
131
+ if (!auth) { deny(res, 400, 'bad-request'); return }
132
+ // Field gates mirror `handlePutBegin`'s up-front rejects.
133
+ const expectedLength = (body as { expectedLength?: unknown }).expectedLength
134
+ if (!Number.isSafeInteger(expectedLength) || (expectedLength as number) < 0 || (expectedLength as number) > MAX_CONTENT_LENGTH) {
135
+ deny(res, 400, 'bad-request'); return
136
+ }
137
+ const prevVersionRaw = (body as { prevVersion?: unknown }).prevVersion
138
+ if (prevVersionRaw != null && (typeof prevVersionRaw !== 'number' || !Number.isSafeInteger(prevVersionRaw))) {
139
+ deny(res, 400, 'bad-request'); return
140
+ }
141
+ const prevVersion = typeof prevVersionRaw === 'number' ? prevVersionRaw : null
142
+ const prevIncarnationRaw = (body as { prevIncarnation?: unknown }).prevIncarnation
143
+ const prevIncarnation = typeof prevIncarnationRaw === 'string' ? prevIncarnationRaw : null
144
+ // prevVersion/prevIncarnation travel as a null-iff-null pair (matches the
145
+ // WS `validPrevPair`): a half-pair is malformed.
146
+ if ((prevVersion === null) !== (prevIncarnation === null)) { deny(res, 400, 'bad-request'); return }
147
+ // A non-null incarnation must be the wire shape (matches the WS path's
148
+ // `isValidIncarnation` gate). Signature-covered + CAS-checked regardless,
149
+ // but reject the obviously-malformed up front.
150
+ if (prevIncarnation !== null && !isValidIncarnation(prevIncarnation)) { deny(res, 400, 'bad-request'); return }
151
+ const contentHash = (body as { contentHash?: unknown }).contentHash
152
+ if (!isValidContentHash(contentHash)) { deny(res, 400, 'bad-request'); return }
153
+
154
+ const fields = {
155
+ workspaceTag: route.tag, resourceTag: route.resourceTag,
156
+ prevVersion, prevIncarnation, contentHash, expectedLength: expectedLength as number,
157
+ }
158
+ if (!await verifyObjstorePutBeginRestSig(fields, auth.ts, auth.signature)) { deny(res, 401, 'unauthorized'); return }
159
+ if (mintGuard.admit(auth.signature, auth.ts) !== 'ok') { deny(res, 401, 'unauthorized'); return }
160
+ // New-workspace operator gate — see the function header. Refusing here
161
+ // routes the client to its in-band WS put-begin fallback.
162
+ if (await deps.restPutGate(route.tag)) { deny(res, 401, 'unauthorized'); return }
163
+ const result = await beginPut(deps.handle, {
164
+ workspaceTag: route.tag, resourceTag: route.resourceTag,
165
+ prevVersion, prevIncarnation, expectedLength: expectedLength as number,
166
+ contentHash, signature: auth.signature,
167
+ })
168
+ if (!result.ok) {
169
+ if (result.reason === 'workspace-full') { deny(res, 403, 'workspace-full'); return }
170
+ denyConflict(res, result.conflict?.version ?? null, result.conflict?.incarnation ?? null); return
171
+ }
172
+ const { token, exp } = mintPutToken(deps.secret, route.tag, route.resourceTag, result.stagingId, expectedLength as number)
173
+ res.writeHead(200, { 'content-type': 'application/json' })
174
+ res.end(JSON.stringify({
175
+ stagingId: result.stagingId,
176
+ urlPath: `/api/objstore/${route.tag}/${route.resourceTag}`,
177
+ token,
178
+ expiresAt: exp,
179
+ }))
180
+ }
181
+
182
+ // op:'delete' — mirrors the WS `handleDelete`: verify the signed delete
183
+ // fields, freshness/replay-guard, then `deleteObject` (a precondition-checked
184
+ // version-CAS drop). Unlike put, there is NO operator gate — delete is
185
+ // signature-gated, idempotent, and creates nothing (the WS handleDelete has
186
+ // no authGate either). Returns 200 `{ deletedVersion }` (0 = idempotent
187
+ // no-op on a missing row with a null precondition); 409 conflict (stale
188
+ // prevVersion/incarnation); 404 not-found (a non-null precondition against a
189
+ // missing row). On a real drop it broadcasts `objstore-deleted` to
190
+ // subscribers (+ cross-instance), exactly like the WS path.
191
+ async function handleRestDelete(
192
+ deps: ObjstoreRestDeps, res: ServerResponse, route: RouteMatch, body: object,
193
+ ): Promise<void> {
194
+ const auth = parseMintAuth(body)
195
+ if (!auth) { deny(res, 400, 'bad-request'); return }
196
+ const prevVersionRaw = (body as { prevVersion?: unknown }).prevVersion
197
+ if (prevVersionRaw != null && (typeof prevVersionRaw !== 'number' || !Number.isSafeInteger(prevVersionRaw))) {
198
+ deny(res, 400, 'bad-request'); return
199
+ }
200
+ const prevVersion = typeof prevVersionRaw === 'number' ? prevVersionRaw : null
201
+ const prevIncarnationRaw = (body as { prevIncarnation?: unknown }).prevIncarnation
202
+ const prevIncarnation = typeof prevIncarnationRaw === 'string' ? prevIncarnationRaw : null
203
+ // null-iff-null pair (matches the WS `validPrevPair`); a non-null
204
+ // incarnation must be the wire shape.
205
+ if ((prevVersion === null) !== (prevIncarnation === null)) { deny(res, 400, 'bad-request'); return }
206
+ if (prevIncarnation !== null && !isValidIncarnation(prevIncarnation)) { deny(res, 400, 'bad-request'); return }
207
+
208
+ const fields = { workspaceTag: route.tag, resourceTag: route.resourceTag, prevVersion, prevIncarnation }
209
+ if (!await verifyObjstoreDeleteRestSig(fields, auth.ts, auth.signature)) { deny(res, 401, 'unauthorized'); return }
210
+ if (mintGuard.admit(auth.signature, auth.ts) !== 'ok') { deny(res, 401, 'unauthorized'); return }
211
+ const result = await deleteObject(deps.handle, route.tag, route.resourceTag, prevVersion, prevIncarnation)
212
+ if (!result.ok) {
213
+ if (result.reason === 'conflict') { denyConflict(res, result.conflict?.version ?? null, result.conflict?.incarnation ?? null); return }
214
+ deny(res, 404, 'not-found'); return
215
+ }
216
+ res.writeHead(200, { 'content-type': 'application/json' })
217
+ res.end(JSON.stringify({ deletedVersion: result.deletedVersion }))
218
+ // deletedVersion 0 = nothing was live → nothing to broadcast. A real drop
219
+ // fans out to subscribers (including the originator, matching the WS path's
220
+ // `except: null`) so peers' `onDeleted` fire.
221
+ if (result.deletedVersion === 0) return
222
+ deps.broadcast(route.tag, { type: 'objstore-deleted', workspaceTag: route.tag, resourceTag: route.resourceTag, version: result.deletedVersion }, null)
223
+ deps.publishObjDeleted(route.tag, route.resourceTag, result.deletedVersion)
224
+ }