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

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 (368) 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 +3 -0
  50. package/common/managed/finding-detail.js +66 -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 +7 -0
  54. package/common/managed/npm-packages.js +51 -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 +359 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +6 -0
  64. package/common/managed/team-app.js +47 -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-2K6UN3PC.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-6LNQTKAV.js +52 -0
  87. package/out/chunk-7BKKYVWR.js +7 -0
  88. package/out/chunk-7C7DWL7I.js +1 -0
  89. package/out/chunk-7JBSSRD7.js +3 -0
  90. package/out/chunk-7VAWINQF.js +6 -0
  91. package/out/chunk-C4Z6ZBXV.js +10 -0
  92. package/out/chunk-FMXWXDR4.js +36 -0
  93. package/out/chunk-GDPCLSOS.js +2 -0
  94. package/out/chunk-GO7FT4OD.js +1 -0
  95. package/out/chunk-HT2SSA3V.js +1 -0
  96. package/out/chunk-IPAH4LKF.js +232 -0
  97. package/out/chunk-IYPY47MC.js +1 -0
  98. package/out/chunk-IZ7POWI3.js +18 -0
  99. package/out/chunk-JCIIBLKR.js +1 -0
  100. package/out/chunk-KOGNQDSJ.js +1 -0
  101. package/out/chunk-NRPC74AM.js +1 -0
  102. package/out/chunk-OOJM4CTU.js +1 -0
  103. package/out/chunk-PBZPYIG7.js +1 -0
  104. package/out/chunk-PNHBZGDD.js +1 -0
  105. package/out/chunk-PWFUOLZK.js +17 -0
  106. package/out/chunk-PXK7EU4V.js +1 -0
  107. package/out/chunk-QB4CBOX2.js +1 -0
  108. package/out/chunk-R3NZFXVR.js +1 -0
  109. package/out/chunk-R5O6FAW4.js +2 -0
  110. package/out/chunk-SXV2T5C4.js +80 -0
  111. package/out/chunk-TQJDYHVE.js +2 -0
  112. package/out/chunk-TUII34K2.js +1 -0
  113. package/out/chunk-VDRXHIHN.js +3 -0
  114. package/out/chunk-VFWMQ3LG.js +2 -0
  115. package/out/chunk-VH4XMZIW.js +2 -0
  116. package/out/chunk-WCPJDAYA.js +10 -0
  117. package/out/chunk-WFYLMF6Z.js +10 -0
  118. package/out/chunk-WRAJVD35.js +1 -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 +364 -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 +23 -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 +76 -0
  237. package/server-managed/comparison.ts +52 -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 +468 -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 +143 -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 +98 -0
  249. package/server-managed/dependency-reviews.ts +364 -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 +376 -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 +136 -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 +5503 -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/listed-activity.ts +39 -0
  273. package/server-managed/locked-packages.ts +152 -0
  274. package/server-managed/lockfile-scope.ts +49 -0
  275. package/server-managed/lru.ts +30 -0
  276. package/server-managed/managed-issues.ts +78 -0
  277. package/server-managed/management-catalog.ts +45 -0
  278. package/server-managed/management.ts +99 -0
  279. package/server-managed/npm-delta-worker.js +23 -0
  280. package/server-managed/npm-delta.ts +194 -0
  281. package/server-managed/npm-insights.ts +449 -0
  282. package/server-managed/npm-kept.ts +225 -0
  283. package/server-managed/npm-loads.ts +264 -0
  284. package/server-managed/npm-manifest.ts +115 -0
  285. package/server-managed/npm-packages.ts +454 -0
  286. package/server-managed/npm-patch.ts +325 -0
  287. package/server-managed/npm-provenance.ts +123 -0
  288. package/server-managed/npm-risk.ts +285 -0
  289. package/server-managed/object-storage-disk.ts +148 -0
  290. package/server-managed/object-storage-vercel.ts +67 -0
  291. package/server-managed/object-storage.ts +75 -0
  292. package/server-managed/package-entry-points.ts +51 -0
  293. package/server-managed/pretty-print.ts +434 -0
  294. package/server-managed/public-workspace.ts +238 -0
  295. package/server-managed/pull-comment-reviews.ts +161 -0
  296. package/server-managed/pull-dependencies.ts +351 -0
  297. package/server-managed/pull-dependency-reviews.ts +334 -0
  298. package/server-managed/pull-reviews.ts +205 -0
  299. package/server-managed/queue.ts +39 -0
  300. package/server-managed/release-commits.ts +84 -0
  301. package/server-managed/rename-diff-worker.js +26 -0
  302. package/server-managed/rename-diff.ts +279 -0
  303. package/server-managed/repo-path.ts +21 -0
  304. package/server-managed/report-migration.ts +35 -0
  305. package/server-managed/report-query.ts +10 -0
  306. package/server-managed/report-response.ts +16 -0
  307. package/server-managed/report-sources.ts +178 -0
  308. package/server-managed/report-store.ts +85 -0
  309. package/server-managed/repository-actions.ts +218 -0
  310. package/server-managed/repository-agents.ts +448 -0
  311. package/server-managed/repository-aliases.ts +91 -0
  312. package/server-managed/repository-browser.ts +294 -0
  313. package/server-managed/repository-changes.ts +284 -0
  314. package/server-managed/repository-code.ts +215 -0
  315. package/server-managed/repository-dependency-reviews.ts +166 -0
  316. package/server-managed/repository-discovery.ts +82 -0
  317. package/server-managed/repository-history.ts +432 -0
  318. package/server-managed/repository-issues.ts +195 -0
  319. package/server-managed/repository-policy.ts +27 -0
  320. package/server-managed/repository-pulls.ts +212 -0
  321. package/server-managed/repository-pulse.ts +460 -0
  322. package/server-managed/repository-risk-assess.ts +105 -0
  323. package/server-managed/repository-risk-audit.ts +79 -0
  324. package/server-managed/repository-risk-read.ts +170 -0
  325. package/server-managed/repository-risk.ts +491 -0
  326. package/server-managed/repository-squashes.ts +168 -0
  327. package/server-managed/request-timing.ts +63 -0
  328. package/server-managed/reviewed-dependencies.ts +149 -0
  329. package/server-managed/revisions.ts +35 -0
  330. package/server-managed/scan-downloads.ts +87 -0
  331. package/server-managed/session.ts +132 -0
  332. package/server-managed/slugs.ts +38 -0
  333. package/server-managed/solidity-entry-points.ts +71 -0
  334. package/server-managed/sql-postgres.ts +34 -0
  335. package/server-managed/sql.ts +88 -0
  336. package/server-managed/static.ts +28 -0
  337. package/server-managed/storage-command.ts +16 -0
  338. package/server-managed/storage-db.ts +183 -0
  339. package/server-managed/storage-encryption.ts +290 -0
  340. package/server-managed/storage-maintenance.ts +259 -0
  341. package/server-managed/storage-payload.ts +121 -0
  342. package/server-managed/storage-stores.ts +91 -0
  343. package/server-managed/storage.ts +82 -0
  344. package/server-managed/team-app.ts +134 -0
  345. package/server-managed/team-catalog.ts +7 -0
  346. package/server-managed/team-feed.ts +180 -0
  347. package/server-managed/team-npm-scopes.ts +106 -0
  348. package/server-managed/team-reports.ts +319 -0
  349. package/server-managed/triage-response.ts +16 -0
  350. package/server-managed/type-only-worker.js +19 -0
  351. package/server-managed/type-only.ts +105 -0
  352. package/server-managed/uploads.ts +106 -0
  353. package/server-managed/upstream-cache.ts +243 -0
  354. package/server-managed/view-as.ts +58 -0
  355. package/server-managed/workspace-shares.ts +169 -0
  356. package/server.ts +70 -0
  357. package/server/index.ts +0 -480
  358. package/server/lifecycle.ts +0 -208
  359. /package/{LICENSE → LICENSE-MIT} +0 -0
  360. /package/{server → server-e2e}/auth.ts +0 -0
  361. /package/{server → server-e2e}/config.example.json +0 -0
  362. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  363. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  364. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  365. /package/{server → server-e2e}/peer.ts +0 -0
  366. /package/{server → server-e2e}/sse-session.ts +0 -0
  367. /package/{server → server-e2e}/util.ts +0 -0
  368. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,121 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createHash } from 'node:crypto'
3
+ import { PassThrough, Readable, Writable } from 'node:stream'
4
+ import { pipeline } from 'node:stream/promises'
5
+ import { createBrotliDecompress } from 'node:zlib'
6
+ import { STORAGE_MAGIC } from '../server-common/storage-crypto.ts'
7
+ import { type RawObject, isBlobId } from './object-storage.ts'
8
+ import type { StorageRow, StorageRowKind } from './storage-db.ts'
9
+
10
+ // A damaged/missing payload needs repair; retrying the whole cleanup job does
11
+ // not fix it. Keep this distinct from database, storage and cancellation errors.
12
+ export class StoragePayloadError extends Error {}
13
+
14
+ export function storageRowPaths(type: StorageRowKind, row: Pick<StorageRow, 'id' | 'kind'>): string[] {
15
+ return type === 'report' ? [`reports/${row.id}.br`, `reports/${row.id}`]
16
+ : [`bundles/${row.id}${row.kind === 'sourcemap' ? '.map.br' : ''}`]
17
+ }
18
+ // A cache of public content, which no row owns, kept unencrypted as avatars
19
+ // are: public npm versions' pretty-printed files (pretty-print.ts).
20
+ export const PUBLIC_CACHE = 'npm'
21
+ export const PUBLIC_CACHE_PREFIX = `cache/${PUBLIC_CACHE}/`
22
+ // A cache of private content no row owns: the code viewer's directories at
23
+ // repositories' default branches' heads (repository-code.ts
24
+ // repositoryCodeKept), by repository. Plaintext while storage is not
25
+ // encrypted, as other caches are; once it is, kept apart under
26
+ // ENCRYPTED_CACHE_PREFIX as a bundle's caches are, so that what was kept
27
+ // before is never read and legacy cleanup removes it, and encrypted with the
28
+ // master key, as upload parts are, each write's key derived from its path,
29
+ // so that one path's ciphertext fails to authenticate at another. No storage
30
+ // row holds a repository's code for a data key of its own to be wrapped in:
31
+ // its selection is none, and stays as it is deactivated. A key of its own
32
+ // would add a column and its migration only for its deletion to make what is
33
+ // left unreadable at once, as a deleted bundle's caches are; here deletion
34
+ // is physical, by the repository's prefix, at best, as an upload part's is,
35
+ // and what a failed one leaves is a directory as GitHub has it at a commit,
36
+ // read only by a reader GitHub lets read that repository.
37
+ export const REPOSITORY_CODE_CACHE = 'repository-code'
38
+ const REPOSITORY_CODE_CACHE_PREFIX = `cache/${REPOSITORY_CODE_CACHE}/`
39
+ export const masterKeyCache = (identity: string) => identity.startsWith(REPOSITORY_CODE_CACHE_PREFIX)
40
+ export function storageOwner(identity: string): { type: StorageRowKind; id: string; cache: boolean } | null {
41
+ const match = /^(reports|bundles)\/([^/]+)$/u.exec(identity)
42
+ if (match) {
43
+ const id = match[2]!.replace(match[1] === 'bundles' ? /\.map\.br$/u : /\.br$/u, '')
44
+ if (isBlobId(id)) return { type: match[1] === 'reports' ? 'report' : 'bundle', id, cache: false }
45
+ }
46
+ const cache = /^cache\/(?:bundles|report-sources)\/([a-f\d-]{36})\//u.exec(identity)
47
+ return cache && isBlobId(cache[1]!) ? { type: 'bundle', id: cache[1]!, cache: true } : null
48
+ }
49
+
50
+ // Peek without buffering the object or consuming bytes from its next reader.
51
+ export async function inspectStorageObject(stored: RawObject): Promise<RawObject & { encrypted: boolean }> {
52
+ const original = stored.stream
53
+ const iterator = original[Symbol.asyncIterator]()
54
+ const first: Buffer[] = [], prefix = Buffer.alloc(STORAGE_MAGIC.length)
55
+ let length = 0
56
+ try {
57
+ while (length < prefix.length) {
58
+ const next = await iterator.next()
59
+ if (next.done) break
60
+ const bytes = Buffer.from(next.value)
61
+ first.push(bytes)
62
+ length += bytes.copy(prefix, length, 0, prefix.length - length)
63
+ }
64
+ } catch (err) { original.destroy(); throw err }
65
+ async function* joined() {
66
+ try {
67
+ yield* first
68
+ for (;;) { const next = await iterator.next(); if (next.done) return; yield next.value }
69
+ } finally { await iterator.return?.(); original.destroy() }
70
+ }
71
+ const stream = Readable.from(joined(), { objectMode: false })
72
+ stream.once('close', () => original.destroy())
73
+ return { ...stored, stream, encrypted: length === prefix.length && prefix.equals(STORAGE_MAGIC) }
74
+ }
75
+
76
+ // Report hashes and sourcemap integrity describe the original upload, before Brotli storage.
77
+ // Hash incrementally, including decompression, so migration stays streaming.
78
+ export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal, identity?: string): Promise<void> {
79
+ const hash = createHash(type === 'report' ? 'sha256' : 'sha512')
80
+ const sink = new Writable({ write(chunk, _encoding, next) { hash.update(chunk); next() } })
81
+ const options = signal ? { signal } : {}
82
+ try {
83
+ if (type === 'bundle' && row.kind === 'sourcemap' || type === 'report' && identity?.endsWith('.br')) {
84
+ await pipeline(source, createBrotliDecompress(), sink, options)
85
+ } else await pipeline(source, sink, options)
86
+ } catch (err) {
87
+ const code = (err as NodeJS.ErrnoException).code ?? ''
88
+ if (['Z_DATA_ERROR', 'Z_BUF_ERROR'].includes(code) || code.startsWith('ERR__ERROR_FORMAT_')) {
89
+ throw new StoragePayloadError(`Stored ${type === 'report' ? 'report' : 'sourcemap'} cannot be decompressed`, { cause: err })
90
+ }
91
+ throw err
92
+ }
93
+ const actual = type === 'report' ? hash.digest('base64url') : `sha512-${hash.digest('base64')}`
94
+ if (actual !== row.hash) throw new StoragePayloadError('Stored payload does not match its upload hash')
95
+ }
96
+
97
+ // Forward the stored representation with backpressure, but withhold EOF until
98
+ // its upload hash passes. Encryption cannot emit its final frame, and an atomic
99
+ // PUT cannot commit, until verification (including Brotli decompression) ends.
100
+ export function verifiedStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal, identity?: string): Readable {
101
+ const input = new PassThrough(), output = new PassThrough()
102
+ const verification = verifyStoragePayload(type, row, input, signal, identity)
103
+ // Propagate asynchronous decompression errors even if the source is stalled.
104
+ void verification.catch(err => { source.destroy(err); output.destroy(err) })
105
+ const forwarding = pipeline(source, async function* (chunks) {
106
+ try {
107
+ for await (const chunk of chunks) {
108
+ await new Promise<void>((resolve, reject) => { input.write(chunk, err => err ? reject(err) : resolve()) })
109
+ yield chunk
110
+ }
111
+ input.end()
112
+ await verification
113
+ } finally {
114
+ input.destroy()
115
+ await verification.catch(() => {})
116
+ }
117
+ }, output, signal ? { signal } : {})
118
+ // pipeline forwards errors to output and tears down input on early cancellation.
119
+ void forwarding.catch(() => {})
120
+ return output
121
+ }
@@ -0,0 +1,91 @@
1
+ // Logical stores share one encryption boundary. Compression, identifiers and
2
+ // public response bytes stay the same with either physical backend.
3
+ import { Buffer } from 'node:buffer'
4
+ import type { AvatarStore } from './avatar-store.ts'
5
+ import type { BlobStore } from './blob-store.ts'
6
+ import type { BundleCacheStorage } from './bundle-cache.ts'
7
+ import { createBundleStore } from './bundle-store.ts'
8
+ import { createReportStore } from './report-store.ts'
9
+ import { CacheMissError, type CacheStorage, validateCacheKey } from './cache-storage.ts'
10
+ import { type ObjectStorage, isBlobId } from './object-storage.ts'
11
+ import { PUBLIC_CACHE, REPOSITORY_CODE_CACHE } from './storage-payload.ts'
12
+
13
+ function id(value: string): string {
14
+ if (!isBlobId(value)) throw new Error('Invalid managed blob id')
15
+ return value
16
+ }
17
+
18
+ function blobStore(objects: ObjectStorage, name: string, suffix = ''): BlobStore {
19
+ const path = (value: string) => `${name}/${id(value)}${suffix}`
20
+ return {
21
+ put: (value, bytes) => objects.put(path(value), bytes),
22
+ get: value => objects.get(path(value)),
23
+ open: value => objects.open(path(value)),
24
+ delete: value => objects.delete(path(value)),
25
+ }
26
+ }
27
+
28
+ function cacheStore(objects: ObjectStorage, name: string): CacheStorage {
29
+ const path = (key: string) => `cache/${name}/${validateCacheKey(key)}`
30
+ return {
31
+ exists: key => objects.exists(path(key)),
32
+ put: async (key, bytes) => { await objects.put(path(key), bytes) },
33
+ async open(key) {
34
+ const value = await objects.open(path(key))
35
+ if (!value) throw new CacheMissError()
36
+ return value
37
+ },
38
+ delete: (prefix, keep) => objects.deletePrefix(`${path(prefix)}/`, keep === undefined ? undefined : `${path(`${prefix}/${keep}`)}/`),
39
+ }
40
+ }
41
+
42
+ function avatarStore(objects: ObjectStorage, sidecar: boolean): AvatarStore {
43
+ return {
44
+ async put(value, contentType, bytes) {
45
+ const path = `avatars/${id(value)}`, type = contentType.split(';', 1)[0]!.trim()
46
+ if (!/^image\/[a-z0-9.+-]+$/iu.test(type)) throw new Error('Invalid avatar type')
47
+ if (sidecar) {
48
+ await objects.put(path, bytes)
49
+ await objects.put(`${path}.type`, Buffer.from(type))
50
+ } else await objects.put(path, Buffer.concat([Buffer.from(`${type}\n`), bytes]))
51
+ },
52
+ async get(value) {
53
+ const path = `avatars/${id(value)}`
54
+ const bytes = await objects.get(path)
55
+ if (!bytes) return null
56
+ if (sidecar) {
57
+ const type = await objects.get(`${path}.type`)
58
+ return type ? { bytes, contentType: type.toString().trim() || 'application/octet-stream' } : null
59
+ }
60
+ const split = bytes.indexOf(10)
61
+ if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
62
+ return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
63
+ },
64
+ async delete(value) {
65
+ const path = `avatars/${id(value)}`
66
+ await objects.delete(path)
67
+ if (sidecar) await objects.delete(`${path}.type`)
68
+ },
69
+ }
70
+ }
71
+
72
+ export function createManagedStores(objects: ObjectStorage, avatarSidecar: boolean) {
73
+ const bundles = cacheStore(objects, 'bundles')
74
+ const cacheStorage: BundleCacheStorage = {
75
+ exists: (value, file) => bundles.exists(`${id(value)}/${file}`),
76
+ put: (value, file, bytes) => bundles.put(`${id(value)}/${file}`, bytes),
77
+ open: (value, file) => bundles.open(`${id(value)}/${file}`),
78
+ delete: value => bundles.delete(id(value)),
79
+ }
80
+ return {
81
+ reportStore: createReportStore(blobStore(objects, 'reports'), blobStore(objects, 'reports', '.br'),
82
+ (value, bytes) => objects.put(`reports/${id(value)}.br`, bytes, { replaceReport: true })),
83
+ bundleStore: createBundleStore(blobStore(objects, 'bundles'), blobStore(objects, 'bundles', '.map.br')),
84
+ uploadStore: blobStore(objects, 'uploads'),
85
+ avatarStore: avatarStore(objects, avatarSidecar),
86
+ cacheStorage,
87
+ reportSourcesStorage: cacheStore(objects, 'report-sources'),
88
+ npmCacheStorage: cacheStore(objects, PUBLIC_CACHE),
89
+ repositoryCodeStorage: cacheStore(objects, REPOSITORY_CODE_CACHE),
90
+ }
91
+ }
@@ -0,0 +1,82 @@
1
+ // Backend selection is shared by standalone, combined and function entrypoints.
2
+ import { dirname } from 'node:path'
3
+ import { parseStorageKey } from '../server-common/storage-crypto.ts'
4
+ import { createBundleCache } from './bundle-cache.ts'
5
+ import { createPrettyCache, stalePrettyRemover } from './pretty-print.ts'
6
+ import { createReportSourcesCache } from './report-sources.ts'
7
+ import { openNeonManagedDb } from './db-neon.ts'
8
+ import type { ManagedConfig } from './config.ts'
9
+ import { createEncryptedObjectStorage } from './storage-encryption.ts'
10
+ import { createManagedStores } from './storage-stores.ts'
11
+ import { createDiskObjectStorage } from './object-storage-disk.ts'
12
+ import { openVercelObjectStorage } from './object-storage-vercel.ts'
13
+ import { migrateStorage, reapStorageUploads } from './storage-maintenance.ts'
14
+
15
+ // Database-only callers (notably status) must not activate byte encryption.
16
+ export async function openManagedStorageDb(config: ManagedConfig) {
17
+ const options = { triageHistoryLimit: config.triageHistoryLimit, storageEncryptionKey: parseStorageKey(config.storageEncryptionKey) }
18
+ if (!config.neonUrl) return (await import('./db.ts')).openSqliteManagedDb(config.dbPath, options)
19
+ // Serverless instances hold pooled connections only while an invocation lasts.
20
+ const attachPool = config.serverless ? (await import('@vercel/functions')).attachDatabasePool : undefined
21
+ return openNeonManagedDb(config.neonUrl, options, attachPool)
22
+ }
23
+
24
+ export async function openManagedStorage(config: ManagedConfig) {
25
+ const key = parseStorageKey(config.storageEncryptionKey)
26
+ if (config.storageEncryptionMigrate && !key) throw new Error('MANAGED_STORAGE_ENCRYPTION_MIGRATE requires MANAGED_STORAGE_ENCRYPTION_KEY')
27
+ if (config.neonUrl && !config.blobToken) throw new Error('Managed Neon mode requires BLOB_READ_WRITE_TOKEN')
28
+ if (config.serverless && !config.neonUrl) throw new Error('Serverless managed storage requires Neon')
29
+ const db = await openManagedStorageDb(config)
30
+ try {
31
+ const raw = config.neonUrl ? await openVercelObjectStorage(config.blobToken!) : createDiskObjectStorage(dirname(config.dbPath))
32
+ // Enable before exposing stores or token writers. Concurrent first starts
33
+ // serialize activation in the database and validate the winning key.
34
+ const enabled = key ? await db.getStorageEncryption() : null
35
+ if (key && !enabled) {
36
+ if (config.vercelPreview) throw new Error('Storage encryption must be enabled outside a Vercel preview deployment')
37
+ await db.enableStorageEncryption()
38
+ }
39
+ // Enabled already: that state, not read again.
40
+ const objects = await createEncryptedObjectStorage(raw, db, key, enabled)
41
+ const storage = createManagedStores(objects, !config.neonUrl)
42
+ // Move legacy team link uploads into encrypted SQL before serving catalogs.
43
+ // The DB transaction commits first; a failed blob cleanup cannot lose links.
44
+ for (const report of await db.listLegacyLinkReports()) {
45
+ if (!key) throw new Error('Migrating managed link reports requires MANAGED_STORAGE_ENCRYPTION_KEY')
46
+ let bytes
47
+ try { bytes = await storage.reportStore.get(report.id) } catch (error) {
48
+ if (!await db.getReport(report.id)) continue // another instance completed migration
49
+ throw error
50
+ }
51
+ if (!bytes) {
52
+ if (!await db.getReport(report.id)) continue
53
+ throw new Error(`Missing legacy link report: ${report.id}`)
54
+ }
55
+ if (await db.migrateLinkReport(report.id, bytes.toString('utf8'))) {
56
+ await storage.reportStore.delete(report.id).catch(error => console.warn('managed: legacy link cleanup failed:', error))
57
+ }
58
+ }
59
+ const bundleCache = createBundleCache(storage.cacheStorage, db, storage.bundleStore)
60
+ return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined, bundleCache,
61
+ reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore, bundle => bundleCache.sourcemapEdges(bundle)),
62
+ prettyCache: createPrettyCache(storage.cacheStorage, storage.npmCacheStorage, db, storage.bundleStore),
63
+ reapPrettyCopies: stalePrettyRemover(storage.npmCacheStorage),
64
+ async reapStorage(signal?: AbortSignal) {
65
+ if (!config.storageEncryptionMigrate || signal?.aborted) return
66
+ const state = await db.getStorageEncryption()
67
+ if (state?.complete && state.cleanupComplete) return
68
+ console.info('managed-storage-migration-start:', JSON.stringify({ migrated: state?.migrated, cursor: state?.cursor }))
69
+ const result = await migrateStorage(raw, db, key!, { maxMs: config.storageEncryptionMigrateMaxMs, signal })
70
+ console.info('managed-storage-migration:', JSON.stringify({
71
+ complete: result.complete === 1, cleanupComplete: result.cleanupComplete === 1,
72
+ migrated: result.migrated, cursor: result.cursor, retryAt: result.retryAt,
73
+ failed: result.failures.length, cancelled: signal?.aborted === true,
74
+ }))
75
+ },
76
+ ...(config.neonUrl ? { reapUploads: (signal?: AbortSignal) => reapStorageUploads(raw, db, Date.now(), signal) } : {}),
77
+ }
78
+ } catch (err) {
79
+ await db.close()
80
+ throw err
81
+ }
82
+ }
@@ -0,0 +1,134 @@
1
+ // Whether each team's sidebar entry opens collapsed to its App findings,
2
+ // decided before the catalog is sent so the first paint is already final. A
3
+ // team is classified from the same filtered, published workspace its view
4
+ // loads. Results are keyed by the catalog's own content keys: an unchanged
5
+ // catalog reuses them, and every change that can move a result also changes
6
+ // the catalog revision that clients refresh on.
7
+ //
8
+ // The same read names the reports a restricted reader would find nothing in:
9
+ // under -deps or -security the catalog leaves those out of the team's list.
10
+ // Readers with both permissions keep every report, empty or not.
11
+ //
12
+ // A team that is not App also says how many of its reports open in App mode
13
+ // on their own, and its findings for the badge it collapses to. None is as
14
+ // uniform as an App team, and collapses like one. A team mixing App and non-App
15
+ // reports stays expanded, and so does one whose reports each open as App but
16
+ // whose combined workspace does not (contradicting verdicts, say): it has App
17
+ // findings, and only expanded does it show that they fail to combine.
18
+ import { reportEntries } from '@preventive/report'
19
+ import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
20
+ import type { BlobStore } from './blob-store.ts'
21
+ import type { ManagedDb, TeamReportAccessSnapshot, UserTeam } from './db.ts'
22
+ import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
23
+ import { TeamReportsError, loadTeamReportsResponse } from './team-reports.ts'
24
+
25
+ export type { TeamAppMetadata }
26
+ export interface TeamWorkspaceSummary { app: TeamAppMetadata; empty: readonly string[] }
27
+ const NO_APP: TeamWorkspaceSummary = { app: { appMode: false }, empty: [] }
28
+
29
+ const CACHE_MAX_ENTRIES = 4096
30
+ // Each workspace build already reads four reports at a time.
31
+ const CLASSIFY_CONCURRENCY = 2
32
+ const caches = new WeakMap<ManagedDb, Map<string, TeamWorkspaceSummary>>()
33
+ const pendingJobs = new WeakMap<ManagedDb, Map<string, Promise<TeamWorkspaceSummary | null>>>()
34
+
35
+ // Published reports with their content keys, and the team's own key (access,
36
+ // scopes and links). Equal keys describe the same filtered workspace, whoever
37
+ // reads it, so accounts with the same access share one classification.
38
+ export function teamAppKey(team: UserTeam): string {
39
+ return JSON.stringify([team.cacheKey ?? null, published(team).map(report => [report.id, report.cacheKey]).toSorted()])
40
+ }
41
+
42
+ function published(team: UserTeam) {
43
+ return team.reports.filter(report => report.visible !== false)
44
+ }
45
+
46
+ function remember(db: ManagedDb, key: string, summary: TeamWorkspaceSummary): TeamWorkspaceSummary {
47
+ let cache = caches.get(db)
48
+ if (!cache) { cache = new Map(); caches.set(db, cache) }
49
+ cache.delete(key)
50
+ cache.set(key, summary)
51
+ for (const oldest of cache.keys()) {
52
+ if (cache.size <= CACHE_MAX_ENTRIES) break
53
+ cache.delete(oldest)
54
+ }
55
+ return summary
56
+ }
57
+
58
+ function isLinks(data: unknown): boolean {
59
+ const record = data as { source?: unknown; links?: unknown } | null
60
+ return record?.source === 'links' && Array.isArray(record.links)
61
+ }
62
+
63
+ // A links document counts by its links, every other report by its findings.
64
+ function hasContent(data: unknown): boolean {
65
+ if (isLinks(data)) return (data as { links: unknown[] }).links.length > 0
66
+ return (reportEntries(data) ?? []).some(entry => !Array.isArray(entry) || entry.length > 0)
67
+ }
68
+
69
+ // Null when the team cannot be classified right now: its catalog went stale,
70
+ // the access snapshot is gone, or storage failed. The sidebar then keeps the
71
+ // team expanded and lists every report, and a later catalog asks again.
72
+ // Oversized and unreadable workspaces never have an App view, so they are
73
+ // remembered as such, hiding nothing.
74
+ async function classify(db: ManagedDb, store: BlobStore, key: string, team: UserTeam,
75
+ snapshot: () => Promise<TeamReportAccessSnapshot | null>): Promise<TeamWorkspaceSummary | null> {
76
+ const cached = caches.get(db)?.get(key)
77
+ if (cached) return remember(db, key, cached)
78
+ const ids = new Set(published(team).map(report => report.id))
79
+ if (ids.size === 0) return remember(db, key, NO_APP)
80
+ let jobs = pendingJobs.get(db)
81
+ if (!jobs) { jobs = new Map(); pendingJobs.set(db, jobs) }
82
+ const existing = jobs.get(key)
83
+ if (existing) return existing
84
+ const job = (async () => {
85
+ const access = await snapshot()
86
+ // A catalog read before a concurrent publication names another report set.
87
+ if (!access?.teamId || access.reports.length !== ids.size || access.reports.some(report => !ids.has(report.id))) return null
88
+ const body = JSON.parse((await loadTeamReportsResponse(db, store, access)).toString('utf8')) as {
89
+ reports: { id: string; filename: string; data: unknown }[]; links?: string[][]
90
+ }
91
+ const restricted = access.reports.some(report => !report.permissions.dependencies || !report.permissions.security)
92
+ const empty = restricted ? body.reports.filter(report => !hasContent(report.data)).map(report => report.id) : []
93
+ const links = body.links ?? []
94
+ const app = managedTeamAppMetadata(Object.assign(body.reports, { links }), { countFindings: true })
95
+ if (app.appMode) return remember(db, key, { app, empty })
96
+ const appReports = body.reports.filter(report => !isLinks(report.data)
97
+ && managedTeamAppMetadata(Object.assign([report], { links })).appMode).length
98
+ return remember(db, key, { app: { ...app, appReports }, empty })
99
+ })().catch((error: unknown) => {
100
+ if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return remember(db, key, NO_APP)
101
+ if (!(error instanceof TeamReportsError)) console.warn('managed: team App classification failed:', error)
102
+ return null
103
+ })
104
+ jobs.set(key, job)
105
+ try { return await job } finally { jobs.delete(key) }
106
+ }
107
+
108
+ // The catalog's view of a classified team: its App classification, and its
109
+ // reports without the ones its restricted reader would find empty.
110
+ export function withWorkspaceSummary<T extends { reports: readonly { id: string }[] }>(team: T, summary: TeamWorkspaceSummary | null | undefined) {
111
+ const empty = new Set(summary?.empty)
112
+ return { ...team, app: summary?.app ?? null, reports: team.reports.filter(report => !empty.has(report.id)) }
113
+ }
114
+
115
+ // Classify a user's catalog, keyed by teamAppKey. `known` carries results from
116
+ // an earlier read of the same request, so only changed teams are classified.
117
+ export async function teamAppStates(db: ManagedDb, store: BlobStore, sessionId: string, teams: readonly UserTeam[],
118
+ known: ReadonlyMap<string, TeamWorkspaceSummary | null> = new Map()): Promise<Map<string, TeamWorkspaceSummary | null>> {
119
+ const states = new Map(known)
120
+ const missing = [...new Map(teams.map(team => [teamAppKey(team), team])).entries()].filter(([key]) => !states.has(key))
121
+ let next = 0
122
+ await Promise.all(Array.from({ length: Math.min(CLASSIFY_CONCURRENCY, missing.length) }, async () => {
123
+ while (next < missing.length) {
124
+ const [key, team] = missing[next++]!
125
+ states.set(key, await classify(db, store, key, team, () => db.getTeamReportAccessSnapshot(sessionId, Date.now(), team.id)))
126
+ }
127
+ }))
128
+ return states
129
+ }
130
+
131
+ // A public link's workspace is its own access snapshot.
132
+ export function sharedTeamApp(db: ManagedDb, store: BlobStore, snapshot: WorkspaceShareSnapshot): Promise<TeamWorkspaceSummary | null> {
133
+ return classify(db, store, JSON.stringify(['share', teamAppKey(snapshot.team)]), snapshot.team, () => Promise.resolve(snapshot))
134
+ }
@@ -0,0 +1,7 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // Version only this user's visible catalog, including access and content keys.
4
+ // REST and the live feed must describe exactly the same snapshot.
5
+ export function teamCatalogRevision(teams: readonly unknown[]): string {
6
+ return createHash('sha256').update(JSON.stringify(teams)).digest('base64url')
7
+ }
@@ -0,0 +1,180 @@
1
+ import type { ServerResponse } from 'node:http'
2
+ import { setTimeout as delay } from 'node:timers/promises'
3
+ import type { ManagedHttpDeps } from './http.ts'
4
+ import type { StoredUser, TeamReportAccessSnapshot } from './db.ts'
5
+ import { TeamReportsError, annotationRevisionDigest, teamAnnotationRevision, teamSnapshotKey, teamWorkspaceFindingIds } from './team-reports.ts'
6
+ import type { WorkspaceShareFeedState } from './workspace-shares.ts'
7
+
8
+ // Short database reads work across instances without pinning a Neon connection.
9
+ // All polling belongs to the awaited request; nothing runs after it completes.
10
+ const TEAM_FEED_POLL_MS = 3_000
11
+ export const TEAM_FEED_LIFETIME_MS = 240_000 // below the configured Vercel 300s
12
+ const HEARTBEAT_MS = 15_000
13
+
14
+ type FeedOptions = { pollMs?: number; lifetimeMs?: number }
15
+ type Publish = (event: 'teams' | 'triage', revision: string | undefined) => boolean
16
+
17
+ // Public capabilities remain scoped to their single shared workspace.
18
+ export async function serveTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
19
+ snapshot: TeamReportAccessSnapshot, recheck: () => Promise<void>,
20
+ options: FeedOptions & { readState?: () => Promise<WorkspaceShareFeedState | null> } = {}): Promise<void> {
21
+ let ids: string[] | undefined
22
+ let previous: WorkspaceShareFeedState | undefined
23
+ const sameAccess = (a: WorkspaceShareFeedState, b?: WorkspaceShareFeedState) => a.grant === b?.grant && a.catalog === b.catalog
24
+ const readState = async () => {
25
+ const state = await options.readState!()
26
+ if (!state) throw new TeamReportsError(404, 'workspace-changed')
27
+ return state
28
+ }
29
+ await serveFeed(res, deps, async publish => {
30
+ const state = options.readState ? await readState() : undefined
31
+ // The first poll still compares the full snapshot loaded before the stream.
32
+ // Unchanged polls validate the live grant without reading report catalogs.
33
+ if (state && sameAccess(state, previous)) {
34
+ if (state.annotations === previous!.annotations) return
35
+ } else await recheck()
36
+ ids ??= [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
37
+ const triage = await deps.db.getAnnotationRevision(ids)
38
+ const after = options.readState ? await readState() : undefined
39
+ if (!after || !sameAccess(after, state)) await recheck()
40
+ publish('triage', triage)
41
+ // A write during the read must remain pending for the following poll.
42
+ if (after?.annotations === state?.annotations) previous = after
43
+ }, options)
44
+ }
45
+
46
+ // Membership and content notifications cover all of the user's teams. Only
47
+ // the focused team needs report parsing or annotation reads; null is the
48
+ // catalog-only subscription used on landing and management pages.
49
+ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
50
+ sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions & { reportId?: string | null } = {}): Promise<void> {
51
+ let ids: string[] = [], visibilityKey: string | undefined
52
+ let focused: TeamReportAccessSnapshot | undefined
53
+ let previousState: { catalog: number; annotations: number } | undefined
54
+ const checkUser = (current: { user: Pick<StoredUser, 'id' | 'role'> } | null) => {
55
+ if (!current) throw new TeamReportsError(401, 'unauthenticated')
56
+ if (current.user.id !== user.id || current.user.role !== user.role || current.user.role === 'none') {
57
+ throw new TeamReportsError(403, 'access-changed')
58
+ }
59
+ }
60
+ await serveFeed(res, deps, async publish => {
61
+ const state = await deps.db.getFeedState(sessionId, Date.now())
62
+ checkUser(state)
63
+ if (state!.catalog === previousState?.catalog && (!teamId || state!.annotations === previousState?.annotations)) return
64
+ if (teamId && focused !== undefined && state!.catalog === previousState?.catalog) {
65
+ // Annotation changes cannot change visibility. Read only this workspace's
66
+ // IDs, then fence against concurrent access/content changes before sending.
67
+ const triage = await teamAnnotationRevision(deps.db, focused, ids)
68
+ const after = await deps.db.getFeedState(sessionId, Date.now())
69
+ checkUser(after)
70
+ if (after!.catalog !== state!.catalog) return
71
+ publish('triage', triage)
72
+ if (after!.annotations === state!.annotations) previousState = state!
73
+ return
74
+ }
75
+ const catalog = await deps.db.getUserTeamFeedSnapshot(sessionId, Date.now())
76
+ checkUser(catalog)
77
+ // The session read again after the catalog, as the catalog is published
78
+ // next: one statement, as the poll's own check is.
79
+ checkUser(await deps.db.getFeedState(sessionId, Date.now()))
80
+ // Publish catalog state before loading any report blobs. A slow or broken
81
+ // focused report must not hide membership/content changes from the client.
82
+ if (!publish('teams', catalog!.revision)) return
83
+ if (!teamId) {
84
+ // A mutation during the snapshot must cause a fresh read next poll.
85
+ const current = await deps.db.getFeedState(sessionId, Date.now())
86
+ checkUser(current)
87
+ if (current!.catalog === state!.catalog) previousState = state!
88
+ return
89
+ }
90
+ let triage: string | undefined
91
+ const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
92
+ if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
93
+ checkUser(snapshot)
94
+ const key = teamSnapshotKey(snapshot)
95
+ if (key !== visibilityKey) {
96
+ try {
97
+ ids = snapshot.teamId ? [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)] : []
98
+ visibilityKey = key
99
+ } catch {
100
+ // Missing, malformed or temporarily unavailable blobs only suspend
101
+ // triage. Retry visibility on the next poll, even for the same key.
102
+ publish('triage', undefined)
103
+ return
104
+ }
105
+ }
106
+ // As GET /api/teams/:id/annotations answers it, so that a client holding
107
+ // that answer knows this tells nothing new.
108
+ if (snapshot.teamId) triage = await teamAnnotationRevision(deps.db, snapshot, ids)
109
+ const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
110
+ // A concurrent access/content change discards only the annotation read.
111
+ checkUser(current)
112
+ if (!current || teamSnapshotKey(current) !== key) return
113
+ publish('triage', triage)
114
+ focused = snapshot.teamId ? snapshot : undefined
115
+ const after = await deps.db.getFeedState(sessionId, Date.now())
116
+ checkUser(after)
117
+ if (after!.catalog === state!.catalog && after!.annotations === state!.annotations) previousState = state!
118
+ }, { ...options, triageRevision: true })
119
+ }
120
+
121
+ // With `triageRevision`, a triage event tells its revision's digest, as the
122
+ // focused team's annotations answer it; else none, as a public share's.
123
+ async function serveFeed(res: ServerResponse, deps: ManagedHttpDeps, read: (publish: Publish) => Promise<void>,
124
+ { pollMs = TEAM_FEED_POLL_MS, lifetimeMs = TEAM_FEED_LIFETIME_MS, triageRevision = false }: FeedOptions & { triageRevision?: boolean }): Promise<void> {
125
+ const controller = new AbortController()
126
+ const close = () => controller.abort()
127
+ res.once('close', close)
128
+ const deadline = setTimeout(close, lifetimeMs)
129
+ const stopped = () => controller.signal.aborted || res.destroyed || deps.isShuttingDown()
130
+ // Notifications carry no annotation bodies, finding IDs, or global cursors.
131
+ // The catalog's opaque version, and a focused team's annotation revision's
132
+ // digest, let clients reuse an identical REST snapshot; every connection
133
+ // still confirms current access, including after reconnects.
134
+ function write(frame: string): boolean {
135
+ if (stopped()) return false
136
+ if (res.write(frame)) return true
137
+ // Slow consumers reconnect and read current state; never queue a backlog.
138
+ res.destroy()
139
+ return false
140
+ }
141
+ try {
142
+ if (stopped()) return
143
+ res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8',
144
+ 'cache-control': 'private, no-store, no-transform', 'x-accel-buffering': 'no',
145
+ 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' })
146
+ res.flushHeaders()
147
+ let heartbeat = Date.now()
148
+ const previous = new Map<string, string>()
149
+ const publish: Publish = (event, revision) => {
150
+ if (stopped()) return false
151
+ if (revision === undefined) { previous.delete(event); return true }
152
+ if (revision === previous.get(event)) return true
153
+ const data = event === 'teams' ? JSON.stringify({ revision }) : triageRevision ? JSON.stringify({ revision: annotationRevisionDigest(revision) }) : '{}'
154
+ if (!write(`event: ${event}\ndata: ${data}\n\n`)) return false
155
+ previous.set(event, revision)
156
+ heartbeat = Date.now()
157
+ return true
158
+ }
159
+ while (!stopped()) {
160
+ if (deps.db.withRequest) await deps.db.withRequest(() => read(publish))
161
+ else await read(publish)
162
+ if (stopped()) break
163
+ if (Date.now() - heartbeat >= HEARTBEAT_MS) {
164
+ if (!write(': keepalive\n\n')) break
165
+ heartbeat = Date.now()
166
+ }
167
+ await delay(pollMs, undefined, { signal: controller.signal }).catch(error => {
168
+ if (!controller.signal.aborted) throw error
169
+ })
170
+ }
171
+ } catch (error) {
172
+ if (!res.headersSent) throw error
173
+ if (error instanceof TeamReportsError) write('event: close\ndata: {}\n\n')
174
+ else if (!stopped()) throw error
175
+ } finally {
176
+ clearTimeout(deadline)
177
+ res.off('close', close)
178
+ if (res.headersSent && !res.destroyed) res.end()
179
+ }
180
+ }