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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +3 -0
  50. package/common/managed/finding-detail.js +66 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +7 -0
  54. package/common/managed/npm-packages.js +51 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +359 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +6 -0
  64. package/common/managed/team-app.js +47 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2K6UN3PC.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-6LNQTKAV.js +52 -0
  87. package/out/chunk-7BKKYVWR.js +7 -0
  88. package/out/chunk-7C7DWL7I.js +1 -0
  89. package/out/chunk-7JBSSRD7.js +3 -0
  90. package/out/chunk-7VAWINQF.js +6 -0
  91. package/out/chunk-C4Z6ZBXV.js +10 -0
  92. package/out/chunk-FMXWXDR4.js +36 -0
  93. package/out/chunk-GDPCLSOS.js +2 -0
  94. package/out/chunk-GO7FT4OD.js +1 -0
  95. package/out/chunk-HT2SSA3V.js +1 -0
  96. package/out/chunk-IPAH4LKF.js +232 -0
  97. package/out/chunk-IYPY47MC.js +1 -0
  98. package/out/chunk-IZ7POWI3.js +18 -0
  99. package/out/chunk-JCIIBLKR.js +1 -0
  100. package/out/chunk-KOGNQDSJ.js +1 -0
  101. package/out/chunk-NRPC74AM.js +1 -0
  102. package/out/chunk-OOJM4CTU.js +1 -0
  103. package/out/chunk-PBZPYIG7.js +1 -0
  104. package/out/chunk-PNHBZGDD.js +1 -0
  105. package/out/chunk-PWFUOLZK.js +17 -0
  106. package/out/chunk-PXK7EU4V.js +1 -0
  107. package/out/chunk-QB4CBOX2.js +1 -0
  108. package/out/chunk-R3NZFXVR.js +1 -0
  109. package/out/chunk-R5O6FAW4.js +2 -0
  110. package/out/chunk-SXV2T5C4.js +80 -0
  111. package/out/chunk-TQJDYHVE.js +2 -0
  112. package/out/chunk-TUII34K2.js +1 -0
  113. package/out/chunk-VDRXHIHN.js +3 -0
  114. package/out/chunk-VFWMQ3LG.js +2 -0
  115. package/out/chunk-VH4XMZIW.js +2 -0
  116. package/out/chunk-WCPJDAYA.js +10 -0
  117. package/out/chunk-WFYLMF6Z.js +10 -0
  118. package/out/chunk-WRAJVD35.js +1 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +364 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +23 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +76 -0
  237. package/server-managed/comparison.ts +52 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +468 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +143 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +98 -0
  249. package/server-managed/dependency-reviews.ts +364 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +376 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +136 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5503 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/listed-activity.ts +39 -0
  273. package/server-managed/locked-packages.ts +152 -0
  274. package/server-managed/lockfile-scope.ts +49 -0
  275. package/server-managed/lru.ts +30 -0
  276. package/server-managed/managed-issues.ts +78 -0
  277. package/server-managed/management-catalog.ts +45 -0
  278. package/server-managed/management.ts +99 -0
  279. package/server-managed/npm-delta-worker.js +23 -0
  280. package/server-managed/npm-delta.ts +194 -0
  281. package/server-managed/npm-insights.ts +449 -0
  282. package/server-managed/npm-kept.ts +225 -0
  283. package/server-managed/npm-loads.ts +264 -0
  284. package/server-managed/npm-manifest.ts +115 -0
  285. package/server-managed/npm-packages.ts +454 -0
  286. package/server-managed/npm-patch.ts +325 -0
  287. package/server-managed/npm-provenance.ts +123 -0
  288. package/server-managed/npm-risk.ts +285 -0
  289. package/server-managed/object-storage-disk.ts +148 -0
  290. package/server-managed/object-storage-vercel.ts +67 -0
  291. package/server-managed/object-storage.ts +75 -0
  292. package/server-managed/package-entry-points.ts +51 -0
  293. package/server-managed/pretty-print.ts +434 -0
  294. package/server-managed/public-workspace.ts +238 -0
  295. package/server-managed/pull-comment-reviews.ts +161 -0
  296. package/server-managed/pull-dependencies.ts +351 -0
  297. package/server-managed/pull-dependency-reviews.ts +334 -0
  298. package/server-managed/pull-reviews.ts +205 -0
  299. package/server-managed/queue.ts +39 -0
  300. package/server-managed/release-commits.ts +84 -0
  301. package/server-managed/rename-diff-worker.js +26 -0
  302. package/server-managed/rename-diff.ts +279 -0
  303. package/server-managed/repo-path.ts +21 -0
  304. package/server-managed/report-migration.ts +35 -0
  305. package/server-managed/report-query.ts +10 -0
  306. package/server-managed/report-response.ts +16 -0
  307. package/server-managed/report-sources.ts +178 -0
  308. package/server-managed/report-store.ts +85 -0
  309. package/server-managed/repository-actions.ts +218 -0
  310. package/server-managed/repository-agents.ts +448 -0
  311. package/server-managed/repository-aliases.ts +91 -0
  312. package/server-managed/repository-browser.ts +294 -0
  313. package/server-managed/repository-changes.ts +284 -0
  314. package/server-managed/repository-code.ts +215 -0
  315. package/server-managed/repository-dependency-reviews.ts +166 -0
  316. package/server-managed/repository-discovery.ts +82 -0
  317. package/server-managed/repository-history.ts +432 -0
  318. package/server-managed/repository-issues.ts +195 -0
  319. package/server-managed/repository-policy.ts +27 -0
  320. package/server-managed/repository-pulls.ts +212 -0
  321. package/server-managed/repository-pulse.ts +460 -0
  322. package/server-managed/repository-risk-assess.ts +105 -0
  323. package/server-managed/repository-risk-audit.ts +79 -0
  324. package/server-managed/repository-risk-read.ts +170 -0
  325. package/server-managed/repository-risk.ts +491 -0
  326. package/server-managed/repository-squashes.ts +168 -0
  327. package/server-managed/request-timing.ts +63 -0
  328. package/server-managed/reviewed-dependencies.ts +149 -0
  329. package/server-managed/revisions.ts +35 -0
  330. package/server-managed/scan-downloads.ts +87 -0
  331. package/server-managed/session.ts +132 -0
  332. package/server-managed/slugs.ts +38 -0
  333. package/server-managed/solidity-entry-points.ts +71 -0
  334. package/server-managed/sql-postgres.ts +34 -0
  335. package/server-managed/sql.ts +88 -0
  336. package/server-managed/static.ts +28 -0
  337. package/server-managed/storage-command.ts +16 -0
  338. package/server-managed/storage-db.ts +183 -0
  339. package/server-managed/storage-encryption.ts +290 -0
  340. package/server-managed/storage-maintenance.ts +259 -0
  341. package/server-managed/storage-payload.ts +121 -0
  342. package/server-managed/storage-stores.ts +91 -0
  343. package/server-managed/storage.ts +82 -0
  344. package/server-managed/team-app.ts +134 -0
  345. package/server-managed/team-catalog.ts +7 -0
  346. package/server-managed/team-feed.ts +180 -0
  347. package/server-managed/team-npm-scopes.ts +106 -0
  348. package/server-managed/team-reports.ts +319 -0
  349. package/server-managed/triage-response.ts +16 -0
  350. package/server-managed/type-only-worker.js +19 -0
  351. package/server-managed/type-only.ts +105 -0
  352. package/server-managed/uploads.ts +106 -0
  353. package/server-managed/upstream-cache.ts +243 -0
  354. package/server-managed/view-as.ts +58 -0
  355. package/server-managed/workspace-shares.ts +169 -0
  356. package/server.ts +70 -0
  357. package/server/index.ts +0 -480
  358. package/server/lifecycle.ts +0 -208
  359. /package/{LICENSE → LICENSE-MIT} +0 -0
  360. /package/{server → server-e2e}/auth.ts +0 -0
  361. /package/{server → server-e2e}/config.example.json +0 -0
  362. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  363. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  364. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  365. /package/{server → server-e2e}/peer.ts +0 -0
  366. /package/{server → server-e2e}/sse-session.ts +0 -0
  367. /package/{server → server-e2e}/util.ts +0 -0
  368. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,319 @@
1
+ // Team content is filtered as one workspace, with separate report envelopes.
2
+ // Bounded snapshots retain filtered content and visibility for repeated reads.
3
+ import { mergeLinkGroups } from './link-reports.ts'
4
+ import { Buffer } from 'node:buffer'
5
+ import { createHash } from 'node:crypto'
6
+ import { backfillFindingIds, reportEntries, stampSecurityGroups } from '@preventive/report'
7
+ import { managedFindingSourcePaths, readManagedReport } from '../common/managed/report-content.ts'
8
+ import { filterReportData, projectFinding } from '../common/managed/report-filter.ts'
9
+ import { revalidationLinks, securityLinks } from '../common/security-links.js'
10
+ import { treeDependencyDirectory } from '../client/dependency-paths.js'
11
+ import { liteWorkspace } from '../common/managed/finding-detail.js'
12
+ import type { BlobStore } from './blob-store.ts'
13
+ import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
14
+ import { triageWireEntry } from './triage-response.ts'
15
+ import { visibleManagedIssues } from './managed-issues.ts'
16
+ import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
17
+
18
+ type Finding = Record<string, unknown>
19
+ type TeamReport = { id: string; filename: string; data: unknown; repo: { github: string | null; directory: string } }
20
+ type ReportVisibility = { ids: Set<string>; sourcePaths: Set<string> }
21
+ type Visibility = Map<string, ReportVisibility>
22
+ const VISIBILITY_CACHE_MAX_ITEMS = 250_000
23
+ const VISIBILITY_CACHE_MAX_SNAPSHOTS = 32
24
+ const caches = new WeakMap<ManagedDb, Map<string, Visibility>>()
25
+ const REPORT_CACHE_BYTES = 16 * 1024 * 1024
26
+ // A workspace's encoded reports, the same without their findings' detail for
27
+ // the board (`lite`), and its reports' file trees apart.
28
+ type EncodedWorkspace = { reports: Buffer; lite: Buffer; trees: Buffer }
29
+ export type TeamResponsePart = keyof EncodedWorkspace
30
+ // The part a team's `reports` or `trees` route asks for.
31
+ export const teamResponsePart = (route: string, url: URL): TeamResponsePart =>
32
+ route === 'trees' ? 'trees' : url.searchParams.get('lite') === '1' ? 'lite' : 'reports'
33
+ const reportCaches = new WeakMap<ManagedDb, Map<string, EncodedWorkspace>>()
34
+ const encodedBytes = ({ reports, lite, trees }: EncodedWorkspace) => reports.length + lite.length + trees.length
35
+ // A workspace's report reads at once. Its reports' sizes are bounded together
36
+ // before any is read, and each is held parsed till the build ends, so more at
37
+ // once costs only the raw bytes in flight: a cold build of a team's reports
38
+ // is so many storage round trips one after another.
39
+ const WORKSPACE_READS_AT_ONCE = 16
40
+ function retainReports(db: ManagedDb, key: string, encoded: EncodedWorkspace) {
41
+ if (encodedBytes(encoded) > REPORT_CACHE_BYTES) return
42
+ let cache = reportCaches.get(db)
43
+ if (!cache) { cache = new Map(); reportCaches.set(db, cache) }
44
+ cache.delete(key)
45
+ let total = encodedBytes(encoded) + [...cache.values()].reduce((sum, value) => sum + encodedBytes(value), 0)
46
+ for (const [oldest, value] of cache) {
47
+ if (total <= REPORT_CACHE_BYTES && cache.size < VISIBILITY_CACHE_MAX_SNAPSHOTS) break
48
+ cache.delete(oldest); total -= encodedBytes(value)
49
+ }
50
+ cache.set(key, encoded)
51
+ }
52
+ function visibilityWeight(visible: Visibility): number {
53
+ return [...visible.values()].reduce((sum, v) => sum + v.ids.size + v.sourcePaths.size, 0)
54
+ }
55
+ function retainVisibility(db: ManagedDb, key: string, visible: Visibility): void {
56
+ let cache = caches.get(db)
57
+ // Replacement contributes its weight once and does not evict a peer merely
58
+ // because the cache already holds the maximum number of snapshots.
59
+ cache?.delete(key)
60
+ const incoming = visibilityWeight(visible)
61
+ if (incoming > VISIBILITY_CACHE_MAX_ITEMS) return
62
+ if (!cache) { cache = new Map(); caches.set(db, cache) }
63
+ let total = incoming + [...cache.values()].reduce((sum, entry) => sum + visibilityWeight(entry), 0)
64
+ for (const [oldest, entry] of cache) {
65
+ if (cache.size < VISIBILITY_CACHE_MAX_SNAPSHOTS && total <= VISIBILITY_CACHE_MAX_ITEMS) break
66
+ cache.delete(oldest)
67
+ total -= visibilityWeight(entry)
68
+ }
69
+ cache.set(key, visible)
70
+ }
71
+ export class TeamReportsError extends Error {
72
+ status: number
73
+ constructor(status: number, error: string) { super(error); this.status = status }
74
+ }
75
+ export function teamSnapshotKey(snapshot: TeamReportAccessSnapshot): string {
76
+ return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.hiddenReportId ?? null, snapshot.reports, snapshot.repositories, snapshot.linkRevision])
77
+ }
78
+ // What a built workspace is kept by: everything its build reads, and nothing
79
+ // of who asked. A user's role and grants decide which reports (and which
80
+ // hidden preview) their snapshot holds and each one's permissions, all of it
81
+ // in the key, so members whose snapshots agree share one build.
82
+ function workspaceKey(snapshot: TeamReportAccessSnapshot): string {
83
+ return JSON.stringify([snapshot.teamId, snapshot.hiddenReportId ?? null, snapshot.reports, snapshot.repositories, snapshot.linkRevision])
84
+ }
85
+ export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string, reportId: string | null = null): Promise<TeamReportAccessSnapshot> {
86
+ const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, reportId)
87
+ if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
88
+ if (!snapshot.teamId) throw new TeamReportsError(404, 'no-team')
89
+ if (reportId !== null && !snapshot.reports.some(report => report.id === reportId)) throw new TeamReportsError(404, 'no-report')
90
+ return snapshot
91
+ }
92
+ export async function recheckTeam(db: ManagedDb, sessionId: string, snapshot: TeamReportAccessSnapshot): Promise<void> {
93
+ if (teamSnapshotKey(await teamSnapshot(db, sessionId, snapshot.teamId!, snapshot.reportId)) !== teamSnapshotKey(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
94
+ }
95
+ function groupsOf(data: unknown): Finding[][] {
96
+ return (reportEntries(data) ?? []).map(entry => (Array.isArray(entry) ? entry : [entry]).filter(
97
+ (finding): finding is Finding => finding !== null && typeof finding === 'object' && !Array.isArray(finding),
98
+ ))
99
+ }
100
+ function linksOf(data: unknown): string[][] | null {
101
+ const record = data as { source?: string; links?: string[][] }
102
+ if (record?.source !== 'links' || !Array.isArray(record.links)) return null
103
+ if (!record.links.every(ids => Array.isArray(ids) && ids.every(id => typeof id === 'string'))) throw new TeamReportsError(422, 'unreadable-links')
104
+ return record.links
105
+ }
106
+ // A report's file tree goes apart, asked for by its graph or Files alone
107
+ // (loadTeamTreesResponse). The report tells in its place how many files it
108
+ // holds (`treeFiles`) and the dependency directory it names
109
+ // (`treeDependencies`), which classifies findings as the tree itself would
110
+ // (client/dependency-paths.js). Each file's is sent without the hash of its
111
+ // tree (`treeHash`), which no team view reads.
112
+ function withoutTree(report: TeamReport): string | null {
113
+ const tree = (report.data as { tree?: unknown } | null)?.tree
114
+ if (!tree || typeof tree !== 'object' || Array.isArray(tree)) return null
115
+ const { tree: _tree, ...data } = report.data as Finding
116
+ const named = treeDependencyDirectory(tree)
117
+ report.data = { ...data, treeFiles: Object.keys(tree).length, ...(named ? { treeDependencies: named } : {}) }
118
+ const files = Object.entries(tree).map(([file, entry]) => {
119
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return [file, entry]
120
+ const { treeHash: _treeHash, ...kept } = entry as Finding
121
+ return [file, kept]
122
+ })
123
+ return `${JSON.stringify(report.id)}:${JSON.stringify(Object.fromEntries(files))}`
124
+ }
125
+ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<EncodedWorkspace & { visible: Visibility }> {
126
+ if (snapshot.reports.length > MAX_REPORT_QUERY_COUNT || snapshot.reports.reduce((n, r) => n + r.byteSize, 0) > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
127
+ const reports: TeamReport[] = []
128
+ let inputBytes = 0, next = 0
129
+ // Bound both remote reads and raw buffers, retaining stable report order for
130
+ // the cross-report classification that follows. Finish workers on failure.
131
+ const results = await Promise.allSettled(Array.from({ length: Math.min(WORKSPACE_READS_AT_ONCE, snapshot.reports.length) }, async () => {
132
+ while (next < snapshot.reports.length) {
133
+ const index = next++
134
+ const access = snapshot.reports[index]!
135
+
136
+ const bytes = await store.get(access.id)
137
+ if (!bytes) throw new TeamReportsError(503, 'unavailable')
138
+ inputBytes += bytes.length
139
+ if (inputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
140
+ const parsed = readManagedReport(bytes.toString('utf8'), access.filename)
141
+ if (!parsed.data) throw new TeamReportsError(422, 'unreadable-report')
142
+ // Backfill before filtering so links, annotations and the client agree on
143
+ // IDs even when hiding a component would change the original row shape.
144
+ await backfillFindingIds(groupsOf(parsed.data).flat())
145
+ reports[index] = { id: access.id, filename: access.filename, data: parsed.data, repo: access.repo }
146
+ }
147
+ }))
148
+ for (const result of results) if (result.status === 'rejected') throw result.reason
149
+ const globalLinks = snapshot.linkRevision ? await db.getEnabledLinkGroups(snapshot.linkRevision) : []
150
+ const groups = reports.flatMap(report => groupsOf(report.data).map(group => group.map(f => projectFinding(f, report.data as Finding)!)))
151
+ // Unknown IDs can connect known findings. App findings link to the inputs
152
+ // they revalidated in other reports, as the App view counts them.
153
+ stampSecurityGroups(groups, { linkedIds: securityLinks([
154
+ ...globalLinks, ...reports.flatMap(report => linksOf(report.data) ?? []), ...revalidationLinks(groups.flat()),
155
+ ]).linkedIds })
156
+ const securityIds = new Set(groups.flat().filter(f => f['isSecurity'] === true).map(f => String(f['id'])))
157
+ const allIds = new Set<string>(), visible: Visibility = new Map()
158
+ for (let i = 0; i < reports.length; i++) {
159
+ const access = snapshot.reports[i]!, report = reports[i]!
160
+ report.data = filterReportData(report.data, access.permissions, access.repo, securityIds)
161
+ if (report.data && typeof report.data === 'object' && 'think' in report.data) {
162
+ const { think: _think, ...data } = report.data as Finding
163
+ report.data = data
164
+ }
165
+ const ids = new Set<string>()
166
+ for (const finding of groupsOf(report.data).flat()) {
167
+ // Preserve linked security classification for the UI lens, including a
168
+ // linked component that a dependency permission will hide from the UI.
169
+ finding['isSecurity'] = securityIds.has(String(finding['id']))
170
+ // The hash of its file's tree, the fingerprint its ID was derived from
171
+ // and whether its run thought, which nothing a team view reads, nor an
172
+ // export (@preventive/report write-md-finding.js) writes.
173
+ delete finding['treeHash']
174
+ delete finding['_idBasis']
175
+ delete finding['think']
176
+ if (typeof finding['id'] === 'string') { ids.add(finding['id']); allIds.add(finding['id']) }
177
+ }
178
+ visible.set(report.id, { ids, sourcePaths: managedFindingSourcePaths(groupsOf(report.data).flat()) })
179
+ }
180
+ for (const report of reports) {
181
+ const links = linksOf(report.data)
182
+ // Hidden previews are independently authorized; preserve a hidden links
183
+ // document even though its referenced findings are outside this response.
184
+ if (links && !snapshot.hiddenReportId) report.data = { source: 'links', findings: [], links: links.map(ids => [...new Set(ids.filter(id => allIds.has(id)))]).filter(ids => ids.length >= 2) }
185
+ }
186
+ const trees = Buffer.from(`{"trees":{${reports.map(withoutTree).filter(tree => tree !== null).join(',')}}}`)
187
+ const links = mergeLinkGroups(globalLinks, allIds)
188
+ // The board's: its findings without their detail, and in App mode its rows
189
+ // as the App view shows them (common/managed/finding-detail.js).
190
+ const lite = liteWorkspace(reports, links)
191
+ const encoded = {
192
+ // Bounded together, as when the trees were sent with their reports.
193
+ reports: encodeReports(reports, links, trees.length),
194
+ lite: encodeReports(lite.reports, links, 0, lite.folded),
195
+ trees,
196
+ }
197
+ retainVisibility(db, workspaceKey(snapshot), visible)
198
+ retainReports(db, workspaceKey(snapshot), encoded)
199
+ return { ...encoded, visible }
200
+ }
201
+ // `{ reports, links }` as a response sends them, `spent` bytes already
202
+ // counted against the bound; `folded` where the board's copy left out rows
203
+ // App mode does not show (common/managed/finding-detail.js).
204
+ function encodeReports(reports: readonly TeamReport[], links: readonly string[][], spent: number, folded = false): Buffer {
205
+ let outputBytes = spent + 14
206
+ const parts = [Buffer.from('{"reports":[')]
207
+ for (const [index, report] of reports.entries()) {
208
+ const bytes = Buffer.from(JSON.stringify(report))
209
+ outputBytes += bytes.length + 1
210
+ if (outputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
211
+ if (index) parts.push(Buffer.from(','))
212
+ parts.push(bytes)
213
+ }
214
+ const tail = JSON.stringify({ ...links.length > 0 ? { links } : {}, ...folded ? { folded } : {} })
215
+ const suffix = Buffer.from(tail === '{}' ? ']}' : `],${tail.slice(1)}`)
216
+ if (outputBytes + suffix.length > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
217
+ parts.push(suffix)
218
+ return Buffer.concat(parts)
219
+ }
220
+ const pendingWorkspaces = new WeakMap<ManagedDb, Map<string, ReturnType<typeof buildTeamWorkspace>>>()
221
+ async function loadTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot) {
222
+ let pending = pendingWorkspaces.get(db)
223
+ if (!pending) { pending = new Map(); pendingWorkspaces.set(db, pending) }
224
+ const key = workspaceKey(snapshot)
225
+ const existing = pending.get(key)
226
+ if (existing) return existing
227
+ const job = buildTeamWorkspace(db, store, snapshot)
228
+ pending.set(key, job)
229
+ try { return await job } finally { pending.delete(key) }
230
+ }
231
+ // Treat the encoded body as immutable and recheck access before sending it.
232
+ // Warm HTTP reads need neither a parsed copy nor another serialization pass.
233
+ // Its `lite` reports and its `trees`, by report ID (withoutTree), are of the
234
+ // same snapshot and take the same recheck.
235
+ export async function loadTeamReportsResponse(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot,
236
+ part: TeamResponsePart = 'reports'): Promise<Buffer> {
237
+ const cached = reportCaches.get(db)?.get(workspaceKey(snapshot))
238
+ return cached?.[part] ?? (await loadTeamWorkspace(db, store, snapshot))[part]
239
+ }
240
+ export async function loadTeamReports(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<TeamReport[]> {
241
+ // Object consumers get their own copy, including when sharing a cold load.
242
+ const body = await loadTeamReportsResponse(db, store, snapshot)
243
+ return (JSON.parse(body.toString('utf8')) as { reports: TeamReport[] }).reports
244
+ }
245
+ async function teamVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Visibility> {
246
+ let visible = caches.get(db)?.get(workspaceKey(snapshot))
247
+ if (!visible) {
248
+ // A large snapshot may deliberately remain uncached, or be evicted by
249
+ // another request. Authorization uses this load's result in either case.
250
+ visible = (await loadTeamWorkspace(db, store, snapshot)).visible
251
+ }
252
+ return visible
253
+ }
254
+ export async function teamWorkspaceFindingIds(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Set<string>> {
255
+ const ids = new Set<string>()
256
+ for (const report of (await teamVisibility(db, store, snapshot)).values()) for (const id of report.ids) ids.add(id)
257
+ return ids
258
+ }
259
+
260
+ // What a focused team's annotations are at, for the snapshot reading them, as
261
+ // its feed tells it (team-feed.ts serveUserTeamFeed) and its annotations
262
+ // answer it (loadTeamAnnotations): the snapshot's key and the revision of
263
+ // every visible finding's annotations (getAnnotationRevision), its workspace's
264
+ // findings being `ids` (teamWorkspaceFindingIds).
265
+ export async function teamAnnotationRevision(db: ManagedDb, snapshot: TeamReportAccessSnapshot, ids: readonly string[]): Promise<string> {
266
+ return JSON.stringify([teamSnapshotKey(snapshot), await db.getAnnotationRevision(ids)])
267
+ }
268
+
269
+ // A revision as the client is told it: a digest, telling only whether what it
270
+ // covers changed, none of the finding IDs, batches or issues it is read from.
271
+ export const annotationRevisionDigest = (revision: string): string => createHash('sha256').update(revision).digest('base64url')
272
+ export async function teamReportVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot, reportId: string): Promise<ReportVisibility> {
273
+ if (!snapshot.reports.some(r => r.id === reportId)) throw new TeamReportsError(404, 'no-report')
274
+ const visible = await teamVisibility(db, store, snapshot)
275
+ return visible.get(reportId)!
276
+ }
277
+ // A report's visibility for the session's current snapshot. A workspace built
278
+ // (or waited for) here outlives that snapshot's read, so the team's access is
279
+ // read again after it; one kept is answered with no read in between, which
280
+ // leaves nothing for another snapshot read to guard.
281
+ async function sessionReportVisibility(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<ReportVisibility> {
282
+ const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
283
+ const kept = caches.get(db)?.get(workspaceKey(snapshot))?.get(reportId)
284
+ if (kept) return kept
285
+ const visible = await teamReportVisibility(db, store, snapshot, reportId)
286
+ await recheckTeam(db, sessionId, snapshot)
287
+ return visible
288
+ }
289
+ export async function teamFindingIds(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
290
+ return (await sessionReportVisibility(db, store, sessionId, teamId, reportId)).ids
291
+ }
292
+ export async function teamSourcePaths(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
293
+ return (await sessionReportVisibility(db, store, sessionId, teamId, reportId)).sourcePaths
294
+ }
295
+
296
+ // Serialize shared annotation bodies once. Reports carry only the annotated
297
+ // finding IDs visible in that report; callers revalidate access before sending.
298
+ // With them, the digest of the revision the whole workspace's are at, as the
299
+ // team's feed tells it (`revision`), read before them: a write between leaves
300
+ // it older than what is sent, so the feed's next differs and the client reads
301
+ // again, never the reverse.
302
+ export async function loadTeamAnnotations(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot, reportId: string | null = null) {
303
+ // Restrict annotation reads only after classifying the complete workspace:
304
+ // links and findings in other reports can affect this report's visibility.
305
+ if (reportId !== null && !snapshot.reports.some(r => r.id === reportId)) throw new TeamReportsError(404, 'no-report')
306
+ const workspace = await teamVisibility(db, store, snapshot)
307
+ const visible = reportId === null ? workspace : new Map([[reportId, workspace.get(reportId)!]])
308
+ const all = new Set<string>(), ids = new Set<string>()
309
+ for (const report of visible.values()) for (const id of report.ids) ids.add(id)
310
+ for (const report of workspace.values()) for (const id of report.ids) all.add(id)
311
+ const revision = await teamAnnotationRevision(db, snapshot, [...all])
312
+ const { triage, comments, issues: savedIssues } = await db.getAnnotations([...ids])
313
+ const issues = Object.fromEntries(visibleManagedIssues(savedIssues, snapshot)
314
+ .map(issue => [issue.findingId, { url: issue.issueUrl!, autoFix: issue.autoFixUrl }]))
315
+ const entries = Object.fromEntries(triage.map(row => [row.findingId, triageWireEntry(row)]))
316
+ const annotated = new Set([...triage.map(row => row.findingId), ...comments.map(comment => comment.findingId), ...Object.keys(issues)])
317
+ const reports = Object.fromEntries([...visible].map(([id, report]) => [id, [...report.ids].filter(finding => annotated.has(finding))]))
318
+ return { reports, entries, comments, issues, revision: annotationRevisionDigest(revision) }
319
+ }
@@ -0,0 +1,16 @@
1
+ import type { TriageRow } from './db.ts'
2
+ import { type TriageEntryPatch, isTriageBucket } from '../common/managed/triage.ts'
3
+
4
+ // A stored triage row (current state or a trail event) → its wire entry: only
5
+ // set fields present, `false` kept for flagged (the explicit un-flag tombstone
6
+ // must round-trip), and null for the row of a cleared entry (every field
7
+ // null) — the reader adopts the clear.
8
+ export function triageWireEntry(row: Pick<TriageRow, 'color' | 'triage' | 'comment' | 'fix' | 'flagged'>, legacyHistory = false): TriageEntryPatch | null {
9
+ const e: TriageEntryPatch = {}
10
+ if (row.color != null) e.color = row.color
11
+ if (isTriageBucket(row.triage)) e.triage = row.triage
12
+ if (legacyHistory && row.comment != null) e.comment = row.comment
13
+ if (row.fix != null) e.fix = row.fix
14
+ if (row.flagged != null) e.flagged = row.flagged
15
+ return Object.keys(e).length > 0 ? e : null
16
+ }
@@ -0,0 +1,19 @@
1
+ // Which of a comparison's pairs of files changed in types alone (type-only.ts),
2
+ // as @preventive/compare's compareTrees stamps a change `typeOnly`, off the
3
+ // server's event loop: each pair a tree of a file a side, a side not there
4
+ // none, its paths as they are, which say what a file is. Two paths pair as one
5
+ // change wherever compareTrees takes them for a rename at all; where it
6
+ // doesn't, the pair is types only where each of its two changes is: the file
7
+ // gone erasing whole, and the one come erasing whole or leaving a single bare
8
+ // `export {}`, which nothing loaded before. `[true, false, ...]` by pair.
9
+ import { parentPort, workerData } from 'node:worker_threads'
10
+ import { compareTrees } from '@preventive/compare'
11
+
12
+ const tree = file => (file ? [{ name: file.path, data: file.text }] : [])
13
+ const answer = workerData.pairs.map(([from, to]) => {
14
+ const changes = compareTrees(tree(from), tree(to), { paths: 'keys', lines: false, renames: Number.MIN_VALUE })
15
+ return changes.length > 0 && changes.every(change => change.typeOnly === true)
16
+ })
17
+ // Node worker messages have no browser target origin.
18
+ // oxlint-disable-next-line unicorn/require-post-message-target-origin
19
+ parentPort.postMessage(answer)
@@ -0,0 +1,105 @@
1
+ // Compare's changes to TypeScript declaration files in types alone (GET
2
+ // /api/bundles/:id/type-only?other=, GET /api/npm/type-only): every file
3
+ // that changed between a comparison's two sides, added and removed ones too,
4
+ // paired as its viewer pairs them (rename-diff.ts comparedFiles), whose
5
+ // change @preventive/compare's compareTrees stamps
6
+ // `typeOnly` -- each side there a file named as a declaration file and
7
+ // proven to hold types alone but for a runtime, the module loads and
8
+ // `export {}` its stripping leaves, that the change leaves as it was, an
9
+ // added one's empty or a single bare `export {}` (proveTypeOnly) -- and
10
+ // nothing else. Proven in a worker, off the event loop, a comparison at a
11
+ // time per process, for at most a minute; told a line each, `{ path, hash,
12
+ // otherPath, otherHash }`, a side not there null, which the viewer checks
13
+ // name the files it shows. Access is the caller's to check.
14
+ import { Worker } from 'node:worker_threads'
15
+ import { type ComparedSide, PrettyError, type PrettyRequest } from './pretty-print.ts'
16
+ import { comparedFiles } from './rename-diff.ts'
17
+
18
+ // TypeScript files, by extension as the pretty-print cache reads them: none
19
+ // other is asked about.
20
+ const TYPESCRIPT = new Set(['ts', 'mts', 'cts'])
21
+ // Comparisons proven at once per process, and those waiting their turn past
22
+ // them, any more refused; the longest one may take, its wait included.
23
+ const MAX_ACTIVE_PROOFS = 1
24
+ const MAX_WAITING_PROOFS = 16
25
+ const PROOF_TIMEOUT_MS = 60_000
26
+
27
+ type File = PrettyRequest & { text: string }
28
+ export interface TypeOnlyLine { path: string | null; hash: string | null; otherPath: string | null; otherHash: string | null }
29
+
30
+ let active = 0
31
+ const waiting: Array<() => void> = []
32
+
33
+ async function turn(deadline: number): Promise<void> {
34
+ if (active < MAX_ACTIVE_PROOFS) { active++; return }
35
+ if (waiting.length >= MAX_WAITING_PROOFS) throw new PrettyError(429, 'proof-busy')
36
+ await new Promise<void>((resolve, reject) => {
37
+ const next = () => { clearTimeout(timer); resolve() }
38
+ const timer = setTimeout(() => {
39
+ waiting.splice(waiting.indexOf(next), 1)
40
+ reject(new PrettyError(504, 'proof-timeout'))
41
+ }, deadline - Date.now())
42
+ waiting.push(next)
43
+ })
44
+ }
45
+ function done() {
46
+ const next = waiting.shift()
47
+ if (next) next()
48
+ else active--
49
+ }
50
+
51
+ // Which of `pairs` compareTrees stamps types only, by index, in a worker
52
+ // settled once it is gone; none where it fails, as no proof should.
53
+ function proven(pairs: Array<[File | null, File | null]>, deadline: number): Promise<boolean[]> {
54
+ let worker: Worker
55
+ try {
56
+ worker = new Worker(new URL('./type-only-worker.js', import.meta.url), {
57
+ workerData: { pairs: pairs.map(sides => sides.map(file => file && { path: file.path, text: file.text })) },
58
+ // Node's type stripping, which the proof holds oxc's tree to, warns it is experimental.
59
+ execArgv: ['--disable-warning=ExperimentalWarning'], resourceLimits: { maxOldGenerationSizeMb: 1024 },
60
+ })
61
+ } catch (err) {
62
+ console.warn('managed: a type-only proof could not start:', err)
63
+ throw new PrettyError(503, 'unavailable')
64
+ }
65
+ return new Promise((resolve, reject) => {
66
+ let answer: boolean[] | null = null, failure = new PrettyError(422, 'unproven')
67
+ const timer = setTimeout(() => { failure = new PrettyError(504, 'proof-timeout'); void worker.terminate() }, deadline - Date.now())
68
+ worker.once('message', (told: unknown) => {
69
+ if (Array.isArray(told) && told.length === pairs.length && told.every(one => typeof one === 'boolean')) answer = told
70
+ void worker.terminate()
71
+ })
72
+ worker.once('error', err => { console.warn('managed: a type-only proof failed:', err) })
73
+ worker.once('exit', () => {
74
+ clearTimeout(timer)
75
+ if (answer) resolve(answer)
76
+ else reject(failure)
77
+ })
78
+ })
79
+ }
80
+
81
+ // The files that changed between a comparison's two sides in types alone,
82
+ // as the viewer pairs them, a line each once all are proven, in path order.
83
+ export function typeOnlyFiles([base, other]: readonly [ComparedSide, ComparedSide]): AsyncGenerator<TypeOnlyLine> {
84
+ const typescript = ({ files }: ComparedSide) => new Map(files.filter(file => TYPESCRIPT.has(file.extension)).map(file => [file.path, file]))
85
+ const [olds, news] = [typescript(base), typescript(other)]
86
+ const { changed, onlyBase, onlyOther } = comparedFiles([base, other])
87
+ // A side there not a TypeScript file this reads leaves its pair out; one not there is null.
88
+ const pairs = [...changed.map(({ path, basePath = path }) => [olds.get(basePath), news.get(path)]),
89
+ ...onlyBase.map(({ path, basePath = path }) => [olds.get(basePath), null]), ...onlyOther.map(({ path }) => [null, news.get(path)])]
90
+ .filter((sides): sides is [File | null, File | null] => sides.every(file => file !== undefined) && sides.some(file => file !== null) && sides[0]?.hash !== sides[1]?.hash)
91
+ .toSorted(([a, b], [c, d]) => {
92
+ const [x, y] = [(b ?? a)!.path, (d ?? c)!.path]
93
+ return x < y ? -1 : x > y ? 1 : 0
94
+ })
95
+ return (async function* () {
96
+ if (pairs.length === 0) return
97
+ const deadline = Date.now() + PROOF_TIMEOUT_MS
98
+ await turn(deadline)
99
+ let stamped
100
+ try { stamped = await proven(pairs, deadline) } finally { done() }
101
+ for (const [i, [from, to]] of pairs.entries()) {
102
+ if (stamped[i]) yield { path: from?.path ?? null, hash: from?.hash ?? null, otherPath: to?.path ?? null, otherHash: to?.hash ?? null }
103
+ }
104
+ })()
105
+ }