@preventive/triage 1.0.0-alpha.42 → 1.0.0-alpha.44

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 (75) hide show
  1. package/common/bundle-compare-diff.js +2 -167
  2. package/common/bundle-compare-versions.js +172 -0
  3. package/common/bundle-comparison.d.ts +8 -0
  4. package/common/bundle-comparison.js +62 -0
  5. package/common/managed/report-filter.ts +2 -1
  6. package/common/managed/routes.js +3 -3
  7. package/common/security-links.d.ts +5 -0
  8. package/common/security-links.js +33 -0
  9. package/common/server-info.ts +8 -0
  10. package/common/workspace-app.js +14 -1
  11. package/out/chunk-2ABLKCOY.js +46 -0
  12. package/out/chunk-5LSXOLUS.js +1 -0
  13. package/out/chunk-5RCM2J63.js +1 -0
  14. package/out/chunk-7B6HB4D7.js +1 -0
  15. package/out/{chunk-4F6JSF57.js → chunk-AJ4GTATZ.js} +1 -1
  16. package/out/chunk-CH4UN6WK.js +225 -0
  17. package/out/chunk-EDKLBQQN.js +17 -0
  18. package/out/{chunk-UXNUAIIC.js → chunk-F47DXCEV.js} +1 -1
  19. package/out/chunk-FK3DZEKL.js +1 -0
  20. package/out/chunk-G6AJWENO.js +1 -0
  21. package/out/chunk-GZWM7UQM.js +2 -0
  22. package/out/chunk-JCIIBLKR.js +1 -0
  23. package/out/chunk-LPCTJYNA.js +7 -0
  24. package/out/{chunk-DGVQPIYB.js → chunk-O4RJY5TP.js} +1 -1
  25. package/out/chunk-OD4LPN3H.js +1 -0
  26. package/out/{chunk-D7AF2JXL.js → chunk-QWDWTBM7.js} +1 -1
  27. package/out/chunk-QYWKC557.js +1 -0
  28. package/out/chunk-R75XOKUP.js +1 -0
  29. package/out/{chunk-RSHMVYNS.js → chunk-SWTLADW5.js} +1 -1
  30. package/out/chunk-T7OKH4VW.js +1 -0
  31. package/out/{chunk-ZMQQCGF6.js → chunk-UTR2LVME.js} +1 -1
  32. package/out/chunk-ZESLQCTF.js +6 -0
  33. package/out/client-managed-import.js +1 -1
  34. package/out/client-managed.js +21 -21
  35. package/out/client-sync.js +1 -1
  36. package/out/graph.js +1 -1
  37. package/out/index.html +14 -10
  38. package/out/terminal.js +1 -1
  39. package/out/view.css +1 -1
  40. package/out/view.js +58 -58
  41. package/package.json +13 -3
  42. package/risk/index.d.ts +26 -0
  43. package/risk/index.js +1 -1
  44. package/risk/npm/assess.js +66 -15
  45. package/risk/npm/dependencies.js +22 -3
  46. package/risk/npm/new-dependency.js +42 -12
  47. package/risk/npm/registry.js +122 -21
  48. package/server-managed/comparison.ts +41 -0
  49. package/server-managed/http.ts +161 -56
  50. package/server-managed/locked-packages.ts +29 -6
  51. package/server-managed/npm-kept.ts +38 -14
  52. package/server-managed/npm-packages.ts +27 -13
  53. package/server-managed/npm-risk.ts +69 -11
  54. package/server-managed/pretty-print.ts +14 -9
  55. package/server-managed/public-workspace.ts +26 -8
  56. package/server-managed/queue.ts +23 -0
  57. package/server-managed/report-sources.ts +1 -1
  58. package/server-managed/repository-risk-assess.ts +105 -0
  59. package/server-managed/repository-risk-audit.ts +66 -0
  60. package/server-managed/repository-risk.ts +265 -209
  61. package/server-managed/scan-downloads.ts +87 -0
  62. package/server-managed/sql.ts +1 -1
  63. package/server-managed/team-app.ts +38 -14
  64. package/server-managed/team-reports.ts +6 -11
  65. package/server-managed/upstream-cache.ts +16 -0
  66. package/out/chunk-CLPKNNC7.js +0 -51
  67. package/out/chunk-Q7ZSIEHP.js +0 -1
  68. package/out/chunk-RFZQQXSG.js +0 -17
  69. package/out/chunk-VQJCXGIY.js +0 -7
  70. package/out/chunk-WOTMRDM3.js +0 -226
  71. package/out/chunk-WZDTZE64.js +0 -1
  72. package/out/chunk-YP2537WX.js +0 -1
  73. package/out/chunk-YQRVLRDH.js +0 -1
  74. package/out/chunk-YVZH5SHP.js +0 -1
  75. package/out/chunk-ZBBOBQ43.js +0 -1
@@ -3,7 +3,8 @@
3
3
  // server doesn't speak the e2e sync protocol yet). Every handler is async; the
4
4
  // boot's `track()` keeps in-flight requests drainable on shutdown.
5
5
  //
6
- // GET /api/config → { mode:'managed', managed:{ loginPath, cookieName } } (public)
6
+ // GET /api/config → { mode:'managed', managed:{ loginPath, cookieName }, session } (public;
7
+ // `session` is /api/auth/session's answer, null signed out, absent when it could not be read)
7
8
  // GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
8
9
  // GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
9
10
  // GET /api/auth/session → { user, csrfToken } | 401
@@ -89,7 +90,7 @@ import { mergeLinkGroups } from './link-reports.ts'
89
90
  import { backfillFindingIds, reportEntries } from '@preventive/report'
90
91
  import type { IncomingMessage, ServerResponse } from 'node:http'
91
92
  import { pipeline } from 'node:stream/promises'
92
- import { npmVersionStore, riskLineStore } from './npm-kept.ts'
93
+ import { npmVersionStore, riskLineStore, scanReadStore } from './npm-kept.ts'
93
94
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
94
95
  import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
95
96
  import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
@@ -120,7 +121,7 @@ import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedR
120
121
  import type { ReportSourcesCache } from './report-sources.ts'
121
122
  import { normalizeTeamPath } from './repo-path.ts'
122
123
  import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
123
- import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
124
+ import { CONFIG_PATH, type ManagedSessionInfo, type ServerInfo } from '../common/server-info.ts'
124
125
  import { bundleCommitHash } from '../common/bundle-commit.js'
125
126
  import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
126
127
  import { upstreamGithub } from './github-errors.ts'
@@ -128,7 +129,7 @@ import { createClient } from '@preventive/upstream/github.js'
128
129
  import type { ConnectedRepo } from './github-app.ts'
129
130
  import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
130
131
  import { RepositoryDiscovery } from './repository-discovery.ts'
131
- import { RISK_KEPT_MS, repositoryRiskLines } from './repository-risk.ts'
132
+ import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines } from './repository-risk.ts'
132
133
  import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
133
134
  import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
134
135
  import { clearCookie, endSession, readSession } from './session.ts'
@@ -146,23 +147,24 @@ import { attachmentDisposition, sendJson, sendLines, writeResponse } from './htt
146
147
  import { triageWireEntry } from './triage-response.ts'
147
148
  import { handlePublicWorkspace } from './public-workspace.ts'
148
149
  import { serveUserTeamFeed } from './team-feed.ts'
149
- import { teamAppKey, teamAppStates } from './team-app.ts'
150
+ import { teamAppKey, teamAppStates, withWorkspaceSummary } from './team-app.ts'
150
151
  import { hashToken, randomToken, safeEqual } from './crypto.ts'
151
152
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
152
153
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
153
154
  import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
154
- import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
155
+ import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
155
156
  import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
156
157
  import { computeBundleDiff } from '../common/bundle-compare-diff.js'
157
158
  import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
158
159
  import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
159
160
  import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
160
161
  import { npmProvenance } from './npm-provenance.ts'
161
- import { npmRisk, npmRiskRemoved } from './npm-risk.ts'
162
+ import { npmReadyScanDownload, npmReadyScanDownloads, npmRisk, npmRiskRemoved } from './npm-risk.ts'
162
163
  import { NpmRiskError } from '../risk/index.js'
163
164
  import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
164
165
  import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
165
166
  import { typeOnlyFiles } from './type-only.ts'
167
+ import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
166
168
  import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
167
169
 
168
170
  const SESSION_PATH = '/api/auth/session'
@@ -216,6 +218,7 @@ const NPM_PROVENANCE_PATH = '/api/npm/provenance'
216
218
  const NPM_PRETTY_PATH = '/api/npm/pretty'
217
219
  const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
218
220
  const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
221
+ const NPM_COMPARISON_PATH = '/api/npm/comparison'
219
222
  const NPM_RISK_PATH = '/api/npm/risk'
220
223
  // What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
221
224
  // having no such package or version, or a tarball unpacking past risk's
@@ -229,7 +232,7 @@ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
229
232
  sendJson(res, status, { error: err.code })
230
233
  }
231
234
  const NPM_PATHS = new Set([NPM_PACKAGE_PATH, NPM_VERSIONS_PATH, NPM_DOWNLOAD_PATH, NPM_STATS_PATH, NPM_ADVISORIES_PATH, NPM_TAGS_PATH, NPM_SOCKET_PATH, NPM_PROVENANCE_PATH, NPM_PRETTY_PATH,
232
- NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH])
235
+ NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH])
233
236
  // How long an npm response may go unread before its connection is dropped.
234
237
  const NPM_RESPONSE_IDLE_MS = 60_000
235
238
  const MAX_TEAM_NAME = 100
@@ -581,6 +584,24 @@ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps,
581
584
  return s
582
585
  }
583
586
 
587
+ // Who am I? While an admin views as another user, that user, plus the admin
588
+ // as `viewer`. A view that has ended (with the admin's session or role, or the
589
+ // viewed account) is cleared, returning to the own session. Null signed out.
590
+ async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: string | undefined): Promise<{ body: ManagedSessionInfo | null; headers: Record<string, string> }> {
591
+ const now = Date.now()
592
+ let s = await readSession(config, db, cookie, now)
593
+ const ended = s == null && viewToken(config, cookie) != null
594
+ if (ended) s = await readSession(config, db, cookie, now, { own: true })
595
+ return {
596
+ body: s && {
597
+ user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
598
+ csrfToken: s.session.csrfToken,
599
+ ...(s.session.viewer ? { viewer: s.session.viewer } : {}),
600
+ },
601
+ headers: ended ? { 'set-cookie': clearViewCookie(config) } : {},
602
+ }
603
+ }
604
+
584
605
  // GET /api/admin/scan/models — the server's canonical scan model ids and effort
585
606
  // levels. Names are intentionally absent; the client derives them from ids.
586
607
  async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
@@ -1351,6 +1372,10 @@ async function sendEncoded(req: IncomingMessage, res: ServerResponse, blob: Open
1351
1372
  // go idle, unread or with no line to send, before the scan is stopped.
1352
1373
  const REPO_RISK_RECHECK_MS = 10_000
1353
1374
  const REPO_RISK_IDLE_MS = 5 * 60_000
1375
+ // A scan's part on a serverless deployment, within its function's 300s
1376
+ // (vercel.managed.json): past it the scan stops, its last line `continue`,
1377
+ // and the page asks again, from where it stopped (repository-risk.ts).
1378
+ const REPO_RISK_PART_MS = 270_000
1354
1379
 
1355
1380
  // The repositories a scan of risk reads, by its query: every connected one;
1356
1381
  // a team's (`team`, its id), each in the team's directories of it; or one
@@ -1379,6 +1404,14 @@ async function riskRepositories(db: ManagedDb, query: URLSearchParams): Promise<
1379
1404
  })
1380
1405
  }
1381
1406
 
1407
+ // Where a scan in parts keeps its progress (repository-risk.ts
1408
+ // ScanProgress) under `key`, for a day; none where the page gave no scan id.
1409
+ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
1410
+ if (scan === null || !/^[\w-]{1,64}$/u.test(scan)) return undefined
1411
+ const kept = scanReadStore<ScanProgress>(deps.db, 'progress', SCAN_READ_KEPT_MS, deps.config.debug)
1412
+ return { read: () => kept.read(key), write: (progress: ScanProgress) => kept.write(key, progress, Date.now()) }
1413
+ }
1414
+
1382
1415
  // GET /api/admin/repositories/risk: every connected repository's npm
1383
1416
  // packages, or a team's or one repository's (riskRepositories), as its
1384
1417
  // lockfiles lock them at its default branch's head, each assessed by risk
@@ -1404,8 +1437,19 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1404
1437
  res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
1405
1438
  const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
1406
1439
  const scopes = new Map<string, Promise<boolean>>()
1440
+ const teamScoped = (name: string) => {
1441
+ const scope = npmPackageScope(name)
1442
+ if (scope === null) return Promise.resolve(false)
1443
+ if (!scopes.has(scope)) scopes.set(scope, deps.db.hasTeamNpmScope(scope))
1444
+ return scopes.get(scope)!
1445
+ }
1407
1446
  const versions = npmVersionStore(deps.db, deps.config.debug)
1447
+ const stores = { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug }
1408
1448
  const lines = repositoryRiskLines({
1449
+ // Which repositories a scan in parts told whole, by the admin, the id the
1450
+ // page gives the scan (`scan`) and its scope, for the next part to go on
1451
+ // past them.
1452
+ progress: scanProgress(deps, `${s.user.id}\0${query.get('scan')}\0${query.get('team') ?? ''}\0${query.get('repo') ?? ''}`, query.get('scan')),
1409
1453
  repos: () => Promise.resolve(repos),
1410
1454
  async open(repo) {
1411
1455
  const reader = await browser.reader(repo)
@@ -1418,21 +1462,32 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1418
1462
  recheck: async () => { await reader.recheckAccess() },
1419
1463
  }
1420
1464
  },
1421
- teamScoped(name) {
1422
- const scope = npmPackageScope(name)
1423
- if (scope === null) return Promise.resolve(false)
1424
- if (!scopes.has(scope)) scopes.set(scope, deps.db.hasTeamNpmScope(scope))
1425
- return scopes.get(scope)!
1465
+ teamScoped,
1466
+ // Each package's downloads readied before it is assessed
1467
+ // (scan-downloads.ts), so that none waits on npm's downloads API; a
1468
+ // team's own not asked, as risk asks none of theirs.
1469
+ downloads: {
1470
+ async atOnce(names, ready, signal) {
1471
+ const asked: string[] = []
1472
+ for (const name of names) if (await teamScoped(name)) ready(name); else asked.push(name)
1473
+ await npmReadyScanDownloads(asked, ready, stores, signal)
1474
+ },
1475
+ one: (name, signal) => npmReadyScanDownload(name, stores, signal),
1426
1476
  },
1427
1477
  // Public versions' documents kept in the database (npm-kept.ts), as the
1428
1478
  // viewer keeps them: whether npm still has one, its whole document tells
1429
1479
  // risk.
1430
1480
  version: async (name, version, signal) => found(await readNpmVersion(name, version, true, signal, versions)),
1431
- assess: (doc, teamScoped, signal) => npmRisk(doc, 'pick', { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
1432
- teamScoped, privileged: true, signal, spare: true }),
1433
- removed: (name, version, teamScoped, signal) => npmRiskRemoved(name, version, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
1434
- teamScoped, privileged: true, signal }),
1435
- risks: riskLineStore(deps.db, RISK_KEPT_MS, deps.config.debug),
1481
+ assess: (doc, ownScope, signal) => npmRisk(doc, 'pick', { ...stores, teamScoped: ownScope, privileged: true, signal, spare: true }),
1482
+ removed: (name, version, ownScope, signal) => npmRiskRemoved(name, version, { ...stores, teamScoped: ownScope, privileged: true, signal }),
1483
+ // Kept for good, a scan telling one past RISK_KEPT_MS at once, as stale.
1484
+ risks: riskLineStore(deps.db, Infinity, deps.config.debug),
1485
+ // What it read of GitHub kept a day, for a scan answered in parts to go
1486
+ // on from where the last stopped, on whichever instance answers the next.
1487
+ listings: scanReadStore(deps.db, 'listings', SCAN_READ_KEPT_MS, deps.config.debug),
1488
+ lockfiles: scanReadStore(deps.db, 'lockfiles', SCAN_READ_KEPT_MS, deps.config.debug),
1489
+ // And each lockfile's advisories, for as long as a scan tells them.
1490
+ audits: scanReadStore(deps.db, 'advisories', RISK_KEPT_MS, deps.config.debug),
1436
1491
  // As a bundle's advisories are asked (bundle-advisories.ts), npm's registry
1437
1492
  // and, for packages anyone may read, OSV: no repository's. Each lockfile's
1438
1493
  // audit is bounded on its own, for one npm doesn't answer to fail alone and
@@ -1444,7 +1499,7 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1444
1499
  if (audited.status !== 200) throw new NpmPackageError(502, audited.body.error)
1445
1500
  return audited.body
1446
1501
  },
1447
- }, controller.signal)
1502
+ }, controller.signal, undefined, { partMs: deps.config.serverless ? REPO_RISK_PART_MS : null })
1448
1503
  let checked = Date.now()
1449
1504
  await sendLines(res, lines, async () => {
1450
1505
  if (deps.isShuttingDown()) return false
@@ -1481,18 +1536,25 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
1481
1536
  await sendEncoded(req, res, cached, contentType, headers)
1482
1537
  }
1483
1538
 
1539
+ // Two bundles a reader asks about together, where they may read both, else
1540
+ // null, told as 401 or 404: asked before what is worked out of them and
1541
+ // again before it is sent.
1542
+ function bothBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, otherId: string) {
1543
+ return async () => {
1544
+ const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
1545
+ if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
1546
+ if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
1547
+ return [base.bundle, other.bundle] as const
1548
+ }
1549
+ }
1550
+
1484
1551
  // GET /api/bundles/:id/rename-diff: one of its JavaScript files diffed with
1485
1552
  // one of the `other` bundle's, both pretty-printed, renamed names hidden
1486
1553
  // (rename-diff.ts), for a reader of both, checked again once it is worked out.
1487
1554
  async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
1488
1555
  const otherId = params.get('other') ?? '', request = renameDiffRequest(params)
1489
1556
  if (!request || !/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-file' }); return }
1490
- const readable = async () => {
1491
- const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
1492
- if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
1493
- if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
1494
- return [base.bundle, other.bundle] as const
1495
- }
1557
+ const readable = bothBundles(res, deps, session, id, otherId)
1496
1558
  const records = await readable()
1497
1559
  if (!records) return
1498
1560
  const { prettyCache } = deps
@@ -1508,6 +1570,25 @@ async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse,
1508
1570
  else diff.stream.destroy()
1509
1571
  }
1510
1572
 
1573
+ // GET /api/bundles/:id/comparison?other=: its files paired with the `other`
1574
+ // bundle's as Compare pairs them, with what its Overview reads beside them
1575
+ // (comparison.ts), within a reason `scope` where given; for a reader of
1576
+ // both, checked again once it is worked out.
1577
+ async function handleBundleComparison(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
1578
+ const otherId = params.get('other') ?? ''
1579
+ if (!/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-bundle' }); return }
1580
+ const readable = bothBundles(res, deps, session, id, otherId)
1581
+ const records = await readable()
1582
+ if (!records) return
1583
+ const { prettyCache } = deps
1584
+ if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1585
+ let body
1586
+ try { body = await comparisonBody(await prettyCache.bundleDetails(records[0]), await prettyCache.bundleDetails(records[1]), params.get('scope') ?? '') }
1587
+ catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
1588
+ if (await readable()) await sendEncoded(req, res, body, 'application/json')
1589
+ else body.stream.destroy()
1590
+ }
1591
+
1511
1592
  // GET /api/bundles/:id/pretty?other=: the files that changed between it and
1512
1593
  // the `other` bundle, both sides' pretty-printed (pretty-print.ts compared), a
1513
1594
  // line each as it is found kept or formatted; GET
@@ -1864,7 +1945,7 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
1864
1945
  // The revision sent is the reloaded catalog's, so classify what changed now.
1865
1946
  const apps = await teamAppStates(deps.db, deps.reportStore, session.id, teams, classified)
1866
1947
  sendJson(res, 200, {
1867
- teams: teams.map(team => ({ ...team, app: apps.get(teamAppKey(team)) ?? null,
1948
+ teams: teams.map(team => ({ ...withWorkspaceSummary(team, apps.get(teamAppKey(team))),
1868
1949
  bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
1869
1950
  commitInfo: commits.commitInfo(bundle) })) })),
1870
1951
  revision,
@@ -2614,12 +2695,15 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
2614
2695
  sendJson(res, 200, { ok: true, scopes: (await deps.db.listTeamNpmScopes())[teamId] ?? [] })
2615
2696
  }
2616
2697
 
2617
- // Who reads npm packages now: a session with workspace access, its role and
2618
- // the npm scopes of its visible teams. Null for none.
2619
- async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined): Promise<NpmReader | null> {
2698
+ // Who reads npm packages now: a session with workspace access, its role and,
2699
+ // where they count for any of `names` it is asked about (npmScopesCount), the
2700
+ // npm scopes of its visible teams, else none: none are read for an unscoped
2701
+ // package, an admin or a manager, or an answer checked again that is public.
2702
+ // Null for none.
2703
+ async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
2620
2704
  const s = await readSession(deps.config, deps.db, cookie, Date.now())
2621
2705
  if (s == null || !roleAtLeast(s.user.role, 'view')) return null
2622
- return { role: s.user.role, scopes: new Set(await deps.db.listUserNpmScopes(s.user.id)), userId: s.user.id }
2706
+ return { role: s.user.role, scopes: new Set(npmScopesCount(s.user.role, names) ? await deps.db.listUserNpmScopes(s.user.id) : []), userId: s.user.id }
2623
2707
  }
2624
2708
 
2625
2709
  // What a registry read found, or a 404 for the package (handleNpm's catch
@@ -2636,7 +2720,7 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
2636
2720
  const onClose = () => { if (!res.writableEnded) controller.abort() }
2637
2721
  res.on('close', onClose)
2638
2722
  try {
2639
- const reader = await npmReader(deps, cookie)
2723
+ const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
2640
2724
  if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2641
2725
  const documents = new Map<string, Promise<NpmVersionDocument | null>>()
2642
2726
  // Public versions' documents as the viewer keeps them (npm-kept.ts), a
@@ -2650,7 +2734,7 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
2650
2734
  const packages = await Promise.all(reviews.map(review => Promise.all([
2651
2735
  read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
2652
2736
  ])))
2653
- const current = await npmReader(deps, cookie)
2737
+ const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
2654
2738
  if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2655
2739
  return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
2656
2740
  } catch (error) {
@@ -2760,7 +2844,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2760
2844
  const onClose = () => { if (!res.writableEnded) controller.abort() }
2761
2845
  res.on('close', onClose)
2762
2846
  try {
2763
- const reader = await npmReader(deps, cookie)
2847
+ const reader = await npmReader(deps, cookie, [context.package, ...context.basePackage ? [context.basePackage] : []])
2764
2848
  if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2765
2849
  const kept = npmVersionStore(deps.db, deps.config.debug)
2766
2850
  const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
@@ -2791,7 +2875,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2791
2875
  }
2792
2876
  const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
2793
2877
  ? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
2794
- const current = await npmReader(deps, cookie)
2878
+ const current = await npmReader(deps, cookie, [doc, base].flatMap(item => item?.private ? [item.name] : []))
2795
2879
  if (!current || current.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2796
2880
  if ([doc, base].some(item => item?.private && !canReadPrivateNpm(current, item.name))) { sendJson(res, 404, { error: 'package-not-found' }); return }
2797
2881
  if (!change) {
@@ -2817,7 +2901,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2817
2901
  } finally { res.off('close', onClose) }
2818
2902
  }
2819
2903
 
2820
- // GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only}. Only readers with private access
2904
+ // GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
2821
2905
  // (canReadPrivateNpm) can be answered from the server's npm token; the rest get
2822
2906
  // what the registry answers anonymously, every time (see npm-packages.ts).
2823
2907
  // Access is checked again after the registry's answer.
@@ -2827,14 +2911,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2827
2911
  // All but the package's own (its versions, figures and advisories) are a version's.
2828
2912
  const versioned = path !== NPM_VERSIONS_PATH && path !== NPM_STATS_PATH && path !== NPM_ADVISORIES_PATH
2829
2913
  const otherSpec = params.get('otherVersion') ?? ''
2830
- if (!isNpmPackageName(name) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
2914
+ // The package a comparison's other side is a version of, another where Review compares a dependency replaced.
2915
+ const otherName = path === NPM_COMPARISON_PATH ? params.get('otherName') ?? name : name
2916
+ if (!isNpmPackageName(name) || !isNpmPackageName(otherName) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
2831
2917
  || (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
2832
- || (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
2918
+ || (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
2919
+ || (path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
2833
2920
  sendJson(res, 400, { error: 'bad-package' }); return
2834
2921
  }
2835
2922
  const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
2836
2923
  if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
2837
- const reader = await npmReader(deps, cookie)
2924
+ const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
2838
2925
  if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2839
2926
  const controller = new AbortController()
2840
2927
  const onClose = () => { if (!res.writableEnded) controller.abort() }
@@ -2842,7 +2929,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2842
2929
  // A reader who lost their session, or their private access to an answer
2843
2930
  // that needed it, while the registry was asked gets nothing.
2844
2931
  const recheck = async (isPrivate: boolean) => {
2845
- const current = await npmReader(deps, cookie)
2932
+ const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
2846
2933
  if (current == null) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
2847
2934
  if (isPrivate && !canReadPrivateNpm(current, name)) { sendJson(res, 404, { error: 'package-not-found' }); return false }
2848
2935
  return !res.destroyed
@@ -2933,6 +3020,28 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2933
3020
  return
2934
3021
  }
2935
3022
  if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
3023
+ // Its files paired with those of `otherVersion` (of `otherName`, else the
3024
+ // same package), as Compare and Review pair them, with what their
3025
+ // Overview reads beside them (comparison.ts): the version shown against
3026
+ // the one compared with, or `(empty)`, none, as Review reads a first
3027
+ // version. Read of both, each package's access checked again once worked out.
3028
+ if (path === NPM_COMPARISON_PATH) {
3029
+ const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(otherName, otherSpec, canReadPrivateNpm(reader, otherName), controller.signal, keptVersions))
3030
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
3031
+ const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
3032
+ let body
3033
+ try {
3034
+ const [files, oldFiles] = await Promise.all(loads.map(load => load.result))
3035
+ body = await comparisonBody(old ? npmComparedSide(old.dist.integrity, oldFiles!) : emptySide(), npmComparedSide(doc.dist.integrity, files!))
3036
+ } finally { for (const load of loads) load.release() }
3037
+ const current = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
3038
+ if (current == null) { body.stream.destroy(); sendJson(res, 401, { error: 'unauthenticated' }); return }
3039
+ if ((doc.private && !canReadPrivateNpm(current, name)) || (old?.private && !canReadPrivateNpm(current, otherName))) {
3040
+ body.stream.destroy(); sendJson(res, 404, { error: 'package-not-found' }); return
3041
+ }
3042
+ if (!res.destroyed) await sendEncoded(req, res, body, 'application/json')
3043
+ return
3044
+ }
2936
3045
  // Every one of its files that needs it pretty-printed (pretty-print.ts
2937
3046
  // npmAll), or, against another version, the files that changed between
2938
3047
  // them, both sides' (compared), a line each as it is formatted, or found
@@ -2963,7 +3072,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2963
3072
  if (!(await recheck(isPrivate))) return
2964
3073
  res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
2965
3074
  await sendLines(res, lines, async () => {
2966
- const current = await npmReader(deps, cookie)
3075
+ const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
2967
3076
  return current != null && (!isPrivate || canReadPrivateNpm(current, name))
2968
3077
  })
2969
3078
  return
@@ -3073,13 +3182,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3073
3182
  }
3074
3183
 
3075
3184
  // Public mode probe — lets a client detect the managed protocol up front.
3185
+ // It also sends the session, sparing the client's GET /api/auth/session;
3186
+ // a failed read leaves that request to the client, never failing the probe.
3076
3187
  if (path === CONFIG_PATH) {
3077
3188
  if (method !== 'GET') { send405(res, 'GET'); return }
3078
3189
  const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
3190
+ const current = await currentSession(config, db, cookie).catch((err) => { console.warn('managed: config session read failed:', err); return null })
3079
3191
  sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
3080
3192
  managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
3081
3193
  uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
3082
- } : {}) } })
3194
+ } : {}) }, ...(current ? { session: current.body } : {}) }, current?.headers)
3083
3195
  return
3084
3196
  }
3085
3197
  if (viewing && underPrefix(path, VIEW_PREFIXES) && !viewAllows(method, path)) { sendViewOnly(res); return }
@@ -3122,22 +3234,11 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3122
3234
  res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
3123
3235
  res.end(); return
3124
3236
  }
3125
- // Who am I? While an admin views as another user, that user, plus the
3126
- // admin as `viewer`. A view that has ended (with the admin's session or
3127
- // role, or the viewed account) is cleared, returning to the own session.
3128
3237
  if (path === SESSION_PATH) {
3129
3238
  if (method !== 'GET') { send405(res, 'GET'); return }
3130
- const now = Date.now()
3131
- let s = await readSession(config, db, cookie, now)
3132
- const ended = s == null && viewing
3133
- if (ended) s = await readSession(config, db, cookie, now, { own: true })
3134
- const headers: Record<string, string> = ended ? { 'set-cookie': clearViewCookie(config) } : {}
3135
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
3136
- sendJson(res, 200, {
3137
- user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
3138
- csrfToken: s.session.csrfToken,
3139
- ...(s.session.viewer ? { viewer: s.session.viewer } : {}),
3140
- }, headers)
3239
+ const { body, headers } = await currentSession(config, db, cookie)
3240
+ if (body == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
3241
+ sendJson(res, 200, body, headers)
3141
3242
  return
3142
3243
  }
3143
3244
  if (path === VIEW_AS_PATH) { await handleViewAs(req, res, deps, cookie); return }
@@ -3265,9 +3366,13 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3265
3366
  await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
3266
3367
  return
3267
3368
  }
3268
- const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only)$/iu.exec(path)
3369
+ const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only|comparison)$/iu.exec(path)
3269
3370
  if (bundleRead) {
3270
3371
  const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
3372
+ if (part === 'comparison') {
3373
+ if (method !== 'GET') { send405(res, 'GET'); return }
3374
+ await handleBundleComparison(req, res, deps, session, id, url.searchParams); return
3375
+ }
3271
3376
  if (part === 'rename-diff' || part === 'type-only' || (part === 'pretty' && url.searchParams.has('other'))) {
3272
3377
  if (method !== 'GET') { send405(res, 'GET'); return }
3273
3378
  if (part === 'rename-diff' && url.searchParams.has('path')) { await handleBundleRenameDiff(req, res, deps, session, id, url.searchParams); return }
@@ -1,7 +1,8 @@
1
1
  // The packages a lockfile of npm's ecosystem locks, as @preventive/lockfile
2
2
  // reads package-lock.json and npm-shrinkwrap.json of `lockfileVersion: 3`,
3
3
  // pnpm-lock.yaml of pnpm 9 to 12 and Yarn 1's yarn.lock, each with where it
4
- // is installed from (repository-risk.ts).
4
+ // is installed from and whether only devDependencies need it
5
+ // (repository-risk.ts).
5
6
  import { LockfileError, parseNpmLockfile } from '@preventive/lockfile/npm.js'
6
7
  import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
7
8
  import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
@@ -21,6 +22,11 @@ export interface LockedPackage {
21
22
  // A mirror's tarball's, as the lockfile has it: npm's package of its name
22
23
  // and version only where npm's integrity is among it.
23
24
  integrity?: string
25
+ // Where only the importers' devDependencies reach it, as npm's `dev` flag
26
+ // says, and an install of no devDependencies leaves it out: in npm's and
27
+ // pnpm's lockfiles. Yarn 1's says nothing of it, which only the
28
+ // package.json files do, and none of its are.
29
+ dev?: true
24
30
  }
25
31
 
26
32
  // A package as each reader resolves one, its name the one it is published
@@ -29,7 +35,7 @@ type Resolution =
29
35
  | { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; gitHosted?: boolean }
30
36
  | { type: 'git'; repo: string }
31
37
  | { type: 'directory' }
32
- interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined }
38
+ interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined }
33
39
 
34
40
  // The packages of a lockfile of `format`, as its reader resolves them.
35
41
  // Versions go unchecked against ranges, as only what is locked is asked for.
@@ -72,14 +78,31 @@ function locked({ name, version, resolution }: ReadPackage): LockedPackage | nul
72
78
  }
73
79
 
74
80
  // The packages a lockfile of `format` (npm, pnpm or yarn, as
75
- // findRepoLockfiles names them) locks, each once, in the lockfile's order.
76
- // Throws the reader's LockfileError or YamlError for one it refuses.
81
+ // findRepoLockfiles names them) locks, each once, in the lockfile's order:
82
+ // `dev` only where it is wherever it is installed, at each path of npm's
83
+ // tree, and with each of pnpm's peers. Throws the reader's LockfileError or
84
+ // YamlError for one it refuses.
77
85
  export function lockedPackages(format: LockfileFormat, text: string): LockedPackage[] {
78
86
  const found = new Map<string, LockedPackage>()
79
87
  for (const pkg of packagesOf(format, text)) {
80
88
  const one = locked(pkg)
81
- const key = one && `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}`
82
- if (one && key && !found.has(key)) found.set(key, one)
89
+ if (!one) continue
90
+ if (pkg.dev === true) one.dev = true
91
+ const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}`
92
+ const had = found.get(key)
93
+ if (!had) found.set(key, one)
94
+ else if (had.dev && !one.dev) delete had.dev
83
95
  }
84
96
  return [...found.values()]
85
97
  }
98
+
99
+ // Whether a lockfile at `path` is a test's fixture or example: under a
100
+ // directory of tests (`test`, `tests`, `__tests__` and such), in one of
101
+ // fixtures or examples (`fixture`, `fixtures`, `example`, `examples`) inside
102
+ // it, as a test may keep one its readers refuse on purpose.
103
+ const FIXTURES_DIR = /^(?:fixture|example)s?$/u, TESTS_DIR = /^_*tests?_*$/u
104
+ export function isTestFixture(path: string) {
105
+ const dirs = path.split('/').slice(0, -1)
106
+ const tests = dirs.findIndex(dir => TESTS_DIR.test(dir))
107
+ return tests !== -1 && dirs.slice(tests + 1).some(dir => FIXTURES_DIR.test(dir))
108
+ }
@@ -5,9 +5,10 @@
5
5
  // (npm-manifest.ts npmVersionFields), for a day (readNpmVersion asks npm again
6
6
  // then, as a version can be deprecated, its package made private, or itself
7
7
  // removed); and, for scans, each package's risk line, for as long as a scan
8
- // keeps it (repository-risk.ts). Each store removes its own rows kept longer
9
- // than that as it writes, at most once an hour a database, so that rows no
10
- // read will take again don't pile up.
8
+ // keeps it, and what a scan read of GitHub, for a day, and of npm's
9
+ // advisories, for an hour (repository-risk.ts). Each store removes its own rows kept longer
10
+ // than that as it writes, at most once an hour a database (pruneKept), so
11
+ // that rows no read will take again don't pile up.
11
12
  // The database is an optimization: a failure reads as a miss, and writes
12
13
  // nothing.
13
14
  import { createHash } from 'node:crypto'
@@ -18,30 +19,45 @@ import type { UpstreamCacheStore } from './upstream-cache.ts'
18
19
  // A value kept by key, as JSON, `at` when it was.
19
20
  export interface KeptStore<T> {
20
21
  read(key: string): Promise<{ at: number; value: T } | null>
22
+ // Those of `keys` kept, by key, in one read of the database.
23
+ readMany(keys: string[]): Promise<Map<string, { at: number; value: T }>>
21
24
  write(key: string, value: T, at: number): Promise<void>
22
25
  }
23
26
 
24
- // When each database last had each type's old rows removed.
27
+ // When each database last had the rows under each prefix removed.
25
28
  const PRUNE_EVERY_MS = 60 * 60_000
26
29
  const pruned = new WeakMap<UpstreamCacheStore, Map<string, number>>()
27
30
 
31
+ // Removes the rows under `prefix` kept `keptMs` before `at`, at most once an
32
+ // hour a database, as rows under it are written: here, and risk's downloads
33
+ // (npm-risk.ts). A failure is `warn`ed of, and removes nothing.
34
+ export async function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void) {
35
+ const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
36
+ if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return
37
+ times.set(prefix, at)
38
+ try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) }
39
+ }
40
+
28
41
  // Values of `type`, each read for `keptMs` after it was kept.
29
42
  function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debug: boolean): KeptStore<T> {
30
43
  const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: kept ${type} ${what} failed:`, error) }
31
- const prune = async (at: number) => {
32
- const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
33
- if (at - (times.get(type) ?? -Infinity) < PRUNE_EVERY_MS) return
34
- times.set(type, at)
35
- try { await db.deleteUpstreamCacheEntries(`${type}/`, at - keptMs) } catch (error) { warn('prune', error) }
36
- }
44
+ // Kept for good (Infinity): none removed.
45
+ const prune = (at: number) => keptMs === Infinity ? Promise.resolve() : pruneKept(db, `${type}/`, keptMs, at, error => { warn('prune', error) })
37
46
  // Keys of any length or character, as the database's: hashed.
38
47
  const keyOf = (key: string) => `${type}/${createHash('sha256').update(key).digest('hex')}`
48
+ const valueOf = (row: string | null | undefined) => {
49
+ const kept: unknown = JSON.parse(row ?? 'null')
50
+ return plainObject(kept) && typeof kept['at'] === 'number' && 'value' in kept ? { at: kept['at'], value: kept['value'] as T } : null
51
+ }
39
52
  return {
40
53
  async read(key) {
54
+ try { return valueOf(await db.getUpstreamCacheEntry(keyOf(key))) } catch (error) { warn('read', error); return null }
55
+ },
56
+ async readMany(keys) {
41
57
  try {
42
- const kept: unknown = JSON.parse(await db.getUpstreamCacheEntry(keyOf(key)) ?? 'null')
43
- return plainObject(kept) && typeof kept['at'] === 'number' && 'value' in kept ? { at: kept['at'], value: kept['value'] as T } : null
44
- } catch (error) { warn('read', error); return null }
58
+ const rows = await db.getUpstreamCacheEntries(keys.map(keyOf))
59
+ return new Map(keys.flatMap(key => { const kept = valueOf(rows.get(keyOf(key))); return kept ? [[key, kept] as const] : [] }))
60
+ } catch (error) { warn('read', error); return new Map() }
45
61
  },
46
62
  async write(key, value, at) {
47
63
  try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
@@ -61,5 +77,13 @@ export function npmVersionStore(db: UpstreamCacheStore, debug = false): NpmVersi
61
77
  }
62
78
  }
63
79
 
64
- // Risk lines, each read for `keptMs`, as long as a scan keeps them.
80
+ // Risk lines, each kept `keptMs`, for good where Infinity, a scan telling one
81
+ // past RISK_KEPT_MS as stale (repository-risk.ts).
65
82
  export const riskLineStore = <T>(db: UpstreamCacheStore, keptMs: number, debug = false): KeptStore<T> => keptStore<T>(db, 'repository-risk/lines', keptMs, debug)
83
+ // What a scan read, each read for keptMs (repository-risk.ts): of GitHub, a
84
+ // repository's lockfiles as listed at a commit (`listings`) and a lockfile's
85
+ // packages by its blob (`lockfiles`), for SCAN_READ_KEPT_MS; of npm, a
86
+ // lockfile's advisories by its blob (`advisories`), for RISK_KEPT_MS; and the
87
+ // repositories a scan in parts told whole (`progress`).
88
+ export const scanReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles' | 'advisories' | 'progress', keptMs: number, debug = false): KeptStore<T> =>
89
+ keptStore<T>(db, `repository-risk/${type}`, keptMs, debug)