@preventive/triage 1.0.0-alpha.46 → 1.0.0-alpha.47

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 (71) hide show
  1. package/common/managed/agent-tasks.ts +5 -3
  2. package/common/managed/github-agents.ts +28 -10
  3. package/out/activity.js +4 -0
  4. package/out/chunk-5AHIQFFG.js +36 -0
  5. package/out/chunk-6ANSHRFQ.js +6 -0
  6. package/out/chunk-6WF3KNTL.js +3 -0
  7. package/out/chunk-7BKKYVWR.js +7 -0
  8. package/out/{chunk-7XRR4OFU.js → chunk-FH24VHSJ.js} +1 -1
  9. package/out/chunk-NOZJULWS.js +52 -0
  10. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  11. package/out/chunk-S6U2J62U.js +232 -0
  12. package/out/{chunk-G24A42V6.js → chunk-SPEFPML6.js} +1 -1
  13. package/out/chunk-UENXT2HK.js +3 -0
  14. package/out/chunk-VL43BTSO.js +1 -0
  15. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  16. package/out/chunk-ZCS4UYHI.js +1 -0
  17. package/out/chunk-ZGNMDGSB.js +1 -0
  18. package/out/client-managed-import.js +1 -1
  19. package/out/client-managed.js +22 -22
  20. package/out/client-sync.js +2 -2
  21. package/out/compare.js +14 -14
  22. package/out/dialogs.js +40 -40
  23. package/out/graph.js +11 -11
  24. package/out/index.html +11 -11
  25. package/out/scan.js +2 -2
  26. package/out/terminal.js +6 -6
  27. package/out/view.css +1 -1
  28. package/out/view.js +15 -15
  29. package/package.json +2 -1
  30. package/risk/index.d.ts +4 -2
  31. package/risk/npm/assess.js +15 -4
  32. package/risk/npm/registry.js +6 -1
  33. package/server-managed/activity.ts +24 -11
  34. package/server-managed/app-agents.ts +10 -21
  35. package/server-managed/bundle-build-worker.js +7 -3
  36. package/server-managed/bundle-build.ts +5 -3
  37. package/server-managed/bundle-cache.ts +9 -3
  38. package/server-managed/bundle.ts +3 -4
  39. package/server-managed/commit-coauthors.ts +5 -9
  40. package/server-managed/db-methods.ts +286 -149
  41. package/server-managed/db-neon.ts +11 -4
  42. package/server-managed/http.ts +75 -33
  43. package/server-managed/link-reports.ts +5 -1
  44. package/server-managed/npm-kept.ts +19 -5
  45. package/server-managed/npm-loads.ts +27 -15
  46. package/server-managed/npm-packages.ts +18 -11
  47. package/server-managed/npm-risk.ts +64 -17
  48. package/server-managed/pretty-print.ts +45 -12
  49. package/server-managed/report-store.ts +16 -11
  50. package/server-managed/repository-agents.ts +1 -1
  51. package/server-managed/repository-browser.ts +4 -2
  52. package/server-managed/repository-code.ts +28 -11
  53. package/server-managed/repository-pulse.ts +1 -1
  54. package/server-managed/solidity-entry-points.ts +5 -3
  55. package/server-managed/sql-postgres.ts +3 -0
  56. package/server-managed/sql.ts +2 -1
  57. package/server-managed/storage-db.ts +11 -3
  58. package/server-managed/storage-encryption.ts +7 -4
  59. package/server-managed/storage.ts +4 -2
  60. package/server-managed/team-feed.ts +3 -1
  61. package/server-managed/team-reports.ts +6 -1
  62. package/server-managed/uploads.ts +29 -9
  63. package/out/chunk-4UFAMJAX.js +0 -1
  64. package/out/chunk-BOIXMUU5.js +0 -3
  65. package/out/chunk-CC4OM6TZ.js +0 -3
  66. package/out/chunk-JKGQ3O7O.js +0 -6
  67. package/out/chunk-LE5ZWLG4.js +0 -235
  68. package/out/chunk-MSION3G4.js +0 -36
  69. package/out/chunk-S6GIXWQW.js +0 -52
  70. package/out/chunk-V3KWNNMY.js +0 -1
  71. package/out/chunk-WQ74ZBZF.js +0 -7
@@ -35,11 +35,18 @@ export type PgConnect = () => Promise<PgConnection>
35
35
  // presence updates, and maintenance leases do not take it. Also fences migrations.
36
36
  const LOCK = 'SELECT pg_advisory_xact_lock(1937006964, 1835101793)'
37
37
 
38
+ // Whether every version up to the latest is recorded, in one round trip at
39
+ // a cold start: a database with no versions table yet (undefined_table) has
40
+ // none. Outside a transaction, so the failed read aborts nothing.
41
+ const SCHEMA_VERSIONS = 31
38
42
  async function currentSchema(db: PgConnection): Promise<boolean> {
39
- const exists = (await db.query("SELECT to_regclass('managed_schema_version') AS name")).rows[0]?.['name']
40
- if (!exists) return false
41
- const versions = new Set((await db.query('SELECT version FROM managed_schema_version')).rows.map(row => Number(row['version'])))
42
- return Array.from({ length: 31 }, (_, i) => i + 1).every(version => versions.has(version))
43
+ try {
44
+ const { rows } = await db.query(`SELECT count(*) AS n FROM managed_schema_version WHERE version BETWEEN 1 AND ${SCHEMA_VERSIONS}`)
45
+ return Number(rows[0]?.['n']) === SCHEMA_VERSIONS
46
+ } catch (error) {
47
+ if ((error as { code?: unknown })?.code === '42P01') return false
48
+ throw error
49
+ }
43
50
  }
44
51
 
45
52
  async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
@@ -135,7 +135,7 @@ import { mergeLinkGroups } from './link-reports.ts'
135
135
  import { backfillFindingIds, reportEntries } from '@preventive/report'
136
136
  import type { IncomingMessage, ServerResponse } from 'node:http'
137
137
  import { pipeline } from 'node:stream/promises'
138
- import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmPublishTimeStore, npmVersionStore, pullDependencyStore,
138
+ import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmPublishTimeStore, npmVersionStore, ownKeptPrunes, pullDependencyStore,
139
139
  repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
140
140
  import { type NpmDelta, npmDelta } from './npm-delta.ts'
141
141
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
@@ -151,7 +151,7 @@ import { contentAccess } from './content-access.ts'
151
151
  import type { BundleStore } from './bundle-store.ts'
152
152
  import { Buffer } from 'node:buffer'
153
153
  import { createHash, randomUUID } from 'node:crypto'
154
- import type { AvatarStore } from './avatar-store.ts'
154
+ import type { AvatarStore, CachedAvatar } from './avatar-store.ts'
155
155
  import type { BlobStore, OpenedBlob } from './blob-store.ts'
156
156
  import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
157
157
  import { resolveRepositoryImportLocation } from './repository-aliases.ts'
@@ -181,6 +181,7 @@ import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines
181
181
  import { WORKFLOWS_DIR } from './repository-actions.ts'
182
182
  import { githubActionsSource } from './github-actions.ts'
183
183
  import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
184
+ import { type Lru, lru } from './lru.ts'
184
185
  import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
185
186
  import { clearCookie, endSession, readSession, runRequestPresence } from './session.ts'
186
187
  import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
@@ -468,14 +469,18 @@ function firstHeader(v: string | string[] | undefined): string | null {
468
469
  }
469
470
 
470
471
  // `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
471
- async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id): Promise<void> {
472
- const avatar = await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id))
472
+ // A user's is never kept by the browser, as it changes with their profile.
473
+ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id, cacheControl = 'no-store'): Promise<void> {
474
+ sendAvatar(res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
475
+ }
476
+
477
+ function sendAvatar(res: ServerResponse, avatar: CachedAvatar | null, cacheControl = 'no-store'): void {
473
478
  if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
474
479
  res.writeHead(200, {
475
480
  'content-type': avatar.contentType,
476
481
  'content-length': String(avatar.bytes.length),
477
482
  'x-content-type-options': 'nosniff',
478
- 'cache-control': 'no-store',
483
+ 'cache-control': cacheControl,
479
484
  })
480
485
  res.end(avatar.bytes)
481
486
  }
@@ -489,15 +494,20 @@ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: st
489
494
  const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
490
495
  .flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
491
496
  const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
497
+ // An agent's mark is a public GitHub image that never changes with anyone's
498
+ // profile, drawn on every tile and comment by it: kept by the browser a day,
499
+ // for it alone.
500
+ const AGENT_AVATAR_CACHE = 'private, max-age=86400'
492
501
 
493
502
  async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
494
503
  const [, kind, id] = AGENT_AVATAR.exec(avatar)!
495
504
  const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
496
- if (await avatarStore.get(key) == null) {
497
- await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
498
- .catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
499
- }
500
- await serveAvatar(res, avatarStore, key)
505
+ // Read once where it is kept; read again only past fetching it.
506
+ const kept = await avatarStore.get(key)
507
+ if (kept != null) { sendAvatar(res, kept, AGENT_AVATAR_CACHE); return }
508
+ await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
509
+ .catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
510
+ await serveAvatar(res, avatarStore, key, key, AGENT_AVATAR_CACHE)
501
511
  }
502
512
 
503
513
  const MAX_JSON_BODY_BYTES = 4096
@@ -518,14 +528,17 @@ async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Bu
518
528
  }
519
529
 
520
530
  // A file upload's content, opening a body the browser sealed to this session
521
- // (common/managed/upload-seal.ts). Link reports are never sent in parts.
531
+ // (common/managed/upload-seal.ts). Link reports are never sent in parts; the
532
+ // parts of one sent in them are deleted behind its answer.
522
533
  async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: ManagedSession, kind: UploadKind | 'links'): Promise<Buffer> {
523
534
  const maxBytes = kind === 'bundles' ? deps.config.maxBundleBytes : deps.config.maxReportBytes
524
535
  const sealed = req.headers[UPLOAD_SEAL_HEADER] !== undefined
525
536
  const limit = sealed ? maxSealedBytes(maxBytes) : maxBytes
526
537
  let bytes
527
538
  if (kind === 'links' || req.headers['x-upload-id'] == null) bytes = await readBodyBytes(req, limit)
528
- else if (deps.uploadStore) bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit)
539
+ else if (deps.uploadStore) {
540
+ bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit, cleanup => { deps.track(cleanup); deps.waitUntil?.(cleanup) })
541
+ }
529
542
  else throw new Error('bad-upload')
530
543
  return sealed ? await openSessionUpload(session.uploadKey, bytes, maxBytes) : bytes
531
544
  }
@@ -640,7 +653,7 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
640
653
  const userId = (body as { userId?: unknown } | null)?.userId
641
654
  if (typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
642
655
  if (userId === s.user.id) { sendJson(res, 400, { error: 'cannot-view-as-self' }); return }
643
- const target = (await deps.db.listUsers()).find(user => user.id === userId)
656
+ const target = await deps.db.getUser(userId)
644
657
  if (!target) { sendJson(res, 404, { error: 'not-found' }); return }
645
658
  // The insert itself rechecks the admin session and role.
646
659
  const setCookie = await startViewSession(deps.config, deps.db, s.session, userId, Date.now())
@@ -723,7 +736,7 @@ async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: Ma
723
736
  const role = (body as { role?: unknown } | null)?.role
724
737
  if (typeof userId !== 'string' || !isRole(role)) { sendJson(res, 400, { error: 'bad-request' }); return }
725
738
  if (userId === s.user.id) { sendJson(res, 403, { error: 'cannot-change-own-role' }); return }
726
- const target = (await deps.db.listUsers()).find(user => user.id === userId)
739
+ const target = await deps.db.getUser(userId)
727
740
  // A caller can hold the body open while their admin access is revoked.
728
741
  if (await readAdminSession(res, deps, cookie) == null) return
729
742
  const ok = await deps.db.setUserRole(userId, role)
@@ -1260,8 +1273,8 @@ async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, ses
1260
1273
  // declared integrity that has a stored bundle wins (bundleId set). When none is
1261
1274
  // stored yet, keep the first declared integrity so a later bundle upload of it
1262
1275
  // re-links (see linkReportsToBundle). Returns the (bundleId, integrity) to store.
1263
- async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, bytes: Buffer): Promise<{ bundleId: string | null; integrity: string | null }> {
1264
- const hashes = reportBundleHashes(bytes)
1276
+ async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, json: unknown): Promise<{ bundleId: string | null; integrity: string | null }> {
1277
+ const hashes = reportBundleHashes(json)
1265
1278
  for (const h of hashes) {
1266
1279
  const b = await deps.db.getBundleByIntegrity(h)
1267
1280
  if (b != null && await canAccessBundle(deps, user, b.id)) return { bundleId: b.id, integrity: h }
@@ -1371,7 +1384,8 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
1371
1384
  if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
1372
1385
  const id = randomUUID()
1373
1386
  const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
1374
- const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
1387
+ // A JSON report's parse already in hand, not parsed again for its hashes.
1388
+ const { bundleId, integrity } = await resolveReportBundle(deps, s.user, parsed.format === 'json' ? parsed.data : null)
1375
1389
  const dataKey = await deps.reportStore.put(id, bytes)
1376
1390
  let report: ReportRecord
1377
1391
  try {
@@ -1461,8 +1475,10 @@ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
1461
1475
  // user's repository access, for the next catalog to send.
1462
1476
  async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, missing: readonly CatalogCommit[]): Promise<void> {
1463
1477
  let browser: ReturnType<typeof createRepositoryBrowser> | undefined
1478
+ let repos: ReturnType<ManagedDb['listAllRepos']> | undefined
1464
1479
  await backfillBundleCommits(deps.db, userId, missing, async repoId => {
1465
- const repo = (await deps.db.listAllRepos()).find(item => item.repoId === repoId)
1480
+ // Read once for the repositories the backfill reaches, not once each.
1481
+ const repo = (await (repos ??= deps.db.listAllRepos())).find(item => item.repoId === repoId)
1466
1482
  if (!repo) return null
1467
1483
  browser ??= createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, userId))
1468
1484
  return browser.reader(repo)
@@ -1534,9 +1550,11 @@ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
1534
1550
  async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
1535
1551
  const s = await readWorkspaceSession(res, deps, cookie)
1536
1552
  if (!s) return null
1537
- const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
1553
+ // bundleRepos's reads, the reader's scopes read once for both.
1554
+ const access = await contentAccess(deps.db, s.user)
1555
+ const repo = (await deps.db.listSelectedRepos()).filter(access.bundle).find(item => item.repoId === repoId)
1538
1556
  if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1539
- const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1557
+ const scopes = access.scopes == null ? [null] : access.scopes.filter(scope => scope.repoId === repoId).map(scope => scope.path)
1540
1558
  return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
1541
1559
  }
1542
1560
 
@@ -1602,6 +1620,22 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1602
1620
  // read, before any of it is sent. The load stays held until this response is
1603
1621
  // written or abandoned, and a reader who stops reading it gives it up after
1604
1622
  // a while.
1623
+ // What GitHub answered of a repository's trees for the code viewer, which a
1624
+ // commit or tree it names never changes: the tree at a directory of a commit
1625
+ // (getRepoTreeId reads the commit, then a listing a level down to it), and
1626
+ // how many files a tree holds under it and their bytes (repositoryTreeFiles),
1627
+ // by the repository's id, for the next to open it. Asked of them only once
1628
+ // the reader's own access has named the commit, as GitHub would have
1629
+ // answered them; by the database, as each instance has one.
1630
+ const REPOSITORY_TREES_KEPT = 4096
1631
+ type TreeFiles = Awaited<ReturnType<typeof repositoryTreeFiles>>
1632
+ const repositoryTrees = new WeakMap<ManagedDb, { ids: Lru<string>; files: Lru<TreeFiles> }>()
1633
+ function repositoryTreesOf(db: ManagedDb) {
1634
+ let kept = repositoryTrees.get(db)
1635
+ if (!kept) repositoryTrees.set(db, kept = { ids: lru(REPOSITORY_TREES_KEPT), files: lru(REPOSITORY_TREES_KEPT) })
1636
+ return kept
1637
+ }
1638
+
1605
1639
  async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1606
1640
  const asked = repositoryQuery(res, query)
1607
1641
  if (!asked) return
@@ -1618,10 +1652,12 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
1618
1652
  const commit = await reader.commit(ref)
1619
1653
  const repo = access.repo.fullName
1620
1654
  const client = createClient({ token: reader.readToken() })
1655
+ const treeKey = `${access.repo.repoId}\0${commit}\0${path}`, trees = repositoryTreesOf(deps.db)
1621
1656
  // A directory the commit has none at is none to read, as browsing it says.
1622
- const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
1657
+ const tree = trees.ids.get(treeKey) ?? await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
1623
1658
  throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
1624
1659
  }))
1660
+ trees.ids.set(treeKey, tree)
1625
1661
  // Access still held, and the same repository, before anything read is sent.
1626
1662
  const fenced = async () => {
1627
1663
  await reader.recheckAccess()
@@ -1631,12 +1667,15 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
1631
1667
  return true
1632
1668
  }
1633
1669
  // Too large names the commit it was read at, for History to read.
1634
- if (!repositoryCodeFits(await repositoryTreeFiles(repo, reader.readToken(), tree))) {
1670
+ const filesKey = `${access.repo.repoId}\0${tree}`
1671
+ let listed = trees.files.get(filesKey)
1672
+ if (listed === undefined) trees.files.set(filesKey, listed = await repositoryTreeFiles(repo, reader.readToken(), tree))
1673
+ if (!repositoryCodeFits(listed)) {
1635
1674
  if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
1636
1675
  return
1637
1676
  }
1638
1677
  if (res.destroyed) return
1639
- const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
1678
+ const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree, listed }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
1640
1679
  res.once('close', load.release)
1641
1680
  const bytes = await load.result
1642
1681
  if (!(await fenced()) || res.destroyed) return
@@ -2763,9 +2802,8 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
2763
2802
  throw new BundleBuildError(409, 'repository-changed')
2764
2803
  }
2765
2804
  signal.throwIfAborted()
2766
- const bytes = Buffer.from(built.bytes)
2767
- await storeUploadedBundle(req, res, deps, cookie, current, bytes, input.repoId, built.directory, built.filename, 'build',
2768
- bundleIntegrity(bytes), input.conditions)
2805
+ await storeUploadedBundle(req, res, deps, cookie, current, built.bytes, input.repoId, built.directory, built.filename, 'build',
2806
+ bundleIntegrity(built.bytes), input.conditions)
2769
2807
  })
2770
2808
  } catch (error) {
2771
2809
  if (res.destroyed || controller.signal.aborted) return
@@ -2846,15 +2884,17 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
2846
2884
  repo.repoId = null
2847
2885
  directory = ''
2848
2886
  }
2849
- await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity)
2887
+ await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity, null, existing === null)
2850
2888
  }
2851
2889
 
2890
+ // `absent` where the caller has just found no bundle of `integrity`: not read
2891
+ // again, one inserted since meeting the insert's UNIQUE below as a race does.
2852
2892
  async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
2853
2893
  s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
2854
- provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null): Promise<void> {
2894
+ provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null, absent = false): Promise<void> {
2855
2895
  const kind = bundleKind(filename)
2856
2896
  const reuse = { provenance, filename, kind, buildConditions }
2857
- const existing = await deps.db.getBundleByIntegrity(integrity)
2897
+ const existing = absent ? null : await deps.db.getBundleByIntegrity(integrity)
2858
2898
  if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, reuse); return }
2859
2899
  const id = randomUUID()
2860
2900
  const dataKey = await deps.bundleStore.put(id, bytes, kind)
@@ -2984,12 +3024,11 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
2984
3024
  // need a published report in their teams; ownership never overrides role none.
2985
3025
  async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
2986
3026
  if (!roleAtLeast(user.role, 'view')) return false
2987
- const report = await deps.db.getReport(reportId)
3027
+ const report = await deps.db.getReportView(user.id, reportId)
2988
3028
  if (report == null) return false
2989
3029
  if (user.role === 'admin') return true
2990
- if (user.role === 'manage') return report.uploadedBy === user.id || deps.db.userCanReadReport(user.id, reportId)
2991
- if (!report.visible) return false
2992
- return deps.db.userCanReadReport(user.id, reportId)
3030
+ if (user.role === 'manage') return report.uploadedBy === user.id || report.readable
3031
+ return report.visible && report.readable
2993
3032
  }
2994
3033
 
2995
3034
  // GET/HEAD /api/reports/:id/sources. Only the linked bundle's files cited by
@@ -4230,6 +4269,9 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
4230
4269
  export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4231
4270
  const { config, db, avatarStore, isShuttingDown, track } = deps
4232
4271
  const repositoryDiscovery = new RepositoryDiscovery(config)
4272
+ // Kept reads' prunes run behind their writes: tracked, so the database
4273
+ // outlives them, and on serverless kept alive past the answer.
4274
+ ownKeptPrunes(db, prune => { track(prune); deps.waitUntil?.(prune) })
4233
4275
 
4234
4276
  async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
4235
4277
  const url = new URL(req.url ?? '/', 'http://localhost')
@@ -39,7 +39,11 @@ function decryptGroups(key: StorageKey | null, id: string, payload: string): str
39
39
  try { return JSON.parse(plain.toString('utf8')) as string[][] } finally { plain.fill(0) }
40
40
  }
41
41
  export async function linkRevision(db: ManagedSql): Promise<string> {
42
- const rows = await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all()
42
+ return linkRevisionOf(await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all())
43
+ }
44
+ // The revision of the enabled link reports' `{ id, sha256 }` rows, by id,
45
+ // however they were read.
46
+ export function linkRevisionOf(rows: unknown[]): string {
43
47
  return rows.length > 0 ? createHash('sha256').update(JSON.stringify(rows)).digest('base64url') : ''
44
48
  }
45
49
  function parseLinkReport(content: string) {
@@ -38,15 +38,25 @@ export interface KeptStore<T> {
38
38
  // When each database last had the rows under each prefix removed.
39
39
  const PRUNE_EVERY_MS = 60 * 60_000
40
40
  const pruned = new WeakMap<UpstreamCacheStore, Map<string, number>>()
41
+ // Who sees each database's prunes through (index.ts: its app, which waits
42
+ // for them before closing it, and on serverless keeps its invocation alive
43
+ // till they settle), as a write no longer waits for one.
44
+ const pruneOwners = new WeakMap<UpstreamCacheStore, (prune: Promise<unknown>) => void>()
45
+ export function ownKeptPrunes(db: UpstreamCacheStore, own: (prune: Promise<unknown>) => void) {
46
+ pruneOwners.set(db, own)
47
+ }
41
48
 
42
49
  // Removes the rows under `prefix` kept `keptMs` before `at`, at most once an
43
50
  // hour a database, as rows under it are written: here, and risk's downloads
44
- // (npm-risk.ts). A failure is `warn`ed of, and removes nothing.
45
- export async function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void) {
51
+ // (npm-risk.ts), each one run handed to the database's owner. A failure is
52
+ // `warn`ed of, and removes nothing.
53
+ export function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void): Promise<void> {
46
54
  const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
47
- if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return
55
+ if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return Promise.resolve()
48
56
  times.set(prefix, at)
49
- try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) }
57
+ const prune = (async () => { try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) } })()
58
+ pruneOwners.get(db)?.(prune)
59
+ return prune
50
60
  }
51
61
 
52
62
  // Values of `type`, each read for `keptMs` after it was kept.
@@ -79,7 +89,11 @@ function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debu
79
89
  },
80
90
  async write(key, value, at) {
81
91
  try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
82
- await prune(at)
92
+ // Behind the write, not before it is done: the prune reads the whole
93
+ // table (no index serves its prefix), which no writer need wait on.
94
+ // Its hour is marked before it runs, so writes meanwhile start none,
95
+ // and its database's owner sees it through (ownKeptPrunes).
96
+ void prune(at)
83
97
  },
84
98
  async remove(key) {
85
99
  try { await db.deleteUpstreamCacheEntries(keyOf(key), Number.MAX_SAFE_INTEGER) } catch (error) { warn('remove', error) }
@@ -9,8 +9,8 @@ import { join, resolve } from 'node:path'
9
9
  import { CompressionError, decompress } from '@preventive/archive/compression.js'
10
10
  import { encodeBrotli } from './brotli.ts'
11
11
  import { keptBriefly } from './kept-briefly.ts'
12
- import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument, askUpstream,
13
- npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
12
+ import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument, type TarReading,
13
+ askUpstream, npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
14
14
  import { keepFile, readKeptFile, upstreamCachePath } from './upstream-cache.ts'
15
15
 
16
16
  // The tar stream a package's files may come in, headers and padding included.
@@ -39,9 +39,10 @@ interface Load { job: Promise<unknown>; readers: number; settled: boolean; slot:
39
39
  // keeps its place among the active ones until its work is done and each
40
40
  // reader has released it, as a reader does once its response is written or
41
41
  // abandoned, so what responses still hold counts too. Places by the slot
42
- // their loads share, with how many hold each.
42
+ // their loads share, with how many hold each and how many places it takes.
43
43
  const loads = new Map<string, Load>()
44
- const slots = new Map<string, number>()
44
+ const slots = new Map<string, { holding: number; places: number }>()
45
+ const placesTaken = () => [...slots.values()].reduce((sum, slot) => sum + slot.places, 0)
45
46
 
46
47
  const refused = <T>(status: number, code: string): NpmLoad<T> => {
47
48
  const result = Promise.reject(new NpmPackageError(status, code))
@@ -57,22 +58,32 @@ function shared<T>(what: string, doc: NpmVersionDocument, work: () => Promise<T>
57
58
  const { unpackedSize, fileCount } = doc.dist
58
59
  if ((unpackedSize ?? 0) > MAX_NPM_PACKAGE_BYTES || (fileCount ?? 0) > MAX_NPM_PACKAGE_FILES) return refused(413, 'package-too-large')
59
60
  const slot = tarballKey(doc)
60
- return sharedLoad(`${what} ${slot}`, work, 'npm-busy', slot)
61
+ return sharedLoad(`${what} ${slot}`, work, 'npm-busy', { slot })
62
+ }
63
+
64
+ // The places among the active ones a load takes whose files hold `files` and
65
+ // `bytes`: as many as packages they would fill, up to every one, each place
66
+ // holding what a package may.
67
+ export function loadPlaces({ files, bytes }: { files: number; bytes: number }): number {
68
+ return Math.min(MAX_ACTIVE_LOADS, Math.max(1, Math.ceil(files / MAX_NPM_PACKAGE_FILES), Math.ceil(bytes / MAX_NPM_PACKAGE_BYTES)))
61
69
  }
62
70
 
63
71
  // A load among the active ones, by `key`, refused as `busy` past them: a
64
72
  // repository directory's (repository-code.ts) as a package's, each holding
65
73
  // alike a tarball, its tar, its files and their encoded JSON. Loads of one
66
- // `slot` take one place between them.
67
- export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string, slot = key): NpmLoad<T> {
74
+ // `slot` take one place between them, or `places` (loadPlaces) where they
75
+ // hold more.
76
+ export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string,
77
+ { slot = key, places = 1 }: { slot?: string; places?: number } = {}): NpmLoad<T> {
68
78
  let load = loads.get(key)
69
79
  if (!load) {
70
- if (!slots.has(slot) && slots.size >= MAX_ACTIVE_LOADS) return refused(429, busy)
80
+ if (!slots.has(slot) && placesTaken() + places > MAX_ACTIVE_LOADS) return refused(429, busy)
71
81
  const created: Load = { job: work(), readers: 0, settled: false, slot }
72
82
  const finish = () => { settle(key, created) }
73
83
  created.job.then(finish, finish)
74
84
  loads.set(key, created)
75
- slots.set(slot, (slots.get(slot) ?? 0) + 1)
85
+ const taken = slots.get(slot)
86
+ slots.set(slot, { holding: (taken?.holding ?? 0) + 1, places: taken?.places ?? places })
76
87
  load = created
77
88
  }
78
89
  const held = load
@@ -99,9 +110,9 @@ function settle(key: string, load: Load) {
99
110
  function done(key: string, load: Load) {
100
111
  if (load.readers !== 0 || !load.settled || loads.get(key) !== load) return
101
112
  loads.delete(key)
102
- const holding = slots.get(load.slot)! - 1
103
- if (holding === 0) slots.delete(load.slot)
104
- else slots.set(load.slot, holding)
113
+ const taken = slots.get(load.slot)!
114
+ if (taken.holding === 1) slots.delete(load.slot)
115
+ else taken.holding--
105
116
  }
106
117
 
107
118
  // The sha512 an integrity names, in base64, or null where it names none.
@@ -201,10 +212,11 @@ function unpackedOf(doc: NpmVersionDocument): Promise<Unpacked> {
201
212
 
202
213
  // A gzipped tarball's files, its top directory left out, as npm extracts
203
214
  // a package's: a repository tree's as GitHub archives it, too, its symlinks
204
- // let climb as readNpmTar's `symlinksAbove` says.
205
- export async function readTarballFiles(tarball: Uint8Array, options: { symlinksAbove?: number } = {}): Promise<NpmPackageFile[]> {
215
+ // let climb as readNpmTar's `symlinksAbove` says, and its tar stream and
216
+ // files bounded as `options` give, a package's bounds unless given.
217
+ export async function readTarballFiles(tarball: Uint8Array, { maxTarBytes = MAX_TAR_BYTES, ...options }: TarReading & { maxTarBytes?: number } = {}): Promise<NpmPackageFile[]> {
206
218
  let tar: Uint8Array
207
- try { tar = await decompress(tarball, 'gzip', { limit: MAX_TAR_BYTES }) }
219
+ try { tar = await decompress(tarball, 'gzip', { limit: maxTarBytes }) }
208
220
  catch (err) {
209
221
  const limited = err instanceof CompressionError && err.limited
210
222
  throw new NpmPackageError(limited ? 413 : 422, limited ? 'package-too-large' : 'bad-tarball')
@@ -31,11 +31,12 @@ const PACKUMENT_BYTES = 64 * 1024 * 1024
31
31
  // limit until it is read and parsed: four packuments, or 32 version documents.
32
32
  const MAX_DOCUMENT_BYTES = 4 * PACKUMENT_BYTES
33
33
  // What a package may unpack to: the files' bytes and their count; the tar
34
- // stream holding them is bounded in npm-loads.ts.
34
+ // stream holding them is bounded in npm-loads.ts. A repository directory's
35
+ // are its own (repository-code.ts).
35
36
  export const MAX_NPM_PACKAGE_BYTES = 64 * 1024 * 1024
36
37
  export const MAX_NPM_PACKAGE_FILES = 20_000
37
38
  // The entries its tar stream may hold, read before any is counted as a file:
38
- // a directory's too, as a GitHub tree's tarball has one for each, npm's none.
39
+ // a directory's too, though npm packs none.
39
40
  export const MAX_NPM_TAR_ENTRIES = 2 * MAX_NPM_PACKAGE_FILES
40
41
  // What the files may take as the viewer's JSON. Escaping grows a text up to
41
42
  // six times (a control character becomes `\u0001`), so it is counted before
@@ -367,18 +368,23 @@ export function npmPublishTime(doc: Pick<NpmVersionDocument, 'name' | 'version'
367
368
 
368
369
  export interface NpmPackageFile { path: string; bytes: Uint8Array }
369
370
 
371
+ // How a tar stream is read (readNpmTar): how far its symlinks may climb above
372
+ // it, and the entries, files and bytes it may hold, a package's unless given.
373
+ export interface TarReading { symlinksAbove?: number; maxEntries?: number; maxFiles?: number; maxBytes?: number }
374
+
370
375
  // The regular files of a tar stream, by package path: npm drops the first
371
376
  // component (`package/`, or whatever the archive used), and a file at the top
372
377
  // is not extracted. The stream is read by @preventive/archive, which refuses
373
378
  // one malformed, a name or a symlink that would climb out of it -- a symlink
374
379
  // past `symlinksAbove` levels above it, none unless given -- and a name
375
- // repeated as another entry, and gives up past MAX_NPM_TAR_ENTRIES before
376
- // reading on; a file under another top directory at the same path replaces
377
- // an earlier one, as extraction would. Directories, links and devices hold no
380
+ // repeated as another entry, and gives up past `maxEntries` before reading
381
+ // on; a file under another top directory at the same path replaces an
382
+ // earlier one, as extraction would. Directories, links and devices hold no
378
383
  // file content and are skipped.
379
- export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbove?: number } = {}): NpmPackageFile[] {
384
+ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0, maxEntries = MAX_NPM_TAR_ENTRIES, maxFiles = MAX_NPM_PACKAGE_FILES,
385
+ maxBytes = MAX_NPM_PACKAGE_BYTES }: TarReading = {}): NpmPackageFile[] {
380
386
  let entries: Entry[]
381
- try { entries = unpack(tar, { maxEntries: MAX_NPM_TAR_ENTRIES, symlinksAbove }) }
387
+ try { entries = unpack(tar, { maxEntries, symlinksAbove }) }
382
388
  catch (err) {
383
389
  if (err instanceof ArchiveError) throw err.limited ? new NpmPackageError(413, 'package-too-large') : new NpmPackageError(422, 'bad-tarball')
384
390
  throw err
@@ -391,7 +397,7 @@ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbo
391
397
  const path = name.slice(slash + 1)
392
398
  total += data.length - (files.get(path)?.length ?? 0)
393
399
  files.set(path, data)
394
- if (files.size > MAX_NPM_PACKAGE_FILES || total > MAX_NPM_PACKAGE_BYTES) throw new NpmPackageError(413, 'package-too-large')
400
+ if (files.size > maxFiles || total > maxBytes) throw new NpmPackageError(413, 'package-too-large')
395
401
  }
396
402
  return [...files].map(([path, bytes]) => ({ path, bytes })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
397
403
  }
@@ -421,13 +427,14 @@ const ROW_LENGTH = 80
421
427
 
422
428
  // The files as the viewer gets them: `[path, bytes, text]`, text null for a
423
429
  // file that is not UTF-8, which instead carries its bytes' sha256, so a
424
- // comparison of two versions tells a changed one from an unchanged one.
425
- export function npmFileRows(files: NpmPackageFile[]): ([string, number, string] | [string, number, null, string])[] {
430
+ // comparison of two versions tells a changed one from an unchanged one. They
431
+ // may take `maxLength` characters of JSON, a package's unless given.
432
+ export function npmFileRows(files: NpmPackageFile[], maxLength = MAX_NPM_JSON_LENGTH): ([string, number, string] | [string, number, null, string])[] {
426
433
  let length = 0
427
434
  return files.map(({ path, bytes }) => {
428
435
  const text = npmFileText(bytes)
429
436
  length += ROW_LENGTH + jsonLength(path) + (text === null ? 0 : jsonLength(text))
430
- if (length > MAX_NPM_JSON_LENGTH) throw new NpmPackageError(413, 'package-too-large')
437
+ if (length > maxLength) throw new NpmPackageError(413, 'package-too-large')
431
438
  return text === null ? [path, bytes.byteLength, null, `sha256-${createHash('sha256').update(bytes).digest('base64')}`] : [path, bytes.byteLength, text]
432
439
  })
433
440
  }