@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.39

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-45WIN3SW.js +1 -0
  46. package/out/chunk-4XCDFGDP.js +10 -0
  47. package/out/chunk-6C76GAXK.js +1 -0
  48. package/out/chunk-7ZKDKGAH.js +1 -0
  49. package/out/chunk-ABW5FVSN.js +1 -0
  50. package/out/chunk-C4Z6ZBXV.js +10 -0
  51. package/out/chunk-CMQA4DM6.js +1 -0
  52. package/out/chunk-CVVJAXVY.js +2 -0
  53. package/out/chunk-DIJJNTRH.js +10 -0
  54. package/out/chunk-F4OMDNEY.js +1 -0
  55. package/out/chunk-GJ4IUCNS.js +1 -0
  56. package/out/chunk-GMFXFEUA.js +2 -0
  57. package/out/chunk-GNXOKIGP.js +80 -0
  58. package/out/chunk-GZ4MS3TE.js +13 -0
  59. package/out/chunk-HIV7SVI7.js +1 -0
  60. package/out/chunk-IU3CCINR.js +3 -0
  61. package/out/chunk-JVBB4LZX.js +1 -0
  62. package/out/chunk-K3X5EJBQ.js +2 -0
  63. package/out/chunk-KEX2ATEW.js +2 -0
  64. package/out/chunk-KYCNJVB7.js +1 -0
  65. package/out/chunk-NRPC74AM.js +1 -0
  66. package/out/chunk-OOJM4CTU.js +1 -0
  67. package/out/chunk-PNHBZGDD.js +1 -0
  68. package/out/chunk-QSLWGJBQ.js +223 -0
  69. package/out/chunk-R5O6FAW4.js +2 -0
  70. package/out/chunk-RFZQQXSG.js +17 -0
  71. package/out/chunk-TINZZKP5.js +33 -0
  72. package/out/chunk-TUII34K2.js +1 -0
  73. package/out/chunk-TZ5KB2Y5.js +10 -0
  74. package/out/chunk-UNTRGE26.js +2 -0
  75. package/out/chunk-UYAXVRVL.js +1 -0
  76. package/out/chunk-V3ME6FI5.js +2 -0
  77. package/out/chunk-V5O7M2YT.js +1 -0
  78. package/out/chunk-VIHGO77Y.js +1 -0
  79. package/out/chunk-W4J3MGLB.js +1 -0
  80. package/out/chunk-WGG7KQUH.js +1 -0
  81. package/out/chunk-XNGEQO7Y.js +2 -0
  82. package/out/chunk-XRWAV3DX.js +7 -0
  83. package/out/chunk-XYDGU6BM.js +51 -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 +89 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +171 -0
  100. package/risk/files/runs-code.js +358 -0
  101. package/risk/files/script.js +114 -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 +228 -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 +34 -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 +74 -12
  129. package/server-managed/bundle-cache.ts +37 -4
  130. package/server-managed/bundle-commits.ts +140 -0
  131. package/server-managed/bundle-summary-cache.ts +24 -10
  132. package/server-managed/config.ts +4 -0
  133. package/server-managed/db-methods.ts +98 -11
  134. package/server-managed/db-neon.ts +39 -4
  135. package/server-managed/db-schema.ts +14 -2
  136. package/server-managed/db-table-names.ts +1 -1
  137. package/server-managed/db.ts +7 -3
  138. package/server-managed/dependency-reviews.ts +248 -0
  139. package/server-managed/github-app.ts +9 -3
  140. package/server-managed/github-errors.ts +21 -2
  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 +952 -53
  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 +418 -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,418 @@
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
+ // GitHub not reached with what failed (githubUnreachable), while `doing` what
115
+ // of a repository, and risk's with what it asked of npm and how that failed,
116
+ // where they say.
117
+ function failureOf(error: unknown, doing?: string): RiskFailure {
118
+ if (error instanceof GithubApiError && error.detail) return { error: error.message, detail: doing ? `${doing}: ${error.detail}` : error.detail }
119
+ if (error instanceof GithubApiError || error instanceof NpmPackageError) return { error: error.message }
120
+ if (!(error instanceof NpmRiskError)) return { error: 'failed' }
121
+ return error.message === error.code ? { error: error.code } : { error: error.code, detail: error.message }
122
+ }
123
+
124
+ const isBusy = (error: unknown) => (error instanceof NpmPackageError && error.status === 429) || (error instanceof NpmRiskError && error.code === 'upstream-busy')
125
+
126
+ type RiskLine = Extract<RepositoryRiskLine, { type: 'risk' }>
127
+ type LockfileRead = { packages: LockedPackage[] } | RiskFailure
128
+
129
+ // What scans keep between them, in this instance's memory: a repository's
130
+ // lockfiles as listed at a commit, and a lockfile's packages by its blob,
131
+ // neither of which changes; and a package's risk line and a lockfile's
132
+ // advisories, asked again after RISK_KEPT_MS, as risk tells again what of a
133
+ // package changes in time (a deprecation, its age, its downloads) and npm
134
+ // publishes advisories. Each is told only once access to its repository is
135
+ // checked as when it was read.
136
+ export interface RiskCaches {
137
+ listings: Lru<ListedLockfile[]>
138
+ lockfiles: Lru<LockfileRead>
139
+ risks: Lru<{ at: number; line: RiskLine }>
140
+ advisories: Lru<{ at: number; rows: AdvisoryRow[] }>
141
+ }
142
+ export const riskCaches = (): RiskCaches => ({
143
+ listings: lru(1000),
144
+ // By the packages they hold, a few hundred bytes each.
145
+ lockfiles: lru(200_000, read => 'packages' in read ? read.packages.length + 1 : 1),
146
+ risks: lru(50_000),
147
+ advisories: lru(50_000, kept => kept.rows.length + 1),
148
+ })
149
+ export const RISK_KEPT_MS = 60 * 60_000
150
+ const shared = riskCaches()
151
+
152
+ // Items in the order they are added, each taken once by whichever reader asks
153
+ // first: none once closed and all taken, or once `signal` aborts.
154
+ interface Queue<T> { add(item: T): void; close(): void; take(): Promise<T | undefined> }
155
+ function queue<T>(signal: AbortSignal): Queue<T> {
156
+ const items: T[] = []
157
+ const waiting: (() => void)[] = []
158
+ let closed = false
159
+ const wake = () => { for (const resolve of waiting.splice(0)) resolve() }
160
+ signal.addEventListener('abort', wake, { once: true })
161
+ return {
162
+ add(item) { items.push(item); wake() },
163
+ close() { closed = true; wake() },
164
+ async take() {
165
+ for (;;) {
166
+ if (signal.aborted) return undefined
167
+ const item = items.shift()
168
+ if (item !== undefined || closed) return item
169
+ await new Promise<void>(resolve => { waiting.push(resolve) })
170
+ }
171
+ },
172
+ }
173
+ }
174
+
175
+ // A package to assess: npm's of its name and version, or, with an
176
+ // `integrity`, a mirror's held to it.
177
+ interface AssessedPackage { name: string; version: string; integrity?: string }
178
+
179
+ interface Scan {
180
+ source: RepositoryRiskSource<RepositoryRiskRepo>
181
+ caches: RiskCaches
182
+ signal: AbortSignal
183
+ lines: Queue<RepositoryRiskLine>
184
+ // Packages to assess, and those already asked for, by name, version and a
185
+ // mirror's integrity.
186
+ pending: Queue<AssessedPackage>
187
+ seen: Set<string>
188
+ }
189
+
190
+ // A package's risk line, its version's document kept and the assessment asked
191
+ // again after a wait while the server is busy; null once the scan stops. A
192
+ // version npm has no document of is told as risk tells one npm removed
193
+ // (removedOf), its malware keeping whether its package was taken down.
194
+ async function assess({ source, signal }: Scan, pkg: AssessedPackage, teamScoped: boolean): Promise<RiskLine | null> {
195
+ const at = { type: 'risk', ...pkg } as const
196
+ let doc: NpmVersionDocument | NpmPackageError | undefined
197
+ for (let attempt = 0; ; attempt++) {
198
+ try {
199
+ const read = doc ??= await documentOf(source, pkg, signal)
200
+ if (!(read instanceof NpmPackageError) && pkg.integrity !== undefined && !pkg.integrity.split(/\s+/u).includes(read.dist.integrity)) return { ...at, error: 'integrity-mismatch' }
201
+ const { published, findings } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
202
+ const told = findings.map(({ id, severity, takenDown }) => ({ id, severity, ...takenDown === true ? { takenDown: true as const } : {} }))
203
+ return { ...at, published, severity: highestSeverity(told), findings: told }
204
+ } catch (error) {
205
+ if (signal.aborted) return null
206
+ if (!isBusy(error) || attempt >= BUSY_RETRIES) return { ...at, ...failureOf(error) }
207
+ try { await delay(BUSY_WAIT_MS * Math.min(attempt + 1, 5), undefined, { signal }) } catch { return null }
208
+ }
209
+ }
210
+ }
211
+
212
+ // npm's document of a version, or its answer that it has none (a 404).
213
+ async function documentOf(source: Scan['source'], pkg: AssessedPackage, signal: AbortSignal): Promise<NpmVersionDocument | NpmPackageError> {
214
+ try { return await source.version(pkg.name, pkg.version, signal) } catch (error) {
215
+ if (error instanceof NpmPackageError && error.status === 404) return error
216
+ throw error
217
+ }
218
+ }
219
+
220
+ // A version's assessment; where risk finds npm no longer has it, as a document
221
+ // read from where versions' are kept doesn't tell, as one npm removed.
222
+ async function assessedOf(source: Scan['source'], pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, doc: NpmVersionDocument): Promise<Assessment> {
223
+ try { return await source.assess(doc, teamScoped, signal) } catch (error) {
224
+ if (!(error instanceof NpmRiskError && error.code === 'version-not-found')) throw error
225
+ return removedOf(source, pkg, teamScoped, signal, new NpmPackageError(404, 'package-not-found'))
226
+ }
227
+ }
228
+
229
+ // A version npm has no document of, as risk tells one npm removed, as it
230
+ // removes every one it finds malware in (`removed`): `gone`, npm's answer of
231
+ // none, thrown where nothing tells of it.
232
+ async function removedOf(source: Scan['source'], pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, gone: NpmPackageError): Promise<Assessment> {
233
+ try { return await source.removed(pkg.name, pkg.version, teamScoped, signal) } catch (error) {
234
+ throw error instanceof NpmRiskError && (error.code === 'version-not-found' || error.code === 'package-not-found') ? gone : error
235
+ }
236
+ }
237
+
238
+ // A package's risk line as kept (RiskCaches, else the source's `risks`), else
239
+ // assessed and kept, in both, where it tells what risk found, or that a
240
+ // mirror's copy isn't npm's.
241
+ async function assessed(scan: Scan, pkg: AssessedPackage): Promise<RiskLine | null> {
242
+ let teamScoped
243
+ try { teamScoped = await scan.source.teamScoped(pkg.name) } catch (error) { return { type: 'risk', ...pkg, ...failureOf(error) } }
244
+ const key = `${pkg.name}@${pkg.version}\0${pkg.integrity ?? ''}\0${teamScoped}`
245
+ const kept = scan.caches.risks.get(key) ?? await keptLine(scan, key)
246
+ if (kept && Date.now() - kept.at < RISK_KEPT_MS) return kept.line
247
+ const line = await assess(scan, pkg, teamScoped)
248
+ if (line && (!('error' in line) || line.error === 'integrity-mismatch')) {
249
+ const at = Date.now()
250
+ scan.caches.risks.set(key, { at, line })
251
+ await scan.source.risks?.write(key, line, at)
252
+ }
253
+ return line
254
+ }
255
+
256
+ async function keptLine({ source, caches }: Scan, key: string) {
257
+ const hit = await source.risks?.read(key)
258
+ if (!hit) return null
259
+ caches.risks.set(key, { at: hit.at, line: hit.value })
260
+ return { at: hit.at, line: hit.value }
261
+ }
262
+
263
+ async function assessor(scan: Scan) {
264
+ for (let next = await scan.pending.take(); next !== undefined; next = await scan.pending.take()) {
265
+ const line = await assessed(scan, next)
266
+ if (line) scan.lines.add(line)
267
+ }
268
+ }
269
+
270
+ type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
271
+
272
+ // A lockfile's line, its packages or why they couldn't be had: as kept by its
273
+ // blob, else read from GitHub and kept.
274
+ async function readLockfile({ caches }: Scan, repo: number, opened: OpenedRepository, { path, format, sha }: ListedLockfile): Promise<LockfileLine> {
275
+ const at = { type: 'lockfile', repo, path, format } as const
276
+ if (!READ_FORMATS.has(format)) return { ...at, error: 'unsupported-format' }
277
+ const key = `${format}\0${sha}`
278
+ let read = caches.lockfiles.get(key)
279
+ if (!read) {
280
+ let text
281
+ try { text = await opened.file(path) } catch (error) { return { ...at, ...failureOf(error) } }
282
+ try { read = { packages: lockedPackages(format, text) } } catch (error) {
283
+ read = { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
284
+ }
285
+ caches.lockfiles.set(key, read)
286
+ }
287
+ return { ...at, ...read }
288
+ }
289
+
290
+ // A lockfile's packages of npm's registry or a mirror not yet seen this scan,
291
+ // given to the assessors.
292
+ function assessLater(scan: Scan, packages: LockedPackage[]) {
293
+ for (const { name, version, source, integrity } of packages) {
294
+ const key = `${name}@${version}\0${integrity ?? ''}`
295
+ if ((source !== 'registry' && source !== 'mirror') || version === null || scan.seen.has(key) || !isNpmPackageName(name) || !isNpmPackageSpec(version)) continue
296
+ scan.seen.add(key)
297
+ scan.pending.add({ name, version, ...integrity === undefined ? {} : { integrity } })
298
+ }
299
+ }
300
+
301
+ // A repository opened and its lockfiles of npm's ecosystem listed, as kept
302
+ // for its commit or else from GitHub, those of its `paths` alone; null, with
303
+ // its line telling why, where either failed, or where the scan stopped.
304
+ async function listLockfiles(scan: Scan, repo: RepositoryRiskRepo) {
305
+ let doing = 'opening it'
306
+ try {
307
+ const opened = await scan.source.open(repo)
308
+ if (scan.signal.aborted) return null
309
+ const key = `${repo.repoId}@${opened.commit}`
310
+ let listed = scan.caches.listings.get(key)
311
+ doing = 'listing its lockfiles'
312
+ if (!listed) scan.caches.listings.set(key, listed = await opened.lockfiles())
313
+ const lockfiles = listed.filter(({ path, format }) => NPM_LOCKFILE_FORMATS.has(format) && lockfileOf(path, repo.paths))
314
+ return { opened, lockfiles }
315
+ } catch (error) {
316
+ if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, doing) })
317
+ return null
318
+ }
319
+ }
320
+
321
+ type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
322
+
323
+ // A lockfile's advisories line: npm's registry's advisories of its packages
324
+ // of the registry or a mirror, by its blob as kept, else asked at once and
325
+ // kept; null where it locks none, or once the scan stops.
326
+ async function readAdvisories(scan: Scan, { repo, path, format, packages }: LockfileLine & { packages: LockedPackage[] }, sha: string): Promise<AdvisoriesLine | null> {
327
+ const at = { type: 'advisories', repo, path } as const
328
+ const key = `${format}\0${sha}`
329
+ const kept = scan.caches.advisories.get(key)
330
+ if (kept && Date.now() - kept.at < RISK_KEPT_MS) return { ...at, advisories: kept.rows }
331
+ const asked = new Map<string, Set<string>>()
332
+ for (const { name, version, source } of packages) {
333
+ if ((source !== 'registry' && source !== 'mirror') || !isNpmPackageName(name) || !isExactVersion(version)) continue
334
+ if (!asked.has(name)) asked.set(name, new Set())
335
+ asked.get(name)!.add(version)
336
+ }
337
+ if (asked.size === 0) return null
338
+ try {
339
+ const rows = (await scan.source.advisories([...asked].map(([name, versions]) => ({ name, versions: [...versions] })), scan.signal))
340
+ .map(advisory => ({ ...advisory, malware: npmMalwareAdvisory(advisory) }))
341
+ scan.caches.advisories.set(key, { at: Date.now(), rows })
342
+ return { ...at, advisories: rows }
343
+ } catch (error) {
344
+ return scan.signal.aborted ? null : { ...at, ...failureOf(error) }
345
+ }
346
+ }
347
+
348
+ // A repository's line, its lockfiles or why they couldn't be listed, and
349
+ // then its lockfiles', and their advisories: told once they are all read
350
+ // and access to the repository is checked again, as GitHub may have
351
+ // withdrawn it meanwhile.
352
+ async function readRepository(scan: Scan, repo: RepositoryRiskRepo) {
353
+ const listed = await listLockfiles(scan, repo)
354
+ if (!listed) return
355
+ const lockfiles: LockfileLine[] = []
356
+ for (const lockfile of listed.lockfiles) {
357
+ if (scan.signal.aborted) return
358
+ lockfiles.push(await readLockfile(scan, repo.repoId, listed.opened, lockfile))
359
+ }
360
+ try { await listed.opened.recheck() } catch (error) {
361
+ if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, 'checking access to it again') })
362
+ return
363
+ }
364
+ if (scan.signal.aborted) return
365
+ scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles: listed.lockfiles.map(({ path, format }) => ({ path, format })) })
366
+ for (const line of lockfiles) {
367
+ scan.lines.add(line)
368
+ if ('packages' in line) assessLater(scan, line.packages)
369
+ }
370
+ for (const [at, line] of lockfiles.entries()) {
371
+ if (scan.signal.aborted) return
372
+ const advisories = 'packages' in line ? await readAdvisories(scan, line, listed.lockfiles[at]!.sha) : null
373
+ if (advisories) scan.lines.add(advisories)
374
+ }
375
+ }
376
+
377
+ // Each repository, a repository at a time.
378
+ async function readRepositories(scan: Scan) {
379
+ const repos = await scan.source.repos()
380
+ scan.lines.add({ type: 'repos', repos: repos.map(repo => ({ id: repo.repoId, fullName: repo.fullName })) })
381
+ for (const repo of repos) {
382
+ if (scan.signal.aborted) return
383
+ await readRepository(scan, repo)
384
+ }
385
+ }
386
+
387
+ // The scan's lines, in the order they are found: the repositories first,
388
+ // then each repository's lockfiles and their packages once read and access
389
+ // is checked again, and each package's risk once assessed, while the
390
+ // repositories are still being read; `done` last. Ends at once, the work
391
+ // under way given up on, where `signal` aborts or the lines are no longer read. A failure of
392
+ // a repository, a lockfile or a package is told in its line; one of the scan
393
+ // itself is thrown, the lines before it told. What it reads is kept in
394
+ // `caches` (RiskCaches), the instance's own unless given.
395
+ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source: RepositoryRiskSource<R>, signal: AbortSignal, caches = shared): AsyncGenerator<RepositoryRiskLine> {
396
+ const stopped = new AbortController()
397
+ const running = AbortSignal.any([signal, stopped.signal])
398
+ const scan: Scan = { source, caches, signal: running, lines: queue(signal), pending: queue(running), seen: new Set() }
399
+ const failed: unknown[] = []
400
+ void (async () => {
401
+ const assessors = Array.from({ length: MAX_SPARE_ASSESSMENTS }, () => assessor(scan))
402
+ try {
403
+ try { await readRepositories(scan) } finally { scan.pending.close() }
404
+ await Promise.all(assessors)
405
+ if (!running.aborted) scan.lines.add({ type: 'done' })
406
+ } catch (error) {
407
+ failed.push(error)
408
+ stopped.abort()
409
+ } finally { scan.lines.close() }
410
+ })()
411
+ try {
412
+ for (let line = await scan.lines.take(); line !== undefined; line = await scan.lines.take()) yield line
413
+ if (failed.length > 0 && !signal.aborted) throw failed[0]
414
+ } finally {
415
+ // A GitHub request under way isn't waited on: it settles on its own.
416
+ stopped.abort()
417
+ }
418
+ }
@@ -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
+ }