@preventive/triage 1.0.0-alpha.52 → 1.0.0-alpha.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/common/bundle-compare-diff.js +28 -9
- package/common/bundle-compare-inputs.d.ts +1 -0
- package/common/bundle-compare-inputs.js +91 -15
- package/common/bundle-comparison.js +14 -7
- package/common/default-scan-models.ts +3 -0
- package/common/dependency-reviews.d.ts +1 -1
- package/common/dependency-reviews.js +1 -1
- package/common/managed/finding-detail.d.ts +4 -2
- package/common/managed/finding-detail.js +27 -15
- package/common/managed/team-app.d.ts +9 -0
- package/common/managed/team-app.js +59 -6
- package/common/workspace-app.js +38 -6
- package/out/activity.js +4 -4
- package/out/chunk-4EMM6UCD.js +1 -0
- package/out/chunk-4ISO5F7Y.js +7 -0
- package/out/chunk-4XIH7YTX.js +1 -0
- package/out/chunk-6W5BMATD.js +1 -0
- package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
- package/out/chunk-DEVAORP2.js +1 -0
- package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
- package/out/chunk-E3LFSUDJ.js +9 -0
- package/out/chunk-FHIVVVUA.js +6 -0
- package/out/chunk-G3LY2FV4.js +1 -0
- package/out/chunk-HAZKDK4M.js +4 -0
- package/out/chunk-HOB3YPS5.js +1 -0
- package/out/chunk-I47TYPXO.js +52 -0
- package/out/chunk-J7A5OSZE.js +244 -0
- package/out/chunk-JFL7WLRV.js +1 -0
- package/out/chunk-JMG5DXII.js +2 -0
- package/out/chunk-JOXLZXMI.js +1 -0
- package/out/chunk-L4CTEMRG.js +2 -0
- package/out/chunk-M37X4LCF.js +14 -0
- package/out/chunk-M3Y6SLC4.js +1 -0
- package/out/chunk-NXATZJQ2.js +1 -0
- package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
- package/out/chunk-PAD2JHZT.js +1 -0
- package/out/chunk-QMVOEBWC.js +13 -0
- package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
- package/out/chunk-RRBIJL2B.js +1 -0
- package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
- package/out/chunk-TOXMUOIW.js +5 -0
- package/out/chunk-UBOCLLF2.js +1 -0
- package/out/chunk-UCP3JRE2.js +1 -0
- package/out/{chunk-NCN3M5EP.js → chunk-VC72ZO3P.js} +2 -2
- package/out/chunk-VRX2APNA.js +9 -0
- package/out/{chunk-IMM274IW.js → chunk-VZMELPVL.js} +4 -3
- package/out/{chunk-CHRBUGVN.js → chunk-WHVXTJHK.js} +1 -1
- package/out/chunk-WMFDQS2L.js +11 -0
- package/out/chunk-WNSVBOSO.js +41 -0
- package/out/chunk-WREU7JAG.js +1 -0
- package/out/chunk-Y5ZHFNBD.js +2 -0
- package/out/chunk-YF4M5OCI.js +1 -0
- package/out/chunk-Z667U2UJ.js +1 -0
- package/out/chunk-ZQHLECTI.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +27 -26
- package/out/compare.js +14 -14
- package/out/dialogs.js +36 -51
- package/out/graph.js +17 -15
- package/out/index.html +26 -10
- package/out/prism.js +1 -1
- package/out/scan.js +2 -2
- package/out/terminal.js +232 -232
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +12 -7
- package/risk/actions/resolve.js +36 -5
- package/risk/index.d.ts +20 -2
- package/risk/npm/malware.js +6 -4
- package/risk/npm/registry.js +8 -6
- package/server-managed/bundle-advisories.ts +56 -74
- package/server-managed/bundle-build-worker.js +61 -4
- package/server-managed/bundle-build.ts +13 -4
- package/server-managed/bundle-dependency-reviews.ts +74 -0
- package/server-managed/bundle-validation.ts +32 -0
- package/server-managed/db-methods.ts +51 -16
- package/server-managed/db-neon.ts +18 -2
- package/server-managed/db.ts +11 -2
- package/server-managed/github-issue-links.ts +151 -47
- package/server-managed/github-pulls.ts +34 -15
- package/server-managed/history-windows.ts +172 -0
- package/server-managed/http.ts +227 -12
- package/server-managed/index.ts +13 -1
- package/server-managed/managed-issues.ts +29 -15
- package/server-managed/npm-insights.ts +4 -3
- package/server-managed/npm-kept.ts +4 -0
- package/server-managed/package-entry-points.ts +37 -9
- package/server-managed/pretty-print.ts +7 -5
- package/server-managed/public-workspace.ts +2 -2
- package/server-managed/repository-actions.ts +7 -4
- package/server-managed/repository-browser.ts +11 -4
- package/server-managed/repository-history.ts +2 -2
- package/server-managed/repository-pulse.ts +11 -2
- package/server-managed/repository-risk-audit.ts +4 -5
- package/server-managed/repository-risk.ts +4 -4
- package/server-managed/sql.ts +2 -1
- package/server-managed/stasis-config.ts +46 -0
- package/server-managed/team-app-summaries.ts +54 -0
- package/server-managed/team-app.ts +52 -31
- package/server-managed/team-reports.ts +34 -13
- package/server-managed/upstream-cache.ts +41 -32
- package/server-managed/workspace-shares.ts +3 -1
- package/out/chunk-2ZDZWLFY.js +0 -1
- package/out/chunk-7VAWINQF.js +0 -6
- package/out/chunk-FMXWXDR4.js +0 -36
- package/out/chunk-IZ7POWI3.js +0 -18
- package/out/chunk-LJ7XRXFU.js +0 -52
- package/out/chunk-OGAYMEER.js +0 -232
- package/out/chunk-QB4CBOX2.js +0 -1
- package/out/chunk-TQJDYHVE.js +0 -2
- package/out/chunk-VH4XMZIW.js +0 -2
- package/out/chunk-WYLQWYLA.js +0 -1
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { ManagedSql } from './sql.ts'
|
|
2
2
|
import type { TeamReportAccessSnapshot } from './db.ts'
|
|
3
|
-
import { parseGithubPrUrl } from '../common/github-pr.ts'
|
|
3
|
+
import { parseGithubIssueUrl, parseGithubPrUrl } from '../common/github-pr.ts'
|
|
4
4
|
|
|
5
5
|
// One immutable reference per finding, shared across reports and teams. Keep
|
|
6
6
|
// reservations across crashes/uncertain GitHub responses to prevent duplicates.
|
|
7
|
-
// No triage/import/admin patch accepts these fields.
|
|
7
|
+
// No triage/import/admin patch accepts these fields. What a lookup derives of
|
|
8
|
+
// the issue (github-issue-links.ts) sits beside it, never in its place: the
|
|
9
|
+
// issue it was closed as a duplicate of, kept as a redirect is, and the pull
|
|
10
|
+
// request fixing either, both as last checked (auto_fix_checked_at).
|
|
8
11
|
export const MANAGED_ISSUE_SCHEMA = `
|
|
9
12
|
CREATE TABLE IF NOT EXISTS managed_finding_issue (
|
|
10
13
|
finding_id TEXT PRIMARY KEY,
|
|
@@ -14,6 +17,7 @@ CREATE TABLE IF NOT EXISTS managed_finding_issue (
|
|
|
14
17
|
issue_url TEXT,
|
|
15
18
|
auto_fix_url TEXT,
|
|
16
19
|
auto_fix_checked_at INTEGER,
|
|
20
|
+
duplicate_of_url TEXT,
|
|
17
21
|
created_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
|
|
18
22
|
created_at INTEGER NOT NULL
|
|
19
23
|
) STRICT;
|
|
@@ -26,16 +30,20 @@ export interface ManagedIssue {
|
|
|
26
30
|
issueUrl: string | null
|
|
27
31
|
autoFixUrl: string | null
|
|
28
32
|
autoFixCheckedAt: number | null
|
|
33
|
+
duplicateOfUrl: string | null
|
|
29
34
|
createdBy: string | null
|
|
30
35
|
createdAt: number
|
|
31
36
|
}
|
|
37
|
+
// What a lookup derives of a saved issue: the pull request fixing it, and the
|
|
38
|
+
// issue it was closed as a duplicate of (followed to the last such one).
|
|
39
|
+
export type IssueTracking = { autoFix: string | null; duplicateOf: string | null }
|
|
32
40
|
export interface ManagedIssueStore {
|
|
33
41
|
getManagedIssue(findingId: string): Promise<ManagedIssue | null>
|
|
34
42
|
listManagedIssues(findingIds: readonly string[]): Promise<ManagedIssue[]>
|
|
35
|
-
claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl' | 'autoFixUrl' | 'autoFixCheckedAt'>): Promise<boolean>
|
|
43
|
+
claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl' | 'autoFixUrl' | 'autoFixCheckedAt' | 'duplicateOfUrl'>): Promise<boolean>
|
|
36
44
|
finishManagedIssue(findingId: string, requestId: string, url: string): Promise<boolean>
|
|
37
45
|
releaseManagedIssue(findingId: string, requestId: string): Promise<void>
|
|
38
|
-
|
|
46
|
+
setManagedIssueTracking(findingId: string, issueUrl: string, previous: IssueTracking, next: IssueTracking, checkedAt: number): Promise<boolean>
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
// A repeated finding ID never grants access to another team's repository.
|
|
@@ -43,21 +51,26 @@ export function visibleManagedIssues(issues: ManagedIssue[], snapshot: TeamRepor
|
|
|
43
51
|
if (snapshot.user.id.startsWith('share:')) return []
|
|
44
52
|
const repos = new Set(snapshot.repositories.map(repo => repo.repoId))
|
|
45
53
|
const names = new Set(snapshot.repositories.map(repo => repo.github.toLowerCase()))
|
|
46
|
-
|
|
47
|
-
const ref =
|
|
48
|
-
return
|
|
49
|
-
}
|
|
54
|
+
const inTeam = (url: string | null, parse: typeof parseGithubPrUrl) => {
|
|
55
|
+
const ref = parse(url)
|
|
56
|
+
return ref && names.has(ref.repo.toLowerCase()) ? url : null
|
|
57
|
+
}
|
|
58
|
+
return issues.filter(issue => issue.issueUrl && repos.has(issue.repoId)).map(issue =>
|
|
59
|
+
({ ...issue, autoFixUrl: inTeam(issue.autoFixUrl, parseGithubPrUrl), duplicateOfUrl: inTeam(issue.duplicateOfUrl, parseGithubIssueUrl) }))
|
|
50
60
|
}
|
|
51
61
|
export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
|
|
52
62
|
const fields = `finding_id AS findingId, repo_id AS repoId, repository, request_id AS requestId,
|
|
53
|
-
issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt,
|
|
63
|
+
issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, duplicate_of_url AS duplicateOfUrl,
|
|
64
|
+
created_by AS createdBy, created_at AS createdAt`
|
|
54
65
|
const get = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id = ?`)
|
|
55
66
|
const list = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY finding_id`)
|
|
56
|
-
// A lookup's derived Fix
|
|
57
|
-
// changed
|
|
58
|
-
// (github-issue-links.ts issueUpdates), orders them.
|
|
59
|
-
|
|
67
|
+
// A lookup's derived Fix and duplicate replace those it read, unless one
|
|
68
|
+
// started later changed them since: the check time, which only changes
|
|
69
|
+
// stamp (github-issue-links.ts issueUpdates), orders them. The issue's own
|
|
70
|
+
// URL is never written here.
|
|
71
|
+
const tracking = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, duplicate_of_url = ?, auto_fix_checked_at = ?
|
|
60
72
|
WHERE finding_id = ? AND issue_url = ? AND COALESCE(auto_fix_url, '') = COALESCE(?, '')
|
|
73
|
+
AND COALESCE(duplicate_of_url, '') = COALESCE(?, '')
|
|
61
74
|
AND (auto_fix_checked_at IS NULL OR auto_fix_checked_at <= ?)`)
|
|
62
75
|
const claim = db.prepare(`INSERT INTO managed_finding_issue (finding_id, repo_id, repository, request_id, created_by, created_at)
|
|
63
76
|
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (finding_id) DO NOTHING`)
|
|
@@ -66,8 +79,9 @@ export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
|
|
|
66
79
|
return {
|
|
67
80
|
async getManagedIssue(findingId) { return (await get.get(findingId) as ManagedIssue | undefined) ?? null },
|
|
68
81
|
async listManagedIssues(ids) { return await list.all(JSON.stringify(ids)) as ManagedIssue[] },
|
|
69
|
-
async
|
|
70
|
-
return Number((await
|
|
82
|
+
async setManagedIssueTracking(findingId, issueUrl, previous, next, checkedAt) {
|
|
83
|
+
return Number((await tracking.run(next.autoFix, next.duplicateOf, checkedAt, findingId, issueUrl,
|
|
84
|
+
previous.autoFix, previous.duplicateOf, checkedAt)).changes) > 0
|
|
71
85
|
},
|
|
72
86
|
async claimManagedIssue(issue) {
|
|
73
87
|
return Number((await claim.run(issue.findingId, issue.repoId, issue.repository, issue.requestId, issue.createdBy, issue.createdAt)).changes) > 0
|
|
@@ -286,10 +286,11 @@ export async function npmCommitTags(repo: string, sha: string, version: string,
|
|
|
286
286
|
// An advisory on the package, with the versions it covers as their indexes in
|
|
287
287
|
// the list it was asked with: npm's (`registry`), or one its repository
|
|
288
288
|
// publishes on GitHub before GitHub reviews it into npm's (`repository`); its
|
|
289
|
-
// text (markdown) where its source has one
|
|
289
|
+
// text (markdown) where its source has one; `malware` where upstream tells it
|
|
290
|
+
// is of malware, any of its rows.
|
|
290
291
|
export interface NpmAdvisory {
|
|
291
292
|
id: string; source: Advisory['source']; ghsa?: string; url?: string; title?: string; severity?: string; cvss?: number; cwe: string[]; range?: string
|
|
292
|
-
details?: string; affected: number[]
|
|
293
|
+
details?: string; malware?: true; affected: number[]
|
|
293
294
|
}
|
|
294
295
|
|
|
295
296
|
// `versions` those `affected` indexes; `repository` false where the
|
|
@@ -377,7 +378,7 @@ async function askAdvisories(name: string, versions: string[], { githubToken, re
|
|
|
377
378
|
const own: NpmAdvisory = {
|
|
378
379
|
id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
|
|
379
380
|
...row.severity && { severity: row.severity }, ...row.cvss !== undefined && { cvss: row.cvss }, cwe: row.cwe, ...row.range && { range: row.range },
|
|
380
|
-
...row.details && { details: row.details }, affected,
|
|
381
|
+
...row.details && { details: row.details }, ...row.malware && { malware: true }, affected,
|
|
381
382
|
}
|
|
382
383
|
const known = merged.get(row.id)
|
|
383
384
|
if (!known) {
|
|
@@ -227,3 +227,7 @@ export const reviewBaselineStore = (db: UpstreamCacheStore, debug = false): Kept
|
|
|
227
227
|
// what their reviews are held to.
|
|
228
228
|
export const lockfileReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles', debug = false): KeptStore<T> =>
|
|
229
229
|
keptStore<T>(db, `repository/review-${type}`, HISTORY_PAGE_KEPT_MS, debug)
|
|
230
|
+
// A bundle's validations (bundle-validation.ts), by the bundle, where it is,
|
|
231
|
+
// what its reader could check and the rules they were made by: kept a month
|
|
232
|
+
// after they were made, as what they check never changes.
|
|
233
|
+
export const bundleValidationStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'bundles/validations', HISTORY_PAGE_KEPT_MS, debug)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { buildableEntry } from './bundle-build.ts'
|
|
2
3
|
import { normalizeTeamPath } from './repo-path.ts'
|
|
4
|
+
import { MAX_STASIS_CONFIG_BYTES, parseStasisConfig } from './stasis-config.ts'
|
|
3
5
|
|
|
4
6
|
export const MAX_PACKAGE_BYTES = 256 * 1024
|
|
5
7
|
const MAX_ENTRY_POINTS = 100
|
|
@@ -33,19 +35,45 @@ export function packageEntryPoints(manifest: unknown, directory: string): string
|
|
|
33
35
|
return [...paths]
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
// The entries a stasis.config.json in `directory` lists, as repository paths:
|
|
39
|
+
// the ones a server build takes, which it builds with their options there.
|
|
40
|
+
export function configEntryPoints(text: string, directory: string): string[] {
|
|
41
|
+
const paths: string[] = []
|
|
42
|
+
for (const key of parseStasisConfig(text, directory) ?? []) {
|
|
43
|
+
const path = directory ? `${directory}/${key}` : key
|
|
44
|
+
if (buildableEntry(path) && paths.length < MAX_ENTRY_POINTS) paths.push(path)
|
|
45
|
+
}
|
|
46
|
+
return paths
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
// Use the immutable blob from the authorized listing, never download_url or a
|
|
37
|
-
// resolved symlink target.
|
|
50
|
+
// resolved symlink target. A malformed or oversized blob is none (null).
|
|
51
|
+
async function readBlobText(sha: string, read: (suffix: string) => Promise<unknown>, maxBytes: number): Promise<string | null> {
|
|
52
|
+
const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
|
|
53
|
+
if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
|
|
54
|
+
|| blob.size < 0 || blob.size > maxBytes || blob.content.length > maxBytes * 2) return null
|
|
55
|
+
const bytes = Buffer.from(blob.content, 'base64')
|
|
56
|
+
return bytes.length !== blob.size || bytes.length > maxBytes ? null : bytes.toString('utf8')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Manifests are optional: suggestions must not prevent browsing. The handler
|
|
60
|
+
// still rechecks GitHub and managed access after these optional upstream reads.
|
|
38
61
|
export async function readPackageEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
|
|
39
62
|
try {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
const text = await readBlobText(sha, read, MAX_PACKAGE_BYTES)
|
|
64
|
+
return text === null ? [] : packageEntryPoints(JSON.parse(text), directory)
|
|
65
|
+
} catch {
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A stasis.config.json whose entries Stasis can't tell lists none here;
|
|
71
|
+
// building them reports why (config-invalid), as it does options Stasis refuses.
|
|
72
|
+
export async function readConfigEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
|
|
73
|
+
try {
|
|
74
|
+
const text = await readBlobText(sha, read, MAX_STASIS_CONFIG_BYTES)
|
|
75
|
+
return text === null ? [] : configEntryPoints(text, directory)
|
|
46
76
|
} catch {
|
|
47
|
-
// Suggestions must not prevent browsing. The handler still rechecks
|
|
48
|
-
// GitHub and managed access after this optional upstream read.
|
|
49
77
|
return []
|
|
50
78
|
}
|
|
51
79
|
}
|
|
@@ -13,7 +13,7 @@ import { brotliDecompress } from 'node:zlib'
|
|
|
13
13
|
import { format } from '@preventive/compare/pretty.js'
|
|
14
14
|
import { computeFileHash } from '@preventive/report'
|
|
15
15
|
import { FILE_HASH, computeBundleFileHashes } from '../common/bundle-metadata.js'
|
|
16
|
-
import { bundleCompareFiles } from '../common/bundle-compare-inputs.js'
|
|
16
|
+
import { bundleCompareFiles, bundleCompareSideScopes } from '../common/bundle-compare-inputs.js'
|
|
17
17
|
import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
|
|
18
18
|
import { MAX_PRETTY_BYTES, needsPrettyPrint, prettyExtension } from '../common/pretty-print.js'
|
|
19
19
|
import type { OpenedBlob } from './blob-store.ts'
|
|
@@ -413,12 +413,14 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
|
|
|
413
413
|
// before this resolves: for the files that changed between them
|
|
414
414
|
// pretty-printed (compared), and their diffs with renamed names hidden
|
|
415
415
|
// (rename-diff.ts renamedPairs); a bundle's within a reason `scope`
|
|
416
|
-
// where one is asked for, as Compare reads it then.
|
|
417
|
-
bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
|
|
418
|
-
const
|
|
416
|
+
// where one is asked for, as Compare reads it then (bundleCompareSideScopes).
|
|
417
|
+
async bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
|
|
418
|
+
const [baseDetails, otherDetails] = await Promise.all([bundleDetails(base), bundleDetails(other)])
|
|
419
|
+
const [baseScope, otherScope] = bundleCompareSideScopes(baseDetails, otherDetails, scope)
|
|
420
|
+
const sideOf = async (record: ManagedBundle, details: typeof baseDetails, side: Side, within: string): Promise<ComparedSide> => ({ side, ...await bundleSide(details, within),
|
|
419
421
|
kept: request => kept(() => bundles.open(record.id, prettyFile(request))),
|
|
420
422
|
made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
|
|
421
|
-
return Promise.all([sideOf(base, 'base'), sideOf(other, 'other')])
|
|
423
|
+
return Promise.all([sideOf(base, baseDetails, 'base', baseScope), sideOf(other, otherDetails, 'other', otherScope)])
|
|
422
424
|
},
|
|
423
425
|
// A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
|
|
424
426
|
bundleDetails,
|
|
@@ -12,7 +12,7 @@ import { triageWireEntry } from './triage-response.ts'
|
|
|
12
12
|
import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
13
13
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
14
14
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
15
|
-
import { auditCache,
|
|
15
|
+
import { auditCache, auditedKeys } from './upstream-cache.ts'
|
|
16
16
|
import { serveTeamFeed } from './team-feed.ts'
|
|
17
17
|
import { type ComparedSide, PrettyError, compared, prettyRequest, prettyText } from './pretty-print.ts'
|
|
18
18
|
import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
|
|
@@ -207,7 +207,7 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
|
|
|
207
207
|
const repoAdvisories = url.searchParams.get('repoAdvisories') === 'true'
|
|
208
208
|
const result = await fetchBundleAdvisories(inventory.packages, signal, {
|
|
209
209
|
debug: deps.config.debug, repoAdvisories, details: url.searchParams.get('details') === 'true',
|
|
210
|
-
cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug,
|
|
210
|
+
cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedKeys(inventory.packages, repoAdvisories)),
|
|
211
211
|
})
|
|
212
212
|
await recheck()
|
|
213
213
|
json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
|
|
@@ -111,7 +111,8 @@ const isCommit = (text: string) => /^[\da-f]{40}$/u.test(text)
|
|
|
111
111
|
|
|
112
112
|
// `source`, what it answers of a public repository kept for every scan, the
|
|
113
113
|
// same whoever asks: of a commit while kept (but none, as one can be pushed
|
|
114
|
-
// later), the rest for RISK_KEPT_MS. A private one's,
|
|
114
|
+
// later), the rest, its advisories too, for RISK_KEPT_MS. A private one's,
|
|
115
|
+
// or one missing, never.
|
|
115
116
|
export function sharedSource(source: ActionsSource, answers: ActionAnswers): ActionsSource {
|
|
116
117
|
const keyOf = (what: string, name: string, ...more: string[]) => [what, name.toLowerCase(), ...more].join('\0')
|
|
117
118
|
const kept = (key: string, lasting = false) => {
|
|
@@ -140,6 +141,8 @@ export function sharedSource(source: ActionsSource, answers: ActionAnswers): Act
|
|
|
140
141
|
branches: name => shared(name, keyOf('branches', name), false, () => source.branches(name)),
|
|
141
142
|
contains: (name, base, sha) => shared(name, keyOf('contains', name, base, sha), isCommit(base), () => source.contains(name, base, sha)),
|
|
142
143
|
file: (name, path, commit) => shared(name, keyOf('file', name, path, commit), isCommit(commit), () => source.file(name, path, commit)),
|
|
144
|
+
...source.advisories && { advisories: (name: string, versions: string[], internal: boolean) =>
|
|
145
|
+
shared(name, keyOf('advisories', name, ...versions), false, () => source.advisories!(name, versions, internal)) },
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
148
|
|
|
@@ -153,8 +156,8 @@ export interface ActionsResolving {
|
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
// A reference's line: kept (`stored`, as read of the store with others),
|
|
156
|
-
// else resolved, and kept where it is a public repository's
|
|
157
|
-
// it couldn't be.
|
|
159
|
+
// else resolved, and kept where it is a public repository's and its
|
|
160
|
+
// advisories could be had; why not, where it couldn't be.
|
|
158
161
|
async function resolved(resolving: ActionsResolving, source: ActionsSource, { key, owner }: ActionRef,
|
|
159
162
|
stored = resolving.store?.read(key)): Promise<ActionLine | null> {
|
|
160
163
|
const kept = resolving.memory.get(key) ?? await stored?.then(row => row && { at: row.at, line: row.value })
|
|
@@ -164,7 +167,7 @@ async function resolved(resolving: ActionsResolving, source: ActionsSource, { ke
|
|
|
164
167
|
let found: ResolvedAction
|
|
165
168
|
try { found = await resolveAction(uses, source, { owner }) } catch (error) { return resolving.signal.aborted ? null : { type: 'action', key, ...failureOf(error) } }
|
|
166
169
|
const line: ActionLine = { type: 'action', ...found }
|
|
167
|
-
if (found.repository?.private === false) {
|
|
170
|
+
if (found.repository?.private === false && !found.findings.some(({ id }) => id === 'advisories-unavailable')) {
|
|
168
171
|
const at = Date.now()
|
|
169
172
|
resolving.memory.set(key, { at, line })
|
|
170
173
|
await resolving.store?.write(key, line, at)
|
|
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import type { ManagedConfig } from './config.ts'
|
|
3
3
|
import type { SelectedRepo } from './db.ts'
|
|
4
4
|
import { GithubApiError, type GithubIdentity, READ_PERMISSIONS, githubJson, githubRepoPermission, githubUserIdentity, onTokenRejected, repoAccessToken } from './github-app.ts'
|
|
5
|
-
import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
|
|
5
|
+
import { MAX_PACKAGE_BYTES, readConfigEntryPoints, readPackageEntryPoints } from './package-entry-points.ts'
|
|
6
|
+
import { MAX_STASIS_CONFIG_BYTES, STASIS_CONFIG } from './stasis-config.ts'
|
|
6
7
|
import { readSolidityEntryPoints } from './solidity-entry-points.ts'
|
|
7
8
|
import { parseGithubCommit } from './bundle-commits.ts'
|
|
8
9
|
import { appDependabotAlerts } from './dependabot-access.ts'
|
|
@@ -279,9 +280,15 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
|
|
|
279
280
|
const type = item.submodule_git_url ? 'submodule' : item.type
|
|
280
281
|
if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
|
|
281
282
|
}
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
&& typeof item.
|
|
283
|
+
const listed = (name: string, maxBytes: number) => data.find(item => item?.path === (path ? `${path}/${name}` : name) && item.name === name
|
|
284
|
+
&& item.type === 'file' && !item.submodule_git_url && typeof item.size === 'number' && item.size >= 0 && item.size <= maxBytes
|
|
285
|
+
&& typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
|
|
286
|
+
// The entries stasis.config.json lists are suggested before any guess:
|
|
287
|
+
// a build takes them alone, with their options there.
|
|
288
|
+
const config = listed(STASIS_CONFIG, MAX_STASIS_CONFIG_BYTES)
|
|
289
|
+
const configEntryPoints = config ? await readConfigEntryPoints(config.sha, path, read) : []
|
|
290
|
+
if (configEntryPoints.length > 0) return { entries, limited: data.length >= 1000, configEntryPoints }
|
|
291
|
+
const manifest = listed('package.json', MAX_PACKAGE_BYTES)
|
|
285
292
|
// Both optional suggestions, read together: neither fails, each
|
|
286
293
|
// answering none where its reads do.
|
|
287
294
|
const [packageEntryPoints, solidity] = await Promise.all([manifest ? readPackageEntryPoints(manifest.sha, path, read) : undefined,
|
|
@@ -242,8 +242,8 @@ async function repositoryHeads(owner: string, name: string, token: string, fetch
|
|
|
242
242
|
// Histories read in a GraphQL query, and queries at once: each counts its
|
|
243
243
|
// commits' lines, which GitHub works out as it answers, stopping a query
|
|
244
244
|
// past ten seconds.
|
|
245
|
-
const PAGES_PER_QUERY = 5
|
|
246
|
-
const PAGES_PARALLEL = 5
|
|
245
|
+
export const PAGES_PER_QUERY = 5
|
|
246
|
+
export const PAGES_PARALLEL = 5
|
|
247
247
|
|
|
248
248
|
// The first page of the history from each of `tops`, by its top, each told
|
|
249
249
|
// to `keep` as it is read, so those read stay kept where another fails: with
|
|
@@ -16,6 +16,7 @@ import { agentOperated, githubAgent } from '../common/managed/github-agents.ts'
|
|
|
16
16
|
import { GithubApiError, githubJson } from './github-app.ts'
|
|
17
17
|
import { shareGithubRead } from './github-requests.ts'
|
|
18
18
|
import { coauthorsOf } from './commit-coauthors.ts'
|
|
19
|
+
import { windowsOn } from './history-windows.ts'
|
|
19
20
|
import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accountOf, actorOf, githubGraphql, historyFrom } from './repository-history.ts'
|
|
20
21
|
|
|
21
22
|
export const PULSE_WEEKS = [1, 4, 13, 52]
|
|
@@ -222,6 +223,7 @@ export async function yearCommits(fullName: string, token: string, head: string,
|
|
|
222
223
|
const read = () => [...found.values()].filter(dated).length
|
|
223
224
|
const passed = () => [...found.values()].filter(commit => !dated(commit) && within(commit))
|
|
224
225
|
let queries = 0
|
|
226
|
+
let windowed = false
|
|
225
227
|
let starts = [head]
|
|
226
228
|
while (starts.length > 0) {
|
|
227
229
|
// Those not read yet, the head before any, else the parents of those
|
|
@@ -232,9 +234,16 @@ export async function yearCommits(fullName: string, token: string, head: string,
|
|
|
232
234
|
.filter(commit => commit.parents.some(sha => unread.has(sha))).map(commit => commit.committedAt ?? Number.POSITIVE_INFINITY))
|
|
233
235
|
if (need > since && from < need) return { complete: true, commits: [...found.values()].filter(dated), passed: passed(), from }
|
|
234
236
|
if (queries >= YEAR_QUERIES) return { complete: false, read: read(), tooMany: false }
|
|
235
|
-
|
|
237
|
+
// The pages kept read first; once past them, the rest read at once by
|
|
238
|
+
// when its commits were made (history-windows.ts windowsOn), as far back
|
|
239
|
+
// as it needs, the year's as far as its commits are followed.
|
|
240
|
+
const next: Awaited<ReturnType<typeof windowsOn>> | null = windowed || found.size === 0 ? null
|
|
241
|
+
: await windowsOn(fullName, token, head, starts, found, need > since ? need : since - YEAR_SKEW_MS, from, pages, fetchImpl, queries)
|
|
242
|
+
windowed ||= next?.windowed === true
|
|
243
|
+
queries += next?.queries ?? 0
|
|
244
|
+
const batch = (next?.batch ?? starts).slice(0, YEAR_STARTS)
|
|
236
245
|
for (const sha of batch) tried.add(sha)
|
|
237
|
-
const got = await historyFrom(fullName, token, batch, pages, fetchImpl)
|
|
246
|
+
const got = batch.length > 0 ? await historyFrom(fullName, token, batch, pages, fetchImpl) : { commits: [], read: 0 }
|
|
238
247
|
if (got.read > 0) queries++
|
|
239
248
|
for (const commit of got.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
|
|
240
249
|
told(read())
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
// A lockfile's advisories, as a repository risk scan asks them
|
|
2
2
|
// (repository-risk.ts): npm's registry's of its packages of the registry or
|
|
3
3
|
// a mirror, asked at once, as `npm audit` asks, each told with whether it is
|
|
4
|
-
// of malware (
|
|
5
|
-
// instance's memory and the source's `audits`, for RISK_KEPT_MS, as npm
|
|
4
|
+
// of malware, as upstream tells (`malware`); kept by the lockfile's blob, in
|
|
5
|
+
// the instance's memory and the source's `audits`, for RISK_KEPT_MS, as npm
|
|
6
6
|
// publishes advisories, or why they couldn't be had for AUDIT_FAILED_KEPT_MS.
|
|
7
7
|
import type { Advisory } from '@preventive/upstream/advisories.js'
|
|
8
8
|
import { isExactVersion } from '@preventive/upstream/semver.js'
|
|
9
|
-
import { npmMalwareAdvisory } from '../risk/index.js'
|
|
10
9
|
import { isNpmPackageName } from '../common/managed/npm-packages.js'
|
|
11
10
|
import type { LockedPackage } from './locked-packages.ts'
|
|
12
11
|
import type { Lru } from './lru.ts'
|
|
13
12
|
import type { KeptStore } from './npm-kept.ts'
|
|
14
13
|
import { RISK_KEPT_MS, type RiskFailure, failureOf } from './repository-risk-assess.ts'
|
|
15
14
|
|
|
16
|
-
export type AdvisoryRow = Advisory & { malware: boolean }
|
|
15
|
+
export type AdvisoryRow = Omit<Advisory, 'malware'> & { malware: boolean }
|
|
17
16
|
// A lockfile's advisories, or why they couldn't be had.
|
|
18
17
|
export type Audit = AdvisoryRow[] | RiskFailure
|
|
19
18
|
|
|
@@ -67,7 +66,7 @@ export async function auditLockfile(source: AuditSource, memory: Lru<{ at: numbe
|
|
|
67
66
|
let audit: Audit
|
|
68
67
|
try {
|
|
69
68
|
audit = (await source.advisories([...asked].map(([name, versions]) => ({ name, versions: [...versions] })), signal))
|
|
70
|
-
.map(advisory => ({ ...advisory, malware:
|
|
69
|
+
.map(advisory => ({ ...advisory, malware: advisory.malware === true }))
|
|
71
70
|
} catch (error) {
|
|
72
71
|
if (signal.aborted) return null
|
|
73
72
|
audit = failureOf(error)
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
// that locks them (LockedPackage `dev`) once all the rest are, as an install
|
|
19
19
|
// of no devDependencies leaves them out. A lockfile's packages of npm's
|
|
20
20
|
// registry or a mirror are asked of npm's advisories all at once, as `npm
|
|
21
|
-
// audit` asks, each advisory told with whether it is of malware (
|
|
22
|
-
//
|
|
21
|
+
// audit` asks, each advisory told with whether it is of malware (upstream's
|
|
22
|
+
// `malware`), which alone is a supply chain's risk; the rest are
|
|
23
23
|
// vulnerabilities, told beside. A repository's Dependabot alerts of npm's, open or dismissed,
|
|
24
24
|
// are told once its lockfiles' advisories are, each with the versions its
|
|
25
25
|
// lockfile locks that it covers, for the page to blend them into npm's; one
|
|
@@ -94,8 +94,8 @@ export interface RepositoryRiskSource<R extends RepositoryRiskRepo> extends Audi
|
|
|
94
94
|
|
|
95
95
|
// A Dependabot alert as a scan tells it: with the versions of its package
|
|
96
96
|
// the lockfile it is of locks that it covers, none where it is of none read
|
|
97
|
-
// (a package.json, bun's lockfile), and whether it is of malware,
|
|
98
|
-
// advisory's
|
|
97
|
+
// (a package.json, bun's lockfile), and whether it is of malware, by the rule
|
|
98
|
+
// upstream tells an advisory's by (risk's npmMalwareAdvisory).
|
|
99
99
|
export type DependabotAlertRow = DependabotAlert & { versions: string[]; malware: boolean }
|
|
100
100
|
|
|
101
101
|
// A risk line is of npm's package of its name and version, or, with an
|
package/server-managed/sql.ts
CHANGED
|
@@ -26,12 +26,13 @@ const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'g
|
|
|
26
26
|
'listTriageFixes', 'getRepoDependencyReviews', 'listTeamDependencyReviews', 'getNpmReviewReader',
|
|
27
27
|
'listDependencyReviewTeams', 'listDependencyReviewEnabledTeams', 'listUserNpmScopes', 'listTeamNpmScopes', 'hasTeamNpmScope',
|
|
28
28
|
'listGithubMetadata', 'listGithubRepositoryVisibility', 'setGithubMetadata', 'recordGithubMetadataAttempts', 'setGithubRepositoryVisibility',
|
|
29
|
-
'listLinkReports', 'listLegacyLinkReports', 'listReportFilenamesWithBundleHash', 'listReportsForRepo', 'listBundlesForRepo',
|
|
29
|
+
'listLinkReports', 'listLegacyLinkReports', 'listReportFilenamesWithBundleHash', 'listReportsForBundle', 'listReportsForRepo', 'listBundlesForRepo',
|
|
30
30
|
'listFindingCatalogReports', 'userCanReadBundleAdvisories',
|
|
31
31
|
'sessionWithUser', 'getTeamReportAccessSnapshot', 'getReportAccessSnapshot', 'getBundleAccessSnapshot', 'getReportView', 'getUserGithubId', 'getUserTeamFeedSnapshot', 'getAnnotations', 'getAnnotationRevision', 'getUserTokens',
|
|
32
32
|
'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
|
|
33
33
|
'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
|
|
34
34
|
'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'getFirstUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
|
|
35
|
+
'getTeamAppSummaries', 'setTeamAppSummary', 'deleteTeamAppSummaries',
|
|
35
36
|
'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits', 'getAvatarKey', 'listUsersByAccount'])
|
|
36
37
|
// Session presence and upload keys change only their own rows.
|
|
37
38
|
const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// stasis.config.json's `entries`, as Stasis reads them (src/cmd/bundle-options.js's
|
|
2
|
+
// readConfigEntries, which is not a public package export): the entries it
|
|
3
|
+
// names, each a path relative to the file's directory. Their options are
|
|
4
|
+
// Stasis's: buildGitHubBundle builds the entries the stasis.config.json in its
|
|
5
|
+
// directory names with their options there, and refuses what it can't build.
|
|
6
|
+
export const STASIS_CONFIG = 'stasis.config.json'
|
|
7
|
+
export const MAX_STASIS_CONFIG_BYTES = 256 * 1024
|
|
8
|
+
|
|
9
|
+
const plainObject = (value: unknown): value is Record<string, unknown> =>
|
|
10
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
|
|
12
|
+
// `entry` as a path from `directory` ('.' for it), as `stasis bundle` run
|
|
13
|
+
// there resolves it; null where that is outside it.
|
|
14
|
+
export function configEntryKey(directory: string, entry: string): string | null {
|
|
15
|
+
if (entry.startsWith('/')) return null
|
|
16
|
+
const base = directory ? directory.split('/') : []
|
|
17
|
+
const parts = [...base]
|
|
18
|
+
for (const part of entry.split('/')) {
|
|
19
|
+
if (part === '' || part === '.') continue
|
|
20
|
+
if (part !== '..') parts.push(part)
|
|
21
|
+
else if (parts.pop() === undefined) return null
|
|
22
|
+
}
|
|
23
|
+
if (base.some((part, i) => parts[i] !== part)) return null
|
|
24
|
+
return parts.slice(base.length).join('/') || '.'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The entries the stasis.config.json in `directory` whose text is `text`
|
|
28
|
+
// names, each as a path from there; null where it has no `entries`. One whose
|
|
29
|
+
// entries Stasis can't tell throws.
|
|
30
|
+
export function parseStasisConfig(text: string, directory = ''): Set<string> | null {
|
|
31
|
+
const file = directory ? `${directory}/${STASIS_CONFIG}` : STASIS_CONFIG
|
|
32
|
+
let config: unknown
|
|
33
|
+
try { config = JSON.parse(text) } catch (cause) { throw new Error(`${file} is not valid JSON`, { cause }) }
|
|
34
|
+
const entries = plainObject(config) ? config['entries'] : undefined
|
|
35
|
+
if (entries === undefined) return null
|
|
36
|
+
if (!plainObject(entries)) throw new Error(`${file}: entries must be an object of each entry's stasis bundle options`)
|
|
37
|
+
const named = new Map<string, string>()
|
|
38
|
+
for (const entry of Object.keys(entries)) {
|
|
39
|
+
const what = `${file}: entries ${JSON.stringify(entry)}`
|
|
40
|
+
const key = configEntryKey(directory, entry)
|
|
41
|
+
if (key === null) throw new Error(`${what}: an entry must be a path relative to the directory of ${STASIS_CONFIG}, within it`)
|
|
42
|
+
if (named.has(key)) throw new Error(`${what} names the same entry as ${JSON.stringify(named.get(key))}`)
|
|
43
|
+
named.set(key, entry)
|
|
44
|
+
}
|
|
45
|
+
return new Set(named.keys())
|
|
46
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { ManagedSql } from './sql.ts'
|
|
3
|
+
|
|
4
|
+
// Each team's catalog summary (team-app.ts), kept for every instance: working
|
|
5
|
+
// one out builds the team's whole workspace, which an instance would otherwise
|
|
6
|
+
// do for the first catalog it serves. Keyed by the catalog's own content key
|
|
7
|
+
// (team-app.ts teamAppKey), hashed: the published reports, their content and
|
|
8
|
+
// what the reader is allowed of each (its role, its dependency and security
|
|
9
|
+
// permissions, the team's scopes and links), so readers whose workspaces
|
|
10
|
+
// differ never share one. A change to any of them is a new key, as is a new
|
|
11
|
+
// deployment; one no longer asked for goes once it is old (index.ts reap).
|
|
12
|
+
export const TEAM_APP_SUMMARY_SCHEMA = `
|
|
13
|
+
CREATE TABLE IF NOT EXISTS managed_team_app_summary (
|
|
14
|
+
summary_key TEXT PRIMARY KEY,
|
|
15
|
+
summary TEXT NOT NULL,
|
|
16
|
+
stored_at INTEGER NOT NULL
|
|
17
|
+
) STRICT;
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
export interface TeamAppSummaryStore {
|
|
21
|
+
// Hashed key (teamAppSummaryKey) → summary for those of `keys` kept.
|
|
22
|
+
getTeamAppSummaries(keys: string[]): Promise<Map<string, string>>
|
|
23
|
+
setTeamAppSummary(key: string, summary: string, storedAt: number): Promise<void>
|
|
24
|
+
// Removes those stored before `before`; how many.
|
|
25
|
+
deleteTeamAppSummaries(before: number): Promise<number>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// How a summary is worked out (common/workspace-app.js, team-app.js), which a
|
|
29
|
+
// deployment may change: one stored by another deployment, or before this
|
|
30
|
+
// is raised, is worked out again.
|
|
31
|
+
const SUMMARY_VERSION = 1
|
|
32
|
+
const DEPLOYMENT = process.env['VERCEL_DEPLOYMENT_ID'] ?? null
|
|
33
|
+
|
|
34
|
+
export const teamAppSummaryKey = (key: string): string => createHash('sha256').update(JSON.stringify([SUMMARY_VERSION, DEPLOYMENT, key])).digest('base64url')
|
|
35
|
+
|
|
36
|
+
export function teamAppSummaryMethods(db: ManagedSql): TeamAppSummaryStore {
|
|
37
|
+
const getMany = db.prepare('SELECT summary_key, summary FROM managed_team_app_summary WHERE summary_key IN (SELECT value FROM json_each(?))')
|
|
38
|
+
const set = db.prepare(`INSERT INTO managed_team_app_summary (summary_key, summary, stored_at) VALUES (?, ?, ?)
|
|
39
|
+
ON CONFLICT (summary_key) DO UPDATE SET summary = excluded.summary, stored_at = excluded.stored_at`)
|
|
40
|
+
const remove = db.prepare('DELETE FROM managed_team_app_summary WHERE stored_at < ?')
|
|
41
|
+
return {
|
|
42
|
+
async getTeamAppSummaries(keys) {
|
|
43
|
+
if (keys.length === 0) return new Map()
|
|
44
|
+
const rows = await getMany.all(JSON.stringify(keys)) as { summary_key: string; summary: string }[]
|
|
45
|
+
return new Map(rows.map(row => [row.summary_key, row.summary]))
|
|
46
|
+
},
|
|
47
|
+
async setTeamAppSummary(key, summary, storedAt) {
|
|
48
|
+
await set.run(key, summary, storedAt)
|
|
49
|
+
},
|
|
50
|
+
async deleteTeamAppSummaries(before) {
|
|
51
|
+
return Number((await remove.run(before)).changes)
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|