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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2AFQKRAM.js +51 -0
  72. package/out/chunk-2KBF5TZ7.js +2 -0
  73. package/out/chunk-2XUEZHIP.js +28 -0
  74. package/out/chunk-35VE6CDH.js +2 -0
  75. package/out/chunk-3BZ7JERB.js +1 -0
  76. package/out/chunk-4F6JSF57.js +3 -0
  77. package/out/chunk-4XCDFGDP.js +10 -0
  78. package/out/chunk-C4Z6ZBXV.js +10 -0
  79. package/out/chunk-CMQA4DM6.js +1 -0
  80. package/out/chunk-CVVJAXVY.js +2 -0
  81. package/out/chunk-DIJJNTRH.js +10 -0
  82. package/out/chunk-GJ4IUCNS.js +1 -0
  83. package/out/chunk-GNXOKIGP.js +80 -0
  84. package/out/chunk-GZ4MS3TE.js +13 -0
  85. package/out/chunk-IU3CCINR.js +3 -0
  86. package/out/chunk-K3X5EJBQ.js +2 -0
  87. package/out/chunk-KEX2ATEW.js +2 -0
  88. package/out/chunk-KVEUXIEU.js +1 -0
  89. package/out/chunk-KYCNJVB7.js +1 -0
  90. package/out/chunk-NRPC74AM.js +1 -0
  91. package/out/chunk-OOJM4CTU.js +1 -0
  92. package/out/chunk-PNHBZGDD.js +1 -0
  93. package/out/chunk-QZ3A376W.js +1 -0
  94. package/out/chunk-R5O6FAW4.js +2 -0
  95. package/out/chunk-RFZQQXSG.js +17 -0
  96. package/out/chunk-RSHMVYNS.js +1 -0
  97. package/out/chunk-TUII34K2.js +1 -0
  98. package/out/chunk-TY4RZD3Z.js +226 -0
  99. package/out/chunk-TZ5KB2Y5.js +10 -0
  100. package/out/chunk-U6OPEXIA.js +1 -0
  101. package/out/chunk-UNTRGE26.js +2 -0
  102. package/out/chunk-UXNUAIIC.js +33 -0
  103. package/out/chunk-UYAXVRVL.js +1 -0
  104. package/out/chunk-UZAZM2SX.js +2 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XL35ZV6R.js +1 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +298 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +595 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +156 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +223 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/score.js +59 -0
  154. package/risk/npm/specs.js +124 -0
  155. package/risk/npm/tarball.js +113 -0
  156. package/server-common/database-config.ts +16 -0
  157. package/server-common/initialize.ts +18 -0
  158. package/server-common/npm-advisories.ts +101 -0
  159. package/{server → server-common}/origin.ts +5 -5
  160. package/server-common/reap.ts +48 -0
  161. package/server-common/scan-config.ts +19 -0
  162. package/server-common/standalone.ts +29 -0
  163. package/server-common/storage-crypto.ts +182 -0
  164. package/server-common/storage-log.ts +38 -0
  165. package/server-common/vercel-blob.ts +103 -0
  166. package/server-e2e/app.ts +486 -0
  167. package/{server → server-e2e}/auth.ts +5 -1
  168. package/{server → server-e2e}/bus-receiver.ts +2 -1
  169. package/{server → server-e2e}/cli.js +9 -4
  170. package/{server → server-e2e}/config.ts +56 -35
  171. package/{server → server-e2e}/db-neon.ts +2 -2
  172. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  173. package/{server → server-e2e}/db-stmt.ts +2 -2
  174. package/{server → server-e2e}/db.ts +7 -7
  175. package/{server → server-e2e}/http.ts +116 -18
  176. package/{server → server-e2e}/hub.ts +40 -9
  177. package/server-e2e/index.ts +17 -0
  178. package/server-e2e/lifecycle.ts +95 -0
  179. package/{server → server-e2e}/neon-driver.ts +2 -2
  180. package/{server → server-e2e}/npm-proxy.ts +12 -145
  181. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  182. package/{server → server-e2e}/objstore/blob.ts +1 -1
  183. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  184. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  185. package/{server → server-e2e}/objstore/init.ts +35 -18
  186. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  187. package/server-e2e/objstore/rest-deny.ts +28 -0
  188. package/server-e2e/objstore/rest-mint.ts +224 -0
  189. package/{server → server-e2e}/objstore/rest.ts +27 -26
  190. package/{server → server-e2e}/objstore/sign.ts +105 -0
  191. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  192. package/{server → server-e2e}/objstore/store.ts +7 -7
  193. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  194. package/{server → server-e2e}/pubsub.ts +12 -9
  195. package/{server → server-e2e}/sign.ts +4 -4
  196. package/{server → server-e2e}/sse-server.ts +102 -68
  197. package/{server → server-e2e}/sse-session.ts +28 -8
  198. package/{server → server-e2e}/static.ts +31 -21
  199. package/server-e2e/sync-handlers.ts +408 -0
  200. package/{server → server-e2e}/ws-server.ts +20 -9
  201. package/server-managed/activity.ts +245 -0
  202. package/server-managed/avatar-store.ts +12 -0
  203. package/server-managed/blob-store.ts +17 -0
  204. package/server-managed/brotli.ts +10 -0
  205. package/server-managed/bundle-advisories.ts +120 -0
  206. package/server-managed/bundle-advisory-inventory.ts +77 -0
  207. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  208. package/server-managed/bundle-build-diagnostics.js +23 -0
  209. package/server-managed/bundle-build-leases.ts +41 -0
  210. package/server-managed/bundle-build-worker.js +111 -0
  211. package/server-managed/bundle-build.ts +198 -0
  212. package/server-managed/bundle-cache.ts +199 -0
  213. package/server-managed/bundle-catalog.ts +29 -0
  214. package/server-managed/bundle-commits.ts +140 -0
  215. package/server-managed/bundle-store.ts +24 -0
  216. package/server-managed/bundle-summary-cache.ts +130 -0
  217. package/server-managed/bundle.ts +120 -0
  218. package/server-managed/cache-storage.ts +19 -0
  219. package/server-managed/cli.js +21 -0
  220. package/server-managed/combined.ts +46 -0
  221. package/server-managed/comments.ts +151 -0
  222. package/server-managed/config.ts +158 -0
  223. package/server-managed/content-access.ts +15 -0
  224. package/server-managed/crypto.ts +25 -0
  225. package/server-managed/db-methods.ts +1725 -0
  226. package/server-managed/db-neon.ts +367 -0
  227. package/server-managed/db-schema.ts +227 -0
  228. package/server-managed/db-table-names.ts +22 -0
  229. package/server-managed/db.ts +132 -0
  230. package/server-managed/dependency-reviews.ts +248 -0
  231. package/server-managed/github-app.ts +391 -0
  232. package/server-managed/github-errors.ts +68 -0
  233. package/server-managed/github-issue-links.ts +127 -0
  234. package/server-managed/github-issue-oauth.ts +47 -0
  235. package/server-managed/github-issues.ts +152 -0
  236. package/server-managed/github-metadata.ts +213 -0
  237. package/server-managed/github-oauth.ts +235 -0
  238. package/server-managed/github-pulls.ts +227 -0
  239. package/server-managed/http-response.ts +50 -0
  240. package/server-managed/http.ts +3472 -0
  241. package/server-managed/import-triage.ts +51 -0
  242. package/server-managed/index.ts +187 -0
  243. package/server-managed/kept.ts +28 -0
  244. package/server-managed/link-reports.ts +158 -0
  245. package/server-managed/locked-packages.ts +85 -0
  246. package/server-managed/lru.ts +30 -0
  247. package/server-managed/managed-issues.ts +75 -0
  248. package/server-managed/management-catalog.ts +45 -0
  249. package/server-managed/management.ts +99 -0
  250. package/server-managed/npm-insights.ts +440 -0
  251. package/server-managed/npm-kept.ts +65 -0
  252. package/server-managed/npm-loads.ts +167 -0
  253. package/server-managed/npm-manifest.ts +115 -0
  254. package/server-managed/npm-packages.ts +378 -0
  255. package/server-managed/npm-provenance.ts +123 -0
  256. package/server-managed/npm-risk.ts +177 -0
  257. package/server-managed/object-storage-disk.ts +148 -0
  258. package/server-managed/object-storage-vercel.ts +67 -0
  259. package/server-managed/object-storage.ts +74 -0
  260. package/server-managed/package-entry-points.ts +51 -0
  261. package/server-managed/pretty-print.ts +352 -0
  262. package/server-managed/public-workspace.ts +220 -0
  263. package/server-managed/rename-diff-worker.js +26 -0
  264. package/server-managed/rename-diff.ts +270 -0
  265. package/server-managed/repo-path.ts +21 -0
  266. package/server-managed/report-migration.ts +35 -0
  267. package/server-managed/report-query.ts +10 -0
  268. package/server-managed/report-response.ts +16 -0
  269. package/server-managed/report-sources.ts +178 -0
  270. package/server-managed/report-store.ts +47 -0
  271. package/server-managed/repository-aliases.ts +91 -0
  272. package/server-managed/repository-browser.ts +153 -0
  273. package/server-managed/repository-discovery.ts +82 -0
  274. package/server-managed/repository-policy.ts +27 -0
  275. package/server-managed/repository-risk.ts +420 -0
  276. package/server-managed/revisions.ts +35 -0
  277. package/server-managed/session.ts +92 -0
  278. package/server-managed/slugs.ts +38 -0
  279. package/server-managed/solidity-entry-points.ts +69 -0
  280. package/server-managed/sql-postgres.ts +31 -0
  281. package/server-managed/sql.ts +76 -0
  282. package/server-managed/static.ts +28 -0
  283. package/server-managed/storage-command.ts +16 -0
  284. package/server-managed/storage-db.ts +175 -0
  285. package/server-managed/storage-encryption.ts +279 -0
  286. package/server-managed/storage-maintenance.ts +256 -0
  287. package/server-managed/storage-payload.ts +103 -0
  288. package/server-managed/storage-stores.ts +85 -0
  289. package/server-managed/storage.ts +78 -0
  290. package/server-managed/team-app.ts +94 -0
  291. package/server-managed/team-catalog.ts +7 -0
  292. package/server-managed/team-feed.ts +173 -0
  293. package/server-managed/team-npm-scopes.ts +77 -0
  294. package/server-managed/team-reports.ts +228 -0
  295. package/server-managed/triage-response.ts +16 -0
  296. package/server-managed/type-only-worker.js +19 -0
  297. package/server-managed/type-only.ts +103 -0
  298. package/server-managed/uploads.ts +86 -0
  299. package/server-managed/upstream-cache.ts +215 -0
  300. package/server-managed/view-as.ts +58 -0
  301. package/server-managed/workspace-shares.ts +166 -0
  302. package/server.ts +50 -0
  303. package/server/index.ts +0 -477
  304. package/server/lifecycle.ts +0 -204
  305. package/server/sync-handlers.ts +0 -320
  306. /package/{LICENSE → LICENSE-MIT} +0 -0
  307. /package/{server → server-e2e}/config.example.json +0 -0
  308. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  309. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  310. /package/{server → server-e2e}/peer.ts +0 -0
  311. /package/{server → server-e2e}/util.ts +0 -0
  312. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,173 @@
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, 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 focusedKey: string | 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 && focusedKey !== 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 = JSON.stringify([focusedKey, await deps.db.getAnnotationRevision(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
+ checkUser(await deps.db.getReportAccessSnapshot(sessionId, Date.now(), []))
78
+ // Publish catalog state before loading any report blobs. A slow or broken
79
+ // focused report must not hide membership/content changes from the client.
80
+ if (!publish('teams', catalog!.revision)) return
81
+ if (!teamId) {
82
+ // A mutation during the snapshot must cause a fresh read next poll.
83
+ const current = await deps.db.getFeedState(sessionId, Date.now())
84
+ checkUser(current)
85
+ if (current!.catalog === state!.catalog) previousState = state!
86
+ return
87
+ }
88
+ let triage: string | undefined
89
+ const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
90
+ if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
91
+ checkUser(snapshot)
92
+ const key = teamSnapshotKey(snapshot)
93
+ if (key !== visibilityKey) {
94
+ try {
95
+ ids = snapshot.teamId ? [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)] : []
96
+ visibilityKey = key
97
+ } catch {
98
+ // Missing, malformed or temporarily unavailable blobs only suspend
99
+ // triage. Retry visibility on the next poll, even for the same key.
100
+ publish('triage', undefined)
101
+ return
102
+ }
103
+ }
104
+ if (snapshot.teamId) triage = JSON.stringify([key, await deps.db.getAnnotationRevision(ids)])
105
+ const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
106
+ // A concurrent access/content change discards only the annotation read.
107
+ checkUser(current)
108
+ if (!current || teamSnapshotKey(current) !== key) return
109
+ publish('triage', triage)
110
+ focusedKey = snapshot.teamId ? key : undefined
111
+ const after = await deps.db.getFeedState(sessionId, Date.now())
112
+ checkUser(after)
113
+ if (after!.catalog === state!.catalog && after!.annotations === state!.annotations) previousState = state!
114
+ }, options)
115
+ }
116
+
117
+ async function serveFeed(res: ServerResponse, deps: ManagedHttpDeps, read: (publish: Publish) => Promise<void>,
118
+ { pollMs = TEAM_FEED_POLL_MS, lifetimeMs = TEAM_FEED_LIFETIME_MS }: FeedOptions): Promise<void> {
119
+ const controller = new AbortController()
120
+ const close = () => controller.abort()
121
+ res.once('close', close)
122
+ const deadline = setTimeout(close, lifetimeMs)
123
+ const stopped = () => controller.signal.aborted || res.destroyed || deps.isShuttingDown()
124
+ // Notifications carry no annotation bodies, finding IDs, or global cursors.
125
+ // The catalog's opaque version lets clients reuse an identical REST snapshot;
126
+ // every connection still confirms current access, including after reconnects.
127
+ function write(frame: string): boolean {
128
+ if (stopped()) return false
129
+ if (res.write(frame)) return true
130
+ // Slow consumers reconnect and read current state; never queue a backlog.
131
+ res.destroy()
132
+ return false
133
+ }
134
+ try {
135
+ if (stopped()) return
136
+ res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8',
137
+ 'cache-control': 'private, no-store, no-transform', 'x-accel-buffering': 'no',
138
+ 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' })
139
+ res.flushHeaders()
140
+ let heartbeat = Date.now()
141
+ const previous = new Map<string, string>()
142
+ const publish: Publish = (event, revision) => {
143
+ if (stopped()) return false
144
+ if (revision === undefined) { previous.delete(event); return true }
145
+ if (revision === previous.get(event)) return true
146
+ const data = event === 'teams' ? JSON.stringify({ revision }) : '{}'
147
+ if (!write(`event: ${event}\ndata: ${data}\n\n`)) return false
148
+ previous.set(event, revision)
149
+ heartbeat = Date.now()
150
+ return true
151
+ }
152
+ while (!stopped()) {
153
+ if (deps.db.withRequest) await deps.db.withRequest(() => read(publish))
154
+ else await read(publish)
155
+ if (stopped()) break
156
+ if (Date.now() - heartbeat >= HEARTBEAT_MS) {
157
+ if (!write(': keepalive\n\n')) break
158
+ heartbeat = Date.now()
159
+ }
160
+ await delay(pollMs, undefined, { signal: controller.signal }).catch(error => {
161
+ if (!controller.signal.aborted) throw error
162
+ })
163
+ }
164
+ } catch (error) {
165
+ if (!res.headersSent) throw error
166
+ if (error instanceof TeamReportsError) write('event: close\ndata: {}\n\n')
167
+ else if (!stopped()) throw error
168
+ } finally {
169
+ clearTimeout(deadline)
170
+ res.off('close', close)
171
+ if (res.headersSent && !res.destroyed) res.end()
172
+ }
173
+ }
@@ -0,0 +1,77 @@
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 { ManagedMutationError } from './management.ts'
6
+ import type { ManagedSql } from './sql.ts'
7
+
8
+ export const TEAM_NPM_SCOPE_SCHEMA = `
9
+ CREATE TABLE IF NOT EXISTS managed_team_npm_scope (
10
+ team_id TEXT NOT NULL REFERENCES managed_team(id) ON DELETE CASCADE,
11
+ scope TEXT NOT NULL,
12
+ PRIMARY KEY (team_id, scope)
13
+ ) STRICT;
14
+ `
15
+
16
+ export interface TeamNpmScopeStore {
17
+ // Every team's scopes, sorted, keyed by team id; teams without any are absent.
18
+ listTeamNpmScopes(): Promise<Record<string, string[]>>
19
+ // Replaces a team's scopes; admin only. Answers what changed, or null for no team.
20
+ setTeamNpmScopes(sessionId: string, teamId: string, scopes: unknown): Promise<{ added: string[]; removed: string[] } | null>
21
+ // The scopes a user's visible teams list.
22
+ listUserNpmScopes(userId: string): Promise<string[]>
23
+ // Whether any visible team lists the scope: whoever reads its packages,
24
+ // they are the instance's own, whose new releases aren't flagged (http.ts).
25
+ hasTeamNpmScope(scope: string): Promise<boolean>
26
+ }
27
+
28
+ // Each distinct scope, normalized; a list with one npm could not publish
29
+ // under, or too many, is refused whole.
30
+ export function parseTeamNpmScopes(input: unknown): string[] {
31
+ if (!Array.isArray(input)) throw new ManagedMutationError(400, 'bad-scopes')
32
+ const scopes = new Set<string>()
33
+ for (const value of input) {
34
+ const scope = normalizeNpmScope(value)
35
+ if (scope === null) throw new ManagedMutationError(400, 'bad-scope')
36
+ scopes.add(scope)
37
+ }
38
+ if (scopes.size > MAX_TEAM_NPM_SCOPES) throw new ManagedMutationError(400, 'too-many-scopes')
39
+ return [...scopes].toSorted()
40
+ }
41
+
42
+ export function teamNpmScopeMethods(db: ManagedSql): TeamNpmScopeStore {
43
+ return {
44
+ async listTeamNpmScopes() {
45
+ 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 }[]
46
+ const result: Record<string, string[]> = {}
47
+ for (const { teamId, scope } of rows) (result[teamId] ??= []).push(scope)
48
+ return result
49
+ },
50
+ async setTeamNpmScopes(sessionId, teamId, input) {
51
+ const user = await db.prepare(`SELECT u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
52
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { role: string } | undefined
53
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
54
+ if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
55
+ const scopes = parseTeamNpmScopes(input)
56
+ if (!await db.prepare('SELECT id FROM managed_team WHERE id = ?').get(teamId)) return null
57
+ const current = (await db.prepare('SELECT scope FROM managed_team_npm_scope WHERE team_id = ?').all(teamId) as { scope: string }[]).map(row => row.scope)
58
+ const removed = current.filter(scope => !scopes.includes(scope)).toSorted()
59
+ const added = scopes.filter(scope => !current.includes(scope))
60
+ for (const scope of removed) await db.prepare('DELETE FROM managed_team_npm_scope WHERE team_id = ? AND scope = ?').run(teamId, scope)
61
+ for (const scope of added) await db.prepare('INSERT INTO managed_team_npm_scope (team_id, scope) VALUES (?, ?)').run(teamId, scope)
62
+ return { added, removed }
63
+ },
64
+ async listUserNpmScopes(userId) {
65
+ const rows = await db.prepare(`SELECT DISTINCT ns.scope FROM managed_team_user tu
66
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
67
+ JOIN managed_team_npm_scope ns ON ns.team_id = tu.team_id
68
+ WHERE tu.user_id = ? ORDER BY ns.scope`).all(userId) as { scope: string }[]
69
+ return rows.map(row => row.scope)
70
+ },
71
+ async hasTeamNpmScope(scope) {
72
+ return !!await db.prepare(`SELECT 1 AS listed FROM managed_team_npm_scope ns
73
+ JOIN managed_team t ON t.id = ns.team_id AND t.hidden = 0
74
+ WHERE ns.scope = ? LIMIT 1`).get(scope)
75
+ },
76
+ }
77
+ }
@@ -0,0 +1,228 @@
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 { backfillFindingIds, reportEntries, stampSecurityGroups } from '@preventive/report'
6
+ import { managedFindingSourcePaths, readManagedReport } from '../common/managed/report-content.ts'
7
+ import { filterReportData, projectFinding } from '../common/managed/report-filter.ts'
8
+ import type { BlobStore } from './blob-store.ts'
9
+ import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
10
+ import { triageWireEntry } from './triage-response.ts'
11
+ import { visibleManagedIssues } from './managed-issues.ts'
12
+ import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
13
+
14
+ type Finding = Record<string, unknown>
15
+ type TeamReport = { id: string; filename: string; data: unknown; repo: { github: string | null; directory: string } }
16
+ type ReportVisibility = { ids: Set<string>; sourcePaths: Set<string> }
17
+ type Visibility = Map<string, ReportVisibility>
18
+ const VISIBILITY_CACHE_MAX_ITEMS = 250_000
19
+ const VISIBILITY_CACHE_MAX_SNAPSHOTS = 32
20
+ const caches = new WeakMap<ManagedDb, Map<string, Visibility>>()
21
+ const REPORT_CACHE_BYTES = 16 * 1024 * 1024
22
+ const reportCaches = new WeakMap<ManagedDb, Map<string, Buffer>>()
23
+ function retainReports(db: ManagedDb, key: string, bytes: Buffer) {
24
+ if (bytes.length > REPORT_CACHE_BYTES) return
25
+ let cache = reportCaches.get(db)
26
+ if (!cache) { cache = new Map(); reportCaches.set(db, cache) }
27
+ cache.delete(key)
28
+ let total = bytes.length + [...cache.values()].reduce((sum, value) => sum + value.length, 0)
29
+ for (const [oldest, value] of cache) {
30
+ if (total <= REPORT_CACHE_BYTES && cache.size < VISIBILITY_CACHE_MAX_SNAPSHOTS) break
31
+ cache.delete(oldest); total -= value.length
32
+ }
33
+ cache.set(key, bytes)
34
+ }
35
+ function visibilityWeight(visible: Visibility): number {
36
+ return [...visible.values()].reduce((sum, v) => sum + v.ids.size + v.sourcePaths.size, 0)
37
+ }
38
+ function retainVisibility(db: ManagedDb, key: string, visible: Visibility): void {
39
+ let cache = caches.get(db)
40
+ // Replacement contributes its weight once and does not evict a peer merely
41
+ // because the cache already holds the maximum number of snapshots.
42
+ cache?.delete(key)
43
+ const incoming = visibilityWeight(visible)
44
+ if (incoming > VISIBILITY_CACHE_MAX_ITEMS) return
45
+ if (!cache) { cache = new Map(); caches.set(db, cache) }
46
+ let total = incoming + [...cache.values()].reduce((sum, entry) => sum + visibilityWeight(entry), 0)
47
+ for (const [oldest, entry] of cache) {
48
+ if (cache.size < VISIBILITY_CACHE_MAX_SNAPSHOTS && total <= VISIBILITY_CACHE_MAX_ITEMS) break
49
+ cache.delete(oldest)
50
+ total -= visibilityWeight(entry)
51
+ }
52
+ cache.set(key, visible)
53
+ }
54
+ export class TeamReportsError extends Error {
55
+ status: number
56
+ constructor(status: number, error: string) { super(error); this.status = status }
57
+ }
58
+ export function teamSnapshotKey(snapshot: TeamReportAccessSnapshot): string {
59
+ return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.hiddenReportId ?? null, snapshot.reports, snapshot.repositories, snapshot.linkRevision])
60
+ }
61
+ export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string, reportId: string | null = null): Promise<TeamReportAccessSnapshot> {
62
+ const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, reportId)
63
+ if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
64
+ if (!snapshot.teamId) throw new TeamReportsError(404, 'no-team')
65
+ if (reportId !== null && !snapshot.reports.some(report => report.id === reportId)) throw new TeamReportsError(404, 'no-report')
66
+ return snapshot
67
+ }
68
+ export async function recheckTeam(db: ManagedDb, sessionId: string, snapshot: TeamReportAccessSnapshot): Promise<void> {
69
+ if (teamSnapshotKey(await teamSnapshot(db, sessionId, snapshot.teamId!, snapshot.reportId)) !== teamSnapshotKey(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
70
+ }
71
+ function groupsOf(data: unknown): Finding[][] {
72
+ return (reportEntries(data) ?? []).map(entry => (Array.isArray(entry) ? entry : [entry]).filter(
73
+ (finding): finding is Finding => finding !== null && typeof finding === 'object' && !Array.isArray(finding),
74
+ ))
75
+ }
76
+ function linksOf(data: unknown): string[][] | null {
77
+ const record = data as { source?: string; links?: string[][] }
78
+ if (record?.source !== 'links' || !Array.isArray(record.links)) return null
79
+ if (!record.links.every(ids => Array.isArray(ids) && ids.every(id => typeof id === 'string'))) throw new TeamReportsError(422, 'unreadable-links')
80
+ return record.links
81
+ }
82
+ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<{ body: Buffer; visible: Visibility }> {
83
+ 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')
84
+ const reports: TeamReport[] = []
85
+ let inputBytes = 0, next = 0
86
+ // Bound both remote reads and raw buffers, retaining stable report order for
87
+ // the cross-report classification that follows. Finish workers on failure.
88
+ const results = await Promise.allSettled(Array.from({ length: Math.min(4, snapshot.reports.length) }, async () => {
89
+ while (next < snapshot.reports.length) {
90
+ const index = next++
91
+ const access = snapshot.reports[index]!
92
+
93
+ const bytes = await store.get(access.id)
94
+ if (!bytes) throw new TeamReportsError(503, 'unavailable')
95
+ inputBytes += bytes.length
96
+ if (inputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
97
+ const parsed = readManagedReport(bytes.toString('utf8'), access.filename)
98
+ if (!parsed.data) throw new TeamReportsError(422, 'unreadable-report')
99
+ // Backfill before filtering so links, annotations and the client agree on
100
+ // IDs even when hiding a component would change the original row shape.
101
+ await backfillFindingIds(groupsOf(parsed.data).flat())
102
+ reports[index] = { id: access.id, filename: access.filename, data: parsed.data, repo: access.repo }
103
+ }
104
+ }))
105
+ for (const result of results) if (result.status === 'rejected') throw result.reason
106
+ const globalLinks = snapshot.linkRevision ? await db.getEnabledLinkGroups(snapshot.linkRevision) : []
107
+ const edges = new Map<string, Set<string>>()
108
+ const connect = (a: string, b: string) => {
109
+ if (!edges.has(a)) edges.set(a, new Set())
110
+ edges.get(a)!.add(b)
111
+ }
112
+ // A star is enough for transitive security propagation, without a quadratic
113
+ // expansion for large links. Unknown IDs can connect known findings.
114
+ for (const ids of globalLinks.concat(reports.flatMap(report => linksOf(report.data) ?? []))) {
115
+ for (const id of ids.slice(1)) { connect(ids[0]!, id); connect(id, ids[0]!) }
116
+ }
117
+ const groups = reports.flatMap(report => groupsOf(report.data).map(group => group.map(f => projectFinding(f, report.data as Finding)!)))
118
+ stampSecurityGroups(groups, { linkedIds: id => edges.get(id) ?? [] })
119
+ const securityIds = new Set(groups.flat().filter(f => f['isSecurity'] === true).map(f => String(f['id'])))
120
+ const allIds = new Set<string>(), visible: Visibility = new Map()
121
+ for (let i = 0; i < reports.length; i++) {
122
+ const access = snapshot.reports[i]!, report = reports[i]!
123
+ report.data = filterReportData(report.data, access.permissions, access.repo, securityIds)
124
+ const ids = new Set<string>()
125
+ for (const finding of groupsOf(report.data).flat()) {
126
+ // Preserve linked security classification for the UI lens, including a
127
+ // linked component that a dependency permission will hide from the UI.
128
+ finding['isSecurity'] = securityIds.has(String(finding['id']))
129
+ if (typeof finding['id'] === 'string') { ids.add(finding['id']); allIds.add(finding['id']) }
130
+ }
131
+ visible.set(report.id, { ids, sourcePaths: managedFindingSourcePaths(groupsOf(report.data).flat()) })
132
+ }
133
+ for (const report of reports) {
134
+ const links = linksOf(report.data)
135
+ // Hidden previews are independently authorized; preserve a hidden links
136
+ // document even though its referenced findings are outside this response.
137
+ 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) }
138
+ }
139
+ let outputBytes = 14
140
+ const parts = [Buffer.from('{"reports":[')]
141
+ for (const [index, report] of reports.entries()) {
142
+ const bytes = Buffer.from(JSON.stringify(report))
143
+ outputBytes += bytes.length + 1
144
+ if (outputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
145
+ if (index) parts.push(Buffer.from(','))
146
+ parts.push(bytes)
147
+ }
148
+ const links = mergeLinkGroups(globalLinks, allIds)
149
+ const suffix = Buffer.from(links.length > 0 ? `],"links":${JSON.stringify(links)}}` : ']}')
150
+ if (outputBytes + suffix.length > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
151
+ parts.push(suffix)
152
+ const body = Buffer.concat(parts)
153
+ retainVisibility(db, teamSnapshotKey(snapshot), visible)
154
+ retainReports(db, teamSnapshotKey(snapshot), body)
155
+ return { body, visible }
156
+ }
157
+ const pendingWorkspaces = new WeakMap<ManagedDb, Map<string, ReturnType<typeof buildTeamWorkspace>>>()
158
+ async function loadTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot) {
159
+ let pending = pendingWorkspaces.get(db)
160
+ if (!pending) { pending = new Map(); pendingWorkspaces.set(db, pending) }
161
+ const key = teamSnapshotKey(snapshot)
162
+ const existing = pending.get(key)
163
+ if (existing) return existing
164
+ const job = buildTeamWorkspace(db, store, snapshot)
165
+ pending.set(key, job)
166
+ try { return await job } finally { pending.delete(key) }
167
+ }
168
+ // Treat the encoded body as immutable and recheck access before sending it.
169
+ // Warm HTTP reads need neither a parsed copy nor another serialization pass.
170
+ export async function loadTeamReportsResponse(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Buffer> {
171
+ const cached = reportCaches.get(db)?.get(teamSnapshotKey(snapshot))
172
+ return cached ?? (await loadTeamWorkspace(db, store, snapshot)).body
173
+ }
174
+ export async function loadTeamReports(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<TeamReport[]> {
175
+ // Object consumers get their own copy, including when sharing a cold load.
176
+ const body = await loadTeamReportsResponse(db, store, snapshot)
177
+ return (JSON.parse(body.toString('utf8')) as { reports: TeamReport[] }).reports
178
+ }
179
+ async function teamVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Visibility> {
180
+ const key = teamSnapshotKey(snapshot)
181
+ let visible = caches.get(db)?.get(key)
182
+ if (!visible) {
183
+ // A large snapshot may deliberately remain uncached, or be evicted by
184
+ // another request. Authorization uses this load's result in either case.
185
+ visible = (await loadTeamWorkspace(db, store, snapshot)).visible
186
+ }
187
+ return visible
188
+ }
189
+ export async function teamWorkspaceFindingIds(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Set<string>> {
190
+ const ids = new Set<string>()
191
+ for (const report of (await teamVisibility(db, store, snapshot)).values()) for (const id of report.ids) ids.add(id)
192
+ return ids
193
+ }
194
+ export async function teamReportVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot, reportId: string): Promise<ReportVisibility> {
195
+ if (!snapshot.reports.some(r => r.id === reportId)) throw new TeamReportsError(404, 'no-report')
196
+ const visible = await teamVisibility(db, store, snapshot)
197
+ return visible.get(reportId)!
198
+ }
199
+ export async function teamFindingIds(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
200
+ const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
201
+ const visible = await teamReportVisibility(db, store, snapshot, reportId)
202
+ await recheckTeam(db, sessionId, snapshot)
203
+ return visible.ids
204
+ }
205
+ export async function teamSourcePaths(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
206
+ const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
207
+ const visible = await teamReportVisibility(db, store, snapshot, reportId)
208
+ await recheckTeam(db, sessionId, snapshot)
209
+ return visible.sourcePaths
210
+ }
211
+
212
+ // Serialize shared annotation bodies once. Reports carry only the annotated
213
+ // finding IDs visible in that report; callers revalidate access before sending.
214
+ export async function loadTeamAnnotations(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot, reportId: string | null = null) {
215
+ // Restrict annotation reads only after classifying the complete workspace:
216
+ // links and findings in other reports can affect this report's visibility.
217
+ const visible = reportId === null ? await teamVisibility(db, store, snapshot)
218
+ : new Map([[reportId, await teamReportVisibility(db, store, snapshot, reportId)]])
219
+ const ids = new Set<string>()
220
+ for (const report of visible.values()) for (const id of report.ids) ids.add(id)
221
+ const { triage, comments, issues: savedIssues } = await db.getAnnotations([...ids])
222
+ const issues = Object.fromEntries(visibleManagedIssues(savedIssues, snapshot)
223
+ .map(issue => [issue.findingId, { url: issue.issueUrl!, autoFix: issue.autoFixUrl }]))
224
+ const entries = Object.fromEntries(triage.map(row => [row.findingId, triageWireEntry(row)]))
225
+ const annotated = new Set([...triage.map(row => row.findingId), ...comments.map(comment => comment.findingId), ...Object.keys(issues)])
226
+ const reports = Object.fromEntries([...visible].map(([id, report]) => [id, [...report.ids].filter(finding => annotated.has(finding))]))
227
+ return { reports, entries, comments, issues }
228
+ }
@@ -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, as each
7
+ // side holds types alone and neither keeps an `export {}`, which is what it
8
+ // would stamp of them paired. `[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,103 @@
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, so that it runs nothing (proveTypeOnly) --
8
+ // and nothing else. Proven in a worker, off the event loop, a comparison at a
9
+ // time per process, for at most a minute; told a line each, `{ path, hash,
10
+ // otherPath, otherHash }`, a side not there null, which the viewer checks
11
+ // name the files it shows. Access is the caller's to check.
12
+ import { Worker } from 'node:worker_threads'
13
+ import { type ComparedSide, PrettyError, type PrettyRequest } from './pretty-print.ts'
14
+ import { comparedFiles } from './rename-diff.ts'
15
+
16
+ // TypeScript files, by extension as the pretty-print cache reads them: none
17
+ // other is asked about.
18
+ const TYPESCRIPT = new Set(['ts', 'mts', 'cts'])
19
+ // Comparisons proven at once per process, and those waiting their turn past
20
+ // them, any more refused; the longest one may take, its wait included.
21
+ const MAX_ACTIVE_PROOFS = 1
22
+ const MAX_WAITING_PROOFS = 16
23
+ const PROOF_TIMEOUT_MS = 60_000
24
+
25
+ type File = PrettyRequest & { text: string }
26
+ export interface TypeOnlyLine { path: string | null; hash: string | null; otherPath: string | null; otherHash: string | null }
27
+
28
+ let active = 0
29
+ const waiting: Array<() => void> = []
30
+
31
+ async function turn(deadline: number): Promise<void> {
32
+ if (active < MAX_ACTIVE_PROOFS) { active++; return }
33
+ if (waiting.length >= MAX_WAITING_PROOFS) throw new PrettyError(429, 'proof-busy')
34
+ await new Promise<void>((resolve, reject) => {
35
+ const next = () => { clearTimeout(timer); resolve() }
36
+ const timer = setTimeout(() => {
37
+ waiting.splice(waiting.indexOf(next), 1)
38
+ reject(new PrettyError(504, 'proof-timeout'))
39
+ }, deadline - Date.now())
40
+ waiting.push(next)
41
+ })
42
+ }
43
+ function done() {
44
+ const next = waiting.shift()
45
+ if (next) next()
46
+ else active--
47
+ }
48
+
49
+ // Which of `pairs` compareTrees stamps types only, by index, in a worker
50
+ // settled once it is gone; none where it fails, as no proof should.
51
+ function proven(pairs: Array<[File | null, File | null]>, deadline: number): Promise<boolean[]> {
52
+ let worker: Worker
53
+ try {
54
+ worker = new Worker(new URL('./type-only-worker.js', import.meta.url), {
55
+ workerData: { pairs: pairs.map(sides => sides.map(file => file && { path: file.path, text: file.text })) },
56
+ // Node's type stripping, which the proof holds oxc's tree to, warns it is experimental.
57
+ execArgv: ['--disable-warning=ExperimentalWarning'], resourceLimits: { maxOldGenerationSizeMb: 1024 },
58
+ })
59
+ } catch (err) {
60
+ console.warn('managed: a type-only proof could not start:', err)
61
+ throw new PrettyError(503, 'unavailable')
62
+ }
63
+ return new Promise((resolve, reject) => {
64
+ let answer: boolean[] | null = null, failure = new PrettyError(422, 'unproven')
65
+ const timer = setTimeout(() => { failure = new PrettyError(504, 'proof-timeout'); void worker.terminate() }, deadline - Date.now())
66
+ worker.once('message', (told: unknown) => {
67
+ if (Array.isArray(told) && told.length === pairs.length && told.every(one => typeof one === 'boolean')) answer = told
68
+ void worker.terminate()
69
+ })
70
+ worker.once('error', err => { console.warn('managed: a type-only proof failed:', err) })
71
+ worker.once('exit', () => {
72
+ clearTimeout(timer)
73
+ if (answer) resolve(answer)
74
+ else reject(failure)
75
+ })
76
+ })
77
+ }
78
+
79
+ // The files that changed between a comparison's two sides in types alone,
80
+ // as the viewer pairs them, a line each once all are proven, in path order.
81
+ export function typeOnlyFiles([base, other]: readonly [ComparedSide, ComparedSide]): AsyncGenerator<TypeOnlyLine> {
82
+ const typescript = ({ files }: ComparedSide) => new Map(files.filter(file => TYPESCRIPT.has(file.extension)).map(file => [file.path, file]))
83
+ const [olds, news] = [typescript(base), typescript(other)]
84
+ const { changed, onlyBase, onlyOther } = comparedFiles([base, other])
85
+ // A side there not a TypeScript file this reads leaves its pair out; one not there is null.
86
+ const pairs = [...changed.map(({ path, basePath = path }) => [olds.get(basePath), news.get(path)]),
87
+ ...onlyBase.map(({ path, basePath = path }) => [olds.get(basePath), null]), ...onlyOther.map(({ path }) => [null, news.get(path)])]
88
+ .filter((sides): sides is [File | null, File | null] => sides.every(file => file !== undefined) && sides.some(file => file !== null) && sides[0]?.hash !== sides[1]?.hash)
89
+ .toSorted(([a, b], [c, d]) => {
90
+ const [x, y] = [(b ?? a)!.path, (d ?? c)!.path]
91
+ return x < y ? -1 : x > y ? 1 : 0
92
+ })
93
+ return (async function* () {
94
+ if (pairs.length === 0) return
95
+ const deadline = Date.now() + PROOF_TIMEOUT_MS
96
+ await turn(deadline)
97
+ let stamped
98
+ try { stamped = await proven(pairs, deadline) } finally { done() }
99
+ for (const [i, [from, to]] of pairs.entries()) {
100
+ if (stamped[i]) yield { path: from?.path ?? null, hash: from?.hash ?? null, otherPath: to?.path ?? null, otherHash: to?.hash ?? null }
101
+ }
102
+ })()
103
+ }