@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.
- package/README.md +16 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +6 -2
- package/common/bundle-metadata.js +63 -20
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-source-links.js +7 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +3 -1
- package/common/bundle-sources.js +47 -19
- package/common/code-language.js +16 -2
- package/common/default-scan-models.ts +2 -1
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/github-pr.ts +1 -1
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +1 -5
- package/common/managed/routes.js +123 -12
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +1 -1
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-45WIN3SW.js +1 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-6C76GAXK.js +1 -0
- package/out/chunk-7ZKDKGAH.js +1 -0
- package/out/chunk-ABW5FVSN.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-F4OMDNEY.js +1 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GMFXFEUA.js +2 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-HIV7SVI7.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-JVBB4LZX.js +1 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-QSLWGJBQ.js +223 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-TINZZKP5.js +33 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-XNGEQO7Y.js +2 -0
- package/out/chunk-XRWAV3DX.js +7 -0
- package/out/chunk-XYDGU6BM.js +51 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZPD5R5RP.js +3 -0
- package/out/client-managed-import.js +3 -87
- package/out/client-managed.js +26 -338
- package/out/client-sync.js +8 -14
- package/out/graph.js +17 -30
- package/out/index.html +34 -0
- package/out/prism.js +6 -2
- package/out/terminal.js +241 -257
- package/out/view.css +1 -1
- package/out/view.js +76 -520
- package/package.json +89 -7
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +171 -0
- package/risk/files/runs-code.js +358 -0
- package/risk/files/script.js +114 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +142 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +219 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +228 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-e2e/http.ts +7 -7
- package/server-e2e/npm-proxy.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
- package/server-e2e/objstore/handlers.ts +1 -1
- package/server-e2e/objstore/tokens.ts +1 -1
- package/server-e2e/sign.ts +1 -1
- package/server-managed/bundle-advisories.ts +74 -12
- package/server-managed/bundle-cache.ts +37 -4
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-summary-cache.ts +24 -10
- package/server-managed/config.ts +4 -0
- package/server-managed/db-methods.ts +98 -11
- package/server-managed/db-neon.ts +39 -4
- package/server-managed/db-schema.ts +14 -2
- package/server-managed/db-table-names.ts +1 -1
- package/server-managed/db.ts +7 -3
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +9 -3
- package/server-managed/github-errors.ts +21 -2
- package/server-managed/github-issue-oauth.ts +5 -6
- package/server-managed/github-metadata.ts +115 -0
- package/server-managed/github-oauth.ts +38 -2
- package/server-managed/http-response.ts +26 -0
- package/server-managed/http.ts +952 -53
- package/server-managed/index.ts +11 -5
- package/server-managed/kept.ts +28 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +78 -7
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/report-sources.ts +13 -2
- package/server-managed/repository-browser.ts +19 -5
- package/server-managed/repository-risk.ts +418 -0
- package/server-managed/session.ts +16 -2
- package/server-managed/sql.ts +5 -2
- package/server-managed/storage-encryption.ts +5 -3
- package/server-managed/storage-payload.ts +4 -0
- package/server-managed/storage-stores.ts +2 -0
- package/server-managed/storage.ts +6 -3
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-feed.ts +1 -1
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +25 -4
- package/server-managed/upstream-cache.ts +147 -5
- 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
|
|
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
|
|
package/server-managed/sql.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
+
}
|