@preventive/triage 1.0.0-alpha.36 → 1.0.0-alpha.38

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 (177) hide show
  1. package/README.md +16 -0
  2. package/common/bundle-compare-diff.d.ts +3 -0
  3. package/common/bundle-compare-diff.js +522 -0
  4. package/common/bundle-compare-inputs.d.ts +2 -0
  5. package/common/bundle-compare-inputs.js +65 -0
  6. package/common/bundle-metadata.d.ts +6 -2
  7. package/common/bundle-metadata.js +63 -20
  8. package/common/bundle-pkg-of.d.ts +1 -0
  9. package/common/bundle-pkg-of.js +90 -0
  10. package/common/bundle-source-links.js +7 -0
  11. package/common/bundle-sourcemap.d.ts +10 -0
  12. package/common/bundle-sourcemap.js +100 -0
  13. package/common/bundle-sources.d.ts +3 -1
  14. package/common/bundle-sources.js +47 -19
  15. package/common/code-language.js +16 -2
  16. package/common/default-scan-models.ts +2 -1
  17. package/common/dependency-reviews.d.ts +23 -0
  18. package/common/dependency-reviews.js +81 -0
  19. package/common/finding-duplicates.js +58 -0
  20. package/common/finding-filters.js +274 -0
  21. package/common/github-pr.ts +1 -1
  22. package/common/gzip.js +59 -0
  23. package/common/line-diff.d.ts +8 -0
  24. package/common/line-diff.js +56 -0
  25. package/common/linked-workspace-groups.js +63 -0
  26. package/common/managed/npm-packages.d.ts +5 -0
  27. package/common/managed/npm-packages.js +34 -0
  28. package/common/managed/permissions.ts +1 -5
  29. package/common/managed/routes.js +123 -12
  30. package/common/managed/team-app.d.ts +2 -0
  31. package/common/managed/team-app.js +31 -0
  32. package/common/managed/triage.ts +1 -1
  33. package/common/managed/upload-seal.ts +126 -0
  34. package/common/pretty-print.d.ts +5 -0
  35. package/common/pretty-print.js +24 -0
  36. package/common/revalidation-conflicts.js +48 -0
  37. package/common/revalidation-input-groups.js +56 -0
  38. package/common/workspace-app.js +23 -0
  39. package/common/workspace-groups.js +176 -0
  40. package/out/brotli-fallback.js +3 -3
  41. package/out/chunk-2KBF5TZ7.js +2 -0
  42. package/out/chunk-2XUEZHIP.js +28 -0
  43. package/out/chunk-35VE6CDH.js +2 -0
  44. package/out/chunk-3BZ7JERB.js +1 -0
  45. package/out/chunk-4XCDFGDP.js +10 -0
  46. package/out/chunk-5FKRIJGF.js +1 -0
  47. package/out/chunk-6Z5XLSPJ.js +1 -0
  48. package/out/chunk-ABW5FVSN.js +1 -0
  49. package/out/chunk-BJKRVROW.js +1 -0
  50. package/out/chunk-BYUEDJOM.js +7 -0
  51. package/out/chunk-C4Z6ZBXV.js +10 -0
  52. package/out/chunk-CMQA4DM6.js +1 -0
  53. package/out/chunk-CVVJAXVY.js +2 -0
  54. package/out/chunk-DCQX2J3C.js +223 -0
  55. package/out/chunk-DIJJNTRH.js +10 -0
  56. package/out/chunk-F4OMDNEY.js +1 -0
  57. package/out/chunk-FU4MTW7F.js +1 -0
  58. package/out/chunk-GJ4IUCNS.js +1 -0
  59. package/out/chunk-GMFXFEUA.js +2 -0
  60. package/out/chunk-GNXOKIGP.js +80 -0
  61. package/out/chunk-GZ4MS3TE.js +13 -0
  62. package/out/chunk-HIV7SVI7.js +1 -0
  63. package/out/chunk-IU3CCINR.js +3 -0
  64. package/out/chunk-JAKWIZWL.js +2 -0
  65. package/out/chunk-K3X5EJBQ.js +2 -0
  66. package/out/chunk-KEX2ATEW.js +2 -0
  67. package/out/chunk-KYCNJVB7.js +1 -0
  68. package/out/chunk-NRPC74AM.js +1 -0
  69. package/out/chunk-OJ5DGP5E.js +33 -0
  70. package/out/chunk-OOJM4CTU.js +1 -0
  71. package/out/chunk-PNHBZGDD.js +1 -0
  72. package/out/chunk-R5O6FAW4.js +2 -0
  73. package/out/chunk-RFZQQXSG.js +17 -0
  74. package/out/chunk-SD2WJAXN.js +48 -0
  75. package/out/chunk-TUII34K2.js +1 -0
  76. package/out/chunk-TZ5KB2Y5.js +10 -0
  77. package/out/chunk-UNTRGE26.js +2 -0
  78. package/out/chunk-UYAXVRVL.js +1 -0
  79. package/out/chunk-V3ME6FI5.js +2 -0
  80. package/out/chunk-V5O7M2YT.js +1 -0
  81. package/out/chunk-VIHGO77Y.js +1 -0
  82. package/out/chunk-W4J3MGLB.js +1 -0
  83. package/out/chunk-WGG7KQUH.js +1 -0
  84. package/out/chunk-XYFXXB2G.js +10 -0
  85. package/out/chunk-YVZH5SHP.js +1 -0
  86. package/out/chunk-ZPD5R5RP.js +3 -0
  87. package/out/client-managed-import.js +3 -87
  88. package/out/client-managed.js +26 -338
  89. package/out/client-sync.js +8 -14
  90. package/out/graph.js +17 -30
  91. package/out/index.html +34 -0
  92. package/out/prism.js +6 -2
  93. package/out/terminal.js +241 -257
  94. package/out/view.css +1 -1
  95. package/out/view.js +76 -520
  96. package/package.json +88 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +164 -0
  100. package/risk/files/runs-code.js +329 -0
  101. package/risk/files/script.js +112 -0
  102. package/risk/files/stamps.js +46 -0
  103. package/risk/index.d.ts +142 -0
  104. package/risk/index.js +7 -0
  105. package/risk/npm/assess.js +219 -0
  106. package/risk/npm/combined.js +65 -0
  107. package/risk/npm/compare.js +222 -0
  108. package/risk/npm/dependencies.js +153 -0
  109. package/risk/npm/diff.js +43 -0
  110. package/risk/npm/downloads.js +84 -0
  111. package/risk/npm/files.js +34 -0
  112. package/risk/npm/held.js +60 -0
  113. package/risk/npm/install-scripts.js +33 -0
  114. package/risk/npm/malware.js +36 -0
  115. package/risk/npm/manifest.js +65 -0
  116. package/risk/npm/new-dependency.js +30 -0
  117. package/risk/npm/old-version.js +108 -0
  118. package/risk/npm/registry.js +282 -0
  119. package/risk/npm/release.js +85 -0
  120. package/risk/npm/specs.js +124 -0
  121. package/risk/npm/tarball.js +113 -0
  122. package/server-e2e/http.ts +7 -7
  123. package/server-e2e/npm-proxy.ts +1 -1
  124. package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
  125. package/server-e2e/objstore/handlers.ts +1 -1
  126. package/server-e2e/objstore/tokens.ts +1 -1
  127. package/server-e2e/sign.ts +1 -1
  128. package/server-managed/bundle-advisories.ts +68 -11
  129. package/server-managed/bundle-build-worker.js +1 -1
  130. package/server-managed/bundle-build.ts +6 -2
  131. package/server-managed/bundle-cache.ts +37 -4
  132. package/server-managed/bundle-commits.ts +140 -0
  133. package/server-managed/bundle-summary-cache.ts +24 -10
  134. package/server-managed/config.ts +4 -0
  135. package/server-managed/db-methods.ts +98 -11
  136. package/server-managed/db-neon.ts +39 -4
  137. package/server-managed/db-schema.ts +14 -2
  138. package/server-managed/db-table-names.ts +1 -1
  139. package/server-managed/db.ts +7 -3
  140. package/server-managed/dependency-reviews.ts +248 -0
  141. package/server-managed/github-issue-oauth.ts +5 -6
  142. package/server-managed/github-metadata.ts +115 -0
  143. package/server-managed/github-oauth.ts +38 -2
  144. package/server-managed/http-response.ts +26 -0
  145. package/server-managed/http.ts +943 -52
  146. package/server-managed/index.ts +11 -5
  147. package/server-managed/kept.ts +28 -0
  148. package/server-managed/locked-packages.ts +85 -0
  149. package/server-managed/lru.ts +30 -0
  150. package/server-managed/npm-insights.ts +440 -0
  151. package/server-managed/npm-kept.ts +65 -0
  152. package/server-managed/npm-loads.ts +167 -0
  153. package/server-managed/npm-manifest.ts +115 -0
  154. package/server-managed/npm-packages.ts +378 -0
  155. package/server-managed/npm-provenance.ts +123 -0
  156. package/server-managed/npm-risk.ts +177 -0
  157. package/server-managed/pretty-print.ts +352 -0
  158. package/server-managed/public-workspace.ts +78 -7
  159. package/server-managed/rename-diff-worker.js +26 -0
  160. package/server-managed/rename-diff.ts +270 -0
  161. package/server-managed/report-sources.ts +13 -2
  162. package/server-managed/repository-browser.ts +19 -5
  163. package/server-managed/repository-risk.ts +413 -0
  164. package/server-managed/session.ts +16 -2
  165. package/server-managed/sql.ts +5 -2
  166. package/server-managed/storage-encryption.ts +5 -3
  167. package/server-managed/storage-payload.ts +4 -0
  168. package/server-managed/storage-stores.ts +2 -0
  169. package/server-managed/storage.ts +6 -3
  170. package/server-managed/team-app.ts +94 -0
  171. package/server-managed/team-feed.ts +1 -1
  172. package/server-managed/team-npm-scopes.ts +77 -0
  173. package/server-managed/type-only-worker.js +19 -0
  174. package/server-managed/type-only.ts +103 -0
  175. package/server-managed/uploads.ts +25 -4
  176. package/server-managed/upstream-cache.ts +147 -5
  177. package/server-managed/view-as.ts +58 -0
@@ -0,0 +1,413 @@
1
+ // Every connected repository's npm packages, as its lockfiles lock them, each
2
+ // assessed by risk (npm-risk.ts), for admins: GET /api/admin/repositories/risk
3
+ // answers a line at a time as each is found and assessed, and stops once its
4
+ // reader leaves (`signal`).
5
+ //
6
+ // A repository's lockfiles are listed at its default branch's head by
7
+ // @preventive/upstream's findRepoLockfiles; of them, those of npm's ecosystem
8
+ // that @preventive/lockfile reads (package-lock.json and npm-shrinkwrap.json
9
+ // of `lockfileVersion: 3`, pnpm-lock.yaml of pnpm 9 to 12, and Yarn 1's
10
+ // yarn.lock) are read for their packages. A package from npm's registry is
11
+ // assessed once a scan, however many lockfiles lock it; one from another
12
+ // host with an integrity, such as a company's mirror of npm's, as npm's
13
+ // package of its name and version where npm's integrity is among it; one
14
+ // from elsewhere (git, a tarball with no integrity, a file) is listed, not
15
+ // assessed. Repositories are read one at a time, as GitHub's limits are the
16
+ // caller's; packages are assessed meanwhile, as many at a time as npm-risk.ts
17
+ // makes spare assessments. A lockfile's packages of npm's registry or a
18
+ // mirror are asked of npm's advisories all at once, as `npm audit` asks, each
19
+ // advisory told with whether it is of malware (risk's npmMalwareAdvisory),
20
+ // which alone is a supply chain's risk; the rest are vulnerabilities, told
21
+ // beside.
22
+ import { setTimeout as delay } from 'node:timers/promises'
23
+ import type { Advisory } from '@preventive/upstream/advisories.js'
24
+ import type { LockfileFormat, RepoLockfile } from '@preventive/upstream/github.js'
25
+ import { isExactVersion } from '@preventive/upstream/semver.js'
26
+ import { NpmRiskError, SEVERITIES, type Severity, npmMalwareAdvisory } from '../risk/index.js'
27
+ import { isNpmPackageName, isNpmPackageSpec } from '../common/managed/npm-packages.js'
28
+ import { GithubApiError } from './github-errors.ts'
29
+ import { NpmPackageError, type NpmVersionDocument } from './npm-packages.ts'
30
+ import { type Lru, lru } from './lru.ts'
31
+ import type { KeptStore } from './npm-kept.ts'
32
+ import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
33
+ import { type LockedPackage, lockedPackages } from './locked-packages.ts'
34
+
35
+ // The formats of npm's ecosystem, as findRepoLockfiles names them, and those
36
+ // of them read here.
37
+ const NPM_LOCKFILE_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno'])
38
+ const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'])
39
+
40
+ // An assessment refused as busy (`npm-busy`, by npm-risk.ts or the npm reads
41
+ // beneath it), or that npm asked to ask later (risk's `upstream-busy`, as its
42
+ // downloads API answers past a burst of requests), is asked again after a
43
+ // wait, up to this many times.
44
+ const BUSY_RETRIES = 20
45
+ const BUSY_WAIT_MS = 1000
46
+
47
+ // The most severe of `findings`, or null for none.
48
+ export function highestSeverity(findings: readonly { severity: Severity }[]): Severity | null {
49
+ return SEVERITIES.find(severity => findings.some(finding => finding.severity === severity)) ?? null
50
+ }
51
+
52
+ // A repository to read: where `paths` are given, a team's directories in it,
53
+ // only the lockfiles in them or above them, as a workspace's root lockfile
54
+ // locks them too.
55
+ export interface RepositoryRiskRepo { repoId: number; fullName: string; paths?: readonly string[] }
56
+
57
+ // Whether a lockfile at `path` is of `paths` (RepositoryRiskRepo).
58
+ function lockfileOf(path: string, paths: readonly string[] | undefined) {
59
+ const dir = path.slice(0, Math.max(path.lastIndexOf('/'), 0))
60
+ return !paths || paths.some(scope => dir === '' || dir === scope || dir.startsWith(`${scope}/`) || scope.startsWith(`${dir}/`))
61
+ }
62
+
63
+ type ListedLockfile = Pick<RepoLockfile, 'path' | 'format' | 'sha'>
64
+
65
+ // A repository opened at its default branch's head (`commit`): its lockfiles,
66
+ // as findRepoLockfiles lists them, each with its blob's id, and a file's
67
+ // text; `recheck` checks access to it again, throwing where it is gone.
68
+ export interface OpenedRepository {
69
+ commit: string
70
+ lockfiles(): Promise<ListedLockfile[]>
71
+ file(path: string): Promise<string>
72
+ recheck(): Promise<void>
73
+ }
74
+
75
+ type Assessment = { published: string | null; findings: { id: string; severity: Severity; takenDown?: unknown }[] }
76
+
77
+ export interface RepositoryRiskSource<R extends RepositoryRiskRepo> {
78
+ repos(): Promise<R[]>
79
+ open(repo: R): Promise<OpenedRepository>
80
+ // Whether a team lists a package's scope, its own packages.
81
+ teamScoped(name: string): Promise<boolean>
82
+ // npm's document of a version; an NpmPackageError where npm has none.
83
+ version(name: string, version: string, signal: AbortSignal): Promise<NpmVersionDocument>
84
+ assess(doc: NpmVersionDocument, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
85
+ // A version npm has no document of, as risk tells one npm removed: its
86
+ // malware alone, its package taken down by npm; an NpmRiskError where
87
+ // nothing tells of it (npm-risk.ts npmRiskRemoved).
88
+ removed(name: string, version: string, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
89
+ // npm's registry's advisories of these packages' versions, asked at once.
90
+ advisories(packages: { name: string; versions: string[] }[], signal: AbortSignal): Promise<Advisory[]>
91
+ // Where risk lines are kept beyond this instance, as RiskCaches keeps them
92
+ // in it (npm-kept.ts riskLineStore, the database's).
93
+ risks?: KeptStore<RiskLine>
94
+ }
95
+
96
+ type AdvisoryRow = Advisory & { malware: boolean }
97
+
98
+ // Why a step failed, as a line says it: an error's code, and for a lockfile
99
+ // refused, what of it its reader refused.
100
+ type RiskFailure = { error: string; detail?: string }
101
+
102
+ // A risk line is of npm's package of its name and version, or, with an
103
+ // `integrity`, of a mirror's that its lockfiles hold to it.
104
+ export type RepositoryRiskLine =
105
+ | { type: 'repos'; repos: { id: number; fullName: string }[] }
106
+ | ({ type: 'repo'; id: number } & ({ commit: string; lockfiles: { path: string; format: LockfileFormat }[] } | RiskFailure))
107
+ | ({ type: 'lockfile'; repo: number; path: string; format: LockfileFormat } & ({ packages: LockedPackage[] } | RiskFailure))
108
+ | ({ type: 'risk'; name: string; version: string; integrity?: string }
109
+ & ({ published: string | null; severity: Severity | null; findings: { id: string; severity: Severity; takenDown?: true }[] } | RiskFailure))
110
+ | ({ type: 'advisories'; repo: number; path: string } & ({ advisories: AdvisoryRow[] } | RiskFailure))
111
+ | { type: 'done' }
112
+
113
+ // What an error is known by: a GitHub or npm failure's code, or `failed`;
114
+ // risk's with what it asked of npm and how that failed, where it says.
115
+ function failureOf(error: unknown): RiskFailure {
116
+ if (error instanceof GithubApiError || error instanceof NpmPackageError) return { error: error.message }
117
+ if (!(error instanceof NpmRiskError)) return { error: 'failed' }
118
+ return error.message === error.code ? { error: error.code } : { error: error.code, detail: error.message }
119
+ }
120
+
121
+ const isBusy = (error: unknown) => (error instanceof NpmPackageError && error.status === 429) || (error instanceof NpmRiskError && error.code === 'upstream-busy')
122
+
123
+ type RiskLine = Extract<RepositoryRiskLine, { type: 'risk' }>
124
+ type LockfileRead = { packages: LockedPackage[] } | RiskFailure
125
+
126
+ // What scans keep between them, in this instance's memory: a repository's
127
+ // lockfiles as listed at a commit, and a lockfile's packages by its blob,
128
+ // neither of which changes; and a package's risk line and a lockfile's
129
+ // advisories, asked again after RISK_KEPT_MS, as risk tells again what of a
130
+ // package changes in time (a deprecation, its age, its downloads) and npm
131
+ // publishes advisories. Each is told only once access to its repository is
132
+ // checked as when it was read.
133
+ export interface RiskCaches {
134
+ listings: Lru<ListedLockfile[]>
135
+ lockfiles: Lru<LockfileRead>
136
+ risks: Lru<{ at: number; line: RiskLine }>
137
+ advisories: Lru<{ at: number; rows: AdvisoryRow[] }>
138
+ }
139
+ export const riskCaches = (): RiskCaches => ({
140
+ listings: lru(1000),
141
+ // By the packages they hold, a few hundred bytes each.
142
+ lockfiles: lru(200_000, read => 'packages' in read ? read.packages.length + 1 : 1),
143
+ risks: lru(50_000),
144
+ advisories: lru(50_000, kept => kept.rows.length + 1),
145
+ })
146
+ export const RISK_KEPT_MS = 60 * 60_000
147
+ const shared = riskCaches()
148
+
149
+ // Items in the order they are added, each taken once by whichever reader asks
150
+ // first: none once closed and all taken, or once `signal` aborts.
151
+ interface Queue<T> { add(item: T): void; close(): void; take(): Promise<T | undefined> }
152
+ function queue<T>(signal: AbortSignal): Queue<T> {
153
+ const items: T[] = []
154
+ const waiting: (() => void)[] = []
155
+ let closed = false
156
+ const wake = () => { for (const resolve of waiting.splice(0)) resolve() }
157
+ signal.addEventListener('abort', wake, { once: true })
158
+ return {
159
+ add(item) { items.push(item); wake() },
160
+ close() { closed = true; wake() },
161
+ async take() {
162
+ for (;;) {
163
+ if (signal.aborted) return undefined
164
+ const item = items.shift()
165
+ if (item !== undefined || closed) return item
166
+ await new Promise<void>(resolve => { waiting.push(resolve) })
167
+ }
168
+ },
169
+ }
170
+ }
171
+
172
+ // A package to assess: npm's of its name and version, or, with an
173
+ // `integrity`, a mirror's held to it.
174
+ interface AssessedPackage { name: string; version: string; integrity?: string }
175
+
176
+ interface Scan {
177
+ source: RepositoryRiskSource<RepositoryRiskRepo>
178
+ caches: RiskCaches
179
+ signal: AbortSignal
180
+ lines: Queue<RepositoryRiskLine>
181
+ // Packages to assess, and those already asked for, by name, version and a
182
+ // mirror's integrity.
183
+ pending: Queue<AssessedPackage>
184
+ seen: Set<string>
185
+ }
186
+
187
+ // A package's risk line, its version's document kept and the assessment asked
188
+ // again after a wait while the server is busy; null once the scan stops. A
189
+ // version npm has no document of is told as risk tells one npm removed
190
+ // (removedOf), its malware keeping whether its package was taken down.
191
+ async function assess({ source, signal }: Scan, pkg: AssessedPackage, teamScoped: boolean): Promise<RiskLine | null> {
192
+ const at = { type: 'risk', ...pkg } as const
193
+ let doc: NpmVersionDocument | NpmPackageError | undefined
194
+ for (let attempt = 0; ; attempt++) {
195
+ try {
196
+ const read = doc ??= await documentOf(source, pkg, signal)
197
+ if (!(read instanceof NpmPackageError) && pkg.integrity !== undefined && !pkg.integrity.split(/\s+/u).includes(read.dist.integrity)) return { ...at, error: 'integrity-mismatch' }
198
+ const { published, findings } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
199
+ const told = findings.map(({ id, severity, takenDown }) => ({ id, severity, ...takenDown === true ? { takenDown: true as const } : {} }))
200
+ return { ...at, published, severity: highestSeverity(told), findings: told }
201
+ } catch (error) {
202
+ if (signal.aborted) return null
203
+ if (!isBusy(error) || attempt >= BUSY_RETRIES) return { ...at, ...failureOf(error) }
204
+ try { await delay(BUSY_WAIT_MS * Math.min(attempt + 1, 5), undefined, { signal }) } catch { return null }
205
+ }
206
+ }
207
+ }
208
+
209
+ // npm's document of a version, or its answer that it has none (a 404).
210
+ async function documentOf(source: Scan['source'], pkg: AssessedPackage, signal: AbortSignal): Promise<NpmVersionDocument | NpmPackageError> {
211
+ try { return await source.version(pkg.name, pkg.version, signal) } catch (error) {
212
+ if (error instanceof NpmPackageError && error.status === 404) return error
213
+ throw error
214
+ }
215
+ }
216
+
217
+ // A version's assessment; where risk finds npm no longer has it, as a document
218
+ // read from where versions' are kept doesn't tell, as one npm removed.
219
+ async function assessedOf(source: Scan['source'], pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, doc: NpmVersionDocument): Promise<Assessment> {
220
+ try { return await source.assess(doc, teamScoped, signal) } catch (error) {
221
+ if (!(error instanceof NpmRiskError && error.code === 'version-not-found')) throw error
222
+ return removedOf(source, pkg, teamScoped, signal, new NpmPackageError(404, 'package-not-found'))
223
+ }
224
+ }
225
+
226
+ // A version npm has no document of, as risk tells one npm removed, as it
227
+ // removes every one it finds malware in (`removed`): `gone`, npm's answer of
228
+ // none, thrown where nothing tells of it.
229
+ async function removedOf(source: Scan['source'], pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, gone: NpmPackageError): Promise<Assessment> {
230
+ try { return await source.removed(pkg.name, pkg.version, teamScoped, signal) } catch (error) {
231
+ throw error instanceof NpmRiskError && (error.code === 'version-not-found' || error.code === 'package-not-found') ? gone : error
232
+ }
233
+ }
234
+
235
+ // A package's risk line as kept (RiskCaches, else the source's `risks`), else
236
+ // assessed and kept, in both, where it tells what risk found, or that a
237
+ // mirror's copy isn't npm's.
238
+ async function assessed(scan: Scan, pkg: AssessedPackage): Promise<RiskLine | null> {
239
+ let teamScoped
240
+ try { teamScoped = await scan.source.teamScoped(pkg.name) } catch (error) { return { type: 'risk', ...pkg, ...failureOf(error) } }
241
+ const key = `${pkg.name}@${pkg.version}\0${pkg.integrity ?? ''}\0${teamScoped}`
242
+ const kept = scan.caches.risks.get(key) ?? await keptLine(scan, key)
243
+ if (kept && Date.now() - kept.at < RISK_KEPT_MS) return kept.line
244
+ const line = await assess(scan, pkg, teamScoped)
245
+ if (line && (!('error' in line) || line.error === 'integrity-mismatch')) {
246
+ const at = Date.now()
247
+ scan.caches.risks.set(key, { at, line })
248
+ await scan.source.risks?.write(key, line, at)
249
+ }
250
+ return line
251
+ }
252
+
253
+ async function keptLine({ source, caches }: Scan, key: string) {
254
+ const hit = await source.risks?.read(key)
255
+ if (!hit) return null
256
+ caches.risks.set(key, { at: hit.at, line: hit.value })
257
+ return { at: hit.at, line: hit.value }
258
+ }
259
+
260
+ async function assessor(scan: Scan) {
261
+ for (let next = await scan.pending.take(); next !== undefined; next = await scan.pending.take()) {
262
+ const line = await assessed(scan, next)
263
+ if (line) scan.lines.add(line)
264
+ }
265
+ }
266
+
267
+ type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
268
+
269
+ // A lockfile's line, its packages or why they couldn't be had: as kept by its
270
+ // blob, else read from GitHub and kept.
271
+ async function readLockfile({ caches }: Scan, repo: number, opened: OpenedRepository, { path, format, sha }: ListedLockfile): Promise<LockfileLine> {
272
+ const at = { type: 'lockfile', repo, path, format } as const
273
+ if (!READ_FORMATS.has(format)) return { ...at, error: 'unsupported-format' }
274
+ const key = `${format}\0${sha}`
275
+ let read = caches.lockfiles.get(key)
276
+ if (!read) {
277
+ let text
278
+ try { text = await opened.file(path) } catch (error) { return { ...at, ...failureOf(error) } }
279
+ try { read = { packages: lockedPackages(format, text) } } catch (error) {
280
+ read = { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
281
+ }
282
+ caches.lockfiles.set(key, read)
283
+ }
284
+ return { ...at, ...read }
285
+ }
286
+
287
+ // A lockfile's packages of npm's registry or a mirror not yet seen this scan,
288
+ // given to the assessors.
289
+ function assessLater(scan: Scan, packages: LockedPackage[]) {
290
+ for (const { name, version, source, integrity } of packages) {
291
+ const key = `${name}@${version}\0${integrity ?? ''}`
292
+ if ((source !== 'registry' && source !== 'mirror') || version === null || scan.seen.has(key) || !isNpmPackageName(name) || !isNpmPackageSpec(version)) continue
293
+ scan.seen.add(key)
294
+ scan.pending.add({ name, version, ...integrity === undefined ? {} : { integrity } })
295
+ }
296
+ }
297
+
298
+ // A repository opened and its lockfiles of npm's ecosystem listed, as kept
299
+ // for its commit or else from GitHub, those of its `paths` alone; null, with
300
+ // its line telling why, where either failed, or where the scan stopped.
301
+ async function listLockfiles(scan: Scan, repo: RepositoryRiskRepo) {
302
+ try {
303
+ const opened = await scan.source.open(repo)
304
+ if (scan.signal.aborted) return null
305
+ const key = `${repo.repoId}@${opened.commit}`
306
+ let listed = scan.caches.listings.get(key)
307
+ if (!listed) scan.caches.listings.set(key, listed = await opened.lockfiles())
308
+ const lockfiles = listed.filter(({ path, format }) => NPM_LOCKFILE_FORMATS.has(format) && lockfileOf(path, repo.paths))
309
+ return { opened, lockfiles }
310
+ } catch (error) {
311
+ if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error) })
312
+ return null
313
+ }
314
+ }
315
+
316
+ type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
317
+
318
+ // A lockfile's advisories line: npm's registry's advisories of its packages
319
+ // of the registry or a mirror, by its blob as kept, else asked at once and
320
+ // kept; null where it locks none, or once the scan stops.
321
+ async function readAdvisories(scan: Scan, { repo, path, format, packages }: LockfileLine & { packages: LockedPackage[] }, sha: string): Promise<AdvisoriesLine | null> {
322
+ const at = { type: 'advisories', repo, path } as const
323
+ const key = `${format}\0${sha}`
324
+ const kept = scan.caches.advisories.get(key)
325
+ if (kept && Date.now() - kept.at < RISK_KEPT_MS) return { ...at, advisories: kept.rows }
326
+ const asked = new Map<string, Set<string>>()
327
+ for (const { name, version, source } of packages) {
328
+ if ((source !== 'registry' && source !== 'mirror') || !isNpmPackageName(name) || !isExactVersion(version)) continue
329
+ if (!asked.has(name)) asked.set(name, new Set())
330
+ asked.get(name)!.add(version)
331
+ }
332
+ if (asked.size === 0) return null
333
+ try {
334
+ const rows = (await scan.source.advisories([...asked].map(([name, versions]) => ({ name, versions: [...versions] })), scan.signal))
335
+ .map(advisory => ({ ...advisory, malware: npmMalwareAdvisory(advisory) }))
336
+ scan.caches.advisories.set(key, { at: Date.now(), rows })
337
+ return { ...at, advisories: rows }
338
+ } catch (error) {
339
+ return scan.signal.aborted ? null : { ...at, ...failureOf(error) }
340
+ }
341
+ }
342
+
343
+ // A repository's line, its lockfiles or why they couldn't be listed, and
344
+ // then its lockfiles', and their advisories: told once they are all read
345
+ // and access to the repository is checked again, as GitHub may have
346
+ // withdrawn it meanwhile.
347
+ async function readRepository(scan: Scan, repo: RepositoryRiskRepo) {
348
+ const listed = await listLockfiles(scan, repo)
349
+ if (!listed) return
350
+ const lockfiles: LockfileLine[] = []
351
+ for (const lockfile of listed.lockfiles) {
352
+ if (scan.signal.aborted) return
353
+ lockfiles.push(await readLockfile(scan, repo.repoId, listed.opened, lockfile))
354
+ }
355
+ try { await listed.opened.recheck() } catch (error) {
356
+ if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error) })
357
+ return
358
+ }
359
+ if (scan.signal.aborted) return
360
+ scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles: listed.lockfiles.map(({ path, format }) => ({ path, format })) })
361
+ for (const line of lockfiles) {
362
+ scan.lines.add(line)
363
+ if ('packages' in line) assessLater(scan, line.packages)
364
+ }
365
+ for (const [at, line] of lockfiles.entries()) {
366
+ if (scan.signal.aborted) return
367
+ const advisories = 'packages' in line ? await readAdvisories(scan, line, listed.lockfiles[at]!.sha) : null
368
+ if (advisories) scan.lines.add(advisories)
369
+ }
370
+ }
371
+
372
+ // Each repository, a repository at a time.
373
+ async function readRepositories(scan: Scan) {
374
+ const repos = await scan.source.repos()
375
+ scan.lines.add({ type: 'repos', repos: repos.map(repo => ({ id: repo.repoId, fullName: repo.fullName })) })
376
+ for (const repo of repos) {
377
+ if (scan.signal.aborted) return
378
+ await readRepository(scan, repo)
379
+ }
380
+ }
381
+
382
+ // The scan's lines, in the order they are found: the repositories first,
383
+ // then each repository's lockfiles and their packages once read and access
384
+ // is checked again, and each package's risk once assessed, while the
385
+ // repositories are still being read; `done` last. Ends at once, the work
386
+ // under way given up on, where `signal` aborts or the lines are no longer read. A failure of
387
+ // a repository, a lockfile or a package is told in its line; one of the scan
388
+ // itself is thrown, the lines before it told. What it reads is kept in
389
+ // `caches` (RiskCaches), the instance's own unless given.
390
+ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source: RepositoryRiskSource<R>, signal: AbortSignal, caches = shared): AsyncGenerator<RepositoryRiskLine> {
391
+ const stopped = new AbortController()
392
+ const running = AbortSignal.any([signal, stopped.signal])
393
+ const scan: Scan = { source, caches, signal: running, lines: queue(signal), pending: queue(running), seen: new Set() }
394
+ const failed: unknown[] = []
395
+ void (async () => {
396
+ const assessors = Array.from({ length: MAX_SPARE_ASSESSMENTS }, () => assessor(scan))
397
+ try {
398
+ try { await readRepositories(scan) } finally { scan.pending.close() }
399
+ await Promise.all(assessors)
400
+ if (!running.aborted) scan.lines.add({ type: 'done' })
401
+ } catch (error) {
402
+ failed.push(error)
403
+ stopped.abort()
404
+ } finally { scan.lines.close() }
405
+ })()
406
+ try {
407
+ for (let line = await scan.lines.take(); line !== undefined; line = await scan.lines.take()) yield line
408
+ if (failed.length > 0 && !signal.aborted) throw failed[0]
409
+ } finally {
410
+ // A GitHub request under way isn't waited on: it settles on its own.
411
+ stopped.abort()
412
+ }
413
+ }
@@ -13,6 +13,14 @@ import { hashToken, randomToken } from './crypto.ts'
13
13
  // `cookieName` when cookies are Secure.
14
14
  export const STATE_COOKIE = 'dvstate'
15
15
 
16
+ // Base names for GitHub issue authorization state (github-issue-oauth.ts) and
17
+ // an administrator's read-only view as another user (view-as.ts).
18
+ export const ISSUE_STATE_COOKIE = 'dvissuestate'
19
+ export const VIEW_COOKIE = 'dvview'
20
+ // The session cookie cannot share a name with these, or one would overwrite
21
+ // or be read as the other.
22
+ export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, VIEW_COOKIE]
23
+
16
24
  // `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
17
25
  // Secure + Path=/ + no Domain, which `buildCookie` already emits).
18
26
  export function cookieName(config: ManagedConfig, base: string): string {
@@ -62,11 +70,17 @@ export async function createSession(
62
70
  }
63
71
 
64
72
  // Resolve the current session (+ user) from the request's cookies, or null.
73
+ // A view cookie resolves only to that admin's view as another user: a view
74
+ // that has ended never falls back to the admin's own session. `own` skips the
75
+ // view and reads the signed-in session itself.
65
76
  export function readSession(
66
- config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number,
77
+ config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number, { own = false } = {},
67
78
  ): Promise<{ session: ManagedSession; user: StoredUser } | null> {
68
- const token = parseCookies(cookieHeader).get(config.sessionCookieName)
79
+ const cookies = parseCookies(cookieHeader)
80
+ const token = cookies.get(config.sessionCookieName)
69
81
  if (token == null || token === '') return Promise.resolve(null)
82
+ const view = own ? undefined : cookies.get(cookieName(config, VIEW_COOKIE))
83
+ if (view != null && view !== '') return db.viewSessionWithUser(hashToken(view), hashToken(token), now)
70
84
  return db.sessionWithUser(hashToken(token), now)
71
85
  }
72
86
 
@@ -22,7 +22,10 @@ export interface ManagedSqlDriver extends ManagedSql {
22
22
  const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
23
23
  'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
24
24
  'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
25
- 'getUpstreamCacheEntry', 'setUpstreamCacheEntry'])
25
+ 'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'ensureSessionUploadKey',
26
+ 'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits'])
27
+ // Session presence and upload keys change only their own rows.
28
+ const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
26
29
 
27
30
  export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
28
31
  const entries = Object.entries(methods).map(([name, method]) => {
@@ -31,7 +34,7 @@ export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver
31
34
  return [name, (...args: unknown[]) => driver.scope(write, () => method(...args), {
32
35
  // Presence writes only one user's timestamp; they need no application
33
36
  // writer lock. Keep the session/user read and timestamp in one transaction.
34
- lock: write && name !== 'sessionWithUser', statement: SINGLE_STATEMENTS.has(name),
37
+ lock: write && !UNLOCKED_WRITES.has(name), statement: SINGLE_STATEMENTS.has(name),
35
38
  })]
36
39
  })
37
40
  return Object.fromEntries(entries) as ManagedDb
@@ -4,7 +4,7 @@ import { Readable } from 'node:stream'
4
4
  import { type StorageKey, decryptStorageStream, encryptStorageStream, wrapStorageValue } from '../server-common/storage-crypto.ts'
5
5
  import { ENCRYPTED_CACHE_PREFIX, type ObjectStorage, type RawObject, type RawObjectStorage, deleteObjects, objectPath, openObjectVersion } from './object-storage.ts'
6
6
  import { type StorageDb, type StorageEncryptionState, type StorageRow, type StorageRowKind, dataKeyIdentity, unwrapDataKey } from './storage-db.ts'
7
- import { inspectStorageObject, storageOwner, storageRowPaths, verifyStoragePayload } from './storage-payload.ts'
7
+ import { PUBLIC_CACHE_PREFIX, inspectStorageObject, storageOwner, storageRowPaths, verifyStoragePayload } from './storage-payload.ts'
8
8
 
9
9
  const STORAGE_WRITE_MS = 180_000
10
10
  export const STORAGE_UPLOAD_TTL_MS = 86_400_000
@@ -22,6 +22,8 @@ function logicalKey(identity: string) {
22
22
  }
23
23
  const encryptedCachePath = (identity: string) => `${ENCRYPTED_CACHE_PREFIX}${identity.slice('cache/'.length)}`
24
24
  const deletionPaths = (identity: string) => identity.startsWith('cache/') ? [identity, encryptedCachePath(identity)] : [identity]
25
+ // Public bytes, stored as they are whether or not storage is encrypted.
26
+ const publicPath = (identity: string) => identity.startsWith('avatars/') || identity.startsWith(PUBLIC_CACHE_PREFIX)
25
27
 
26
28
  // A PUT can commit and still throw. Remove only this attempted plaintext,
27
29
  // comparing bytes rather than magic: arbitrary uploads can share the header.
@@ -199,7 +201,7 @@ export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: St
199
201
  async function open(identity: string): ReturnType<ObjectStorage['open']> {
200
202
  logicalKey(identity)
201
203
  const state = await mode()
202
- if (identity.startsWith('avatars/')) return raw.open(identity)
204
+ if (publicPath(identity)) return raw.open(identity)
203
205
  if (state) return openEncrypted(raw, db, key!, identity, state)
204
206
  const stored = await raw.open(identity)
205
207
  if (!stored) return null
@@ -231,7 +233,7 @@ export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: St
231
233
  if (replaceReport) return convertStoredReport(raw, db, key, identity, bytes, mode)
232
234
  const state = await mode()
233
235
  const owner = storageOwner(identity)
234
- if (identity.startsWith('avatars/')) { await raw.put(identity, bytes); return null }
236
+ if (publicPath(identity)) { await raw.put(identity, bytes); return null }
235
237
  if (!state) return putPlaintext(raw, identity, bytes, () => mode(true))
236
238
  let dataKey: Buffer, wrapped: string | null = null
237
239
  if (owner?.cache) {
@@ -15,6 +15,10 @@ export function storageRowPaths(type: StorageRowKind, row: Pick<StorageRow, 'id'
15
15
  return type === 'report' ? [`reports/${row.id}.br`, `reports/${row.id}`]
16
16
  : [`bundles/${row.id}${row.kind === 'sourcemap' ? '.map.br' : ''}`]
17
17
  }
18
+ // A cache of public content, which no row owns, kept unencrypted as avatars
19
+ // are: public npm versions' pretty-printed files (pretty-print.ts).
20
+ export const PUBLIC_CACHE = 'npm'
21
+ export const PUBLIC_CACHE_PREFIX = `cache/${PUBLIC_CACHE}/`
18
22
  export function storageOwner(identity: string): { type: StorageRowKind; id: string; cache: boolean } | null {
19
23
  const match = /^(reports|bundles)\/([^/]+)$/u.exec(identity)
20
24
  if (match) {
@@ -8,6 +8,7 @@ import { createBundleStore } from './bundle-store.ts'
8
8
  import { createReportStore } from './report-store.ts'
9
9
  import { CacheMissError, type CacheStorage, validateCacheKey } from './cache-storage.ts'
10
10
  import { type ObjectStorage, isBlobId } from './object-storage.ts'
11
+ import { PUBLIC_CACHE } from './storage-payload.ts'
11
12
 
12
13
  function id(value: string): string {
13
14
  if (!isBlobId(value)) throw new Error('Invalid managed blob id')
@@ -79,5 +80,6 @@ export function createManagedStores(objects: ObjectStorage, avatarSidecar: boole
79
80
  avatarStore: avatarStore(objects, avatarSidecar),
80
81
  cacheStorage,
81
82
  reportSourcesStorage: cacheStore(objects, 'report-sources'),
83
+ npmCacheStorage: cacheStore(objects, PUBLIC_CACHE),
82
84
  }
83
85
  }
@@ -2,6 +2,7 @@
2
2
  import { dirname } from 'node:path'
3
3
  import { parseStorageKey } from '../server-common/storage-crypto.ts'
4
4
  import { createBundleCache } from './bundle-cache.ts'
5
+ import { createPrettyCache, stalePrettyRemover } from './pretty-print.ts'
5
6
  import { createReportSourcesCache } from './report-sources.ts'
6
7
  import { openNeonManagedDb } from './db-neon.ts'
7
8
  import type { ManagedConfig } from './config.ts'
@@ -51,9 +52,11 @@ export async function openManagedStorage(config: ManagedConfig) {
51
52
  await storage.reportStore.delete(report.id).catch(error => console.warn('managed: legacy link cleanup failed:', error))
52
53
  }
53
54
  }
54
- return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined,
55
- bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
56
- reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore),
55
+ const bundleCache = createBundleCache(storage.cacheStorage, db, storage.bundleStore)
56
+ return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined, bundleCache,
57
+ reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore, bundle => bundleCache.sourcemapEdges(bundle)),
58
+ prettyCache: createPrettyCache(storage.cacheStorage, storage.npmCacheStorage, db, storage.bundleStore),
59
+ reapPrettyCopies: stalePrettyRemover(storage.npmCacheStorage),
57
60
  async reapStorage(signal?: AbortSignal) {
58
61
  if (!config.storageEncryptionMigrate || signal?.aborted) return
59
62
  const state = await db.getStorageEncryption()
@@ -0,0 +1,94 @@
1
+ // Whether each team's sidebar entry opens collapsed to its App findings,
2
+ // decided before the catalog is sent so the first paint is already final. A
3
+ // team is classified from the same filtered, published workspace its view
4
+ // loads. Results are keyed by the catalog's own content keys: an unchanged
5
+ // catalog reuses them, and every change that can move a result also changes
6
+ // the catalog revision that clients refresh on.
7
+ import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
8
+ import type { BlobStore } from './blob-store.ts'
9
+ import type { ManagedDb, TeamReportAccessSnapshot, UserTeam } from './db.ts'
10
+ import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
11
+ import { TeamReportsError, loadTeamReportsResponse } from './team-reports.ts'
12
+
13
+ export type { TeamAppMetadata }
14
+
15
+ const CACHE_MAX_ENTRIES = 4096
16
+ // Each workspace build already reads four reports at a time.
17
+ const CLASSIFY_CONCURRENCY = 2
18
+ const caches = new WeakMap<ManagedDb, Map<string, TeamAppMetadata>>()
19
+ const pendingJobs = new WeakMap<ManagedDb, Map<string, Promise<TeamAppMetadata | null>>>()
20
+
21
+ // Published reports with their content keys, and the team's own key (access,
22
+ // scopes and links). Equal keys describe the same filtered workspace, whoever
23
+ // reads it, so accounts with the same access share one classification.
24
+ export function teamAppKey(team: UserTeam): string {
25
+ return JSON.stringify([team.cacheKey ?? null, published(team).map(report => [report.id, report.cacheKey]).toSorted()])
26
+ }
27
+
28
+ function published(team: UserTeam) {
29
+ return team.reports.filter(report => report.visible !== false)
30
+ }
31
+
32
+ function remember(db: ManagedDb, key: string, app: TeamAppMetadata): TeamAppMetadata {
33
+ let cache = caches.get(db)
34
+ if (!cache) { cache = new Map(); caches.set(db, cache) }
35
+ cache.delete(key)
36
+ cache.set(key, app)
37
+ for (const oldest of cache.keys()) {
38
+ if (cache.size <= CACHE_MAX_ENTRIES) break
39
+ cache.delete(oldest)
40
+ }
41
+ return app
42
+ }
43
+
44
+ // Null when the team cannot be classified right now: its catalog went stale,
45
+ // the access snapshot is gone, or storage failed. The sidebar then keeps the
46
+ // team expanded, and a later catalog asks again. Oversized and unreadable
47
+ // workspaces never have an App view, so they are remembered as such.
48
+ async function classify(db: ManagedDb, store: BlobStore, key: string, team: UserTeam,
49
+ snapshot: () => Promise<TeamReportAccessSnapshot | null>): Promise<TeamAppMetadata | null> {
50
+ const cached = caches.get(db)?.get(key)
51
+ if (cached) return remember(db, key, cached)
52
+ const ids = new Set(published(team).map(report => report.id))
53
+ if (ids.size === 0) return remember(db, key, { appMode: false })
54
+ let jobs = pendingJobs.get(db)
55
+ if (!jobs) { jobs = new Map(); pendingJobs.set(db, jobs) }
56
+ const existing = jobs.get(key)
57
+ if (existing) return existing
58
+ const job = (async () => {
59
+ const access = await snapshot()
60
+ // A catalog read before a concurrent publication names another report set.
61
+ if (!access?.teamId || access.reports.length !== ids.size || access.reports.some(report => !ids.has(report.id))) return null
62
+ const body = JSON.parse((await loadTeamReportsResponse(db, store, access)).toString('utf8')) as {
63
+ reports: { filename: string; data: unknown }[]; links?: string[][]
64
+ }
65
+ return remember(db, key, managedTeamAppMetadata(Object.assign(body.reports, { links: body.links ?? [] })))
66
+ })().catch((error: unknown) => {
67
+ if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return remember(db, key, { appMode: false })
68
+ if (!(error instanceof TeamReportsError)) console.warn('managed: team App classification failed:', error)
69
+ return null
70
+ })
71
+ jobs.set(key, job)
72
+ try { return await job } finally { jobs.delete(key) }
73
+ }
74
+
75
+ // Classify a user's catalog, keyed by teamAppKey. `known` carries results from
76
+ // an earlier read of the same request, so only changed teams are classified.
77
+ export async function teamAppStates(db: ManagedDb, store: BlobStore, sessionId: string, teams: readonly UserTeam[],
78
+ known: ReadonlyMap<string, TeamAppMetadata | null> = new Map()): Promise<Map<string, TeamAppMetadata | null>> {
79
+ const states = new Map(known)
80
+ const missing = [...new Map(teams.map(team => [teamAppKey(team), team])).entries()].filter(([key]) => !states.has(key))
81
+ let next = 0
82
+ await Promise.all(Array.from({ length: Math.min(CLASSIFY_CONCURRENCY, missing.length) }, async () => {
83
+ while (next < missing.length) {
84
+ const [key, team] = missing[next++]!
85
+ states.set(key, await classify(db, store, key, team, () => db.getTeamReportAccessSnapshot(sessionId, Date.now(), team.id)))
86
+ }
87
+ }))
88
+ return states
89
+ }
90
+
91
+ // A public link's workspace is its own access snapshot.
92
+ export function sharedTeamApp(db: ManagedDb, store: BlobStore, snapshot: WorkspaceShareSnapshot): Promise<TeamAppMetadata | null> {
93
+ return classify(db, store, JSON.stringify(['share', teamAppKey(snapshot.team)]), snapshot.team, () => Promise.resolve(snapshot))
94
+ }