@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50

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 (366) 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 +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -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 +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +2 -0
  50. package/common/managed/finding-detail.js +32 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +5 -0
  54. package/common/managed/npm-packages.js +34 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +347 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +5 -0
  64. package/common/managed/team-app.js +31 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2WDZPXVL.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-4XMP7YVH.js +36 -0
  87. package/out/chunk-6LNQTKAV.js +52 -0
  88. package/out/chunk-6S2U2UYG.js +232 -0
  89. package/out/chunk-75YLHBCM.js +3 -0
  90. package/out/chunk-7BKKYVWR.js +7 -0
  91. package/out/chunk-7C7DWL7I.js +1 -0
  92. package/out/chunk-7VAWINQF.js +6 -0
  93. package/out/chunk-C4Z6ZBXV.js +10 -0
  94. package/out/chunk-FC65TPUB.js +1 -0
  95. package/out/chunk-FKBXNUW4.js +1 -0
  96. package/out/chunk-GDPCLSOS.js +2 -0
  97. package/out/chunk-GO7FT4OD.js +1 -0
  98. package/out/chunk-HTBYHSPG.js +1 -0
  99. package/out/chunk-IYPY47MC.js +1 -0
  100. package/out/chunk-IZ7POWI3.js +18 -0
  101. package/out/chunk-JCIIBLKR.js +1 -0
  102. package/out/chunk-NRPC74AM.js +1 -0
  103. package/out/chunk-OOJM4CTU.js +1 -0
  104. package/out/chunk-PBZPYIG7.js +1 -0
  105. package/out/chunk-PNHBZGDD.js +1 -0
  106. package/out/chunk-PWFUOLZK.js +17 -0
  107. package/out/chunk-PXK7EU4V.js +1 -0
  108. package/out/chunk-QB4CBOX2.js +1 -0
  109. package/out/chunk-R3NZFXVR.js +1 -0
  110. package/out/chunk-R5O6FAW4.js +2 -0
  111. package/out/chunk-SXV2T5C4.js +80 -0
  112. package/out/chunk-TQJDYHVE.js +2 -0
  113. package/out/chunk-TUII34K2.js +1 -0
  114. package/out/chunk-UK7RGJT6.js +3 -0
  115. package/out/chunk-VFWMQ3LG.js +2 -0
  116. package/out/chunk-VH4XMZIW.js +2 -0
  117. package/out/chunk-WCPJDAYA.js +10 -0
  118. package/out/chunk-WFYLMF6Z.js +10 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +362 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +21 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +73 -0
  237. package/server-managed/comparison.ts +41 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +458 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +142 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +100 -0
  249. package/server-managed/dependency-reviews.ts +346 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +415 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +39 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5329 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/locked-packages.ts +129 -0
  273. package/server-managed/lockfile-scope.ts +49 -0
  274. package/server-managed/lru.ts +30 -0
  275. package/server-managed/managed-issues.ts +78 -0
  276. package/server-managed/management-catalog.ts +45 -0
  277. package/server-managed/management.ts +99 -0
  278. package/server-managed/npm-delta-worker.js +23 -0
  279. package/server-managed/npm-delta.ts +194 -0
  280. package/server-managed/npm-insights.ts +449 -0
  281. package/server-managed/npm-kept.ts +215 -0
  282. package/server-managed/npm-loads.ts +264 -0
  283. package/server-managed/npm-manifest.ts +115 -0
  284. package/server-managed/npm-packages.ts +454 -0
  285. package/server-managed/npm-provenance.ts +123 -0
  286. package/server-managed/npm-risk.ts +285 -0
  287. package/server-managed/object-storage-disk.ts +148 -0
  288. package/server-managed/object-storage-vercel.ts +67 -0
  289. package/server-managed/object-storage.ts +75 -0
  290. package/server-managed/package-entry-points.ts +51 -0
  291. package/server-managed/pretty-print.ts +434 -0
  292. package/server-managed/public-workspace.ts +238 -0
  293. package/server-managed/pull-comment-reviews.ts +161 -0
  294. package/server-managed/pull-dependencies.ts +348 -0
  295. package/server-managed/pull-dependency-reviews.ts +328 -0
  296. package/server-managed/pull-reviews.ts +205 -0
  297. package/server-managed/queue.ts +39 -0
  298. package/server-managed/release-commits.ts +84 -0
  299. package/server-managed/rename-diff-worker.js +26 -0
  300. package/server-managed/rename-diff.ts +279 -0
  301. package/server-managed/repo-path.ts +21 -0
  302. package/server-managed/report-migration.ts +35 -0
  303. package/server-managed/report-query.ts +10 -0
  304. package/server-managed/report-response.ts +16 -0
  305. package/server-managed/report-sources.ts +178 -0
  306. package/server-managed/report-store.ts +85 -0
  307. package/server-managed/repository-actions.ts +218 -0
  308. package/server-managed/repository-agents.ts +355 -0
  309. package/server-managed/repository-aliases.ts +91 -0
  310. package/server-managed/repository-browser.ts +294 -0
  311. package/server-managed/repository-changes.ts +284 -0
  312. package/server-managed/repository-code.ts +215 -0
  313. package/server-managed/repository-dependency-reviews.ts +165 -0
  314. package/server-managed/repository-discovery.ts +82 -0
  315. package/server-managed/repository-history.ts +432 -0
  316. package/server-managed/repository-issues.ts +195 -0
  317. package/server-managed/repository-policy.ts +27 -0
  318. package/server-managed/repository-pulls.ts +156 -0
  319. package/server-managed/repository-pulse.ts +460 -0
  320. package/server-managed/repository-risk-assess.ts +105 -0
  321. package/server-managed/repository-risk-audit.ts +79 -0
  322. package/server-managed/repository-risk-read.ts +170 -0
  323. package/server-managed/repository-risk.ts +491 -0
  324. package/server-managed/repository-squashes.ts +168 -0
  325. package/server-managed/request-timing.ts +63 -0
  326. package/server-managed/reviewed-dependencies.ts +150 -0
  327. package/server-managed/revisions.ts +35 -0
  328. package/server-managed/scan-downloads.ts +87 -0
  329. package/server-managed/session.ts +132 -0
  330. package/server-managed/slugs.ts +38 -0
  331. package/server-managed/solidity-entry-points.ts +71 -0
  332. package/server-managed/sql-postgres.ts +34 -0
  333. package/server-managed/sql.ts +88 -0
  334. package/server-managed/static.ts +28 -0
  335. package/server-managed/storage-command.ts +16 -0
  336. package/server-managed/storage-db.ts +183 -0
  337. package/server-managed/storage-encryption.ts +290 -0
  338. package/server-managed/storage-maintenance.ts +259 -0
  339. package/server-managed/storage-payload.ts +121 -0
  340. package/server-managed/storage-stores.ts +91 -0
  341. package/server-managed/storage.ts +82 -0
  342. package/server-managed/team-app.ts +134 -0
  343. package/server-managed/team-catalog.ts +7 -0
  344. package/server-managed/team-feed.ts +180 -0
  345. package/server-managed/team-npm-scopes.ts +106 -0
  346. package/server-managed/team-reports.ts +307 -0
  347. package/server-managed/triage-response.ts +16 -0
  348. package/server-managed/type-only-worker.js +19 -0
  349. package/server-managed/type-only.ts +105 -0
  350. package/server-managed/uploads.ts +106 -0
  351. package/server-managed/upstream-cache.ts +243 -0
  352. package/server-managed/view-as.ts +58 -0
  353. package/server-managed/workspace-shares.ts +169 -0
  354. package/server.ts +70 -0
  355. package/server/index.ts +0 -480
  356. package/server/lifecycle.ts +0 -208
  357. /package/{LICENSE → LICENSE-MIT} +0 -0
  358. /package/{server → server-e2e}/auth.ts +0 -0
  359. /package/{server → server-e2e}/config.example.json +0 -0
  360. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  361. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  362. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  363. /package/{server → server-e2e}/peer.ts +0 -0
  364. /package/{server → server-e2e}/sse-session.ts +0 -0
  365. /package/{server → server-e2e}/util.ts +0 -0
  366. /package/{server → server-e2e}/validation.ts +0 -0
@@ -13,10 +13,10 @@
13
13
  // so the absent peer dep is never loaded under test.
14
14
  //
15
15
  // In production the wrapper is only ever imported on the Neon path
16
- // (`DATABASE_URL` set), where the operator has installed the peer dep
16
+ // (an e2e database URL set), where the operator has installed the peer dep
17
17
  // (`pnpm add @neondatabase/serverless`) so the re-export resolves. A
18
18
  // SQLite-only deploy never imports this module — the dynamic `import()`
19
- // sites are gated behind the `DATABASE_URL` branch in `index.ts`.
19
+ // sites are gated behind the configured database URL branch in `index.ts`.
20
20
  //
21
21
  // `@ts-ignore` rather than `@ts-expect-error`: when the peer dep IS
22
22
  // installed the specifier resolves and tsc sees a real type, which
@@ -8,7 +8,7 @@
8
8
  // the request body and answers with the upstream's JSON.
9
9
  //
10
10
  // The route is mounted at `/api/npm-advisories`. The same-origin
11
- // gate runs inside `dispatchNpmAdvisories` below (server/http.ts
11
+ // gate runs inside `dispatchNpmAdvisories` below (server-e2e/http.ts
12
12
  // calls the dispatcher directly without a pre-check), so any
13
13
  // browser request from a foreign origin is rejected with 403 before
14
14
  // we ever issue a fetch — non-browser callers that omit Origin are
@@ -25,37 +25,23 @@
25
25
  //
26
26
  // Outbound fetch carries an `AbortController` so a hung upstream
27
27
  // (slow-loris response, TLS stall) tears down after
28
- // `UPSTREAM_TIMEOUT_MS`, and a client that closes its connection
28
+ // `NPM_ADVISORIES_TIMEOUT_MS`, and a client that closes its connection
29
29
  // mid-fetch propagates an abort through the same controller — so a
30
30
  // stranded in-flight call doesn't block SIGTERM drain.
31
31
 
32
32
  import type { IncomingMessage, ServerResponse } from 'node:http'
33
33
  import { Buffer } from 'node:buffer'
34
34
  import { errStack } from './util.ts'
35
+ import { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
35
36
 
36
37
  type HasHeaders = { headers: IncomingMessage['headers'] }
37
38
 
38
39
  export const NPM_ADVISORIES_PATH = '/api/npm-advisories'
39
- const UPSTREAM_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'
40
-
41
40
  // 1 MiB is generous: the bulk endpoint accepts `{ packageName:
42
41
  // [versions] }` maps, and even a bundle with thousands of pinned
43
42
  // versions serialises to well under this. Anything bigger is almost
44
43
  // certainly malformed input or a probe.
45
44
  const REQUEST_BODY_LIMIT = 1 * 1024 * 1024
46
- // 4 MiB caps the upstream response. The advisories endpoint
47
- // returns at most a few CVEs per package, so even a bundle with
48
- // hundreds of affected packages comes in well under this — a
49
- // runaway / hostile upstream gets cut off before we burn arbitrary
50
- // memory buffering it.
51
- const RESPONSE_BODY_LIMIT = 4 * 1024 * 1024
52
- // Hard deadline on the upstream call. The bulk endpoint typically
53
- // answers in well under a second; 30 s leaves plenty of headroom
54
- // for a slow path but caps a hung TLS / slow-loris connection so
55
- // a stranded fetch can't pin the inflight slot through SIGTERM
56
- // drain. Triggered via AbortController.
57
- const UPSTREAM_TIMEOUT_MS = 30_000
58
-
59
45
  export type NpmProxyDeps = {
60
46
  debug: boolean
61
47
  }
@@ -129,11 +115,12 @@ function deny(res: ServerResponse, status: number, reason: string): void {
129
115
  // Same shape as `deny` but for the richer multi-field envelopes
130
116
  // (upstream-not-json / upstream-too-large) — these can't reuse
131
117
  // `deny` because the body carries more than `{ error }`.
132
- function writeJsonEnvelope(res: ServerResponse, status: number, body: object): void {
118
+ function writeJsonEnvelope(res: ServerResponse, status: number, body: unknown): void {
133
119
  if (!canWrite(res)) return
134
120
  try {
135
- res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
136
- res.end(JSON.stringify(body))
121
+ const out = JSON.stringify(body)
122
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(out) })
123
+ res.end(out)
137
124
  } catch {}
138
125
  }
139
126
 
@@ -154,7 +141,7 @@ async function readRequestBody(req: IncomingMessage): Promise<Buffer | null> {
154
141
  return Buffer.concat(chunks)
155
142
  }
156
143
 
157
- export async function handleNpmAdvisories(
144
+ async function handleNpmAdvisories(
158
145
  deps: NpmProxyDeps,
159
146
  req: IncomingMessage,
160
147
  res: ServerResponse,
@@ -162,7 +149,7 @@ export async function handleNpmAdvisories(
162
149
  if (req.method !== 'POST') { deny(res, 405, 'method-not-allowed'); return }
163
150
  // Single controller drives both the upstream deadline timer AND
164
151
  // the client-disconnect propagation: a fetch hung past
165
- // UPSTREAM_TIMEOUT_MS and a browser tab closed mid-fetch both end
152
+ // NPM_ADVISORIES_TIMEOUT_MS and a browser tab closed mid-fetch both end
166
153
  // up aborting the same signal, which undici threads through into
167
154
  // the body reader. Without this the inflight slot pinned by
168
155
  // `track()` could outlive both a dead client and a wedged
@@ -187,7 +174,7 @@ export async function handleNpmAdvisories(
187
174
  // doesn't fire a no-op abort — itself a no-op on a settled
188
175
  // controller, but skipping the log noise.)
189
176
  const controller = new AbortController()
190
- const timer = setTimeout(() => { try { controller.abort() } catch {} }, UPSTREAM_TIMEOUT_MS)
177
+ const timer = setTimeout(() => { try { controller.abort() } catch {} }, NPM_ADVISORIES_TIMEOUT_MS)
191
178
  const onResClose = (): void => {
192
179
  if (!res.writableEnded) controller.abort()
193
180
  }
@@ -219,130 +206,10 @@ export async function handleNpmAdvisories(
219
206
  try { req.destroy() } catch {}
220
207
  return
221
208
  }
222
- await handleNpmAdvisoriesInner(deps, body, res, controller.signal)
209
+ const result = await fetchNpmAdvisories(body, controller.signal, deps.debug)
210
+ writeJsonEnvelope(res, result.status, result.body)
223
211
  } finally {
224
212
  clearTimeout(timer)
225
213
  res.off('close', onResClose)
226
214
  }
227
215
  }
228
-
229
- async function handleNpmAdvisoriesInner(
230
- deps: NpmProxyDeps,
231
- body: Buffer,
232
- res: ServerResponse,
233
- signal: AbortSignal,
234
- ): Promise<void> {
235
- let upstream: Response
236
- try {
237
- upstream = await fetch(UPSTREAM_URL, {
238
- method: 'POST',
239
- // Force JSON — the bulk endpoint requires it. Drop every
240
- // client-supplied header to keep an upstream fingerprint from
241
- // leaking through (cookies, auth, custom UA, ...). The
242
- // registry's bulk endpoint doesn't need any of them for a
243
- // public lookup.
244
- headers: { 'content-type': 'application/json', 'accept': 'application/json' },
245
- // Re-wrap as a plain Uint8Array — Buffer's underlying
246
- // ArrayBufferLike type doesn't satisfy fetch's BodyInit
247
- // narrowing (it can't statically rule out SharedArrayBuffer),
248
- // but a copy through Uint8Array is zero-cost in practice and
249
- // unambiguously typed.
250
- body: new Uint8Array(body),
251
- signal,
252
- })
253
- } catch (err: unknown) {
254
- if (deps.debug) console.warn('npm-advisories upstream error:', errStack(err))
255
- // Client already gone — `canWrite` (inside `deny`) gates the
256
- // write so a destroyed / writableEnded socket doesn't trip
257
- // ERR_STREAM_DESTROYED on the way out.
258
- deny(res, 502, 'upstream-unreachable')
259
- return
260
- }
261
- // Assert JSON on the upstream body. The Content-Type header is
262
- // unreliable (Cloudflare in front of registry.npmjs.org strips it
263
- // from some responses; a captive portal / WAF can declare HTML on
264
- // a body that's actually JSON or vice-versa), so we don't lean on
265
- // it — instead we buffer the body and parse. A successful
266
- // JSON.parse is the strongest guarantee we can hand the UI's
267
- // `await res.json()`. Buffering is bounded by
268
- // `RESPONSE_BODY_LIMIT`; the advisories endpoint's payloads sit
269
- // well under that.
270
- const upstreamContentType = upstream.headers.get('content-type') ?? ''
271
- let buffered: Buffer | null
272
- try {
273
- buffered = await readUpstreamBody(upstream)
274
- } catch (err: unknown) {
275
- if (deps.debug) console.warn('npm-advisories upstream body error:', errStack(err))
276
- deny(res, 502, 'upstream-unreachable')
277
- return
278
- }
279
- if (buffered === null) {
280
- if (deps.debug) console.warn(`npm-advisories upstream too large: status=${upstream.status}`)
281
- writeJsonEnvelope(res, 502, { error: 'upstream-too-large', upstreamStatus: upstream.status })
282
- return
283
- }
284
- // Treat the body as UTF-8 — `JSON.parse` operates on a string and
285
- // the registry's responses are always UTF-8 in practice. A
286
- // non-UTF-8 byte sequence still decodes (with U+FFFD
287
- // substitution); the subsequent JSON.parse fails and routes
288
- // through the error branch.
289
- const text = buffered.toString('utf8')
290
- let parsed: unknown
291
- try {
292
- parsed = JSON.parse(text)
293
- } catch {
294
- if (deps.debug) console.warn(`npm-advisories upstream non-JSON: status=${upstream.status} ct=${upstreamContentType || '<none>'} bytes=${buffered.byteLength}`)
295
- writeJsonEnvelope(res, 502, {
296
- error: 'upstream-not-json',
297
- upstreamStatus: upstream.status,
298
- upstreamContentType: upstreamContentType || null,
299
- })
300
- return
301
- }
302
- if (!canWrite(res)) return
303
- // Re-stringify rather than echoing `text` so the wire shape we
304
- // emit is canonical (no upstream whitespace / BOM / trailing
305
- // junk after the parsed value rides along), and so the client
306
- // can rely on a single JSON document per response.
307
- const out = JSON.stringify(parsed)
308
- try {
309
- res.writeHead(upstream.status, {
310
- 'content-type': 'application/json',
311
- 'cache-control': 'no-store',
312
- 'content-length': Buffer.byteLength(out),
313
- })
314
- res.end(out)
315
- } catch {}
316
- }
317
-
318
- // Buffer the upstream response body up to RESPONSE_BODY_LIMIT.
319
- // Returns null if the cap is exceeded (caller maps to a 502
320
- // `upstream-too-large`), or the cumulative Buffer otherwise. A
321
- // transport error mid-read (e.g. AbortSignal fired by the deadline
322
- // timer or by `req` close) throws — the caller catches it.
323
- //
324
- // `finally { reader.cancel() }` is load-bearing on the error and
325
- // cap-exceeded paths: leaving the reader locked to the body holds
326
- // the underlying undici TCP socket out of the connection pool until
327
- // GC, and the cap-exceeded path explicitly needs to tear the
328
- // transfer down so we don't keep buffering bytes we'll never use.
329
- // On the clean-drain path (done:true), cancel() is a no-op.
330
- async function readUpstreamBody(upstream: Response): Promise<Buffer | null> {
331
- if (!upstream.body) return Buffer.alloc(0)
332
- const reader = upstream.body.getReader()
333
- const chunks: Uint8Array[] = []
334
- let received = 0
335
- try {
336
- for (;;) {
337
- const { done, value } = await reader.read()
338
- if (done) break
339
- if (!value) continue
340
- received += value.byteLength
341
- if (received > RESPONSE_BODY_LIMIT) return null
342
- chunks.push(value)
343
- }
344
- return Buffer.concat(chunks)
345
- } finally {
346
- try { await reader.cancel() } catch {}
347
- }
348
- }
@@ -1,5 +1,5 @@
1
1
  // Vercel Blob Private Storage BlobBackend. Paired with the Neon DB
2
- // plane in `server/index.ts` when `BLOB_READ_WRITE_TOKEN` is set;
2
+ // plane in `server-e2e/index.ts` when `BLOB_READ_WRITE_TOKEN` is set;
3
3
  // the combination is the supported multi-replica deployment shape
4
4
  // (Neon for metadata, Vercel Blob for bytes — both serverless, both
5
5
  // HTTP-backed, no shared filesystem required).
@@ -35,108 +35,10 @@
35
35
  // any single resource), and the stale-staging TTL sweep drops orphan
36
36
  // staging blobs.
37
37
 
38
- import { PassThrough, type Readable } from 'node:stream'
39
- import { Buffer } from 'node:buffer'
38
+ import { PassThrough } from 'node:stream'
40
39
  import type { BlobBackend, OpenLiveResult, StagingWriter } from './blob.ts'
41
40
  import { errMsg } from '../util.ts'
42
-
43
- // Minimal structural shape of the bits of `@vercel/blob` we use.
44
- // Kept local so the optional peer dep doesn't have to type-resolve
45
- // for SQLite-only deployments. Real type details live in the
46
- // installed package; the fields/parameters we touch here are stable
47
- // per the SDK v2 public surface.
48
- //
49
- // `put` accepts the SDK's full `PutBody` union (string | Readable |
50
- // Buffer | Blob | ArrayBuffer | ReadableStream | File). We only ever
51
- // pass a Node `PassThrough` (a Readable), but the wider type lets
52
- // callers reuse this signature for future buffer/blob bodies without
53
- // type gymnastics. `copy` accepts `allowOverwrite` — REQUIRED for
54
- // version bumps since the live pathname is reused on re-upload.
55
- type VercelBlobBody = Readable | Buffer | string | Blob | ArrayBuffer | ReadableStream<Uint8Array>
56
- type VercelBlobSdk = {
57
- put: (
58
- pathname: string,
59
- body: VercelBlobBody,
60
- options: {
61
- access: 'private' | 'public'
62
- allowOverwrite?: boolean
63
- contentType?: string
64
- token?: string
65
- multipart?: boolean
66
- abortSignal?: AbortSignal
67
- cacheControlMaxAge?: number
68
- },
69
- ) => Promise<{ url: string; pathname: string }>
70
- head: (
71
- pathname: string,
72
- options?: { token?: string; abortSignal?: AbortSignal },
73
- ) => Promise<{ size: number; pathname: string; url: string }>
74
- get: (
75
- pathname: string,
76
- options: {
77
- access: 'private' | 'public'
78
- token?: string
79
- useCache?: boolean
80
- abortSignal?: AbortSignal
81
- },
82
- ) => Promise<{
83
- statusCode: 200 | 304
84
- stream: ReadableStream<Uint8Array> | null
85
- blob: { size: number | null }
86
- } | null>
87
- copy: (
88
- fromPathname: string,
89
- toPathname: string,
90
- options: {
91
- access: 'private' | 'public'
92
- allowOverwrite?: boolean
93
- token?: string
94
- contentType?: string
95
- cacheControlMaxAge?: number
96
- },
97
- ) => Promise<{ url: string; pathname: string }>
98
- del: (
99
- urlOrPathname: string | string[],
100
- options?: { token?: string; abortSignal?: AbortSignal },
101
- ) => Promise<void>
102
- list: (options: {
103
- prefix?: string
104
- cursor?: string
105
- limit?: number
106
- mode?: 'expanded' | 'folded'
107
- token?: string
108
- }) => Promise<{
109
- // `uploadedAt` (a Date per the SDK v2 surface) is the blob's
110
- // creation time — used by the reaper's GC grace window. Optional
111
- // in the type because the `folded` listing path ignores it (only
112
- // `listLiveBlobs` reads it, and it uses the default expanded mode).
113
- blobs: Array<{ pathname: string; size: number; uploadedAt?: Date | string | number }>
114
- folders?: string[]
115
- cursor?: string
116
- hasMore: boolean
117
- }>
118
- }
119
-
120
- // Recognise "blob is gone" errors uniformly across read/write/delete
121
- // paths so callers can treat them as success (delete) or
122
- // not-found (read). The SDK exposes BlobNotFoundError as a class with
123
- // `.name === 'BlobNotFoundError'`; checking the name string avoids
124
- // importing the class at the top level (which would force the optional
125
- // peer dep to resolve).
126
- //
127
- // Class-name check ONLY — the SDK's internal mapper turns every API
128
- // `not_found` into BlobNotFoundError-by-name, so a bare-404 transport
129
- // leak doesn't reach here. A broader `/does not exist|\b404\b/` match
130
- // is DANGEROUS: it also matches BlobStoreNotFoundError's "This store
131
- // does not exist.", so a config fault (revoked token, deleted store)
132
- // would silently surface as every-blob-missing across reads/unlinks,
133
- // masking the fatal misconfiguration. The tight name check lets
134
- // BlobStoreNotFoundError / other classes propagate as real exceptions.
135
- function isNotFound(err: unknown): boolean {
136
- if (err == null || typeof err !== 'object') return false
137
- const name = (err as { name?: unknown }).name
138
- return typeof name === 'string' && name === 'BlobNotFoundError'
139
- }
41
+ import { type VercelBlobSdk, isNotFound, loadVercelBlobSdk } from '../../server-common/vercel-blob.ts'
140
42
 
141
43
  function liveBlobPath(tag: string, contentHash: string): string {
142
44
  return `${tag}/${contentHash}.bin`
@@ -293,7 +195,7 @@ function buildStatStaging(sdk: VercelBlobSdk, token: string): BlobBackend['statS
293
195
  const h = await sdk.head(stagingBlobPath(tag, stagingId), { token })
294
196
  return h.size
295
197
  } catch (err) {
296
- if (isNotFound(err)) return null
198
+ if (isNotFound(err, sdk)) return null
297
199
  throw err
298
200
  }
299
201
  }
@@ -363,8 +265,8 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
363
265
  // `not-found` would emit a 404 the FS backend never emits for the
364
266
  // same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
365
267
  // the client the resource is gone for good when it should refetch.
366
- // See server/README.md's GET status table.
367
- if (isNotFound(err)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
268
+ // See server-e2e/README.md's GET status table.
269
+ if (isNotFound(err, sdk)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
368
270
  throw err
369
271
  }
370
272
  // SDK returned null (no blob) — same "bytes missing for a live row"
@@ -395,7 +297,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
395
297
  // Blob vanished between get() and the head() size fallback (a
396
298
  // racing reaper GC) — still the "live row present, bytes gone"
397
299
  // transient, so `unavailable` (503), matching the get() path above.
398
- if (isNotFound(headErr)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
300
+ if (isNotFound(headErr, sdk)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
399
301
  throw headErr
400
302
  }
401
303
  }
@@ -426,7 +328,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
426
328
  function buildUnlink(sdk: VercelBlobSdk, token: string, op: 'staging' | 'live', toPath: (tag: string, id: string) => string): (tag: string, id: string) => Promise<void> {
427
329
  return async (tag, id) => {
428
330
  try { await sdk.del(toPath(tag, id), { token }) } catch (err) {
429
- if (isNotFound(err)) return
331
+ if (isNotFound(err, sdk)) return
430
332
  // Don't propagate — the DB-side row drop has already
431
333
  // committed by the time the caller reaches here, and the
432
334
  // reaper picks up the stranded blob on its next sweep. Same
@@ -493,7 +395,7 @@ export type VercelBlobBackendOptions = {
493
395
  // Vercel Blob R/W token, typically from BLOB_READ_WRITE_TOKEN.
494
396
  // The SDK also reads it from process.env, but passing it
495
397
  // explicitly here keeps the env-var → boot config path single-
496
- // sourced through server/index.ts (matches the Neon DATABASE_URL
398
+ // sourced through server-e2e/index.ts (matches the Neon database URL
497
399
  // handling — env-read at boot, threaded as a parameter).
498
400
  token: string
499
401
  // Test seam: inject a stub of the @vercel/blob module to avoid
@@ -507,7 +409,7 @@ export async function openVercelBlobBackend(opts: VercelBlobBackendOptions): Pro
507
409
  // dep. `@ts-ignore` (not `@ts-expect-error`) so an operator who
508
410
  // DOES install `@vercel/blob` doesn't trip TS2578 "unused
509
411
  // directive" — same pattern as db-neon.ts.
510
- const sdk: VercelBlobSdk = opts.sdk ?? (await loadSdk())
412
+ const sdk: VercelBlobSdk = opts.sdk ?? (await loadVercelBlobSdk())
511
413
  const token = opts.token
512
414
  return {
513
415
  // No-op: Vercel Blob has no folder concept. The pathname's
@@ -533,9 +435,3 @@ export async function openVercelBlobBackend(opts: VercelBlobBackendOptions): Pro
533
435
  listStagingIds: buildListStagingIds(sdk, token, (tag) => `${tag}/.staging/`),
534
436
  }
535
437
  }
536
-
537
- async function loadSdk(): Promise<VercelBlobSdk> {
538
- // @ts-ignore optional peer dep: '@vercel/blob'
539
- const mod = (await import('@vercel/blob')) as VercelBlobSdk
540
- return mod
541
- }
@@ -11,7 +11,7 @@
11
11
  // the Neon DB plane for multi-replica
12
12
  // deployments)
13
13
  //
14
- // Selected at boot in `server/index.ts` and passed to `openObjstore`
14
+ // Selected at boot in `server-e2e/index.ts` and passed to `openObjstore`
15
15
  // / `openNeonObjstore`. The DB-plane code in ./store.ts, ./rest.ts,
16
16
  // and ./reaper.ts goes through `handle.blob.*` and is backend-
17
17
  // agnostic — no `if (vercel) … else` branching in consumers.
@@ -0,0 +1,74 @@
1
+ // Anti-replay guard for the REST fetch-mint endpoint (POST
2
+ // /api/objstore/{tag}/{res}, see ./rest.ts). The WS fetch handshake binds
3
+ // the per-connection challenge nonce so a captured frame can't be
4
+ // replayed; the REST mint has no connection, so it binds a client
5
+ // timestamp instead and this guard supplies the matching freshness +
6
+ // dedup the nonce gave for free:
7
+ //
8
+ // - FRESHNESS: reject a request whose `ts` is outside ±`windowMs` of
9
+ // server time (an old captured request, or a future-dated one).
10
+ // - DEDUP: within the window, a signature is accepted at most once — a
11
+ // captured-and-replayed request (same `ts` ⇒ same signature) is
12
+ // rejected on the second presentation.
13
+ //
14
+ // Memory: each accepted signature is held for `windowMs` then pruned.
15
+ // Entries share a uniform TTL and the Map preserves insertion order, so
16
+ // the oldest entries expire first and a cheap front-prune (amortised
17
+ // O(1) per entry) keeps the set bounded; a hard `maxEntries` cap drops
18
+ // the oldest beyond it as a flood backstop.
19
+ //
20
+ // Scope: per-process. In a multi-replica deployment a captured request
21
+ // could be replayed once per replica that hasn't seen its signature yet
22
+ // (bounded by the replica count, within the window) — acceptable because
23
+ // the mint only ever yields a short-TTL GET token over AEAD ciphertext
24
+ // the relay can't read. A shared-store dedup (Redis/Neon) would close
25
+ // that residual gap if ever needed.
26
+
27
+ export type FetchMintVerdict = 'ok' | 'stale' | 'replay'
28
+
29
+ export type FetchMintGuard = {
30
+ // `signature` is the request's Ed25519 signature (unique per
31
+ // (tag, res, ts) tuple, so it doubles as the dedup key). `ts` is the
32
+ // client epoch-ms timestamp the signature commits to; `now` is the
33
+ // server clock (injectable for tests).
34
+ admit: (signature: string, ts: number, now?: number) => FetchMintVerdict
35
+ size: () => number
36
+ }
37
+
38
+ const DEFAULT_FETCH_MINT_WINDOW_MS = 60_000
39
+ const DEFAULT_FETCH_MINT_MAX_ENTRIES = 50_000
40
+
41
+ export function createFetchMintGuard(
42
+ { windowMs = DEFAULT_FETCH_MINT_WINDOW_MS, maxEntries = DEFAULT_FETCH_MINT_MAX_ENTRIES }:
43
+ { windowMs?: number; maxEntries?: number } = {},
44
+ ): FetchMintGuard {
45
+ // signature → expiry (ms). Insertion-ordered; uniform TTL ⇒ the head is
46
+ // always the soonest to expire.
47
+ const seen = new Map<string, number>()
48
+
49
+ function admit(signature: string, ts: number, now: number = Date.now()): FetchMintVerdict {
50
+ // Freshness first — a stale/future request never touches the cache, so
51
+ // it can't be used to grow the set.
52
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > windowMs) return 'stale'
53
+ // Front-prune expired entries (contiguous at the head under the
54
+ // uniform TTL). Breaks at the first live entry.
55
+ for (const [key, exp] of seen) {
56
+ if (exp > now) break
57
+ seen.delete(key)
58
+ }
59
+ // Post-prune, any remaining entry is live, so a hit is a genuine replay.
60
+ if (seen.has(signature)) return 'replay'
61
+ seen.set(signature, now + windowMs)
62
+ // Flood backstop: drop the oldest beyond the cap. Those are the
63
+ // closest to expiry anyway; dropping them only shortens their dedup
64
+ // window (still freshness-gated).
65
+ while (seen.size > maxEntries) {
66
+ const oldest = seen.keys().next().value
67
+ if (oldest === undefined) break
68
+ seen.delete(oldest)
69
+ }
70
+ return 'ok'
71
+ }
72
+
73
+ return { admit, size: () => seen.size }
74
+ }
@@ -48,9 +48,9 @@ export type ObjstoreDeps = {
48
48
  // Cross-instance pub/sub for objstore-deleted. Fired alongside the
49
49
  // local `broadcast` after a successful delete so peers on OTHER
50
50
  // server instances see the version drop in real time. Carries the
51
- // full (tag, resourceTag, version) tuple inline — the workspace_object
51
+ // full (tag, resourceTag, version, incarnation) tuple inline — the workspace_object
52
52
  // row is gone post-delete, so the bus payload IS the wire data.
53
- publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
53
+ publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
54
54
  getNonce: (socket: WebSocket) => string | undefined
55
55
  debug: boolean
56
56
  // Auth gate for the FIRST put-begin against a never-before-seen
@@ -106,9 +106,9 @@ async function handlePutBegin(deps: ObjstoreDeps, socket: WebSocket, msg: Objsto
106
106
  const resourceTag = msg.resourceTag
107
107
  // Auth gate for the FIRST action against a never-before-seen
108
108
  // workspace tag (no rows in workspace_revision AND none in
109
- // workspace_object). Mirrors handleSave in server/index.ts; runs
109
+ // workspace_object). Mirrors handleSave in server-e2e/index.ts; runs
110
110
  // AFTER sig verify so `unauthorized` only reaches a legitimate
111
- // signer. Config-driven (server/config.json `password`), no-op when
111
+ // signer. Config-driven (server-e2e/config.json `password`), no-op when
112
112
  // unconfigured.
113
113
  if (deps.authGate && deps.sendUnauthorized && await deps.authGate(socket, tag)) {
114
114
  if (socket.readyState !== socket.OPEN) return
@@ -191,13 +191,14 @@ async function handleDelete(deps: ObjstoreDeps, socket: WebSocket, msg: Objstore
191
191
  // observes its own PUTs as echo events. The same symmetry on
192
192
  // `onDeleted` lets `session.onDeleted` fire for the session's
193
193
  // own deletes — pinned by `tests/objstore-client-races.test.js`.
194
- deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion }, null)
194
+ // A successful nonempty delete matched this authenticated incarnation.
195
+ deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion, incarnation: prevIncarnation! }, null)
195
196
  // Cross-instance fan-out (Neon mode). The workspace_object row is
196
197
  // gone post-delete so the bus payload carries (tag, resourceTag,
197
- // version) inline; the receiver builds its `objstore-deleted`
198
+ // version, incarnation) inline; the receiver builds its `objstore-deleted`
198
199
  // broadcast directly from the bus envelope. SQLite mode publishes
199
200
  // to a no-op.
200
- deps.publishObjDeleted(tag, resourceTag, result.deletedVersion)
201
+ deps.publishObjDeleted(tag, resourceTag, result.deletedVersion, prevIncarnation!)
201
202
  if (deps.debug) console.log(`objstore delete → ${debugTag(tag)}/${resourceTag.slice(0, 8)}…`)
202
203
  }
203
204
 
@@ -228,7 +229,7 @@ async function handleFetch(deps: ObjstoreDeps, socket: WebSocket, msg: ObjstoreF
228
229
  // the reaper's TTL pass within `STAGING_TTL_MS_DEFAULT` — no per-
229
230
  // socket bookkeeping is needed on disconnect today. If that ever
230
231
  // changes, wire a `cleanupSocket(socket)` into both this bundle and
231
- // server/index.ts's close handler.
232
+ // server-e2e/ws-server.ts's per-socket close handler.
232
233
 
233
234
  export type ObjstoreHandlers = {
234
235
  handlePutBegin: (s: WebSocket, m: ObjstorePutBeginMsg) => Promise<void>