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

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 (183) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/api/reap.ts +17 -0
  3. package/cli.js +6 -0
  4. package/client/finding-link.js +305 -0
  5. package/client/linked-findings.d.ts +1 -0
  6. package/client/linked-findings.js +111 -0
  7. package/common/bundle-commit.d.ts +1 -0
  8. package/common/bundle-commit.js +4 -0
  9. package/common/bundle-metadata.d.ts +10 -0
  10. package/common/bundle-metadata.js +182 -0
  11. package/common/bundle-reasons.d.ts +2 -0
  12. package/common/bundle-reasons.js +21 -0
  13. package/common/bundle-source-links.d.ts +5 -0
  14. package/common/bundle-source-links.js +82 -0
  15. package/common/bundle-sources.d.ts +3 -0
  16. package/common/bundle-sources.js +284 -0
  17. package/common/bundle-stats.js +41 -0
  18. package/common/bundle-tabs.js +1 -0
  19. package/common/code-language.js +51 -0
  20. package/common/default-scan-models.ts +30 -0
  21. package/common/finding-id.js +47 -0
  22. package/common/github-issue-labels.d.ts +1 -0
  23. package/common/github-issue-labels.js +11 -0
  24. package/common/github-pr.ts +56 -0
  25. package/common/managed/comments.ts +34 -0
  26. package/common/managed/permissions.ts +35 -0
  27. package/common/managed/report-content.ts +42 -0
  28. package/common/managed/report-filter.ts +108 -0
  29. package/common/managed/roles.ts +28 -0
  30. package/common/managed/routes.d.ts +2 -0
  31. package/common/managed/routes.js +124 -0
  32. package/common/managed/scan-models.ts +6 -0
  33. package/common/managed/triage.ts +83 -0
  34. package/common/save-error-reason.ts +20 -7
  35. package/common/scan-server.ts +13 -0
  36. package/common/server-info.ts +35 -0
  37. package/common/utf8.d.ts +3 -0
  38. package/common/utf8.js +45 -0
  39. package/out/brotli-fallback.js +3 -3
  40. package/out/client-managed-import.js +81 -0
  41. package/out/client-managed.js +167 -0
  42. package/out/client-sync.js +16 -13
  43. package/out/github-issue-authorized.html +3 -0
  44. package/out/github-issue-authorized.js +1 -0
  45. package/out/graph.js +30 -4
  46. package/out/index.html +55 -8
  47. package/out/prism.js +2 -2
  48. package/out/stasis.svg +20 -0
  49. package/out/terminal.js +273 -39
  50. package/out/view.css +1 -1
  51. package/out/view.js +259 -63
  52. package/package.json +205 -57
  53. package/server-common/database-config.ts +16 -0
  54. package/server-common/initialize.ts +18 -0
  55. package/server-common/npm-advisories.ts +101 -0
  56. package/{server → server-common}/origin.ts +5 -5
  57. package/server-common/reap.ts +48 -0
  58. package/server-common/scan-config.ts +19 -0
  59. package/server-common/standalone.ts +29 -0
  60. package/server-common/storage-crypto.ts +182 -0
  61. package/server-common/storage-log.ts +38 -0
  62. package/server-common/vercel-blob.ts +102 -0
  63. package/server-e2e/app.ts +486 -0
  64. package/{server → server-e2e}/auth.ts +5 -1
  65. package/{server → server-e2e}/bus-receiver.ts +2 -1
  66. package/{server → server-e2e}/cli.js +9 -4
  67. package/{server → server-e2e}/config.ts +56 -35
  68. package/{server → server-e2e}/db-neon.ts +2 -2
  69. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  70. package/{server → server-e2e}/db-stmt.ts +2 -2
  71. package/{server → server-e2e}/db.ts +7 -7
  72. package/{server → server-e2e}/http.ts +110 -12
  73. package/{server → server-e2e}/hub.ts +40 -9
  74. package/server-e2e/index.ts +17 -0
  75. package/server-e2e/lifecycle.ts +95 -0
  76. package/{server → server-e2e}/neon-driver.ts +2 -2
  77. package/{server → server-e2e}/npm-proxy.ts +11 -144
  78. package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
  79. package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
  80. package/{server → server-e2e}/objstore/blob.ts +9 -2
  81. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  82. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  83. package/{server → server-e2e}/objstore/init.ts +35 -18
  84. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  85. package/server-e2e/objstore/rest-deny.ts +28 -0
  86. package/server-e2e/objstore/rest-mint.ts +224 -0
  87. package/{server → server-e2e}/objstore/rest.ts +58 -33
  88. package/{server → server-e2e}/objstore/sign.ts +105 -0
  89. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  90. package/{server → server-e2e}/objstore/store.ts +7 -7
  91. package/{server → server-e2e}/objstore/tokens.ts +1 -1
  92. package/{server → server-e2e}/pubsub.ts +12 -9
  93. package/{server → server-e2e}/sign.ts +4 -4
  94. package/{server → server-e2e}/sse-server.ts +102 -68
  95. package/{server → server-e2e}/sse-session.ts +28 -8
  96. package/{server → server-e2e}/static.ts +31 -21
  97. package/server-e2e/sync-handlers.ts +408 -0
  98. package/{server → server-e2e}/util.ts +9 -0
  99. package/{server → server-e2e}/ws-server.ts +20 -9
  100. package/server-managed/activity.ts +231 -0
  101. package/server-managed/avatar-store.ts +12 -0
  102. package/server-managed/blob-store.ts +17 -0
  103. package/server-managed/brotli.ts +10 -0
  104. package/server-managed/bundle-advisories.ts +53 -0
  105. package/server-managed/bundle-advisory-inventory.ts +75 -0
  106. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  107. package/server-managed/bundle-build-diagnostics.js +23 -0
  108. package/server-managed/bundle-build-leases.ts +41 -0
  109. package/server-managed/bundle-build-worker.js +54 -0
  110. package/server-managed/bundle-build.ts +168 -0
  111. package/server-managed/bundle-cache.ts +166 -0
  112. package/server-managed/bundle-catalog.ts +29 -0
  113. package/server-managed/bundle-store.ts +24 -0
  114. package/server-managed/bundle-summary-cache.ts +116 -0
  115. package/server-managed/bundle.ts +104 -0
  116. package/server-managed/cache-storage.ts +19 -0
  117. package/server-managed/cli.js +21 -0
  118. package/server-managed/combined.ts +46 -0
  119. package/server-managed/comments.ts +151 -0
  120. package/server-managed/config.ts +149 -0
  121. package/server-managed/content-access.ts +15 -0
  122. package/server-managed/crypto.ts +25 -0
  123. package/server-managed/db-methods.ts +1517 -0
  124. package/server-managed/db-neon.ts +277 -0
  125. package/server-managed/db-schema.ts +206 -0
  126. package/server-managed/db-table-names.ts +22 -0
  127. package/server-managed/db.ts +120 -0
  128. package/server-managed/github-app.ts +385 -0
  129. package/server-managed/github-errors.ts +49 -0
  130. package/server-managed/github-issue-oauth.ts +48 -0
  131. package/server-managed/github-issues.ts +152 -0
  132. package/server-managed/github-metadata.ts +98 -0
  133. package/server-managed/github-oauth.ts +199 -0
  134. package/server-managed/github-pulls.ts +227 -0
  135. package/server-managed/http-response.ts +18 -0
  136. package/server-managed/http.ts +2350 -0
  137. package/server-managed/import-triage.ts +51 -0
  138. package/server-managed/index.ts +175 -0
  139. package/server-managed/managed-issues.ts +47 -0
  140. package/server-managed/management-catalog.ts +45 -0
  141. package/server-managed/management.ts +86 -0
  142. package/server-managed/object-storage-disk.ts +144 -0
  143. package/server-managed/object-storage-vercel.ts +60 -0
  144. package/server-managed/object-storage.ts +71 -0
  145. package/server-managed/package-entry-points.ts +51 -0
  146. package/server-managed/public-workspace.ts +146 -0
  147. package/server-managed/repo-path.ts +21 -0
  148. package/server-managed/report-migration.ts +35 -0
  149. package/server-managed/report-query.ts +10 -0
  150. package/server-managed/report-response.ts +16 -0
  151. package/server-managed/report-sources.ts +166 -0
  152. package/server-managed/repository-browser.ts +139 -0
  153. package/server-managed/repository-discovery.ts +82 -0
  154. package/server-managed/repository-policy.ts +27 -0
  155. package/server-managed/revisions.ts +35 -0
  156. package/server-managed/session.ts +78 -0
  157. package/server-managed/slugs.ts +38 -0
  158. package/server-managed/solidity-entry-points.ts +69 -0
  159. package/server-managed/sql-postgres.ts +31 -0
  160. package/server-managed/sql.ts +72 -0
  161. package/server-managed/static.ts +28 -0
  162. package/server-managed/storage-command.ts +16 -0
  163. package/server-managed/storage-db.ts +175 -0
  164. package/server-managed/storage-encryption.ts +211 -0
  165. package/server-managed/storage-maintenance.ts +223 -0
  166. package/server-managed/storage-payload.ts +72 -0
  167. package/server-managed/storage-stores.ts +81 -0
  168. package/server-managed/storage.ts +58 -0
  169. package/server-managed/team-catalog.ts +7 -0
  170. package/server-managed/team-feed.ts +173 -0
  171. package/server-managed/team-reports.ts +219 -0
  172. package/server-managed/triage-response.ts +16 -0
  173. package/server-managed/uploads.ts +65 -0
  174. package/server-managed/workspace-shares.ts +162 -0
  175. package/server.ts +50 -0
  176. package/server/index.ts +0 -477
  177. package/server/lifecycle.ts +0 -204
  178. package/server/sync-handlers.ts +0 -320
  179. /package/{LICENSE → LICENSE-MIT} +0 -0
  180. /package/{server → server-e2e}/config.example.json +0 -0
  181. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  182. /package/{server → server-e2e}/peer.ts +0 -0
  183. /package/{server → server-e2e}/validation.ts +0 -0
@@ -41,7 +41,9 @@ 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
@@ -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, incarnation) inline (the row is gone post-delete). SQLite mode passes a no-op.
75
+ publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => 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
 
@@ -91,7 +105,7 @@ export type ObjstoreRestDeps = {
91
105
  // concurrent same-token PUT is rejected (409) before it can open a
92
106
  // second writer. The slot is held only across body + commit, released
93
107
  // in a `finally`, and bounded by the REST idle-body timeout in
94
- // server/http.ts so a slow-loris can't pin it. NOT a commit lock —
108
+ // server-e2e/http.ts so a slow-loris can't pin it. NOT a commit lock —
95
109
  // commits stay lock-free on the version-CAS above. Per-process: the
96
110
  // Vercel multi-replica path leans on `allowOverwrite: true` + the
97
111
  // commit CAS + content-addressing for cross-replica correctness (a
@@ -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)
@@ -431,7 +432,10 @@ async function runUploadAndCommit(
431
432
  type GetOpened =
432
433
  | { reason: 'ok'; reader: LiveReader }
433
434
  | { reason: 'not-found' }
434
- | { 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 }
435
439
 
436
440
  async function openLiveSnapshot(
437
441
  deps: ObjstoreRestDeps, route: RouteMatch, payload: { ver: number; inc: string },
@@ -448,17 +452,27 @@ async function openLiveSnapshot(
448
452
  // returns a pinned fd; for the Vercel backend a fetch-backed stream.)
449
453
  const live = await deps.handle.selectLiveOne.get(route.tag, route.resourceTag)
450
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)}`
451
459
  let opened
452
460
  try { opened = await deps.handle.blob.openLiveReader(route.tag, live.content_hash) }
453
- catch { return { reason: 'unavailable' } }
454
- 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}` }
455
469
  // Size mismatch between the live row and the on-storage bytes
456
470
  // is a transient inconsistency — reaper will reconcile. Close
457
471
  // the reader before returning so we don't leak the fd / fetch
458
472
  // reader. PR #4 review H8.
459
473
  if (opened.reader.size !== live.content_length) {
460
474
  await opened.reader.close().catch(() => {})
461
- return { reason: 'unavailable' }
475
+ return { reason: 'unavailable', detail: `size-mismatch row=${live.content_length} blob=${opened.reader.size} ${hashTag}` }
462
476
  }
463
477
  return { reason: 'ok', reader: opened.reader }
464
478
  }
@@ -478,8 +492,19 @@ async function handleRestGet(
478
492
  // If the live row is there but the bytes are missing / wrong size,
479
493
  // it's a transient inconsistency the reaper will sort out — 503
480
494
  // (vs 404) tells the client this is a server-side state, not a
481
- // "the resource truly isn't there" answer.
482
- 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
+ }
483
508
  res.writeHead(200, {
484
509
  'content-type': 'application/octet-stream',
485
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 configured 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,14 +13,10 @@
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:
17
- // - Neon + Vercel Blob Private Storage (multi-replica, the only
18
- // pairing that survives a replica restart without local-disk
19
- // coordination). Activated by setting BLOB_READ_WRITE_TOKEN
20
- // alongside DATABASE_URL.
21
- // - Neon + local FS (development / operator escape hatch — bytes
22
- // still need to live on a shared filesystem if you run more
23
- // than one replica).
16
+ // supported Neon pairing selected by server-e2e/index.ts is Vercel
17
+ // Blob Private Storage. DATABASE_URL or E2E_DATABASE_URL plus BLOB_READ_WRITE_TOKEN
18
+ // is required; the launcher never selects Neon + local FS. Tests
19
+ // can inject a local-FS backend directly through this adapter.
24
20
 
25
21
  import type { BlobBackend } from './blob.ts'
26
22
  import type { AllStmt, GetStmt, RunStmt } from '../db-stmt.ts'
@@ -309,7 +305,7 @@ export async function openNeonObjstore(connectionString: string, blob: BlobBacke
309
305
  // local `../neon-driver.ts` re-export wrapper so the peer dep stays
310
306
  // optional AND tests can mock the driver (a local path is always
311
307
  // resolvable; the bare specifier isn't when the dep is absent). See
312
- // `server/neon-driver.ts`. Cast through `unknown` because the
308
+ // `server-e2e/neon-driver.ts`. Cast through `unknown` because the
313
309
  // wrapper's `export *` re-exports a `@ts-ignore`'d module.
314
310
  const mod = (await import('../neon-driver.ts')) as unknown as { neon: (url: string) => NeonSql }
315
311
  const sql: NeonSql = mod.neon(connectionString)
@@ -1,5 +1,5 @@
1
1
  // SQLite + filesystem-backed object store for the v1.objstore
2
- // protocol extension. Sibling of `server/db.ts`; shares the
2
+ // protocol extension. Sibling of `server-e2e/db.ts`; shares the
3
3
  // underlying DatabaseSync handle but keeps its own tables.
4
4
  //
5
5
  // workspace_object — one row per LIVE resource (no
@@ -150,7 +150,7 @@ export type DeleteResult =
150
150
  | { ok: true; deletedVersion: number }
151
151
  | { ok: false; reason: 'not-found' | 'conflict'; conflict?: ObjectRow }
152
152
 
153
- // Async statement shapes are shared with server/db.ts via
153
+ // Async statement shapes are shared with server-e2e/db.ts via
154
154
  // ../db-stmt.ts — same `.get/.all/.run` → Promise contract across
155
155
  // both planes.
156
156
 
@@ -186,7 +186,7 @@ export type Handle = {
186
186
  db?: DatabaseSync
187
187
  // Byte-plane backend (local FS or Vercel Blob). All bytes-side
188
188
  // operations go through this — there is no direct fs.* call in
189
- // store / rest / reaper. Selected at boot in server/index.ts.
189
+ // store / rest / reaper. Selected at boot in server-e2e/index.ts.
190
190
  blob: BlobBackend
191
191
  // Storage root for the FS backend — set only when `blob` was
192
192
  // constructed from `openFsBlobBackend(dir)`. Production code
@@ -247,7 +247,7 @@ export type Handle = {
247
247
  // returned. The reaper passes `Date.now() - stagingTtlMs` so the
248
248
  // index `workspace_object_staging_begun_at_idx` is used and the
249
249
  // sweep is O(stale-rows) instead of O(in-flight-uploads-cluster-
250
- // wide). DB-layout audit `server/objstore/store.ts:312`.
250
+ // wide). DB-layout audit `server-e2e/objstore/store.ts:312`.
251
251
  listAllStaging: AllStmt<[number], { workspace_tag: string; resource_tag: string; staging_id: string; begun_at: number }>
252
252
  listLiveTags: AllStmt<[], { workspace_tag: string }>
253
253
  countLive: GetStmt<[string], { c: number }>
@@ -255,7 +255,7 @@ export type Handle = {
255
255
 
256
256
  // Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
257
257
  // to be set. `openObjstore` returns this so call sites (production
258
- // shutdown plumbing in `server/index.ts` + the entire SQLite-only
258
+ // shutdown plumbing in `server-e2e/index.ts` + the entire SQLite-only
259
259
  // test suite in `tests/server-objstore.test.js`) can reach
260
260
  // `handle.db.prepare(...)` without an optional-chain or non-null
261
261
  // assertion. A Neon-backed Handle (`openNeonObjstore`) keeps the
@@ -352,7 +352,7 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
352
352
  mkdirSync(dir, { recursive: true })
353
353
  db.exec(SCHEMA)
354
354
  // Fail-loud on a pre-existing non-STRICT table — same rationale as
355
- // server/db.ts: `CREATE TABLE IF NOT EXISTS … STRICT` doesn't
355
+ // server-e2e/db.ts: `CREATE TABLE IF NOT EXISTS … STRICT` doesn't
356
356
  // upgrade an existing non-STRICT table, and dropping strict type
357
357
  // affinity opens an operator-attack path. PR #4 review F3.
358
358
  for (const name of ['workspace_object', 'workspace_object_staging']) {
@@ -362,7 +362,7 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
362
362
  const blob = openFsBlobBackend(dir)
363
363
  // No `close` method on the returned Handle: the underlying
364
364
  // `DatabaseSync` is owned by the caller (in production, the
365
- // workspace_revision handle in `server/db.ts`, closed from
365
+ // workspace_revision handle in `server-e2e/db.ts`, closed from
366
366
  // `shutdown()`; tests close their own DB). A `close()` here would
367
367
  // mislead — it could only no-op or leak, never close the caller-
368
368
  // owned connection.
@@ -119,7 +119,7 @@ function isValidPayload(v: unknown): v is TokenPayload {
119
119
  // different actual value, or — for `exp` — claim a
120
120
  // `Number.MAX_SAFE_INTEGER + 1` expiry that compares ambiguously near
121
121
  // the IEEE-754 boundary. Matches the codebase's other safe-int gates
122
- // (server/objstore/sign.ts, rest.ts, handlers.ts).
122
+ // (server-e2e/objstore/sign.ts, rest.ts, handlers.ts).
123
123
  if (!Number.isSafeInteger(o['exp']) || (o['exp'] as number) < 0) return false
124
124
  if (o['op'] === 'put') {
125
125
  return typeof o['sid'] === 'string'
@@ -1,5 +1,5 @@
1
1
  // Cross-instance pub/sub for real-time WS broadcasts. The triage-sync
2
- // fan-out is an in-memory subscriber map (server/hub.ts) by design — it
2
+ // fan-out is an in-memory subscriber map (server-e2e/hub.ts) by design — it
3
3
  // routes a commit only to peers on the SAME instance. A multi-instance
4
4
  // deployment behind a load balancer needs commit-landed-on-A to reach
5
5
  // peers-on-B with the same latency the hub gives same-instance peers.
@@ -57,15 +57,15 @@ function newSenderId(): string {
57
57
  // - 'objput': objstore-put broadcast. `res` is the resource tag; the
58
58
  // receiver SELECTs the live row by (tag, res) for the rest of the
59
59
  // metadata fields.
60
- // - 'objdel': objstore-deleted broadcast. `ver` is the deleted version
61
- // — inline because the row is gone from workspace_object after the
62
- // delete commit.
60
+ // - 'objdel': objstore-deleted broadcast. `ver` and `incarnation` identify
61
+ // the deleted row, which is gone from workspace_object after commit.
62
+ // Incarnation is optional only for messages from older relay instances.
63
63
  export type BusMessage =
64
64
  | { kind: 'rev'; tag: string; id: string }
65
65
  | { kind: 'objput'; tag: string; res: string }
66
- | { kind: 'objdel'; tag: string; res: string; ver: number }
66
+ | { kind: 'objdel'; tag: string; res: string; ver: number; incarnation?: string }
67
67
 
68
- // Receiver wired up by the hub layer (see server/index.ts). Each handler
68
+ // Receiver wired up by the hub layer (see server-e2e/index.ts). Each handler
69
69
  // runs once per remote message; failures are logged but don't crash the
70
70
  // LISTEN loop — a missed broadcast surfaces to clients on reconnect
71
71
  // (chain re-pull). Async because the workspace-revision handler does a
@@ -164,7 +164,7 @@ type NeonState = {
164
164
  // In-flight bus-message handler promises. `dispatchNotification`
165
165
  // fires handlers fire-and-forget, but `stop()` awaits this set before
166
166
  // returning so the lifecycle's `handle.close()` (which runs after
167
- // `pubsub.stop()` — see closeDb in server/index.ts) can't race a
167
+ // `pubsub.stop()` — see closeDb in server-e2e/index.ts) can't race a
168
168
  // handler mid-`handle.revisionById.get` / `getLive`.
169
169
  pendingHandlers: Set<Promise<void>>
170
170
  }
@@ -333,7 +333,7 @@ export function createNeonPubSub(deps: NeonPubSubDeps): PubSub {
333
333
  if (state.connectAttempt) { try { await state.connectAttempt } catch {} }
334
334
  // Drain in-flight bus-message handlers BEFORE returning. The
335
335
  // lifecycle teardown runs `pubsub.stop()` and THEN
336
- // `handle.close()` (see closeDb in server/index.ts); a handler
336
+ // `handle.close()` (see closeDb in server-e2e/index.ts); a handler
337
337
  // still in `handle.revisionById.get` / `getLive` would otherwise
338
338
  // throw against a closed DB. `allSettled` so one handler's
339
339
  // rejection doesn't abort the drain.
@@ -388,7 +388,10 @@ function parseBusMessage(raw: Record<string, unknown>): BusMessage | null {
388
388
  const ver = raw['ver']
389
389
  if (typeof res !== 'string') return null
390
390
  if (typeof ver !== 'number' || !Number.isSafeInteger(ver)) return null
391
- return { kind: 'objdel', tag, res, ver }
391
+ const incarnation = raw['incarnation']
392
+ if (incarnation === undefined) return { kind: 'objdel', tag, res, ver }
393
+ if (typeof incarnation !== 'string' || incarnation.length === 0) return null
394
+ return { kind: 'objdel', tag, res, ver, incarnation }
392
395
  }
393
396
  return null
394
397
  }
@@ -92,7 +92,7 @@ export function canonicalSave(
92
92
  // confusing diff in the canonical bytes rather than a clean drop.
93
93
  // Mirrors the `isSafeNonNegativeInt` rigor that the objstore
94
94
  // canonical builders apply. Input-validation audit
95
- // `server/sign.ts:88`.
95
+ // `server-e2e/sign.ts:88`.
96
96
  if (typeof workspaceTag !== 'string') throw new TypeError('canonicalSave: workspaceTag must be string')
97
97
  if (typeof nonce !== 'string') throw new TypeError('canonicalSave: nonce must be string')
98
98
  if (typeof ciphertext !== 'string') throw new TypeError('canonicalSave: ciphertext must be string')
@@ -122,7 +122,7 @@ function canonicalSubscribe(
122
122
  return encodeUtf8([SUBSCRIBE_DOMAIN, workspaceTag as string, fromStr, connectionNonce].join('\n'))
123
123
  }
124
124
 
125
- // Exported because the v1.objstore signing module (server/objstore/sign.ts)
125
+ // Exported because the v1.objstore signing module (server-e2e/objstore/sign.ts)
126
126
  // reuses it for its four verifiers — same workspaceTag-as-pubkey contract,
127
127
  // same domain-separated canonical bytes. Keeping the WebCrypto plumbing
128
128
  // in one place avoids drift between the triage-sync and objstore verify
@@ -157,7 +157,7 @@ export async function verifyEd25519(
157
157
  // `{ ok: false, canonical: null }` on bad shape / bad sig;
158
158
  // `{ ok: true, canonical: <bytes> }` on success.
159
159
  //
160
- // NOT used by `server/index.ts handleSave`. Production composes
160
+ // NOT used by `server-e2e/index.ts handleSave`. Production composes
161
161
  // the smaller helpers (`canonicalSave`, `computeRevisionIdFromCanonical`,
162
162
  // `verifyEd25519`) separately so the dup-precheck via
163
163
  // `revisionExists` can fire BEFORE Ed25519-verify and skip the
@@ -204,7 +204,7 @@ export async function computeRevisionIdFromCanonical(canonical: Uint8Array<Array
204
204
 
205
205
  // `connectionNonce` is the per-socket challenge the server issued
206
206
  // to the originating connection (see `Peer.challenge` in
207
- // server/peer.ts). The client signs a canonical that includes the
207
+ // server-e2e/peer.ts). The client signs a canonical that includes the
208
208
  // nonce; verifying against the SAME nonce here is what blocks
209
209
  // cross-connection replay of a captured subscribe frame. Audit
210
210
  // round-9 H2.