@preventive/triage 1.0.0-alpha.1 → 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 (56) 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 +16 -13
  8. package/out/graph.js +5 -4
  9. package/out/index.html +4 -15
  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 -52
  14. package/package.json +70 -54
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +5 -1
  17. package/{server → server-e2e}/bus-receiver.ts +8 -8
  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 +31 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +96 -135
  24. package/{server → server-e2e}/http.ts +97 -9
  25. package/{server → server-e2e}/hub.ts +7 -8
  26. package/{server → server-e2e}/index.ts +67 -43
  27. package/{server → server-e2e}/lifecycle.ts +14 -5
  28. package/{server → server-e2e}/npm-proxy.ts +1 -1
  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 +13 -15
  34. package/{server → server-e2e}/objstore/init.ts +47 -13
  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 +110 -93
  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 → server-e2e}/pubsub.ts +21 -31
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/{server → server-e2e}/sse-server.ts +105 -73
  47. package/{server → server-e2e}/sse-session.ts +30 -16
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/{server → server-e2e}/sync-handlers.ts +172 -117
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/{server → server-e2e}/ws-server.ts +29 -23
  52. package/strip-types-loader.js +94 -0
  53. /package/{server → server-e2e}/config.example.json +0 -0
  54. /package/{server → server-e2e}/neon-driver.ts +0 -0
  55. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  56. /package/{server → server-e2e}/validation.ts +0 -0
@@ -41,20 +41,20 @@ 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 { errStack } from '../util.ts'
44
+ import { deny, denyConflict } from './rest-deny.ts'
45
+ import { handleRestMint } from './rest-mint.ts'
46
+ import { debugId, debugTag, errMsg, errStack } from '../util.ts'
45
47
 
46
48
  // Server-side fault codes that should surface as 500 `io-error`
47
49
  // rather than 400 `aborted`. `pipeline(req, ws)` rejects with the
48
- // first stream error; the client-side codes (socket close, the
49
- // manual `overrun`) are everything else. ENOENT is included because
50
- // `createWriteStream` will reject with it if the `${tag}/.staging`
51
- // dir was removed out from under us (operator action / external
52
- // fs activity) — that's a server-side state, not a client-fixable
53
- // abort. PR #4 review. The Vercel-blob backend surfaces failures
54
- // through other paths (rejected put promise → caught by the
55
- // pipeline catch as a plain Error without a `code`); those land in
56
- // the default `aborted` branch and the operator-facing log line
57
- // carries the SDK's error message.
50
+ // first stream error; client-side codes (socket close, the manual
51
+ // `overrun`) are everything else. ENOENT is included because
52
+ // `createWriteStream` rejects with it if the `${tag}/.staging` dir
53
+ // was removed out from under us (operator / external fs activity) —
54
+ // a server-side state, not a client-fixable abort. Vercel-blob
55
+ // failures arrive without a `code` (rejected put promise → plain
56
+ // Error); those land in the default `aborted` branch, with the SDK
57
+ // error in the operator log line.
58
58
  const IO_FAULT_CODES = new Set(['ENOSPC', 'EACCES', 'EROFS', 'EIO', 'EMFILE', 'ENFILE', 'EDQUOT', 'EPERM', 'ENOENT'])
59
59
 
60
60
  export type ObjstoreRestDeps = {
@@ -68,38 +68,49 @@ export type ObjstoreRestDeps = {
68
68
  // workspace_object for the full metadata (version, hash, length,
69
69
  // signature). SQLite mode passes a no-op.
70
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>
71
83
  debug: boolean
72
84
  }
73
85
 
74
- // Concurrent commits need no lock at all: the live blob is content-
75
- // addressed (`${tag}/${contentHash}.bin`) so two racing commits write
76
- // to DIFFERENT immutable addresses, and the commit itself is an atomic
77
- // version compare-and-set on the live row (see commitPut in store.ts).
78
- // Exactly one racer wins the CAS; the loser gets a 409 `conflict` and
79
- // rebases. This holds within a single process and across replicas —
80
- // there is no in-process mutex serialising commits. (The PUT *body*
81
- // does take a per-process single-writer reservation — `inFlightSids`
82
- // below — but that only rejects a duplicate upload of one staging
83
- // slot; it never makes distinct commits wait.)
86
+ // Concurrent commits need no lock: the live blob is content-addressed
87
+ // (`${tag}/${contentHash}.bin`) so two racing commits write to
88
+ // DIFFERENT immutable addresses, and the commit is an atomic version
89
+ // compare-and-set on the live row (see commitPut in store.ts). Exactly
90
+ // one racer wins the CAS; the loser gets a 409 `conflict` and rebases.
91
+ // Holds within a process and across replicas — no in-process mutex
92
+ // serialises commits. (The PUT *body* takes a per-process single-
93
+ // writer reservation — `inFlightSids` below — but that only rejects a
94
+ // duplicate upload of one staging slot; distinct commits never wait.)
84
95
 
85
96
  // Per-process single-writer guard for the REST PUT body. A put-token is
86
97
  // a REUSABLE bearer capability (tokens.ts) valid for its whole TTL, so a
87
- // client replaying it on overlapping PUTs — a retry that doesn't cancel
88
- // the in-flight request, a proxy re-issuing the PUT, a double-submit —
98
+ // client replaying it on overlapping PUTs (a retry that doesn't cancel
99
+ // the in-flight request, a proxy re-issuing the PUT, a double-submit)
89
100
  // would otherwise have two requests stream into the SAME staging file
90
- // (same sid). On the FS backend `createWriteStream(…, { flags: 'w' })`
91
- // truncates, so the two writers clobber each other's bytes/size and BOTH
92
- // can fail (size-mismatch 400 + promote-race 500) with neither
93
- // committing. `inFlightSids` admits exactly ONE in-flight upload per
94
- // staging id; a concurrent same-token PUT is rejected (409) before it
95
- // can open a second writer. The slot is held only across the body +
96
- // commit and released in a `finally` (and bounded by the REST idle-body
97
- // timeout in server/http.ts so a slow-loris can't pin it). It is NOT a
98
- // commit lock — commits stay lock-free on the version-CAS above. It's
99
- // per-process: the Vercel multi-replica path leans on `allowOverwrite:
100
- // true` + the commit CAS + content-addressing for cross-replica
101
- // correctness (a duplicate that lands on another replica can't clobber a
102
- // shared local file and still loses the CAS), not on this set.
101
+ // (same sid). On FS, `createWriteStream(…, { flags: 'w' })` truncates,
102
+ // so the two writers clobber each other's bytes/size and BOTH can fail
103
+ // (size-mismatch 400 + promote-race 500) with neither committing.
104
+ // `inFlightSids` admits exactly ONE in-flight upload per staging id; a
105
+ // concurrent same-token PUT is rejected (409) before it can open a
106
+ // second writer. The slot is held only across body + commit, released
107
+ // in a `finally`, and bounded by the REST idle-body timeout in
108
+ // server-e2e/http.ts so a slow-loris can't pin it. NOT a commit lock —
109
+ // commits stay lock-free on the version-CAS above. Per-process: the
110
+ // Vercel multi-replica path leans on `allowOverwrite: true` + the
111
+ // commit CAS + content-addressing for cross-replica correctness (a
112
+ // duplicate landing on another replica can't clobber a shared local
113
+ // file and still loses the CAS), not on this set.
103
114
  const inFlightSids = new Set<string>()
104
115
 
105
116
  // `/api/objstore/${workspaceTag}/${resourceTag}` — base64url
@@ -119,35 +130,21 @@ export function matchRoute(url: string | undefined): RouteMatch | null {
119
130
  return { tag: tag!, resourceTag: resourceTag! }
120
131
  }
121
132
 
122
- function deny(res: ServerResponse, status: number, body: string): void {
123
- // Uniform `{ error: <reason> }` JSON envelope for every failure so
124
- // clients have one shape to parse. Status + reason are NOT
125
- // intentionally indistinguishable across causes — 401, 404, 405,
126
- // 410, 411, 500 each map to a documented reason in server/README.md
127
- // and the client decides recovery from the code. Defense against
128
- // probe-driven distinguishing isn't a property the relay aims for;
129
- // every error reason is reachable only after the route + bearer-
130
- // token check passes (or as 401/404 from the public surface), so
131
- // there's no signal here a probe couldn't otherwise enumerate.
132
- res.writeHead(status, { 'content-type': 'application/json' })
133
- res.end(JSON.stringify({ error: body }))
134
- }
135
-
136
- // Variant of `deny` that augments the JSON envelope with the live
137
- // row's `currentVersion` + `currentIncarnation` so a REST PUT 409 lets
138
- // the caller rebase onto the right precondition token. Without this the
139
- // client only learns the slot is occupied — not at what (version,
140
- // incarnation) — and retries blindly against a non-empty slot, looping
141
- // indefinitely against a live row. Symmetric with the WS plane's
142
- // `objstore-conflict` envelope.
143
- function denyConflict(res: ServerResponse, currentVersion: number | null, currentIncarnation: string | null): void {
144
- res.writeHead(409, { 'content-type': 'application/json' })
145
- res.end(JSON.stringify({ error: 'conflict', currentVersion, currentIncarnation }))
146
- }
147
-
148
133
  export async function handleRest(deps: ObjstoreRestDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
149
134
  const route = matchRoute(req.url)
150
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
+ }
151
148
  const token = extractBearer(req.headers['authorization'])
152
149
  if (!token) { deny(res, 401, 'unauthorized'); return }
153
150
  const payload = verifyToken(deps.secret, token)
@@ -209,21 +206,20 @@ async function handleRestPut(
209
206
  // No lock: the commit's version-CAS arbitrates concurrent commits
210
207
  // (the live blob is content-addressed, so racers can't desync
211
208
  // metadata vs bytes — the loser surfaces a 409 `conflict`). The
212
- // staging row is protected from the reaper not by a lock but by its
213
- // `begun_at`: an upload under the staging TTL stays fresh through the
214
- // body, and the after-body `refreshStagingBegunAt` re-extends the
215
- // TTL across the commit. (An upload exceeding the TTL during the
216
- // body can be reaped mid-flight → commit 410s; documented accepted
217
- // tradeoff — see commitPut in store.ts.)
209
+ // staging row is protected from the reaper by its `begun_at`, not a
210
+ // lock: an upload under the staging TTL stays fresh through the body,
211
+ // and the after-body `refreshStagingBegunAt` re-extends the TTL across
212
+ // the commit. (An upload exceeding the TTL during the body can be
213
+ // reaped mid-flight → commit 410s; documented accepted tradeoff —
214
+ // see commitPut in store.ts.)
218
215
  await handleRestPutBody(deps, req, res, route, payload, declared)
219
216
  }
220
217
 
221
218
  // Map a `commitPut` failure to its wire response. Extracted from
222
- // `handleRestPutBody` to keep it under the per-function line cap
223
- // and to make the wire-mapping ladder its own audit surface — the
224
- // exhaustiveness `never` guard at the bottom catches a forward-
225
- // compat hazard where a new `CommitPutResult` reason lands without
226
- // updating this dispatch.
219
+ // `handleRestPutBody` to keep it under the per-function line cap and
220
+ // to make the wire-mapping ladder its own audit surface — the
221
+ // exhaustiveness `never` guard at the bottom catches a new
222
+ // `CommitPutResult` reason landing without updating this dispatch.
227
223
  function denyCommitFailure(res: ServerResponse, result: Exclude<CommitPutResult, { ok: true }>): void {
228
224
  if (result.reason === 'conflict') {
229
225
  denyConflict(res, result.conflict?.version ?? null, result.conflict?.incarnation ?? null)
@@ -273,10 +269,9 @@ async function handleRestPutBody(
273
269
  }
274
270
  inFlightSids.add(payload.sid)
275
271
  try {
276
- // Stream the upload + commit, no lock. The commit's version-CAS
277
- // arbitrates concurrent commits; the staging row is kept fresh for
278
- // the reaper by its `begun_at` (+ the after-body refresh), not by a
279
- // mutex. See the rationale in handleRestPut above.
272
+ // Stream the upload + commit, no lock — version-CAS arbitrates
273
+ // commits, the staging row stays fresh for the reaper via its
274
+ // `begun_at` (+ after-body refresh). See handleRestPut above.
280
275
  const result = await runUploadAndCommit(deps, route, payload, declared, req, res)
281
276
  if (result.handled) return
282
277
  if (!result.commit.ok) { denyCommitFailure(res, result.commit); return }
@@ -294,15 +289,13 @@ async function handleRestPutBody(
294
289
  ...objectMetaWire(row),
295
290
  }, null)
296
291
  // Cross-instance fan-out (Neon mode). The bus payload carries only
297
- // (tag, resourceTag); peers on other instances re-fetch the live
298
- // row from workspace_object to compose their local broadcast. The
299
- // committed row is durable by here (commitPut's version-CAS already
300
- // landed), so the receiver typically sees either THIS version or a
301
- // STRICTLY newer one (also a valid broadcast — clients are
302
- // idempotent on (resourceTag, version)). The receiver is allowed
303
- // to find no live row at all if a subsequent delete races the
304
- // notification; bus-receiver.ts drops that case silently. SQLite
305
- // mode publishes to a no-op.
292
+ // (tag, resourceTag); peers on other instances re-fetch the live row
293
+ // from workspace_object to compose their broadcast. The committed
294
+ // row is durable by here (commitPut's version-CAS landed), so the
295
+ // receiver sees THIS version or a STRICTLY newer one (also valid —
296
+ // clients are idempotent on (resourceTag, version)), or no live row
297
+ // at all if a subsequent delete races the notification (bus-
298
+ // receiver.ts drops that silently). SQLite mode publishes to a no-op.
306
299
  deps.publishObjPut(route.tag, route.resourceTag)
307
300
  if (deps.debug) console.log(`objstore put → ${route.tag.slice(0, 12)}…/${route.resourceTag.slice(0, 8)}… v${row.version}`)
308
301
  } finally {
@@ -439,7 +432,10 @@ async function runUploadAndCommit(
439
432
  type GetOpened =
440
433
  | { reason: 'ok'; reader: LiveReader }
441
434
  | { reason: 'not-found' }
442
- | { reason: 'unavailable' }
435
+ // `detail` is a short non-sensitive cause tag (backend sub-reason +
436
+ // content-hash prefix) the GET handler logs so a 503 is diagnosable —
437
+ // every byte-side failure collapses to the same wire 503 otherwise.
438
+ | { reason: 'unavailable'; detail: string }
443
439
 
444
440
  async function openLiveSnapshot(
445
441
  deps: ObjstoreRestDeps, route: RouteMatch, payload: { ver: number; inc: string },
@@ -451,22 +447,32 @@ async function openLiveSnapshot(
451
447
  // bytes behind `live.content_hash` can never change underneath us —
452
448
  // the worst a race can do is have the reaper GC an already-superseded
453
449
  // hash just before we open it, which surfaces as openLiveReader
454
- // not-found → `unavailable` → 503, and the client refetches. We can
450
+ // returning `unavailable` → 503, and the client refetches. We can
455
451
  // never serve torn or wrong bytes. (For the FS backend the open also
456
452
  // returns a pinned fd; for the Vercel backend a fetch-backed stream.)
457
453
  const live = await deps.handle.selectLiveOne.get(route.tag, route.resourceTag)
458
454
  if (!live || live.version !== payload.ver || live.incarnation !== payload.inc) return { reason: 'not-found' }
455
+ // Tag the content hash into every `unavailable` detail so an operator
456
+ // can go check the byte store directly for THIS blob (gone → reaper /
457
+ // deletion; present → transient read fault).
458
+ const hashTag = `hash=${debugId(live.content_hash)}`
459
459
  let opened
460
460
  try { opened = await deps.handle.blob.openLiveReader(route.tag, live.content_hash) }
461
- catch { return { reason: 'unavailable' } }
462
- if (!opened.ok) return { reason: opened.reason }
461
+ // Length-cap the thrown error text: today a non-BlobNotFound SDK throw
462
+ // (BlobServiceNotAvailable / store-not-found) carries no credential
463
+ // (the RW token rides the Authorization header, never `.message`), but
464
+ // a future SDK could embed a signed URL / token fragment — bound the
465
+ // log line so it can't dump one verbatim. Mirrors the `.slice(0, 200)`
466
+ // cap used on SDK error text in blob-vercel.ts.
467
+ catch (err) { return { reason: 'unavailable', detail: `open-threw ${hashTag} ${String(errMsg(err)).slice(0, 200)}` } }
468
+ if (!opened.ok) return { reason: 'unavailable', detail: `${opened.detail ?? 'backend'} ${hashTag}` }
463
469
  // Size mismatch between the live row and the on-storage bytes
464
470
  // is a transient inconsistency — reaper will reconcile. Close
465
471
  // the reader before returning so we don't leak the fd / fetch
466
472
  // reader. PR #4 review H8.
467
473
  if (opened.reader.size !== live.content_length) {
468
474
  await opened.reader.close().catch(() => {})
469
- return { reason: 'unavailable' }
475
+ return { reason: 'unavailable', detail: `size-mismatch row=${live.content_length} blob=${opened.reader.size} ${hashTag}` }
470
476
  }
471
477
  return { reason: 'ok', reader: opened.reader }
472
478
  }
@@ -486,8 +492,19 @@ async function handleRestGet(
486
492
  // If the live row is there but the bytes are missing / wrong size,
487
493
  // it's a transient inconsistency the reaper will sort out — 503
488
494
  // (vs 404) tells the client this is a server-side state, not a
489
- // "the resource truly isn't there" answer.
490
- if (opened.reason === 'unavailable') { deny(res, 503, 'unavailable'); return }
495
+ // "the resource truly isn't there" answer. Log the cause
496
+ // UNCONDITIONALLY (not behind `debug`): a 503 means a live row whose
497
+ // bytes can't be served, and the wire response can't distinguish a
498
+ // permanent loss (reaper GC'd referenced bytes) from a transient read
499
+ // fault. `detail` carries the backend sub-reason + content-hash prefix
500
+ // so an operator can tell which — the only server-side breadcrumb for
501
+ // the "all data turned into 503" failure. (Volume is bounded: a 503 is
502
+ // an error path; a workspace-wide outage is exactly when these are
503
+ // wanted.)
504
+ if (opened.reason === 'unavailable') {
505
+ console.warn(`objstore-get: 503 unavailable ${debugTag(route.tag)}/${route.resourceTag.slice(0, 8)}… v${payload.ver} ${opened.detail}`)
506
+ deny(res, 503, 'unavailable'); return
507
+ }
491
508
  res.writeHead(200, {
492
509
  'content-type': 'application/octet-stream',
493
510
  'content-length': String(opened.reader.size),
@@ -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
+ }
@@ -4,7 +4,7 @@
4
4
  // dialect on the wire.
5
5
  //
6
6
  // `@neondatabase/serverless` is an OPTIONAL peer dep — selected by
7
- // the `DATABASE_URL` branch in `server/index.ts`. The peer dep
7
+ // the `DATABASE_URL` branch in `server-e2e/index.ts`. The peer dep
8
8
  // itself is loaded lazily inside `openNeonObjstore` below. A single
9
9
  // Neon project / database holds both `workspace_revision` and the
10
10
  // two objstore tables; each plane opens its own stateless
@@ -13,7 +13,7 @@
13
13
  // release.
14
14
  //
15
15
  // Byte plane: passed in as a `BlobBackend` (./blob.ts). The
16
- // supported pairings selected by server/index.ts are:
16
+ // supported pairings selected by server-e2e/index.ts are:
17
17
  // - Neon + Vercel Blob Private Storage (multi-replica, the only
18
18
  // pairing that survives a replica restart without local-disk
19
19
  // coordination). Activated by setting BLOB_READ_WRITE_TOKEN
@@ -46,10 +46,10 @@ const DDL_LOCK_KEY_OBJSTORE_SUB = 0x6f62_6a73 // 'objs'
46
46
  // defend the commitPut conflict arithmetic — a manual `UPDATE
47
47
  // workspace_object SET version = -1` would otherwise round-trip
48
48
  // through `num()` (which only rejects non-safe-integers) and corrupt
49
- // the version monotonicity invariant. The SQLite schema carries the
50
- // identical CHECKs (see `store.ts`) — STRICT there enforces the column
51
- // TYPE but NOT this value domain (`version = -1` is a valid integer
52
- // STRICT accepts), so both backends need the explicit CHECKs.
49
+ // version monotonicity. SQLite carries the identical CHECKs (see
50
+ // `store.ts`): STRICT there enforces column TYPE but NOT this value
51
+ // domain (`version = -1` is a valid integer STRICT accepts), so both
52
+ // backends need the explicit CHECKs.
53
53
  const SCHEMA_PG = [
54
54
  `CREATE TABLE IF NOT EXISTS workspace_object (
55
55
  workspace_tag TEXT NOT NULL,
@@ -121,7 +121,7 @@ type StagingRow = {
121
121
 
122
122
  function buildSelectStaging(sql: NeonSql): GetStmt<[string, string, string], StagingRow> {
123
123
  return { get: async (tag, resourceTag, stagingId) => {
124
- const rows = await sql(
124
+ const rows = await sql.query(
125
125
  `SELECT prev_version, prev_incarnation, expected_length, content_hash, signature, begun_at
126
126
  FROM workspace_object_staging
127
127
  WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3`,
@@ -162,7 +162,7 @@ function buildDeleteStaging(sql: NeonSql): RunStmt<[string, string, string]> {
162
162
  // Bind order: (tag, res, sid, staleBefore).
163
163
  function buildDeleteStagingIfStale(sql: NeonSql): GetStmt<[string, string, string, number], { ok: number }> {
164
164
  return { get: async (tag, resourceTag, stagingId, staleBefore) => {
165
- const rows = await sql(
165
+ const rows = await sql.query(
166
166
  `DELETE FROM workspace_object_staging
167
167
  WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3 AND begun_at < $4
168
168
  RETURNING 1 AS ok`,
@@ -175,7 +175,7 @@ function buildDeleteStagingIfStale(sql: NeonSql): GetStmt<[string, string, strin
175
175
 
176
176
  function buildSelectLive(sql: NeonSql): AllStmt<[string], LiveDbRow> {
177
177
  return { all: async (tag) => {
178
- const rows = await sql(
178
+ const rows = await sql.query(
179
179
  `SELECT resource_tag, version, incarnation, content_hash, content_length,
180
180
  signature, put_at
181
181
  FROM workspace_object
@@ -189,7 +189,7 @@ function buildSelectLive(sql: NeonSql): AllStmt<[string], LiveDbRow> {
189
189
 
190
190
  function buildSelectLiveOne(sql: NeonSql): GetStmt<[string, string], LiveDbRow> {
191
191
  return { get: async (tag, resourceTag) => {
192
- const rows = await sql(
192
+ const rows = await sql.query(
193
193
  `SELECT resource_tag, version, incarnation, content_hash, content_length,
194
194
  signature, put_at
195
195
  FROM workspace_object
@@ -208,7 +208,7 @@ function buildSelectLiveOne(sql: NeonSql): GetStmt<[string, string], LiveDbRow>
208
208
  // (tag, res, hash, len, sig, put_at); version is the literal 1.
209
209
  function buildInsertLiveIfAbsent(sql: NeonSql): GetStmt<[string, string, string, string, number, string, number], { ok: number }> {
210
210
  return { get: async (tag, resourceTag, incarnation, contentHash, contentLength, signature, putAt) => {
211
- const rows = await sql(
211
+ const rows = await sql.query(
212
212
  `INSERT INTO workspace_object
213
213
  (workspace_tag, resource_tag, version, incarnation, content_hash, content_length,
214
214
  signature, put_at)
@@ -230,7 +230,7 @@ function buildInsertLiveIfAbsent(sql: NeonSql): GetStmt<[string, string, string,
230
230
  // (tag, res, nextVersion, hash, len, sig, put_at, expectedVersion).
231
231
  function buildUpdateLiveCAS(sql: NeonSql): GetStmt<[string, string, number, string, number, string, number, number, string], { ok: number }> {
232
232
  return { get: async (tag, resourceTag, nextVersion, contentHash, contentLength, signature, putAt, expectedVersion, expectedIncarnation) => {
233
- const rows = await sql(
233
+ const rows = await sql.query(
234
234
  `UPDATE workspace_object
235
235
  SET version = $3,
236
236
  content_hash = $4,
@@ -252,7 +252,7 @@ function buildUpdateLiveCAS(sql: NeonSql): GetStmt<[string, string, number, stri
252
252
  // not-found, never a lost update.
253
253
  function buildDeleteLiveCAS(sql: NeonSql): GetStmt<[string, string, number, string], { ok: number }> {
254
254
  return { get: async (tag, resourceTag, expectedVersion, expectedIncarnation) => {
255
- const rows = await sql(
255
+ const rows = await sql.query(
256
256
  `DELETE FROM workspace_object
257
257
  WHERE workspace_tag = $1 AND resource_tag = $2 AND version = $3 AND incarnation = $4
258
258
  RETURNING 1 AS ok`,
@@ -268,7 +268,7 @@ function buildListAllStaging(sql: NeonSql): AllStmt<[number], { workspace_tag: s
268
268
  // `WHERE begun_at < $1` uses workspace_object_staging_begun_at_idx
269
269
  // so the reaper sweep is O(stale-rows) cluster-wide. DB-layout
270
270
  // audit follow-up.
271
- const rows = await sql(
271
+ const rows = await sql.query(
272
272
  `SELECT workspace_tag, resource_tag, staging_id, begun_at
273
273
  FROM workspace_object_staging
274
274
  WHERE begun_at < $1`,
@@ -285,7 +285,7 @@ function buildListAllStaging(sql: NeonSql): AllStmt<[number], { workspace_tag: s
285
285
 
286
286
  function buildListLiveTags(sql: NeonSql): AllStmt<[], { workspace_tag: string }> {
287
287
  return { all: async () => {
288
- const rows = await sql(
288
+ const rows = await sql.query(
289
289
  `SELECT DISTINCT workspace_tag FROM workspace_object`,
290
290
  [],
291
291
  ) as Array<Record<string, unknown>>
@@ -295,7 +295,7 @@ function buildListLiveTags(sql: NeonSql): AllStmt<[], { workspace_tag: string }>
295
295
 
296
296
  function buildCountLive(sql: NeonSql): GetStmt<[string], { c: number }> {
297
297
  return { get: async (tag) => {
298
- const rows = await sql(
298
+ const rows = await sql.query(
299
299
  `SELECT COUNT(*) AS c FROM workspace_object WHERE workspace_tag = $1`,
300
300
  [tag],
301
301
  ) as Array<{ c: number | string | bigint }>
@@ -309,7 +309,7 @@ export async function openNeonObjstore(connectionString: string, blob: BlobBacke
309
309
  // local `../neon-driver.ts` re-export wrapper so the peer dep stays
310
310
  // optional AND tests can mock the driver (a local path is always
311
311
  // resolvable; the bare specifier isn't when the dep is absent). See
312
- // `server/neon-driver.ts`. Cast through `unknown` because the
312
+ // `server-e2e/neon-driver.ts`. Cast through `unknown` because the
313
313
  // wrapper's `export *` re-exports a `@ts-ignore`'d module.
314
314
  const mod = (await import('../neon-driver.ts')) as unknown as { neon: (url: string) => NeonSql }
315
315
  const sql: NeonSql = mod.neon(connectionString)
@@ -324,8 +324,8 @@ export async function openNeonObjstore(connectionString: string, blob: BlobBacke
324
324
  // advisory lock so two replicas booting concurrently serialize
325
325
  // their DDL (the advisory lock releases at COMMIT).
326
326
  await sql.transaction([
327
- sql(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_OBJSTORE, DDL_LOCK_KEY_OBJSTORE_SUB]),
328
- ...SCHEMA_PG.map((stmt) => sql(stmt, [])),
327
+ sql.query(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_OBJSTORE, DDL_LOCK_KEY_OBJSTORE_SUB]),
328
+ ...SCHEMA_PG.map((stmt) => sql.query(stmt, [])),
329
329
  ])
330
330
 
331
331
  return {