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

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 (312) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2AFQKRAM.js +51 -0
  72. package/out/chunk-2KBF5TZ7.js +2 -0
  73. package/out/chunk-2XUEZHIP.js +28 -0
  74. package/out/chunk-35VE6CDH.js +2 -0
  75. package/out/chunk-3BZ7JERB.js +1 -0
  76. package/out/chunk-4F6JSF57.js +3 -0
  77. package/out/chunk-4XCDFGDP.js +10 -0
  78. package/out/chunk-C4Z6ZBXV.js +10 -0
  79. package/out/chunk-CMQA4DM6.js +1 -0
  80. package/out/chunk-CVVJAXVY.js +2 -0
  81. package/out/chunk-DIJJNTRH.js +10 -0
  82. package/out/chunk-GJ4IUCNS.js +1 -0
  83. package/out/chunk-GNXOKIGP.js +80 -0
  84. package/out/chunk-GZ4MS3TE.js +13 -0
  85. package/out/chunk-IU3CCINR.js +3 -0
  86. package/out/chunk-K3X5EJBQ.js +2 -0
  87. package/out/chunk-KEX2ATEW.js +2 -0
  88. package/out/chunk-KVEUXIEU.js +1 -0
  89. package/out/chunk-KYCNJVB7.js +1 -0
  90. package/out/chunk-NRPC74AM.js +1 -0
  91. package/out/chunk-OOJM4CTU.js +1 -0
  92. package/out/chunk-PNHBZGDD.js +1 -0
  93. package/out/chunk-QZ3A376W.js +1 -0
  94. package/out/chunk-R5O6FAW4.js +2 -0
  95. package/out/chunk-RFZQQXSG.js +17 -0
  96. package/out/chunk-RSHMVYNS.js +1 -0
  97. package/out/chunk-TUII34K2.js +1 -0
  98. package/out/chunk-TY4RZD3Z.js +226 -0
  99. package/out/chunk-TZ5KB2Y5.js +10 -0
  100. package/out/chunk-U6OPEXIA.js +1 -0
  101. package/out/chunk-UNTRGE26.js +2 -0
  102. package/out/chunk-UXNUAIIC.js +33 -0
  103. package/out/chunk-UYAXVRVL.js +1 -0
  104. package/out/chunk-UZAZM2SX.js +2 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XL35ZV6R.js +1 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +298 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +595 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +156 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +223 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/score.js +59 -0
  154. package/risk/npm/specs.js +124 -0
  155. package/risk/npm/tarball.js +113 -0
  156. package/server-common/database-config.ts +16 -0
  157. package/server-common/initialize.ts +18 -0
  158. package/server-common/npm-advisories.ts +101 -0
  159. package/{server → server-common}/origin.ts +5 -5
  160. package/server-common/reap.ts +48 -0
  161. package/server-common/scan-config.ts +19 -0
  162. package/server-common/standalone.ts +29 -0
  163. package/server-common/storage-crypto.ts +182 -0
  164. package/server-common/storage-log.ts +38 -0
  165. package/server-common/vercel-blob.ts +103 -0
  166. package/server-e2e/app.ts +486 -0
  167. package/{server → server-e2e}/auth.ts +5 -1
  168. package/{server → server-e2e}/bus-receiver.ts +2 -1
  169. package/{server → server-e2e}/cli.js +9 -4
  170. package/{server → server-e2e}/config.ts +56 -35
  171. package/{server → server-e2e}/db-neon.ts +2 -2
  172. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  173. package/{server → server-e2e}/db-stmt.ts +2 -2
  174. package/{server → server-e2e}/db.ts +7 -7
  175. package/{server → server-e2e}/http.ts +116 -18
  176. package/{server → server-e2e}/hub.ts +40 -9
  177. package/server-e2e/index.ts +17 -0
  178. package/server-e2e/lifecycle.ts +95 -0
  179. package/{server → server-e2e}/neon-driver.ts +2 -2
  180. package/{server → server-e2e}/npm-proxy.ts +12 -145
  181. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  182. package/{server → server-e2e}/objstore/blob.ts +1 -1
  183. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  184. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  185. package/{server → server-e2e}/objstore/init.ts +35 -18
  186. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  187. package/server-e2e/objstore/rest-deny.ts +28 -0
  188. package/server-e2e/objstore/rest-mint.ts +224 -0
  189. package/{server → server-e2e}/objstore/rest.ts +27 -26
  190. package/{server → server-e2e}/objstore/sign.ts +105 -0
  191. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  192. package/{server → server-e2e}/objstore/store.ts +7 -7
  193. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  194. package/{server → server-e2e}/pubsub.ts +12 -9
  195. package/{server → server-e2e}/sign.ts +4 -4
  196. package/{server → server-e2e}/sse-server.ts +102 -68
  197. package/{server → server-e2e}/sse-session.ts +28 -8
  198. package/{server → server-e2e}/static.ts +31 -21
  199. package/server-e2e/sync-handlers.ts +408 -0
  200. package/{server → server-e2e}/ws-server.ts +20 -9
  201. package/server-managed/activity.ts +245 -0
  202. package/server-managed/avatar-store.ts +12 -0
  203. package/server-managed/blob-store.ts +17 -0
  204. package/server-managed/brotli.ts +10 -0
  205. package/server-managed/bundle-advisories.ts +120 -0
  206. package/server-managed/bundle-advisory-inventory.ts +77 -0
  207. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  208. package/server-managed/bundle-build-diagnostics.js +23 -0
  209. package/server-managed/bundle-build-leases.ts +41 -0
  210. package/server-managed/bundle-build-worker.js +111 -0
  211. package/server-managed/bundle-build.ts +198 -0
  212. package/server-managed/bundle-cache.ts +199 -0
  213. package/server-managed/bundle-catalog.ts +29 -0
  214. package/server-managed/bundle-commits.ts +140 -0
  215. package/server-managed/bundle-store.ts +24 -0
  216. package/server-managed/bundle-summary-cache.ts +130 -0
  217. package/server-managed/bundle.ts +120 -0
  218. package/server-managed/cache-storage.ts +19 -0
  219. package/server-managed/cli.js +21 -0
  220. package/server-managed/combined.ts +46 -0
  221. package/server-managed/comments.ts +151 -0
  222. package/server-managed/config.ts +158 -0
  223. package/server-managed/content-access.ts +15 -0
  224. package/server-managed/crypto.ts +25 -0
  225. package/server-managed/db-methods.ts +1725 -0
  226. package/server-managed/db-neon.ts +367 -0
  227. package/server-managed/db-schema.ts +227 -0
  228. package/server-managed/db-table-names.ts +22 -0
  229. package/server-managed/db.ts +132 -0
  230. package/server-managed/dependency-reviews.ts +248 -0
  231. package/server-managed/github-app.ts +391 -0
  232. package/server-managed/github-errors.ts +68 -0
  233. package/server-managed/github-issue-links.ts +127 -0
  234. package/server-managed/github-issue-oauth.ts +47 -0
  235. package/server-managed/github-issues.ts +152 -0
  236. package/server-managed/github-metadata.ts +213 -0
  237. package/server-managed/github-oauth.ts +235 -0
  238. package/server-managed/github-pulls.ts +227 -0
  239. package/server-managed/http-response.ts +50 -0
  240. package/server-managed/http.ts +3472 -0
  241. package/server-managed/import-triage.ts +51 -0
  242. package/server-managed/index.ts +187 -0
  243. package/server-managed/kept.ts +28 -0
  244. package/server-managed/link-reports.ts +158 -0
  245. package/server-managed/locked-packages.ts +85 -0
  246. package/server-managed/lru.ts +30 -0
  247. package/server-managed/managed-issues.ts +75 -0
  248. package/server-managed/management-catalog.ts +45 -0
  249. package/server-managed/management.ts +99 -0
  250. package/server-managed/npm-insights.ts +440 -0
  251. package/server-managed/npm-kept.ts +65 -0
  252. package/server-managed/npm-loads.ts +167 -0
  253. package/server-managed/npm-manifest.ts +115 -0
  254. package/server-managed/npm-packages.ts +378 -0
  255. package/server-managed/npm-provenance.ts +123 -0
  256. package/server-managed/npm-risk.ts +177 -0
  257. package/server-managed/object-storage-disk.ts +148 -0
  258. package/server-managed/object-storage-vercel.ts +67 -0
  259. package/server-managed/object-storage.ts +74 -0
  260. package/server-managed/package-entry-points.ts +51 -0
  261. package/server-managed/pretty-print.ts +352 -0
  262. package/server-managed/public-workspace.ts +220 -0
  263. package/server-managed/rename-diff-worker.js +26 -0
  264. package/server-managed/rename-diff.ts +270 -0
  265. package/server-managed/repo-path.ts +21 -0
  266. package/server-managed/report-migration.ts +35 -0
  267. package/server-managed/report-query.ts +10 -0
  268. package/server-managed/report-response.ts +16 -0
  269. package/server-managed/report-sources.ts +178 -0
  270. package/server-managed/report-store.ts +47 -0
  271. package/server-managed/repository-aliases.ts +91 -0
  272. package/server-managed/repository-browser.ts +153 -0
  273. package/server-managed/repository-discovery.ts +82 -0
  274. package/server-managed/repository-policy.ts +27 -0
  275. package/server-managed/repository-risk.ts +420 -0
  276. package/server-managed/revisions.ts +35 -0
  277. package/server-managed/session.ts +92 -0
  278. package/server-managed/slugs.ts +38 -0
  279. package/server-managed/solidity-entry-points.ts +69 -0
  280. package/server-managed/sql-postgres.ts +31 -0
  281. package/server-managed/sql.ts +76 -0
  282. package/server-managed/static.ts +28 -0
  283. package/server-managed/storage-command.ts +16 -0
  284. package/server-managed/storage-db.ts +175 -0
  285. package/server-managed/storage-encryption.ts +279 -0
  286. package/server-managed/storage-maintenance.ts +256 -0
  287. package/server-managed/storage-payload.ts +103 -0
  288. package/server-managed/storage-stores.ts +85 -0
  289. package/server-managed/storage.ts +78 -0
  290. package/server-managed/team-app.ts +94 -0
  291. package/server-managed/team-catalog.ts +7 -0
  292. package/server-managed/team-feed.ts +173 -0
  293. package/server-managed/team-npm-scopes.ts +77 -0
  294. package/server-managed/team-reports.ts +228 -0
  295. package/server-managed/triage-response.ts +16 -0
  296. package/server-managed/type-only-worker.js +19 -0
  297. package/server-managed/type-only.ts +103 -0
  298. package/server-managed/uploads.ts +86 -0
  299. package/server-managed/upstream-cache.ts +215 -0
  300. package/server-managed/view-as.ts +58 -0
  301. package/server-managed/workspace-shares.ts +166 -0
  302. package/server.ts +50 -0
  303. package/server/index.ts +0 -477
  304. package/server/lifecycle.ts +0 -204
  305. package/server/sync-handlers.ts +0 -320
  306. /package/{LICENSE → LICENSE-MIT} +0 -0
  307. /package/{server → server-e2e}/config.example.json +0 -0
  308. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  309. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  310. /package/{server → server-e2e}/peer.ts +0 -0
  311. /package/{server → server-e2e}/util.ts +0 -0
  312. /package/{server → server-e2e}/validation.ts +0 -0
@@ -1,204 +0,0 @@
1
- // Process lifecycle: the in-flight request tracker, the `shuttingDown`
2
- // gate, the graceful-shutdown choreography, and the signal / error /
3
- // uncaught-exception handlers that drive it. Two-phase so the bits the
4
- // HTTP + WS planes need (`track`, `isShuttingDown`) exist BEFORE those
5
- // servers are built, while the teardown is `install()`ed afterward —
6
- // once the server objects, heartbeat timer, and reaper exist.
7
-
8
- import type { Server } from 'node:http'
9
- import type { WebSocketServer } from 'ws'
10
- import type { SseSession } from './sse-session.ts'
11
- import { errStack } from './util.ts'
12
-
13
- export type ShutdownDeps = {
14
- httpServer: Server
15
- wss: WebSocketServer
16
- heartbeatTimer: ReturnType<typeof setInterval>
17
- // Stops the periodic reaper AND awaits any in-flight sweep.
18
- stopReaper: () => Promise<void>
19
- // Live SSE+POST session iterator (mirrors `wss.clients` for the SSE
20
- // fallback transport). Walked alongside `wss.clients` to send the
21
- // 1001 close frame and apply the terminate-grace timer to both
22
- // transports uniformly.
23
- sseSessions: () => Iterable<SseSession>
24
- // App-specific teardown, run AFTER the in-flight drain: close the DB.
25
- // Swallows its own errors (a failed close shouldn't abort the exit).
26
- closeDb: () => Promise<void>
27
- }
28
-
29
- export type Lifecycle = {
30
- // Collects in-flight async handlers so `shutdown` can drain them
31
- // before closing the DB. Passed to the HTTP + WS planes.
32
- track: (promise: Promise<unknown>) => void
33
- // Read by the REST shutdown gate + the WS message loop to drop new
34
- // work once teardown began.
35
- isShuttingDown: () => boolean
36
- // Wire the graceful shutdown + the error / signal / process-catchall
37
- // handlers. Call once, after the server objects exist.
38
- install: (deps: ShutdownDeps) => void
39
- }
40
-
41
- export function createLifecycle(): Lifecycle {
42
- // In-flight async message handlers. `shutdown` awaits this set before
43
- // closing the DB so a SIGINT mid-save can't resume against a closed
44
- // handle (which would throw inside the commit's gated INSERT after
45
- // the client believed its save was committed).
46
- const inFlight = new Set<Promise<unknown>>()
47
- function track(promise: Promise<unknown>): void {
48
- inFlight.add(promise)
49
- // Trailing `.catch(() => {})` swallows the rejection that `.finally`
50
- // propagates through its returned promise — without it, a tracked
51
- // handler that rejects (or whose caller's catch handler itself
52
- // throws) trips the `unhandledRejection` catchall below and crashes
53
- // the process via `fireShutdown(1)`.
54
- promise.finally(() => inFlight.delete(promise)).catch(() => {})
55
- }
56
- let shuttingDown = false
57
- // Live exit code the in-progress shutdown will pass to `process.exit`.
58
- // Re-entry can ESCALATE it from 0 → 1 (e.g. a `wss.error` firing
59
- // during a SIGTERM-driven graceful shutdown shouldn't leave the
60
- // launcher seeing a clean exit code) but can never DE-escalate.
61
- // Audit round-13.
62
- let pendingExitCode = 0
63
-
64
- function install(deps: ShutdownDeps): void {
65
- const { httpServer, wss, heartbeatTimer, stopReaper, sseSessions, closeDb } = deps
66
-
67
- async function shutdown(exitCode: number = 0): Promise<void> {
68
- // Re-entry: don't restart the teardown, but escalate the pending
69
- // exit code if the new caller is non-zero (e.g. a wss.error during
70
- // a SIGTERM-driven graceful shutdown). Without this, an error
71
- // arriving mid-shutdown would silently exit 0 and the launcher
72
- // would record a clean stop.
73
- if (shuttingDown) {
74
- if (exitCode !== 0 && pendingExitCode === 0) pendingExitCode = exitCode
75
- return
76
- }
77
- shuttingDown = true
78
- pendingExitCode = exitCode
79
- console.log('Shutting down…')
80
- // Stop the heartbeat so a tick can't fire mid-shutdown and ping a
81
- // socket the close-loop below already started tearing down.
82
- clearInterval(heartbeatTimer)
83
- // Send a 1001 (going away) close frame to every open socket BEFORE
84
- // shutting the listener. Lets clients distinguish a server-initiated
85
- // graceful shutdown from a network drop, so they can skip their
86
- // reconnect backoff. Fire-and-forget — `process.exit` below would
87
- // force-kill any in-progress flush anyway. `try/catch` shrugs at
88
- // sockets already in CLOSING / CLOSED.
89
- for (const socket of wss.clients) {
90
- try { socket.close(1001, 'Server shutting down') } catch {}
91
- }
92
- // Same close frame on every SSE+POST session — the adapter
93
- // translates `close(1001, …)` into a structured `event: close`
94
- // record on the SSE stream so the client transport can read the
95
- // code and skip its reconnect backoff (parity with the WS path).
96
- for (const session of sseSessions()) {
97
- try { session.close(1001, 'Server shutting down') } catch {}
98
- }
99
- // Force-terminate any client that doesn't ack the close frame
100
- // within a short grace window. `wss.close()` waits for every client
101
- // to emit `'close'`, and `ws` only TCP-RSTs unresponsive peers
102
- // after its own ~30 s `closeTimeout`. A single dead/blackholed peer
103
- // would otherwise stretch SIGTERM/SIGINT response by that full
104
- // timeout. Audit round-11.
105
- const TERMINATE_GRACE_MS = 1_000
106
- const terminateTimer = setTimeout(() => {
107
- for (const socket of wss.clients) {
108
- const rs = socket.readyState
109
- if (rs === socket.OPEN || rs === socket.CLOSING) {
110
- try { socket.terminate() } catch {}
111
- }
112
- }
113
- // SSE sessions are tracked separately from `wss.clients`; apply
114
- // the same terminate-grace policy so a stranded SSE response
115
- // can't pin the HTTP keep-alive shutdown branch below.
116
- for (const session of sseSessions()) {
117
- const rs = session.readyState
118
- if (rs === session.OPEN || rs === session.CLOSING) {
119
- try { session.terminate() } catch {}
120
- }
121
- }
122
- // Same grace for HTTP keep-alive sockets that didn't respect the
123
- // `Connection: close` hint, which would otherwise hold
124
- // `httpServer.close()` until their TCP timeout.
125
- try { httpServer.closeAllConnections() } catch {}
126
- }, TERMINATE_GRACE_MS)
127
- // Don't keep the event loop alive solely for the grace timer.
128
- terminateTimer.unref?.()
129
- // Stop the periodic reaper AND wait for any in-flight sweep (incl.
130
- // the startup sweep) before the DB close — otherwise a
131
- // readdir / unlink would race a closed DB.
132
- await stopReaper()
133
- // Free idle HTTP keep-alive sockets up front so close() below
134
- // doesn't wait on them. Active in-flight requests still finish.
135
- try { httpServer.closeIdleConnections() } catch {}
136
- // Close http.Server first to stop accepting new upgrades + HTTP
137
- // requests. Guard with `.listening` because `close()` throws
138
- // ERR_SERVER_NOT_RUNNING when bind never succeeded (the http error
139
- // handler is the path that invoked shutdown in that case).
140
- if (httpServer.listening) {
141
- await new Promise<void>((resolve) => { httpServer.close(() => resolve()) })
142
- }
143
- await new Promise<void>((resolve) => { wss.close(() => resolve()) })
144
- clearTimeout(terminateTimer)
145
- // Drain in-flight handlers so a save that's mid-pipeline finishes
146
- // its commit before the DB closes. `handleSave` splits its
147
- // canonical/id/dup-precheck/verify/commit work across awaits, so
148
- // the window spans several yield points. `Promise.allSettled` so a
149
- // single handler rejection doesn't abort the drain.
150
- if (inFlight.size > 0) await Promise.allSettled([...inFlight])
151
- // App teardown AFTER the drain: close the DB.
152
- await closeDb()
153
- // Read `pendingExitCode` (not the parameter) so a re-entrant
154
- // `shutdown(1)` that landed during the drain wins over the original
155
- // `shutdown(0)`. See round-13 escalation note.
156
- process.exit(pendingExitCode)
157
- }
158
-
159
- // `.catch` defends against an unguarded `await` slipping into
160
- // `shutdown`: an unhandled rejection there would skip the non-zero
161
- // exit the launcher relies on.
162
- function fireShutdown(code: number): void {
163
- shutdown(code).catch((err) => {
164
- console.warn('shutdown error:', errStack(err))
165
- process.exit(code === 0 ? 1 : code)
166
- })
167
- }
168
-
169
- // Route bind / post-listen failures through `shutdown` so the
170
- // in-flight drain + DB close still run before exit. Without this,
171
- // `ws` re-emits the error as uncaughtException and the launcher sees
172
- // a confusing crash rather than the bind failure. Audit round-9 M2.
173
- httpServer.on('error', (err: Error) => {
174
- console.error('Server error:', errStack(err))
175
- fireShutdown(1)
176
- })
177
- // Symmetric with the http.Server error handler — route through
178
- // `fireShutdown(1)` so the launcher sees a non-zero exit, and the
179
- // re-entry escalation bumps `pendingExitCode` 0 → 1 for a `wss.error`
180
- // arriving mid-graceful-SIGTERM.
181
- wss.on('error', (err: Error) => {
182
- console.error('WS server error:', errStack(err))
183
- fireShutdown(1)
184
- })
185
- // Wrap signal handlers so the signal name (the listener's first arg)
186
- // doesn't bleed into shutdown's `exitCode`.
187
- process.on('SIGINT', () => fireShutdown(0))
188
- process.on('SIGTERM', () => fireShutdown(0))
189
- // Process-level catchalls so a stray rejection / uncaught exception
190
- // doesn't bypass `shutdown()` — Node 20+ exits on unhandled
191
- // rejection, which would skip the drain + DB close. Log forensically
192
- // and route through `fireShutdown(1)`. Audit round-11 observability.
193
- process.on('unhandledRejection', (reason) => {
194
- console.error('Unhandled rejection:', errStack(reason))
195
- fireShutdown(1)
196
- })
197
- process.on('uncaughtException', (err) => {
198
- console.error('Uncaught exception:', errStack(err))
199
- fireShutdown(1)
200
- })
201
- }
202
-
203
- return { track, isShuttingDown: () => shuttingDown, install }
204
- }
@@ -1,320 +0,0 @@
1
- // Triage-sync protocol handlers: `workspace-save` and
2
- // `workspace-subscribe`. Built once at boot with the DB handle plus
3
- // the transport / auth / registry primitives it needs; the WS message
4
- // dispatcher in index.ts calls the returned handlers. Kept out of the
5
- // entrypoint so the protocol logic (the save pipeline, the
6
- // subscribe/catch-up path) is one cohesive, testable unit.
7
-
8
- import type { WebSocket } from 'ws'
9
- import { SAVE_ERROR_REASONS, type SaveErrorReason } from '../common/save-error-reason.ts'
10
- import { type Handle, type RevisionRow, chainFrom, commitRevision, revisionExists } from './db.ts'
11
- import { type SaveMsg, type SubscribeMsg, canonicalSave, computeRevisionIdFromCanonical, verifyEd25519, verifySubscribeSig } from './sign.ts'
12
- import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, validTagSigBase } from './validation.ts'
13
- import { debugTag } from './util.ts'
14
- import type { UnauthorizedContext } from './auth.ts'
15
-
16
- // Wire shape `chainForWire` produces from a `chainFrom` row, with
17
- // `keyframe` normalised from the SQLite INTEGER 0/1 to a strict boolean.
18
- type WireRevision = {
19
- base: string | null
20
- id: string
21
- keyframe: boolean
22
- nonce: string
23
- ciphertext: string
24
- signature: string
25
- }
26
-
27
- export type SyncHandlersDeps = {
28
- handle: Handle
29
- send: (socket: WebSocket, msg: object) => void
30
- broadcast: (tag: string, msg: object, except: WebSocket | null) => void
31
- // Cross-instance pub/sub for live broadcasts. Fired alongside the
32
- // local `broadcast` after a successful commit so peers on OTHER
33
- // server instances see the new revision in real time. Carries only
34
- // `(tag, revisionId)` — the receiver re-fetches the row from
35
- // workspace_revision because the ciphertext can exceed the bus's
36
- // payload budget. Optional: a SQLite deployment passes a no-op.
37
- publishRevision: (tag: string, revisionId: string) => void
38
- subscribe: (socket: WebSocket, tag: string) => void
39
- getNonce: (socket: WebSocket) => string | undefined
40
- requiresAuth: (socket: WebSocket) => boolean
41
- sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
42
- workspaceExists: (tag: string) => Promise<boolean>
43
- // Objstore inventory snapshot for a workspace tag, as wire rows. The
44
- // `workspace-subscribed` ack carries it. Injected — the objstore store
45
- // has its own richer `Handle`, so index.ts wires `listLive(
46
- // objstoreHandle, tag).then(rows => rows.map(objectMetaWire))` rather
47
- // than coupling this module to that store type. Returns [] for a
48
- // triage-only tag.
49
- objstoreResources: (tag: string) => Promise<object[]>
50
- debug: boolean
51
- }
52
-
53
- export type SyncHandlers = {
54
- handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
55
- handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
56
- // Exported because the dispatcher's inflight-cap `busy` NACK path
57
- // emits a save-error too (the only emit site outside this module).
58
- sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
59
- }
60
-
61
- export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
62
- const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
63
-
64
- // Typed wrapper for the three `workspace-save-error` emit sites
65
- // (too-large at handleSave, stale-base after the catch-up, busy at
66
- // the inflight-cap drop). Forces `reason` to be a member of
67
- // `SaveErrorReason` so a typo or a server-side addition that didn't
68
- // update `common/save-error-reason.ts` fails at compile time rather
69
- // than turning into a wire-level surprise. The shared taxonomy is
70
- // pinned by `tests/save-error-reason-taxonomy.test.js`.
71
- function sendSaveError(
72
- socket: WebSocket,
73
- workspaceTag: string,
74
- base: string | null,
75
- reason: SaveErrorReason,
76
- ): void {
77
- // Runtime guard alongside the compile-time `SaveErrorReason` union —
78
- // covers the case where the `reason` argument is a variable (not a
79
- // string literal) and TS's narrowing can't enforce taxonomy
80
- // membership at the call site. Throws because a server emitting a
81
- // typo'd reason would be a wire-protocol break the client can't
82
- // recover from; better to fail fast in the test suite than to
83
- // silently land bytes the client coerces to `'rejected'`.
84
- if (!SAVE_ERROR_REASONS.has(reason)) {
85
- throw new Error(`sendSaveError: reason '${reason}' is not in SAVE_ERROR_REASONS — update common/save-error-reason.ts`)
86
- }
87
- send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
88
- }
89
-
90
- // Normalise `keyframe` to a strict boolean on outbound chain entries.
91
- // SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
92
- // raw rows; the wire contract (and canonical signing payload) uses
93
- // strict `=== true`. Convert once on the send side — forwarding the
94
- // integer relies on clients coercing via `Boolean()` before rebuilding
95
- // canonical bytes, fragile against any client that strict-compares.
96
- function chainForWire(revisions: RevisionRow[]): WireRevision[] {
97
- return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
98
- }
99
-
100
- async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
101
- // `base` is `string | null`; null is the keyframe-root marker.
102
- if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validNonce(msg.nonce, MAX_FIELD_LEN) || !validCiphertextShape(msg.ciphertext) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.base != null && !validTagSigBase(msg.base, MAX_FIELD_LEN))) return
103
- // Compute canonical bytes + content-addressed id ONCE, then thread
104
- // both through the precheck → sig verify → commit pipeline:
105
- // 1. canonicalSave (sync, throws on lone-surrogate input)
106
- // 2. SHA-256 → id
107
- // 3. precheck: revisionExists → short-circuit ack on replay
108
- // (skips Ed25519 verify; closes the round-9 H1 CPU-DoS vector
109
- // where a passive observer floods captured saves)
110
- // 4. verifyEd25519 against the SAME canonical bytes the id was
111
- // hashed from — provably tied
112
- // 5. ciphertext size policy (post-sig so the explicit error only
113
- // reaches a legit signer)
114
- // 6. commitRevision — re-checks dup + base + inserts via a single
115
- // gated INSERT (dup gate + head-equals-base gate + the
116
- // server-assigned seq folded into one statement) — NO write
117
- // lock. The dup recheck, headFor, base-match and insert all
118
- // collapse into that one statement, whose head-check and
119
- // MAX(seq) read one snapshot; the `UNIQUE(workspace_tag, seq)`
120
- // PK rejects any racer that computed the same seq. So two
121
- // concurrent saves with the same `base` and different ids
122
- // can't both insert (the loser's `head IS base` gate fails →
123
- // `stale-base`, no chain fork even though UNIQUE is on id, not
124
- // base), and two concurrent same-id retransmits resolve to one
125
- // `inserted` + one `duplicate` with no UNIQUE throw escaping.
126
- // See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
127
- let canonical: Uint8Array<ArrayBuffer>
128
- try { canonical = canonicalSave(msg) } catch { return }
129
- const id = await computeRevisionIdFromCanonical(canonical)
130
- const tag = msg.workspaceTag
131
- if (await revisionExists(handle, tag, id)) {
132
- if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
133
- send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: msg.base ?? null, id })
134
- return
135
- }
136
- if (!await verifyEd25519(tag, canonical, msg.signature)) {
137
- if (debug) console.warn('reject save: bad signature', debugTag(tag))
138
- return
139
- }
140
- // Size policy — emit an explicit error so the client can surface
141
- // the failure to the user. Without this, an oversized save hangs
142
- // forever in the client's `pending` slot (no ack, no rebase).
143
- if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
144
- if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
145
- sendSaveError(socket, tag, msg.base == null || typeof msg.base !== 'string' ? null : msg.base, 'too-large')
146
- return
147
- }
148
- // Auth gate for the FIRST action against a workspace tag that
149
- // doesn't yet exist on the server (no rows in workspace_revision
150
- // AND none in workspace_object). Once any row lands, the workspace
151
- // is established and every signed action flows freely — access
152
- // control falls back to the Ed25519 signature for the rest of the
153
- // workspace's lifetime. Checked AFTER sig verify so the
154
- // `unauthorized` frame only reaches a legitimate signer; shape /
155
- // sig attacks still drop silently.
156
- //
157
- // RACE: `workspaceExists` reads at a different moment than the
158
- // commit's gated INSERT (a plain TOCTOU — there is no lock spanning
159
- // the two). Under concurrent saves on a fresh tag, an
160
- // unauthenticated socket whose `workspaceExists` observes "true"
161
- // (because an authenticated peer's commit landed between this
162
- // socket's check and its commit) skips the gate and commits as the
163
- // second writer. Accepted: the unauthenticated peer still had to
164
- // produce a valid Ed25519 signature (= holds the workspace seed),
165
- // and "two concurrent writes both authorising" is the worst case.
166
- // Tightening would require folding the gate into the commit
167
- // statement itself and is not worth the layer crossing for the
168
- // soft-policy guarantee.
169
- if (requiresAuth(socket) && !await workspaceExists(tag)) {
170
- if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
171
- sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: msg.base ?? null })
172
- return
173
- }
174
- // Save does NOT subscribe the sending socket — that would be a
175
- // replay vector: a passive observer who captured one valid
176
- // `workspace-save` frame could replay it from any TCP connection to
177
- // attach as a subscriber and mirror every future encrypted
178
- // broadcast, without holding the seed (the duplicate-id path returns
179
- // ack-only, doesn't reject the socket). Explicit `workspace-subscribe`
180
- // (signs the per-connection challenge nonce) is the ONLY attach path.
181
- // Audit round-9 H1.
182
- const baseNorm = msg.base ?? null
183
- // `keyframe === true` is what canonicalSave bound the signature to
184
- // (strict equality); the signer's intent is unambiguous here.
185
- const keyframe = msg.keyframe === true
186
- const commit = await commitRevision(handle, {
187
- tag, id, base: baseNorm, keyframe,
188
- nonce: msg.nonce, ciphertext: msg.ciphertext, signature: msg.signature,
189
- })
190
- if (commit.kind === 'duplicate') {
191
- if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
192
- send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: baseNorm, id })
193
- return
194
- }
195
- if (commit.kind === 'stale-base') {
196
- // Client claimed a base that's no longer head. Catch-up chain is
197
- // computed OUTSIDE the lock — a concurrent commit landing between
198
- // lock-release and `chainFrom` only means the catch-up is fresher
199
- // than the recheck saw, which is benign (clients tolerate extra
200
- // revisions in the chain).
201
- //
202
- // Wire order: send `workspace-state` (catch-up) FIRST, then the
203
- // typed `workspace-save-error { reason: 'stale-base' }`. The
204
- // catch-up's handler clears `session.pending`; the subsequent
205
- // error frame's `handleSaveError` then early-returns on the
206
- // missing pending and does NOT mark the session errored — exactly
207
- // what we want, since stale-base is a recoverable race (client
208
- // rebases + re-saves). The typed frame is for protocol clarity
209
- // (debug surfaces / explicit rejection signal), not for triggering
210
- // an error transition. Audit follow-up to round-15 —
211
- // `sync-server-races.test.js:1105`.
212
- const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
213
- if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
214
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
215
- sendSaveError(socket, tag, baseNorm, 'stale-base')
216
- return
217
- }
218
- if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
219
- send(socket, {
220
- type: 'workspace-save-ack',
221
- workspaceTag: tag,
222
- base: baseNorm,
223
- id,
224
- })
225
- // Carry `keyframe` as a strict boolean on the broadcast wire — peers
226
- // strict-compare `=== true` (matching the canonical-payload
227
- // contract). An integer 0/1 here would make a keyframe look like a
228
- // regular delta on broadcast paths.
229
- broadcast(tag, {
230
- type: 'workspace-state',
231
- workspaceTag: tag,
232
- revisions: [{
233
- base: baseNorm,
234
- id,
235
- keyframe,
236
- nonce: msg.nonce,
237
- ciphertext: msg.ciphertext,
238
- signature: msg.signature,
239
- }],
240
- }, socket)
241
- // Cross-instance fan-out. The bus payload carries only the revision
242
- // id — peers on OTHER instances re-fetch the row from
243
- // workspace_revision to compose their local `workspace-state`. Sized
244
- // for the bus's 8 KB payload budget, which can't carry a 2 MiB
245
- // ciphertext. SQLite mode passes a no-op; Neon mode publishes via
246
- // pg_notify. Best-effort: a dropped publish only means peers on
247
- // other instances miss the live push, but they still catch up via
248
- // the shared DB on their next subscribe / reconnect.
249
- publishRevision(tag, id)
250
- }
251
-
252
- async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
253
- // Mirror handleSave's wire gate: length-cap + base64url-alphabet on
254
- // workspaceTag / signature / from BEFORE canonicalSubscribe UTF-8-
255
- // encodes them and verifyEd25519 base64-decodes the signature.
256
- // Without it a peer can submit up-to-maxPayload strings and force
257
- // O(n) encode + decode per subscribe before the 32/64-byte length
258
- // gates reject — a CPU-DoS surface handleSave is already hardened
259
- // against. `from` keeps the `string | null` contract (null = full-
260
- // chain catch-up); a legit signer's `from` is a base64url revision
261
- // id, so any non-string / over-long / wrong-alphabet value can't be
262
- // one the signature was computed over, and the chain-lookup path
263
- // (`typeof msg.from === 'string' ? msg.from : null`) still treats a
264
- // null / absent `from` as the keyframe-fallback.
265
- if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.from != null && !validTagSigBase(msg.from, MAX_FIELD_LEN))) return
266
- // The challenge nonce we issued on this socket is bound into the
267
- // signed canonical, blocking cross-connection replay of a captured
268
- // subscribe frame. A subscribe arriving before we sent the
269
- // challenge (impossible from the legitimate client) has no nonce to
270
- // verify against — drop. Audit round-9 H2.
271
- const nonce = getNonce(socket)
272
- if (typeof nonce !== 'string') return
273
- const ok = await verifySubscribeSig(msg, nonce)
274
- if (!ok) {
275
- if (debug) console.warn('reject subscribe: bad signature', debugTag(msg.workspaceTag))
276
- return
277
- }
278
- // Bail if the socket closed during the verify await. The close
279
- // handler's `unsubscribeAll(socket)` already ran (when there was
280
- // nothing to remove yet), and `subscribe()` below would add the
281
- // dead socket to `subscribers[tag]` — a permanent leak: broadcasts
282
- // no-op via `send`'s readyState gate, but the Set entry pins the
283
- // socket reference past close, blocking GC. Audit round-12.
284
- if (socket.readyState !== socket.OPEN) {
285
- if (debug) console.warn('reject subscribe: socket closed mid-verify', debugTag(msg.workspaceTag))
286
- return
287
- }
288
- const tag = msg.workspaceTag
289
- subscribe(socket, tag)
290
- // Explicit ack — distinguishes "the server processed my subscribe
291
- // and registered me as a peer" from "the WebSocket is open". A
292
- // client that sent a malformed / bad-sig subscribe never gets this;
293
- // a client that did gets one before the chain arrives. Lets the UI
294
- // surface a `connecting → online` transition based on real handshake
295
- // completion, not just socket state.
296
- //
297
- // The ack also carries the objstore inventory snapshot: the same
298
- // subscribe that registers this socket for objstore-put / -deleted
299
- // broadcasts seeds the client's initial inventory in one handshake.
300
- // The client keeps it live thereafter from those broadcasts.
301
- // Returns [] for a triage-only workspace. A failing inventory
302
- // lookup must NOT sink the subscribe — degrade to an empty snapshot
303
- // (broadcasts will fill it in) so the ack + chain still go out.
304
- let resources: object[] = []
305
- try { resources = await objstoreResources(tag) }
306
- catch (err) { if (debug) console.warn('subscribe: objstore inventory lookup failed', debugTag(tag), err) }
307
- send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
308
- // `from` is the last revision id the client claims to have applied —
309
- // now a base64url string, not an integer. We send only revisions
310
- // after that. Client lying about `from` just means they get a
311
- // smaller catch-up — their subsequent saves will reveal stale state
312
- // on the usual base-mismatch path. Null / missing → full chain.
313
- const fromId = typeof msg.from === 'string' ? msg.from : null
314
- const revisions = chainForWire(await chainFrom(handle, tag, fromId))
315
- if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
316
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
317
- }
318
-
319
- return { handleSave, handleSubscribe, sendSaveError }
320
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes