@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
@@ -8,9 +8,10 @@
8
8
  //
9
9
  // One route, POSTs only:
10
10
  //
11
- // POST /api/sync/sse[?id=<sid>]
11
+ // POST /api/sync/sse
12
12
  // Request body:
13
- // { password?: string, — cached client password
13
+ // { id?: string, — session continuation token
14
+ // password?: string, — cached client password
14
15
  // frames?: Array<protocol-frame> — WS-style JSON frames
15
16
  // }
16
17
  // Response:
@@ -25,14 +26,23 @@
25
26
  // carrying the WS protocol's JSON envelopes.
26
27
  //
27
28
  // Continuation: each POST replaces the previous POST's response as the
28
- // session's downstream channel. If the `?id=<sid>` in the URL matches
29
- // a session this replica knows, the session continues (new outbound
30
- // stream attached, old one end()ed). If the id is unknown (different
31
- // replica picked up the POST, or session expired), a fresh session
32
- // with a new id is created and announced via the first `session`
33
- // event; the client uses the new id on all subsequent POSTs and re-
34
- // sends its subscribe frames on the next POST (its `frames` carry the
35
- // signed subscribes — they always do, see client/sync/sse-transport.ts).
29
+ // session's downstream channel. If the body's `id` matches a session
30
+ // this replica knows, the session continues (new outbound stream
31
+ // attached, old one end()ed). If the id is unknown (different replica
32
+ // picked up the POST, or session expired), a fresh session with a new
33
+ // id is created and announced via the first `session` event; the
34
+ // client uses the new id on all subsequent POSTs and re-sends its
35
+ // subscribe frames on the next POST (its `frames` carry the signed
36
+ // subscribes — they always do, see client/sync/sse-transport.ts).
37
+ //
38
+ // The id rides the JSON body, NOT the URL: the sid is a live bearer
39
+ // capability (whoever presents it attaches to the session's downstream
40
+ // and inherits its operator-auth flag), and a query-string token leaks
41
+ // into proxy / LB access logs — the same reason the objstore bearer
42
+ // tokens ride the Authorization header, never the URL. A legacy
43
+ // `?id=<sid>` query form is still ACCEPTED (older client bundles sent
44
+ // it; rejecting would churn them through a fresh session per POST),
45
+ // but current clients never emit it.
36
46
  //
37
47
  // Why POSTs only: a long-lived GET pins the client to one replica via
38
48
  // TCP affinity, which would mean POSTs from the same client must be
@@ -47,7 +57,7 @@ import { type PeerConnectionDeps, setupPeerConnection } from './ws-server.ts'
47
57
  import { SseSession } from './sse-session.ts'
48
58
  import { errMsg, randomId } from './util.ts'
49
59
 
50
- // Same prefix the WS upgrade lives under (server/http.ts WS_UPGRADE_PATH
60
+ // Same prefix the WS upgrade lives under (server-e2e/http.ts WS_UPGRADE_PATH
51
61
  // = '/api/sync'); subroute keeps the SSE plane sibling to the upgrade
52
62
  // path so the same `location` block routes both.
53
63
  export const SSE_OPEN_PATH = '/api/sync/sse'
@@ -75,7 +85,7 @@ export type SseServerDeps = {
75
85
  // transport into it. Closure over the same handler / hub / objstore
76
86
  // / track / debug surface the WS path uses.
77
87
  peerDeps: PeerConnectionDeps
78
- // Shutdown gate. Mirror of the REST + WS branches in server/http.ts:
88
+ // Shutdown gate. Mirror of the REST + WS branches in server-e2e/http.ts:
79
89
  // an SSE POST arriving on an existing keep-alive socket after
80
90
  // SIGTERM should be rejected, not dispatched against a draining DB.
81
91
  isShuttingDown: () => boolean
@@ -83,7 +93,7 @@ export type SseServerDeps = {
83
93
  // 503 the open request. Caps the SSE-side equivalent of `wss.clients`.
84
94
  maxSessions: number
85
95
  // Max body size for one POST. Matches the WS `maxPayload` in
86
- // server/index.ts so the SSE plane can't accept frames the WS plane
96
+ // server-e2e/index.ts so the SSE plane can't accept frames the WS plane
87
97
  // would reject. The POST body envelope can hold multiple frames so
88
98
  // the per-frame budget is the same as the WS plane after the
89
99
  // dispatcher splits them.
@@ -106,8 +116,10 @@ export type SseServer = {
106
116
  // Inbound POST body. Every field optional — an empty-body POST is a
107
117
  // valid "wake the session" probe (the response stream rides on every
108
118
  // POST), and a body that carries only `password` or only `frames` is
109
- // a normal partial update.
119
+ // a normal partial update. `id` is the session continuation token
120
+ // (see the header's "Continuation" note for why it rides the body).
110
121
  type SseBody = {
122
+ id?: unknown
111
123
  password?: unknown
112
124
  frames?: unknown
113
125
  }
@@ -116,12 +128,13 @@ export function installSseServer(deps: SseServerDeps): SseServer {
116
128
  const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes, debug } = deps
117
129
 
118
130
  // Active SSE sessions, keyed by the random session id `createSession`
119
- // mints on the first POST that lacks a `?id=` (or whose id this
120
- // replica doesn't recognise) and that subsequent POSTs echo back to
121
- // continue the session. Bounded by `maxSessions` — over the cap, new
122
- // POSTs get a 503. POSTs against an unknown id are NOT 404'd — they
123
- // mint a fresh session instead, so a multi-replica deployment doesn't
124
- // require sticky LB routing to recover.
131
+ // mints on the first POST that carries no continuation id (or whose
132
+ // id this replica doesn't recognise) and that subsequent POSTs echo
133
+ // back (body `id` field) to continue the session. Bounded by
134
+ // `maxSessions` — over the cap, new POSTs get a 503. POSTs against an
135
+ // unknown id are NOT 404'd — they mint a fresh session instead, so a
136
+ // multi-replica deployment doesn't require sticky LB routing to
137
+ // recover.
125
138
  const sessions = new Map<string, SseSession>()
126
139
 
127
140
  function dropSession(sid: string): void {
@@ -251,6 +264,12 @@ export function installSseServer(deps: SseServerDeps): SseServer {
251
264
  return
252
265
  }
253
266
  }
267
+ // Resolve the continuation token: the JSON body's `id` is the
268
+ // canonical carrier; the `?id=` query form is the legacy
269
+ // fallback for older client bundles (see the header note — a
270
+ // query sid leaks into access logs). Body wins when both are
271
+ // present (current clients only ever send one).
272
+ const sid = parseSid(body.id) ?? sidFromUrl
254
273
  // Look up the session by id (if the client sent one). If found
255
274
  // and alive, attach the new response and dispatch. If not (or
256
275
  // session is closed), mint a fresh one — the response stream
@@ -265,8 +284,8 @@ export function installSseServer(deps: SseServerDeps): SseServer {
265
284
  // through to createSession with `res` still header-virgin, so
266
285
  // the new session can writeSseHeaders without ERR_HTTP_HEADERS_SENT.
267
286
  let session: SseSession | null = null
268
- if (sidFromUrl) {
269
- const existing = sessions.get(sidFromUrl)
287
+ if (sid) {
288
+ const existing = sessions.get(sid)
270
289
  if (existing && existing.readyState === existing.OPEN && existing.attachResponse(res)) {
271
290
  writeSseHeaders(res)
272
291
  session = existing
@@ -333,25 +352,33 @@ export function installSseServer(deps: SseServerDeps): SseServer {
333
352
  return { handle, sessions: () => sessions.values(), keepaliveTimer }
334
353
  }
335
354
 
336
- // Bare-bones query parse for `id=<base64url>`. Avoids URLSearchParams
355
+ // Shape gate for a continuation token from either carrier (body `id`
356
+ // field or the legacy `?id=` query). The {1,64} bound is a
357
+ // deliberately lenient sanity gate: anything outside the base64url
358
+ // alphabet is rejected; an unrecognised sid just gets a fresh session
359
+ // from createSession (no failure mode), and the wide length window
360
+ // keeps a future randomId-length change from silently breaking old
361
+ // clients that round-trip a longer/shorter token. Returns null on
362
+ // missing / non-string / malformed.
363
+ function parseSid(v: unknown): string | null {
364
+ if (typeof v !== 'string') return null
365
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(v)) return null
366
+ return v
367
+ }
368
+
369
+ // Bare-bones query parse for the LEGACY `id=<base64url>` carrier
370
+ // (older client bundles; current clients send the sid in the POST
371
+ // body — see the header's "Continuation" note). Avoids URLSearchParams
337
372
  // (which decodes percent-escapes) — `randomId()` mints a 22-char
338
373
  // base64url string echoed back unchanged, so no escapes are possible
339
- // on the legitimate path. The {1,64} bound is a deliberately lenient
340
- // sanity gate: anything outside the base64url alphabet is rejected; an
341
- // unrecognised sid just gets a fresh session from createSession (no
342
- // failure mode), and the wide length window keeps a future
343
- // randomId-length change from silently breaking old clients that
344
- // round-trip a longer/shorter token. Returns null on missing /
345
- // malformed.
374
+ // on the legitimate path.
346
375
  function parseSidQuery(query: string | undefined): string | null {
347
376
  if (typeof query !== 'string') return null
348
377
  for (const part of query.split('&')) {
349
378
  const eq = part.indexOf('=')
350
379
  if (eq <= 0) continue
351
380
  if (part.slice(0, eq) !== 'id') continue
352
- const v = part.slice(eq + 1)
353
- if (!/^[A-Za-z0-9_-]{1,64}$/u.test(v)) return null
354
- return v
381
+ return parseSid(part.slice(eq + 1))
355
382
  }
356
383
  return null
357
384
  }
@@ -6,8 +6,8 @@
6
6
  // `join(staticDir, dirent.name)` from `readdirSync` at boot — the
7
7
  // request URL is only used as a Map key, never joined with the
8
8
  // filesystem — so the handler has no path-traversal surface at all:
9
- // `..`, percent-encoded slashes, nested subpaths and absolute-form
10
- // URIs all just produce a key that isn't in the map and 404.
9
+ // `..`, percent-encoded slashes and absolute-form URIs all just
10
+ // produce a key that isn't in the map and 404.
11
11
  //
12
12
  // Compression: every entry whose extension is in COMPRESSIBLE gets
13
13
  // pre-computed brotli + gzip at boot. The handler picks brotli over
@@ -38,6 +38,7 @@ import { readFileSync, readdirSync } from 'node:fs'
38
38
  import { createHash } from 'node:crypto'
39
39
  import { brotliCompressSync, gzipSync, constants as zlibConstants } from 'node:zlib'
40
40
  import { extname, join } from 'node:path'
41
+ import { scanServerHtml } from '../server-common/scan-config.ts'
41
42
 
42
43
  // MIME types for extensions the production UI bundle currently
43
44
  // emits. Anything else falls through to `application/octet-stream`
@@ -64,15 +65,24 @@ const COMPRESSIBLE = new Set(['.html', '.css', '.js', '.svg', '.webmanifest'])
64
65
  // navigated `image/svg+xml` icon into something script-executable.
65
66
  // `no-referrer` keeps URLs of this (potentially sensitive) viewer out
66
67
  // of outbound Referer headers, and `same-origin` CORP stops other
67
- // origins embedding these bytes. CSP is deliberately NOT here: the
68
- // HTML documents carry their own per-page `<meta http-equiv>` policy
69
- // (and the three pages differ), so a blanket header CSP would just AND
70
- // against the meta one and risk breaking a page — non-document assets
71
- // get their own flat CSP via `StaticEntry.csp` instead.
68
+ // origins embedding these bytes. COOP `same-origin` severs the
69
+ // window.opener link to any cross-origin opener/popup (so an external
70
+ // `target=_blank` GitHub/Claude link can't reach back into this context),
71
+ // and COEP `require-corp` bars the document from loading any cross-origin
72
+ // subresource that doesn't opt in via CORP/CORS. Together they also make
73
+ // the page cross-origin-isolated. Safe here because the bundle is fully
74
+ // same-origin (no external scripts/images/fonts/iframes) — switch COEP to
75
+ // `credentialless` if a cross-origin resource is ever added. CSP is
76
+ // deliberately NOT here: the HTML documents carry their own per-page
77
+ // `<meta http-equiv>` policy (and the three pages differ), so a blanket
78
+ // header CSP would just AND against the meta one and risk breaking a page
79
+ // — non-document assets get their own flat CSP via `StaticEntry.csp`.
72
80
  const SECURITY_HEADERS = {
73
81
  'x-content-type-options': 'nosniff',
74
82
  'referrer-policy': 'no-referrer',
75
83
  'cross-origin-resource-policy': 'same-origin',
84
+ 'cross-origin-opener-policy': 'same-origin',
85
+ 'cross-origin-embedder-policy': 'require-corp',
76
86
  } as const
77
87
 
78
88
  type StaticEntry = {
@@ -93,8 +103,10 @@ export type StaticHandler = (req: HttpRequest, res: ServerResponse) => boolean
93
103
  // caller falls through to its next route. Missing `staticDir`
94
104
  // (pre-build case) logs a warning and returns a handler that always
95
105
  // answers false; the API/WS planes are unaffected.
96
- export function loadStatic(staticDir: string): StaticHandler {
97
- const files = readStaticFiles(staticDir)
106
+ type StaticOptions = { transformIndex?: (html: string) => string, indexOnly?: boolean }
107
+
108
+ export function loadStatic(staticDir: string, deepviewScanServer: string | null = null, options: StaticOptions = {}): StaticHandler {
109
+ const files = readStaticFiles(staticDir, deepviewScanServer, options)
98
110
  return function handleStatic(req: HttpRequest, res: ServerResponse): boolean {
99
111
  if (req.method !== 'GET' && req.method !== 'HEAD') return false
100
112
  if (typeof req.url !== 'string') return false
@@ -142,7 +154,7 @@ export function loadStatic(staticDir: string): StaticHandler {
142
154
  }
143
155
  }
144
156
 
145
- function readStaticFiles(staticDir: string): ReadonlyMap<string, StaticEntry> {
157
+ function readStaticFiles(staticDir: string, deepviewScanServer: string | null, options: StaticOptions): ReadonlyMap<string, StaticEntry> {
146
158
  const files = new Map<string, StaticEntry>()
147
159
  let entries
148
160
  try { entries = readdirSync(staticDir, { withFileTypes: true }) } catch (err) {
@@ -156,18 +168,17 @@ function readStaticFiles(staticDir: string): ReadonlyMap<string, StaticEntry> {
156
168
  throw err
157
169
  }
158
170
  for (const entry of entries) {
159
- // `isFile()` filters subdirectories, symlinks, FIFOs etc. The
160
- // build emits a flat tree; dropping anything else keeps the
161
- // surface minimal even if a stray non-file sneaks in.
162
- if (!entry.isFile()) continue
163
- files.set(entry.name, buildEntry(staticDir, entry.name))
171
+ // Exclude subdirectories, symlinks and non-file entries.
172
+ if (!entry.isFile() || (options.indexOnly && entry.name !== 'index.html')) continue
173
+ files.set(entry.name, buildEntry(staticDir, entry.name, deepviewScanServer, options.transformIndex))
164
174
  }
165
175
  return files
166
176
  }
167
177
 
168
- function buildEntry(staticDir: string, name: string): StaticEntry {
178
+ function buildEntry(staticDir: string, name: string, deepviewScanServer: string | null = null, transformIndex: (html: string) => string = html => html): StaticEntry {
169
179
  const ext = extname(name)
170
- const raw = readFileSync(join(staticDir, name))
180
+ const source = readFileSync(join(staticDir, name))
181
+ const raw = name === 'index.html' ? Buffer.from(transformIndex(scanServerHtml(source.toString('utf8'), deepviewScanServer))) : source
171
182
  const type = CONTENT_TYPE[ext] ?? 'application/octet-stream'
172
183
  // HTML: lift `<link rel="(module)preload" …>` into a Link header and
173
184
  // drop the tags from the served body. ETag + compression run against
@@ -183,13 +194,12 @@ function buildEntry(staticDir: string, name: string): StaticEntry {
183
194
  // collision-resistance against the ~tens of files we load.
184
195
  const etag = `"${createHash('sha256').update(identity).digest('hex').slice(0, 32)}"`
185
196
  const isCompressible = COMPRESSIBLE.has(ext)
186
- // Brotli quality 11 is the maximum — slow at compress time but
187
- // we pay it once at boot and ship the smaller bytes on every
188
- // request thereafter. text mode tells the encoder we're working
197
+ // Brotli quality 9 limits boot-time compression work, including on
198
+ // serverless cold starts. Text mode tells the encoder we're working
189
199
  // on UTF-8 text (every COMPRESSIBLE entry is text-shaped).
190
200
  const br = isCompressible ? brotliCompressSync(identity, {
191
201
  params: {
192
- [zlibConstants.BROTLI_PARAM_QUALITY]: 11,
202
+ [zlibConstants.BROTLI_PARAM_QUALITY]: 9,
193
203
  [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT,
194
204
  },
195
205
  }) : null
@@ -26,6 +26,8 @@ type WireRevision = {
26
26
  signature: string
27
27
  }
28
28
 
29
+ type SubscriptionLookup = { resume: () => void }
30
+
29
31
  // Structured result of the shared save pipeline (`commitSave`), rendered
30
32
  // per transport: WS → protocol frames; REST → JSON + HTTP status.
31
33
  type SaveOutcome =
@@ -79,6 +81,7 @@ export type SyncHandlersDeps = {
79
81
  // payload budget. Optional: a SQLite deployment passes a no-op.
80
82
  publishRevision: (tag: string, revisionId: string) => void
81
83
  subscribe: (socket: WebSocket, tag: string) => void
84
+ pauseBroadcasts: (socket: WebSocket) => () => void
82
85
  getNonce: (socket: WebSocket) => string | undefined
83
86
  requiresAuth: (socket: WebSocket) => boolean
84
87
  // Whether an operator password is configured. The REST save plane's
@@ -101,7 +104,7 @@ export type SyncHandlersDeps = {
101
104
  export type SyncHandlers = {
102
105
  handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
103
106
  // Session-independent REST save plane (`POST /api/sync/save`), wired into
104
- // the HTTP dispatcher in server/http.ts. Runs the same pipeline as
107
+ // the HTTP dispatcher in server-e2e/http.ts. Runs the same pipeline as
105
108
  // `handleSave` and renders the outcome as a JSON response.
106
109
  handleSaveRest: (req: IncomingMessage, res: ServerResponse) => Promise<void>
107
110
  handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
@@ -112,6 +115,9 @@ export type SyncHandlers = {
112
115
 
113
116
  export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
114
117
  const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
118
+ // Only the newest lookup for a socket/tag may publish a snapshot.
119
+ // Versions cannot order different object incarnations (or deletions).
120
+ const subscriptionLookups = new WeakMap<WebSocket, Map<string, SubscriptionLookup>>()
115
121
 
116
122
  // Typed wrapper for the three `workspace-save-error` emit sites
117
123
  // (too-large at handleSave, stale-base after the catch-up, busy at
@@ -238,6 +244,14 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
238
244
  // chain in the 409 body. Either way the catch-up clears the client's
239
245
  // pending and the error is a no-op on the now-missing pending (a
240
246
  // recoverable race — client rebases + re-saves).
247
+ //
248
+ // The catch-up CAN be empty: a client holding a base from a chain
249
+ // this deployment no longer has (wiped / moved DB, SQLite→Neon
250
+ // migration — head=null, no keyframe, no rows) gets `revisions: []`.
251
+ // The client detects that shape (pending survives the catch-up) and
252
+ // answers with a full-state push re-anchored at base=null, which
253
+ // commits as the new chain root — see the client's
254
+ // `handleSaveError` stale-base branch.
241
255
  const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
242
256
  if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
243
257
  return { kind: 'stale-base', base: baseNorm, revisions }
@@ -291,7 +305,7 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
291
305
  // maps the outcome to a JSON + HTTP status the client switches on. SSE-mode
292
306
  // clients POST here so a save doesn't take over their event-stream; a 401
293
307
  // routes them to the in-band frame (which runs the operator auth flow).
294
- // Mounted + gated (same-origin, shutdown, idle-timeout) in server/http.ts.
308
+ // Mounted + gated (same-origin, shutdown, idle-timeout) in server-e2e/http.ts.
295
309
  async function handleSaveRest(req: IncomingMessage, res: ServerResponse): Promise<void> {
296
310
  const body = await readSaveBody(req)
297
311
  if (!body || typeof body !== 'object') { respondJson(res, 400, { reason: 'bad-request' }); return }
@@ -340,34 +354,54 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
340
354
  return
341
355
  }
342
356
  const tag = msg.workspaceTag
343
- subscribe(socket, tag)
344
- // Explicit ack — distinguishes "the server processed my subscribe
345
- // and registered me as a peer" from "the WebSocket is open". A
346
- // client that sent a malformed / bad-sig subscribe never gets this;
347
- // a client that did gets one before the chain arrives. Lets the UI
348
- // surface a `connecting → online` transition based on real handshake
349
- // completion, not just socket state.
350
- //
351
- // The ack also carries the objstore inventory snapshot: the same
352
- // subscribe that registers this socket for objstore-put / -deleted
353
- // broadcasts seeds the client's initial inventory in one handshake.
354
- // The client keeps it live thereafter from those broadcasts.
355
- // Returns [] for a triage-only workspace. A failing inventory
356
- // lookup must NOT sink the subscribe — degrade to an empty snapshot
357
- // (broadcasts will fill it in) so the ack + chain still go out.
358
- let resources: object[] = []
359
- try { resources = await objstoreResources(tag) }
360
- catch (err) { if (debug) console.warn('subscribe: objstore inventory lookup failed', debugTag(tag), err) }
361
- send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
362
- // `from` is the last revision id the client claims to have applied —
363
- // now a base64url string, not an integer. We send only revisions
364
- // after that. Client lying about `from` just means they get a
365
- // smaller catch-up — their subsequent saves will reveal stale state
366
- // on the usual base-mismatch path. Null / missing → full chain.
367
- const fromId = typeof msg.from === 'string' ? msg.from : null
368
- const revisions = chainForWire(await chainFrom(handle, tag, fromId))
369
- if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
370
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
357
+ const lookups = subscriptionLookups.get(socket) ?? new Map<string, SubscriptionLookup>()
358
+ subscriptionLookups.set(socket, lookups)
359
+ const previous = lookups.get(tag)
360
+ const resume = deps.pauseBroadcasts(socket)
361
+ const lookup = { resume }
362
+ lookups.set(tag, lookup)
363
+ // Acquire the replacement's pause before releasing its predecessor so
364
+ // buffered broadcasts cannot overtake the replacement snapshot. A hung
365
+ // obsolete read must not keep the whole socket paused. The hub's release
366
+ // is idempotent, so the old finally can still call it when the read ends.
367
+ previous?.resume()
368
+ try {
369
+ subscribe(socket, tag)
370
+ // Explicit ack — distinguishes "the server processed my subscribe
371
+ // and registered me as a peer" from "the WebSocket is open". A
372
+ // client that sent a malformed / bad-sig subscribe never gets this;
373
+ // a client that did gets one before the chain arrives. Lets the UI
374
+ // surface a `connecting → online` transition based on real handshake
375
+ // completion, not just socket state.
376
+ //
377
+ // The ack also carries the objstore inventory snapshot: the same
378
+ // subscribe that registers this socket for objstore-put / -deleted
379
+ // broadcasts seeds the client's initial inventory in one handshake.
380
+ // The client keeps it live thereafter from those broadcasts.
381
+ // Returns [] for a triage-only workspace. A lookup failure is NOT an
382
+ // empty inventory: clients interpret absent resources as deletions.
383
+ // Reconnect to retry the complete handshake on either transport.
384
+ const resources = await objstoreResources(tag)
385
+ if (socket.readyState !== socket.OPEN || lookups.get(tag) !== lookup) return
386
+ // `from` is the last revision id the client claims to have applied —
387
+ // now a base64url string, not an integer. We send only revisions
388
+ // after that. Client lying about `from` just means they get a
389
+ // smaller catch-up — their subsequent saves will reveal stale state
390
+ // on the usual base-mismatch path. Null / missing → full chain.
391
+ const fromId = typeof msg.from === 'string' ? msg.from : null
392
+ const revisions = chainForWire(await chainFrom(handle, tag, fromId))
393
+ if (socket.readyState !== socket.OPEN || lookups.get(tag) !== lookup) return
394
+ if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
395
+ send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
396
+ send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
397
+ } catch (err) {
398
+ if (lookups.get(tag) !== lookup) return
399
+ if (debug) console.warn('subscribe: snapshot lookup failed', debugTag(tag), err)
400
+ socket.close(1011, 'subscription unavailable')
401
+ } finally {
402
+ if (lookups.get(tag) === lookup) lookups.delete(tag)
403
+ resume()
404
+ }
371
405
  }
372
406
 
373
407
  return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
@@ -16,6 +16,7 @@ import { Buffer } from 'node:buffer'
16
16
  import type { IncomingMessage as HttpRequest } from 'node:http'
17
17
  import { decodeUtf8 } from '../common/utf8.js'
18
18
  import type { SaveErrorReason } from '../common/save-error-reason.ts'
19
+ import type { ServerInfo } from '../common/server-info.ts'
19
20
  import { Peer, type PeerRegistry } from './peer.ts'
20
21
  import { errMsg, errStack, randomId } from './util.ts'
21
22
  import type { SaveMsg, SubscribeMsg } from './sign.ts'
@@ -36,6 +37,9 @@ type IncomingMessage = {
36
37
  // SSE path doesn't need them.
37
38
  export type PeerConnectionDeps = {
38
39
  peers: PeerRegistry
40
+ // The mode this server advertises, emitted as the first `server-info`
41
+ // frame on every connection (see setupPeerConnection).
42
+ serverInfo: ServerInfo
39
43
  send: (socket: WebSocket, msg: object) => void
40
44
  unsubscribeAll: (socket: WebSocket) => void
41
45
  handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
@@ -60,7 +64,7 @@ export type WsServerDeps = PeerConnectionDeps & {
60
64
  // `as unknown as WebSocket`; only the subset both expose is used.
61
65
  export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: PeerConnectionDeps): void {
62
66
  const {
63
- peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
67
+ peers, serverInfo, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
64
68
  sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, debug,
65
69
  } = deps
66
70
  if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
@@ -71,13 +75,20 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
71
75
  const peer = new Peer(randomId())
72
76
  peers.set(socket, peer)
73
77
  socket.on('pong', () => { peer.alive = true })
74
- // Issue the per-connection challenge nonce BEFORE the client can
75
- // send anything that needs it. The client signs it into every
76
- // `workspace-subscribe` (see canonicalSubscribe in server/sign.ts);
77
- // a captured subscribe frame can't be replayed from a different
78
- // connection because that connection's nonce differs and the
78
+ // Issue the per-connection challenge nonce FIRST — before the client can
79
+ // send anything that needs it, AND as frame #0 so clients/tests that
80
+ // positionally read the first frame as the challenge keep working. The
81
+ // client signs it into every `workspace-subscribe` (see canonicalSubscribe
82
+ // in server-e2e/sign.ts); a captured subscribe frame can't be replayed from
83
+ // a different connection because that connection's nonce differs and the
79
84
  // signature won't verify against the new canonical bytes. Round-9 H2.
80
85
  send(socket, { type: 'challenge', nonce: peer.challenge })
86
+ // Then advertise the sync protocol (the mode probe), right after the
87
+ // challenge — the WS plane and the SSE fallback share this path. A client
88
+ // uses it to detect the mode, cache it, and refuse a cross-mode switch.
89
+ // Unauthenticated + mode-agnostic; an older client ignores it (predicate
90
+ // readers skip it; the workspaceTag demux drops it).
91
+ send(socket, { type: 'server-info', ...serverInfo })
81
92
  // Per-socket handlers are DELIBERATELY NOT serialized (vs the
82
93
  // client-side `messageQueue = messageQueue.then(...)` Promise
83
94
  // chain inside `client/triage-sync.ts:onTransportMessage`).
@@ -86,10 +97,10 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
86
97
  // boundaries inside the handlers. Per-resource correctness needs
87
98
  // no in-process lock: `commitRevision` resolves concurrent saves
88
99
  // via its single gated INSERT (one snapshot + the
89
- // `UNIQUE(workspace_tag, seq)` PK — see `server/db.ts`), and the
100
+ // `UNIQUE(workspace_tag, seq)` PK — see `server-e2e/db.ts`), and the
90
101
  // objstore handlers (`commitPut` / `beginPut` / `deleteObject`)
91
102
  // via the version compare-and-set + content-addressing (see
92
- // `server/objstore/store.ts`), backed by post-await
103
+ // `server-e2e/objstore/store.ts`), backed by post-await
93
104
  // `readyState === OPEN` rechecks in every objstore handler. The
94
105
  // unbounded fan-out is capped by `maxInflightPerSocket` (see
95
106
  // also the `'busy'` NACK at the cap below). Concurrent dispatch
@@ -129,7 +140,7 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
129
140
  // shedding load. Pings go through a fast inline `send(pong)`
130
141
  // path BELOW that doesn't bump the per-socket inflight counter,
131
142
  // so a ping-spam at the cap can't outrun the gate. Transport
132
- // audit `server/index.ts:590` + post-#58 audit follow-up.
143
+ // audit `server-e2e/index.ts:590` + post-#58 audit follow-up.
133
144
  if (parsed.type === 'ping') {
134
145
  send(socket, { type: 'pong' })
135
146
  return