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

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 (311) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2KBF5TZ7.js +2 -0
  72. package/out/chunk-2XUEZHIP.js +28 -0
  73. package/out/chunk-35VE6CDH.js +2 -0
  74. package/out/chunk-3BZ7JERB.js +1 -0
  75. package/out/chunk-4F6JSF57.js +3 -0
  76. package/out/chunk-4XCDFGDP.js +10 -0
  77. package/out/chunk-AQ75QITE.js +1 -0
  78. package/out/chunk-B2RQY6OP.js +2 -0
  79. package/out/chunk-C4Z6ZBXV.js +10 -0
  80. package/out/chunk-CMQA4DM6.js +1 -0
  81. package/out/chunk-CVVJAXVY.js +2 -0
  82. package/out/chunk-DIJJNTRH.js +10 -0
  83. package/out/chunk-GJ4IUCNS.js +1 -0
  84. package/out/chunk-GNXOKIGP.js +80 -0
  85. package/out/chunk-GZ4MS3TE.js +13 -0
  86. package/out/chunk-IISEF2BZ.js +1 -0
  87. package/out/chunk-IU3CCINR.js +3 -0
  88. package/out/chunk-K3X5EJBQ.js +2 -0
  89. package/out/chunk-KEX2ATEW.js +2 -0
  90. package/out/chunk-KYCNJVB7.js +1 -0
  91. package/out/chunk-MYDDTA7W.js +225 -0
  92. package/out/chunk-NRPC74AM.js +1 -0
  93. package/out/chunk-OOJM4CTU.js +1 -0
  94. package/out/chunk-PNHBZGDD.js +1 -0
  95. package/out/chunk-R5O6FAW4.js +2 -0
  96. package/out/chunk-RFZQQXSG.js +17 -0
  97. package/out/chunk-RSHMVYNS.js +1 -0
  98. package/out/chunk-TGANXVTA.js +1 -0
  99. package/out/chunk-TUII34K2.js +1 -0
  100. package/out/chunk-TZ5KB2Y5.js +10 -0
  101. package/out/chunk-UN5ZSYLE.js +1 -0
  102. package/out/chunk-UNTRGE26.js +2 -0
  103. package/out/chunk-UXNUAIIC.js +33 -0
  104. package/out/chunk-UYAXVRVL.js +1 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XYDGU6BM.js +51 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +297 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +358 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +142 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +219 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/specs.js +124 -0
  154. package/risk/npm/tarball.js +113 -0
  155. package/server-common/database-config.ts +16 -0
  156. package/server-common/initialize.ts +18 -0
  157. package/server-common/npm-advisories.ts +101 -0
  158. package/{server → server-common}/origin.ts +5 -5
  159. package/server-common/reap.ts +48 -0
  160. package/server-common/scan-config.ts +19 -0
  161. package/server-common/standalone.ts +29 -0
  162. package/server-common/storage-crypto.ts +182 -0
  163. package/server-common/storage-log.ts +38 -0
  164. package/server-common/vercel-blob.ts +103 -0
  165. package/server-e2e/app.ts +486 -0
  166. package/{server → server-e2e}/auth.ts +5 -1
  167. package/{server → server-e2e}/bus-receiver.ts +2 -1
  168. package/{server → server-e2e}/cli.js +9 -4
  169. package/{server → server-e2e}/config.ts +56 -35
  170. package/{server → server-e2e}/db-neon.ts +2 -2
  171. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  172. package/{server → server-e2e}/db-stmt.ts +2 -2
  173. package/{server → server-e2e}/db.ts +7 -7
  174. package/{server → server-e2e}/http.ts +116 -18
  175. package/{server → server-e2e}/hub.ts +40 -9
  176. package/server-e2e/index.ts +17 -0
  177. package/server-e2e/lifecycle.ts +95 -0
  178. package/{server → server-e2e}/neon-driver.ts +2 -2
  179. package/{server → server-e2e}/npm-proxy.ts +12 -145
  180. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  181. package/{server → server-e2e}/objstore/blob.ts +1 -1
  182. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  183. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  184. package/{server → server-e2e}/objstore/init.ts +35 -18
  185. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  186. package/server-e2e/objstore/rest-deny.ts +28 -0
  187. package/server-e2e/objstore/rest-mint.ts +224 -0
  188. package/{server → server-e2e}/objstore/rest.ts +27 -26
  189. package/{server → server-e2e}/objstore/sign.ts +105 -0
  190. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  191. package/{server → server-e2e}/objstore/store.ts +7 -7
  192. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  193. package/{server → server-e2e}/pubsub.ts +12 -9
  194. package/{server → server-e2e}/sign.ts +4 -4
  195. package/{server → server-e2e}/sse-server.ts +102 -68
  196. package/{server → server-e2e}/sse-session.ts +28 -8
  197. package/{server → server-e2e}/static.ts +31 -21
  198. package/server-e2e/sync-handlers.ts +408 -0
  199. package/{server → server-e2e}/ws-server.ts +20 -9
  200. package/server-managed/activity.ts +245 -0
  201. package/server-managed/avatar-store.ts +12 -0
  202. package/server-managed/blob-store.ts +17 -0
  203. package/server-managed/brotli.ts +10 -0
  204. package/server-managed/bundle-advisories.ts +120 -0
  205. package/server-managed/bundle-advisory-inventory.ts +77 -0
  206. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  207. package/server-managed/bundle-build-diagnostics.js +23 -0
  208. package/server-managed/bundle-build-leases.ts +41 -0
  209. package/server-managed/bundle-build-worker.js +111 -0
  210. package/server-managed/bundle-build.ts +198 -0
  211. package/server-managed/bundle-cache.ts +199 -0
  212. package/server-managed/bundle-catalog.ts +29 -0
  213. package/server-managed/bundle-commits.ts +140 -0
  214. package/server-managed/bundle-store.ts +24 -0
  215. package/server-managed/bundle-summary-cache.ts +130 -0
  216. package/server-managed/bundle.ts +120 -0
  217. package/server-managed/cache-storage.ts +19 -0
  218. package/server-managed/cli.js +21 -0
  219. package/server-managed/combined.ts +46 -0
  220. package/server-managed/comments.ts +151 -0
  221. package/server-managed/config.ts +158 -0
  222. package/server-managed/content-access.ts +15 -0
  223. package/server-managed/crypto.ts +25 -0
  224. package/server-managed/db-methods.ts +1725 -0
  225. package/server-managed/db-neon.ts +367 -0
  226. package/server-managed/db-schema.ts +227 -0
  227. package/server-managed/db-table-names.ts +22 -0
  228. package/server-managed/db.ts +132 -0
  229. package/server-managed/dependency-reviews.ts +248 -0
  230. package/server-managed/github-app.ts +391 -0
  231. package/server-managed/github-errors.ts +68 -0
  232. package/server-managed/github-issue-links.ts +127 -0
  233. package/server-managed/github-issue-oauth.ts +47 -0
  234. package/server-managed/github-issues.ts +152 -0
  235. package/server-managed/github-metadata.ts +213 -0
  236. package/server-managed/github-oauth.ts +235 -0
  237. package/server-managed/github-pulls.ts +227 -0
  238. package/server-managed/http-response.ts +50 -0
  239. package/server-managed/http.ts +3472 -0
  240. package/server-managed/import-triage.ts +51 -0
  241. package/server-managed/index.ts +187 -0
  242. package/server-managed/kept.ts +28 -0
  243. package/server-managed/link-reports.ts +158 -0
  244. package/server-managed/locked-packages.ts +85 -0
  245. package/server-managed/lru.ts +30 -0
  246. package/server-managed/managed-issues.ts +75 -0
  247. package/server-managed/management-catalog.ts +45 -0
  248. package/server-managed/management.ts +99 -0
  249. package/server-managed/npm-insights.ts +440 -0
  250. package/server-managed/npm-kept.ts +65 -0
  251. package/server-managed/npm-loads.ts +167 -0
  252. package/server-managed/npm-manifest.ts +115 -0
  253. package/server-managed/npm-packages.ts +378 -0
  254. package/server-managed/npm-provenance.ts +123 -0
  255. package/server-managed/npm-risk.ts +177 -0
  256. package/server-managed/object-storage-disk.ts +148 -0
  257. package/server-managed/object-storage-vercel.ts +67 -0
  258. package/server-managed/object-storage.ts +74 -0
  259. package/server-managed/package-entry-points.ts +51 -0
  260. package/server-managed/pretty-print.ts +352 -0
  261. package/server-managed/public-workspace.ts +220 -0
  262. package/server-managed/rename-diff-worker.js +26 -0
  263. package/server-managed/rename-diff.ts +270 -0
  264. package/server-managed/repo-path.ts +21 -0
  265. package/server-managed/report-migration.ts +35 -0
  266. package/server-managed/report-query.ts +10 -0
  267. package/server-managed/report-response.ts +16 -0
  268. package/server-managed/report-sources.ts +178 -0
  269. package/server-managed/report-store.ts +47 -0
  270. package/server-managed/repository-aliases.ts +91 -0
  271. package/server-managed/repository-browser.ts +153 -0
  272. package/server-managed/repository-discovery.ts +82 -0
  273. package/server-managed/repository-policy.ts +27 -0
  274. package/server-managed/repository-risk.ts +418 -0
  275. package/server-managed/revisions.ts +35 -0
  276. package/server-managed/session.ts +92 -0
  277. package/server-managed/slugs.ts +38 -0
  278. package/server-managed/solidity-entry-points.ts +69 -0
  279. package/server-managed/sql-postgres.ts +31 -0
  280. package/server-managed/sql.ts +76 -0
  281. package/server-managed/static.ts +28 -0
  282. package/server-managed/storage-command.ts +16 -0
  283. package/server-managed/storage-db.ts +175 -0
  284. package/server-managed/storage-encryption.ts +279 -0
  285. package/server-managed/storage-maintenance.ts +256 -0
  286. package/server-managed/storage-payload.ts +103 -0
  287. package/server-managed/storage-stores.ts +85 -0
  288. package/server-managed/storage.ts +78 -0
  289. package/server-managed/team-app.ts +94 -0
  290. package/server-managed/team-catalog.ts +7 -0
  291. package/server-managed/team-feed.ts +173 -0
  292. package/server-managed/team-npm-scopes.ts +77 -0
  293. package/server-managed/team-reports.ts +228 -0
  294. package/server-managed/triage-response.ts +16 -0
  295. package/server-managed/type-only-worker.js +19 -0
  296. package/server-managed/type-only.ts +103 -0
  297. package/server-managed/uploads.ts +86 -0
  298. package/server-managed/upstream-cache.ts +215 -0
  299. package/server-managed/view-as.ts +58 -0
  300. package/server-managed/workspace-shares.ts +166 -0
  301. package/server.ts +50 -0
  302. package/server/index.ts +0 -477
  303. package/server/lifecycle.ts +0 -204
  304. package/server/sync-handlers.ts +0 -320
  305. /package/{LICENSE → LICENSE-MIT} +0 -0
  306. /package/{server → server-e2e}/config.example.json +0 -0
  307. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  308. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  309. /package/{server → server-e2e}/peer.ts +0 -0
  310. /package/{server → server-e2e}/util.ts +0 -0
  311. /package/{server → server-e2e}/validation.ts +0 -0
@@ -15,16 +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
+ // Automatic-sweep switch (OBJSTORE_REAP_DISABLED). When true, NEITHER the boot
22
+ // sweep nor the periodic timer runs: `startupReap` resolves immediately
23
+ // and no timer is installed. Orphaned/superseded blobs and stale
24
+ // staging rows then accumulate unbounded — only safe if an external job
25
+ // handles GC. Omitted/false → reaper runs (the default). See config.ts.
26
+ reapDisabled?: boolean
21
27
  send: (socket: WebSocket, msg: object) => void
22
28
  broadcast: (tag: string, msg: object, except: WebSocket | null) => void
23
29
  // Cross-instance pub/sub publishers. SQLite mode passes no-ops; Neon
24
30
  // mode passes Postgres LISTEN/NOTIFY-backed implementations. See
25
- // server/pubsub.ts for the bus design.
31
+ // server-e2e/pubsub.ts for the bus design.
26
32
  publishObjPut: (tag: string, resourceTag: string) => void
27
- publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
33
+ publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
28
34
  getNonce: (socket: WebSocket) => string | undefined
29
35
  debug: boolean
30
36
  // Auth gate for the FIRST objstore-put-begin against a workspace
@@ -39,14 +45,21 @@ export type ObjstoreInitDeps = {
39
45
  // process behaviour). Multi-replica deployments MUST supply a
40
46
  // shared secret here so a token minted on one replica's WS plane
41
47
  // validates on another replica's REST plane (the WS-to-REST hop
42
- // is not load-balancer-pinned). See server/index.ts boot logic
48
+ // is not load-balancer-pinned). See server-e2e/index.ts boot logic
43
49
  // for the env var (`OBJSTORE_TOKEN_SECRET`).
44
50
  tokenSecret?: TokenSecret
51
+ // New-workspace operator gate for the REST put-begin mint — returns
52
+ // `true` to DENY (password configured AND workspace new). The
53
+ // connection-independent analog of `authGate`; the client falls back to
54
+ // the in-band WS put-begin on a deny. Omitted → open (never deny),
55
+ // matching `authGate`'s no-config default.
56
+ restPutGate?: (workspaceTag: string) => Promise<boolean>
45
57
  }
46
58
 
47
59
  export type ObjstoreInit = {
48
60
  handlers: ObjstoreHandlers
49
61
  restDeps: ObjstoreRestDeps
62
+ reap: () => Promise<void>
50
63
  startupReap: Promise<void>
51
64
  // Cancels the periodic timer AND resolves only after any
52
65
  // currently-in-flight sweep has finished, so shutdown can safely
@@ -77,7 +90,10 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
77
90
  ...(deps.sendUnauthorized ? { sendUnauthorized: deps.sendUnauthorized } : {}),
78
91
  })
79
92
  const restDeps: ObjstoreRestDeps = {
80
- handle, secret, broadcast: deps.broadcast, publishObjPut: deps.publishObjPut, debug: deps.debug,
93
+ handle, secret, broadcast: deps.broadcast,
94
+ publishObjPut: deps.publishObjPut, publishObjDeleted: deps.publishObjDeleted,
95
+ restPutGate: deps.restPutGate ?? (() => Promise.resolve(false)),
96
+ debug: deps.debug,
81
97
  }
82
98
  // Re-entrancy guard for periodic + startup sweeps. Kicking the
83
99
  // startup sweep through the same `enqueueSweep` path means the
@@ -87,21 +103,22 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
87
103
  let inFlight: Promise<void> | null = null
88
104
  function enqueueSweep(): Promise<void> {
89
105
  if (inFlight) return inFlight
90
- // Log reaper failures unconditionally (not gated on `debug`) — a
91
- // failed sweep means stranded files / staging rows that never get
92
- // cleaned, and operators need to see it. Inner catch makes the
93
- // promise resolve so callers (startupReap awaiter + setInterval)
94
- // don't have to handle rejection.
95
- const p = reapOrphans(handle).catch((err) => {
96
- console.warn('objstore reaper error:', errStack(err))
97
- }).finally(() => { if (inFlight === p) inFlight = null })
106
+ // Keep failures visible to explicit callers; automatic sweeps log them.
107
+ const p = reapOrphans(handle).finally(() => { if (inFlight === p) inFlight = null })
98
108
  inFlight = p
99
109
  return p
100
110
  }
111
+ const drain = async () => { if (inFlight) await inFlight.catch(() => {}) }
112
+ const automaticSweep = () => enqueueSweep().catch(err => console.warn('objstore reaper error:', errStack(err)))
113
+ // Explicit cleanup remains available when automatic sweeps are disabled.
114
+ if (deps.reapDisabled) {
115
+ console.warn('objstore: automatic reaper DISABLED (OBJSTORE_REAP_DISABLED) — use /api/reap or an external cleanup job')
116
+ return { handlers, restDeps, reap: enqueueSweep, startupReap: Promise.resolve(), stopReaper: drain }
117
+ }
101
118
  // Caller awaits this before accepting traffic so a fresh boot
102
119
  // can't hand out list / fetch / put-begin against a tag whose
103
120
  // on-disk state still has stranded files from a prior crash.
104
- const startupReap = enqueueSweep()
121
+ const startupReap = automaticSweep()
105
122
  // Jittered start of the periodic timer. Multi-replica deploys
106
123
  // (Neon + Vercel Blob) commonly boot N replicas in tight lock-step
107
124
  // (deploy rollout, cluster restart), which would otherwise sync every
@@ -114,20 +131,20 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
114
131
  let reapTimer: ReturnType<typeof setInterval> | null = null
115
132
  const jitterMs = Math.floor(Math.random() * deps.reapIntervalMs)
116
133
  const firstTimer = setTimeout(() => {
117
- enqueueSweep()
118
- reapTimer = setInterval(enqueueSweep, deps.reapIntervalMs)
134
+ void automaticSweep()
135
+ reapTimer = setInterval(automaticSweep, deps.reapIntervalMs)
119
136
  reapTimer.unref?.()
120
137
  }, deps.reapIntervalMs + jitterMs)
121
138
  firstTimer.unref?.()
122
139
  return {
123
- handlers, restDeps, startupReap,
140
+ handlers, restDeps, reap: enqueueSweep, startupReap,
124
141
  stopReaper: async () => {
125
142
  clearTimeout(firstTimer)
126
143
  if (reapTimer) clearInterval(reapTimer)
127
144
  // Drain whichever sweep is currently running (startup or
128
145
  // periodic) — either could be mid-readdir/unlink at SIGTERM
129
146
  // time and would otherwise outlive `handle.close()`.
130
- if (inFlight) await inFlight.catch(() => {})
147
+ await drain()
131
148
  },
132
149
  }
133
150
  }
@@ -121,7 +121,7 @@ async function reapStaleStagingRows(handle: Handle, now: number, stagingTtlMs: n
121
121
  // Push the staleness filter into SQL so the
122
122
  // `workspace_object_staging_begun_at_idx` index handles the scan.
123
123
  // The snapshot is O(stale-rows) cluster-wide. DB-layout audit
124
- // `server/objstore/store.ts`.
124
+ // `server-e2e/objstore/store.ts`.
125
125
  const staleBefore = now - stagingTtlMs
126
126
  const staging = await handle.listAllStaging.all(staleBefore) as StagingRow[]
127
127
  for (const s of staging) {
@@ -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, incarnation: prevIncarnation! }, null)
223
+ deps.publishObjDeleted(route.tag, route.resourceTag, result.deletedVersion, prevIncarnation!)
224
+ }
@@ -41,6 +41,8 @@ import {
41
41
  } from './store.ts'
42
42
  import type { LiveReader } from './blob.ts'
43
43
  import { type TokenSecret, extractBearer, verifyToken } from './tokens.ts'
44
+ import { deny, denyConflict } from './rest-deny.ts'
45
+ import { handleRestMint } from './rest-mint.ts'
44
46
  import { debugId, debugTag, errMsg, errStack } from '../util.ts'
45
47
 
46
48
  // Server-side fault codes that should surface as 500 `io-error`
@@ -66,6 +68,18 @@ export type ObjstoreRestDeps = {
66
68
  // workspace_object for the full metadata (version, hash, length,
67
69
  // signature). SQLite mode passes a no-op.
68
70
  publishObjPut: (tag: string, resourceTag: string) => void
71
+ // Cross-instance pub/sub for objstore-deleted — fired alongside the local
72
+ // `broadcast` after a successful REST delete mint, so peers on OTHER
73
+ // instances drop the resource in real time. Carries (tag, resourceTag,
74
+ // version, 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)
@@ -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)