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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2KBF5TZ7.js +2 -0
  72. package/out/chunk-2XUEZHIP.js +28 -0
  73. package/out/chunk-35VE6CDH.js +2 -0
  74. package/out/chunk-3BZ7JERB.js +1 -0
  75. package/out/chunk-4F6JSF57.js +3 -0
  76. package/out/chunk-4XCDFGDP.js +10 -0
  77. package/out/chunk-AQ75QITE.js +1 -0
  78. package/out/chunk-B2RQY6OP.js +2 -0
  79. package/out/chunk-C4Z6ZBXV.js +10 -0
  80. package/out/chunk-CMQA4DM6.js +1 -0
  81. package/out/chunk-CVVJAXVY.js +2 -0
  82. package/out/chunk-DIJJNTRH.js +10 -0
  83. package/out/chunk-GJ4IUCNS.js +1 -0
  84. package/out/chunk-GNXOKIGP.js +80 -0
  85. package/out/chunk-GZ4MS3TE.js +13 -0
  86. package/out/chunk-IISEF2BZ.js +1 -0
  87. package/out/chunk-IU3CCINR.js +3 -0
  88. package/out/chunk-K3X5EJBQ.js +2 -0
  89. package/out/chunk-KEX2ATEW.js +2 -0
  90. package/out/chunk-KYCNJVB7.js +1 -0
  91. package/out/chunk-MYDDTA7W.js +225 -0
  92. package/out/chunk-NRPC74AM.js +1 -0
  93. package/out/chunk-OOJM4CTU.js +1 -0
  94. package/out/chunk-PNHBZGDD.js +1 -0
  95. package/out/chunk-R5O6FAW4.js +2 -0
  96. package/out/chunk-RFZQQXSG.js +17 -0
  97. package/out/chunk-RSHMVYNS.js +1 -0
  98. package/out/chunk-TGANXVTA.js +1 -0
  99. package/out/chunk-TUII34K2.js +1 -0
  100. package/out/chunk-TZ5KB2Y5.js +10 -0
  101. package/out/chunk-UN5ZSYLE.js +1 -0
  102. package/out/chunk-UNTRGE26.js +2 -0
  103. package/out/chunk-UXNUAIIC.js +33 -0
  104. package/out/chunk-UYAXVRVL.js +1 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XYDGU6BM.js +51 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +297 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +358 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +142 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +219 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/specs.js +124 -0
  154. package/risk/npm/tarball.js +113 -0
  155. package/server-common/database-config.ts +16 -0
  156. package/server-common/initialize.ts +18 -0
  157. package/server-common/npm-advisories.ts +101 -0
  158. package/{server → server-common}/origin.ts +5 -5
  159. package/server-common/reap.ts +48 -0
  160. package/server-common/scan-config.ts +19 -0
  161. package/server-common/standalone.ts +29 -0
  162. package/server-common/storage-crypto.ts +182 -0
  163. package/server-common/storage-log.ts +38 -0
  164. package/server-common/vercel-blob.ts +103 -0
  165. package/server-e2e/app.ts +486 -0
  166. package/{server → server-e2e}/auth.ts +5 -1
  167. package/{server → server-e2e}/bus-receiver.ts +2 -1
  168. package/{server → server-e2e}/cli.js +9 -4
  169. package/{server → server-e2e}/config.ts +56 -35
  170. package/{server → server-e2e}/db-neon.ts +2 -2
  171. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  172. package/{server → server-e2e}/db-stmt.ts +2 -2
  173. package/{server → server-e2e}/db.ts +7 -7
  174. package/{server → server-e2e}/http.ts +116 -18
  175. package/{server → server-e2e}/hub.ts +40 -9
  176. package/server-e2e/index.ts +17 -0
  177. package/server-e2e/lifecycle.ts +95 -0
  178. package/{server → server-e2e}/neon-driver.ts +2 -2
  179. package/{server → server-e2e}/npm-proxy.ts +12 -145
  180. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  181. package/{server → server-e2e}/objstore/blob.ts +1 -1
  182. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  183. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  184. package/{server → server-e2e}/objstore/init.ts +35 -18
  185. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  186. package/server-e2e/objstore/rest-deny.ts +28 -0
  187. package/server-e2e/objstore/rest-mint.ts +224 -0
  188. package/{server → server-e2e}/objstore/rest.ts +27 -26
  189. package/{server → server-e2e}/objstore/sign.ts +105 -0
  190. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  191. package/{server → server-e2e}/objstore/store.ts +7 -7
  192. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  193. package/{server → server-e2e}/pubsub.ts +12 -9
  194. package/{server → server-e2e}/sign.ts +4 -4
  195. package/{server → server-e2e}/sse-server.ts +102 -68
  196. package/{server → server-e2e}/sse-session.ts +28 -8
  197. package/{server → server-e2e}/static.ts +31 -21
  198. package/server-e2e/sync-handlers.ts +408 -0
  199. package/{server → server-e2e}/ws-server.ts +20 -9
  200. package/server-managed/activity.ts +245 -0
  201. package/server-managed/avatar-store.ts +12 -0
  202. package/server-managed/blob-store.ts +17 -0
  203. package/server-managed/brotli.ts +10 -0
  204. package/server-managed/bundle-advisories.ts +120 -0
  205. package/server-managed/bundle-advisory-inventory.ts +77 -0
  206. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  207. package/server-managed/bundle-build-diagnostics.js +23 -0
  208. package/server-managed/bundle-build-leases.ts +41 -0
  209. package/server-managed/bundle-build-worker.js +111 -0
  210. package/server-managed/bundle-build.ts +198 -0
  211. package/server-managed/bundle-cache.ts +199 -0
  212. package/server-managed/bundle-catalog.ts +29 -0
  213. package/server-managed/bundle-commits.ts +140 -0
  214. package/server-managed/bundle-store.ts +24 -0
  215. package/server-managed/bundle-summary-cache.ts +130 -0
  216. package/server-managed/bundle.ts +120 -0
  217. package/server-managed/cache-storage.ts +19 -0
  218. package/server-managed/cli.js +21 -0
  219. package/server-managed/combined.ts +46 -0
  220. package/server-managed/comments.ts +151 -0
  221. package/server-managed/config.ts +158 -0
  222. package/server-managed/content-access.ts +15 -0
  223. package/server-managed/crypto.ts +25 -0
  224. package/server-managed/db-methods.ts +1725 -0
  225. package/server-managed/db-neon.ts +367 -0
  226. package/server-managed/db-schema.ts +227 -0
  227. package/server-managed/db-table-names.ts +22 -0
  228. package/server-managed/db.ts +132 -0
  229. package/server-managed/dependency-reviews.ts +248 -0
  230. package/server-managed/github-app.ts +391 -0
  231. package/server-managed/github-errors.ts +68 -0
  232. package/server-managed/github-issue-links.ts +127 -0
  233. package/server-managed/github-issue-oauth.ts +47 -0
  234. package/server-managed/github-issues.ts +152 -0
  235. package/server-managed/github-metadata.ts +213 -0
  236. package/server-managed/github-oauth.ts +235 -0
  237. package/server-managed/github-pulls.ts +227 -0
  238. package/server-managed/http-response.ts +50 -0
  239. package/server-managed/http.ts +3472 -0
  240. package/server-managed/import-triage.ts +51 -0
  241. package/server-managed/index.ts +187 -0
  242. package/server-managed/kept.ts +28 -0
  243. package/server-managed/link-reports.ts +158 -0
  244. package/server-managed/locked-packages.ts +85 -0
  245. package/server-managed/lru.ts +30 -0
  246. package/server-managed/managed-issues.ts +75 -0
  247. package/server-managed/management-catalog.ts +45 -0
  248. package/server-managed/management.ts +99 -0
  249. package/server-managed/npm-insights.ts +440 -0
  250. package/server-managed/npm-kept.ts +65 -0
  251. package/server-managed/npm-loads.ts +167 -0
  252. package/server-managed/npm-manifest.ts +115 -0
  253. package/server-managed/npm-packages.ts +378 -0
  254. package/server-managed/npm-provenance.ts +123 -0
  255. package/server-managed/npm-risk.ts +177 -0
  256. package/server-managed/object-storage-disk.ts +148 -0
  257. package/server-managed/object-storage-vercel.ts +67 -0
  258. package/server-managed/object-storage.ts +74 -0
  259. package/server-managed/package-entry-points.ts +51 -0
  260. package/server-managed/pretty-print.ts +352 -0
  261. package/server-managed/public-workspace.ts +220 -0
  262. package/server-managed/rename-diff-worker.js +26 -0
  263. package/server-managed/rename-diff.ts +270 -0
  264. package/server-managed/repo-path.ts +21 -0
  265. package/server-managed/report-migration.ts +35 -0
  266. package/server-managed/report-query.ts +10 -0
  267. package/server-managed/report-response.ts +16 -0
  268. package/server-managed/report-sources.ts +178 -0
  269. package/server-managed/report-store.ts +47 -0
  270. package/server-managed/repository-aliases.ts +91 -0
  271. package/server-managed/repository-browser.ts +153 -0
  272. package/server-managed/repository-discovery.ts +82 -0
  273. package/server-managed/repository-policy.ts +27 -0
  274. package/server-managed/repository-risk.ts +418 -0
  275. package/server-managed/revisions.ts +35 -0
  276. package/server-managed/session.ts +92 -0
  277. package/server-managed/slugs.ts +38 -0
  278. package/server-managed/solidity-entry-points.ts +69 -0
  279. package/server-managed/sql-postgres.ts +31 -0
  280. package/server-managed/sql.ts +76 -0
  281. package/server-managed/static.ts +28 -0
  282. package/server-managed/storage-command.ts +16 -0
  283. package/server-managed/storage-db.ts +175 -0
  284. package/server-managed/storage-encryption.ts +279 -0
  285. package/server-managed/storage-maintenance.ts +256 -0
  286. package/server-managed/storage-payload.ts +103 -0
  287. package/server-managed/storage-stores.ts +85 -0
  288. package/server-managed/storage.ts +78 -0
  289. package/server-managed/team-app.ts +94 -0
  290. package/server-managed/team-catalog.ts +7 -0
  291. package/server-managed/team-feed.ts +173 -0
  292. package/server-managed/team-npm-scopes.ts +77 -0
  293. package/server-managed/team-reports.ts +228 -0
  294. package/server-managed/triage-response.ts +16 -0
  295. package/server-managed/type-only-worker.js +19 -0
  296. package/server-managed/type-only.ts +103 -0
  297. package/server-managed/uploads.ts +86 -0
  298. package/server-managed/upstream-cache.ts +215 -0
  299. package/server-managed/view-as.ts +58 -0
  300. package/server-managed/workspace-shares.ts +166 -0
  301. package/server.ts +50 -0
  302. package/server/index.ts +0 -477
  303. package/server/lifecycle.ts +0 -204
  304. package/server/sync-handlers.ts +0 -320
  305. /package/{LICENSE → LICENSE-MIT} +0 -0
  306. /package/{server → server-e2e}/config.example.json +0 -0
  307. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  308. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  309. /package/{server → server-e2e}/peer.ts +0 -0
  310. /package/{server → server-e2e}/util.ts +0 -0
  311. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,3472 @@
1
+ // HTTP router for the managed auth server. Mounts the GitHub OAuth flow plus
2
+ // the session endpoints + the mode probe; there is NO api/sync here (a managed
3
+ // server doesn't speak the e2e sync protocol yet). Every handler is async; the
4
+ // boot's `track()` keeps in-flight requests drainable on shutdown.
5
+ //
6
+ // GET /api/config → { mode:'managed', managed:{ loginPath, cookieName } } (public)
7
+ // GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
8
+ // GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
9
+ // GET /api/auth/session → { user, csrfToken } | 401
10
+ // GET /api/teams → the current user's teams + their reports and bundles | 401
11
+ // GET /api/teams/<id>/fixes → PR/issue metadata from visible findings' stored Fix links
12
+ // GET /api/teams/<id>/reports → all reports and links filtered through this team | 401/404
13
+ // GET /api/teams/<id>/feed → SSE notifications for visible triage and comments | 401/404
14
+ // GET /api/reports/<id> → admin/manager report preview | 401/403/404
15
+ // POST /api/reports/query → admin/manager batch preview, with repository metadata | 400/401/403/404/503
16
+ // GET /api/reports/<id>/triage → triage entries (by finding id, shared across reports) for a viewable report's findings | 401/404
17
+ // POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
18
+ // GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
19
+ // GET /api/avatar/<id> → cached avatar bytes by user id | 401/404
20
+ // GET /api/admin/users → admin-only user list | 401/403
21
+ // GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
22
+ // POST /api/admin/set-role → admin sets another user's role | 401/403/404
23
+ // GET /api/admin/repositories → admin repo list (each flagged selected) | 401/403
24
+ // POST /api/admin/repositories/select → admin selects/deactivates a repo | 401/403
25
+ // GET /api/admin/repositories/impact → admin attached data summary
26
+ // GET /api/admin/repositories/{browsable,refs,contents} → admin|manage + GitHub read access
27
+ // POST /api/admin/repositories/remove → admin permanently removes a repo
28
+ // GET /api/admin/repositories/risk[?team=|?repo=] → admin: each connected repo's npm lockfiles, or a team's or one repo's,
29
+ // their packages and what risk tells of each, Brotli-encoded ndjson as each is found; the reader leaving stops it | 400/401/403/404
30
+ // GET /api/admin/reports → admin|manage list of uploaded reports | 401/403
31
+ // POST /api/admin/reports → admin|manage uploads a report (raw body) | 401/403/413
32
+ // GET /api/admin/reports/<id> → admin|manage downloads a stored report | 401/403/404
33
+ // DELETE /api/admin/reports/<id> → admin|manage deletes a report | 401/403/404
34
+ // POST /api/admin/reports/set-repo → admin|manage attaches/detaches a report's repo | 401/403/404
35
+ // GET /api/admin/reports/<id>/location → admin|manage live location suggestion | 401/403/404/503
36
+ // GET /api/bundles/<id>/{metadata,contents} → authorized encoded bytes | 401/404/422
37
+ // GET /api/bundles/<id>/advisories → published dependency advisories (security access) | 401/403/404
38
+ // GET /api/bundles/<id>/download → authorized original upload | 401/404
39
+ // GET /api/bundles/<id>/pretty?path=&hash= → one of its files pretty-printed, Brotli-encoded | 400/401/404/409/413/422/429
40
+ // GET /api/bundles/<id>/pretty?other= → the files that changed between it and another bundle, both sides' pretty-printed,
41
+ // Brotli-encoded ndjson | 400/401/404/422/503
42
+ // GET /api/bundles/<id>/rename-diff?path=&hash=&other=&otherPath=&otherHash= → a JavaScript file diffed with another
43
+ // bundle's, both pretty-printed, renamed names hidden, whitespace changes shown and hidden | 400/401/404/409/413/422/429/504
44
+ // GET /api/bundles/<id>/rename-diff?other=&scope= → every pair of its JavaScript files and another bundle's the viewer
45
+ // diffs so, within a reason scope where given, Brotli-encoded ndjson | 400/401/404/422/503
46
+ // GET /api/bundles/<id>/type-only?other=&scope= → the files that changed between it and another bundle in types alone, as
47
+ // @preventive/compare proves them, within a reason scope where given, Brotli-encoded ndjson | 400/401/404/422/503
48
+ // GET /api/admin/bundles → admin all bundles; managers own/team bundles | 401/403
49
+ // POST /api/admin/bundles → admin|manage uploads a bundle (raw body) | 401/403/413
50
+ // GET /api/admin/bundles/<id> → admin|manage downloads a stored bundle | 401/403/404
51
+ // DELETE /api/admin/bundles/<id> → admin|manage deletes a bundle | 401/403/404
52
+ // POST /api/admin/bundles/set-repo → admin|manage attaches/detaches a bundle's repo | 401/403/404
53
+ // GET /api/admin/uploads/key → admin|manage public key for sealed upload bodies | 401/403
54
+ // GET /api/admin/teams → admin teams (+ members/repos) + pickers | 401/403
55
+ // POST /api/admin/teams → admin creates a team | 401/403/409
56
+ // POST /api/admin/teams/rename → admin renames a team | 401/403/404/409
57
+ // POST /api/admin/teams/set-hidden → admin hides/restores a team | 401/403/404
58
+ // POST /api/admin/teams/delete → admin deletes a team | 401/403/404
59
+ // POST /api/admin/teams/{set,remove}-repo → admin links/unlinks a repo (+path) | 401/403/404
60
+ // POST /api/admin/teams/{set,remove}-member → admin links/unlinks a user (+perms) | 401/403/404
61
+ // POST /api/admin/teams/set-dependency-review → admin toggles dependency review publication for a team | 400/401/403/404
62
+ // POST /api/admin/teams/set-npm-scopes → admin replaces a team's npm scopes | 400/401/403/404
63
+ // GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set | 400/401/403/404/409
64
+ // POST /api/npm/reviews/discard → author discards an unpublished review and its file rows | 400/401/403/409
65
+ // GET /api/npm/reviews/latest → latest own review for exact target bytes, across all bases | 400/401/403
66
+ // GET /api/npm/package?name=&version= → a published version's manifest and files | 400/401/404/413/429/502
67
+ // GET /api/npm/versions?name= → its dist-tags, versions, when each was published and its first was, and whether a team lists its scope | 400/401/404/429/502
68
+ // GET /api/npm/download?name=&version= → its tarball | 400/401/404/413/429/502
69
+ // GET /api/npm/stats?name= → its downloads over the last year and its GitHub repository's figures | 400/401/404/429/502
70
+ // GET /api/npm/advisories?name= → its advisories across every published version, npm's and its repository's | 400/401/404/429/502
71
+ // GET /api/npm/tags?name=&version= → the tags pointing to its publish commit | 400/401/404/429/502
72
+ // GET /api/npm/socket?name=&version= → Socket's scores and alerts for a public version | 400/401/404/429/502
73
+ // GET /api/npm/provenance?name=&version= → npm's provenance for a public version | 400/401/404/429/502
74
+ // GET /api/npm/pretty?name=&version= → every one of its files that needs it pretty-printed, Brotli-encoded ndjson | 400/401/404/413/429/502/503
75
+ // GET /api/npm/pretty?name=&version=&otherVersion= → the files that changed between it and another version, both sides'
76
+ // pretty-printed, Brotli-encoded ndjson | 400/401/404/413/429/502/503
77
+ // GET /api/npm/pretty?name=&version=&path=&hash= → one of its files pretty-printed, Brotli-encoded | 400/401/404/409/413/422/429/502/503
78
+ // GET /api/npm/rename-diff?name=&version=&path=&hash=&otherVersion=&otherPath=&otherHash= → a JavaScript file diffed with
79
+ // another version's, both pretty-printed, renamed names hidden, whitespace changes shown and hidden | 400/401/404/409/413/422/429/502/503/504
80
+ // GET /api/npm/rename-diff?name=&version=&otherVersion= → every pair of their JavaScript files the viewer diffs so,
81
+ // Brotli-encoded ndjson | 400/401/404/413/429/502/503
82
+ // GET /api/npm/type-only?name=&version=&otherVersion= → the files that changed between two versions in types alone,
83
+ // Brotli-encoded ndjson | 400/401/404/413/429/502/503
84
+ // GET /api/npm/risk?name=&version=&otherVersion= → what calls for a look at a version, as risk tells it, against another where given | 400/401/404/413/429/502
85
+ // POST /api/auth/logout → same-origin + CSRF, drops the session (and any view)
86
+ // POST /api/auth/view-as → admin opens a read-only view as another user | 400/401/403/404
87
+ // DELETE /api/auth/view-as → ends the view (the view's CSRF token)
88
+ import { mergeLinkGroups } from './link-reports.ts'
89
+ import { backfillFindingIds, reportEntries } from '@preventive/report'
90
+ import type { IncomingMessage, ServerResponse } from 'node:http'
91
+ import { pipeline } from 'node:stream/promises'
92
+ import { npmVersionStore, riskLineStore } from './npm-kept.ts'
93
+ import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
94
+ import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
95
+ import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
96
+ import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, newUploadKey, openSessionUpload, putUploadPart, readUpload, uploadPublicKey, validUpload, validUploadPart } from './uploads.ts'
97
+ import { UPLOAD_SEAL_HEADER, maxSealedBytes } from '../common/managed/upload-seal.ts'
98
+ import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
99
+ import type { CacheStorage } from './cache-storage.ts'
100
+ import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
101
+ import { type CatalogCommit, backfillBundleCommits, bundleCommits, cacheCommitDetails } from './bundle-commits.ts'
102
+ import { contentAccess } from './content-access.ts'
103
+ import type { BundleStore } from './bundle-store.ts'
104
+ import { Buffer } from 'node:buffer'
105
+ import { createHash, randomUUID } from 'node:crypto'
106
+ import type { AvatarStore } from './avatar-store.ts'
107
+ import type { BlobStore, OpenedBlob } from './blob-store.ts'
108
+ import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
109
+ import { resolveRepositoryImportLocation } from './repository-aliases.ts'
110
+ import type { ManagedConfig } from './config.ts'
111
+ import type { BundleBuildConditions, BundleProvenance, BundleReuse, ManagedBundle, ManagedDb, ManagedSession, ReportRecord, SelectedRepo, StoredUser, TriageEventRow } from './db.ts'
112
+ import type { OriginGate } from '../server-common/origin.ts'
113
+ import { isRole, roleAtLeast } from '../common/managed/roles.ts'
114
+ import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
115
+ import { filterReportData } from '../common/managed/report-filter.ts'
116
+ import type { TriageEntryPatch } from '../common/managed/triage.ts'
117
+ import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
118
+ import { reportRepoGithub } from '@preventive/report'
119
+ import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
120
+ import type { ReportSourcesCache } from './report-sources.ts'
121
+ import { normalizeTeamPath } from './repo-path.ts'
122
+ import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
123
+ import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
124
+ import { bundleCommitHash } from '../common/bundle-commit.js'
125
+ import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
126
+ import { upstreamGithub } from './github-errors.ts'
127
+ import { createClient } from '@preventive/upstream/github.js'
128
+ import type { ConnectedRepo } from './github-app.ts'
129
+ import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
130
+ import { RepositoryDiscovery } from './repository-discovery.ts'
131
+ import { RISK_KEPT_MS, repositoryRiskLines } from './repository-risk.ts'
132
+ import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
133
+ import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
134
+ import { clearCookie, endSession, readSession } from './session.ts'
135
+ import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
136
+ import type { ActivityContext, ActivityInput } from './activity.ts'
137
+ import { acceptsReportMetadata } from './report-response.ts'
138
+ import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
139
+ import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
140
+ import { lookupFixes, storedFixUrls } from './github-pulls.ts'
141
+ import { lookupIssueLinks } from './github-issue-links.ts'
142
+ import { visibleManagedIssues } from './managed-issues.ts'
143
+ import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
144
+ import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
145
+ import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
146
+ import { triageWireEntry } from './triage-response.ts'
147
+ import { handlePublicWorkspace } from './public-workspace.ts'
148
+ import { serveUserTeamFeed } from './team-feed.ts'
149
+ import { teamAppKey, teamAppStates } from './team-app.ts'
150
+ import { hashToken, randomToken, safeEqual } from './crypto.ts'
151
+ import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
152
+ import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
153
+ import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
154
+ import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
155
+ import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
156
+ import { computeBundleDiff } from '../common/bundle-compare-diff.js'
157
+ import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
158
+ import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
159
+ import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
160
+ import { npmProvenance } from './npm-provenance.ts'
161
+ import { npmRisk, npmRiskRemoved } from './npm-risk.ts'
162
+ import { NpmRiskError } from '../risk/index.js'
163
+ import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
164
+ import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
165
+ import { typeOnlyFiles } from './type-only.ts'
166
+ import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
167
+
168
+ const SESSION_PATH = '/api/auth/session'
169
+ const AVATAR_PREFIX = '/api/avatar/'
170
+ const LOGOUT_PATH = '/api/auth/logout'
171
+ const ADMIN_USERS_PATH = '/api/admin/users'
172
+ const ADMIN_HISTORY_PATH = '/api/admin/history'
173
+ const ADMIN_SCAN_MODELS_PATH = '/api/admin/scan/models'
174
+ const SET_ROLE_PATH = '/api/admin/set-role'
175
+ const ADMIN_REPOS_PATH = '/api/admin/repositories'
176
+ const BROWSABLE_REPOS_PATH = '/api/admin/repositories/browsable'
177
+ const REPO_REFS_PATH = '/api/admin/repositories/refs'
178
+ const REPO_CONTENTS_PATH = '/api/admin/repositories/contents'
179
+ const SELECT_REPO_PATH = '/api/admin/repositories/select'
180
+ const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
181
+ const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
182
+ const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
183
+ const REMOVE_REPO_PATH = '/api/admin/repositories/remove'
184
+ const REPO_RISK_PATH = '/api/admin/repositories/risk'
185
+ const ADMIN_REPORTS_PATH = '/api/admin/reports'
186
+ const REPORT_SET_REPO_PATH = '/api/admin/reports/set-repo'
187
+ const REPORT_SET_VISIBLE_PATH = '/api/admin/reports/set-visible'
188
+ const REPORT_PREFIX = '/api/admin/reports/'
189
+ const ADMIN_BUNDLES_PATH = '/api/admin/bundles'
190
+ const BUNDLE_CREATE_PATH = '/api/admin/bundles/create'
191
+ const BUNDLE_SET_REPO_PATH = '/api/admin/bundles/set-repo'
192
+ const BUNDLE_SET_VISIBLE_PATH = '/api/admin/bundles/set-visible'
193
+ const BUNDLE_PREFIX = '/api/admin/bundles/'
194
+ const MY_TEAMS_PATH = '/api/teams'
195
+ const MY_REPORT_PREFIX = '/api/reports/'
196
+ const REPORT_QUERY_PATH = '/api/reports/query'
197
+ const MY_REPORT_TRIAGE_SUFFIX = '/triage'
198
+ const MY_REPORT_TRIAGE_HISTORY_SUFFIX = '/triage/history'
199
+ const ADMIN_TEAMS_PATH = '/api/admin/teams'
200
+ const TEAM_DELETE_PATH = '/api/admin/teams/delete'
201
+ const TEAM_RENAME_PATH = '/api/admin/teams/rename'
202
+ const TEAM_SET_HIDDEN_PATH = '/api/admin/teams/set-hidden'
203
+ const TEAM_SET_REPO_PATH = '/api/admin/teams/set-repo'
204
+ const TEAM_REMOVE_REPO_PATH = '/api/admin/teams/remove-repo'
205
+ const TEAM_SET_MEMBER_PATH = '/api/admin/teams/set-member'
206
+ const TEAM_REMOVE_MEMBER_PATH = '/api/admin/teams/remove-member'
207
+ const TEAM_SET_NPM_SCOPES_PATH = '/api/admin/teams/set-npm-scopes'
208
+ const NPM_PACKAGE_PATH = '/api/npm/package'
209
+ const NPM_VERSIONS_PATH = '/api/npm/versions'
210
+ const NPM_DOWNLOAD_PATH = '/api/npm/download'
211
+ const NPM_STATS_PATH = '/api/npm/stats'
212
+ const NPM_ADVISORIES_PATH = '/api/npm/advisories'
213
+ const NPM_TAGS_PATH = '/api/npm/tags'
214
+ const NPM_SOCKET_PATH = '/api/npm/socket'
215
+ const NPM_PROVENANCE_PATH = '/api/npm/provenance'
216
+ const NPM_PRETTY_PATH = '/api/npm/pretty'
217
+ const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
218
+ const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
219
+ const NPM_RISK_PATH = '/api/npm/risk'
220
+ // What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
221
+ // having no such package or version, or a tarball unpacking past risk's
222
+ // bounds; npm failing otherwise a 502.
223
+ const NPM_RISK_STATUS: Partial<Record<string, number>> = { 'package-not-found': 404, 'version-not-found': 404, 'package-too-large': 413 }
224
+ // A risk failure answered with its code alone, as risk's status for it (NPM_RISK_STATUS, else 502), its cause
225
+ // logged where it is the server's or npm's, not the reader's (a 5xx), for the logs to say why.
226
+ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
227
+ const status = NPM_RISK_STATUS[err.code] ?? 502
228
+ if (status >= 500) console.error(`managed: npm risk failed (${err.code}):`, err.message)
229
+ sendJson(res, status, { error: err.code })
230
+ }
231
+ const NPM_PATHS = new Set([NPM_PACKAGE_PATH, NPM_VERSIONS_PATH, NPM_DOWNLOAD_PATH, NPM_STATS_PATH, NPM_ADVISORIES_PATH, NPM_TAGS_PATH, NPM_SOCKET_PATH, NPM_PROVENANCE_PATH, NPM_PRETTY_PATH,
232
+ NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH])
233
+ // How long an npm response may go unread before its connection is dropped.
234
+ const NPM_RESPONSE_IDLE_MS = 60_000
235
+ const MAX_TEAM_NAME = 100
236
+ // Managed data routes, and with them the authentication routes, which an
237
+ // admin's view as another user covers. Other paths fall through to a combined
238
+ // e2e server, whose own authentication a view never touches.
239
+ const MANAGED_DATA_PREFIXES = ['/api/admin', '/api/reports', '/api/bundles', '/api/teams', '/api/avatar', '/api/github', '/api/npm']
240
+ const VIEW_PREFIXES = [...MANAGED_DATA_PREFIXES, '/api/auth', '/api/oauth']
241
+ const underPrefix = (path: string, prefixes: readonly string[]) => prefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))
242
+
243
+ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
244
+ const s = await readWorkspaceSession(res, deps, cookie)
245
+ if (!s) return
246
+ const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
247
+ const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
248
+ const manualUrls = storedFixUrls(await deps.db.listTriage(ids))
249
+ const storedIssues = await deps.db.listManagedIssues(ids)
250
+ const issues = visibleManagedIssues(storedIssues, snapshot)
251
+ const visibleIds = new Set(issues.map(issue => issue.findingId))
252
+ const urls = [...new Set([...manualUrls, ...issues.flatMap(issue => [issue.issueUrl!, ...(issue.autoFixUrl ? [issue.autoFixUrl] : [])])])]
253
+ await recheckTeam(deps.db, s.session.id, snapshot)
254
+ const result = issues.length > 0
255
+ ? await lookupIssueLinks(deps.config, deps.db, snapshot, urls, storedIssues.filter(issue => visibleIds.has(issue.findingId)))
256
+ : { fixes: await lookupFixes(deps.config, deps.db, snapshot, urls), updates: [] }
257
+ // Cold report reads, token refresh and upstream batches can outlive changes
258
+ // to security/links, team grants, publication, sessions or the Fix links.
259
+ const currentUrls = storedFixUrls(await deps.db.listTriage(ids))
260
+ if (await readWorkspaceSession(res, deps, cookie) == null) return
261
+ await recheckTeam(deps.db, s.session.id, snapshot)
262
+ if (JSON.stringify(currentUrls) !== JSON.stringify(manualUrls)) {
263
+ sendJson(res, 404, { error: 'workspace-changed' }); return
264
+ }
265
+ if (result.updates.length > 0 && !await deps.db.applyManagedIssueFixes(s.session.id, snapshot, result.updates)) {
266
+ sendJson(res, 404, { error: 'workspace-changed' }); return
267
+ }
268
+ sendJson(res, 200, { fixes: result.fixes })
269
+ }
270
+
271
+ async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
272
+ cookie: string | undefined, teamId: string, query: URLSearchParams): Promise<void> {
273
+ if (req.method !== 'GET' && req.method !== 'POST') { send405(res, 'GET, POST'); return }
274
+ const s = req.method === 'GET' ? await readWorkspaceSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
275
+ if (!s) return
276
+ if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
277
+ let body: unknown = Object.fromEntries(query)
278
+ if (req.method === 'POST') {
279
+ try { body = await readJsonBody(req, MAX_ISSUE_BODY_BYTES) }
280
+ catch { sendJson(res, 400, { error: 'bad-body' }); return }
281
+ }
282
+ const context = parseIssueContext(body)
283
+ const prepared = await prepareGithubIssue(deps.config, deps.db, deps.reportStore, s.session, teamId, context)
284
+ await prepared.recheck()
285
+ if (req.method === 'POST' && prepared.mode === 'api') {
286
+ sendJson(res, 201, await createGithubIssue(prepared, body)); return
287
+ }
288
+ sendJson(res, 200, { mode: prepared.mode, labels: prepared.labels,
289
+ ...(prepared.mode === 'existing' ? { url: prepared.url } : {}),
290
+ ...(prepared.mode === 'pending' ? { repositoryUrl: prepared.repositoryUrl } : {}),
291
+ ...(prepared.mode === 'authorize' || prepared.mode === 'permissions' ? { authorizationPath: prepared.authorizationPath } : {}) })
292
+ }
293
+
294
+ function activity(deps: ManagedHttpDeps, user: StoredUser, kind: ActivityInput['kind'], action: string, context: Pick<ActivityInput, 'repo' | 'reportId' | 'bundleId' | 'report' | 'repoId' | 'repoDirectory'> = {}): Promise<void> {
295
+ return deps.db.recordActivity({ kind, actor: user.login, actorId: user.id, action, ...context }, Date.now())
296
+ }
297
+
298
+ async function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
299
+ if (repoId == null) return null
300
+ return (await deps.db.listAllRepos()).find(repo => repo.repoId === repoId)?.fullName ?? null
301
+ }
302
+
303
+ // Workspace history is authorized BEFORE filtering, counting, or paging.
304
+ // Managers get content activity for their currently accessible team content;
305
+ // even the displayed report context must come from those accessible reports.
306
+ async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
307
+ const s = await readManageSession(res, deps, cookie)
308
+ if (s == null) return
309
+ const page = Number(query.get('page') ?? 1)
310
+ const limit = Number(query.get('limit') ?? 100)
311
+ const kind = query.get('kind') ?? 'all'
312
+ const search = (query.get('q') ?? '').trim()
313
+ const repo = query.get('repo') ?? ''
314
+ const actor = query.get('actor') ?? ''
315
+ if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100
316
+ || !['all', 'triage', 'upload', 'visibility', 'access', 'repository', 'delete'].includes(kind) || search.length > 500
317
+ || repo.length > 500 || actor.length > 500) {
318
+ sendJson(res, 400, { error: 'bad-request' }); return
319
+ }
320
+ let contexts: ActivityContext[] | null = null
321
+ if (s.user.role !== 'admin') {
322
+ const findings = new Map<string, ActivityContext>()
323
+ const reports = kind === 'all' || kind === 'triage' ? await deps.db.listActivityReports(s.user.id) : []
324
+ for (const report of reports) {
325
+ for (const finding of await visibleFindingIds(deps, s.user, report.reportId, s.session.id, null)) {
326
+ if (!findings.has(finding)) findings.set(finding, { finding, ...report })
327
+ }
328
+ }
329
+ contexts = [...findings.values()]
330
+ }
331
+ sendJson(res, 200, await deps.db.listActivity({ page, limit, kind, query: search, repo, actor, contexts, userId: s.user.id }))
332
+ }
333
+
334
+ export interface ManagedHttpDeps {
335
+ config: ManagedConfig
336
+ db: ManagedDb
337
+ avatarStore: AvatarStore
338
+ reportStore: BlobStore
339
+ bundleStore: BundleStore
340
+ bundleCache?: BundleCache
341
+ reportSourcesCache?: ReportSourcesCache
342
+ prettyCache?: PrettyCache
343
+ // The public npm cache, where risk keeps public packages' assessments (npm-risk.ts).
344
+ npmCacheStorage?: CacheStorage
345
+ uploadStore?: BlobStore
346
+ originGate: OriginGate
347
+ isShuttingDown: () => boolean
348
+ track: (p: Promise<unknown>) => void
349
+ // Combined boot delegates unmatched paths to e2e and overrides discovery.
350
+ serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
351
+ next?: Handler
352
+ serverInfo?: ServerInfo
353
+ }
354
+
355
+ type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
356
+
357
+ function send405(res: ServerResponse, allow: string): void {
358
+ sendJson(res, 405, { error: 'method-not-allowed' }, { allow })
359
+ }
360
+
361
+ function firstHeader(v: string | string[] | undefined): string | null {
362
+ if (typeof v === 'string') return v
363
+ if (Array.isArray(v) && v.length > 0) return v[0] ?? null
364
+ return null
365
+ }
366
+
367
+ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId: string): Promise<void> {
368
+ const avatar = await avatarStore.get(userId)
369
+ if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
370
+ res.writeHead(200, {
371
+ 'content-type': avatar.contentType,
372
+ 'content-length': String(avatar.bytes.length),
373
+ 'x-content-type-options': 'nosniff',
374
+ 'cache-control': 'no-store',
375
+ })
376
+ res.end(avatar.bytes)
377
+ }
378
+
379
+ const MAX_JSON_BODY_BYTES = 4096
380
+
381
+ // Buffer the request body, aborting (and throwing 'too-large') once it exceeds
382
+ // `maxBytes` — bounds memory on the JSON mutations (small) and the report
383
+ // upload (config.maxReportBytes) alike.
384
+ async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
385
+ const chunks: Buffer[] = []
386
+ let size = 0
387
+ for await (const chunk of req) {
388
+ const c = chunk as Buffer
389
+ size += c.length
390
+ if (size > maxBytes) { req.destroy(); throw new Error('too-large') }
391
+ chunks.push(c)
392
+ }
393
+ return Buffer.concat(chunks)
394
+ }
395
+
396
+ // A file upload's content, opening a body the browser sealed to this session
397
+ // (common/managed/upload-seal.ts). Link reports are never sent in parts.
398
+ async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: ManagedSession, kind: UploadKind | 'links'): Promise<Buffer> {
399
+ const maxBytes = kind === 'bundles' ? deps.config.maxBundleBytes : deps.config.maxReportBytes
400
+ const sealed = req.headers[UPLOAD_SEAL_HEADER] !== undefined
401
+ const limit = sealed ? maxSealedBytes(maxBytes) : maxBytes
402
+ let bytes
403
+ if (kind === 'links' || req.headers['x-upload-id'] == null) bytes = await readBodyBytes(req, limit)
404
+ else if (deps.uploadStore) bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit)
405
+ else throw new Error('bad-upload')
406
+ return sealed ? await openSessionUpload(session.uploadKey, bytes, maxBytes) : bytes
407
+ }
408
+
409
+ // GET /api/admin/uploads/key — the session's upload public key, created on
410
+ // first use. Only this session's server-side private key opens what is sealed to it.
411
+ async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
412
+ if (req.method !== 'GET') { send405(res, 'GET'); return }
413
+ const s = await readManageSession(res, deps, cookie)
414
+ if (!s) return
415
+ const key = s.session.uploadKey ?? await deps.db.ensureSessionUploadKey(s.session.id, newUploadKey())
416
+ if (!key) { sendJson(res, 401, { error: 'unauthenticated' }); return }
417
+ sendJson(res, 200, { key: uploadPublicKey(key) })
418
+ }
419
+
420
+ async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
421
+ const cancel = req.method === 'DELETE'
422
+ if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
423
+ const s = await checkMutation(req, res, deps, cookie)
424
+ if (!s || !requireManageRole(res, s.user)) return
425
+ if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
426
+ const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
427
+ if (!match || (cancel ? match[3] !== undefined : match[3] === undefined)) { sendJson(res, 400, { error: 'bad-upload' }); return }
428
+ const id = match[2]!, kind = match[1] as UploadKind
429
+ if (cancel) {
430
+ const count = Number(req.headers['x-upload-parts'])
431
+ if (!validUpload(id, count)) { sendJson(res, 400, { error: 'bad-upload' }); return }
432
+ await deleteUpload(deps.uploadStore, s.session.id, kind, id, count)
433
+ sendJson(res, 200, { ok: true }); return
434
+ }
435
+ const index = Number(match[3])
436
+ const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
437
+ const discard = async () => {
438
+ if (validUpload(id, index + 1)) {
439
+ await deleteUpload(deps.uploadStore!, s.session.id, kind, id, index + 1).catch(err => console.warn('managed: upload cleanup failed:', err))
440
+ }
441
+ }
442
+ if (!validUploadPart(id, index, maxBytes)) { await discard(); sendJson(res, 400, { error: 'bad-upload' }); return }
443
+ let bytes
444
+ try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxSealedBytes(maxBytes))) }
445
+ catch { await discard(); sendJson(res, 413, { error: 'too-large' }); return }
446
+ if (bytes.length === 0) { await discard(); sendJson(res, 400, { error: 'empty' }); return }
447
+ if (await manageMutation(req, res, deps, cookie) == null) return
448
+ await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
449
+ sendJson(res, 200, { ok: true })
450
+ }
451
+
452
+ // `maxBytes` overrides the small default for the one endpoint whose JSON body
453
+ // legitimately grows (triage entry batches).
454
+ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<unknown> {
455
+ const buf = await readBodyBytes(req, maxBytes)
456
+ return JSON.parse(buf.toString('utf8') || 'null')
457
+ }
458
+
459
+ // The router enforces same-origin for every request. A mutation also needs an
460
+ // authenticated session whose CSRF token matches the X-CSRF-Token header.
461
+ // Returns the session, or null after having already sent the 401/403. An
462
+ // admin's view as another user writes nothing; `view` admits only the
463
+ // endpoints that end it.
464
+ async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
465
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
466
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
467
+ const csrf = firstHeader(req.headers['x-csrf-token'])
468
+ if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
469
+ if (!safeEqual(csrf, s.session.csrfToken)) { sendJson(res, 403, { error: 'csrf-mismatch' }); return null }
470
+ if (s.session.viewer && !view) { sendViewOnly(res); return null }
471
+ return s
472
+ }
473
+
474
+ function sendViewOnly(res: ServerResponse): void {
475
+ sendJson(res, 403, { error: VIEW_ONLY_ERROR })
476
+ }
477
+
478
+ // Reads stay available while an admin views as another user. Everything else
479
+ // is refused before dispatch, except ending the view or signing out, and the
480
+ // batch report preview, a POST without effects. Issue authorization would
481
+ // grant GitHub access on the viewed user's behalf.
482
+ function viewAllows(method: string, path: string): boolean {
483
+ if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH
484
+ return (method === 'DELETE' && path === VIEW_AS_PATH) || (method === 'POST' && (path === LOGOUT_PATH || path === REPORT_QUERY_PATH))
485
+ }
486
+
487
+ // POST /api/auth/logout — drop the session (same-origin + CSRF), which also
488
+ // ends a view opened from it.
489
+ async function handleLogout(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
490
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
491
+ const s = await checkMutation(req, res, deps, cookie, { view: true })
492
+ if (s == null) return
493
+ await endSession(deps.config, deps.db, cookie)
494
+ const cleared = clearCookie(deps.config.sessionCookieName, deps.config.cookieSecure)
495
+ res.writeHead(204, { 'set-cookie': s.session.viewer ? [cleared, clearViewCookie(deps.config)] : cleared, 'cache-control': 'no-store' })
496
+ res.end()
497
+ }
498
+
499
+ // POST /api/auth/view-as — an admin opens a read-only view as another user
500
+ // (body { userId }); DELETE ends it. Viewing as oneself is refused.
501
+ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
502
+ const method = req.method ?? 'GET'
503
+ if (method === 'DELETE') {
504
+ if (await checkMutation(req, res, deps, cookie, { view: true }) == null) return
505
+ await endViewSession(deps.config, deps.db, cookie)
506
+ res.writeHead(204, { 'set-cookie': clearViewCookie(deps.config), 'cache-control': 'no-store' })
507
+ res.end(); return
508
+ }
509
+ if (method !== 'POST') { send405(res, 'POST, DELETE'); return }
510
+ const s = await checkMutation(req, res, deps, cookie)
511
+ if (s == null) return
512
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
513
+ let body: unknown
514
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
515
+ const userId = (body as { userId?: unknown } | null)?.userId
516
+ if (typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
517
+ if (userId === s.user.id) { sendJson(res, 400, { error: 'cannot-view-as-self' }); return }
518
+ const target = (await deps.db.listUsers()).find(user => user.id === userId)
519
+ if (!target) { sendJson(res, 404, { error: 'not-found' }); return }
520
+ // The insert itself rechecks the admin session and role.
521
+ const setCookie = await startViewSession(deps.config, deps.db, s.session, userId, Date.now())
522
+ if (setCookie == null) { sendJson(res, 403, { error: 'forbidden' }); return }
523
+ await activity(deps, s.user, 'access', `viewed as ${target.login}`)
524
+ res.writeHead(204, { 'set-cookie': setCookie, 'cache-control': 'no-store' })
525
+ res.end()
526
+ }
527
+
528
+ // POST /api/admin/set-role — an admin sets ANOTHER user's role. Admin-only, and
529
+ // refused for the caller's own id so an admin can't drop their own admin (keeps
530
+ // at least one admin).
531
+ async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
532
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
533
+ const s = await checkMutation(req, res, deps, cookie)
534
+ if (s == null) return
535
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
536
+ let body: unknown
537
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
538
+ const userId = (body as { userId?: unknown } | null)?.userId
539
+ const role = (body as { role?: unknown } | null)?.role
540
+ if (typeof userId !== 'string' || !isRole(role)) { sendJson(res, 400, { error: 'bad-request' }); return }
541
+ if (userId === s.user.id) { sendJson(res, 403, { error: 'cannot-change-own-role' }); return }
542
+ const target = (await deps.db.listUsers()).find(user => user.id === userId)
543
+ // A caller can hold the body open while their admin access is revoked.
544
+ if (await readAdminSession(res, deps, cookie) == null) return
545
+ const ok = await deps.db.setUserRole(userId, role)
546
+ if (!ok) { sendJson(res, 404, { error: 'not-found' }); return }
547
+ if (target?.role !== role) await activity(deps, s.user, 'access', `changed ${target?.login ?? userId}'s role to ${role}`)
548
+ sendJson(res, 200, { ok: true })
549
+ }
550
+
551
+ // admin OR manage — the roles allowed into the management pages (repositories,
552
+ // reports), matching the sidebar account-menu gate. Sends 403 + returns false
553
+ // otherwise.
554
+ function requireManageRole(res: ServerResponse, user: StoredUser): boolean {
555
+ if (user.role === 'admin' || user.role === 'manage') return true
556
+ sendJson(res, 403, { error: 'forbidden' })
557
+ return false
558
+ }
559
+
560
+ // A read endpoint open to admin|manage: resolve the session (401 if absent),
561
+ // then gate on the role (403). Returns the session, or null after the response
562
+ // was already sent. No CSRF — reads aren't mutations (cf. /api/admin/users).
563
+ async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
564
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
565
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
566
+ if (!requireManageRole(res, s.user)) return null
567
+ return s
568
+ }
569
+
570
+ async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
571
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
572
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
573
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
574
+ return s
575
+ }
576
+
577
+ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
578
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
579
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
580
+ if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
581
+ return s
582
+ }
583
+
584
+ // GET /api/admin/scan/models — the server's canonical scan model ids and effort
585
+ // levels. Names are intentionally absent; the client derives them from ids.
586
+ async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
587
+ const s = await readManageSession(res, deps, cookie)
588
+ if (s == null) return
589
+ sendJson(res, 200, { models: MANAGED_SCAN_MODELS, defaultModel: DEFAULT_MANAGED_SCAN_MODEL })
590
+ }
591
+
592
+ // GET /api/admin/repositories — the connected repositories by default. The
593
+ // potentially large GitHub discovery lists are opt-in (`scope=installed` or
594
+ // `scope=public`). Return the complete catalogue for local search and organization
595
+ // filtering. Admin-only. No CSRF.
596
+ async function handleListRepositories(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, discovery: RepositoryDiscovery): Promise<void> {
597
+ if ((req.method ?? 'GET') !== 'GET') { send405(res, 'GET'); return }
598
+ const s = await readAdminSession(res, deps, cookie)
599
+ if (s == null) return
600
+ const url = new URL(req.url ?? '/', 'http://localhost')
601
+ // Keep the original unparameterized response shape for older clients. The
602
+ // admin UI always sends an explicit scope, which opts into the connected-first
603
+ // discovery flow below.
604
+ if ([...url.searchParams.keys()].length === 0) {
605
+ const token = await ensureUserAccessToken(deps.config, deps.db, s.user.id, Date.now())
606
+ const listing = await collectRepos(deps.config, token)
607
+ const selected = new Set((await deps.db.listSelectedRepos()).map((repo) => repo.repoId))
608
+ sendJson(res, 200, {
609
+ installUrl: installUrl(deps.config),
610
+ repositories: listing.repositories.map((repo) => ({
611
+ id: repo.id, fullName: repo.fullName, private: repo.private, visibility: repo.visibility, htmlUrl: repo.htmlUrl,
612
+ installed: repo.installationId != null, selected: selected.has(repo.id),
613
+ })),
614
+ tokenMissing: listing.tokenMissing,
615
+ })
616
+ return
617
+ }
618
+ const scope = url.searchParams.get('scope') ?? 'connected'
619
+ if (scope !== 'connected' && scope !== 'installed' && scope !== 'public') { sendJson(res, 400, { error: 'bad-scope' }); return }
620
+ const selectedRows = await deps.db.listSelectedRepos()
621
+ const allRows = scope === 'connected' ? await deps.db.listAllRepos() : []
622
+ const selected = new Set(selectedRows.map((r) => r.repoId))
623
+ let tokenMissing = false
624
+ let repositories
625
+ if (scope === 'connected') {
626
+ repositories = allRows.map((r) => ({
627
+ id: r.repoId, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl,
628
+ installed: r.installationId != null, selected: r.active, active: r.active,
629
+ }))
630
+ } else {
631
+ const showAll = scope === 'installed' && url.searchParams.get('showAll') === 'true'
632
+ const token = showAll ? null : await ensureUserAccessToken(deps.config, deps.db, s.user.id, Date.now())
633
+ let listing
634
+ try {
635
+ listing = await discovery.list(scope, s.user.id, token, showAll, url.searchParams.get('refresh') === 'true')
636
+ } catch (err) {
637
+ if (!(err instanceof GithubApiError)) throw err
638
+ sendJson(res, 502, { error: err.message })
639
+ return
640
+ }
641
+ tokenMissing = listing.tokenMissing
642
+ repositories = listing.repositories
643
+ .map((r) => ({
644
+ id: r.id, fullName: r.fullName, private: r.private, visibility: r.visibility, htmlUrl: r.htmlUrl,
645
+ installed: r.installationId != null, selected: selected.has(r.id),
646
+ }))
647
+ }
648
+ repositories.sort((a, b) => a.fullName.localeCompare(b.fullName))
649
+ const total = repositories.length
650
+ sendJson(res, 200, {
651
+ installUrl: installUrl(deps.config),
652
+ canAddAnyPublicRepository: canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id)),
653
+ repositories,
654
+ connectedCount: selectedRows.length,
655
+ inactiveCount: allRows.filter((r) => !r.active).length,
656
+ total,
657
+ tokenMissing,
658
+ })
659
+ }
660
+
661
+ // The select half of the toggle: re-list the caller's reachable repos to VERIFY
662
+ // access (never trust a client-supplied id) and capture the server-derived read
663
+ // context (installation id, default branch). A PRIVATE repo with no installation
664
+ // can't be read server-side → 409. Stores via selectRepo (upsert).
665
+ async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number, cookie: string | undefined): Promise<void> {
666
+ if (!canAddRepositories(user)) { sendJson(res, 403, { error: 'forbidden' }); return }
667
+ const userId = user.id
668
+ const token = await ensureUserAccessToken(deps.config, deps.db, userId, Date.now())
669
+ const { repositories } = await collectRepos(deps.config, token)
670
+ let repo = repositories.find((r) => r.id === repoId)
671
+ // A WHITEHAT addition may not appear in /user/repos. Revalidate the stored
672
+ // canonical name when reactivating it, with the same permission gates.
673
+ if (repo == null && canAddAnyPublicRepository(user, await deps.db.getUserGithubId(userId))) {
674
+ const stored = (await deps.db.listAllRepos()).find(r => r.repoId === repoId)
675
+ if (stored && !stored.private && stored.installationId == null) {
676
+ try { repo = await fetchPublicRepository(stored.fullName) } catch (err) {
677
+ if (!(err instanceof GithubApiError)) throw err
678
+ sendJson(res, err.status, { error: err.message }); return
679
+ }
680
+ if (repo.id !== repoId) { sendJson(res, 409, { error: 'repo-identity-changed' }); return }
681
+ }
682
+ }
683
+ if (repo == null) { sendJson(res, 404, { error: 'repo-not-accessible' }); return }
684
+ // (0) Installed or explicitly public. For (3a), an admin may choose any App
685
+ // installation; for (3b), /user/repos is the public involvement evidence.
686
+ if (repo.installationId == null) {
687
+ if (repo.private || repo.visibility !== 'public') { sendJson(res, 409, { error: 'repo-not-readable' }); return }
688
+ if (!passesPublicRepositorySafeguard(await deps.db.getUserGithubId(userId), repositories.some(r => r.id === repoId))) {
689
+ sendJson(res, 403, { error: 'forbidden' }); return
690
+ }
691
+ }
692
+ if (await readAdminSession(res, deps, cookie) == null) return
693
+ await connectRepository(res, deps, user, repo)
694
+ }
695
+
696
+ async function connectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repo: ConnectedRepo): Promise<void> {
697
+ const alreadySelected = (await deps.db.listSelectedRepos()).some(row => row.repoId === repo.id)
698
+ await deps.db.selectRepo({
699
+ repoId: repo.id, fullName: repo.fullName, private: repo.private,
700
+ installationId: repo.installationId, defaultBranch: repo.defaultBranch,
701
+ htmlUrl: repo.htmlUrl, addedBy: user.id,
702
+ }, Date.now())
703
+ if (!alreadySelected) await activity(deps, user, 'repository', 'connected a repository', { repo: repo.fullName })
704
+ sendJson(res, 200, { ok: true, selected: true })
705
+ }
706
+
707
+ // Separate from ordinary discovery: WHITEHAT bypasses only public involvement.
708
+ // Authentication, server admin permission, origin and CSRF remain mandatory.
709
+ async function handleAddPublicRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
710
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
711
+ const s = await adminMutation(req, res, deps, cookie)
712
+ if (s == null) return
713
+ if (!canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id))) { sendJson(res, 403, { error: 'forbidden' }); return }
714
+ let body: unknown
715
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
716
+ const fullName = publicRepositoryName((body as { repository?: unknown } | null)?.repository)
717
+ if (fullName == null) { sendJson(res, 400, { error: 'bad-repository' }); return }
718
+ let repo
719
+ try { repo = await fetchPublicRepository(fullName) } catch (err) {
720
+ if (!(err instanceof GithubApiError)) throw err
721
+ sendJson(res, err.status, { error: err.message }); return
722
+ }
723
+ // A GitHub lookup can outlive a role change or logout.
724
+ if (await readAdminSession(res, deps, cookie) == null) return
725
+ // Only public access was verified. Keep its null installation context rather
726
+ // than restoring a stored App installation that may no longer cover this repo.
727
+ await connectRepository(res, deps, s.user, repo)
728
+ }
729
+
730
+ // Associate an existing connection with this App without changing its active
731
+ // state, ownership, team grants, or stored data. GitHub grants remain separate
732
+ // from the per-user checks required for browsing source.
733
+ async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
734
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
735
+ const s = await adminMutation(req, res, deps, cookie)
736
+ if (s == null) return
737
+ let body: unknown
738
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
739
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
740
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
741
+ const repo = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
742
+ if (repo == null) { sendJson(res, 404, { error: 'repo-not-connected' }); return }
743
+ if (repo.installationId != null) { sendJson(res, 200, { connected: true }); return }
744
+ let installationId
745
+ try { installationId = await repositoryInstallation(deps.config, repo.repoId, repo.fullName) } catch (err) {
746
+ if (!(err instanceof GithubApiError)) throw err
747
+ // These credentials belong to the server App, not the user's session.
748
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }); return
749
+ }
750
+ if (await readAdminSession(res, deps, cookie) == null) return
751
+ if (installationId == null) {
752
+ const current = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
753
+ if (current == null || current.fullName !== repo.fullName || current.addedAt !== repo.addedAt || current.installationId != null) {
754
+ sendJson(res, 409, { error: 'repo-connection-changed' }); return
755
+ }
756
+ const url = installUrl(deps.config)
757
+ if (url == null) { sendJson(res, 503, { error: 'github-app-not-configured' }); return }
758
+ sendJson(res, 200, { connected: false, installUrl: url }); return
759
+ }
760
+ if (!await deps.db.connectRepoInstallation(repo, installationId, s.session.id, Date.now())) {
761
+ sendJson(res, 409, { error: 'repo-connection-changed' }); return
762
+ }
763
+ await activity(deps, s.user, 'repository', 'connected GitHub App access', { repo: repo.fullName })
764
+ sendJson(res, 200, { connected: true })
765
+ }
766
+
767
+ // POST /api/admin/repositories/select — an admin toggles whether a repo is
768
+ // active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
769
+ // selected }. selected:true verifies + records the read context; selected:false
770
+ // deactivates the row while retaining its reports, bundles, and metadata.
771
+ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
772
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
773
+ const s = await adminMutation(req, res, deps, cookie)
774
+ if (s == null) return
775
+ let body: unknown
776
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
777
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
778
+ const selected = (body as { selected?: unknown } | null)?.selected
779
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof selected !== 'boolean') {
780
+ sendJson(res, 400, { error: 'bad-request' }); return
781
+ }
782
+ if (!selected) {
783
+ const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
784
+ if (await readAdminSession(res, deps, cookie) == null) return
785
+ await deps.db.deactivateRepo(repoId)
786
+ if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
787
+ sendJson(res, 200, { ok: true, selected: false }); return
788
+ }
789
+ await selectRepository(res, deps, s.user, repoId, cookie)
790
+ }
791
+
792
+ async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
793
+ const ids = new Set<string>()
794
+ for (const report of reports) {
795
+ const bytes = await deps.reportStore.get(report.id)
796
+ if (bytes == null) throw new Error(`Cannot establish repository triage overlap: report ${report.id} is unavailable`)
797
+ const parsed = await loadManagedFindings(bytes.toString('utf8'), report.filename)
798
+ if (parsed == null) throw new Error(`Cannot establish repository triage overlap: report ${report.id} is unreadable`)
799
+ for (const finding of parsed.findings) {
800
+ const id = (finding as { id?: unknown })?.id
801
+ if (typeof id === 'string' && id !== '') ids.add(id)
802
+ }
803
+ }
804
+ return ids
805
+ }
806
+
807
+ async function repositoryImpact(deps: ManagedHttpDeps, repoId: number) {
808
+ const allReports = await deps.db.listReports()
809
+ const reports = allReports.filter((report) => report.repoId === repoId)
810
+ const bundles = await deps.db.listBundlesForRepo(repoId)
811
+ const triageIds = await repositoryExclusiveTriageIds(deps, reports, allReports.filter((report) => report.repoId !== repoId))
812
+ return {
813
+ reports: reports.map((report) => ({ id: report.id, filename: report.filename, repoDirectory: report.repoDirectory })),
814
+ bundles,
815
+ triageCount: triageIds.length,
816
+ }
817
+ }
818
+
819
+ async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[], annotationsOnly = true): Promise<string[]> {
820
+ const targetIds = await repositoryFindingIds(deps, reports)
821
+ const triage = annotationsOnly ? await deps.db.listTriage([...targetIds]) : []
822
+ const commentIds = annotationsOnly ? await deps.db.listCommentedFindingIds([...targetIds]) : []
823
+ // Removal includes every exclusive finding, including annotations created
824
+ // during the blob scan; impact only needs the currently annotated subset.
825
+ const annotatedIds = annotationsOnly ? new Set([...triage.map(entry => entry.findingId), ...commentIds]) : targetIds
826
+ if (annotatedIds.size === 0) return []
827
+ // Impact can skip unannotated findings; deletion must also cover new writes.
828
+ // TODO(managed): Persist finding IDs per report at upload time and maintain
829
+ // the index on report deletion. Use it for repository impact and triage
830
+ // cleanup so overlap checks do not fetch and parse every other report blob.
831
+ // Deferred for production; the preview still performs the blob scan below.
832
+ const otherIds = await repositoryFindingIds(deps, otherReports)
833
+ return [...annotatedIds].filter((id) => !otherIds.has(id))
834
+ }
835
+
836
+ // GET /api/admin/repositories/impact — show the data a permanent repository
837
+ // removal would destroy. This is intentionally separate from the repository
838
+ // list so the normal page stays cheap even with many reports.
839
+ async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
840
+ const s = await readAdminSession(res, deps, cookie)
841
+ if (s == null) return
842
+ const repoId = Number(new URL(req.url ?? '/', 'http://localhost').searchParams.get('repoId'))
843
+ if (!Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-repo' }); return }
844
+ const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
845
+ if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
846
+ sendJson(res, 200, { repoId, fullName: repo.fullName, ...await repositoryImpact(deps, repoId) })
847
+ }
848
+
849
+ // POST /api/admin/repositories/remove — permanently remove a repository and
850
+ // attached reports/bundles. The exact name + explicit acknowledgement are
851
+ // checked server-side too; the UI's dialog is a usability guard, not the policy.
852
+ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
853
+ const s = await checkMutation(req, res, deps, cookie)
854
+ if (s == null) return
855
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
856
+ let body: unknown
857
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
858
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
859
+ const fullName = (body as { fullName?: unknown } | null)?.fullName
860
+ const acknowledge = (body as { acknowledge?: unknown } | null)?.acknowledge
861
+ const deleteTriage = (body as { deleteTriage?: unknown } | null)?.deleteTriage
862
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof fullName !== 'string' || acknowledge !== true || typeof deleteTriage !== 'boolean') {
863
+ sendJson(res, 400, { error: 'confirmation-required' }); return
864
+ }
865
+ const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
866
+ if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
867
+ if (repo.fullName !== fullName) { sendJson(res, 400, { error: 'repo-name-mismatch' }); return }
868
+ const allReports = deleteTriage ? await deps.db.listReports() : []
869
+ const triageIds = deleteTriage
870
+ ? await repositoryExclusiveTriageIds(deps, allReports.filter(report => report.repoId === repoId), allReports.filter(report => report.repoId !== repoId), false)
871
+ : []
872
+ const { reports, bundles, deletedReports, deletedBundles, deletedTriage } = await deps.db.removeRepository(s.session.id, repo,
873
+ deleteTriage ? { reports: reportReferenceSnapshot(allReports), ids: triageIds } : null)
874
+ // Remove metadata first so a blob-store failure leaves an orphaned blob for
875
+ // later cleanup, rather than a live row pointing at missing report data.
876
+ for (const report of reports) {
877
+ await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
878
+ await deps.reportStore.delete(report.id).catch(() => {})
879
+ }
880
+ for (const bundle of bundles) {
881
+ // Best-effort derivative cleanup follows the committed metadata removal.
882
+ await deps.bundleCache?.delete(bundle.id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
883
+ await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
884
+ await deps.bundleStore.delete(bundle.id).catch(() => {})
885
+ }
886
+ sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
887
+ }
888
+
889
+ // Strip a client-supplied upload filename to a safe display string. The bytes
890
+ // are keyed by a server uuid. This name is used for display, Content-Disposition,
891
+ // and format detection: decode URLs, remove controls/paths, and cap the basename
892
+ // without discarding the extension that the report reader needs.
893
+ // Falls back to `fallback` when nothing usable remains.
894
+ function sanitizeFilename(raw: string | null, fallback: string): string {
895
+ if (raw == null || raw === '') return fallback
896
+ let decoded = raw
897
+ try { decoded = decodeURIComponent(raw) } catch { decoded = raw }
898
+ // Build the cleaned name char-by-char: drop control chars, fold path
899
+ // separators to '_' (avoids a control-character regex).
900
+ let cleaned = ''
901
+ for (const ch of decoded) {
902
+ const code = ch.codePointAt(0) ?? 0
903
+ if (code < 0x20 || code === 0x7f) continue
904
+ cleaned += ch === '/' || ch === '\\' ? '_' : ch
905
+ }
906
+ cleaned = cleaned.trim()
907
+ if (cleaned.length <= 200) return cleaned || fallback
908
+ const dot = cleaned.lastIndexOf('.')
909
+ const suffix = dot > 0 ? cleaned.slice(dot) : ''
910
+ // An extension that alone exceeds the cap cannot fit; ordinary extensions
911
+ // (including case-sensitive display names such as .CSV) remain unchanged.
912
+ const extension = suffix.length < 200 ? suffix : ''
913
+ return cleaned.slice(0, 200 - extension.length) + extension
914
+ }
915
+
916
+ // The selected repos a report / bundle can be linked to, for the upload UI's
917
+ // repo picker. Minimal shape (id + full name).
918
+ function selectableRepos(repos: { repoId: number; fullName: string }[]): { repoId: number; fullName: string }[] {
919
+ return repos.map((r) => ({ repoId: r.repoId, fullName: r.fullName }))
920
+ }
921
+
922
+ // Resolve the optional X-Repo-Id upload header to a selected repo id, or null
923
+ // when absent. Validates the id is currently in the selected-repos set (so the
924
+ // FK can't dangle); on a bad / unknown id sends 400 and returns { ok: false }.
925
+ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps): Promise<{ ok: true; repoId: number | null } | { ok: false }> {
926
+ const raw = firstHeader(req.headers['x-repo-id'])
927
+ if (raw == null || raw === '') return { ok: true, repoId: null }
928
+ const n = Number(raw)
929
+ if (!Number.isSafeInteger(n)) { sendJson(res, 400, { error: 'bad-repo' }); return { ok: false } }
930
+ const selected = await deps.db.listSelectedRepos()
931
+ if (!selected.some((r) => r.repoId === n)) { sendJson(res, 400, { error: 'repo-not-selected' }); return { ok: false } }
932
+ return { ok: true, repoId: n }
933
+ }
934
+
935
+ // POST /api/admin/reports/set-repo — attach / detach a stored report's repo
936
+ // + directory link. Mutation: same-origin + CSRF, admin|manage. Body
937
+ // { reportId, repoId, directory }, where repoId is null (detach) or a
938
+ // currently-selected repo id. Directory is relative to the repository root.
939
+ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
940
+ const s = await manageMutation(req, res, deps, cookie)
941
+ if (s == null) return
942
+ let body: unknown
943
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
944
+ const reportId = (body as { reportId?: unknown } | null)?.reportId
945
+ const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
946
+ const directory = (body as { directory?: unknown } | null)?.directory ?? ''
947
+ if (typeof reportId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
948
+ if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
949
+ const normalized = normalizeTeamPath(directory)
950
+ if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
951
+ const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'repo', repoId, directory: normalized.path ?? '' })
952
+ if (report.repoId !== repoId || report.repoDirectory !== (repoId == null ? '' : normalized.path ?? '')) {
953
+ await activity(deps, user, 'repository', repoId == null ? 'detached a report from its repository' : `assigned a report to repository path ${normalized.path || '/'}`, { reportId, report: report.filename, repo: await repositoryName(deps, repoId ?? report.repoId) })
954
+ }
955
+ sendJson(res, 200, { ok: true, repoId, repoDirectory: normalized.path ?? '' })
956
+ }
957
+
958
+ // POST /api/admin/reports/set-visible — publish or hide a stored report. New
959
+ // reports start hidden so an admin can inspect the parsed metadata first.
960
+ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
961
+ const s = await manageMutation(req, res, deps, cookie)
962
+ if (s == null) return
963
+ let body: unknown
964
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
965
+ const reportId = (body as { reportId?: unknown } | null)?.reportId
966
+ const visible = (body as { visible?: unknown } | null)?.visible
967
+ if (typeof reportId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
968
+ const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'visibility', visible })
969
+ if (report.visible !== visible) await activity(deps, user, 'visibility', visible ? 'published a report' : 'hid a report', { reportId, report: report.filename, repo: await repositoryName(deps, report.repoId) })
970
+ sendJson(res, 200, { ok: true, visible })
971
+ }
972
+
973
+ async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
974
+ const s = await manageMutation(req, res, deps, cookie)
975
+ if (s == null) return
976
+ let body: unknown
977
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
978
+ const bundleId = (body as { bundleId?: unknown } | null)?.bundleId
979
+ const visible = (body as { visible?: unknown } | null)?.visible
980
+ if (typeof bundleId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
981
+ const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'visibility', visible })
982
+ if (bundle.visible !== visible) await activity(deps, user, 'visibility', visible ? 'published a bundle' : 'hid a bundle', { bundleId, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
983
+ sendJson(res, 200, { ok: true, visible })
984
+ }
985
+
986
+ // POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
987
+ // and directory (same shape as reports). Body { bundleId, repoId, directory }.
988
+ async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
989
+ const s = await manageMutation(req, res, deps, cookie)
990
+ if (s == null) return
991
+ let body: unknown
992
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
993
+ const bundleId = (body as { bundleId?: unknown } | null)?.bundleId
994
+ const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
995
+ const normalized = normalizeTeamPath((body as { directory?: unknown } | null)?.directory ?? '')
996
+ if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
997
+ const directory = repoId == null ? '' : normalized.path ?? ''
998
+ if (typeof bundleId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
999
+ if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
1000
+ const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'repo', repoId, directory })
1001
+ if (bundle.repoId !== repoId || bundle.repoDirectory !== directory) await activity(deps, user, 'repository', repoId == null ? 'detached a bundle from its repository' : `assigned a bundle to repository path ${directory || '/'}`, { bundleId, report: bundle.filename, repo: await repositoryName(deps, repoId ?? bundle.repoId) })
1002
+ sendJson(res, 200, { ok: true, repoId, repoDirectory: directory })
1003
+ }
1004
+
1005
+ // GET /api/admin/reports — the uploaded reports for the "Manage reports" page.
1006
+ // Visible to admin|manage. Read-only, so no CSRF (like /api/admin/users).
1007
+ // `maxBytes` lets the page show / pre-check the upload size cap; `repos` feeds
1008
+ // the upload repo picker.
1009
+ async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
1010
+ sendJson(res, 200, {
1011
+ ...await deps.db.getReportCatalog(session.id, Date.now()),
1012
+ maxBytes: deps.config.maxReportBytes,
1013
+ })
1014
+ }
1015
+
1016
+ // Resolve a report's bundle link from its embedded `bundleHashes`: the first
1017
+ // declared integrity that has a stored bundle wins (bundleId set). When none is
1018
+ // stored yet, keep the first declared integrity so a later bundle upload of it
1019
+ // re-links (see linkReportsToBundle). Returns the (bundleId, integrity) to store.
1020
+ async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, bytes: Buffer): Promise<{ bundleId: string | null; integrity: string | null }> {
1021
+ const hashes = reportBundleHashes(bytes)
1022
+ for (const h of hashes) {
1023
+ const b = await deps.db.getBundleByIntegrity(h)
1024
+ if (b != null && await canAccessBundle(deps, user, b.id)) return { bundleId: b.id, integrity: h }
1025
+ }
1026
+ return { bundleId: null, integrity: hashes[0] ?? null }
1027
+ }
1028
+
1029
+ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, report: ReportRecord, deduped: boolean): Promise<void> {
1030
+ const current = await checkMutation(req, res, deps, cookie)
1031
+ if (!current || !requireManageRole(res, current.user)) return
1032
+ // Uploading known bytes cannot reveal another team's report or its source
1033
+ // bundle. The stored location/ownership wins over the upload headers.
1034
+ if (!(await canViewReport(deps, current.user, report.id))) { sendJson(res, 409, { error: 'report-conflict' }); return }
1035
+ const bundleId = report.bundleId != null && await canAccessBundle(deps, current.user, report.bundleId) ? report.bundleId : null
1036
+ const { id, slug, filename, byteSize, sha256, repoId, repoDirectory, repoEmbedded, analyzer, visible } = report
1037
+ sendJson(res, deduped ? 200 : 201, { id, slug, filename, byteSize, sha256, repoId, repoDirectory, repoEmbedded, analyzer, visible, bundleId, ...(deduped ? { deduped: true } : {}) })
1038
+ }
1039
+
1040
+ // POST /api/admin/reports — upload a report. Mutation: same-origin + CSRF,
1041
+ // admin|manage. The body is the raw report bytes (any findings format — JSON /
1042
+ // markdown / CSV — archived as-is, like the e2e objstore; the server parses them
1043
+ // downstream). Display name rides X-Report-Filename; the repository and
1044
+ // directory come from the report header, or from optional repository/directory
1045
+ // headers when the report has no repository metadata, else from the repository
1046
+ // its findings name when that resolves within the uploader's access. The bundle link is
1047
+ // auto-resolved from the report's bundleHashes. New reports start hidden until
1048
+ // published. Identical content reuses its stored identity and metadata. Bytes
1049
+ // are written first (keyed by a fresh uuid) then the metadata row. A definite
1050
+ // insert failure or concurrent duplicate drops the orphan blob; an uncertain
1051
+ // commit retains it. 413 over the cap, 400 on empty.
1052
+ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1053
+ let s = await checkMutation(req, res, deps, cookie)
1054
+ if (s == null) return
1055
+ if (!requireManageRole(res, s.user)) return
1056
+ let bytes: Buffer
1057
+ try {
1058
+ bytes = await readUploadBody(req, deps, s.session, 'reports')
1059
+ } catch (err) {
1060
+ const tooLarge = err instanceof Error && err.message === 'too-large'
1061
+ sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
1062
+ return
1063
+ }
1064
+ // Shared storage reads may outlast a session or role change.
1065
+ s = await checkMutation(req, res, deps, cookie)
1066
+ if (!s || !requireManageRole(res, s.user)) return
1067
+ if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
1068
+ const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.json')
1069
+ const parsed = readManagedReport(bytes.toString('utf8'), filename)
1070
+ if (parsed.data == null) {
1071
+ sendJson(res, 400, { error: 'invalid-report', reason: parsed.reason ?? 'Unrecognized report format' })
1072
+ return
1073
+ }
1074
+ if (parsed.format === 'links' || parsed.data.source === 'links') {
1075
+ const result = await deps.db.importLinkReport(s.session.id, filename, bytes.toString('utf8'))
1076
+ sendJson(res, result.reused ? 200 : 201, { ...result.report, kind: 'links', deduped: result.reused })
1077
+ return
1078
+ }
1079
+ const repoGithub = reportRepoGithub(parsed.data)
1080
+ // The browser splits these documents before upload. Enforce that boundary
1081
+ // for direct API clients too: one stored record has only one repo's ACL.
1082
+ if (parsed.format === 'markdown-generic' && repoGithub == null) {
1083
+ sendJson(res, 400, { error: 'invalid-report', reason: 'Multi-product Markdown must be split into one report per product before upload.' })
1084
+ return
1085
+ }
1086
+ const repoEmbedded = repoGithub != null
1087
+ const rawHeaderDirectory = firstHeader(req.headers['x-repo-directory']) ?? ''
1088
+ let headerDirectory = rawHeaderDirectory
1089
+ try { headerDirectory = decodeURIComponent(rawHeaderDirectory) } catch { sendJson(res, 400, { error: 'bad-directory' }); return }
1090
+ // Validate the raw header, not repoDirectory()'s display/link normalization,
1091
+ // which trims characters and turns invalid paths into the repository root.
1092
+ const requestedDirectory = repoEmbedded ? parsed.data?.repo?.directory : headerDirectory
1093
+ const normalizedDirectory = normalizeTeamPath(requestedDirectory)
1094
+ if (!normalizedDirectory.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
1095
+ let directory = normalizedDirectory.path ?? ''
1096
+ const analyzer = typeof parsed.data.source === 'string' ? parsed.data.source : null
1097
+ const sha256 = createHash('sha256').update(bytes).digest('base64url')
1098
+ // Reuploads retain their stored location, including after an alias changes.
1099
+ const existing = await deps.db.getReportByHash(sha256, analyzer)
1100
+ let repoId: number | null = null
1101
+ if (repoGithub != null) {
1102
+ const location = existing ? { repoId: existing.repoId, directory: existing.repoDirectory }
1103
+ : await deps.db.getRepositoryImportLocation(repoGithub, directory)
1104
+ repoId = location.repoId; directory = location.directory
1105
+ if (!existing && repoId == null) { sendJson(res, 400, { error: 'repo-not-connected', repo: repoGithub }); return }
1106
+ }
1107
+ // When the report has no repository header, X-Repo-Id lets the managed uploader
1108
+ // assign it at upload time. Clients may omit it and attach the report later.
1109
+ if (repoGithub == null) {
1110
+ const legacyRepo = await resolveUploadRepoId(req, res, deps)
1111
+ if (!legacyRepo.ok) return
1112
+ repoId = legacyRepo.repoId
1113
+ // Otherwise the repository its findings name assigns a new report, like
1114
+ // embedded metadata, when it resolves to a connected destination within
1115
+ // the uploader's access. X-Repo-Directory still overrides the directory.
1116
+ if (repoId == null && !existing) {
1117
+ const suggestion = await suggestReportLocation(deps, s.user, parsed.data)
1118
+ if (suggestion?.repoId != null) {
1119
+ const inferred = req.headers['x-repo-directory'] == null ? suggestion.directory ?? '' : directory
1120
+ if (s.user.role === 'admin' || await deps.db.userCanReadRepoPath(s.user.id, suggestion.repoId, inferred)) {
1121
+ repoId = suggestion.repoId
1122
+ directory = inferred
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+ if (repoId != null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId, directory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
1128
+ if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
1129
+ const id = randomUUID()
1130
+ const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
1131
+ const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
1132
+ const dataKey = await deps.reportStore.put(id, bytes)
1133
+ let report: ReportRecord
1134
+ try {
1135
+ report = await deps.db.insertOrReuseReport({
1136
+ id, filename, contentType, byteSize: bytes.length, sha256, dataKey,
1137
+ uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId,
1138
+ repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
1139
+ }, Date.now(), s.session.id)
1140
+ } catch (err) {
1141
+ // A successful COMMIT can lose its acknowledgement. Never delete a file
1142
+ // until a writer-locked read confirms that this candidate did not commit.
1143
+ let committed: ReportRecord | null
1144
+ try { committed = await deps.db.resolveReportUpload(id) }
1145
+ catch (lookup) { throw new AggregateError([err, lookup], 'Report upload reconciliation failed', { cause: lookup }) }
1146
+ if (committed) report = committed
1147
+ else { await deps.reportStore.delete(id).catch(() => {}); throw err }
1148
+ }
1149
+ if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
1150
+ await sendUploadedReport(req, res, deps, cookie, report, report.id !== id)
1151
+ }
1152
+
1153
+ // GET /api/admin/reports/<id> — download a stored report (admin|manage). Serves
1154
+ // the bytes with the recorded content-type + filename. The recorded content-type
1155
+ // is uploader-supplied, so the response is forced to download
1156
+ // (Content-Disposition: attachment) AND marked `nosniff` — it can't be sniffed
1157
+ // or rendered inline against the app origin. 404 when there's no such report;
1158
+ // 503 when the row exists but the bytes don't (store desync).
1159
+ async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1160
+ const s = await readManageSession(res, deps, cookie)
1161
+ if (s == null) return
1162
+ if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1163
+ const rec = await deps.db.getReport(id)
1164
+ if (rec == null) { sendJson(res, 404, { error: 'no-report' }); return }
1165
+ const bytes = await deps.reportStore.get(id)
1166
+ if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
1167
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
1168
+ if (!current || !roleAtLeast(current.user.role, 'manage') || !(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1169
+ res.writeHead(200, {
1170
+ 'content-type': rec.contentType,
1171
+ 'content-length': String(bytes.length),
1172
+ 'content-disposition': attachmentDisposition(rec.filename),
1173
+ 'x-content-type-options': 'nosniff',
1174
+ 'cache-control': 'no-store',
1175
+ })
1176
+ writeResponse(res, bytes)
1177
+ }
1178
+
1179
+ // DELETE /api/admin/reports/<id> — remove a report (admin|manage). Mutation:
1180
+ // same-origin + CSRF. Drops the row, then best-effort the bytes; 404 when there
1181
+ // was no such report.
1182
+ async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1183
+ const s = await checkMutation(req, res, deps, cookie)
1184
+ if (s == null) return
1185
+ if (!requireManageRole(res, s.user)) return
1186
+ const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
1187
+ await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
1188
+ await deps.reportStore.delete(id).catch((err) => { console.warn('managed: report bytes delete failed:', err) })
1189
+ await activity(deps, user, 'delete', 'deleted a report', { repoId: report.repoId, repoDirectory: report.repoDirectory, reportId: id, report: report.filename, repo: await repositoryName(deps, report.repoId) })
1190
+ sendJson(res, 200, { ok: true })
1191
+ }
1192
+
1193
+ // Apply the same access rule to inventory, cached derivatives, raw downloads
1194
+ // and mutations. Ownership is a manager exception; lower roles require teams.
1195
+ async function canAccessBundle(deps: ManagedHttpDeps, user: StoredUser, id: string): Promise<boolean> {
1196
+ if (!roleAtLeast(user.role, 'view')) return false
1197
+ const rec = await deps.db.getBundle(id)
1198
+ if (!rec) return false
1199
+ if (user.role === 'admin') return true
1200
+ if (user.role === 'manage') return deps.db.userCanReadBundle(user.id, id)
1201
+ return rec.visible && rec.repoId !== null && deps.db.userCanReadRepoPath(user.id, rec.repoId, rec.repoDirectory)
1202
+ }
1203
+
1204
+ async function bundleRepos(deps: ManagedHttpDeps, user: StoredUser) {
1205
+ const repos = await deps.db.listSelectedRepos()
1206
+ const access = await contentAccess(deps.db, user)
1207
+ return repos.filter(access.bundle)
1208
+ }
1209
+
1210
+ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
1211
+ const [githubUserId, token] = await Promise.all([
1212
+ deps.db.getUserGithubId(userId), ensureUserAccessToken(deps.config, deps.db, userId, Date.now()),
1213
+ ])
1214
+ return { githubUserId, token }
1215
+ }
1216
+
1217
+ // After a catalog response: read the commit details it lacked with this
1218
+ // user's repository access, for the next catalog to send.
1219
+ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, missing: readonly CatalogCommit[]): Promise<void> {
1220
+ let browser: ReturnType<typeof createRepositoryBrowser> | undefined
1221
+ await backfillBundleCommits(deps.db, userId, missing, async repoId => {
1222
+ const repo = (await deps.db.listAllRepos()).find(item => item.repoId === repoId)
1223
+ if (!repo) return null
1224
+ browser ??= createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, userId))
1225
+ return browser.reader(repo)
1226
+ }).catch(err => { console.warn('managed: commit backfill failed:', err) })
1227
+ }
1228
+
1229
+ // Listing connected repository names uses managed access only. Verify GitHub
1230
+ // access after selection, before returning any refs or source directory data.
1231
+ async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1232
+ const s = await readManageSession(res, deps, cookie)
1233
+ if (!s) return
1234
+ sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
1235
+ }
1236
+
1237
+ type RepositoryReader = Awaited<ReturnType<ReturnType<typeof createRepositoryBrowser>['reader']>>
1238
+ type RepositoryContents = Awaited<ReturnType<typeof readRepositoryContents>>
1239
+ type RepositoryRefs = Awaited<ReturnType<RepositoryReader['refs']>> & { defaultContents?: RepositoryContents | null }
1240
+
1241
+ async function readRepositoryContents(reader: RepositoryReader, ref: string, path: string, virtualEntries: RepositoryEntry[] | null) {
1242
+ const commit = await reader.commit(ref)
1243
+ const contents = virtualEntries ? { entries: virtualEntries, limited: false } : await reader.directory(path, commit)
1244
+ return { ...contents, path, commit }
1245
+ }
1246
+
1247
+ async function readRepositoryRefs(db: ManagedDb, reader: RepositoryReader, repo: SelectedRepo, withDefault: boolean, virtualEntries: RepositoryEntry[] | null): Promise<RepositoryRefs> {
1248
+ // Wait for both, including failures: a deleted cached branch must not prevent
1249
+ // a successful live default lookup from recovering the initial directory.
1250
+ const [refs, contents] = await Promise.allSettled([
1251
+ reader.refs(),
1252
+ withDefault && repo.cachedDefaultBranch ? readRepositoryContents(reader, `heads/${repo.cachedDefaultBranch}`, '', virtualEntries) : Promise.resolve(null),
1253
+ ])
1254
+ if (refs.status === 'rejected') throw refs.reason
1255
+ const defaultBranch = refs.value.defaultBranch || null
1256
+ const changed = defaultBranch !== repo.cachedDefaultBranch
1257
+ if (changed) await db.cacheRepoDefaultBranch(repo, defaultBranch)
1258
+ if (!withDefault) return refs.value
1259
+ if (!defaultBranch) return { ...refs.value, defaultContents: null }
1260
+ if (changed) return { ...refs.value, defaultContents: await readRepositoryContents(reader, `heads/${defaultBranch}`, '', virtualEntries) }
1261
+ if (contents.status === 'rejected') throw contents.reason
1262
+ return { ...refs.value, defaultContents: contents.value }
1263
+ }
1264
+
1265
+ function scopeRepositoryContents(contents: RepositoryContents | null, virtualEntries: RepositoryEntry[] | null) {
1266
+ return contents && { ...contents, ...(virtualEntries ? {
1267
+ entries: virtualEntries, limited: false, packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false,
1268
+ } : {}) }
1269
+ }
1270
+
1271
+ // Recheck repository/path grants after upstream reads, before returning names.
1272
+ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
1273
+ const repoId = Number(query.get('repoId'))
1274
+ const normalized = normalizeTeamPath(query.get('path') ?? '')
1275
+ const ref = query.get('ref') ?? ''
1276
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
1277
+ sendJson(res, 400, { error: 'bad-request' }); return
1278
+ }
1279
+ const withDefault = refs && query.get('withDefault') === 'true'
1280
+ const path = refs ? '' : normalized.path ?? ''
1281
+ const authorize = async () => {
1282
+ const s = await readManageSession(res, deps, cookie)
1283
+ if (!s) return null
1284
+ const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
1285
+ if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1286
+ const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1287
+ const virtualEntries = scopedDirectory(path, scopes)
1288
+ if ((!refs || withDefault) && virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1289
+ return { repo, virtualEntries, user: s.user }
1290
+ }
1291
+ const access = await authorize()
1292
+ if (!access) return
1293
+ try {
1294
+ const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
1295
+ const reader = await browser.reader(access.repo)
1296
+ // Microseconds, so overlapping browses practically never share a listing time.
1297
+ const observedUs = Math.round((performance.timeOrigin + performance.now()) * 1000)
1298
+ const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
1299
+ const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
1300
+ const { tagCommits, tagsComplete, ...listed } = revisions ?? { tagCommits: [], tagsComplete: false }
1301
+ // Create a bundle refreshes the tags bundle catalogs show, and keeps a tag
1302
+ // its revision input resolves even when the listed page leaves it out.
1303
+ // Like other GitHub caches, written before the access fence below.
1304
+ const tags = refs ? tagCommits : ref.length > 5 && ref.startsWith('tags/') && contents ? [{ name: ref.slice(5), sha: contents.commit }] : []
1305
+ if (refs || tags.length > 0) {
1306
+ await deps.db.refreshGithubTags(access.repo.repoId, tags, refs && tagsComplete, observedUs).catch(err => { console.warn('managed: tag cache refresh failed:', err) })
1307
+ }
1308
+ await reader.recheckAccess()
1309
+ const current = await authorize()
1310
+ if (!current) return
1311
+ // This request (or another reader) may have refreshed the shared hint. It
1312
+ // does not change repository identity, credentials, or managed grants.
1313
+ if (JSON.stringify({ ...current.repo, cachedDefaultBranch: access.repo.cachedDefaultBranch }) !== JSON.stringify(access.repo)) {
1314
+ sendJson(res, 409, { error: 'repository-changed' }); return
1315
+ }
1316
+ sendJson(res, 200, refs ? { ...listed, ...(withDefault ? {
1317
+ defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
1318
+ } : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
1319
+ } catch (err) {
1320
+ if (err instanceof GithubApiError) {
1321
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
1322
+ return
1323
+ }
1324
+ throw err
1325
+ }
1326
+ }
1327
+
1328
+ function prebuildBundle(deps: ManagedHttpDeps, id: string) {
1329
+ if (!deps.bundleCache || deps.config.serverless) return
1330
+ deps.track((async () => {
1331
+ const record = await deps.db.getBundle(id)
1332
+ if (record?.kind) await deps.bundleCache!.prebuild(record)
1333
+ })().catch(err => console.warn('managed: bundle cache build failed:', err)))
1334
+ }
1335
+
1336
+ // Brotli-encoded bytes as they are: the browser's HTTP stack decompresses
1337
+ // them without a Brotli JS dependency.
1338
+ async function sendEncoded(req: IncomingMessage, res: ServerResponse, blob: OpenedBlob, contentType: string, headers: Record<string, string> = {}) {
1339
+ res.writeHead(200, {
1340
+ ...headers, 'content-type': contentType, 'content-encoding': 'br',
1341
+ ...(blob.size == null ? {} : { 'content-length': String(blob.size) }), 'cache-control': 'private, no-store',
1342
+ 'x-content-type-options': 'nosniff',
1343
+ })
1344
+ if (req.method === 'HEAD') { blob.stream.destroy(); res.end(); return }
1345
+ try { await pipeline(blob.stream, res) } catch { res.destroy() }
1346
+ }
1347
+
1348
+
1349
+ // How often a scan of repositories' risk checks again that its reader is
1350
+ // still an admin, between the lines it sends; and how long its connection may
1351
+ // go idle, unread or with no line to send, before the scan is stopped.
1352
+ const REPO_RISK_RECHECK_MS = 10_000
1353
+ const REPO_RISK_IDLE_MS = 5 * 60_000
1354
+
1355
+ // The repositories a scan of risk reads, by its query: every connected one;
1356
+ // a team's (`team`, its id), each in the team's directories of it; or one
1357
+ // (`repo`, its id). Null for a team or repository there is none of, or a
1358
+ // query naming both.
1359
+ async function riskRepositories(db: ManagedDb, query: URLSearchParams): Promise<(SelectedRepo & { paths?: string[] })[] | null> {
1360
+ const connected = await db.listSelectedRepos()
1361
+ const repoId = query.get('repo'), teamId = query.get('team')
1362
+ if (teamId !== null && repoId !== null) return null
1363
+ if (repoId !== null) {
1364
+ const repo = connected.find(item => String(item.repoId) === repoId)
1365
+ return repo ? [repo] : null
1366
+ }
1367
+ if (teamId === null) return connected
1368
+ const team = (await db.listTeams()).find(item => item.id === teamId)
1369
+ if (!team) return null
1370
+ // By repository, its directories; null for the whole of it.
1371
+ const paths = new Map<number, string[] | null>()
1372
+ for (const { repoId: id, path } of team.repos) {
1373
+ const kept = paths.get(id)
1374
+ paths.set(id, path === null || kept === null ? null : [...kept ?? [], path])
1375
+ }
1376
+ return connected.flatMap(repo => {
1377
+ const of = paths.get(repo.repoId)
1378
+ return of === undefined ? [] : [of === null ? repo : { ...repo, paths: of }]
1379
+ })
1380
+ }
1381
+
1382
+ // GET /api/admin/repositories/risk: every connected repository's npm
1383
+ // packages, or a team's or one repository's (riskRepositories), as its
1384
+ // lockfiles lock them at its default branch's head, each assessed by risk
1385
+ // (repository-risk.ts), a line as each is found. A
1386
+ // repository is read with the admin's own GitHub access, as browsing it is
1387
+ // (repository-browser.ts), and that access checked again once it is read,
1388
+ // before any of it is told; packages as the npm viewer's admins read them, in
1389
+ // spare assessments (npm-risk.ts), trusted however new where a team lists
1390
+ // their scope, and each lockfile's asked of npm's advisories at once. The
1391
+ // reader leaving, losing their admin role or the server shutting down stops
1392
+ // it.
1393
+ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1394
+ const s = await readAdminSession(res, deps, cookie)
1395
+ if (!s) return
1396
+ const repos = await riskRepositories(deps.db, query)
1397
+ if (!repos) {
1398
+ const both = query.has('team') && query.has('repo')
1399
+ sendJson(res, both ? 400 : 404, { error: both ? 'bad-scope' : 'no-repository' })
1400
+ return
1401
+ }
1402
+ const controller = new AbortController()
1403
+ res.on('close', () => { controller.abort() })
1404
+ res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
1405
+ const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
1406
+ const scopes = new Map<string, Promise<boolean>>()
1407
+ const versions = npmVersionStore(deps.db, deps.config.debug)
1408
+ const lines = repositoryRiskLines({
1409
+ repos: () => Promise.resolve(repos),
1410
+ async open(repo) {
1411
+ const reader = await browser.reader(repo)
1412
+ const commit = await reader.commit('')
1413
+ const client = createClient({ token: reader.readToken() })
1414
+ return {
1415
+ commit,
1416
+ lockfiles: async () => (await upstreamGithub(() => client.findRepoLockfiles({ repo: repo.fullName, sha: commit }))).lockfiles,
1417
+ file: path => upstreamGithub(() => client.getRepoFile({ repo: repo.fullName, path, ref: commit })),
1418
+ recheck: async () => { await reader.recheckAccess() },
1419
+ }
1420
+ },
1421
+ teamScoped(name) {
1422
+ const scope = npmPackageScope(name)
1423
+ if (scope === null) return Promise.resolve(false)
1424
+ if (!scopes.has(scope)) scopes.set(scope, deps.db.hasTeamNpmScope(scope))
1425
+ return scopes.get(scope)!
1426
+ },
1427
+ // Public versions' documents kept in the database (npm-kept.ts), as the
1428
+ // viewer keeps them: whether npm still has one, its whole document tells
1429
+ // risk.
1430
+ version: async (name, version, signal) => found(await readNpmVersion(name, version, true, signal, versions)),
1431
+ assess: (doc, teamScoped, signal) => npmRisk(doc, 'pick', { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
1432
+ teamScoped, privileged: true, signal, spare: true }),
1433
+ removed: (name, version, teamScoped, signal) => npmRiskRemoved(name, version, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
1434
+ teamScoped, privileged: true, signal }),
1435
+ risks: riskLineStore(deps.db, RISK_KEPT_MS, deps.config.debug),
1436
+ // As a bundle's advisories are asked (bundle-advisories.ts), npm's registry
1437
+ // and, for packages anyone may read, OSV: no repository's. Each lockfile's
1438
+ // audit is bounded on its own, for one npm doesn't answer to fail alone and
1439
+ // the scan to carry on.
1440
+ async advisories(packages, scanning) {
1441
+ const signal = AbortSignal.any([scanning, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)])
1442
+ const audited = await fetchBundleAdvisories(packages.map(pkg => ({ ecosystem: 'npm' as const, ...pkg })), signal,
1443
+ { debug: deps.config.debug, cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug) })
1444
+ if (audited.status !== 200) throw new NpmPackageError(502, audited.body.error)
1445
+ return audited.body
1446
+ },
1447
+ }, controller.signal)
1448
+ let checked = Date.now()
1449
+ await sendLines(res, lines, async () => {
1450
+ if (deps.isShuttingDown()) return false
1451
+ if (Date.now() - checked < REPO_RISK_RECHECK_MS) return true
1452
+ if ((await readSession(deps.config, deps.db, cookie, Date.now()))?.user.role !== 'admin') return false
1453
+ checked = Date.now()
1454
+ return true
1455
+ })
1456
+ }
1457
+
1458
+ // A pretty-printed copy names the file it was formatted from, by the hash it
1459
+ // was asked for and its source was found to have, for the viewer to check.
1460
+ const prettySource = ({ hash }: PrettyRequest): Record<string, string> => ({ 'x-source-hash': hash })
1461
+
1462
+ // GET /api/bundles/:id/{metadata,contents,pretty}: what `open` reads of the
1463
+ // bundle, already encoded; it is absent where its cache is.
1464
+ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string,
1465
+ open: ((rec: ManagedBundle) => Promise<OpenedBlob>) | undefined, contentType = 'application/json', headers: Record<string, string> = {}) {
1466
+ const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1467
+ if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1468
+ const rec = access.bundle
1469
+ if (!rec) { sendJson(res, 404, { error: 'no-bundle' }); return }
1470
+ if (!open) { sendJson(res, 503, { error: 'unavailable' }); return }
1471
+ let cached
1472
+ try { cached = await open(rec) }
1473
+ catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
1474
+ // A cold build can outlast a session or membership change.
1475
+ const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1476
+ if (!current?.bundle) {
1477
+ cached.stream.destroy()
1478
+ sendJson(res, current ? 404 : 401, { error: current ? 'no-bundle' : 'unauthenticated' })
1479
+ return
1480
+ }
1481
+ await sendEncoded(req, res, cached, contentType, headers)
1482
+ }
1483
+
1484
+ // GET /api/bundles/:id/rename-diff: one of its JavaScript files diffed with
1485
+ // one of the `other` bundle's, both pretty-printed, renamed names hidden
1486
+ // (rename-diff.ts), for a reader of both, checked again once it is worked out.
1487
+ async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
1488
+ const otherId = params.get('other') ?? '', request = renameDiffRequest(params)
1489
+ if (!request || !/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-file' }); return }
1490
+ const readable = async () => {
1491
+ const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
1492
+ if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
1493
+ if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
1494
+ return [base.bundle, other.bundle] as const
1495
+ }
1496
+ const records = await readable()
1497
+ if (!records) return
1498
+ const { prettyCache } = deps
1499
+ if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1500
+ let diff
1501
+ try {
1502
+ diff = await renameDiffOf(request, async () => [await prettyText(await prettyCache.bundle(records[0], request.base)),
1503
+ await prettyText(await prettyCache.bundle(records[1], request.other))])
1504
+ } catch (err) {
1505
+ sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return
1506
+ }
1507
+ if (await readable()) await sendEncoded(req, res, diff, 'application/json')
1508
+ else diff.stream.destroy()
1509
+ }
1510
+
1511
+ // GET /api/bundles/:id/pretty?other=: the files that changed between it and
1512
+ // the `other` bundle, both sides' pretty-printed (pretty-print.ts compared), a
1513
+ // line each as it is found kept or formatted; GET
1514
+ // /api/bundles/:id/rename-diff?other=, the pairs of them its viewer diffs
1515
+ // with renamed names hidden (rename-diff.ts renamedPairs), a line each as it
1516
+ // is worked out; GET /api/bundles/:id/type-only?other=, those that changed in
1517
+ // types alone (type-only.ts). For a reader of both, checked again before each.
1518
+ async function handleBundleChanges(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, otherId: string,
1519
+ linesOf: (cache: PrettyCache, base: ManagedBundle, other: ManagedBundle) => Promise<AsyncIterable<unknown>>) {
1520
+ if (!/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-bundle' }); return }
1521
+ const access = () => Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
1522
+ const [base, other] = await access()
1523
+ if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1524
+ if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return }
1525
+ if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1526
+ let lines
1527
+ try { lines = await linesOf(deps.prettyCache, base.bundle, other.bundle) }
1528
+ catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
1529
+ await sendLines(res, lines, async () => (await access()).every(snapshot => snapshot?.bundle))
1530
+ }
1531
+
1532
+ // Published dependency advisories require security access, independently of access to
1533
+ // unpublished dependency findings. Managers retain their normal bundle access.
1534
+ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, teamId: string | null, reason: string, repoAdvisories: boolean, details: boolean) {
1535
+ const authorize = async () => {
1536
+ const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id, teamId)
1537
+ if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
1538
+ if (!access.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
1539
+ if (!access.canReadAdvisories) {
1540
+ sendJson(res, 403, { error: 'security-access-required' }); return null
1541
+ }
1542
+ return access.bundle
1543
+ }
1544
+ const record = await authorize()
1545
+ if (!record) return
1546
+ if (record.kind !== 'stasis') { sendJson(res, 422, { error: 'unsupported-bundle' }); return }
1547
+ if (!deps.bundleCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1548
+ let inventory: BundleAdvisoryInventory | null | undefined
1549
+ try { inventory = await deps.bundleCache.advisoryInventory(record, reason) }
1550
+ catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
1551
+ if (!(await authorize())) return
1552
+ if (inventory === null) { sendJson(res, 413, { error: 'payload-too-large' }); return }
1553
+ if (inventory === undefined) { sendJson(res, 400, { error: 'unknown-reason' }); return }
1554
+ if (Buffer.byteLength(JSON.stringify(inventory)) > MAX_PACKAGE_INVENTORY_BYTES) { sendJson(res, 413, { error: 'payload-too-large' }); return }
1555
+ const controller = new AbortController()
1556
+ const onClose = () => { if (!res.writableEnded) controller.abort() }
1557
+ res.on('close', onClose)
1558
+ // Its deadline aborts as AbortSignal.timeout does, for a failure it leaves to be told from the reader's leaving.
1559
+ const timer = setTimeout(() => controller.abort(new DOMException('advisories took too long', 'TimeoutError')), ADVISORIES_TIMEOUT_MS)
1560
+ try {
1561
+ if (res.destroyed) return
1562
+ const needsGithub = inventory.packages.some(pkg => repoAdvisories || pkg.ecosystem === 'github' || pkg.ecosystem === 'soldeer')
1563
+ const githubToken = needsGithub ? await ensureUserAccessToken(deps.config, deps.db, session.userId, Date.now(), (url, init) => fetch(url, {
1564
+ ...init, signal: init?.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
1565
+ })) : null
1566
+ if (res.destroyed || (needsGithub && !(await authorize()))) return
1567
+ const result = await fetchBundleAdvisories(inventory.packages, controller.signal, {
1568
+ debug: deps.config.debug, repoAdvisories, details, githubToken,
1569
+ cache: auditCache(deps.config.upstreamCacheDir, deps.db, controller.signal, deps.config.debug, auditedRepos(inventory.packages, repoAdvisories)),
1570
+ })
1571
+ if (res.destroyed || !(await authorize())) return
1572
+ // Return just inventory and public advisories, never source or report data.
1573
+ sendJson(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body)
1574
+ } finally {
1575
+ clearTimeout(timer)
1576
+ res.off('close', onClose)
1577
+ }
1578
+ }
1579
+
1580
+ // GET /api/admin/bundles — the uploaded bundles for the "Manage bundles" page.
1581
+ // admin|manage, read-only (no CSRF). `maxBytes` is the upload cap; `repos` feeds
1582
+ // the upload repo picker.
1583
+ async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
1584
+ const before = await deps.db.getBundleCatalog(session.id, Date.now())
1585
+ const summaries = await bundleSummaries(before.bundles, deps.bundleCache)
1586
+ const commits = await bundleCommits(deps.db, before.bundles, summaries)
1587
+ // Even cached storage and commit reads may outlast changes to access or locations.
1588
+ const catalog = await deps.db.getBundleCatalog(session.id, Date.now())
1589
+ sendJson(res, 200, {
1590
+ ...catalog,
1591
+ bundles: catalog.bundles.map(bundle => ({
1592
+ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }), commitInfo: commits.commitInfo(bundle),
1593
+ })),
1594
+ maxBytes: deps.config.maxBundleBytes,
1595
+ })
1596
+ await backfillBundleSummaries(catalog.bundles, deps.bundleCache)
1597
+ await backfillCatalogCommits(deps, session.userId, commits.missing)
1598
+ }
1599
+
1600
+ type UploadedBundle = Pick<ManagedBundle, 'id' | 'slug' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
1601
+ // `reuse` describes a repeated upload/build of an existing row (see reuseBundle).
1602
+ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
1603
+ bundle: UploadedBundle, reuse?: Omit<BundleReuse, 'rename'>): Promise<void> {
1604
+ const s = await manageMutation(req, res, deps, cookie)
1605
+ if (!s) return
1606
+ const deduped = reuse != null
1607
+ if (deduped) {
1608
+ // Authorized in the same transaction as the rename. A server build labels
1609
+ // even a row the caller cannot see: the bytes are this server's own output.
1610
+ if (await deps.db.reuseBundleUpload(s.session.id, bundle.id, reuse)) bundle = await deps.db.getBundle(bundle.id) ?? bundle
1611
+ if (!(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1612
+ }
1613
+ const { id, slug, integrity, filename, byteSize, repoId, repoDirectory } = bundle
1614
+ // Re-uploading also repairs reports uploaded while the bundle was inaccessible.
1615
+ await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
1616
+ prebuildBundle(deps, id)
1617
+ sendJson(res, deduped ? 200 : 201, { id, slug, integrity, filename, repoId, repoDirectory, ...(deduped ? { deduped: true } : { byteSize }) })
1618
+ }
1619
+
1620
+ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1621
+ const initial = await manageMutation(req, res, deps, cookie)
1622
+ if (!initial) return
1623
+ const controller = new AbortController()
1624
+ const onClose = () => controller.abort()
1625
+ res.on('close', onClose)
1626
+ try {
1627
+ let body: unknown
1628
+ try { body = await readJsonBody(req, 128 * 1024) } catch { throw new BundleBuildError(400, 'bad-body') }
1629
+ const input = parseBundleBuild(body)
1630
+ const authorize = async (directory: string) => {
1631
+ const session = await manageMutation(req, res, deps, cookie)
1632
+ if (!session) return null
1633
+ const repo = (await bundleRepos(deps, session.user)).find(item => item.repoId === input.repoId)
1634
+ if (!repo) throw new BundleBuildError(404, 'no-repository')
1635
+ const scopes = session.user.role === 'admin' ? [null]
1636
+ : (await deps.db.listRepoScopesForUser(session.user.id)).filter(scope => scope.repoId === input.repoId).map(scope => scope.path)
1637
+ if (scopedDirectory(directory, scopes) !== null) throw new BundleBuildError(403, 'build-scope')
1638
+ return { ...session, repo, scopes }
1639
+ }
1640
+ const access = await authorize(input.directory)
1641
+ if (!access) return
1642
+ await withBundleBuildLease(deps.db, access.user.id, controller.signal, async signal => {
1643
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1644
+ signal.throwIfAborted()
1645
+ // Catalogs then show the built bundle's commit details from the cache.
1646
+ const commitCached = cacheCommitDetails(deps.db, reader, input.repoId, input.commit)
1647
+ const built = await buildRepositoryBundle(access.user.id, {
1648
+ input, github: access.repo.fullName, token: reader.readToken(), maxBytes: deps.config.maxBundleBytes, scopes: access.scopes,
1649
+ cacheDir: deps.config.upstreamCacheDir ?? null,
1650
+ }, signal)
1651
+ await commitCached
1652
+ signal.throwIfAborted()
1653
+ await reader.recheckAccess()
1654
+ const current = await authorize(built.directory)
1655
+ if (!current) return
1656
+ if (JSON.stringify({ ...current.repo, cachedDefaultBranch: access.repo.cachedDefaultBranch }) !== JSON.stringify(access.repo)) {
1657
+ throw new BundleBuildError(409, 'repository-changed')
1658
+ }
1659
+ signal.throwIfAborted()
1660
+ const bytes = Buffer.from(built.bytes)
1661
+ await storeUploadedBundle(req, res, deps, cookie, current, bytes, input.repoId, built.directory, built.filename, 'build',
1662
+ bundleIntegrity(bytes), input.conditions)
1663
+ })
1664
+ } catch (error) {
1665
+ if (res.destroyed || controller.signal.aborted) return
1666
+ if (error instanceof BundleBuildError) { sendJson(res, error.status, { error: error.code }); return }
1667
+ if (error instanceof GithubApiError) {
1668
+ sendJson(res, error.status === 401 ? 502 : error.status, { error: error.message }, error.retryAfter == null ? {} : { 'retry-after': String(error.retryAfter) })
1669
+ return
1670
+ }
1671
+ throw error
1672
+ } finally { res.off('close', onClose) }
1673
+ }
1674
+
1675
+ // POST /api/admin/bundles — upload a bundle. Mutation: same-origin + CSRF,
1676
+ // admin|manage. Raw bytes; X-Bundle-Filename names it, optional X-Repo-Id and
1677
+ // X-Repo-Directory assign a repository location; Stasis repo headers default
1678
+ // to a matching connected repository within the uploader's access, otherwise
1679
+ // the bundle stays unattached. Its identity is its content hash (sha512), UNIQUE — a
1680
+ // re-upload of identical bytes dedupes to the existing row (no second copy) and
1681
+ // renames it, unless the row was built on this server.
1682
+ // After storing, any reports that declared this integrity but weren't linked yet
1683
+ // get attached (auto-link). 413 over the cap, 400 on empty.
1684
+ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1685
+ let s = await checkMutation(req, res, deps, cookie)
1686
+ if (s == null) return
1687
+ if (!requireManageRole(res, s.user)) return
1688
+ let bytes: Buffer
1689
+ try {
1690
+ bytes = await readUploadBody(req, deps, s.session, 'bundles')
1691
+ } catch (err) {
1692
+ const tooLarge = err instanceof Error && err.message === 'too-large'
1693
+ sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
1694
+ return
1695
+ }
1696
+ // Shared storage reads may outlast a session or role change.
1697
+ s = await checkMutation(req, res, deps, cookie)
1698
+ if (!s || !requireManageRole(res, s.user)) return
1699
+ if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
1700
+ const repo = await resolveUploadRepoId(req, res, deps)
1701
+ if (!repo.ok) return
1702
+ const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
1703
+ const kind = bundleKind(filename)
1704
+ // Explicit locations override embedded defaults. Reuploads keep their
1705
+ // stored location, so only new bundles need their origin header decoded.
1706
+ const integrity = bundleIntegrity(bytes)
1707
+ const existing = await deps.db.getBundleByIntegrity(integrity)
1708
+ let inferredLocation = false
1709
+ let rawDirectory: unknown
1710
+ try { rawDirectory = decodeURIComponent(firstHeader(req.headers['x-repo-directory']) ?? '') }
1711
+ catch { sendJson(res, 400, { error: 'bad-directory' }); return }
1712
+ if (!existing && repo.repoId == null && kind === 'stasis') {
1713
+ const embedded = await bundleRepo(bytes)
1714
+ const github = reportRepoGithub({ repo: embedded })
1715
+ const embeddedDirectory = normalizeTeamPath(embedded?.directory)
1716
+ // Invalid optional metadata must not reject an otherwise unattached bundle.
1717
+ // Only repository-wide aliases/direct connections can resolve without a
1718
+ // valid directory; do not decode file paths or match directory aliases.
1719
+ const location = github == null ? null : embeddedDirectory.ok
1720
+ ? await resolveRepositoryImportLocation(deps.db, github, embeddedDirectory.path ?? '', () => bundleFilePrefix(bytes))
1721
+ : await deps.db.getRepositoryImportLocation(github, '')
1722
+ if (location?.repoId != null) {
1723
+ repo.repoId = location.repoId
1724
+ if (req.headers['x-repo-directory'] == null) {
1725
+ if (!embeddedDirectory.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
1726
+ rawDirectory = location.directory
1727
+ inferredLocation = true
1728
+ }
1729
+ }
1730
+ s = await checkMutation(req, res, deps, cookie)
1731
+ if (!s || !requireManageRole(res, s.user)) return
1732
+ }
1733
+ const normalized = normalizeTeamPath(rawDirectory)
1734
+ if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
1735
+ let directory = repo.repoId == null ? '' : normalized.path ?? ''
1736
+ if (repo.repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repo.repoId, directory))) {
1737
+ if (!inferredLocation) { sendJson(res, 403, { error: 'forbidden' }); return }
1738
+ // A bundle's own stamp is a default, not a required destination. Retain
1739
+ // it in the archive while letting the uploader assign an allowed location.
1740
+ repo.repoId = null
1741
+ directory = ''
1742
+ }
1743
+ await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity)
1744
+ }
1745
+
1746
+ async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
1747
+ s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
1748
+ provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null): Promise<void> {
1749
+ const kind = bundleKind(filename)
1750
+ const reuse = { provenance, filename, kind, buildConditions }
1751
+ const existing = await deps.db.getBundleByIntegrity(integrity)
1752
+ if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, reuse); return }
1753
+ const id = randomUUID()
1754
+ const dataKey = await deps.bundleStore.put(id, bytes, kind)
1755
+ try {
1756
+ await deps.db.insertBundle({
1757
+ id, integrity, filename, kind, dataKey, provenance, buildConditions,
1758
+ byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId, repoDirectory: directory,
1759
+ }, Date.now(), s.session.id)
1760
+ } catch (err) {
1761
+ // A concurrent upload of identical bytes can insert this integrity (UNIQUE)
1762
+ // between our dedup check and this insert — treat that as a dedup, not a 500.
1763
+ // Any other failure rethrows.
1764
+ let raced: ManagedBundle | null
1765
+ try { raced = await deps.db.resolveBundleUpload(integrity) }
1766
+ catch (lookup) { throw new AggregateError([err, lookup], 'Bundle upload reconciliation failed', { cause: lookup }) }
1767
+ // Preserve a committed candidate after a lost acknowledgement. If this
1768
+ // read fails too, leave the bytes for later reconciliation.
1769
+ if (raced?.id !== id) await deps.bundleStore.delete(id).catch(() => {})
1770
+ if (err instanceof ManagedMutationError) throw err
1771
+ if (!raced) throw err
1772
+ if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, reuse); return }
1773
+ // Our insert committed: continue with the ordinary creation response.
1774
+ }
1775
+ const stored = await deps.db.getBundle(id)
1776
+ if (!stored) { sendJson(res, 404, { error: 'no-bundle' }); return }
1777
+ await sendUploadedBundle(req, res, deps, cookie, stored)
1778
+ }
1779
+
1780
+ // GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
1781
+ // are opaque archives, so served as application/octet-stream. Sourcemaps use
1782
+ // HTTP Brotli decoding to restore the uploaded .map bytes. 404 no such
1783
+ // bundle; 503 row-without-bytes (store desync).
1784
+ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string): Promise<void> {
1785
+ const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1786
+ if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1787
+ const rec = access.bundle
1788
+ if (rec == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
1789
+ const stored = await deps.bundleStore.open(id, rec.kind)
1790
+ if (stored == null) { sendJson(res, 503, { error: 'unavailable' }); return }
1791
+ const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1792
+ if (!current?.bundle) {
1793
+ stored.stream.destroy()
1794
+ sendJson(res, current ? 404 : 401, { error: 'no-bundle' }); return
1795
+ }
1796
+ res.writeHead(200, {
1797
+ 'content-type': 'application/octet-stream',
1798
+ ...(rec.kind === 'sourcemap' ? { 'content-encoding': 'br' } : {}),
1799
+ ...(stored.size == null ? {} : { 'content-length': String(stored.size) }),
1800
+ 'content-disposition': attachmentDisposition(rec.filename),
1801
+ 'x-content-type-options': 'nosniff',
1802
+ 'cache-control': 'no-store',
1803
+ })
1804
+ if (req.method === 'HEAD') { stored.stream.destroy(); res.end(); return }
1805
+ try { await pipeline(stored.stream, res) } catch { res.destroy() }
1806
+ }
1807
+
1808
+ // DELETE /api/admin/bundles/<id> — remove a bundle (admin|manage). Mutation:
1809
+ // same-origin + CSRF. Drops the row (referencing reports' bundle_id null out via
1810
+ // the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
1811
+ // best-effort. 404 when there was no such bundle.
1812
+ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1813
+ const s = await checkMutation(req, res, deps, cookie)
1814
+ if (s == null) return
1815
+ if (!requireManageRole(res, s.user)) return
1816
+ const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
1817
+ await deps.bundleCache?.delete(id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
1818
+ await deps.reportSourcesCache?.deleteBundle(id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
1819
+ await deps.bundleStore.delete(id).catch((err) => { console.warn('managed: bundle bytes delete failed:', err) })
1820
+ await activity(deps, user, 'delete', 'deleted a bundle', { repoId: bundle.repoId, repoDirectory: bundle.repoDirectory, bundleId: id, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
1821
+ sendJson(res, 200, { ok: true })
1822
+ }
1823
+
1824
+ // admin|manage mutation guard: same-origin + CSRF + role, in one step. Returns
1825
+ // the session, or null after the 401/403 was sent.
1826
+ async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
1827
+ const s = await checkMutation(req, res, deps, cookie)
1828
+ if (s == null) return null
1829
+ if (!requireManageRole(res, s.user)) return null
1830
+ return s
1831
+ }
1832
+
1833
+ // Workspace access and repository connections are administered separately
1834
+ // from content management. Managers cannot change their own access via teams.
1835
+ async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
1836
+ const s = await checkMutation(req, res, deps, cookie)
1837
+ if (s == null) return null
1838
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
1839
+ return s
1840
+ }
1841
+
1842
+ // GET /api/teams — the CURRENT user's teams, each with the reports and bundles
1843
+ // attached to the team's repos, for the sidebar's per-user Teams section. Any approved
1844
+ // user (not just admin|manage); a user only ever sees their own teams.
1845
+ // Each team carries `app`, its App classification (team-app.ts), so the
1846
+ // sidebar's first paint already shows App teams collapsed.
1847
+ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
1848
+ let snapshot = await deps.db.getUserTeamFeedSnapshot(session.id, Date.now())
1849
+ if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1850
+ const [summaries, classified] = await Promise.all([
1851
+ bundleSummaries(snapshot.teams.flatMap(team => team.bundles), deps.bundleCache),
1852
+ teamAppStates(deps.db, deps.reportStore, session.id, snapshot.teams),
1853
+ ])
1854
+ const commits = await bundleCommits(deps.db, snapshot.teams.flatMap(team => team.bundles), summaries)
1855
+ const current = await deps.db.getFeedState(session.id, Date.now())
1856
+ if (!current) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1857
+ // Keep the post-storage authorization fence, but reload the catalog only
1858
+ // when a concurrent mutation actually invalidated its consistent snapshot.
1859
+ if (current.user.id !== snapshot.user.id || current.user.role !== snapshot.user.role || current.catalog !== snapshot.catalog) {
1860
+ snapshot = await deps.db.getUserTeamFeedSnapshot(session.id, Date.now())
1861
+ if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1862
+ }
1863
+ const { teams, revision } = snapshot
1864
+ // The revision sent is the reloaded catalog's, so classify what changed now.
1865
+ const apps = await teamAppStates(deps.db, deps.reportStore, session.id, teams, classified)
1866
+ sendJson(res, 200, {
1867
+ teams: teams.map(team => ({ ...team, app: apps.get(teamAppKey(team)) ?? null,
1868
+ bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
1869
+ commitInfo: commits.commitInfo(bundle) })) })),
1870
+ revision,
1871
+ })
1872
+ await backfillBundleSummaries(teams.flatMap(team => team.bundles), deps.bundleCache)
1873
+ await backfillCatalogCommits(deps, session.userId, commits.missing)
1874
+ }
1875
+
1876
+ // Admins read all reports. Managers read their uploads or reports inside their
1877
+ // team repository paths, including drafts they may manage. View/triage users
1878
+ // need a published report in their teams; ownership never overrides role none.
1879
+ async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
1880
+ if (!roleAtLeast(user.role, 'view')) return false
1881
+ const report = await deps.db.getReport(reportId)
1882
+ if (report == null) return false
1883
+ if (user.role === 'admin') return true
1884
+ if (user.role === 'manage') return report.uploadedBy === user.id || deps.db.userCanReadReport(user.id, reportId)
1885
+ if (!report.visible) return false
1886
+ return deps.db.userCanReadReport(user.id, reportId)
1887
+ }
1888
+
1889
+ // GET/HEAD /api/reports/:id/sources. Only the linked bundle's files cited by
1890
+ // visible findings (location AND evidence) are returned. Missing/unavailable
1891
+ // bundles are a quiet no-op; cached gzip bodies stream directly from disk.
1892
+ async function handleReportSources(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null) {
1893
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
1894
+ if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1895
+ if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1896
+ const sourcePaths = roleAtLeast(s.user.role, 'manage') ? undefined : await teamSourcePaths(deps.db, deps.reportStore, s.session.id, teamId ?? '', id)
1897
+ const report = await deps.db.getReport(id)
1898
+ const bundle = report?.bundleId ? await deps.db.getBundle(report.bundleId) : null
1899
+ const empty = () => { res.writeHead(204, { 'cache-control': 'private, no-store' }); res.end() }
1900
+ if (!report || !bundle || !['stasis', 'sourcemap'].includes(bundle.kind ?? '') || !(await canAccessBundle(deps, s.user, bundle.id))) { empty(); return }
1901
+ if (!deps.reportSourcesCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1902
+ let cached
1903
+ try { cached = await deps.reportSourcesCache.open(report, bundle, { dependencies: true, security: true }, sourcePaths) }
1904
+ catch { empty(); return }
1905
+ if (!cached) { empty(); return }
1906
+ // Cold parsing can outlast role/team changes, report deletion, or relinking.
1907
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
1908
+ const latest = await deps.db.getReport(id)
1909
+ let latestPaths: Set<string> | undefined
1910
+ try { latestPaths = current && !roleAtLeast(current.user.role, 'manage') ? await teamSourcePaths(deps.db, deps.reportStore, current.session.id, teamId ?? '', id) : undefined }
1911
+ catch (error) { cached.stream.destroy(); throw error }
1912
+ if (!current || current.user.role !== s.user.role || sourcePaths?.size !== latestPaths?.size || [...sourcePaths ?? []].some(path => !latestPaths?.has(path)) || !(await canViewReport(deps, current.user, id)) || !(await canAccessBundle(deps, current.user, bundle.id))
1913
+ || latest?.bundleId !== bundle.id || latest.sha256 !== report.sha256
1914
+ || latest.repoId !== report.repoId || await repositoryName(deps, latest.repoId) !== cached.repo.github) {
1915
+ cached.stream.destroy()
1916
+ sendJson(res, current ? 404 : 401, { error: current ? 'no-report' : 'unauthenticated' })
1917
+ return
1918
+ }
1919
+ res.writeHead(200, {
1920
+ 'content-type': 'application/json', 'content-encoding': 'gzip',
1921
+ ...(cached.size == null ? {} : { 'content-length': String(cached.size) }), 'cache-control': 'private, no-store',
1922
+ 'x-content-type-options': 'nosniff',
1923
+ })
1924
+ if (req.method === 'HEAD') { cached.stream.destroy(); res.end(); return }
1925
+ try { await pipeline(cached.stream, res) } catch { res.destroy() }
1926
+ }
1927
+
1928
+ async function reportIds(data: unknown): Promise<Set<string>> {
1929
+ const findings = (reportEntries(data) ?? []).flat().filter((f): f is Record<string, unknown> => f !== null && typeof f === 'object' && !Array.isArray(f))
1930
+ await backfillFindingIds(findings)
1931
+ return new Set(findings.flatMap(f => typeof f['id'] === 'string' ? [f['id']] : []))
1932
+ }
1933
+
1934
+ async function handleDeduplication(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
1935
+ const method = req.method ?? 'GET'
1936
+ if (!(id ? ['GET', 'PATCH'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'GET, PATCH' : 'GET, POST'); return }
1937
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
1938
+ if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
1939
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
1940
+ if (method === 'GET') { sendJson(res, 200, id ? await deps.db.getLinkReport(s.session.id, id) : { reports: await deps.db.listLinkReports() }); return }
1941
+ if (id) {
1942
+ let body
1943
+ try { body = await readJsonBody(req, 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1944
+ const enabled = (body as { enabled?: unknown } | null)?.enabled
1945
+ if (typeof enabled !== 'boolean') { sendJson(res, 400, { error: 'bad-enabled' }); return }
1946
+ await deps.db.setLinkReportEnabled(s.session.id, id, enabled)
1947
+ sendJson(res, 200, { ok: true }); return
1948
+ }
1949
+ let bytes: Buffer
1950
+ try { bytes = await readUploadBody(req, deps, s.session, 'links') }
1951
+ catch (error) { const large = error instanceof Error && error.message === 'too-large'; sendJson(res, large ? 413 : 400, { error: large ? 'too-large' : 'bad-body' }); return }
1952
+ const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.link.json')
1953
+ const result = await deps.db.importLinkReport(s.session.id, filename, bytes.toString('utf8'))
1954
+ sendJson(res, result.reused ? 200 : 201, { ...result.report, deduped: result.reused })
1955
+ }
1956
+
1957
+ async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
1958
+ const method = req.method ?? 'GET'
1959
+ if (!(id ? ['PATCH', 'DELETE'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'PATCH, DELETE' : 'GET, POST'); return }
1960
+ const s = method === 'GET' ? await readAdminSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
1961
+ if (!s) return
1962
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
1963
+ if (method === 'GET') {
1964
+ sendJson(res, 200, { aliases: await deps.db.listRepositoryAliases(), repos: await deps.db.listAllRepos() }); return
1965
+ }
1966
+ if (method === 'DELETE') {
1967
+ await deps.db.deleteRepositoryAlias(s.session.id, id!)
1968
+ sendJson(res, 200, { ok: true }); return
1969
+ }
1970
+ let body
1971
+ try { body = await readJsonBody(req, 8192) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1972
+ const alias = await deps.db.saveRepositoryAlias(s.session.id, id ?? null, body)
1973
+ sendJson(res, id ? 200 : 201, alias)
1974
+ }
1975
+
1976
+ // Live suggestions do not mutate archived content or its stored assignment.
1977
+ // Managers can resolve only destinations their current repository grants cover.
1978
+ async function handleRepositorySuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1979
+ if (req.method !== 'GET') { send405(res, 'GET'); return }
1980
+ if (!await readManageSession(res, deps, cookie)) return
1981
+ const params = new URL(req.url!, 'http://localhost').searchParams
1982
+ const github = reportRepoGithub({ repo: { github: params.get('repo') } })
1983
+ const directory = normalizeTeamPath(params.get('directory'))
1984
+ const filePrefix = normalizeTeamPath(params.get('filePrefix'))
1985
+ if (!github || !directory.ok || !filePrefix.ok) { sendJson(res, 400, { error: 'bad-location' }); return }
1986
+ const location = await deps.db.getRepositoryImportLocation(github, directory.path ?? '', filePrefix.path ?? '')
1987
+ const repo = location.repoId == null ? null : (await deps.db.listSelectedRepos()).find(row => row.repoId === location.repoId)
1988
+ const s = await readManageSession(res, deps, cookie)
1989
+ if (!s) return
1990
+ const allowed = repo && (s.user.role === 'admin' || await deps.db.userCanReadRepoPath(s.user.id, repo.repoId, location.directory))
1991
+ sendJson(res, 200, { location: allowed ? { repoId: repo.repoId, github: repo.fullName, directory: location.directory,
1992
+ mapped: github.toLowerCase() !== repo.fullName.toLowerCase() || (directory.path ?? '') !== location.directory } : null })
1993
+ }
1994
+
1995
+ // Where a report without an embedded repository suggests it belongs, resolved
1996
+ // through current connections and aliases like bundle metadata. `repoId` is
1997
+ // set only for an active destination within the user's grants; the named
1998
+ // repository itself is part of the report. `directory` is null when neither
1999
+ // the report nor an alias specifies one.
2000
+ async function suggestReportLocation(deps: ManagedHttpDeps, user: StoredUser, data: { repo?: { directory?: unknown } } | null | undefined) {
2001
+ const github = findingsRepository(data)
2002
+ if (github == null) return null
2003
+ // An invalid optional directory is ignored, as for bundle metadata.
2004
+ const declared = normalizeTeamPath(data?.repo?.directory)
2005
+ const directory = declared.ok ? declared.path : null
2006
+ const location = await deps.db.getRepositoryImportLocation(github, directory ?? '', ownFileDirectory(data))
2007
+ const repo = location.repoId == null ? null : (await deps.db.listSelectedRepos()).find(row => row.repoId === location.repoId)
2008
+ const allowed = repo && (user.role === 'admin' || await deps.db.userCanReadRepoPath(user.id, repo.repoId, location.directory))
2009
+ if (!allowed) return { repoId: null, github, directory }
2010
+ const mapped = github.toLowerCase() !== repo.fullName.toLowerCase() || (directory ?? '') !== location.directory
2011
+ return { repoId: repo.repoId, github: repo.fullName, directory: mapped ? location.directory : directory }
2012
+ }
2013
+
2014
+ // GET /api/admin/reports/<id>/location — the live suggestion for a report's
2015
+ // location editor. It never changes the stored assignment.
2016
+ async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
2017
+ if (req.method !== 'GET') { send405(res, 'GET'); return }
2018
+ const s = await readManageSession(res, deps, cookie)
2019
+ if (!s) return
2020
+ const report = await deps.db.getReport(id)
2021
+ if (!report || !(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
2022
+ if (report.repoEmbedded) { sendJson(res, 200, { location: null }); return }
2023
+ const bytes = await deps.reportStore.get(id)
2024
+ if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
2025
+ const { data } = readManagedReport(bytes.toString('utf8'), report.filename)
2026
+ // Storage reads can outlast a session, role or grant change.
2027
+ const current = await readManageSession(res, deps, cookie)
2028
+ if (!current) return
2029
+ if (!(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
2030
+ sendJson(res, 200, { location: await suggestReportLocation(deps, current.user, data) })
2031
+ }
2032
+
2033
+ // GET /api/reports/<id> — admin/manager preview, within management access.
2034
+ // Accept: application/json includes parsed, filtered data and the server's repo
2035
+ // assignment. Other callers retain the raw text/plain response. The
2036
+ // client renders either without caching to OPFS. 404 covers "no such report" AND
2037
+ // "not authorized" (so neither existence nor membership is probeable); 503 = row
2038
+ // without bytes (store desync).
2039
+ async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
2040
+ const s = await readManageSession(res, deps, cookie)
2041
+ if (s == null) return
2042
+ const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
2043
+ const access = snapshot?.reports[0]
2044
+ if (!snapshot || !access || !roleAtLeast(snapshot.user.role, 'manage')) { sendJson(res, 404, { error: 'no-report' }); return }
2045
+ const bytes = await deps.reportStore.get(id)
2046
+ if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
2047
+ const metadata = acceptsReportMetadata(req.headers.accept)
2048
+ let envelope
2049
+ if (metadata) {
2050
+ const { data } = readManagedReport(bytes.toString('utf8'), access.filename)
2051
+ if (data == null) { sendJson(res, 422, { error: 'unreadable-report' }); return }
2052
+ const links = mergeLinkGroups(snapshot.linkRevision ? await deps.db.getEnabledLinkGroups(snapshot.linkRevision) : [], await reportIds(data))
2053
+ envelope = { data, repo: access.repo, ...(links.length > 0 ? { links } : {}) }
2054
+ }
2055
+ // Link decryption and finding-ID backfill can outlast access or link changes.
2056
+ const current = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
2057
+ if (!current || current.user.id !== snapshot.user.id || current.user.role !== snapshot.user.role
2058
+ || current.linkRevision !== snapshot.linkRevision || JSON.stringify(current.reports) !== JSON.stringify(snapshot.reports)) {
2059
+ sendJson(res, 404, { error: 'no-report' }); return
2060
+ }
2061
+ if (metadata) {
2062
+ sendJson(res, 200, envelope, { vary: 'Accept', 'x-content-type-options': 'nosniff' }); return
2063
+ }
2064
+ res.writeHead(200, {
2065
+ 'content-type': 'text/plain; charset=utf-8', 'content-length': String(bytes.length),
2066
+ 'x-content-type-options': 'nosniff', 'cache-control': 'no-store', vary: 'Accept',
2067
+ })
2068
+ writeResponse(res, bytes)
2069
+ }
2070
+
2071
+ // A read-only POST avoids URL-length limits when a workspace has many reports.
2072
+ // Authorize every requested id before reading blobs; never return a partial
2073
+ // workspace, or let one authorized report grant access to another in the batch.
2074
+ const REPORT_QUERY_CONCURRENCY = 8
2075
+ const REPORT_QUERY_IN_FLIGHT_BYTES = 64 * 1024 * 1024
2076
+ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2077
+ const s = await readManageSession(res, deps, cookie)
2078
+ if (!s) return
2079
+ let body
2080
+ try { body = await readJsonBody(req, 1024 * 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2081
+ const raw = (body as { ids?: unknown } | null)?.ids
2082
+ if (!Array.isArray(raw) || raw.some(id => typeof id !== 'string' || !id || id.length > 256)) {
2083
+ sendJson(res, 400, { error: 'bad-ids' }); return
2084
+ }
2085
+ const ids = [...new Set(raw as string[])]
2086
+ if (ids.length > MAX_REPORT_QUERY_COUNT) { sendJson(res, 413, { error: 'batch-too-large' }); return }
2087
+ const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), ids)
2088
+ if (!snapshot || snapshot.user.id !== s.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2089
+ if (!roleAtLeast(snapshot.user.role, 'manage')) { sendJson(res, 404, { error: 'no-report' }); return }
2090
+ const reports = new Map(snapshot.reports.map(report => [report.id, report]))
2091
+ if (reports.size !== ids.length) { sendJson(res, 404, { error: 'no-report' }); return }
2092
+ const storedBytes = snapshot.reports.reduce((total, report) => total + report.byteSize, 0)
2093
+ if (storedBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
2094
+ // Overlap remote blob reads, retaining only each report's encoded response
2095
+ // after processing. Check actual bytes too if storage and metadata disagree.
2096
+ const parts = [Buffer.from('{"reports":[')]
2097
+ const findingIds = new Set<string>()
2098
+ let inputBytes = 0, outputBytes = parts[0]!.length + 2
2099
+ let next = 0, stopped = false
2100
+ let inFlightBytes = 0
2101
+ let capacity = Promise.withResolvers<void>()
2102
+ const fail = (status: number, error: string) => { stopped = true; return { status, error } }
2103
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(REPORT_QUERY_CONCURRENCY, ids.length) }, async () => {
2104
+ try {
2105
+ while (next < ids.length) {
2106
+ if (stopped) return
2107
+ const index = next
2108
+ const id = ids[index]!
2109
+ const access = reports.get(id)!
2110
+ const reservedBytes = Math.max(1, access.byteSize)
2111
+ // Reserve stored bytes before starting remote reads, through encoding.
2112
+ // A report larger than the concurrent budget is still allowed alone.
2113
+ if (inFlightBytes > 0 && inFlightBytes + reservedBytes > REPORT_QUERY_IN_FLIGHT_BYTES) {
2114
+ await capacity.promise
2115
+ continue
2116
+ }
2117
+ next++
2118
+ inFlightBytes += reservedBytes
2119
+ try {
2120
+ const bytes = await deps.reportStore.get(id)
2121
+ if (stopped) return
2122
+ if (bytes == null) return fail(503, 'unavailable')
2123
+ inputBytes += bytes.length
2124
+ if (inputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
2125
+ const { data } = readManagedReport(bytes.toString('utf8'), access.filename)
2126
+ if (!data) return fail(422, 'unreadable-report')
2127
+ for (const findingId of await reportIds(data)) findingIds.add(findingId)
2128
+ const part = Buffer.from(`${index > 0 ? ',' : ''}${JSON.stringify({ id, data: filterReportData(data, access.permissions, access.repo), repo: access.repo })}`)
2129
+ outputBytes += part.length
2130
+ if (outputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
2131
+ // Completion order may differ from the requested report order.
2132
+ parts[index + 1] = part
2133
+ } finally {
2134
+ inFlightBytes -= reservedBytes
2135
+ capacity.resolve()
2136
+ capacity = Promise.withResolvers<void>()
2137
+ }
2138
+ }
2139
+ } catch (error) {
2140
+ if (stopped) return
2141
+ stopped = true
2142
+ throw error
2143
+ }
2144
+ return undefined
2145
+ }))
2146
+ // Stop scheduling on any failure, and drain already-started reads before
2147
+ // responding so none outlive the tracked request or reject unhandled.
2148
+ for (const result of workers) {
2149
+ if (result.status === 'rejected') throw result.reason
2150
+ if (result.value) { sendJson(res, result.value.status, { error: result.value.error }); return }
2151
+ }
2152
+ const links = mergeLinkGroups(snapshot.linkRevision ? await deps.db.getEnabledLinkGroups(snapshot.linkRevision) : [], findingIds)
2153
+ // Access to an earlier report may change while a later blob is fetched.
2154
+ // Reject the whole answer if any content/access/assignment snapshot changed.
2155
+ const current = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), ids)
2156
+ if (!current || current.user.id !== snapshot.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2157
+ if (current.linkRevision !== snapshot.linkRevision || current.user.role !== snapshot.user.role || current.reports.length !== reports.size
2158
+ || current.reports.some(report => JSON.stringify(report) !== JSON.stringify(reports.get(report.id)))) {
2159
+ sendJson(res, 404, { error: 'no-report' }); return
2160
+ }
2161
+ const suffix = Buffer.from(links.length > 0 ? `],"links":${JSON.stringify(links)}}` : ']}')
2162
+ outputBytes += suffix.length - 2
2163
+ if (outputBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
2164
+ parts.push(suffix)
2165
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
2166
+ writeResponse(res, Buffer.concat(parts, outputBytes))
2167
+ }
2168
+
2169
+ // The server selects the complete published workspace for classification.
2170
+ // Privileged hidden-report previews are independently scoped and bounded.
2171
+ async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string, reportId: string | null): Promise<void> {
2172
+ const snapshot = await teamSnapshot(deps.db, session.id, teamId, reportId)
2173
+ const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
2174
+ await recheckTeam(deps.db, session.id, snapshot)
2175
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
2176
+ writeResponse(res, body)
2177
+ }
2178
+
2179
+ // Triage requires both a writing role and access to the report itself.
2180
+ async function canTriageReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
2181
+ return roleAtLeast(user.role, 'triage') && await canViewReport(deps, user, reportId)
2182
+ }
2183
+
2184
+ // Ordinary users authorize annotations through the complete team's filtered
2185
+ // workspace. Privileged users retain report-scoped access and the same stable
2186
+ // ID derivation. Both paths store/read annotations by finding ID alone.
2187
+ const VISIBLE_IDS_CACHE_MAX = 256
2188
+ const visibleIdsCaches = new WeakMap<ManagedHttpDeps, Map<string, Set<string>>>()
2189
+ async function visibleFindingIds(deps: ManagedHttpDeps, user: StoredUser, reportId: string, sessionId: string, teamId: string | null): Promise<Set<string>> {
2190
+ if (!roleAtLeast(user.role, 'manage')) {
2191
+ if (!teamId) throw new TeamReportsError(404, 'no-team')
2192
+ return teamFindingIds(deps.db, deps.reportStore, sessionId, teamId, reportId)
2193
+ }
2194
+ const rec = await deps.db.getReport(reportId)
2195
+ if (rec == null) return new Set()
2196
+ const repo = { github: await repositoryName(deps, rec.repoId) }
2197
+ const key = JSON.stringify([reportId, rec.sha256, rec.filename, repo])
2198
+ let cache = visibleIdsCaches.get(deps)
2199
+ if (cache == null) { cache = new Map(); visibleIdsCaches.set(deps, cache) }
2200
+ const hit = cache.get(key)
2201
+ if (hit != null) return hit
2202
+ const ids = new Set<string>()
2203
+ const bytes = await deps.reportStore.get(reportId)
2204
+ if (bytes == null) return ids
2205
+ const text = bytes.toString('utf8')
2206
+ const { data } = readManagedReport(text, rec.filename)
2207
+ if (data == null) return ids
2208
+ const report = await loadManagedFindings(JSON.stringify(data), rec.filename)
2209
+ if (report == null) return ids
2210
+ for (const f of report.findings) {
2211
+ const id = (f as { id?: unknown }).id
2212
+ if (typeof id === 'string' && id !== '') ids.add(id)
2213
+ }
2214
+ while (cache.size >= VISIBLE_IDS_CACHE_MAX) {
2215
+ const oldest = cache.keys().next().value
2216
+ if (oldest === undefined) break
2217
+ cache.delete(oldest)
2218
+ }
2219
+ cache.set(key, ids)
2220
+ return ids
2221
+ }
2222
+
2223
+ // GET /api/reports/<id>/triage — the stored triage entries for the findings of
2224
+ // a report the caller may view (canViewReport; 404 hides existence and denial
2225
+ // alike, matching handleViewReport). Entries are keyed by finding id and shared
2226
+ // by every report carrying the finding; a viewer only receives entries for
2227
+ // findings their visibility permissions keep in THIS report — an entry on a
2228
+ // stripped finding must not leak that the finding exists. A cleared entry is
2229
+ // sent as null (the server's tombstone), distinct from one never set.
2230
+ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
2231
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
2232
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2233
+ if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
2234
+ await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
2235
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
2236
+ if (!current || !(await canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
2237
+ const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
2238
+ const entries: Record<string, TriageEntryPatch | null> = {}
2239
+ for (const row of await deps.db.listTriage([...visible])) entries[row.findingId] = triageWireEntry(row)
2240
+ sendJson(res, 200, { entries })
2241
+ }
2242
+
2243
+ // GET /api/reports/<id>/triage/history?finding=<fid> — one finding's triage
2244
+ // trail (newest first, capped). The caller must have at least triage access
2245
+ // to the report, and the finding is one their visibility permissions
2246
+ // keep in it (a stripped or foreign id 404s without revealing whether it
2247
+ // exists). Each event is the entry as written then (null = a clear), who
2248
+ // wrote it and when; "what changed" is the diff against the next-older event.
2249
+ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
2250
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
2251
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2252
+ if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
2253
+ const finding = query.get('finding') ?? ''
2254
+ if (finding === '' || finding.length > MAX_FINDING_ID) { sendJson(res, 400, { error: 'bad-request' }); return }
2255
+ await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
2256
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
2257
+ if (!current || !(await canTriageReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
2258
+ const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
2259
+ if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
2260
+ const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
2261
+ seq: row.seq, at: row.at, actorId: row.actorId, actorLogin: row.actorLogin, actorName: row.actorName,
2262
+ batchId: row.batchId, entry: triageWireEntry(row, true),
2263
+ }))
2264
+ // A database read can outlive a role, membership, or visibility change.
2265
+ const latest = await readSession(deps.config, deps.db, cookie, Date.now())
2266
+ if (!latest || latest.user.role !== current.user.role || !(await canTriageReport(deps, latest.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
2267
+ if (!(await visibleFindingIds(deps, latest.user, id, latest.session.id, teamId)).has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
2268
+ sendJson(res, 200, { finding, events })
2269
+ }
2270
+
2271
+ // POST /api/reports/<id>/triage — write triage entries for a report's findings.
2272
+ // Mutation: same-origin + CSRF, then canTriageReport (404 on any failure — role
2273
+ // too low, no membership, or no such report). Body { entries: { <findingId>:
2274
+ // entry|null } } — whole-entry replace, last write wins, null clears the entry
2275
+ // (a tombstone, so a later reader adopts the clear); each entry validates
2276
+ // through parseTriageEntryPatch (400 on a malformed one). Every id must be a
2277
+ // finding the writer sees in THIS report — the report is the authorization
2278
+ // scope for rows that are themselves per finding id — so a stripped (or
2279
+ // foreign) finding id 404s without revealing whether it exists.
2280
+ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
2281
+ const s = await checkMutation(req, res, deps, cookie)
2282
+ if (s == null) return
2283
+ if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
2284
+ let body: unknown
2285
+ try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2286
+ const raw = (body as { entries?: unknown } | null)?.entries
2287
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { sendJson(res, 400, { error: 'bad-request' }); return }
2288
+ const pairs = Object.entries(raw)
2289
+ if (pairs.length > MAX_TRIAGE_ENTRIES) { sendJson(res, 400, { error: 'too-many-entries' }); return }
2290
+ const parsed: [string, TriageEntryPatch | null][] = []
2291
+ for (const [findingId, value] of pairs) {
2292
+ const patch = parseTriageEntryPatch(value)
2293
+ if (value != null && typeof value === 'object' && Object.hasOwn(value, 'comment')) {
2294
+ sendJson(res, 400, { error: 'use-comments-endpoint' }); return
2295
+ }
2296
+ if (patch === 'invalid' || findingId === '' || findingId.length > MAX_FINDING_ID) {
2297
+ sendJson(res, 400, { error: 'bad-entry' }); return
2298
+ }
2299
+ parsed.push([findingId, patch])
2300
+ }
2301
+ await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
2302
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
2303
+ if (!current || !(await canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
2304
+ const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
2305
+ if (parsed.some(([findingId]) => !visible.has(findingId))) {
2306
+ sendJson(res, 404, { error: 'no-finding' }); return
2307
+ }
2308
+ await deps.db.setTriageEntries(parsed, s.user.id, s.user.login, Date.now(), id)
2309
+ sendJson(res, 200, { ok: true })
2310
+ }
2311
+
2312
+ // GET only: clients match local annotations against these IDs without sending
2313
+ // their local finding IDs or annotation bodies to discover which reports exist.
2314
+ async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
2315
+ const s = await readAdminSession(res, deps, cookie)
2316
+ if (!s) return
2317
+ const after = search.get('after') ?? ''
2318
+ if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
2319
+ const stored = await deps.db.listFindingCatalogReports(after, FINDING_CATALOG_PAGE_COUNT + 1)
2320
+ const reports = []
2321
+ let bytes = 0, cursor = after, processed = 0
2322
+ for (const report of stored) {
2323
+ if (processed >= FINDING_CATALOG_PAGE_COUNT || (processed > 0 && bytes + report.byteSize > FINDING_CATALOG_PAGE_BYTES)) break
2324
+ bytes += report.byteSize
2325
+ processed++
2326
+ cursor = report.id
2327
+ const findingIds = [...await visibleFindingIds(deps, s.user, report.id, s.session.id, null)]
2328
+ if (findingIds.length > 0) reports.push({ id: report.id, findingIds })
2329
+ }
2330
+ if (await readAdminSession(res, deps, cookie) == null) return
2331
+ sendJson(res, 200, { reports, nextCursor: processed < stored.length ? cursor : null })
2332
+ }
2333
+
2334
+ // Admin imports compare the exact snapshot shown by the conflict dialog before
2335
+ // writing. The store commits shared finding triage and imported comments together.
2336
+ async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
2337
+ const s = await adminMutation(req, res, deps, cookie)
2338
+ if (!s) return
2339
+ let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
2340
+ try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof body }
2341
+ catch { sendJson(res, 400, { error: 'bad-body' }); return }
2342
+ if (!body || typeof body !== 'object') { sendJson(res, 400, { error: 'bad-body' }); return }
2343
+ const reading = body.entries === undefined
2344
+ const raw = body.entries
2345
+ if (!reading && (!raw || typeof raw !== 'object' || Array.isArray(raw))) { sendJson(res, 400, { error: 'bad-entries' }); return }
2346
+ const ids = reading ? body.findingIds : Object.keys(raw!)
2347
+ if (!Array.isArray(ids) || ids.length > MAX_TRIAGE_ENTRIES || ids.some(value => typeof value !== 'string' || !value || value.length > MAX_FINDING_ID)) {
2348
+ sendJson(res, 400, { error: 'bad-finding-ids' }); return
2349
+ }
2350
+ const parsed: [string, TriageEntryPatch | null][] = []
2351
+ const expected = body.expected as Record<string, string> | null
2352
+ if (!reading) {
2353
+ if (!expected || typeof expected !== 'object' || Array.isArray(expected)) { sendJson(res, 400, { error: 'bad-expected' }); return }
2354
+ for (const [findingId, value] of Object.entries(raw!)) {
2355
+ const patch = parseTriageEntryPatch(value)
2356
+ if (patch === 'invalid' || !/^[a-f\d]{64}$/u.test(expected[findingId] ?? '')) { sendJson(res, 400, { error: 'bad-entry' }); return }
2357
+ parsed.push([findingId, patch])
2358
+ }
2359
+ }
2360
+ if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
2361
+ const visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
2362
+ if (await readAdminSession(res, deps, cookie) == null) return
2363
+ if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
2364
+ if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
2365
+ const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
2366
+ sendJson(res, applied ? 200 : 409, { ok: applied })
2367
+ }
2368
+
2369
+ // A report grants access to its visible findings; comments themselves are
2370
+ // shared by finding ID. Author IDs always come from the authenticated session.
2371
+ async function handleReportComments(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, reportId: string, commentId: string | null, teamId: string | null): Promise<void> {
2372
+ const method = req.method ?? 'GET'
2373
+ if (commentId == null ? method !== 'GET' && method !== 'POST' : method !== 'PATCH' && method !== 'DELETE') {
2374
+ send405(res, commentId == null ? 'GET, POST' : 'PATCH, DELETE'); return
2375
+ }
2376
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
2377
+ if (s == null) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
2378
+ const allowed = method === 'GET' ? await canViewReport(deps, s.user, reportId) : await canTriageReport(deps, s.user, reportId)
2379
+ if (!allowed) { sendJson(res, 404, { error: 'no-report' }); return }
2380
+ let raw: { body?: unknown; findingId?: unknown; version?: unknown } | null
2381
+ if (method === 'GET') raw = null
2382
+ else {
2383
+ try { raw = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof raw } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2384
+ }
2385
+ // Body delivery and a cold report read can outlive a permission change.
2386
+ // Match triage's recheck before exposing or changing any annotation.
2387
+ await visibleFindingIds(deps, s.user, reportId, s.session.id, teamId)
2388
+ const session = await readSession(deps.config, deps.db, cookie, Date.now())
2389
+ if (!session || session.user.role !== s.user.role || !(await canViewReport(deps, session.user, reportId))) {
2390
+ sendJson(res, 404, { error: 'no-report' }); return
2391
+ }
2392
+ const visible = await visibleFindingIds(deps, session.user, reportId, session.session.id, teamId)
2393
+ if (method === 'GET') {
2394
+ sendJson(res, 200, { comments: await deps.db.listComments([...visible]) }); return
2395
+ }
2396
+ const body = parseCommentBody(raw?.body)
2397
+ if (body == null && method !== 'DELETE') { sendJson(res, 400, { error: 'bad-comment' }); return }
2398
+ if (commentId == null) {
2399
+ if (typeof raw?.findingId !== 'string' || !visible.has(raw.findingId)) { sendJson(res, 404, { error: 'no-finding' }); return }
2400
+ const comment = await deps.db.createComment({ findingId: raw.findingId, body: body!, authorId: session.user.id, authorLogin: session.user.login, reportId }, Date.now())
2401
+ sendJson(res, 201, { comment }); return
2402
+ }
2403
+ const current = await deps.db.getComment(commentId)
2404
+ if (current == null || !visible.has(current.findingId)) { sendJson(res, 404, { error: 'no-comment' }); return }
2405
+ const allowedComment = method === 'DELETE' ? canDeleteComment(current, session.user) : current.authorId === session.user.id
2406
+ if (!allowedComment) { sendJson(res, 403, { error: 'not-comment-author' }); return }
2407
+ if (typeof raw?.version !== 'number' || !Number.isSafeInteger(raw.version) || raw.version < 1) {
2408
+ sendJson(res, 400, { error: 'bad-version' }); return
2409
+ }
2410
+ const comment = method === 'DELETE'
2411
+ ? await deps.db.deleteComment(commentId, session.user.id, session.user.login, raw.version, reportId, Date.now())
2412
+ : await deps.db.editComment(commentId, session.user.id, session.user.login, body!, raw.version, reportId, Date.now())
2413
+ if (comment === 'conflict') { sendJson(res, 409, { error: 'comment-changed' }); return }
2414
+ if (comment === 'forbidden') { sendJson(res, 403, { error: 'not-comment-author' }); return }
2415
+ if (comment == null) { sendJson(res, 404, { error: 'no-comment' }); return }
2416
+ if (comment === 'deleted') { res.writeHead(204, { 'cache-control': 'no-store' }); res.end(); return }
2417
+ sendJson(res, 200, { comment })
2418
+ }
2419
+
2420
+ // GET /api/admin/teams — every team (members + repos inlined) plus the pickers
2421
+ // the page needs: all users (member dropdown), selected repos (repo dropdown),
2422
+ // and the visibility-permission keys. admin, read-only (no CSRF).
2423
+ async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2424
+ const s = await readAdminSession(res, deps, cookie)
2425
+ if (s == null) return
2426
+ const scopes = await deps.db.listTeamNpmScopes()
2427
+ const reviewTeams = new Set(await deps.db.listDependencyReviewEnabledTeams())
2428
+ sendJson(res, 200, {
2429
+ teams: (await deps.db.listTeams()).map(team => ({ ...team, npmScopes: scopes[team.id] ?? [], dependencyReview: reviewTeams.has(team.id) })),
2430
+ users: await deps.db.listUserOptions(),
2431
+ repos: selectableRepos(await deps.db.listSelectedRepos()),
2432
+ permissions: VISIBILITY_PERMISSIONS,
2433
+ })
2434
+ }
2435
+
2436
+ // POST /api/admin/teams — create a team. Body { name }. 409 if the name's taken.
2437
+ async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2438
+ const s = await adminMutation(req, res, deps, cookie)
2439
+ if (s == null) return
2440
+ let body: unknown
2441
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2442
+ const rawName = (body as { name?: unknown } | null)?.name
2443
+ const name = typeof rawName === 'string' ? rawName.trim() : ''
2444
+ if (name === '' || name.length > MAX_TEAM_NAME) { sendJson(res, 400, { error: 'bad-name' }); return }
2445
+ const id = randomUUID()
2446
+ if (await readAdminSession(res, deps, cookie) == null) return
2447
+ if (!(await deps.db.createTeam(id, name, Date.now()))) { sendJson(res, 409, { error: 'name-taken' }); return }
2448
+ await activity(deps, s.user, 'access', `created team ${name}`)
2449
+ sendJson(res, 201, await deps.db.getTeam(id))
2450
+ }
2451
+
2452
+ // POST /api/admin/teams/rename — rename a team. Body { teamId, name }. 404 if no
2453
+ // such team; 409 if the new name is already taken by another team.
2454
+ async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2455
+ const s = await adminMutation(req, res, deps, cookie)
2456
+ if (s == null) return
2457
+ let body: unknown
2458
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2459
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
2460
+ const rawName = (body as { name?: unknown } | null)?.name
2461
+ const name = typeof rawName === 'string' ? rawName.trim() : ''
2462
+ if (typeof teamId !== 'string' || name === '' || name.length > MAX_TEAM_NAME) { sendJson(res, 400, { error: 'bad-name' }); return }
2463
+ const team = await deps.db.getTeam(teamId)
2464
+ if (await readAdminSession(res, deps, cookie) == null) return
2465
+ const result = await deps.db.renameTeam(teamId, name, Date.now())
2466
+ if (result === 'not-found') { sendJson(res, 404, { error: 'no-team' }); return }
2467
+ if (result === 'name-taken') { sendJson(res, 409, { error: 'name-taken' }); return }
2468
+ if (team?.name !== name) await activity(deps, s.user, 'access', `renamed team ${team?.name ?? teamId} to ${name}`)
2469
+ sendJson(res, 200, { ok: true, name })
2470
+ }
2471
+
2472
+ // Hidden teams retain their configuration but grant no access or sidebar presence.
2473
+ async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2474
+ const s = await adminMutation(req, res, deps, cookie)
2475
+ if (s == null) return
2476
+ let body: unknown
2477
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2478
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
2479
+ const hidden = (body as { hidden?: unknown } | null)?.hidden
2480
+ if (typeof teamId !== 'string' || typeof hidden !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
2481
+ const team = await deps.db.getTeam(teamId)
2482
+ if (await readAdminSession(res, deps, cookie) == null) return
2483
+ if (!(await deps.db.setTeamHidden(teamId, hidden, Date.now()))) { sendJson(res, 404, { error: 'no-team' }); return }
2484
+ if (team?.hidden !== hidden) await activity(deps, s.user, 'access', `${hidden ? 'hid' : 'restored'} team ${team?.name ?? teamId}`)
2485
+ sendJson(res, 200, { ok: true, hidden })
2486
+ }
2487
+
2488
+ // POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
2489
+ async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2490
+ const s = await adminMutation(req, res, deps, cookie)
2491
+ if (s == null) return
2492
+ let body: unknown
2493
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2494
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
2495
+ if (typeof teamId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
2496
+ const team = await deps.db.getTeam(teamId)
2497
+ if (await readAdminSession(res, deps, cookie) == null) return
2498
+ if (!(await deps.db.deleteTeam(teamId))) { sendJson(res, 404, { error: 'no-team' }); return }
2499
+ await activity(deps, s.user, 'access', `deleted team ${team?.name ?? teamId}`)
2500
+ sendJson(res, 200, { ok: true })
2501
+ }
2502
+
2503
+ // POST /api/admin/teams/set-repo — add a repo path. Empty path replaces all
2504
+ // path links with the whole repository. Body { teamId, repoId, path? }.
2505
+ // Repo must be in the selected set.
2506
+ async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2507
+ const s = await adminMutation(req, res, deps, cookie)
2508
+ if (s == null) return
2509
+ let body: unknown
2510
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2511
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
2512
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
2513
+ if (typeof teamId !== 'string' || typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) {
2514
+ sendJson(res, 400, { error: 'bad-request' }); return
2515
+ }
2516
+ const path = normalizeTeamPath((body as { path?: unknown }).path)
2517
+ if (!path.ok) { sendJson(res, 400, { error: 'bad-path' }); return }
2518
+ const team = (await deps.db.listTeams()).find(row => row.id === teamId)
2519
+ if (team == null) { sendJson(res, 404, { error: 'no-team' }); return }
2520
+ if (!(await deps.db.listSelectedRepos()).some((r) => r.repoId === repoId)) { sendJson(res, 400, { error: 'repo-not-selected' }); return }
2521
+ if (await readAdminSession(res, deps, cookie) == null) return
2522
+ await deps.db.setTeamRepo(teamId, repoId, path.path)
2523
+ if (!team.repos.some(repo => repo.repoId === repoId && (!repo.path || repo.path === (path.path ?? '')))) {
2524
+ await activity(deps, s.user, 'access', `granted team ${team.name} access to ${path.path || '/'}`, { repo: await repositoryName(deps, repoId) })
2525
+ }
2526
+ sendJson(res, 200, { ok: true })
2527
+ }
2528
+
2529
+ // POST /api/admin/teams/remove-repo — unlink one path. Omitting path retains
2530
+ // the legacy remove-all behavior. Body { teamId, repoId, path? }.
2531
+ async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2532
+ const s = await adminMutation(req, res, deps, cookie)
2533
+ if (s == null) return
2534
+ let body: unknown
2535
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2536
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
2537
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
2538
+ if (typeof teamId !== 'string' || typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-request' }); return }
2539
+ const rawPath = (body as { path?: unknown }).path
2540
+ if (rawPath !== undefined && rawPath !== null && typeof rawPath !== 'string') { sendJson(res, 400, { error: 'bad-path' }); return }
2541
+ const path = normalizeTeamPath(rawPath)
2542
+ if (!path.ok) { sendJson(res, 400, { error: 'bad-path' }); return }
2543
+ if (await readAdminSession(res, deps, cookie) == null) return
2544
+ if (!(await deps.db.removeTeamRepo(teamId, repoId, rawPath === undefined ? undefined : path.path))) { sendJson(res, 404, { error: 'not-linked' }); return }
2545
+ await activity(deps, s.user, 'access', `removed team ${(await deps.db.getTeam(teamId))?.name ?? teamId}'s access to ${rawPath === undefined ? 'all paths' : path.path || '/'}`, { repo: await repositoryName(deps, repoId) })
2546
+ sendJson(res, 200, { ok: true })
2547
+ }
2548
+
2549
+ // POST /api/admin/teams/set-member — add/update a member + their visibility
2550
+ // permissions. Body { teamId, userId, dependencies?, security? }.
2551
+ async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2552
+ const s = await adminMutation(req, res, deps, cookie)
2553
+ if (s == null) return
2554
+ let body: unknown
2555
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2556
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
2557
+ const userId = (body as { userId?: unknown } | null)?.userId
2558
+ if (typeof teamId !== 'string' || typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
2559
+ const team = (await deps.db.listTeams()).find(row => row.id === teamId)
2560
+ if (team == null) { sendJson(res, 404, { error: 'no-team' }); return }
2561
+ const user = (await deps.db.listUserOptions()).find(row => row.id === userId)
2562
+ if (user == null) { sendJson(res, 404, { error: 'no-user' }); return }
2563
+ const permissions = parseTeamUserPermissions(body)
2564
+ const member = team.members.find(row => row.userId === userId)
2565
+ if (await readAdminSession(res, deps, cookie) == null) return
2566
+ await deps.db.setTeamMember(teamId, userId, permissions)
2567
+ if (!member || member.dependencies !== permissions.dependencies || member.security !== permissions.security) {
2568
+ await activity(deps, s.user, 'access', `set ${user.login}'s membership in ${team.name} (dependencies: ${permissions.dependencies ? 'on' : 'off'}, security: ${permissions.security ? 'on' : 'off'})`)
2569
+ }
2570
+ sendJson(res, 200, { ok: true })
2571
+ }
2572
+
2573
+ // POST /api/admin/teams/remove-member — remove a membership. Body { teamId, userId }.
2574
+ async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2575
+ const s = await adminMutation(req, res, deps, cookie)
2576
+ if (s == null) return
2577
+ let body: unknown
2578
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2579
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
2580
+ const userId = (body as { userId?: unknown } | null)?.userId
2581
+ if (typeof teamId !== 'string' || typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
2582
+ if (await readAdminSession(res, deps, cookie) == null) return
2583
+ if (!(await deps.db.removeTeamMember(teamId, userId))) { sendJson(res, 404, { error: 'not-member' }); return }
2584
+ const team = await deps.db.getTeam(teamId)
2585
+ const user = (await deps.db.listUserOptions()).find(row => row.id === userId)
2586
+ await activity(deps, s.user, 'access', `removed ${user?.login ?? userId} from team ${team?.name ?? teamId}`)
2587
+ sendJson(res, 200, { ok: true })
2588
+ }
2589
+
2590
+ async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2591
+ const s = await adminMutation(req, res, deps, cookie)
2592
+ if (!s) return
2593
+ let body
2594
+ try { body = await readJsonBody(req) as { teamId?: unknown; enabled?: unknown } } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2595
+ if (typeof body?.teamId !== 'string' || typeof body?.enabled !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
2596
+ if (!await deps.db.setTeamDependencyReview(s.session.id, body.teamId, body.enabled)) { sendJson(res, 404, { error: 'no-team' }); return }
2597
+ sendJson(res, 200, { ok: true })
2598
+ }
2599
+
2600
+ // POST /api/admin/teams/set-npm-scopes — replace the npm scopes whose private
2601
+ // packages the team's members may read. Body { teamId, scopes: ['@scope'] }.
2602
+ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2603
+ const s = await adminMutation(req, res, deps, cookie)
2604
+ if (s == null) return
2605
+ let body: unknown
2606
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2607
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
2608
+ if (typeof teamId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
2609
+ const team = await deps.db.getTeam(teamId)
2610
+ const changed = await deps.db.setTeamNpmScopes(s.session.id, teamId, (body as { scopes?: unknown }).scopes)
2611
+ if (changed == null) { sendJson(res, 404, { error: 'no-team' }); return }
2612
+ const parts = [changed.added.length > 0 ? `added ${changed.added.join(', ')}` : '', changed.removed.length > 0 ? `removed ${changed.removed.join(', ')}` : ''].filter(Boolean)
2613
+ if (parts.length > 0) await activity(deps, s.user, 'access', `changed team ${team?.name ?? teamId}'s npm scopes: ${parts.join('; ')}`)
2614
+ sendJson(res, 200, { ok: true, scopes: (await deps.db.listTeamNpmScopes())[teamId] ?? [] })
2615
+ }
2616
+
2617
+ // Who reads npm packages now: a session with workspace access, its role and
2618
+ // the npm scopes of its visible teams. Null for none.
2619
+ async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined): Promise<NpmReader | null> {
2620
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
2621
+ if (s == null || !roleAtLeast(s.user.role, 'view')) return null
2622
+ return { role: s.user.role, scopes: new Set(await deps.db.listUserNpmScopes(s.user.id)), userId: s.user.id }
2623
+ }
2624
+
2625
+ // What a registry read found, or a 404 for the package (handleNpm's catch
2626
+ // answers it) where it found none.
2627
+ function found<T>(value: T | null | undefined): T {
2628
+ if (value == null) throw new NpmPackageError(404, 'package-not-found')
2629
+ return value
2630
+ }
2631
+
2632
+ // Lists must enforce the same target and base access as the review itself,
2633
+ // including permission changes while the registry requests are in flight.
2634
+ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[]): Promise<T[] | null> {
2635
+ const controller = new AbortController()
2636
+ const onClose = () => { if (!res.writableEnded) controller.abort() }
2637
+ res.on('close', onClose)
2638
+ try {
2639
+ const reader = await npmReader(deps, cookie)
2640
+ if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2641
+ const documents = new Map<string, Promise<NpmVersionDocument | null>>()
2642
+ // Public versions' documents as the viewer keeps them (npm-kept.ts), a
2643
+ // day: a list asks npm for none it read within it.
2644
+ const kept = npmVersionStore(deps.db, deps.config.debug)
2645
+ const read = (name: string, version: string) => {
2646
+ const key = `${name}@${version}`
2647
+ if (!documents.has(key)) documents.set(key, readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
2648
+ return documents.get(key)!
2649
+ }
2650
+ const packages = await Promise.all(reviews.map(review => Promise.all([
2651
+ read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
2652
+ ])))
2653
+ const current = await npmReader(deps, cookie)
2654
+ if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2655
+ return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
2656
+ } catch (error) {
2657
+ if (controller.signal.aborted) return null
2658
+ if (error instanceof NpmPackageError) { sendJson(res, error.status, { error: error.message }); return null }
2659
+ throw error
2660
+ } finally { res.off('close', onClose) }
2661
+ }
2662
+
2663
+ const githubRepoOf = (doc: NpmVersionDocument) => (doc.manifest['github'] as { github?: string } | undefined)?.github ?? null
2664
+
2665
+ function npmAdvisoryOptions(deps: ManagedHttpDeps, githubToken: NpmAdvisoryOptions['githubToken'], latestDoc: () => Promise<NpmVersionDocument | null>): NpmAdvisoryOptions {
2666
+ return {
2667
+ githubToken,
2668
+ repo: async () => { const doc = await latestDoc(); return doc && githubRepoOf(doc) },
2669
+ cache: signal => auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug),
2670
+ lastListing: repo => lastListing(deps.config.upstreamCacheDir, deps.db, repo, deps.config.debug),
2671
+ debug: deps.config.debug,
2672
+ }
2673
+ }
2674
+
2675
+ // Read the same target-version evidence as the viewer. Missing upstream
2676
+ // reports impose no extra verdict restriction; detected findings do.
2677
+ async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReader, doc: NpmVersionDocument, signal: AbortSignal) {
2678
+ if (npmTakenDown({ npm: doc })) return 'reject' as const
2679
+ const privileged = canReadPrivateNpm(reader, doc.name)
2680
+ const advisories = async () => {
2681
+ const versions = await readNpmVersions(doc.name, privileged, signal)
2682
+ if (!versions) return null
2683
+ return npmAdvisories(doc.name, versions.versions, npmAdvisoryOptions(deps,
2684
+ () => ensureUserAccessToken(deps.config, deps.db, reader.userId, Date.now()).catch(() => null),
2685
+ () => readNpmVersion(doc.name, 'latest', privileged, signal)))
2686
+ }
2687
+ const [list, socket, risk] = await Promise.all([advisories().catch(() => null), doc.private ? null : npmSocketReport(doc.name, doc.version).catch(() => null),
2688
+ npmRisk(doc, null, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped: true, privileged, signal })])
2689
+ return npmReviewVerdictLimit(doc, list, socket, risk)
2690
+ }
2691
+
2692
+ // Reviews are scoped to the bytes actually displayed, including the base.
2693
+ // Access to both versions is rechecked before returning or saving annotations.
2694
+ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
2695
+ const method = req.method ?? 'GET'
2696
+ if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
2697
+ const mutation = method !== 'GET'
2698
+ const session = mutation ? await checkMutation(req, res, deps, cookie) : await readSession(deps.config, deps.db, cookie, Date.now())
2699
+ if (!session) { if (!mutation) sendJson(res, 401, { error: 'unauthenticated' }); return }
2700
+ if (!roleAtLeast(session.user.role, mutation ? 'triage' : 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
2701
+ let body: Record<string, unknown> = {}
2702
+ if (mutation) {
2703
+ try {
2704
+ const raw = await readJsonBody(req, 128 * 1024)
2705
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('bad-body')
2706
+ body = raw as Record<string, unknown>
2707
+ } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2708
+ }
2709
+ const field = (key: string) => mutation ? body[key] : params.get(key)
2710
+ const name = field('package')
2711
+ const version = field('version')
2712
+ const integrity = field('integrity')
2713
+ const basePackage = field('basePackage') ?? null
2714
+ const baseVersion = field('baseVersion') ?? null
2715
+ const baseIntegrity = field('baseIntegrity') ?? null
2716
+ const validIntegrity = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 1024
2717
+ if (field('ecosystem') !== 'npm' || !isNpmPackageName(name) || !isNpmPackageSpec(version) || !validIntegrity(integrity)
2718
+ || (basePackage !== null || baseVersion !== null || baseIntegrity !== null)
2719
+ && (!isNpmPackageName(basePackage) || !isNpmPackageSpec(baseVersion) || !validIntegrity(baseIntegrity))) {
2720
+ sendJson(res, 400, { error: 'bad-review-context' }); return
2721
+ }
2722
+ const context: ReviewContext = { ecosystem: 'npm', package: name, version, integrity,
2723
+ basePackage: basePackage as string | null, baseVersion: baseVersion as string | null, baseIntegrity: baseIntegrity as string | null }
2724
+ let change: ReviewChange | null = null
2725
+ if (mutation) {
2726
+ const action = body['action'], reviewId = body['reviewId'] ?? null, revision = body['revision'] ?? null
2727
+ if (typeof action !== 'string' || !['start', 'save', 'publish', 'continue', 'revoke'].includes(action)
2728
+ || (reviewId !== null && (typeof reviewId !== 'string' || reviewId.length === 0 || reviewId.length > 100 || !Number.isSafeInteger(revision) || Number(revision) < 1))
2729
+ || (!['save', 'start'].includes(action) && reviewId === null) || (action === 'start' && reviewId !== null)) { sendJson(res, 400, { error: 'bad-review' }); return }
2730
+ if (action === 'start') change = { action, reviewId: null, revision: null }
2731
+ else if (action === 'publish') {
2732
+ const teamIds = body['teamIds']
2733
+ if (!Array.isArray(teamIds) || teamIds.length === 0 || teamIds.length > 100 || teamIds.some(id => typeof id !== 'string' || id.length > 100)) {
2734
+ sendJson(res, 400, { error: 'bad-review-teams' }); return
2735
+ }
2736
+ const finalComment = body['finalComment'] ?? '', finalStatus = body['finalStatus'] ?? null
2737
+ if (typeof finalComment !== 'string' || finalComment.length > 20000 || finalStatus !== null
2738
+ && (typeof finalStatus !== 'string' || !['accept', 'accept-with-nits', 'request-changes', 'reject'].includes(finalStatus))) {
2739
+ sendJson(res, 400, { error: 'bad-final-verdict' }); return
2740
+ }
2741
+ change = { action, reviewId: reviewId as string, revision: revision as number, teamIds, files: [], nonTextFiles: [],
2742
+ final: finalStatus === null ? null : { status: finalStatus as ReviewInput['status'] & string, comment: finalComment } }
2743
+ } else if (action === 'continue' || action === 'revoke') change = { action, reviewId: reviewId as string, revision: revision as number }
2744
+ else {
2745
+ const file = body['file']
2746
+ const comment = body['comment']
2747
+ const status = body['status'] ?? null
2748
+ const entryId = body['entryId'] ?? null
2749
+ if (typeof file !== 'string' || !file || file.length > 4096 || file.includes('\0')
2750
+ || (entryId !== null && (typeof entryId !== 'string' || entryId.length === 0 || entryId.length > 100))
2751
+ || typeof comment !== 'string' || comment.length > 20000
2752
+ || (status !== null && (typeof status !== 'string' || !['accept', 'accept-with-nits', 'request-changes', 'reject'].includes(status))) || (status === null && entryId === null)) {
2753
+ sendJson(res, 400, { error: 'bad-review' }); return
2754
+ }
2755
+ change = { action: 'save', reviewId: reviewId as string | null, revision: revision as number | null, entryId: entryId as string | null,
2756
+ entry: { file, comment, status: status as ReviewInput['status'] } }
2757
+ }
2758
+ }
2759
+ const controller = new AbortController()
2760
+ const onClose = () => { if (!res.writableEnded) controller.abort() }
2761
+ res.on('close', onClose)
2762
+ try {
2763
+ const reader = await npmReader(deps, cookie)
2764
+ if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2765
+ const kept = npmVersionStore(deps.db, deps.config.debug)
2766
+ const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
2767
+ const [doc, base] = await Promise.all([read(name, version), context.basePackage ? read(context.basePackage, context.baseVersion!) : null])
2768
+ if (!doc || (context.basePackage && !base)) { sendJson(res, 404, { error: 'package-not-found' }); return }
2769
+ if (doc.version !== version || doc.dist.integrity !== integrity
2770
+ || (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
2771
+ sendJson(res, 409, { error: 'review-content-changed' }); return
2772
+ }
2773
+ if (change?.action === 'publish' || change?.action === 'save') {
2774
+ const loads = [doc, ...base ? [base] : []].map(loadNpmFiles)
2775
+ try {
2776
+ const files = await Promise.all(loads.map(load => load.result))
2777
+ const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
2778
+ const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
2779
+ const diff = computeBundleDiff(before, after, () => '.')
2780
+ const changed: { path: string; basePath?: string }[] = [...diff.files.onlyBase, ...diff.files.onlyOther, ...diff.files.changed]
2781
+ const reviewable = (file: typeof changed[number]) => isReviewableFile(before.get(file.basePath ?? file.path), after.get(file.path))
2782
+ const reviewedPath = change.action === 'save' ? change.entry.file : null
2783
+ const reviewedFile = changed.find(file => file.path === reviewedPath)
2784
+ if (change.action === 'save' && !reviewedFile) { sendJson(res, 400, { error: 'unknown-review-file' }); return }
2785
+ if (change.action === 'save' && change.entry.status !== null && reviewedFile && !reviewable(reviewedFile)) { sendJson(res, 400, { error: 'non-text-review-file' }); return }
2786
+ if (change.action === 'publish') {
2787
+ change.files = changed.map(file => file.path)
2788
+ change.nonTextFiles = changed.filter(file => !reviewable(file)).map(file => file.path)
2789
+ }
2790
+ } finally { loads.forEach(load => load.release()) }
2791
+ }
2792
+ const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
2793
+ ? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
2794
+ const current = await npmReader(deps, cookie)
2795
+ if (!current || current.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2796
+ if ([doc, base].some(item => item?.private && !canReadPrivateNpm(current, item.name))) { sendJson(res, 404, { error: 'package-not-found' }); return }
2797
+ if (!change) {
2798
+ const allBases = params.get('allBases') === 'true'
2799
+ const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
2800
+ const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
2801
+ if (reviews) sendJson(res, 200, { reviews, teams: await deps.db.listDependencyReviewTeams(current.userId) })
2802
+ return
2803
+ }
2804
+ const checked = await checkMutation(req, res, deps, cookie)
2805
+ if (!checked) return
2806
+ if (checked.user.id !== current.userId || !roleAtLeast(checked.user.role, 'triage')) { sendJson(res, 403, { error: 'forbidden' }); return }
2807
+ if (change.action === 'publish' && change.final && !allowedReviewVerdicts([], limit).includes(change.final.status)) {
2808
+ sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
2809
+ }
2810
+ const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
2811
+ sendJson(res, 200, { review })
2812
+ } catch (err) {
2813
+ if (controller.signal.aborted) return
2814
+ if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
2815
+ if (err instanceof NpmRiskError) { sendRiskError(res, err); return }
2816
+ throw err
2817
+ } finally { res.off('close', onClose) }
2818
+ }
2819
+
2820
+ // GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only}. Only readers with private access
2821
+ // (canReadPrivateNpm) can be answered from the server's npm token; the rest get
2822
+ // what the registry answers anonymously, every time (see npm-packages.ts).
2823
+ // Access is checked again after the registry's answer.
2824
+ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string, params: URLSearchParams): Promise<void> {
2825
+ if (req.method !== 'GET') { send405(res, 'GET'); return }
2826
+ const name = params.get('name'), spec = params.get('version') ?? 'latest'
2827
+ // All but the package's own (its versions, figures and advisories) are a version's.
2828
+ const versioned = path !== NPM_VERSIONS_PATH && path !== NPM_STATS_PATH && path !== NPM_ADVISORIES_PATH
2829
+ const otherSpec = params.get('otherVersion') ?? ''
2830
+ if (!isNpmPackageName(name) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
2831
+ || (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
2832
+ || (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
2833
+ sendJson(res, 400, { error: 'bad-package' }); return
2834
+ }
2835
+ const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
2836
+ if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
2837
+ const reader = await npmReader(deps, cookie)
2838
+ if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2839
+ const controller = new AbortController()
2840
+ const onClose = () => { if (!res.writableEnded) controller.abort() }
2841
+ res.on('close', onClose)
2842
+ // A reader who lost their session, or their private access to an answer
2843
+ // that needed it, while the registry was asked gets nothing.
2844
+ const recheck = async (isPrivate: boolean) => {
2845
+ const current = await npmReader(deps, cookie)
2846
+ if (current == null) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
2847
+ if (isPrivate && !canReadPrivateNpm(current, name)) { sendJson(res, 404, { error: 'package-not-found' }); return false }
2848
+ return !res.destroyed
2849
+ }
2850
+ const reply = async (isPrivate: boolean, body: Record<string, unknown>) => { if (await recheck(isPrivate)) sendJson(res, 200, body) }
2851
+ // The reader's GitHub token, for GitHub's rate limits and never for what it
2852
+ // shows: asked for once, and only where an answer isn't kept (npm-insights.ts).
2853
+ let token: Promise<string | null> | undefined
2854
+ const githubToken = () => token ??= ensureUserAccessToken(deps.config, deps.db, reader.userId, Date.now()).catch(() => null)
2855
+ const privileged = canReadPrivateNpm(reader, name)
2856
+ // Public versions' documents, kept in the database (npm-kept.ts), as
2857
+ // repository risk scans keep them: an exact version's, read on every view,
2858
+ // asked of npm once a day.
2859
+ const keptVersions = npmVersionStore(deps.db, deps.config.debug)
2860
+ // The latest version, read once a request: the package's figures and
2861
+ // advisories go by the repository it names.
2862
+ let latest: Promise<NpmVersionDocument | null> | undefined
2863
+ const latestDoc = () => latest ??= readNpmVersion(name, 'latest', privileged, controller.signal, keptVersions)
2864
+ const advisoryOptions = npmAdvisoryOptions(deps, githubToken, latestDoc)
2865
+ // The figures of the repository the latest version names, noted as its
2866
+ // repository for npmKnown; null where it names none, or they can't be had.
2867
+ const githubFigures = (doc: NpmVersionDocument) => {
2868
+ const repo = githubRepoOf(doc)
2869
+ npmLatestRepo(name, repo)
2870
+ return repo === null ? null : npmGithubStats(repo, githubToken).catch(() => null)
2871
+ }
2872
+ try {
2873
+ // With whether a visible team lists its scope (`teamScoped`): its own
2874
+ // releases, which the viewer doesn't flag as just published.
2875
+ if (path === NPM_VERSIONS_PATH) {
2876
+ const scope = npmPackageScope(name)
2877
+ const [versions, teamScoped] = await Promise.all([readNpmVersions(name, privileged, controller.signal, { times: true }),
2878
+ scope !== null && deps.db.hasTeamNpmScope(scope)])
2879
+ await reply(found(versions).private, { ...versions, teamScoped })
2880
+ return
2881
+ }
2882
+ // Across every published version, as the version list has them, which
2883
+ // each advisory's `affected` indexes; those of the repository its latest
2884
+ // version names, where GitHub says it is public, asked with the reader's
2885
+ // GitHub token, its listing kept as bundle advisories keep it.
2886
+ if (path === NPM_ADVISORIES_PATH) {
2887
+ const versions = found(await readNpmVersions(name, privileged, controller.signal))
2888
+ await reply(versions.private, { name, ...await npmAdvisories(name, versions.versions, advisoryOptions) })
2889
+ return
2890
+ }
2891
+ // The package's, with the repository its latest version names; either
2892
+ // is null where it can't be had.
2893
+ if (path === NPM_STATS_PATH) {
2894
+ // npm's downloads, which need nothing of the version, are asked at once;
2895
+ // none is answered before the package's access is.
2896
+ const downloadsAsked = npmDownloads(name).catch(() => null)
2897
+ const doc = found(await latestDoc())
2898
+ const [downloads, github] = await Promise.all([downloadsAsked, githubFigures(doc)])
2899
+ await reply(doc.private, { name, downloads, github })
2900
+ return
2901
+ }
2902
+ const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptVersions))
2903
+ if (spec === 'latest') latest = Promise.resolve(doc)
2904
+ const repo = githubRepoOf(doc), sha = bundleCommitHash(doc.manifest['gitHead'])
2905
+ // What the viewer asks about a version beside its files, by its route,
2906
+ // and asks for meanwhile below. Its publish commit's tags, where its
2907
+ // repository is public on GitHub and the reader has a token to ask with.
2908
+ // Socket's report, asked only where npm answers for the version without a
2909
+ // token: a private package's name never leaves for Socket. npm's
2910
+ // provenance, asked only where its version says npm keeps one, and npm
2911
+ // answers for the version without a token.
2912
+ const tagsOf = () => repo === null || sha === null ? Promise.resolve([]) : npmCommitTags(repo, sha, doc.version, githubToken)
2913
+ const socketOf = () => doc.private ? Promise.resolve(null) : npmSocketReport(name, doc.version)
2914
+ const provenanceOf = () => doc.private || doc.manifest['provenance'] !== true ? Promise.resolve(null) : npmProvenance(name, doc.version, doc.dist.integrity)
2915
+ // None where tags or Socket's report can't be had; a failure to have
2916
+ // provenance is answered as one, for the viewer to ask again.
2917
+ if (path === NPM_TAGS_PATH) { await reply(doc.private, { name, version: doc.version, tags: await tagsOf().catch(() => []) }); return }
2918
+ if (path === NPM_SOCKET_PATH) { await reply(doc.private, { name, version: doc.version, socket: await socketOf().catch(() => null) }); return }
2919
+ // What calls for a look at the version, as risk tells it (npm-risk.ts),
2920
+ // against `otherVersion` where given, as Compare asks, else against the
2921
+ // one risk picks of its versions, as the Overview asks; trusted however
2922
+ // new and however few download it where a team lists its scope, its own
2923
+ // packages, as a new dependency on a package of such a scope is.
2924
+ if (path === NPM_RISK_PATH) {
2925
+ const old = otherSpec === '' || otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
2926
+ const scope = npmPackageScope(name)
2927
+ const teamScoped = scope !== null && await deps.db.hasTeamNpmScope(scope)
2928
+ const assessment = await npmRisk(doc, otherSpec === '' ? 'pick' : old, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped, fromEmpty: otherSpec === '(empty)',
2929
+ ownScope: listed => deps.db.hasTeamNpmScope(listed), privileged, signal: controller.signal })
2930
+ // What it tells is read of both versions: private where either is.
2931
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
2932
+ await reply(doc.private || old?.private === true, { ...assessment })
2933
+ return
2934
+ }
2935
+ if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
2936
+ // Every one of its files that needs it pretty-printed (pretty-print.ts
2937
+ // npmAll), or, against another version, the files that changed between
2938
+ // them, both sides' (compared), a line each as it is formatted, or found
2939
+ // kept for a public version; its files read before anything is answered.
2940
+ // A reader who lost their session, or their access, meanwhile has the
2941
+ // rest cut short.
2942
+ if (path === NPM_PRETTY_PATH && params.has('path')) {
2943
+ // One of its files pretty-printed, as one of a bundle's is.
2944
+ const file = prettyRequest(params)
2945
+ if (!file) { sendJson(res, 400, { error: 'bad-file' }); return }
2946
+ if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
2947
+ const copy = await deps.prettyCache.npm(doc, file)
2948
+ if (!(await recheck(doc.private))) { copy.stream.destroy(); return }
2949
+ await sendEncoded(req, res, copy, 'text/plain; charset=utf-8', prettySource(file))
2950
+ return
2951
+ }
2952
+ // Against another version, the pairs of its files that changed that the
2953
+ // viewer diffs with renamed names hidden (rename-diff.ts renamedPairs), a
2954
+ // line each as it is worked out, as Compare's pretty-printed files are;
2955
+ // and those that changed in types alone (type-only.ts).
2956
+ if (path === NPM_PRETTY_PATH || path === NPM_TYPE_ONLY_PATH || (path === NPM_RENAME_DIFF_PATH && !diffed)) {
2957
+ if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
2958
+ const other = otherSpec === '' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
2959
+ const sides = other && await deps.prettyCache.npmSides(doc, other)
2960
+ const lines = path === NPM_RENAME_DIFF_PATH ? renamedPairs(sides!, !doc.private && !other!.private)
2961
+ : path === NPM_TYPE_ONLY_PATH ? typeOnlyFiles(sides!) : sides ? compared(sides) : await deps.prettyCache.npmAll(doc)
2962
+ const isPrivate = doc.private || other?.private === true
2963
+ if (!(await recheck(isPrivate))) return
2964
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
2965
+ await sendLines(res, lines, async () => {
2966
+ const current = await npmReader(deps, cookie)
2967
+ return current != null && (!isPrivate || canReadPrivateNpm(current, name))
2968
+ })
2969
+ return
2970
+ }
2971
+ // One of its JavaScript files diffed with another version's, both
2972
+ // pretty-printed, renamed names hidden (rename-diff.ts).
2973
+ if (diffed) {
2974
+ const { prettyCache } = deps
2975
+ if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
2976
+ const other = found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
2977
+ const diff = await renameDiffOf(diffed, async () => [await prettyText(await prettyCache.npm(doc, diffed.base)),
2978
+ await prettyText(await prettyCache.npm(other, diffed.other))], !doc.private && !other.private)
2979
+ if (!(await recheck(doc.private || other.private))) { diff.stream.destroy(); return }
2980
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
2981
+ await sendEncoded(req, res, diff, 'application/json')
2982
+ return
2983
+ }
2984
+ // The load stays held until this response is written or abandoned, and
2985
+ // a reader who stops reading it gives it up after a while. One already
2986
+ // gone takes none, as its close has passed.
2987
+ if (res.destroyed) return
2988
+ const download = path === NPM_DOWNLOAD_PATH
2989
+ // A version's files take longest to load: what the viewer asks for beside
2990
+ // them is asked for meanwhile, so that what is answered by the time they
2991
+ // are encoded comes with them (npmKnown), and the rest is on its way when
2992
+ // the viewer asks. Nothing waits on it, and a failure is left for the
2993
+ // viewer's own request to meet. Its whole document, read for its
2994
+ // advisories' version list and the time each version was published
2995
+ // (npmPublishTime), is read only where registry reads leave room for
2996
+ // readers'.
2997
+ if (!download) {
2998
+ const quiet = (asked: Promise<unknown>) => { asked.catch(() => {}) }
2999
+ quiet(npmDownloads(name))
3000
+ quiet(latestDoc().then(newest => newest && githubFigures(newest)))
3001
+ quiet(readNpmVersions(name, privileged, controller.signal, { times: true, spare: true })
3002
+ .then(versions => versions && npmAdvisories(name, versions.versions, advisoryOptions)))
3003
+ quiet(tagsOf())
3004
+ quiet(socketOf())
3005
+ quiet(provenanceOf())
3006
+ }
3007
+ const load = download ? loadNpmTarball(doc) : loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha))
3008
+ res.once('close', load.release)
3009
+ const bytes = await load.result
3010
+ if (!(await recheck(doc.private))) return
3011
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
3012
+ res.writeHead(200, {
3013
+ ...download
3014
+ ? { 'content-type': 'application/gzip', 'content-disposition': attachmentDisposition(npmTarballFilename(doc.name, doc.version)) }
3015
+ : { 'content-type': 'application/json', 'content-encoding': 'br' },
3016
+ 'content-length': String(bytes.byteLength), 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
3017
+ })
3018
+ writeResponse(res, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength))
3019
+ } catch (err) {
3020
+ if (err instanceof NpmPackageError || err instanceof PrettyError) { if (!res.headersSent && !res.destroyed) sendJson(res, err.status, { error: err.message }); return }
3021
+ if (err instanceof NpmRiskError) { if (!res.headersSent && !res.destroyed) sendRiskError(res, err); return }
3022
+ if (controller.signal.aborted) return
3023
+ throw err
3024
+ } finally {
3025
+ res.off('close', onClose)
3026
+ }
3027
+ }
3028
+
3029
+ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, id?: string) {
3030
+ if (!deps.config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
3031
+ const method = req.method ?? 'GET'
3032
+ const allowed = id ? ['PATCH', 'DELETE'] : ['GET', 'POST', 'DELETE']
3033
+ if (!allowed.includes(method)) { send405(res, allowed.join(', ')); return }
3034
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
3035
+ if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
3036
+ if (!requireManageRole(res, s.user)) return
3037
+ const { db } = deps
3038
+ if (method === 'GET') {
3039
+ const shares = await db.listWorkspaceShares(s.session.id, Date.now(), teamId)
3040
+ sendJson(res, shares ? 200 : 404, shares ? { shares } : { error: 'no-team' }); return
3041
+ }
3042
+ let body
3043
+ if (method === 'POST' || method === 'PATCH') {
3044
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
3045
+ }
3046
+ const permissions = parseTeamUserPermissions(body), token = method === 'POST' ? randomToken() : ''
3047
+ const shareId = id ?? hashToken(token)
3048
+ const ok = method === 'POST' ? await db.createWorkspaceShare(s.session.id, Date.now(), teamId, shareId, permissions)
3049
+ : method === 'PATCH' ? await db.updateWorkspaceShare(s.session.id, Date.now(), teamId, shareId, permissions)
3050
+ : await db.revokeWorkspaceShares(s.session.id, Date.now(), teamId, id)
3051
+ if (!ok) { sendJson(res, 404, { error: 'no-share' }); return }
3052
+ const team = await db.getTeam(teamId)
3053
+ if (!team) { sendJson(res, 404, { error: 'no-team' }); return }
3054
+ const action = method === 'POST' ? 'created a public link' : method === 'PATCH' ? 'updated a public link' : id ? 'revoked a public link' : 'revoked public links'
3055
+ const access = method === 'DELETE' ? '' : ` (dependencies: ${permissions.dependencies ? 'on' : 'off'}, security: ${permissions.security ? 'on' : 'off'})`
3056
+ await activity(deps, s.user, 'access', `${action} for team ${team.name}${access}`)
3057
+ sendJson(res, 200, method === 'POST' ? { id: shareId, path: `/team/${team.slug}#public=${shareId.slice(0, 8)}.${token}` } : { ok: true })
3058
+ }
3059
+
3060
+ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3061
+ const { config, db, avatarStore, isShuttingDown, track } = deps
3062
+ const repositoryDiscovery = new RepositoryDiscovery(config)
3063
+
3064
+ async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
3065
+ const url = new URL(req.url ?? '/', 'http://localhost')
3066
+ const path = url.pathname
3067
+ const method = req.method ?? 'GET'
3068
+ const cookie = req.headers.cookie
3069
+ const viewing = viewToken(config, cookie) != null
3070
+
3071
+ if (req.headers['x-deepview-share'] !== undefined || path.startsWith('/api/shares/')) {
3072
+ await handlePublicWorkspace(req, res, deps, url); return
3073
+ }
3074
+
3075
+ // Public mode probe — lets a client detect the managed protocol up front.
3076
+ if (path === CONFIG_PATH) {
3077
+ if (method !== 'GET') { send405(res, 'GET'); return }
3078
+ const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
3079
+ sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
3080
+ managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
3081
+ uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
3082
+ } : {}) } })
3083
+ return
3084
+ }
3085
+ if (viewing && underPrefix(path, VIEW_PREFIXES) && !viewAllows(method, path)) { sendViewOnly(res); return }
3086
+ // OAuth: start → redirect to GitHub with the CSRF state cookie.
3087
+ if (path === LOGIN_PATH) {
3088
+ if (method !== 'GET') { send405(res, 'GET'); return }
3089
+ const { location, setCookie } = buildLoginRedirect(config)
3090
+ res.writeHead(302, { location, 'set-cookie': setCookie, 'cache-control': 'no-store' })
3091
+ res.end()
3092
+ return
3093
+ }
3094
+ // OAuth: callback (the GitHub hook) → mint a session, land on the app.
3095
+ if (path === CALLBACK_PATH) {
3096
+ if (method !== 'GET') { send405(res, 'GET'); return }
3097
+ try {
3098
+ if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
3099
+ if (viewing) { sendViewOnly(res); return }
3100
+ const s = await readWorkspaceSession(res, deps, cookie)
3101
+ if (!s) return
3102
+ const result = await issueOAuthCallback(config, db, s.session, url.searchParams, cookie)
3103
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
3104
+ res.end(); return
3105
+ }
3106
+ const result = await handleCallback(url.searchParams, cookie, { config, db, avatarStore })
3107
+ // A new sign-in never resumes an earlier view.
3108
+ const setCookies = viewing ? [...result.setCookies, clearViewCookie(config)] : result.setCookies
3109
+ res.writeHead(302, { location: result.location, 'set-cookie': setCookies, 'cache-control': 'no-store' })
3110
+ res.end()
3111
+ } catch (err) {
3112
+ if (err instanceof OAuthError) sendJson(res, err.status, { error: err.message })
3113
+ else throw err
3114
+ }
3115
+ return
3116
+ }
3117
+ if (path === ISSUE_LOGIN_PATH) {
3118
+ if (method !== 'GET') { send405(res, 'GET'); return }
3119
+ const s = await readWorkspaceSession(res, deps, cookie)
3120
+ if (!s) return
3121
+ const result = issueLoginRedirect(config, s.session)
3122
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
3123
+ res.end(); return
3124
+ }
3125
+ // Who am I? While an admin views as another user, that user, plus the
3126
+ // admin as `viewer`. A view that has ended (with the admin's session or
3127
+ // role, or the viewed account) is cleared, returning to the own session.
3128
+ if (path === SESSION_PATH) {
3129
+ if (method !== 'GET') { send405(res, 'GET'); return }
3130
+ const now = Date.now()
3131
+ let s = await readSession(config, db, cookie, now)
3132
+ const ended = s == null && viewing
3133
+ if (ended) s = await readSession(config, db, cookie, now, { own: true })
3134
+ const headers: Record<string, string> = ended ? { 'set-cookie': clearViewCookie(config) } : {}
3135
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
3136
+ sendJson(res, 200, {
3137
+ user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
3138
+ csrfToken: s.session.csrfToken,
3139
+ ...(s.session.viewer ? { viewer: s.session.viewer } : {}),
3140
+ }, headers)
3141
+ return
3142
+ }
3143
+ if (path === VIEW_AS_PATH) { await handleViewAs(req, res, deps, cookie); return }
3144
+ // Authentication is not workspace access. Keep bootstrap/session/logout
3145
+ // available so blocked accounts can see their role and sign out, but deny
3146
+ // every managed data route before reading bodies or looking up resources.
3147
+ const managedDataPath = underPrefix(path, MANAGED_DATA_PREFIXES)
3148
+ const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
3149
+ if (managedDataPath && !workspaceSession) return
3150
+ const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
3151
+ if (issueRoute) {
3152
+ await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
3153
+ }
3154
+ const deduplication = /^\/api\/admin\/deduplication(?:\/([a-f\d-]{36}))?$/iu.exec(path)
3155
+ if (deduplication) { await handleDeduplication(req, res, deps, cookie, deduplication[1]); return }
3156
+ const repositoryAliases = /^\/api\/admin\/repositories\/aliases(?:\/([a-f\d-]{36}))?$/iu.exec(path)
3157
+ if (repositoryAliases) { await handleRepositoryAliases(req, res, deps, cookie, repositoryAliases[1]); return }
3158
+ if (path === '/api/admin/repositories/resolve') { await handleRepositorySuggestion(req, res, deps, cookie); return }
3159
+ if (path === '/api/admin/links') {
3160
+ if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
3161
+ if (method !== 'GET') { send405(res, 'GET'); return }
3162
+ const s = await readSession(config, db, cookie, Date.now())
3163
+ if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3164
+ if (!requireManageRole(res, s.user)) return
3165
+ const shares = await db.listManagedWorkspaceShares(s.session.id, Date.now())
3166
+ sendJson(res, shares ? 200 : 403, shares ? { shares } : { error: 'forbidden' }); return
3167
+ }
3168
+ const shareRoute = /^\/api\/teams\/([^/]+)\/share(?:\/([A-Za-z0-9_-]{43}))?$/u.exec(path)
3169
+ if (shareRoute) {
3170
+ await handleWorkspaceShare(req, res, deps, cookie, shareRoute[1]!, shareRoute[2]); return
3171
+ }
3172
+ if (path === '/api/admin/uploads/key') { await handleUploadKey(req, res, deps, cookie); return }
3173
+ if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
3174
+ // Cached avatar by user id, served same-origin (the page CSP forbids the
3175
+ // github CDN). The id in the path keys the browser cache per user, so a user
3176
+ // switch never serves a stale avatar. Workspace access is required.
3177
+ if (path.startsWith(AVATAR_PREFIX)) {
3178
+ if (method !== 'GET') { send405(res, 'GET'); return }
3179
+ const s = await readSession(config, db, cookie, Date.now())
3180
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3181
+ await serveAvatar(res, avatarStore, path.slice(AVATAR_PREFIX.length))
3182
+ return
3183
+ }
3184
+ // Admin: list users (admin-only).
3185
+ if (path === ADMIN_HISTORY_PATH) {
3186
+ if (method !== 'GET') { send405(res, 'GET'); return }
3187
+ await handleHistory(res, deps, cookie, url.searchParams); return
3188
+ }
3189
+ if (path === ADMIN_USERS_PATH) {
3190
+ if (method !== 'GET') { send405(res, 'GET'); return }
3191
+ const s = await readSession(config, db, cookie, Date.now())
3192
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3193
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
3194
+ sendJson(res, 200, { users: await db.listUsers() })
3195
+ return
3196
+ }
3197
+ if (path === ADMIN_SCAN_MODELS_PATH) {
3198
+ if (method !== 'GET') { send405(res, 'GET'); return }
3199
+ await handleListModels(res, deps, cookie); return
3200
+ }
3201
+ if (path === SET_ROLE_PATH) { await handleSetRole(req, res, deps, cookie); return }
3202
+ if (path === REPO_IMPACT_PATH) {
3203
+ if (method !== 'GET') { send405(res, 'GET'); return }
3204
+ await handleRepositoryImpact(req, res, deps, cookie); return
3205
+ }
3206
+ if (path === REMOVE_REPO_PATH) {
3207
+ if (method !== 'POST') { send405(res, 'POST'); return }
3208
+ await handleRemoveRepository(req, res, deps, cookie); return
3209
+ }
3210
+ if (path === REPO_RISK_PATH) {
3211
+ if (method !== 'GET') { send405(res, 'GET'); return }
3212
+ await handleRepositoryRisk(res, deps, cookie, url.searchParams); return
3213
+ }
3214
+ if (path === BROWSABLE_REPOS_PATH) {
3215
+ if (method !== 'GET') { send405(res, 'GET'); return }
3216
+ await handleBrowsableRepositories(res, deps, cookie); return
3217
+ }
3218
+ if (path === REPO_REFS_PATH || path === REPO_CONTENTS_PATH) {
3219
+ if (method !== 'GET') { send405(res, 'GET'); return }
3220
+ await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
3221
+ }
3222
+ if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
3223
+ if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
3224
+ if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
3225
+ if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
3226
+ // Reports: list / upload on the exact path, download / delete per-id on the
3227
+ // prefix. Method-dispatched here since each path carries two verbs.
3228
+ if (path === ADMIN_REPORTS_PATH) {
3229
+ if (method === 'GET') { await handleListReports(res, deps, workspaceSession!.session); return }
3230
+ if (method === 'POST') { await handleUploadReport(req, res, deps, cookie); return }
3231
+ send405(res, 'GET, POST'); return
3232
+ }
3233
+ // set-repo is an exact sub-path; check it before the per-id prefix (a report
3234
+ // id is a uuid, so it never collides with "set-repo").
3235
+ if (path === REPORT_SET_REPO_PATH) {
3236
+ if (method !== 'POST') { send405(res, 'POST'); return }
3237
+ await handleSetReportRepo(req, res, deps, cookie); return
3238
+ }
3239
+ if (path === REPORT_SET_VISIBLE_PATH) {
3240
+ if (method !== 'POST') { send405(res, 'POST'); return }
3241
+ await handleSetReportVisible(req, res, deps, cookie); return
3242
+ }
3243
+ if (path === '/api/admin/reports/finding-ids') {
3244
+ if (method !== 'GET') { send405(res, 'GET'); return }
3245
+ await handleImportFindingIds(res, deps, cookie, url.searchParams); return
3246
+ }
3247
+ const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
3248
+ if (importTriage) {
3249
+ if (method !== 'POST') { send405(res, 'POST'); return }
3250
+ await handleImportTriage(req, res, deps, cookie, importTriage[1]!); return
3251
+ }
3252
+ const reportLocation = /^\/api\/admin\/reports\/([a-f\d-]{36})\/location$/iu.exec(path)
3253
+ if (reportLocation) { await handleReportLocationSuggestion(req, res, deps, cookie, reportLocation[1]!); return }
3254
+ if (path.startsWith(REPORT_PREFIX)) {
3255
+ const id = path.slice(REPORT_PREFIX.length)
3256
+ if (method === 'GET') { await handleGetReport(res, deps, cookie, id); return }
3257
+ if (method === 'DELETE') { await handleDeleteReport(req, res, deps, cookie, id); return }
3258
+ send405(res, 'GET, DELETE'); return
3259
+ }
3260
+ // Bundles: list / upload on the exact path, download / delete per-id on the
3261
+ // prefix (same shape as reports).
3262
+ const bundleAdvisories = /^\/api\/bundles\/([a-f\d-]{36})\/advisories$/iu.exec(path)
3263
+ if (bundleAdvisories) {
3264
+ if (method !== 'GET') { send405(res, 'GET'); return }
3265
+ await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
3266
+ return
3267
+ }
3268
+ const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only)$/iu.exec(path)
3269
+ if (bundleRead) {
3270
+ const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
3271
+ if (part === 'rename-diff' || part === 'type-only' || (part === 'pretty' && url.searchParams.has('other'))) {
3272
+ if (method !== 'GET') { send405(res, 'GET'); return }
3273
+ if (part === 'rename-diff' && url.searchParams.has('path')) { await handleBundleRenameDiff(req, res, deps, session, id, url.searchParams); return }
3274
+ // The pairs a scoped Compare diffs, of the files it compares within its scope.
3275
+ const [linesOf, scope] = part === 'pretty' ? [compared, ''] : [part === 'type-only' ? typeOnlyFiles : renamedPairs, url.searchParams.get('scope') ?? '']
3276
+ await handleBundleChanges(res, deps, session, id, url.searchParams.get('other') ?? '', async (cache, base, other) => linesOf(await cache.bundleSides(base, other, scope))); return
3277
+ }
3278
+ if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
3279
+ if (part === 'download') { await handleGetBundle(req, res, deps, session, id); return }
3280
+ if (part !== 'pretty') { await handleBundleCache(req, res, deps, session, id, bundleCache && (rec => bundleCache.open(rec, part as BundleCachePart))); return }
3281
+ const file = prettyRequest(url.searchParams)
3282
+ if (!file) { sendJson(res, 400, { error: 'bad-file' }); return }
3283
+ await handleBundleCache(req, res, deps, session, id, prettyCache && (rec => prettyCache.bundle(rec, file)), 'text/plain; charset=utf-8', prettySource(file))
3284
+ return
3285
+ }
3286
+ if (path === BUNDLE_CREATE_PATH) {
3287
+ if (method !== 'POST') { send405(res, 'POST'); return }
3288
+ await handleCreateBundle(req, res, deps, cookie); return
3289
+ }
3290
+ if (path === ADMIN_BUNDLES_PATH) {
3291
+ if (method === 'GET') { await handleListBundles(res, deps, workspaceSession!.session); return }
3292
+ if (method === 'POST') { await handleUploadBundle(req, res, deps, cookie); return }
3293
+ send405(res, 'GET, POST'); return
3294
+ }
3295
+ if (path === BUNDLE_SET_REPO_PATH) {
3296
+ if (method !== 'POST') { send405(res, 'POST'); return }
3297
+ await handleSetBundleRepo(req, res, deps, cookie); return
3298
+ }
3299
+ if (path === BUNDLE_SET_VISIBLE_PATH) {
3300
+ if (method !== 'POST') { send405(res, 'POST'); return }
3301
+ await handleSetBundleVisible(req, res, deps, cookie); return
3302
+ }
3303
+ if (path.startsWith(BUNDLE_PREFIX)) {
3304
+ const id = path.slice(BUNDLE_PREFIX.length)
3305
+ if (method === 'GET') { await handleGetBundle(req, res, deps, workspaceSession!.session, id); return }
3306
+ if (method === 'DELETE') { await handleDeleteBundle(req, res, deps, cookie, id); return }
3307
+ send405(res, 'GET, DELETE'); return
3308
+ }
3309
+ const teamFeed = /^\/api\/teams\/([^/]+)\/feed$/u.exec(path)
3310
+ if (teamFeed || path === '/api/teams/feed') {
3311
+ if (method !== 'GET') { send405(res, 'GET'); return }
3312
+ const s = await readWorkspaceSession(res, deps, cookie)
3313
+ if (!s) return
3314
+ const teamId = teamFeed?.[1] ?? null
3315
+ const reportId = url.searchParams.get('reportId')
3316
+ if (teamId) await teamSnapshot(db, s.session.id, teamId, reportId)
3317
+ await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId, { reportId }); return
3318
+ }
3319
+ const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
3320
+ if (teamFixes) {
3321
+ if (method !== 'GET') { send405(res, 'GET'); return }
3322
+ await handleWorkspaceFixes(res, deps, cookie, teamFixes[1]!, url.searchParams.get('reportId')); return
3323
+ }
3324
+ const teamAnnotations = /^\/api\/teams\/([^/]+)\/annotations$/u.exec(path)
3325
+ if (teamAnnotations) {
3326
+ if (method !== 'GET') { send405(res, 'GET'); return }
3327
+ const snapshot = await teamSnapshot(db, workspaceSession!.session.id, teamAnnotations[1]!, url.searchParams.get('reportId'))
3328
+ const annotations = await loadTeamAnnotations(db, deps.reportStore, snapshot, url.searchParams.get('reportId'))
3329
+ await recheckTeam(db, workspaceSession!.session.id, snapshot)
3330
+ sendJson(res, 200, annotations); return
3331
+ }
3332
+ const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
3333
+ if (teamReports) {
3334
+ if (method !== 'GET') { send405(res, 'GET'); return }
3335
+ await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId')); return
3336
+ }
3337
+ if (path === REPORT_QUERY_PATH) {
3338
+ if (method !== 'POST') { send405(res, 'POST'); return }
3339
+ await handleQueryReports(req, res, deps, cookie); return
3340
+ }
3341
+ const sourcesRoute = /^\/api\/reports\/([^/]+)\/sources$/u.exec(path)
3342
+ if (sourcesRoute) {
3343
+ if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
3344
+ await handleReportSources(req, res, deps, cookie, sourcesRoute[1]!, url.searchParams.get('team')); return
3345
+ }
3346
+ const commentsRoute = /^\/api\/reports\/([^/]+)\/comments(?:\/([^/]+))?$/u.exec(path)
3347
+ if (commentsRoute) {
3348
+ await handleReportComments(req, res, deps, cookie, commentsRoute[1]!, commentsRoute[2] ?? null, url.searchParams.get('team')); return
3349
+ }
3350
+ // Per-finding triage on a viewable report. The '/triage' suffixes are
3351
+ // matched before the bare per-id slice below (a report id is a uuid, so it
3352
+ // never ends in them) — same trick as REPORT_SET_REPO_PATH above; the
3353
+ // longer '/triage/history' goes first.
3354
+ if (path.startsWith(MY_REPORT_PREFIX) && path.endsWith(MY_REPORT_TRIAGE_HISTORY_SUFFIX)) {
3355
+ const id = path.slice(MY_REPORT_PREFIX.length, -MY_REPORT_TRIAGE_HISTORY_SUFFIX.length)
3356
+ if (method !== 'GET') { send405(res, 'GET'); return }
3357
+ await handleGetReportTriageHistory(res, deps, cookie, id, url.searchParams, url.searchParams.get('team')); return
3358
+ }
3359
+ if (path.startsWith(MY_REPORT_PREFIX) && path.endsWith(MY_REPORT_TRIAGE_SUFFIX)) {
3360
+ const id = path.slice(MY_REPORT_PREFIX.length, -MY_REPORT_TRIAGE_SUFFIX.length)
3361
+ if (method === 'GET') { await handleGetReportTriage(res, deps, cookie, id, url.searchParams.get('team')); return }
3362
+ if (method === 'POST') { await handleSetReportTriage(req, res, deps, cookie, id, url.searchParams.get('team')); return }
3363
+ send405(res, 'GET, POST'); return
3364
+ }
3365
+ // Individual report previews are reserved for admins/managers.
3366
+ if (path.startsWith(MY_REPORT_PREFIX)) {
3367
+ if (method !== 'GET') { send405(res, 'GET'); return }
3368
+ await handleViewReport(req, res, deps, cookie, path.slice(MY_REPORT_PREFIX.length)); return
3369
+ }
3370
+ // The signed-in user's own team memberships (any approved user).
3371
+ if (path === MY_TEAMS_PATH) {
3372
+ if (method !== 'GET') { send405(res, 'GET'); return }
3373
+ await handleMyTeams(res, deps, workspaceSession!.session); return
3374
+ }
3375
+ // Teams: list / create on the exact path; the link mutations are POST-only
3376
+ // action sub-paths (each carries its ids in the JSON body).
3377
+ if (path === ADMIN_TEAMS_PATH) {
3378
+ if (method === 'GET') { await handleListTeams(res, deps, cookie); return }
3379
+ if (method === 'POST') { await handleCreateTeam(req, res, deps, cookie); return }
3380
+ send405(res, 'GET, POST'); return
3381
+ }
3382
+ if (path === TEAM_DELETE_PATH || path === TEAM_RENAME_PATH || path === TEAM_SET_HIDDEN_PATH || path === TEAM_SET_REPO_PATH || path === TEAM_REMOVE_REPO_PATH
3383
+ || path === TEAM_SET_MEMBER_PATH || path === TEAM_REMOVE_MEMBER_PATH) {
3384
+ if (method !== 'POST') { send405(res, 'POST'); return }
3385
+ if (path === TEAM_DELETE_PATH) { await handleDeleteTeam(req, res, deps, cookie); return }
3386
+ if (path === TEAM_RENAME_PATH) { await handleRenameTeam(req, res, deps, cookie); return }
3387
+ if (path === TEAM_SET_HIDDEN_PATH) { await handleSetTeamHidden(req, res, deps, cookie); return }
3388
+ if (path === TEAM_SET_REPO_PATH) { await handleSetTeamRepo(req, res, deps, cookie); return }
3389
+ if (path === TEAM_REMOVE_REPO_PATH) { await handleRemoveTeamRepo(req, res, deps, cookie); return }
3390
+ if (path === TEAM_SET_MEMBER_PATH) { await handleSetTeamMember(req, res, deps, cookie); return }
3391
+ await handleRemoveTeamMember(req, res, deps, cookie); return
3392
+ }
3393
+ if (path === '/api/admin/teams/set-dependency-review') {
3394
+ if (method !== 'POST') { send405(res, 'POST'); return }
3395
+ await handleSetTeamDependencyReview(req, res, deps, cookie); return
3396
+ }
3397
+ if (path === TEAM_SET_NPM_SCOPES_PATH) {
3398
+ if (method !== 'POST') { send405(res, 'POST'); return }
3399
+ await handleSetTeamNpmScopes(req, res, deps, cookie); return
3400
+ }
3401
+ if (path === '/api/npm/reviews/latest') {
3402
+ if (method !== 'GET') { send405(res, 'GET'); return }
3403
+ const s = await readWorkspaceSession(res, deps, cookie)
3404
+ if (!s) return
3405
+ const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
3406
+ if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
3407
+ sendJson(res, 400, { error: 'bad-review-context' }); return
3408
+ }
3409
+ const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
3410
+ const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
3411
+ if (visible) sendJson(res, 200, { review: visible[0] ?? null })
3412
+ return
3413
+ }
3414
+ if (path === '/api/npm/reviews/discard') {
3415
+ if (method !== 'POST') { send405(res, 'POST'); return }
3416
+ const s = await checkMutation(req, res, deps, cookie)
3417
+ if (!s) return
3418
+ let body: Record<string, unknown>
3419
+ try {
3420
+ const raw = await readJsonBody(req, 4096)
3421
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('bad-body')
3422
+ body = raw as Record<string, unknown>
3423
+ } catch { sendJson(res, 400, { error: 'bad-body' }); return }
3424
+ const id = body['reviewId'], revision = body['revision']
3425
+ if (typeof id !== 'string' || !id || id.length > 100 || !Number.isSafeInteger(revision) || Number(revision) < 1) {
3426
+ sendJson(res, 400, { error: 'bad-review' }); return
3427
+ }
3428
+ await deps.db.discardDependencyReview(s.session.id, id, revision as number)
3429
+ sendJson(res, 200, { ok: true }); return
3430
+ }
3431
+ if (path === '/api/npm/reviews/mine') {
3432
+ if (method !== 'GET') { send405(res, 'GET'); return }
3433
+ const s = await readWorkspaceSession(res, deps, cookie)
3434
+ if (!s) return
3435
+ const id = url.searchParams.get('beforeId'), time = url.searchParams.get('beforeTime')
3436
+ if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
3437
+ sendJson(res, 400, { error: 'bad-review-cursor' }); return
3438
+ }
3439
+ const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
3440
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
3441
+ if (reviews) sendJson(res, 200, { ...page, reviews })
3442
+ return
3443
+ }
3444
+ if (path === '/api/npm/reviews') {
3445
+ await handleNpmReviews(req, res, deps, cookie, url.searchParams); return
3446
+ }
3447
+ if (NPM_PATHS.has(path)) {
3448
+ await handleNpm(req, res, deps, cookie, path, url.searchParams); return
3449
+ }
3450
+ if (path === LOGOUT_PATH) { await handleLogout(req, res, deps, cookie); return }
3451
+ if (deps.serveStatic?.(req, res)) return
3452
+ if (deps.next) { deps.next(req, res); return }
3453
+ sendJson(res, 404, { error: 'not-found' }, { connection: 'close' })
3454
+ }
3455
+
3456
+ return (req, res) => {
3457
+ if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
3458
+ if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return }
3459
+ // Feeds lease a connection per poll, never across streaming sleeps. This
3460
+ // also covers capability-authenticated feeds handled by public-workspace.
3461
+ const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
3462
+ const serve = () => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)
3463
+ const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
3464
+ if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
3465
+ console.warn('managed: request handler error:', err)
3466
+ if (res.headersSent) { try { res.destroy() } catch {} }
3467
+ else sendJson(res, 500, { error: 'internal' })
3468
+ })
3469
+ track(work)
3470
+ return work
3471
+ }
3472
+ }