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

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 (79) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +5 -1
  5. package/common/line-diff.js +5 -4
  6. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  7. package/out/{chunk-AFLZH7WG.js → chunk-4UFAMJAX.js} +1 -1
  8. package/out/{chunk-HMAJDVXN.js → chunk-7XRR4OFU.js} +1 -1
  9. package/out/{chunk-Q6MVYGYV.js → chunk-BOIXMUU5.js} +1 -1
  10. package/out/{chunk-XVBEOGOS.js → chunk-CC4OM6TZ.js} +2 -2
  11. package/out/chunk-G24A42V6.js +2 -0
  12. package/out/{chunk-WI7Z5A2T.js → chunk-JKGQ3O7O.js} +1 -1
  13. package/out/chunk-LE5ZWLG4.js +235 -0
  14. package/out/chunk-MSION3G4.js +36 -0
  15. package/out/{chunk-6HNXNIQY.js → chunk-S6GIXWQW.js} +1 -1
  16. package/out/chunk-V3KWNNMY.js +1 -0
  17. package/out/client-managed-import.js +1 -1
  18. package/out/client-managed.js +25 -25
  19. package/out/compare.js +13 -13
  20. package/out/dialogs.js +40 -40
  21. package/out/graph.js +1 -1
  22. package/out/index.html +6 -6
  23. package/out/scan.js +2 -2
  24. package/out/terminal.js +1 -1
  25. package/out/view.css +1 -1
  26. package/out/view.js +15 -15
  27. package/package.json +3 -1
  28. package/risk/actions/findings.js +14 -9
  29. package/risk/actions/scripts/shell.js +5 -1
  30. package/risk/index.d.ts +5 -2
  31. package/server-e2e/app.ts +5 -4
  32. package/server-e2e/http.ts +5 -2
  33. package/server-managed/bundle-cache.ts +7 -1
  34. package/server-managed/combined.ts +3 -2
  35. package/server-managed/commit-lockfiles.ts +21 -11
  36. package/server-managed/config.ts +6 -0
  37. package/server-managed/db-methods.ts +37 -9
  38. package/server-managed/db-neon.ts +61 -21
  39. package/server-managed/db-schema.ts +4 -0
  40. package/server-managed/db.ts +2 -1
  41. package/server-managed/dependency-reviews.ts +13 -3
  42. package/server-managed/github-app.ts +12 -6
  43. package/server-managed/github-issue-links.ts +11 -5
  44. package/server-managed/github-pulls.ts +2 -2
  45. package/server-managed/github-requests.ts +39 -0
  46. package/server-managed/http-response.ts +10 -2
  47. package/server-managed/http.ts +195 -111
  48. package/server-managed/index.ts +8 -7
  49. package/server-managed/kept-briefly.ts +37 -0
  50. package/server-managed/npm-delta.ts +11 -6
  51. package/server-managed/npm-kept.ts +32 -2
  52. package/server-managed/npm-loads.ts +100 -30
  53. package/server-managed/npm-packages.ts +89 -3
  54. package/server-managed/npm-risk.ts +4 -1
  55. package/server-managed/pretty-print.ts +67 -23
  56. package/server-managed/pull-dependencies.ts +29 -9
  57. package/server-managed/pull-dependency-reviews.ts +8 -8
  58. package/server-managed/queue.ts +16 -0
  59. package/server-managed/rename-diff.ts +22 -13
  60. package/server-managed/report-sources.ts +1 -1
  61. package/server-managed/report-store.ts +42 -9
  62. package/server-managed/repository-actions.ts +15 -6
  63. package/server-managed/repository-browser.ts +119 -31
  64. package/server-managed/repository-dependency-reviews.ts +41 -19
  65. package/server-managed/repository-history.ts +24 -8
  66. package/server-managed/repository-pulse.ts +8 -1
  67. package/server-managed/repository-risk-audit.ts +13 -0
  68. package/server-managed/repository-risk-read.ts +11 -6
  69. package/server-managed/repository-risk.ts +27 -20
  70. package/server-managed/session.ts +18 -4
  71. package/server-managed/sql.ts +5 -2
  72. package/server-managed/storage.ts +4 -2
  73. package/server-managed/team-reports.ts +24 -12
  74. package/server-managed/upstream-cache.ts +12 -0
  75. package/server.ts +14 -5
  76. package/out/chunk-5DHL5NG3.js +0 -2
  77. package/out/chunk-NV7ES4EW.js +0 -1
  78. package/out/chunk-PTGDYLNS.js +0 -36
  79. package/out/chunk-QRD6GIG5.js +0 -229
@@ -51,9 +51,10 @@
51
51
  // description's Markdown | 400/401/403/404/409/429/502
52
52
  // GET /api/admin/repositories/pull-dependencies?repoId=&number= → the package versions a pull request's npm, pnpm and
53
53
  // Yarn 1 lockfiles lock that they didn't at its merge base, an update's with those it was, by directory; with review=1, each's
54
- // review, where its repository reviews dependencies | 400/401/403/404/409/429/502
54
+ // review, where its repository reviews dependencies; with numbers=1,2,3 (twenty at most) in place of number, those of each,
55
+ // { pulls }, one GitHub fails told { number, error } | 400/401/403/404/409/429/502
55
56
  // GET /api/admin/repositories/branch-dependencies?repoId= → the versions the default branch's lockfiles lock that want review, where
56
- // its review is in effect for the reader | 400/401/403/404/409/429/502
57
+ // its review is in effect for the reader, GitHub not asked where it reviews none | 400/401/403/404/409/429/502
57
58
  // GET /api/admin/repositories/lockfiles?repoId= → the lockfiles of the default branch its dependency review reads, by their
58
59
  // paths, for its settings to name | 400/401/403/404/409/429/502
59
60
  // GET /api/admin/repositories/pulse?repoId=&weeks=1|4|13|52 → the default branch's commits and lines a week and a day over
@@ -134,7 +135,7 @@ import { mergeLinkGroups } from './link-reports.ts'
134
135
  import { backfillFindingIds, reportEntries } from '@preventive/report'
135
136
  import type { IncomingMessage, ServerResponse } from 'node:http'
136
137
  import { pipeline } from 'node:stream/promises'
137
- import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmVersionStore, pullDependencyStore,
138
+ import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmPublishTimeStore, npmVersionStore, pullDependencyStore,
138
139
  repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
139
140
  import { type NpmDelta, npmDelta } from './npm-delta.ts'
140
141
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
@@ -181,7 +182,7 @@ import { WORKFLOWS_DIR } from './repository-actions.ts'
181
182
  import { githubActionsSource } from './github-actions.ts'
182
183
  import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
183
184
  import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
184
- import { clearCookie, endSession, readSession } from './session.ts'
185
+ import { clearCookie, endSession, readSession, runRequestPresence } from './session.ts'
185
186
  import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
186
187
  import type { ActivityContext, ActivityInput } from './activity.ts'
187
188
  import { acceptsReportMetadata } from './report-response.ts'
@@ -196,7 +197,7 @@ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallb
196
197
  import type { ProfileChoice } from './github-links.ts'
197
198
  import { withAppUsers } from './github-users.ts'
198
199
  import { isBlobId } from './object-storage.ts'
199
- import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
200
+ import { attachmentDisposition, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
200
201
  import { triageWireEntry } from './triage-response.ts'
201
202
  import { handlePublicWorkspace } from './public-workspace.ts'
202
203
  import { serveUserTeamFeed } from './team-feed.ts'
@@ -205,7 +206,8 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
205
206
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
206
207
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
207
208
  import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
208
- import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
209
+ import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions,
210
+ readTogether } from './npm-packages.ts'
209
211
  import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
210
212
  import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
211
213
  import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
@@ -214,8 +216,8 @@ import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE,
214
216
  repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
215
217
  import { AGENTS_READ_VERSION, APP_AGENTS, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
216
218
  import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
217
- import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependencies } from './pull-dependencies.ts'
218
- import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
219
+ import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
220
+ import { type GithubAsked, lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
219
221
  import { MAX_REQUIRED_REVIEWS, SINCE_COMMIT } from './repository-dependency-reviews.ts'
220
222
  import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
221
223
  import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
@@ -335,7 +337,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
335
337
  if (!s) return
336
338
  const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
337
339
  const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
338
- const manualUrls = storedFixUrls(await deps.db.listTriage(ids))
340
+ const manualUrls = storedFixUrls(await deps.db.listTriageFixes(ids))
339
341
  const storedIssues = await deps.db.listManagedIssues(ids)
340
342
  const issues = visibleManagedIssues(storedIssues, snapshot)
341
343
  const visibleIds = new Set(issues.map(issue => issue.findingId))
@@ -346,7 +348,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
346
348
  : { fixes: await lookupFixes(deps.config, deps.db, snapshot, urls), updates: [] }
347
349
  // Cold report reads, token refresh and upstream batches can outlive changes
348
350
  // to security/links, team grants, publication, sessions or the Fix links.
349
- const currentUrls = storedFixUrls(await deps.db.listTriage(ids))
351
+ const currentUrls = storedFixUrls(await deps.db.listTriageFixes(ids))
350
352
  if (await readWorkspaceSession(res, deps, cookie) == null) return
351
353
  await recheckTeam(deps.db, s.session.id, snapshot)
352
354
  if (JSON.stringify(currentUrls) !== JSON.stringify(manualUrls)) {
@@ -385,9 +387,8 @@ function activity(deps: ManagedHttpDeps, user: StoredUser, kind: ActivityInput['
385
387
  return deps.db.recordActivity({ kind, actor: user.login, actorId: user.id, action, ...context }, Date.now())
386
388
  }
387
389
 
388
- async function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
389
- if (repoId == null) return null
390
- return (await deps.db.listAllRepos()).find(repo => repo.repoId === repoId)?.fullName ?? null
390
+ function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
391
+ return repoId == null ? Promise.resolve(null) : deps.db.getRepoFullName(repoId)
391
392
  }
392
393
 
393
394
  // Workspace history is authorized BEFORE filtering, counting, or paging.
@@ -411,8 +412,14 @@ async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie:
411
412
  if (s.user.role !== 'admin') {
412
413
  const findings = new Map<string, ActivityContext>()
413
414
  const reports = kind === 'all' || kind === 'triage' ? await deps.db.listActivityReports(s.user.id) : []
415
+ // Every report's record in one read, as visibleFindingIds reads each one's.
416
+ const snapshot = reports.length > 0 ? await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), reports.map(report => report.reportId)) : { reports: [] }
417
+ if (snapshot == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
418
+ const records = new Map(snapshot.reports.map(record => [record.id, record]))
414
419
  for (const report of reports) {
415
- for (const finding of await visibleFindingIds(deps, s.user, report.reportId, s.session.id, null)) {
420
+ const record = records.get(report.reportId)
421
+ if (record == null) continue
422
+ for (const finding of await reportFindingIds(deps, record, record.repo.github)) {
416
423
  if (!findings.has(finding)) findings.set(finding, { finding, ...report })
417
424
  }
418
425
  }
@@ -436,6 +443,9 @@ export interface ManagedHttpDeps {
436
443
  originGate: OriginGate
437
444
  isShuttingDown: () => boolean
438
445
  track: (p: Promise<unknown>) => void
446
+ // Serverless (Vercel's): keeps the invocation alive for work that goes on
447
+ // past its answer, as a kept read read again behind it does.
448
+ waitUntil?: (p: Promise<unknown>) => void
439
449
  // Combined boot delegates unmatched paths to e2e and overrides discovery.
440
450
  serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
441
451
  next?: Handler
@@ -1679,12 +1689,22 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1679
1689
  }
1680
1690
  const access = await authorize()
1681
1691
  if (!access) return
1692
+ let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
1682
1693
  try {
1683
1694
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1684
1695
  const repo = access.repo.fullName
1685
1696
  const token = reader.readToken()
1686
1697
  // With a token, as GraphQL reads them.
1687
1698
  const pages = historyPages(deps, repoId, token !== null)
1699
+ const quiet = <T>(fallback: T) => (err: unknown) => {
1700
+ if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
1701
+ return fallback
1702
+ }
1703
+ // Open pull heads do not depend on the commit history. Start them with
1704
+ // it, handling failures immediately even if the history fails first.
1705
+ if (token && path === '' && page === 1) {
1706
+ readingPulls = keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null))
1707
+ }
1688
1708
  let body: object
1689
1709
  if (all) {
1690
1710
  if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
@@ -1719,13 +1739,9 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1719
1739
  // naming commits: with a token, as far as GitHub answers them, the
1720
1740
  // history sent all the same where it doesn't.
1721
1741
  const listed = (body as { commits: HistoryCommit[] }).commits
1722
- const quiet = <T>(fallback: T) => (err: unknown) => {
1723
- if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
1724
- return fallback
1725
- }
1726
1742
  const [pulls, openPulls] = token && path === '' ? await Promise.all([
1727
1743
  squashedPulls(repo, token, listed, historySquashes(deps, repoId)).catch(quiet<SquashedPull[]>([])),
1728
- page === 1 ? keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null)) : null,
1744
+ readingPulls,
1729
1745
  ]) : [[], null]
1730
1746
  await reader.recheckAccess()
1731
1747
  const current = await authorize()
@@ -1740,6 +1756,10 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1740
1756
  } catch (err) {
1741
1757
  if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1742
1758
  throw err
1759
+ } finally {
1760
+ // A failed history must not release the request's database lease while
1761
+ // its independent pull read is still writing to the cache.
1762
+ await readingPulls
1743
1763
  }
1744
1764
  }
1745
1765
 
@@ -1840,9 +1860,9 @@ const refusedRead = (err: unknown) => err instanceof GithubApiError
1840
1860
  // What `read` answers of a repository, kept by `key` (its id first) for every
1841
1861
  // reader (repositoryReadStore), each one's access checked as they ask before
1842
1862
  // this: answered as kept for REPOSITORY_READ_FRESH_MS, then, while it is
1843
- // kept, answered at once and read again behind it for the next to ask, where
1844
- // the instance outlives its answer (not serverless, where it is read again
1845
- // before it is answered); `kept` where it was, or where reading it again
1863
+ // kept, answered at once and read again behind it for the next to ask, the
1864
+ // read tracked (serverless, its invocation kept alive till it settles,
1865
+ // deps.waitUntil); `kept` where it was, or where reading it again
1846
1866
  // failed as `keepThrough` lets it. One asked for afresh (`fresh`) is read
1847
1867
  // now, unless it was read REPOSITORY_READ_FLOOR_MS since, what was kept
1848
1868
  // answered where that read fails. One GitHub refuses to read again is no
@@ -1877,10 +1897,12 @@ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string,
1877
1897
  }
1878
1898
  const age = held ? Date.now() - held.at : Infinity
1879
1899
  const now = fresh && age >= REPOSITORY_READ_FLOOR_MS
1880
- if (held && !now && (age < REPOSITORY_READ_FRESH_MS || !deps.config.serverless)) {
1900
+ if (held && !now) {
1881
1901
  if (age >= REPOSITORY_READ_FRESH_MS && !repositoryRereads.has(key)) {
1882
1902
  // Its failure leaves the kept answer sent until it is a day old, so it is said.
1883
- deps.track(reread().catch((err: unknown) => { console.warn('managed: repository read failed:', err) }))
1903
+ const behind = reread().catch((err: unknown) => { console.warn('managed: repository read failed:', err) })
1904
+ deps.track(behind)
1905
+ deps.waitUntil?.(behind)
1884
1906
  }
1885
1907
  return { value: held.value as T, kept: true, at: held.at }
1886
1908
  }
@@ -1902,14 +1924,16 @@ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string,
1902
1924
  function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): HistoryPages {
1903
1925
  const store = historyPageStore<HistoryCommit[]>(deps.db, deps.config.debug)
1904
1926
  const keyOf = (top: string) => `${repoId}\0${COAUTHORS_READ}\0${top}`
1927
+ const told = (tops: string[], kept: Map<string, { value: HistoryCommit[] }>) => new Map(tops.flatMap(top => {
1928
+ const page = kept.get(keyOf(top))?.value
1929
+ return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
1930
+ ? [[top, page] as const] : []
1931
+ }))
1905
1932
  return {
1906
- async read(tops) {
1907
- const kept = await store.readMany(tops.map(keyOf))
1908
- return new Map(tops.flatMap(top => {
1909
- const page = kept.get(keyOf(top))?.value
1910
- return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
1911
- ? [[top, page] as const] : []
1912
- }))
1933
+ read: async tops => told(tops, await store.readMany(tops.map(keyOf))),
1934
+ async readFirst(first, then) {
1935
+ const kept = await store.readFirst(first.map(keyOf), then.map(keyOf))
1936
+ return { first: kept.first, pages: told(kept.first ? first : then, kept.found) }
1913
1937
  },
1914
1938
  write: (top, commits) => store.write(keyOf(top), commits, Date.now()),
1915
1939
  }
@@ -1955,9 +1979,11 @@ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, c
1955
1979
  // What `answer` gives of the whole of repository `repoId` (with its name and
1956
1980
  // a token), as readWholeRepository reads it: sent at once where it was kept,
1957
1981
  // else once access is checked again, with the app's users it names
1958
- // (github-users.ts), never kept with it.
1982
+ // (github-users.ts), never kept with it; or what `unread` tells of it, the
1983
+ // app's alone, once the app's access is checked, GitHub then never asked.
1959
1984
  async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
1960
- answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>): Promise<void> {
1985
+ answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
1986
+ unread: () => Promise<object | null> = () => Promise.resolve(null)): Promise<void> {
1961
1987
  const authorize = async () => {
1962
1988
  const access = await authorizeRepository(res, deps, cookie, repoId, '')
1963
1989
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
@@ -1965,6 +1991,8 @@ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps,
1965
1991
  }
1966
1992
  const access = await authorize()
1967
1993
  if (!access) return
1994
+ const told = await unread()
1995
+ if (told) { sendJson(res, 200, await withAppUsers(deps.db, told)); return }
1968
1996
  try {
1969
1997
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1970
1998
  const token = reader.readToken()
@@ -2033,72 +2061,108 @@ function itemNumber(query: URLSearchParams): number | null {
2033
2061
  // GET /api/admin/repositories/pull-dependencies: the package versions pull
2034
2062
  // request `number`'s lockfiles add (pull-dependencies.ts), what its base's and head's
2035
2063
  // commits tell kept by them (pullDependencyKept), its `number` with them.
2036
- // They are the whole repository's (answerWholeRepository), kept by its number
2037
- // as its popup's item is, its head moving with each push. With `review=1`,
2064
+ // They are the whole repository's (answerWholeRepository), the commits they
2065
+ // are of kept by its number as its popup's item is, its head moving with each
2066
+ // push, what they tell kept by them alone, not again. With `review=1`,
2038
2067
  // as an open one's are asked, where the repository reviews dependencies,
2039
2068
  // each version's review (pull-dependency-reviews.ts), read afresh as each
2040
2069
  // asks, reviews being published meanwhile; its baseline kept, where the host
2041
2070
  // registered no reviewed dependencies, which are asked in its place.
2071
+ // Up to MAX_ASKED_PULLS pull requests asked together (`numbers=1,2,3`) are
2072
+ // answered together, as many read at once as PULLS_AT_ONCE, `{ pulls }`,
2073
+ // each's answer, else `{ number, error }` where GitHub fails it, with one
2074
+ // check of its access before and one after, but where each was kept.
2075
+ const MAX_ASKED_PULLS = 20
2076
+ const PULLS_AT_ONCE = 3
2042
2077
  async function handleRepositoryPullDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2043
2078
  const repoId = Number(query.get('repoId'))
2044
- const number = itemNumber(query)
2045
2079
  const review = query.get('review') === '1'
2046
- if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null || !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
2080
+ const listed = query.get('numbers')
2081
+ const numbers = listed === null ? [itemNumber(query)] : listed.split(',').map(text => itemNumber(new URLSearchParams({ number: text })))
2082
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || numbers.length > MAX_ASKED_PULLS || numbers.some(number => number === null) || new Set(numbers).size < numbers.length
2083
+ || !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
2047
2084
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2048
- const answered = await keptAnswer(deps, `${repoId}\0pull-dependencies\0${PULL_DEPENDENCIES_READ}\0${number}`,
2049
- async () => ({ number, ...await pullDependencies(fullName, token, number, pullDependencyKept(deps, repoId)) }))
2050
- // Where its review is on, those of lockfiles it leaves out told to none,
2051
- // whoever asks.
2052
- const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
2053
- const held = setting?.enabled ? heldDependencies(answered.value, setting.lockfiles) : answered.value
2054
- const now = review ? await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(held)) : null
2055
- // Its review by the lockfiles they were told by, though they have changed
2056
- // since; turned off meanwhile, all of them, as where it is off.
2057
- const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
2058
- const told = review && !now && setting?.enabled && !(await deps.db.listRepoDependencyReviews()).get(repoId)?.enabled ? answered.value : held
2059
- const reviewed = deps.reviewedDependencies ?? null
2060
- const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
2061
- // Its comments read for reviews, where the host reads them, while it is
2062
- // open (pull-comment-reviews.ts reads whether it is with them).
2063
- const parse = reviewed?.parseComments
2064
- const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
2065
- // The comment the host formats of the reader's reviews of its versions, for its copy button.
2066
- const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
2067
- return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
2068
- ...formatted ? { formatted } : {} }, kept: answered.kept }
2085
+ if (listed === null) return pullDependencyAnswer(deps, fullName, token, user, repoId, numbers[0]!, review)
2086
+ const answers = await eachOf(numbers as number[], PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, review).catch((err: unknown) => {
2087
+ // GitHub asked, though it failed: its access asked again after.
2088
+ if (err instanceof GithubApiError) return { value: { number, error: err.message }, kept: false }
2089
+ throw err
2090
+ }))
2091
+ return { value: { pulls: answers.map(({ value }) => value) }, kept: answers.every(({ kept }) => kept) }
2069
2092
  })
2070
2093
  }
2071
2094
 
2095
+ // Pull request `number`'s answer, as the endpoint gives it, and whether all
2096
+ // of it was kept, GitHub asked for none of it.
2097
+ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, number: number, review: boolean) {
2098
+ const kept = pullDependencyKept(deps, repoId)
2099
+ const reading: { told?: PullDependencies } = {}
2100
+ const answered = await keptAnswer(deps, `${repoId}\0pull-commits\0${PULL_DEPENDENCIES_READ}\0${number}`, async () => {
2101
+ const { commits, told } = await pullDependenciesAt(fullName, token, number, kept)
2102
+ reading.told = told
2103
+ return { commits }
2104
+ })
2105
+ // What its commits tell, as read, else as kept by them, else, no longer
2106
+ // kept, read again.
2107
+ const held = reading.told ?? (typeof answered.value.commits === 'string' ? await kept.read(answered.value.commits) : null)
2108
+ const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept)).told }
2109
+ // Where its review is on, those of lockfiles it leaves out told to none,
2110
+ // whoever asks.
2111
+ const setting = await deps.db.repoDependencyReview(repoId)
2112
+ const scoped = setting?.enabled ? heldDependencies(pulled, setting.lockfiles) : pulled
2113
+ const now = review ? await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(scoped)) : null
2114
+ // Its review by the lockfiles they were told by, though they have changed
2115
+ // since; turned off meanwhile, all of them, as where it is off.
2116
+ const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
2117
+ const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
2118
+ const reviewed = deps.reviewedDependencies ?? null
2119
+ const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
2120
+ // Its comments read for reviews, where the host reads them, while it is
2121
+ // open (pull-comment-reviews.ts reads whether it is with them).
2122
+ const parse = reviewed?.parseComments
2123
+ const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
2124
+ // The comment the host formats of the reader's reviews of its versions, for its copy button.
2125
+ const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
2126
+ return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
2127
+ ...formatted ? { formatted } : {} }, kept: answered.kept && held !== null }
2128
+
2129
+ }
2130
+
2072
2131
  // GET /api/admin/repositories/branch-dependencies: what the lockfiles of the
2073
2132
  // repository's default branch lock that wants review, where its review is in
2074
- // effect for the reader (`review`, else none), as a team's Dependencies tab
2075
- // shows it: its head, as its pulse reads it, kept a while; every lockfile
2076
- // there its review holds to (lockfile-scope.ts, commit-lockfiles.ts) as one
2077
- // directory, its versions as a pull request's others are
2078
- // (pull-dependency-reviews.ts), each left unread a directory of its own
2079
- // failing the review, as a pull request's are.
2133
+ // effect for the reader (`review`, else none, told without asking GitHub
2134
+ // where it reviews none), as a team's Dependencies tab shows it: its head, as
2135
+ // its pulse reads it, kept a while; every lockfile there its review holds to
2136
+ // (lockfile-scope.ts, commit-lockfiles.ts) as one directory, its versions as
2137
+ // a pull request's others are (pull-dependency-reviews.ts), each left unread
2138
+ // a directory of its own failing the review, as a pull request's are. Kept,
2139
+ // its access not asked again, where its head was kept and its lockfiles and
2140
+ // baseline held, GitHub asked for none of them.
2080
2141
  async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2081
2142
  const repoId = Number(query.get('repoId'))
2082
2143
  if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2083
2144
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2084
- const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
2145
+ const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2146
+ const head = headRead.value.sha
2085
2147
  if (!head) return { value: { head: null, review: null }, kept: false }
2148
+ const asked: GithubAsked = { github: !headRead.kept }
2086
2149
  // Those its review holds to alone read, as many as are read of them.
2087
- const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
2150
+ const setting = await deps.db.repoDependencyReview(repoId)
2088
2151
  if (!setting?.enabled) return { value: { head, review: null }, kept: false }
2089
- const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path))
2152
+ const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path), asked)
2090
2153
  const { packages, paths } = lockedIn(read)
2091
2154
  const told = { base: head, head, cut: false, lockfiles: [{ paths, added: [], more: 0, locked: packages.slice(0, MAX_LOCKED), moreLocked: Math.max(0, packages.length - MAX_LOCKED) },
2092
2155
  ...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
2093
2156
  const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
2094
- if (!now) return { value: { head, review: null }, kept: false }
2157
+ if (!now) return { value: { head, review: null }, kept: !asked.github }
2095
2158
  // Told by the lockfiles it held to as they were read, though they have
2096
2159
  // changed since.
2097
2160
  const state = { ...now, lockfiles: setting.lockfiles }
2098
2161
  const reviewed = deps.reviewedDependencies ?? null
2099
- const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null } : await baselineFor(fullName, token, repoId, state, baselineKept(deps))
2100
- return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: false }
2101
- })
2162
+ const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
2163
+ : await baselineFor(fullName, token, repoId, state, baselineKept(deps), asked)
2164
+ return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: !asked.github }
2165
+ }, async () => (await deps.db.repoDependencyReview(repoId))?.enabled ? null : { review: null })
2102
2166
  }
2103
2167
 
2104
2168
  // GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
@@ -3205,7 +3269,7 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
3205
3269
  if (outputBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
3206
3270
  parts.push(suffix)
3207
3271
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
3208
- writeResponse(res, Buffer.concat(parts, outputBytes))
3272
+ writeResponseParts(res, parts, outputBytes)
3209
3273
  }
3210
3274
 
3211
3275
  // The server selects the complete published workspace for classification.
@@ -3235,24 +3299,23 @@ async function visibleFindingIds(deps: ManagedHttpDeps, user: StoredUser, report
3235
3299
  }
3236
3300
  const rec = await deps.db.getReport(reportId)
3237
3301
  if (rec == null) return new Set()
3238
- const repo = { github: await repositoryName(deps, rec.repoId) }
3239
- const key = JSON.stringify([reportId, rec.sha256, rec.filename, repo])
3302
+ return reportFindingIds(deps, rec, await repositoryName(deps, rec.repoId))
3303
+ }
3304
+ // A whole report's finding ids, kept by its content, name and repository,
3305
+ // the one read least recently dropped first.
3306
+ async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256: string; filename: string }, github: string | null): Promise<Set<string>> {
3307
+ const key = JSON.stringify([rec.id, rec.sha256, rec.filename, { github }])
3240
3308
  let cache = visibleIdsCaches.get(deps)
3241
3309
  if (cache == null) { cache = new Map(); visibleIdsCaches.set(deps, cache) }
3242
3310
  const hit = cache.get(key)
3243
- if (hit != null) return hit
3244
- const ids = new Set<string>()
3245
- const bytes = await deps.reportStore.get(reportId)
3246
- if (bytes == null) return ids
3247
- const text = bytes.toString('utf8')
3248
- const { data } = readManagedReport(text, rec.filename)
3249
- if (data == null) return ids
3250
- const report = await loadManagedFindings(JSON.stringify(data), rec.filename)
3251
- if (report == null) return ids
3252
- for (const f of report.findings) {
3253
- const id = (f as { id?: unknown }).id
3254
- if (typeof id === 'string' && id !== '') ids.add(id)
3255
- }
3311
+ if (hit != null) { cache.delete(key); cache.set(key, hit); return hit }
3312
+ const bytes = await deps.reportStore.get(rec.id)
3313
+ if (bytes == null) return new Set()
3314
+ const { data } = readManagedReport(bytes.toString('utf8'), rec.filename)
3315
+ if (data == null) return new Set()
3316
+ // The ids the team workspace derives (teamFindingIds), from the one parse.
3317
+ const ids = await reportIds(data)
3318
+ ids.delete('')
3256
3319
  while (cache.size >= VISIBLE_IDS_CACHE_MAX) {
3257
3320
  const oldest = cache.keys().next().value
3258
3321
  if (oldest === undefined) break
@@ -3663,8 +3726,12 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
3663
3726
  // Null for none.
3664
3727
  async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
3665
3728
  const s = await readSession(deps.config, deps.db, cookie, Date.now())
3666
- if (s == null || !roleAtLeast(s.user.role, 'view')) return null
3667
- return { role: s.user.role, scopes: new Set(npmScopesCount(s.user.role, names) ? await deps.db.listUserNpmScopes(s.user.id) : []), userId: s.user.id }
3729
+ return s == null ? null : npmReaderOf(deps, s.user, names)
3730
+ }
3731
+ // The same of `user`, as a session the request read just now tells them.
3732
+ async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
3733
+ if (!roleAtLeast(user.role, 'view')) return null
3734
+ return { role: user.role, scopes: new Set(npmScopesCount(user.role, names) ? await deps.db.listUserNpmScopes(user.id) : []), userId: user.id }
3668
3735
  }
3669
3736
 
3670
3737
  // What a registry read found, or a 404 for the package (handleNpm's catch
@@ -3680,20 +3747,25 @@ function found<T>(value: T | null | undefined): T {
3680
3747
  const REVIEW_READS_AT_ONCE = 8
3681
3748
 
3682
3749
  // Lists must enforce the same target and base access as the review itself,
3683
- // including permission changes while the registry requests are in flight.
3684
- async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[]): Promise<T[] | null> {
3750
+ // including permission changes while the registry requests are in flight;
3751
+ // first as `session`, the one the request read just now, where given.
3752
+ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[],
3753
+ session?: { id: string; role: NpmReader['role'] }): Promise<T[] | null> {
3685
3754
  const controller = new AbortController()
3686
3755
  const onClose = () => { if (!res.writableEnded) controller.abort() }
3687
3756
  res.on('close', onClose)
3688
3757
  try {
3689
- const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
3758
+ const names = reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []])
3759
+ const reader = session ? await npmReaderOf(deps, session, names) : await npmReader(deps, cookie, names)
3690
3760
  if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
3691
3761
  // Public versions' documents as the viewer keeps them (npm-kept.ts), a
3692
- // day: a list asks npm for none it read within it. Each version once, as
3693
- // many at once as REVIEW_READS_AT_ONCE.
3694
- const kept = npmVersionStore(deps.db, deps.config.debug)
3762
+ // day, all read of the database at once: a list asks npm for none it read
3763
+ // within it. Each version once, as many at once as REVIEW_READS_AT_ONCE.
3695
3764
  const versionsOf = (review: T) => [[review.package, review.version], ...review.basePackage ? [[review.basePackage, review.baseVersion!]] : []] as [string, string][]
3696
3765
  const asked = new Map(reviews.flatMap(versionsOf).map(([name, version]) => [`${name}\0${version}`, [name, version] as const]))
3766
+ const store = npmVersionStore(deps.db, deps.config.debug)
3767
+ const held = await store.readMany?.([...asked.values()].map(([name, version]) => ({ name, version }))) ?? new Map()
3768
+ const kept: NpmVersionStore = { read: (name, version) => Promise.resolve(held.get(`${name}@${version}`) ?? null), write: store.write }
3697
3769
  const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
3698
3770
  const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
3699
3771
  const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
@@ -3809,7 +3881,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3809
3881
  try {
3810
3882
  const reader = await npmReader(deps, cookie, [context.package, ...context.basePackage ? [context.basePackage] : []])
3811
3883
  if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3812
- const kept = npmVersionStore(deps.db, deps.config.debug)
3884
+ const kept = readTogether(npmVersionStore(deps.db, deps.config.debug), [{ name, version }, ...context.basePackage ? [{ name: context.basePackage, version: context.baseVersion! }] : []])
3813
3885
  const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
3814
3886
  const [doc, base] = await Promise.all([read(name, version), context.basePackage ? read(context.basePackage, context.baseVersion!) : null])
3815
3887
  if (!doc || (context.basePackage && !base)) { sendJson(res, 404, { error: 'package-not-found' }); return }
@@ -3844,7 +3916,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3844
3916
  if (!change) {
3845
3917
  const allBases = params.get('allBases') === 'true'
3846
3918
  const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
3847
- const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
3919
+ const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows, { id: current.userId, role: current.role }) : rows
3848
3920
  if (reviews) sendJson(res, 200, { reviews: await withFormatted(deps.reviewedDependencies ?? null, current.userId, reviews), teams: await deps.db.listDependencyReviewTeams(current.userId) })
3849
3921
  return
3850
3922
  }
@@ -3949,7 +4021,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3949
4021
  await reply(doc.private, { name, downloads, github })
3950
4022
  return
3951
4023
  }
3952
- const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptVersions))
4024
+ // The version it is compared with, where its route names one, read
4025
+ // beside it, the two read of those kept at once.
4026
+ const against: [string, string, boolean] | null = (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)') || (path === NPM_DELTA_PATH && otherSpec !== '(empty)')
4027
+ || ((path === NPM_TYPE_ONLY_PATH || path === NPM_RENAME_DIFF_PATH || (path === NPM_PRETTY_PATH && !params.has('path'))) && otherSpec !== '')
4028
+ ? [name, otherSpec, privileged]
4029
+ : path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' ? [otherName, otherSpec, canReadPrivateNpm(reader, otherName)] : null
4030
+ const keptTogether = against ? readTogether(keptVersions, [{ name, version: spec }, { name: against[0], version: against[1] }]) : keptVersions
4031
+ const otherAsked = against && readNpmVersion(against[0], against[1], against[2], controller.signal, keptTogether)
4032
+ otherAsked?.catch(() => {})
4033
+ const otherDoc = async () => found(await otherAsked!)
4034
+ const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptTogether))
3953
4035
  if (spec === 'latest') latest = Promise.resolve(doc)
3954
4036
  const repo = githubRepoOf(doc), sha = bundleCommitHash(doc.manifest['gitHead'])
3955
4037
  // What the viewer asks about a version beside its files, by its route,
@@ -3972,7 +4054,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3972
4054
  // new and however few download it where a team lists its scope, its own
3973
4055
  // packages, as a new dependency on a package of such a scope is.
3974
4056
  if (path === NPM_RISK_PATH) {
3975
- const old = otherSpec === '' || otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4057
+ const old = otherSpec === '' || otherSpec === '(empty)' ? null : await otherDoc()
3976
4058
  const scope = npmPackageScope(name)
3977
4059
  const teamScoped = scope !== null && await deps.db.hasTeamNpmScope(scope)
3978
4060
  const assessment = await npmRisk(doc, otherSpec === '' ? 'pick' : old, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped, fromEmpty: otherSpec === '(empty)',
@@ -3987,7 +4069,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3987
4069
  // package's, and those but for declaration files changed in types alone
3988
4070
  // (npm-delta.ts), as a list of versions wanting reviews tells them.
3989
4071
  if (path === NPM_DELTA_PATH) {
3990
- const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4072
+ const old = otherSpec === '(empty)' ? null : await otherDoc()
3991
4073
  res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
3992
4074
  const side = (version: NpmVersionDocument) => ({ integrity: version.dist.integrity, files: async () => {
3993
4075
  const load = loadNpmFiles(version)
@@ -4003,7 +4085,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4003
4085
  // the one compared with, or `(empty)`, none, as Review reads a first
4004
4086
  // version. Read of both, each package's access checked again once worked out.
4005
4087
  if (path === NPM_COMPARISON_PATH) {
4006
- const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(otherName, otherSpec, canReadPrivateNpm(reader, otherName), controller.signal, keptVersions))
4088
+ const old = otherSpec === '(empty)' ? null : await otherDoc()
4007
4089
  res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
4008
4090
  const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
4009
4091
  let body
@@ -4041,7 +4123,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4041
4123
  // and those that changed in types alone (type-only.ts).
4042
4124
  if (path === NPM_PRETTY_PATH || path === NPM_TYPE_ONLY_PATH || (path === NPM_RENAME_DIFF_PATH && !diffed)) {
4043
4125
  if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
4044
- const other = otherSpec === '' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4126
+ const other = otherSpec === '' ? null : await otherDoc()
4045
4127
  const sides = other && await deps.prettyCache.npmSides(doc, other)
4046
4128
  const lines = path === NPM_RENAME_DIFF_PATH ? renamedPairs(sides!, !doc.private && !other!.private)
4047
4129
  : path === NPM_TYPE_ONLY_PATH ? typeOnlyFiles(sides!) : sides ? compared(sides) : await deps.prettyCache.npmAll(doc)
@@ -4059,7 +4141,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4059
4141
  if (diffed) {
4060
4142
  const { prettyCache } = deps
4061
4143
  if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
4062
- const other = found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4144
+ const other = await otherDoc()
4063
4145
  const diff = await renameDiffOf(diffed, async () => [await prettyText(await prettyCache.npm(doc, diffed.base)),
4064
4146
  await prettyText(await prettyCache.npm(other, diffed.other))], !doc.private && !other.private)
4065
4147
  if (!(await recheck(doc.private || other.private))) { diff.stream.destroy(); return }
@@ -4079,18 +4161,20 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4079
4161
  // viewer's own request to meet. Its whole document, read for its
4080
4162
  // advisories' version list and the time each version was published
4081
4163
  // (npmPublishTime), is read only where registry reads leave room for
4082
- // readers'.
4083
- if (!download) {
4164
+ // readers'; a public version's time is then kept in the database
4165
+ // (npm-kept.ts), to come with the version on any instance.
4166
+ const published = download ? undefined : npmPublishTime(doc, npmPublishTimeStore(deps.db, deps.config.debug))
4167
+ if (published) {
4084
4168
  const quiet = (asked: Promise<unknown>) => { asked.catch(() => {}) }
4085
4169
  quiet(npmDownloads(name))
4086
4170
  quiet(latestDoc().then(newest => newest && githubFigures(newest)))
4087
4171
  quiet(readNpmVersions(name, privileged, controller.signal, { times: true, spare: true })
4088
- .then(versions => versions && npmAdvisories(name, versions.versions, advisoryOptions)))
4172
+ .then(versions => versions && Promise.all([published.keep(), npmAdvisories(name, versions.versions, advisoryOptions)])))
4089
4173
  quiet(tagsOf())
4090
4174
  quiet(socketOf())
4091
4175
  quiet(provenanceOf())
4092
4176
  }
4093
- const load = download ? loadNpmTarball(doc) : loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha))
4177
+ const load = published ? loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha), published.read) : loadNpmTarball(doc)
4094
4178
  res.once('close', load.release)
4095
4179
  const bytes = await load.result
4096
4180
  if (!(await recheck(doc.private))) return
@@ -4562,7 +4646,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4562
4646
  sendJson(res, 400, { error: 'bad-review-context' }); return
4563
4647
  }
4564
4648
  const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
4565
- const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
4649
+ const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [], s.user)
4566
4650
  if (visible) sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, s.user.id, visible))[0] ?? null })
4567
4651
  return
4568
4652
  }
@@ -4592,7 +4676,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4592
4676
  sendJson(res, 400, { error: 'bad-review-cursor' }); return
4593
4677
  }
4594
4678
  const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
4595
- const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
4679
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
4596
4680
  if (reviews) sendJson(res, 200, { ...page, reviews: await withFormatted(deps.reviewedDependencies ?? null, s.user.id, reviews) })
4597
4681
  return
4598
4682
  }
@@ -4609,7 +4693,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4609
4693
  }
4610
4694
  const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
4611
4695
  if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
4612
- const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
4696
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
4613
4697
  if (!reviews) return
4614
4698
  // Its access asked again, as it may have been taken while npm was read.
4615
4699
  if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
@@ -4634,7 +4718,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4634
4718
  // Feeds lease a connection per poll, never across streaming sleeps. This
4635
4719
  // also covers capability-authenticated feeds handled by public-workspace.
4636
4720
  const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
4637
- const serve = () => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)
4721
+ const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
4638
4722
  const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
4639
4723
  if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
4640
4724
  console.warn('managed: request handler error:', err)