@preventive/triage 1.0.0-alpha.52 → 1.0.0-alpha.54

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 (112) hide show
  1. package/common/bundle-compare-diff.js +28 -9
  2. package/common/bundle-compare-inputs.d.ts +1 -0
  3. package/common/bundle-compare-inputs.js +91 -15
  4. package/common/bundle-comparison.js +14 -7
  5. package/common/default-scan-models.ts +3 -0
  6. package/common/dependency-reviews.d.ts +1 -1
  7. package/common/dependency-reviews.js +1 -1
  8. package/common/managed/finding-detail.d.ts +4 -2
  9. package/common/managed/finding-detail.js +27 -15
  10. package/common/managed/team-app.d.ts +9 -0
  11. package/common/managed/team-app.js +59 -6
  12. package/common/workspace-app.js +38 -6
  13. package/out/activity.js +4 -4
  14. package/out/chunk-4EMM6UCD.js +1 -0
  15. package/out/chunk-4ISO5F7Y.js +7 -0
  16. package/out/chunk-4XIH7YTX.js +1 -0
  17. package/out/chunk-6W5BMATD.js +1 -0
  18. package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
  19. package/out/chunk-DEVAORP2.js +1 -0
  20. package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
  21. package/out/chunk-E3LFSUDJ.js +9 -0
  22. package/out/chunk-FHIVVVUA.js +6 -0
  23. package/out/chunk-G3LY2FV4.js +1 -0
  24. package/out/chunk-HAZKDK4M.js +4 -0
  25. package/out/chunk-HOB3YPS5.js +1 -0
  26. package/out/chunk-I47TYPXO.js +52 -0
  27. package/out/chunk-J7A5OSZE.js +244 -0
  28. package/out/chunk-JFL7WLRV.js +1 -0
  29. package/out/chunk-JMG5DXII.js +2 -0
  30. package/out/chunk-JOXLZXMI.js +1 -0
  31. package/out/chunk-L4CTEMRG.js +2 -0
  32. package/out/chunk-M37X4LCF.js +14 -0
  33. package/out/chunk-M3Y6SLC4.js +1 -0
  34. package/out/chunk-NXATZJQ2.js +1 -0
  35. package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
  36. package/out/chunk-PAD2JHZT.js +1 -0
  37. package/out/chunk-QMVOEBWC.js +13 -0
  38. package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
  39. package/out/chunk-RRBIJL2B.js +1 -0
  40. package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
  41. package/out/chunk-TOXMUOIW.js +5 -0
  42. package/out/chunk-UBOCLLF2.js +1 -0
  43. package/out/chunk-UCP3JRE2.js +1 -0
  44. package/out/{chunk-NCN3M5EP.js → chunk-VC72ZO3P.js} +2 -2
  45. package/out/chunk-VRX2APNA.js +9 -0
  46. package/out/{chunk-IMM274IW.js → chunk-VZMELPVL.js} +4 -3
  47. package/out/{chunk-CHRBUGVN.js → chunk-WHVXTJHK.js} +1 -1
  48. package/out/chunk-WMFDQS2L.js +11 -0
  49. package/out/chunk-WNSVBOSO.js +41 -0
  50. package/out/chunk-WREU7JAG.js +1 -0
  51. package/out/chunk-Y5ZHFNBD.js +2 -0
  52. package/out/chunk-YF4M5OCI.js +1 -0
  53. package/out/chunk-Z667U2UJ.js +1 -0
  54. package/out/chunk-ZQHLECTI.js +1 -0
  55. package/out/client-managed-import.js +1 -1
  56. package/out/client-managed.js +27 -26
  57. package/out/compare.js +14 -14
  58. package/out/dialogs.js +36 -51
  59. package/out/graph.js +17 -15
  60. package/out/index.html +26 -10
  61. package/out/prism.js +1 -1
  62. package/out/scan.js +2 -2
  63. package/out/terminal.js +232 -232
  64. package/out/view.css +1 -1
  65. package/out/view.js +15 -15
  66. package/package.json +12 -7
  67. package/risk/actions/resolve.js +36 -5
  68. package/risk/index.d.ts +20 -2
  69. package/risk/npm/malware.js +6 -4
  70. package/risk/npm/registry.js +8 -6
  71. package/server-managed/bundle-advisories.ts +56 -74
  72. package/server-managed/bundle-build-worker.js +61 -4
  73. package/server-managed/bundle-build.ts +13 -4
  74. package/server-managed/bundle-dependency-reviews.ts +74 -0
  75. package/server-managed/bundle-validation.ts +32 -0
  76. package/server-managed/db-methods.ts +51 -16
  77. package/server-managed/db-neon.ts +18 -2
  78. package/server-managed/db.ts +11 -2
  79. package/server-managed/github-issue-links.ts +151 -47
  80. package/server-managed/github-pulls.ts +34 -15
  81. package/server-managed/history-windows.ts +172 -0
  82. package/server-managed/http.ts +227 -12
  83. package/server-managed/index.ts +13 -1
  84. package/server-managed/managed-issues.ts +29 -15
  85. package/server-managed/npm-insights.ts +4 -3
  86. package/server-managed/npm-kept.ts +4 -0
  87. package/server-managed/package-entry-points.ts +37 -9
  88. package/server-managed/pretty-print.ts +7 -5
  89. package/server-managed/public-workspace.ts +2 -2
  90. package/server-managed/repository-actions.ts +7 -4
  91. package/server-managed/repository-browser.ts +11 -4
  92. package/server-managed/repository-history.ts +2 -2
  93. package/server-managed/repository-pulse.ts +11 -2
  94. package/server-managed/repository-risk-audit.ts +4 -5
  95. package/server-managed/repository-risk.ts +4 -4
  96. package/server-managed/sql.ts +2 -1
  97. package/server-managed/stasis-config.ts +46 -0
  98. package/server-managed/team-app-summaries.ts +54 -0
  99. package/server-managed/team-app.ts +52 -31
  100. package/server-managed/team-reports.ts +34 -13
  101. package/server-managed/upstream-cache.ts +41 -32
  102. package/server-managed/workspace-shares.ts +3 -1
  103. package/out/chunk-2ZDZWLFY.js +0 -1
  104. package/out/chunk-7VAWINQF.js +0 -6
  105. package/out/chunk-FMXWXDR4.js +0 -36
  106. package/out/chunk-IZ7POWI3.js +0 -18
  107. package/out/chunk-LJ7XRXFU.js +0 -52
  108. package/out/chunk-OGAYMEER.js +0 -232
  109. package/out/chunk-QB4CBOX2.js +0 -1
  110. package/out/chunk-TQJDYHVE.js +0 -2
  111. package/out/chunk-VH4XMZIW.js +0 -2
  112. package/out/chunk-WYLQWYLA.js +0 -1
@@ -6,6 +6,9 @@ import { ensureUserAccessToken } from './github-oauth.ts'
6
6
 
7
7
  type Metadata = Pick<GithubMetadata, 'title' | 'description' | 'status' | 'stateReason'>
8
8
  const MAX_GITHUB_LOOKUPS = 200
9
+ // A load reading this many open or unknown items anew also rechecks one closed
10
+ // item, rounded up (lookupFixes).
11
+ const CLOSED_RECHECK_EVERY = 10
9
12
  const GITHUB_LOOKUP_TIMEOUT_MS = 10_000
10
13
  const GITHUB_METADATA_TTL_MS = 60_000
11
14
  const GITHUB_PUBLIC_VISIBILITY_TTL_MS = 60_000
@@ -132,6 +135,32 @@ async function fetchMetadata(ref: GithubFixRef, token: string | null, fetchImpl:
132
135
  } catch { return null }
133
136
  }
134
137
 
138
+ // What a load reads of its `entries`, by what is cached of them at `now`:
139
+ // those missing or stale, the least recently checked first, as many as
140
+ // MAX_GITHUB_LOOKUPS. Closed items are kept once fetched, but GitHub can
141
+ // reopen them (merged ones are final): a load reading open or unknown items
142
+ // anew also rechecks a tenth as many closed ones, the least recently checked
143
+ // first, after them. None where every item is closed or fresh, and a busy
144
+ // workspace cycles through its closed items, a recheck rotating its item
145
+ // behind the others whether or not GitHub answers.
146
+ function readsOf<Entry extends [string, GithubFixRef]>(entries: Entry[], metadata: Map<string, GithubMetadata>, now: number): Entry[] {
147
+ const lastCheckedAt = ([key]: Entry) => Math.max(metadata.get(key)?.fetchedAt ?? 0, metadata.get(key)?.attemptedAt ?? 0)
148
+ const oldestFirst = (a: Entry, b: Entry) => lastCheckedAt(a) - lastCheckedAt(b)
149
+ const stale = ([key, ref]: Entry) => {
150
+ const cached = metadata.get(key)
151
+ // Backfill closed issues cached before closure reasons existed, within the
152
+ // same authorized/capped queue. Even an unknown reason completes backfill.
153
+ return !cached || (ref.kind === 'issue' && cached.status === 'closed' && cached.stateReason == null)
154
+ || (['open', 'draft'].includes(cached.status) && cached.fetchedAt + GITHUB_METADATA_TTL_MS <= now)
155
+ }
156
+ const pending = entries.filter(stale).toSorted(oldestFirst).slice(0, MAX_GITHUB_LOOKUPS)
157
+ // Stale ones are missing, open, draft, or closed issues awaiting backfill.
158
+ const reread = pending.filter(([key]) => metadata.get(key)?.status !== 'closed').length
159
+ const rechecks = entries.filter(entry => metadata.get(entry[0])?.status === 'closed' && !stale(entry))
160
+ .toSorted(oldestFirst).slice(0, Math.ceil(reread / CLOSED_RECHECK_EVERY))
161
+ return [...pending, ...rechecks]
162
+ }
163
+
135
164
  // The workspace handler checks user -> team membership and finding visibility.
136
165
  // Shared private metadata also requires this viewer's live GitHub access,
137
166
  // including for admins. Only recently verified public repos skip that check.
@@ -169,27 +198,17 @@ export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot
169
198
  const readable = await readableRepositories(repositories, context)
170
199
  const authorized = [...jobs.entries()].filter(([, ref]) => readable.has(ref.repo))
171
200
  const metadata = new Map((await db.listGithubMetadata(authorized.map(([key]) => key))).map(entry => [entry.key, entry]))
172
- const lastCheckedAt = (key: string) => {
173
- const cached = metadata.get(key)
174
- return Math.max(cached?.fetchedAt ?? 0, cached?.attemptedAt ?? 0)
175
- }
176
- const pending = authorized.filter(([key, ref]) => {
177
- const cached = metadata.get(key)
178
- // Backfill closed issues cached before closure reasons existed, within the
179
- // same authorized/capped queue. Even an unknown reason completes backfill.
180
- return !cached || (ref.kind === 'issue' && cached.status === 'closed' && cached.stateReason == null)
181
- || (['open', 'draft'].includes(cached.status) && cached.fetchedAt + GITHUB_METADATA_TTL_MS <= now)
182
- }).toSorted(([a], [b]) => lastCheckedAt(a) - lastCheckedAt(b)).slice(0, MAX_GITHUB_LOOKUPS)
201
+ const reads = readsOf(authorized, metadata, now)
183
202
  // Only stale/missing, distinct, authorized entries consume the request budget.
184
203
  // Prioritize missing/oldest entries so a large workspace makes progress.
185
- if (pending.length > 0) {
186
- const publicRefreshes = new Set(pending.filter(([, ref]) => publicRepos.has(ref.repo) && !checks.verified.has(ref.repo)).map(([, ref]) => ref.repo))
204
+ if (reads.length > 0) {
205
+ const publicRefreshes = new Set(reads.filter(([, ref]) => publicRepos.has(ref.repo) && !checks.verified.has(ref.repo)).map(([, ref]) => ref.repo))
187
206
  if (publicRefreshes.size > 0) token = await verifyPublicRefreshes(config, snapshot.user.id, repositories.filter(repo => publicRefreshes.has(repo.github)), context)
188
207
  const fresh: Omit<GithubMetadata, 'attemptedAt'>[] = []
189
208
  const attempted: string[] = []
190
209
  // All waves and access checks share one deadline and four-call limit.
191
- for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
192
- await Promise.all(pending.slice(i, i + 4).map(async ([key, ref]) => {
210
+ for (let i = 0; i < reads.length && !signal.aborted; i += 4) {
211
+ await Promise.all(reads.slice(i, i + 4).map(async ([key, ref]) => {
193
212
  if (signal.aborted) return
194
213
  if (metadata.has(key)) attempted.push(key)
195
214
  const result = await fetchMetadata(ref, publicRepos.has(ref.repo) && !checks.verified.has(ref.repo) ? null : token, fetchWithinDeadline)
@@ -0,0 +1,172 @@
1
+ // A default branch's history read by when its commits were made, for the
2
+ // Pulse (repository-pulse.ts yearCommits): where the pages kept from a head
3
+ // run out, the rest of the time it needs read at once, in windows of time
4
+ // (GitHub's `history(since:, until:)`), not a page after another, each page
5
+ // waiting on the last; and the pages GitHub would list from the commits read
6
+ // (git log) kept by their tops, as reading a page at a time keeps them, so a
7
+ // walk later, from a head moved on or in the Commits tab, reads them kept.
8
+ import { linkNoreplyUsers } from './github-accounts.ts'
9
+ import { githubGraphql } from './github-graphql.ts'
10
+ import { COMMIT_FRAGMENT, type GraphCommit, HISTORY_PAGE, type HistoryCommit, type HistoryPages, PAGES_PARALLEL, PAGES_PER_QUERY, commitActors, graphCommit,
11
+ inBatches, variablesOf } from './repository-history.ts'
12
+
13
+ // Windows a read is split into at most, each meant to hold as many commits
14
+ // as WINDOW_COMMITS, a page's worth with room to spare, as often as the
15
+ // commits read before it were made, as far back as they reach, the rest left
16
+ // to the next read; pages read of them at most a read, those over going on
17
+ // where a window holds more, as many times as MAX_WAVES, each waiting on the
18
+ // last; and pages kept of what was read at most.
19
+ const MAX_WINDOWS = 40
20
+ const WINDOW_COMMITS = 70
21
+ const WINDOW_PAGES = 50
22
+ const MAX_WAVES = 5
23
+ const MAX_KEPT = 500
24
+ const KEPT_AT_ONCE = 10
25
+ const WINDOWS_AFTER = 2
26
+ const HOUR_MS = 60 * 60_000
27
+
28
+ // Each window's commits from `$head`, `w<n>` the `n`th's: those made since
29
+ // `$s<n>` (none, any time) and until `$u<n>`, after `$a<n>`.
30
+ const windowsQuery = (count: number) => `query($owner: String!, $name: String!, $head: GitObjectID!, ${variablesOf('s', count, 'GitTimestamp')}, ${
31
+ variablesOf('u', count, 'GitTimestamp!')}, ${variablesOf('a', count, 'String')}) {
32
+ repository(owner: $owner, name: $name) {
33
+ object(oid: $head) { ... on Commit {
34
+ ${Array.from({ length: count }, (_, at) => `w${at}: history(first: ${HISTORY_PAGE}, since: $s${at}, until: $u${at}, after: $a${at}) {
35
+ pageInfo { hasNextPage endCursor } nodes { ...historyCommit } }`).join('\n ')}
36
+ } }
37
+ }
38
+ }
39
+ ${COMMIT_FRAGMENT}`
40
+
41
+ type Window = { since: number | null; until: number; after: string | null; oldest?: number }
42
+ type WindowPage = { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: GraphCommit[] } | null
43
+ type Windows = { repository?: { object?: Record<string, WindowPage> | null } | null } | null
44
+ // A time as GitHub takes it, to the second: a window's start rounded down,
45
+ // its end up, so that those beside each other overlap rather than part.
46
+ const stamp = (at: number, up: boolean) => new Date((up ? Math.ceil : Math.floor)(at / 1000) * 1000).toISOString()
47
+
48
+ // The commits from `head` made from `low` to `high`, as often as `perMs` (commits
49
+ // a millisecond) says they are made, as many windows as MAX_WINDOWS reach
50
+ // down from `high`, read at once (inBatches), the latest first, each paged on
51
+ // as far as it goes, as many pages as WINDOW_PAGES; and, those reaching
52
+ // `low`, a page of those made before it besides, where a walk reading them
53
+ // goes on from. Since when every one is read (`through`: where the windows
54
+ // reach, each read whole, else, of the latest not, just after the earliest
55
+ // made of those it read, as GitHub lists them the latest made first, those
56
+ // made together left out; or its end, none read), and how many pages were
57
+ // read (`read`).
58
+ export async function readWindows(fullName: string, token: string, head: string, low: number, high: number, perMs: number, fetchImpl: typeof fetch) {
59
+ const [owner, name] = fullName.split('/') as [string, string]
60
+ const bottom = Math.max(low, high - MAX_WINDOWS * WINDOW_COMMITS / perMs)
61
+ const count = Math.min(MAX_WINDOWS, Math.max(1, Math.ceil((high - bottom) * perMs / WINDOW_COMMITS)))
62
+ const width = (high - bottom) / count
63
+ let open: Window[] = [...Array.from({ length: count }, (_, at) => ({ since: high - (at + 1) * width, until: high - at * width, after: null })),
64
+ ...bottom > low ? [] : [{ since: null, until: low - 1000, after: null }]]
65
+ const commits = new Map<string, HistoryCommit>()
66
+ let [read, waves] = [0, 0]
67
+ while (open.length > 0 && read < WINDOW_PAGES && waves++ < MAX_WAVES) {
68
+ const wave = open.slice(0, WINDOW_PAGES - read)
69
+ const next: Window[] = []
70
+ read += wave.length
71
+ await inBatches(wave, PAGES_PER_QUERY, PAGES_PARALLEL, async batch => {
72
+ const data = await githubGraphql(windowsQuery(batch.length), { owner, name, head, ...Object.fromEntries(batch.flatMap((window, at) => [
73
+ [`s${at}`, window.since === null ? null : stamp(window.since, false)], [`u${at}`, stamp(window.until, true)], [`a${at}`, window.after]])) }, token, fetchImpl) as Windows
74
+ const pages = batch.map((_, at) => data?.repository?.object?.[`w${at}`] ?? null)
75
+ await linkNoreplyUsers(pages.flatMap(page => page?.nodes ?? []).flatMap(commitActors), token, fetchImpl)
76
+ for (const [at, window] of batch.entries()) {
77
+ let oldest = window.oldest ?? Number.POSITIVE_INFINITY
78
+ for (const commit of (pages[at]?.nodes ?? []).flatMap(node => graphCommit(node) ?? [])) {
79
+ if (!commits.has(commit.sha)) commits.set(commit.sha, commit)
80
+ oldest = Math.min(oldest, commit.committedAt ?? Number.POSITIVE_INFINITY)
81
+ }
82
+ const info = pages[at]?.pageInfo
83
+ if (window.since !== null && info?.hasNextPage === true && typeof info.endCursor === 'string') next.push({ ...window, after: info.endCursor, oldest })
84
+ }
85
+ })
86
+ open = [...next, ...open.slice(wave.length)]
87
+ }
88
+ const through = Math.max(bottom, ...open.flatMap(window => window.since === null ? [] : [Number.isFinite(window.oldest) ? window.oldest! + 1 : window.until]))
89
+ return { commits: [...commits.values()], through, read }
90
+ }
91
+
92
+ // The page GitHub would list from `top` (git log: of those reached, the
93
+ // latest made first, of those made together the first reached) of `known`
94
+ // commits, every one made since `low` known; none where it would list one not
95
+ // known, or one made before `low` while one not known waits beside it.
96
+ export function gitLogPage(top: string, known: Map<string, HistoryCommit>, low: number): HistoryCommit[] | null {
97
+ if (!known.has(top)) return null
98
+ const page: HistoryCommit[] = []
99
+ const reached = new Set<string>()
100
+ const waiting: HistoryCommit[] = []
101
+ let unknown = 0
102
+ const reach = (sha: string) => {
103
+ if (reached.has(sha)) return
104
+ reached.add(sha)
105
+ const commit = known.get(sha)
106
+ if (!commit || commit.committedAt === null) { unknown++; return }
107
+ const at = waiting.findIndex(other => other.committedAt! < commit.committedAt!)
108
+ waiting.splice(at < 0 ? waiting.length : at, 0, commit)
109
+ }
110
+ reach(top)
111
+ while (page.length < HISTORY_PAGE && waiting.length > 0) {
112
+ const commit = waiting.shift()!
113
+ if (commit.committedAt! < low && unknown > 0) return null
114
+ page.push(commit)
115
+ for (const parent of commit.parents) reach(parent)
116
+ }
117
+ return page.length < HISTORY_PAGE && unknown > 0 ? null : page
118
+ }
119
+
120
+ // The pages from each of `tops` GitHub would list of `known` commits
121
+ // (gitLogPage), kept by their tops, KEPT_AT_ONCE at a time, and from the
122
+ // parents of those they list that none of `listed` is nor any kept lists, on
123
+ // till none is left, as many as MAX_KEPT: as a walk reading pages goes on to
124
+ // them.
125
+ export async function keepPages(tops: string[], known: Map<string, HistoryCommit>, listed: Set<string>, low: number, pages: HistoryPages) {
126
+ const covered = new Set(listed)
127
+ const kept: [string, HistoryCommit[]][] = []
128
+ const waiting = [...tops]
129
+ while (waiting.length > 0 && kept.length < MAX_KEPT) {
130
+ const top = waiting.shift()!
131
+ if (covered.has(top)) continue
132
+ const page = gitLogPage(top, known, low)
133
+ if (!page) continue
134
+ kept.push([top, page])
135
+ for (const commit of page) covered.add(commit.sha)
136
+ for (const commit of page) for (const parent of commit.parents) if (!covered.has(parent) && known.has(parent)) waiting.push(parent)
137
+ }
138
+ for (let at = 0; at < kept.length; at += KEPT_AT_ONCE) await Promise.all(kept.slice(at, at + KEPT_AT_ONCE).map(([top, page]) => pages.write(top, page)))
139
+ }
140
+
141
+ // What a walk from `head` (yearCommits) reads next of `starts`: those a page
142
+ // is kept from (`batch`), where any is, or all of them, a page at a time, as
143
+ // long as it has read GitHub fewer times than WINDOWS_AFTER (`queries`), a
144
+ // head moved on soon reaching the pages kept from where it was; else, none
145
+ // kept, those made from
146
+ // `low` to `high` read at once (readWindows), as often as those `found`
147
+ // were made, told it (`found`), and the pages from `starts` on kept of them,
148
+ // as far down as every one is read (keepPages), the queries that took
149
+ // (`queries`, PAGES_PER_QUERY pages each, as many at once as PAGES_PARALLEL
150
+ // a query of its) and none to read (`windowed`); where the windows can't be
151
+ // read, all of them, a page at a time (`windowed` all the same).
152
+ export async function windowsOn(fullName: string, token: string, head: string, starts: string[], found: Map<string, HistoryCommit>, low: number, high: number,
153
+ pages: HistoryPages, fetchImpl: typeof fetch, queries: number): Promise<{ batch: string[]; queries: number; windowed: boolean }> {
154
+ const kept = await pages.read(starts)
155
+ if (kept.size > 0 || queries < WINDOWS_AFTER || !(high > low) || !Number.isFinite(high)) {
156
+ return { batch: kept.size > 0 ? starts.filter(sha => kept.has(sha)) : starts, queries: 0, windowed: false }
157
+ }
158
+ let [newest, oldest, dated] = [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, 0]
159
+ for (const { committedAt } of found.values()) {
160
+ if (committedAt === null) continue
161
+ [newest, oldest, dated] = [Math.max(newest, committedAt), Math.min(oldest, committedAt), dated + 1]
162
+ }
163
+ // From the one commit the walk goes on from, where there is one, none of
164
+ // those read listed again; else from the head.
165
+ const read = await readWindows(fullName, token, starts.length === 1 ? starts[0]! : head, low, high, dated / Math.max(newest - oldest, HOUR_MS), fetchImpl)
166
+ .catch((err: unknown) => { console.warn('managed: history windows read failed:', err); return null })
167
+ if (!read) return { batch: starts, queries: 0, windowed: true }
168
+ const listed = new Set(found.keys())
169
+ for (const commit of read.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
170
+ await keepPages(starts, found, listed, read.through, pages)
171
+ return { batch: [], queries: Math.ceil(read.read / (PAGES_PER_QUERY * PAGES_PARALLEL)), windowed: true }
172
+ }
@@ -76,6 +76,10 @@
76
76
  // GET /api/admin/reports/<id>/location → admin|manage live location suggestion | 401/403/404/503
77
77
  // GET /api/bundles/<id>/{metadata,contents} → authorized encoded bytes | 401/404/422
78
78
  // GET /api/bundles/<id>/advisories → published dependency advisories (security access) | 401/403/404
79
+ // GET /api/bundles/<id>/dependency-review → what the npm versions it bundles want of review, as the lockfiles installing it lock
80
+ // them at the commit it was built from, where its repository's review is in effect for a reader of all of it | 401/404/409/413/422/429/502
81
+ // GET /api/bundles/<id>/validation → its last validation the reader may see, else none; POST validates it anew: its own code
82
+ // as GitHub has it at its commit, its dependencies as upstream's packages have them, those covering every file | 401/403/404/409/422/429/502
79
83
  // GET /api/bundles/<id>/download → authorized original upload | 401/404
80
84
  // GET /api/bundles/<id>/pretty?path=&hash= → one of its files pretty-printed, Brotli-encoded | 400/401/404/409/413/422/429
81
85
  // GET /api/bundles/<id>/pretty?other= → the files that changed between it and another bundle, both sides' pretty-printed,
@@ -89,6 +93,7 @@
89
93
  // GET /api/admin/bundles → admin all bundles; managers own/team bundles | 401/403
90
94
  // POST /api/admin/bundles → admin|manage uploads a bundle (raw body) | 401/403/413
91
95
  // GET /api/admin/bundles/<id> → admin|manage downloads a stored bundle | 401/403/404
96
+ // GET /api/admin/bundles/<id>/usage → admin|manage the reports linked to it or declaring it, asked before a delete | 401/403/404
92
97
  // DELETE /api/admin/bundles/<id> → admin|manage deletes a bundle | 401/403/404
93
98
  // POST /api/admin/bundles/set-repo → admin|manage attaches/detaches a bundle's repo | 401/403/404
94
99
  // GET /api/admin/uploads/key → admin|manage public key for sealed upload bodies | 401/403
@@ -139,17 +144,18 @@ import { backfillFindingIds, reportEntries } from '@preventive/report'
139
144
  import type { IncomingMessage, ServerResponse } from 'node:http'
140
145
  import { pipeline } from 'node:stream/promises'
141
146
  import { setTimeout as delay } from 'node:timers/promises'
142
- import { type KeptStore, REPOSITORY_READ_KEPT_MS, authoredCommitStore, branchTipStore, codeHeadStore, codeTreeStore, commitPullStore, githubTagStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore,
147
+ import { type KeptStore, REPOSITORY_READ_KEPT_MS, authoredCommitStore, branchTipStore, bundleValidationStore, codeHeadStore, codeTreeStore, commitPullStore, githubTagStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore,
143
148
  issueListStore, lockfileReadStore,
144
149
  npmDeltaStore, npmPublishTimeStore, npmVersionStore, ownKeptPrunes, pullDependencyStore, pullListStore, repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
145
150
  import { plainObject } from './npm-manifest.ts'
146
151
  import { type NpmDelta, keptNpmDeltas, npmDelta } from './npm-delta.ts'
147
152
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
148
- import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
153
+ import { auditCache, auditedKeys, lastListing } from './upstream-cache.ts'
149
154
  import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
150
155
  import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, newUploadKey, openSessionUpload, putUploadPart, readUpload, uploadPublicKey, validUpload, validUploadPart } from './uploads.ts'
151
156
  import { UPLOAD_SEAL_HEADER, maxSealedBytes } from '../common/managed/upload-seal.ts'
152
- import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
157
+ import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES, readBundleDetails } from './bundle-cache.ts'
158
+ import { BUNDLE_VALIDATION_READ, type BundleValidation, type PlannedBundle, validateAtCommit, validateDependencies, validationPlan } from './bundle-validation.ts'
153
159
  import type { CacheStorage } from './cache-storage.ts'
154
160
  import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
155
161
  import { type CatalogCommit, backfillBundleCommits, bundleCommits, cacheCommitDetails } from './bundle-commits.ts'
@@ -170,7 +176,7 @@ import { filterReportData } from '../common/managed/report-filter.ts'
170
176
  import type { TriageEntryPatch } from '../common/managed/triage.ts'
171
177
  import { GITHUB_AGENTS } from '../common/managed/github-agents.ts'
172
178
  import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
173
- import { reportRepoGithub } from '@preventive/report'
179
+ import { reportRepoGithub, repoDirectory as stampedDirectory } from '@preventive/report'
174
180
  import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
175
181
  import type { ReportSourcesCache } from './report-sources.ts'
176
182
  import { normalizeTeamPath } from './repo-path.ts'
@@ -231,7 +237,8 @@ import { type GithubAsked, type ListedLockfile, type LockfileRead, type Lockfile
231
237
  type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
232
238
  import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
233
239
  import { MAX_REQUIRED_REVIEWS, type RepoDependencyReview, SINCE_COMMIT } from './repository-dependency-reviews.ts'
234
- import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
240
+ import { EVERY_LOCKFILE, heldTo, reviewedIn, scopeOf } from './lockfile-scope.ts'
241
+ import { type BundledVersion, bundleDependencies, installsBundle } from './bundle-dependency-reviews.ts'
235
242
  import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
236
243
  import { formattedPull, withFormatted } from './dependency-review-comments.ts'
237
244
  import type { ReviewedDependencies } from './reviewed-dependencies.ts'
@@ -359,7 +366,9 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
359
366
  const storedIssues = await deps.db.listManagedIssues(ids)
360
367
  const issues = visibleManagedIssues(storedIssues, snapshot)
361
368
  const visibleIds = new Set(issues.map(issue => issue.findingId))
362
- const urls = [...new Set([...manualUrls, ...issues.flatMap(issue => [issue.issueUrl!, ...(issue.autoFixUrl ? [issue.autoFixUrl] : [])])])]
369
+ // A saved issue's duplicate is read with it, as a kept redirect is followed:
370
+ // its status even where only cached metadata can be shown.
371
+ const urls = [...new Set([...manualUrls, ...issues.flatMap(issue => [issue.issueUrl, issue.autoFixUrl, issue.duplicateOfUrl].filter(url => url !== null))])]
363
372
  await recheckTeam(deps.db, s.session.id, snapshot)
364
373
  const result = issues.length > 0
365
374
  ? await lookupIssueLinks(deps.config, deps.db, snapshot, urls, storedIssues.filter(issue => visibleIds.has(issue.findingId)))
@@ -1566,7 +1575,7 @@ async function readRepositoryRefs(db: ManagedDb, reader: RepositoryReader, repo:
1566
1575
 
1567
1576
  function scopeRepositoryContents(contents: RepositoryContents | null, virtualEntries: RepositoryEntry[] | null) {
1568
1577
  return contents && { ...contents, ...(virtualEntries ? {
1569
- entries: virtualEntries, limited: false, packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false,
1578
+ entries: virtualEntries, limited: false, configEntryPoints: [], packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false,
1570
1579
  } : {}) }
1571
1580
  }
1572
1581
 
@@ -3258,7 +3267,17 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
3258
3267
  (error: unknown) => { if (error instanceof GithubApiError && error.status === 404) return []; throw error }),
3259
3268
  }
3260
3269
  },
3261
- actions: githubActionsSource(user.token),
3270
+ // References resolved with the admin's token, and each one's advisories
3271
+ // asked as a bundle's `github` package's are (bundle-advisories.ts), one
3272
+ // `internal` of its repository alone, each bounded on its own.
3273
+ actions: { ...githubActionsSource(user.token), async advisories(name, asked, internal) {
3274
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)])
3275
+ const packages = [{ ecosystem: 'github' as const, name, versions: asked, internal }]
3276
+ const audited = await fetchBundleAdvisories(packages, signal, { debug: deps.config.debug, githubToken: user.token,
3277
+ cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedKeys(packages, false)) })
3278
+ if (audited.status !== 200) throw new GithubApiError(502, audited.body.error)
3279
+ return audited.body
3280
+ } },
3262
3281
  actionFiles: scanReadStore(deps.db, 'action-files', SCAN_READ_KEPT_MS, deps.config.debug),
3263
3282
  actionLines: scanReadStore(deps.db, 'actions', RISK_KEPT_MS, deps.config.debug),
3264
3283
  teamScoped,
@@ -3291,10 +3310,11 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
3291
3310
  // and, for packages anyone may read, OSV: no repository's. Each lockfile's
3292
3311
  // audit is bounded on its own, for one npm doesn't answer to fail alone and
3293
3312
  // the scan to carry on.
3294
- async advisories(packages, scanning) {
3313
+ async advisories(asked, scanning) {
3295
3314
  const signal = AbortSignal.any([scanning, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)])
3296
- const audited = await fetchBundleAdvisories(packages.map(pkg => ({ ecosystem: 'npm' as const, ...pkg })), signal,
3297
- { debug: deps.config.debug, cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug) })
3315
+ const packages = asked.map(pkg => ({ ecosystem: 'npm' as const, ...pkg }))
3316
+ const audited = await fetchBundleAdvisories(packages, signal,
3317
+ { debug: deps.config.debug, cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedKeys(packages, false)) })
3298
3318
  if (audited.status !== 200) throw new NpmPackageError(502, audited.body.error)
3299
3319
  return audited.body
3300
3320
  },
@@ -3446,7 +3466,7 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
3446
3466
  if (res.destroyed || (needsGithub && !(await authorize()))) return
3447
3467
  const result = await fetchBundleAdvisories(inventory.packages, controller.signal, {
3448
3468
  debug: deps.config.debug, repoAdvisories, details, githubToken,
3449
- cache: auditCache(deps.config.upstreamCacheDir, deps.db, controller.signal, deps.config.debug, auditedRepos(inventory.packages, repoAdvisories)),
3469
+ cache: auditCache(deps.config.upstreamCacheDir, deps.db, controller.signal, deps.config.debug, auditedKeys(inventory.packages, repoAdvisories)),
3450
3470
  })
3451
3471
  if (res.destroyed || !(await authorize())) return
3452
3472
  // Return just inventory and public advisories, never source or report data.
@@ -3457,6 +3477,163 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
3457
3477
  }
3458
3478
  }
3459
3479
 
3480
+ // Bundle `id` as `session` may read it now, else null, its refusal sent.
3481
+ async function readableBundle(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string) {
3482
+ const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
3483
+ if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
3484
+ if (!access.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
3485
+ return access.bundle
3486
+ }
3487
+
3488
+ // `body` told of bundle `record` where `session` may still read it, and it is
3489
+ // still where it was read: else refused, as one of another repository.
3490
+ async function tellOfBundle(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, record: ManagedBundle, body: unknown) {
3491
+ const now = await readableBundle(res, deps, session, id)
3492
+ if (!now) return
3493
+ if (now.repoId !== record.repoId || now.repoDirectory !== record.repoDirectory) sendJson(res, 409, { error: 'repository-changed' })
3494
+ else sendJson(res, 200, body)
3495
+ }
3496
+
3497
+ // GET /api/bundles/:id/dependency-review: what the npm package versions a
3498
+ // Stasis bundle holds (its advisory inventory's, bundle-advisory-inventory.ts)
3499
+ // want of review, where the repository it is attached to reviews
3500
+ // dependencies, as its Overview tells it (bundle-dependency-reviews.ts):
3501
+ // read as a default branch's are (branchDependencyAnswer), at the commit its
3502
+ // stamp names, of the lockfiles installing it there. None (`review: null`),
3503
+ // GitHub not asked, for a bundle of no repository, commit or npm versions,
3504
+ // one whose repository reviews none, and for a reader who may read only some
3505
+ // of it, as a team's Dependencies tab leaves such a one out; the bundle's
3506
+ // access checked again before it is told, and its repository not moved.
3507
+ async function handleBundleDependencyReview(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, session: ManagedSession, id: string) {
3508
+ const record = await readableBundle(res, deps, session, id)
3509
+ if (!record) return
3510
+ const none = () => { sendJson(res, 200, { review: null }) }
3511
+ const repoId = record.repoId
3512
+ if (record.kind !== 'stasis' || repoId === null || !deps.bundleCache) { none(); return }
3513
+ const setting = await deps.db.repoDependencyReview(repoId)
3514
+ if (!setting?.enabled) { none(); return }
3515
+ const access = await wholeRepositoryAccess(deps, cookie, repoId)
3516
+ if ('error' in access) {
3517
+ if (PARTIAL_ACCESS.has(access.error)) none()
3518
+ else sendRefusal(res, access)
3519
+ return
3520
+ }
3521
+ // Its inventory first: building it, where it isn't yet, keeps its summary too.
3522
+ let commit: string | undefined, inventory: BundleAdvisoryInventory | null | undefined
3523
+ try {
3524
+ inventory = await deps.bundleCache.advisoryInventory(record)
3525
+ commit = (await deps.bundleCache.summary(record))?.commit
3526
+ } catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
3527
+ if (inventory === null) { sendJson(res, 413, { error: 'payload-too-large' }); return }
3528
+ const bundled = (inventory?.packages ?? []).filter(pkg => pkg.ecosystem === 'npm').flatMap(pkg => pkg.versions.map(version => ({ name: pkg.name, version })))
3529
+ // Its commit as GitHub names one, of SHA-1.
3530
+ if (!commit || !/^[a-f\d]{40}$/u.test(commit) || bundled.length === 0) { none(); return }
3531
+ const read = await readWholeRepositoryOf(deps, access, (fullName, token, user) => bundleDependencyAnswer(deps, fullName, token, user, repoId, setting,
3532
+ { commit: commit!, directory: record.repoDirectory, bundled }), () => wholeRepositoryAccess(deps, cookie, repoId))
3533
+ if ('error' in read) { sendRefusal(res, read); return }
3534
+ await tellOfBundle(res, deps, session, id, record, read.value)
3535
+ }
3536
+
3537
+ // A bundle's answer, as GET /api/bundles/:id/dependency-review gives it, and
3538
+ // whether all of it was kept, GitHub asked for none of it: the versions
3539
+ // `bundled` (those of the bundle in `directory`, built from `commit`), as the
3540
+ // lockfiles there its review holds to lock them, judged by its review
3541
+ // (pull-dependency-reviews.ts), with how many it bundles (`versions`) and
3542
+ // those none of them lock (`unlocked`); none where its review holds to none
3543
+ // of the lockfiles installing it, there being some.
3544
+ async function bundleDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, setting: RepoDependencyReview,
3545
+ { commit, directory, bundled }: { commit: string; directory: string; bundled: BundledVersion[] }): Promise<{ value: object; kept: boolean }> {
3546
+ const asked: GithubAsked = { github: false }
3547
+ const reads = lockfileKept(deps, repoId)
3548
+ const installing = (await lockfilesAt(fullName, token, commit, reads, asked)).map(({ path }) => path).filter(path => installsBundle(path, directory))
3549
+ if (!reviewedIn(setting.lockfiles, installing)) return { value: { review: null }, kept: !asked.github }
3550
+ const read = await lockedAt(fullName, token, commit, reads, path => installsBundle(path, directory) && heldTo(setting.lockfiles, path), asked)
3551
+ const { told, ...bundle } = bundleDependencies(bundled, read, commit)
3552
+ const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told), setting)
3553
+ if (!now) return { value: { review: null }, kept: !asked.github }
3554
+ const state = { ...now, lockfiles: setting.lockfiles }
3555
+ const reviewed = deps.reviewedDependencies ?? null
3556
+ const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
3557
+ : await baselineFor(fullName, token, repoId, state, baselineKept(deps), reads, asked)
3558
+ const { base: _base, ...answer } = await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed })
3559
+ return { value: { ...answer, ...bundle }, kept: !asked.github }
3560
+ }
3561
+
3562
+ // GET /api/bundles/:id/validation: the Stasis bundle's last validation its
3563
+ // reader may see, `{ validation }`; POST (`run`) validates it anew and keeps
3564
+ // it. Own code is checked only for a reader of all of its repository, and
3565
+ // told only while they still are; see doc/bundles.md#validation.
3566
+ const VALIDATIONS_AT_ONCE = 2
3567
+ const VALIDATION_MS = 240_000
3568
+ const validating = new Map<string, Promise<BundleValidation | Refusal>>()
3569
+ async function handleBundleValidation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, session: ManagedSession, id: string,
3570
+ run: boolean) {
3571
+ if (run && !await checkMutation(req, res, deps, cookie, { handed: true })) return
3572
+ const record = await readableBundle(res, deps, session, id)
3573
+ if (!record) return
3574
+ if (record.kind !== 'stasis') { sendJson(res, 422, { error: 'unsupported-bundle' }); return }
3575
+ const whole = record.repoId === null ? null : await wholeRepositoryAccess(deps, cookie, record.repoId)
3576
+ if (whole && 'error' in whole && !PARTIAL_ACCESS.has(whole.error)) { sendRefusal(res, whole); return }
3577
+ const access = whole && !('error' in whole) ? whole : null
3578
+ const store = bundleValidationStore<BundleValidation>(deps.db, deps.config.debug)
3579
+ const keyOf = (own: boolean) => `${record.id}\0${record.integrity}\0${record.repoId ?? ''}\0${record.repoDirectory}\0${own ? 'own' : 'dependencies'}\0${BUNDLE_VALIDATION_READ}`
3580
+ // Why the reader of all of the repository may no longer be told its own
3581
+ // code, asked once that is read; null where they still may.
3582
+ const lostWhole = async () => {
3583
+ if (!access) return null
3584
+ const current = await wholeRepositoryAccess(deps, cookie, access.repo.repoId)
3585
+ return 'error' in current ? current : sameRepository(current.repo, access.repo) ? null : refusal(409, 'repository-changed')
3586
+ }
3587
+ if (!run) {
3588
+ const read = access ? await store.readFirst([keyOf(true)], [keyOf(false)]) : null
3589
+ let kept = read ? [...read.found.values()][0]?.value : (await store.read(keyOf(false)))?.value
3590
+ // The one that checked own code told only while its reader still reads all of the repository.
3591
+ if (read?.first && await lostWhole()) kept = (await store.read(keyOf(false)))?.value
3592
+ await tellOfBundle(res, deps, session, id, record, { validation: plainObject(kept) && typeof kept.status === 'string' ? kept : null })
3593
+ return
3594
+ }
3595
+ const key = keyOf(access !== null)
3596
+ const shared = access ? `${key}\0${access.user.id}` : key
3597
+ let job = validating.get(shared)
3598
+ if (!job) {
3599
+ if (validating.size >= VALIDATIONS_AT_ONCE) { sendJson(res, 429, { error: 'validation-busy' }); return }
3600
+ // Kept before it is told, for a reader asking next.
3601
+ job = bundleValidation(deps, cookie, record, access).then(async validation => {
3602
+ if (!('error' in validation)) await store.write(key, validation, validation.validatedAt)
3603
+ return validation
3604
+ }).finally(() => validating.delete(shared))
3605
+ validating.set(shared, job)
3606
+ }
3607
+ let validation: BundleValidation | Refusal
3608
+ try { validation = await job } catch (err) {
3609
+ if (deps.config.debug) console.warn('managed: bundle validation failed:', err)
3610
+ sendJson(res, 502, { error: 'validation-failed' }); return
3611
+ }
3612
+ if ('error' in validation) { sendRefusal(res, validation); return }
3613
+ const lost = validation.own.checked ? await lostWhole() : null
3614
+ if (lost) { sendRefusal(res, lost); return }
3615
+ await tellOfBundle(res, deps, session, id, record, { validation })
3616
+ }
3617
+
3618
+ // Bundle `record` validated (bundle-validation.ts): at the commit its stamp
3619
+ // names, with the GitHub access of `access`'s reader of all of its repository,
3620
+ // checked again after; else its dependencies alone, and why.
3621
+ async function bundleValidation(deps: ManagedHttpDeps, cookie: string | undefined, record: ManagedBundle, access: WholeAccess | null) {
3622
+ let details
3623
+ try { details = await readBundleDetails(record, deps.bundleStore) } catch { return refusal(422, 'bundle-unavailable') }
3624
+ const plan = details?.bundle ? validationPlan(details.bundle as PlannedBundle) : null
3625
+ if (!plan) return refusal(422, 'bundle-unavailable')
3626
+ const stamp = (details!.bundle as { repo?: { commit?: unknown; directory?: unknown } } | undefined)?.repo
3627
+ const commit = typeof stamp?.commit === 'string' && /^[a-f\d]{40}$/u.test(stamp.commit) ? stamp.commit : null
3628
+ // Where it was built, as its stamp says, else where it is kept.
3629
+ const directory = stampedDirectory(typeof stamp?.directory === 'string' ? stamp : { directory: record.repoDirectory })
3630
+ const options = { deadline: Date.now() + VALIDATION_MS }
3631
+ if (!access || !commit) return validateDependencies(plan, access ? 'no-commit' : record.repoId === null ? 'no-repository' : 'no-access', options)
3632
+ const read = await readWholeRepositoryOf(deps, access, async (fullName, token) => ({ value: await validateAtCommit(plan, { fullName, token, commit, directory }, options), kept: false }),
3633
+ () => wholeRepositoryAccess(deps, cookie, access.repo.repoId))
3634
+ return 'error' in read ? read : read.value
3635
+ }
3636
+
3460
3637
  // GET /api/admin/bundles — the uploaded bundles for the "Manage bundles" page.
3461
3638
  // admin|manage, read-only (no CSRF). `maxBytes` is the upload cap; `repos` feeds
3462
3639
  // the upload repo picker.
@@ -3686,6 +3863,27 @@ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps:
3686
3863
  try { await pipeline(stored.stream, res) } catch { res.destroy() }
3687
3864
  }
3688
3865
 
3866
+ // GET /api/admin/bundles/<id>/usage — the reports that name a bundle, read
3867
+ // before deleting it (admin|manage, as the delete): linked to it, so their
3868
+ // findings show its code, or declaring it in their bundleHashes without the
3869
+ // link. Reports the caller can't read are counted, never named. Read-only, so
3870
+ // no CSRF. 404 when the caller can't manage the bundle.
3871
+ async function handleBundleUsage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
3872
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3873
+ if (s == null) return
3874
+ const bundle = await deps.db.getBundle(id)
3875
+ if (!bundle || (s.user.role !== 'admin' && !await deps.db.userCanReadBundle(s.user.id, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
3876
+ // Each report's readability is read with it, as canViewReport tells a
3877
+ // manager's: one statement however many reports use the bundle.
3878
+ const reports: { id: string; filename: string; linked: boolean }[] = []
3879
+ let otherReports = 0
3880
+ for (const report of await deps.db.listReportsForBundle(id, bundle.integrity, s.user.role === 'admin' ? undefined : s.user.id)) {
3881
+ if (report.readable) reports.push({ id: report.id, filename: report.filename, linked: report.bundleId === id })
3882
+ else otherReports++
3883
+ }
3884
+ sendJson(res, 200, { bundleId: id, reports, otherReports })
3885
+ }
3886
+
3689
3887
  // DELETE /api/admin/bundles/<id> — remove a bundle (admin|manage). Mutation:
3690
3888
  // same-origin + CSRF. Drops the row (referencing reports' bundle_id null out via
3691
3889
  // the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
@@ -5397,6 +5595,18 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
5397
5595
  await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
5398
5596
  return
5399
5597
  }
5598
+ const bundleValidationRoute = /^\/api\/bundles\/([a-f\d-]{36})\/validation$/iu.exec(path)
5599
+ if (bundleValidationRoute) {
5600
+ if (method !== 'GET' && method !== 'POST') { send405(res, 'GET, POST'); return }
5601
+ await handleBundleValidation(req, res, deps, cookie, workspaceSession!.session, bundleValidationRoute[1]!, method === 'POST')
5602
+ return
5603
+ }
5604
+ const bundleReview = /^\/api\/bundles\/([a-f\d-]{36})\/dependency-review$/iu.exec(path)
5605
+ if (bundleReview) {
5606
+ if (method !== 'GET') { send405(res, 'GET'); return }
5607
+ await handleBundleDependencyReview(res, deps, cookie, workspaceSession!.session, bundleReview[1]!)
5608
+ return
5609
+ }
5400
5610
  const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only|comparison)$/iu.exec(path)
5401
5611
  if (bundleRead) {
5402
5612
  const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
@@ -5436,6 +5646,11 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
5436
5646
  if (method !== 'POST') { send405(res, 'POST'); return }
5437
5647
  await handleSetBundleVisible(req, res, deps, cookie); return
5438
5648
  }
5649
+ const bundleUsage = /^\/api\/admin\/bundles\/([a-f\d-]{36})\/usage$/iu.exec(path)
5650
+ if (bundleUsage) {
5651
+ if (method !== 'GET') { send405(res, 'GET'); return }
5652
+ await handleBundleUsage(res, deps, cookie, bundleUsage[1]!); return
5653
+ }
5439
5654
  if (path.startsWith(BUNDLE_PREFIX)) {
5440
5655
  const id = path.slice(BUNDLE_PREFIX.length)
5441
5656
  if (method === 'GET') { await handleGetBundle(req, res, deps, workspaceSession!.session, id); return }
@@ -9,6 +9,7 @@ import { runReapers, withReap } from '../server-common/reap.ts'
9
9
  import { startServer } from '../server-common/standalone.ts'
10
10
  import { initializeApp } from '../server-common/initialize.ts'
11
11
  import { type ManagedConfig, loadManagedConfig } from './config.ts'
12
+ import type { ManagedDb } from './db.ts'
12
13
  import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
13
14
  import { setNpmTarballCache } from './npm-loads.ts'
14
15
  import { assembledReviewedDependencies } from './reviewed-dependencies.ts'
@@ -21,6 +22,17 @@ import { setCacheDir } from '@preventive/upstream/npm.js'
21
22
  // without a timer or cron. Failed sweeps retry on later traffic after a minute.
22
23
  const REAP_INTERVAL_MS = 3_600_000
23
24
  const REAP_RETRY_MS = 60_000
25
+ // A team's stored catalog summary (team-app-summaries.ts) not stored again in
26
+ // this long: one still asked for is worked out again once.
27
+ const TEAM_APP_SUMMARY_KEPT_MS = 30 * 86_400_000
28
+
29
+ // Rows kept past their time: expired sessions, how many, and teams' catalog
30
+ // summaries stored too long ago.
31
+ async function reapRows(db: Pick<ManagedDb, 'deleteExpiredSessions' | 'deleteTeamAppSummaries'>): Promise<number> {
32
+ const now = Date.now()
33
+ const [sessions] = await Promise.all([db.deleteExpiredSessions(now), db.deleteTeamAppSummaries(now - TEAM_APP_SUMMARY_KEPT_MS)])
34
+ return sessions
35
+ }
24
36
 
25
37
  // `ui: false` serves only the API: `out/` is never read.
26
38
  type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown' | 'reviewedDependencies'>> & { ui?: boolean }
@@ -79,7 +91,7 @@ async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options
79
91
  let succeeded = false
80
92
  try {
81
93
  await runReapers({
82
- sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
94
+ sessions: async () => { sessions = await reapRows(db) },
83
95
  ...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!(maintenance.signal) } } : {}),
84
96
  ...('reapPrettyCopies' in storage ? { prettyCopies: () => storage.reapPrettyCopies(maintenance.signal) } : {}),
85
97
  ...('reapStorage' in storage ? { storage: () => storage.reapStorage(maintenance.signal) } : {}),