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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +2 -0
  50. package/common/managed/finding-detail.js +32 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +5 -0
  54. package/common/managed/npm-packages.js +34 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +347 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +5 -0
  64. package/common/managed/team-app.js +31 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2WDZPXVL.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-4XMP7YVH.js +36 -0
  87. package/out/chunk-6LNQTKAV.js +52 -0
  88. package/out/chunk-6S2U2UYG.js +232 -0
  89. package/out/chunk-75YLHBCM.js +3 -0
  90. package/out/chunk-7BKKYVWR.js +7 -0
  91. package/out/chunk-7C7DWL7I.js +1 -0
  92. package/out/chunk-7VAWINQF.js +6 -0
  93. package/out/chunk-C4Z6ZBXV.js +10 -0
  94. package/out/chunk-FC65TPUB.js +1 -0
  95. package/out/chunk-FKBXNUW4.js +1 -0
  96. package/out/chunk-GDPCLSOS.js +2 -0
  97. package/out/chunk-GO7FT4OD.js +1 -0
  98. package/out/chunk-HTBYHSPG.js +1 -0
  99. package/out/chunk-IYPY47MC.js +1 -0
  100. package/out/chunk-IZ7POWI3.js +18 -0
  101. package/out/chunk-JCIIBLKR.js +1 -0
  102. package/out/chunk-NRPC74AM.js +1 -0
  103. package/out/chunk-OOJM4CTU.js +1 -0
  104. package/out/chunk-PBZPYIG7.js +1 -0
  105. package/out/chunk-PNHBZGDD.js +1 -0
  106. package/out/chunk-PWFUOLZK.js +17 -0
  107. package/out/chunk-PXK7EU4V.js +1 -0
  108. package/out/chunk-QB4CBOX2.js +1 -0
  109. package/out/chunk-R3NZFXVR.js +1 -0
  110. package/out/chunk-R5O6FAW4.js +2 -0
  111. package/out/chunk-SXV2T5C4.js +80 -0
  112. package/out/chunk-TQJDYHVE.js +2 -0
  113. package/out/chunk-TUII34K2.js +1 -0
  114. package/out/chunk-UK7RGJT6.js +3 -0
  115. package/out/chunk-VFWMQ3LG.js +2 -0
  116. package/out/chunk-VH4XMZIW.js +2 -0
  117. package/out/chunk-WCPJDAYA.js +10 -0
  118. package/out/chunk-WFYLMF6Z.js +10 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +362 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +21 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +73 -0
  237. package/server-managed/comparison.ts +41 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +458 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +142 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +100 -0
  249. package/server-managed/dependency-reviews.ts +346 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +415 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +39 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5329 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/locked-packages.ts +129 -0
  273. package/server-managed/lockfile-scope.ts +49 -0
  274. package/server-managed/lru.ts +30 -0
  275. package/server-managed/managed-issues.ts +78 -0
  276. package/server-managed/management-catalog.ts +45 -0
  277. package/server-managed/management.ts +99 -0
  278. package/server-managed/npm-delta-worker.js +23 -0
  279. package/server-managed/npm-delta.ts +194 -0
  280. package/server-managed/npm-insights.ts +449 -0
  281. package/server-managed/npm-kept.ts +215 -0
  282. package/server-managed/npm-loads.ts +264 -0
  283. package/server-managed/npm-manifest.ts +115 -0
  284. package/server-managed/npm-packages.ts +454 -0
  285. package/server-managed/npm-provenance.ts +123 -0
  286. package/server-managed/npm-risk.ts +285 -0
  287. package/server-managed/object-storage-disk.ts +148 -0
  288. package/server-managed/object-storage-vercel.ts +67 -0
  289. package/server-managed/object-storage.ts +75 -0
  290. package/server-managed/package-entry-points.ts +51 -0
  291. package/server-managed/pretty-print.ts +434 -0
  292. package/server-managed/public-workspace.ts +238 -0
  293. package/server-managed/pull-comment-reviews.ts +161 -0
  294. package/server-managed/pull-dependencies.ts +348 -0
  295. package/server-managed/pull-dependency-reviews.ts +328 -0
  296. package/server-managed/pull-reviews.ts +205 -0
  297. package/server-managed/queue.ts +39 -0
  298. package/server-managed/release-commits.ts +84 -0
  299. package/server-managed/rename-diff-worker.js +26 -0
  300. package/server-managed/rename-diff.ts +279 -0
  301. package/server-managed/repo-path.ts +21 -0
  302. package/server-managed/report-migration.ts +35 -0
  303. package/server-managed/report-query.ts +10 -0
  304. package/server-managed/report-response.ts +16 -0
  305. package/server-managed/report-sources.ts +178 -0
  306. package/server-managed/report-store.ts +85 -0
  307. package/server-managed/repository-actions.ts +218 -0
  308. package/server-managed/repository-agents.ts +355 -0
  309. package/server-managed/repository-aliases.ts +91 -0
  310. package/server-managed/repository-browser.ts +294 -0
  311. package/server-managed/repository-changes.ts +284 -0
  312. package/server-managed/repository-code.ts +215 -0
  313. package/server-managed/repository-dependency-reviews.ts +165 -0
  314. package/server-managed/repository-discovery.ts +82 -0
  315. package/server-managed/repository-history.ts +432 -0
  316. package/server-managed/repository-issues.ts +195 -0
  317. package/server-managed/repository-policy.ts +27 -0
  318. package/server-managed/repository-pulls.ts +156 -0
  319. package/server-managed/repository-pulse.ts +460 -0
  320. package/server-managed/repository-risk-assess.ts +105 -0
  321. package/server-managed/repository-risk-audit.ts +79 -0
  322. package/server-managed/repository-risk-read.ts +170 -0
  323. package/server-managed/repository-risk.ts +491 -0
  324. package/server-managed/repository-squashes.ts +168 -0
  325. package/server-managed/request-timing.ts +63 -0
  326. package/server-managed/reviewed-dependencies.ts +150 -0
  327. package/server-managed/revisions.ts +35 -0
  328. package/server-managed/scan-downloads.ts +87 -0
  329. package/server-managed/session.ts +132 -0
  330. package/server-managed/slugs.ts +38 -0
  331. package/server-managed/solidity-entry-points.ts +71 -0
  332. package/server-managed/sql-postgres.ts +34 -0
  333. package/server-managed/sql.ts +88 -0
  334. package/server-managed/static.ts +28 -0
  335. package/server-managed/storage-command.ts +16 -0
  336. package/server-managed/storage-db.ts +183 -0
  337. package/server-managed/storage-encryption.ts +290 -0
  338. package/server-managed/storage-maintenance.ts +259 -0
  339. package/server-managed/storage-payload.ts +121 -0
  340. package/server-managed/storage-stores.ts +91 -0
  341. package/server-managed/storage.ts +82 -0
  342. package/server-managed/team-app.ts +134 -0
  343. package/server-managed/team-catalog.ts +7 -0
  344. package/server-managed/team-feed.ts +180 -0
  345. package/server-managed/team-npm-scopes.ts +106 -0
  346. package/server-managed/team-reports.ts +307 -0
  347. package/server-managed/triage-response.ts +16 -0
  348. package/server-managed/type-only-worker.js +19 -0
  349. package/server-managed/type-only.ts +105 -0
  350. package/server-managed/uploads.ts +106 -0
  351. package/server-managed/upstream-cache.ts +243 -0
  352. package/server-managed/view-as.ts +58 -0
  353. package/server-managed/workspace-shares.ts +169 -0
  354. package/server.ts +70 -0
  355. package/server/index.ts +0 -480
  356. package/server/lifecycle.ts +0 -208
  357. /package/{LICENSE → LICENSE-MIT} +0 -0
  358. /package/{server → server-e2e}/auth.ts +0 -0
  359. /package/{server → server-e2e}/config.example.json +0 -0
  360. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  361. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  362. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  363. /package/{server → server-e2e}/peer.ts +0 -0
  364. /package/{server → server-e2e}/sse-session.ts +0 -0
  365. /package/{server → server-e2e}/util.ts +0 -0
  366. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,134 @@
1
+ // Whether each team's sidebar entry opens collapsed to its App findings,
2
+ // decided before the catalog is sent so the first paint is already final. A
3
+ // team is classified from the same filtered, published workspace its view
4
+ // loads. Results are keyed by the catalog's own content keys: an unchanged
5
+ // catalog reuses them, and every change that can move a result also changes
6
+ // the catalog revision that clients refresh on.
7
+ //
8
+ // The same read names the reports a restricted reader would find nothing in:
9
+ // under -deps or -security the catalog leaves those out of the team's list.
10
+ // Readers with both permissions keep every report, empty or not.
11
+ //
12
+ // A team that is not App also says how many of its reports open in App mode
13
+ // on their own, and its findings for the badge it collapses to. None is as
14
+ // uniform as an App team, and collapses like one. A team mixing App and non-App
15
+ // reports stays expanded, and so does one whose reports each open as App but
16
+ // whose combined workspace does not (contradicting verdicts, say): it has App
17
+ // findings, and only expanded does it show that they fail to combine.
18
+ import { reportEntries } from '@preventive/report'
19
+ import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
20
+ import type { BlobStore } from './blob-store.ts'
21
+ import type { ManagedDb, TeamReportAccessSnapshot, UserTeam } from './db.ts'
22
+ import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
23
+ import { TeamReportsError, loadTeamReportsResponse } from './team-reports.ts'
24
+
25
+ export type { TeamAppMetadata }
26
+ export interface TeamWorkspaceSummary { app: TeamAppMetadata; empty: readonly string[] }
27
+ const NO_APP: TeamWorkspaceSummary = { app: { appMode: false }, empty: [] }
28
+
29
+ const CACHE_MAX_ENTRIES = 4096
30
+ // Each workspace build already reads four reports at a time.
31
+ const CLASSIFY_CONCURRENCY = 2
32
+ const caches = new WeakMap<ManagedDb, Map<string, TeamWorkspaceSummary>>()
33
+ const pendingJobs = new WeakMap<ManagedDb, Map<string, Promise<TeamWorkspaceSummary | null>>>()
34
+
35
+ // Published reports with their content keys, and the team's own key (access,
36
+ // scopes and links). Equal keys describe the same filtered workspace, whoever
37
+ // reads it, so accounts with the same access share one classification.
38
+ export function teamAppKey(team: UserTeam): string {
39
+ return JSON.stringify([team.cacheKey ?? null, published(team).map(report => [report.id, report.cacheKey]).toSorted()])
40
+ }
41
+
42
+ function published(team: UserTeam) {
43
+ return team.reports.filter(report => report.visible !== false)
44
+ }
45
+
46
+ function remember(db: ManagedDb, key: string, summary: TeamWorkspaceSummary): TeamWorkspaceSummary {
47
+ let cache = caches.get(db)
48
+ if (!cache) { cache = new Map(); caches.set(db, cache) }
49
+ cache.delete(key)
50
+ cache.set(key, summary)
51
+ for (const oldest of cache.keys()) {
52
+ if (cache.size <= CACHE_MAX_ENTRIES) break
53
+ cache.delete(oldest)
54
+ }
55
+ return summary
56
+ }
57
+
58
+ function isLinks(data: unknown): boolean {
59
+ const record = data as { source?: unknown; links?: unknown } | null
60
+ return record?.source === 'links' && Array.isArray(record.links)
61
+ }
62
+
63
+ // A links document counts by its links, every other report by its findings.
64
+ function hasContent(data: unknown): boolean {
65
+ if (isLinks(data)) return (data as { links: unknown[] }).links.length > 0
66
+ return (reportEntries(data) ?? []).some(entry => !Array.isArray(entry) || entry.length > 0)
67
+ }
68
+
69
+ // Null when the team cannot be classified right now: its catalog went stale,
70
+ // the access snapshot is gone, or storage failed. The sidebar then keeps the
71
+ // team expanded and lists every report, and a later catalog asks again.
72
+ // Oversized and unreadable workspaces never have an App view, so they are
73
+ // remembered as such, hiding nothing.
74
+ async function classify(db: ManagedDb, store: BlobStore, key: string, team: UserTeam,
75
+ snapshot: () => Promise<TeamReportAccessSnapshot | null>): Promise<TeamWorkspaceSummary | null> {
76
+ const cached = caches.get(db)?.get(key)
77
+ if (cached) return remember(db, key, cached)
78
+ const ids = new Set(published(team).map(report => report.id))
79
+ if (ids.size === 0) return remember(db, key, NO_APP)
80
+ let jobs = pendingJobs.get(db)
81
+ if (!jobs) { jobs = new Map(); pendingJobs.set(db, jobs) }
82
+ const existing = jobs.get(key)
83
+ if (existing) return existing
84
+ const job = (async () => {
85
+ const access = await snapshot()
86
+ // A catalog read before a concurrent publication names another report set.
87
+ if (!access?.teamId || access.reports.length !== ids.size || access.reports.some(report => !ids.has(report.id))) return null
88
+ const body = JSON.parse((await loadTeamReportsResponse(db, store, access)).toString('utf8')) as {
89
+ reports: { id: string; filename: string; data: unknown }[]; links?: string[][]
90
+ }
91
+ const restricted = access.reports.some(report => !report.permissions.dependencies || !report.permissions.security)
92
+ const empty = restricted ? body.reports.filter(report => !hasContent(report.data)).map(report => report.id) : []
93
+ const links = body.links ?? []
94
+ const app = managedTeamAppMetadata(Object.assign(body.reports, { links }), { countFindings: true })
95
+ if (app.appMode) return remember(db, key, { app, empty })
96
+ const appReports = body.reports.filter(report => !isLinks(report.data)
97
+ && managedTeamAppMetadata(Object.assign([report], { links })).appMode).length
98
+ return remember(db, key, { app: { ...app, appReports }, empty })
99
+ })().catch((error: unknown) => {
100
+ if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return remember(db, key, NO_APP)
101
+ if (!(error instanceof TeamReportsError)) console.warn('managed: team App classification failed:', error)
102
+ return null
103
+ })
104
+ jobs.set(key, job)
105
+ try { return await job } finally { jobs.delete(key) }
106
+ }
107
+
108
+ // The catalog's view of a classified team: its App classification, and its
109
+ // reports without the ones its restricted reader would find empty.
110
+ export function withWorkspaceSummary<T extends { reports: readonly { id: string }[] }>(team: T, summary: TeamWorkspaceSummary | null | undefined) {
111
+ const empty = new Set(summary?.empty)
112
+ return { ...team, app: summary?.app ?? null, reports: team.reports.filter(report => !empty.has(report.id)) }
113
+ }
114
+
115
+ // Classify a user's catalog, keyed by teamAppKey. `known` carries results from
116
+ // an earlier read of the same request, so only changed teams are classified.
117
+ export async function teamAppStates(db: ManagedDb, store: BlobStore, sessionId: string, teams: readonly UserTeam[],
118
+ known: ReadonlyMap<string, TeamWorkspaceSummary | null> = new Map()): Promise<Map<string, TeamWorkspaceSummary | null>> {
119
+ const states = new Map(known)
120
+ const missing = [...new Map(teams.map(team => [teamAppKey(team), team])).entries()].filter(([key]) => !states.has(key))
121
+ let next = 0
122
+ await Promise.all(Array.from({ length: Math.min(CLASSIFY_CONCURRENCY, missing.length) }, async () => {
123
+ while (next < missing.length) {
124
+ const [key, team] = missing[next++]!
125
+ states.set(key, await classify(db, store, key, team, () => db.getTeamReportAccessSnapshot(sessionId, Date.now(), team.id)))
126
+ }
127
+ }))
128
+ return states
129
+ }
130
+
131
+ // A public link's workspace is its own access snapshot.
132
+ export function sharedTeamApp(db: ManagedDb, store: BlobStore, snapshot: WorkspaceShareSnapshot): Promise<TeamWorkspaceSummary | null> {
133
+ return classify(db, store, JSON.stringify(['share', teamAppKey(snapshot.team)]), snapshot.team, () => Promise.resolve(snapshot))
134
+ }
@@ -0,0 +1,7 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // Version only this user's visible catalog, including access and content keys.
4
+ // REST and the live feed must describe exactly the same snapshot.
5
+ export function teamCatalogRevision(teams: readonly unknown[]): string {
6
+ return createHash('sha256').update(JSON.stringify(teams)).digest('base64url')
7
+ }
@@ -0,0 +1,180 @@
1
+ import type { ServerResponse } from 'node:http'
2
+ import { setTimeout as delay } from 'node:timers/promises'
3
+ import type { ManagedHttpDeps } from './http.ts'
4
+ import type { StoredUser, TeamReportAccessSnapshot } from './db.ts'
5
+ import { TeamReportsError, annotationRevisionDigest, teamAnnotationRevision, teamSnapshotKey, teamWorkspaceFindingIds } from './team-reports.ts'
6
+ import type { WorkspaceShareFeedState } from './workspace-shares.ts'
7
+
8
+ // Short database reads work across instances without pinning a Neon connection.
9
+ // All polling belongs to the awaited request; nothing runs after it completes.
10
+ const TEAM_FEED_POLL_MS = 3_000
11
+ export const TEAM_FEED_LIFETIME_MS = 240_000 // below the configured Vercel 300s
12
+ const HEARTBEAT_MS = 15_000
13
+
14
+ type FeedOptions = { pollMs?: number; lifetimeMs?: number }
15
+ type Publish = (event: 'teams' | 'triage', revision: string | undefined) => boolean
16
+
17
+ // Public capabilities remain scoped to their single shared workspace.
18
+ export async function serveTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
19
+ snapshot: TeamReportAccessSnapshot, recheck: () => Promise<void>,
20
+ options: FeedOptions & { readState?: () => Promise<WorkspaceShareFeedState | null> } = {}): Promise<void> {
21
+ let ids: string[] | undefined
22
+ let previous: WorkspaceShareFeedState | undefined
23
+ const sameAccess = (a: WorkspaceShareFeedState, b?: WorkspaceShareFeedState) => a.grant === b?.grant && a.catalog === b.catalog
24
+ const readState = async () => {
25
+ const state = await options.readState!()
26
+ if (!state) throw new TeamReportsError(404, 'workspace-changed')
27
+ return state
28
+ }
29
+ await serveFeed(res, deps, async publish => {
30
+ const state = options.readState ? await readState() : undefined
31
+ // The first poll still compares the full snapshot loaded before the stream.
32
+ // Unchanged polls validate the live grant without reading report catalogs.
33
+ if (state && sameAccess(state, previous)) {
34
+ if (state.annotations === previous!.annotations) return
35
+ } else await recheck()
36
+ ids ??= [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
37
+ const triage = await deps.db.getAnnotationRevision(ids)
38
+ const after = options.readState ? await readState() : undefined
39
+ if (!after || !sameAccess(after, state)) await recheck()
40
+ publish('triage', triage)
41
+ // A write during the read must remain pending for the following poll.
42
+ if (after?.annotations === state?.annotations) previous = after
43
+ }, options)
44
+ }
45
+
46
+ // Membership and content notifications cover all of the user's teams. Only
47
+ // the focused team needs report parsing or annotation reads; null is the
48
+ // catalog-only subscription used on landing and management pages.
49
+ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
50
+ sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions & { reportId?: string | null } = {}): Promise<void> {
51
+ let ids: string[] = [], visibilityKey: string | undefined
52
+ let focused: TeamReportAccessSnapshot | undefined
53
+ let previousState: { catalog: number; annotations: number } | undefined
54
+ const checkUser = (current: { user: Pick<StoredUser, 'id' | 'role'> } | null) => {
55
+ if (!current) throw new TeamReportsError(401, 'unauthenticated')
56
+ if (current.user.id !== user.id || current.user.role !== user.role || current.user.role === 'none') {
57
+ throw new TeamReportsError(403, 'access-changed')
58
+ }
59
+ }
60
+ await serveFeed(res, deps, async publish => {
61
+ const state = await deps.db.getFeedState(sessionId, Date.now())
62
+ checkUser(state)
63
+ if (state!.catalog === previousState?.catalog && (!teamId || state!.annotations === previousState?.annotations)) return
64
+ if (teamId && focused !== undefined && state!.catalog === previousState?.catalog) {
65
+ // Annotation changes cannot change visibility. Read only this workspace's
66
+ // IDs, then fence against concurrent access/content changes before sending.
67
+ const triage = await teamAnnotationRevision(deps.db, focused, ids)
68
+ const after = await deps.db.getFeedState(sessionId, Date.now())
69
+ checkUser(after)
70
+ if (after!.catalog !== state!.catalog) return
71
+ publish('triage', triage)
72
+ if (after!.annotations === state!.annotations) previousState = state!
73
+ return
74
+ }
75
+ const catalog = await deps.db.getUserTeamFeedSnapshot(sessionId, Date.now())
76
+ checkUser(catalog)
77
+ // The session read again after the catalog, as the catalog is published
78
+ // next: one statement, as the poll's own check is.
79
+ checkUser(await deps.db.getFeedState(sessionId, Date.now()))
80
+ // Publish catalog state before loading any report blobs. A slow or broken
81
+ // focused report must not hide membership/content changes from the client.
82
+ if (!publish('teams', catalog!.revision)) return
83
+ if (!teamId) {
84
+ // A mutation during the snapshot must cause a fresh read next poll.
85
+ const current = await deps.db.getFeedState(sessionId, Date.now())
86
+ checkUser(current)
87
+ if (current!.catalog === state!.catalog) previousState = state!
88
+ return
89
+ }
90
+ let triage: string | undefined
91
+ const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
92
+ if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
93
+ checkUser(snapshot)
94
+ const key = teamSnapshotKey(snapshot)
95
+ if (key !== visibilityKey) {
96
+ try {
97
+ ids = snapshot.teamId ? [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)] : []
98
+ visibilityKey = key
99
+ } catch {
100
+ // Missing, malformed or temporarily unavailable blobs only suspend
101
+ // triage. Retry visibility on the next poll, even for the same key.
102
+ publish('triage', undefined)
103
+ return
104
+ }
105
+ }
106
+ // As GET /api/teams/:id/annotations answers it, so that a client holding
107
+ // that answer knows this tells nothing new.
108
+ if (snapshot.teamId) triage = await teamAnnotationRevision(deps.db, snapshot, ids)
109
+ const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
110
+ // A concurrent access/content change discards only the annotation read.
111
+ checkUser(current)
112
+ if (!current || teamSnapshotKey(current) !== key) return
113
+ publish('triage', triage)
114
+ focused = snapshot.teamId ? snapshot : undefined
115
+ const after = await deps.db.getFeedState(sessionId, Date.now())
116
+ checkUser(after)
117
+ if (after!.catalog === state!.catalog && after!.annotations === state!.annotations) previousState = state!
118
+ }, { ...options, triageRevision: true })
119
+ }
120
+
121
+ // With `triageRevision`, a triage event tells its revision's digest, as the
122
+ // focused team's annotations answer it; else none, as a public share's.
123
+ async function serveFeed(res: ServerResponse, deps: ManagedHttpDeps, read: (publish: Publish) => Promise<void>,
124
+ { pollMs = TEAM_FEED_POLL_MS, lifetimeMs = TEAM_FEED_LIFETIME_MS, triageRevision = false }: FeedOptions & { triageRevision?: boolean }): Promise<void> {
125
+ const controller = new AbortController()
126
+ const close = () => controller.abort()
127
+ res.once('close', close)
128
+ const deadline = setTimeout(close, lifetimeMs)
129
+ const stopped = () => controller.signal.aborted || res.destroyed || deps.isShuttingDown()
130
+ // Notifications carry no annotation bodies, finding IDs, or global cursors.
131
+ // The catalog's opaque version, and a focused team's annotation revision's
132
+ // digest, let clients reuse an identical REST snapshot; every connection
133
+ // still confirms current access, including after reconnects.
134
+ function write(frame: string): boolean {
135
+ if (stopped()) return false
136
+ if (res.write(frame)) return true
137
+ // Slow consumers reconnect and read current state; never queue a backlog.
138
+ res.destroy()
139
+ return false
140
+ }
141
+ try {
142
+ if (stopped()) return
143
+ res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8',
144
+ 'cache-control': 'private, no-store, no-transform', 'x-accel-buffering': 'no',
145
+ 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' })
146
+ res.flushHeaders()
147
+ let heartbeat = Date.now()
148
+ const previous = new Map<string, string>()
149
+ const publish: Publish = (event, revision) => {
150
+ if (stopped()) return false
151
+ if (revision === undefined) { previous.delete(event); return true }
152
+ if (revision === previous.get(event)) return true
153
+ const data = event === 'teams' ? JSON.stringify({ revision }) : triageRevision ? JSON.stringify({ revision: annotationRevisionDigest(revision) }) : '{}'
154
+ if (!write(`event: ${event}\ndata: ${data}\n\n`)) return false
155
+ previous.set(event, revision)
156
+ heartbeat = Date.now()
157
+ return true
158
+ }
159
+ while (!stopped()) {
160
+ if (deps.db.withRequest) await deps.db.withRequest(() => read(publish))
161
+ else await read(publish)
162
+ if (stopped()) break
163
+ if (Date.now() - heartbeat >= HEARTBEAT_MS) {
164
+ if (!write(': keepalive\n\n')) break
165
+ heartbeat = Date.now()
166
+ }
167
+ await delay(pollMs, undefined, { signal: controller.signal }).catch(error => {
168
+ if (!controller.signal.aborted) throw error
169
+ })
170
+ }
171
+ } catch (error) {
172
+ if (!res.headersSent) throw error
173
+ if (error instanceof TeamReportsError) write('event: close\ndata: {}\n\n')
174
+ else if (!stopped()) throw error
175
+ } finally {
176
+ clearTimeout(deadline)
177
+ res.off('close', close)
178
+ if (res.headersSent && !res.destroyed) res.end()
179
+ }
180
+ }
@@ -0,0 +1,106 @@
1
+ // npm scopes a team lists: its members, whatever their role, may read that
2
+ // scope's private packages in the npm viewer (npm-packages.ts), as admins and
3
+ // managers may read any. Hidden teams grant nothing, as for their content.
4
+ import { MAX_TEAM_NPM_SCOPES, normalizeNpmScope } from '../common/managed/npm-packages.js'
5
+ import type { Role } from '../common/managed/roles.ts'
6
+ import { gathered, reviewsIn } from './dependency-reviews.ts'
7
+ import { ManagedMutationError } from './management.ts'
8
+ import type { ManagedSql } from './sql.ts'
9
+
10
+ export const TEAM_NPM_SCOPE_SCHEMA = `
11
+ CREATE TABLE IF NOT EXISTS managed_team_npm_scope (
12
+ team_id TEXT NOT NULL REFERENCES managed_team(id) ON DELETE CASCADE,
13
+ scope TEXT NOT NULL,
14
+ PRIMARY KEY (team_id, scope)
15
+ ) STRICT;
16
+ `
17
+
18
+ export interface TeamNpmScopeStore {
19
+ // Every team's scopes, sorted, keyed by team id; teams without any are absent.
20
+ listTeamNpmScopes(): Promise<Record<string, string[]>>
21
+ // Replaces a team's scopes; admin only. Answers what changed, or null for no team.
22
+ setTeamNpmScopes(sessionId: string, teamId: string, scopes: unknown): Promise<{ added: string[]; removed: string[] } | null>
23
+ // The scopes a user's visible teams list.
24
+ listUserNpmScopes(userId: string): Promise<string[]>
25
+ // Whether any visible team lists the scope: whoever reads its packages,
26
+ // they are the instance's own, whose new releases aren't flagged (http.ts).
27
+ hasTeamNpmScope(scope: string): Promise<boolean>
28
+ // Who session `sessionId` names now, as readSession reads it, their own
29
+ // or, where `viewerSessionId` names an admin's own one, a view of it: their
30
+ // role, the scopes their teams list (listUserNpmScopes), and whether they
31
+ // read the dependency reviews published to team `teamId`
32
+ // (dependency-reviews.ts reviewsIn); in one statement, as a list of a
33
+ // team's reviews asks again once npm is read. Null where it names none.
34
+ getNpmReviewReader(sessionId: string, viewerSessionId: string | null, now: number, teamId: string): Promise<NpmReviewReader | null>
35
+ }
36
+ export interface NpmReviewReader { id: string; role: Role; scopes: string[]; readsTeam: boolean }
37
+
38
+ // Each distinct scope, normalized; a list with one npm could not publish
39
+ // under, or too many, is refused whole.
40
+ export function parseTeamNpmScopes(input: unknown): string[] {
41
+ if (!Array.isArray(input)) throw new ManagedMutationError(400, 'bad-scopes')
42
+ const scopes = new Set<string>()
43
+ for (const value of input) {
44
+ const scope = normalizeNpmScope(value)
45
+ if (scope === null) throw new ManagedMutationError(400, 'bad-scope')
46
+ scopes.add(scope)
47
+ }
48
+ if (scopes.size > MAX_TEAM_NPM_SCOPES) throw new ManagedMutationError(400, 'too-many-scopes')
49
+ return [...scopes].toSorted()
50
+ }
51
+
52
+ export function teamNpmScopeMethods(db: ManagedSql): TeamNpmScopeStore {
53
+ return {
54
+ async listTeamNpmScopes() {
55
+ const rows = await db.prepare('SELECT team_id AS teamId, scope FROM managed_team_npm_scope ORDER BY team_id, scope').all() as { teamId: string; scope: string }[]
56
+ const result: Record<string, string[]> = {}
57
+ for (const { teamId, scope } of rows) (result[teamId] ??= []).push(scope)
58
+ return result
59
+ },
60
+ async setTeamNpmScopes(sessionId, teamId, input) {
61
+ const user = await db.prepare(`SELECT u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
62
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { role: string } | undefined
63
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
64
+ if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
65
+ const scopes = parseTeamNpmScopes(input)
66
+ if (!await db.prepare('SELECT id FROM managed_team WHERE id = ?').get(teamId)) return null
67
+ const current = (await db.prepare('SELECT scope FROM managed_team_npm_scope WHERE team_id = ?').all(teamId) as { scope: string }[]).map(row => row.scope)
68
+ const removed = current.filter(scope => !scopes.includes(scope)).toSorted()
69
+ const added = scopes.filter(scope => !current.includes(scope))
70
+ for (const scope of removed) await db.prepare('DELETE FROM managed_team_npm_scope WHERE team_id = ? AND scope = ?').run(teamId, scope)
71
+ for (const scope of added) await db.prepare('INSERT INTO managed_team_npm_scope (team_id, scope) VALUES (?, ?)').run(teamId, scope)
72
+ return { added, removed }
73
+ },
74
+ async listUserNpmScopes(userId) {
75
+ const rows = await db.prepare(`SELECT DISTINCT ns.scope FROM managed_team_user tu
76
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
77
+ JOIN managed_team_npm_scope ns ON ns.team_id = tu.team_id
78
+ WHERE tu.user_id = ? ORDER BY ns.scope`).all(userId) as { scope: string }[]
79
+ return rows.map(row => row.scope)
80
+ },
81
+ getNpmReviewReader: (sessionId, viewerSessionId, now, teamId) => npmReviewReader(db, sessionId, viewerSessionId, now, teamId),
82
+ async hasTeamNpmScope(scope) {
83
+ return !!await db.prepare(`SELECT 1 AS listed FROM managed_team_npm_scope ns
84
+ JOIN managed_team t ON t.id = ns.team_id AND t.hidden = 0
85
+ WHERE ns.scope = ? LIMIT 1`).get(scope)
86
+ },
87
+ }
88
+ }
89
+
90
+ // getNpmReviewReader's statement, its session held to what readSession's
91
+ // own are (db-methods.ts selectOwnSessionStmt, selectViewSessionStmt).
92
+ async function npmReviewReader(db: ManagedSql, sessionId: string, viewerSessionId: string | null, now: number, teamId: string): Promise<NpmReviewReader | null> {
93
+ const row = await db.prepare(`WITH reader AS (
94
+ SELECT u.id AS id, u.role AS role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
95
+ WHERE s.id = ? AND s.expires_at > ? AND ((? IS NULL AND s.viewer_session IS NULL) OR (s.viewer_session = ? AND EXISTS (
96
+ SELECT 1 FROM managed_session p JOIN managed_user a ON a.id = p.user_id
97
+ WHERE p.id = s.viewer_session AND p.expires_at > ? AND p.viewer_session IS NULL AND a.role = 'admin')))
98
+ )
99
+ SELECT v.id AS id, v.role AS role,
100
+ (SELECT json_group_array(x.scope ORDER BY x.scope) FROM (SELECT DISTINCT ns.scope AS scope FROM managed_team_user tu
101
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0 JOIN managed_team_npm_scope ns ON ns.team_id = tu.team_id
102
+ WHERE tu.user_id = v.id) x) AS scopes,
103
+ CASE WHEN EXISTS (SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = v.id WHERE t.id = ? AND ${reviewsIn(false)}) THEN 1 ELSE 0 END AS readsTeam
104
+ FROM reader v`).get(sessionId, now, viewerSessionId, viewerSessionId, now, teamId) as { id: string; role: Role; scopes: unknown; readsTeam: unknown } | undefined
105
+ return row ? { id: row.id, role: row.role, scopes: gathered<string>(row.scopes), readsTeam: Number(row.readsTeam) === 1 } : null
106
+ }