@preventive/triage 1.0.0-alpha.45 → 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 (110) 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/common/managed/agent-tasks.ts +5 -3
  7. package/common/managed/github-agents.ts +28 -10
  8. package/out/activity.js +4 -0
  9. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  10. package/out/chunk-5AHIQFFG.js +36 -0
  11. package/out/chunk-6ANSHRFQ.js +6 -0
  12. package/out/chunk-6WF3KNTL.js +3 -0
  13. package/out/chunk-7BKKYVWR.js +7 -0
  14. package/out/{chunk-HMAJDVXN.js → chunk-FH24VHSJ.js} +1 -1
  15. package/out/chunk-NOZJULWS.js +52 -0
  16. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  17. package/out/chunk-S6U2J62U.js +232 -0
  18. package/out/chunk-SPEFPML6.js +2 -0
  19. package/out/chunk-UENXT2HK.js +3 -0
  20. package/out/chunk-VL43BTSO.js +1 -0
  21. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  22. package/out/chunk-ZCS4UYHI.js +1 -0
  23. package/out/chunk-ZGNMDGSB.js +1 -0
  24. package/out/client-managed-import.js +1 -1
  25. package/out/client-managed.js +15 -15
  26. package/out/client-sync.js +2 -2
  27. package/out/compare.js +12 -12
  28. package/out/dialogs.js +28 -28
  29. package/out/graph.js +11 -11
  30. package/out/index.html +11 -11
  31. package/out/scan.js +2 -2
  32. package/out/terminal.js +6 -6
  33. package/out/view.css +1 -1
  34. package/out/view.js +15 -15
  35. package/package.json +4 -1
  36. package/risk/actions/findings.js +14 -9
  37. package/risk/actions/scripts/shell.js +5 -1
  38. package/risk/index.d.ts +9 -4
  39. package/risk/npm/assess.js +15 -4
  40. package/risk/npm/registry.js +6 -1
  41. package/server-e2e/app.ts +5 -4
  42. package/server-e2e/http.ts +5 -2
  43. package/server-managed/activity.ts +24 -11
  44. package/server-managed/app-agents.ts +10 -21
  45. package/server-managed/bundle-build-worker.js +7 -3
  46. package/server-managed/bundle-build.ts +5 -3
  47. package/server-managed/bundle-cache.ts +16 -4
  48. package/server-managed/bundle.ts +3 -4
  49. package/server-managed/combined.ts +3 -2
  50. package/server-managed/commit-coauthors.ts +5 -9
  51. package/server-managed/commit-lockfiles.ts +21 -11
  52. package/server-managed/config.ts +6 -0
  53. package/server-managed/db-methods.ts +321 -156
  54. package/server-managed/db-neon.ts +71 -24
  55. package/server-managed/db-schema.ts +4 -0
  56. package/server-managed/db.ts +2 -1
  57. package/server-managed/dependency-reviews.ts +13 -3
  58. package/server-managed/github-app.ts +12 -6
  59. package/server-managed/github-issue-links.ts +11 -5
  60. package/server-managed/github-pulls.ts +2 -2
  61. package/server-managed/github-requests.ts +39 -0
  62. package/server-managed/http-response.ts +10 -2
  63. package/server-managed/http.ts +269 -143
  64. package/server-managed/index.ts +8 -7
  65. package/server-managed/kept-briefly.ts +37 -0
  66. package/server-managed/link-reports.ts +5 -1
  67. package/server-managed/npm-delta.ts +11 -6
  68. package/server-managed/npm-kept.ts +51 -7
  69. package/server-managed/npm-loads.ts +117 -35
  70. package/server-managed/npm-packages.ts +107 -14
  71. package/server-managed/npm-risk.ts +68 -18
  72. package/server-managed/pretty-print.ts +112 -35
  73. package/server-managed/pull-dependencies.ts +29 -9
  74. package/server-managed/pull-dependency-reviews.ts +8 -8
  75. package/server-managed/queue.ts +16 -0
  76. package/server-managed/rename-diff.ts +22 -13
  77. package/server-managed/report-sources.ts +1 -1
  78. package/server-managed/report-store.ts +50 -12
  79. package/server-managed/repository-actions.ts +15 -6
  80. package/server-managed/repository-agents.ts +1 -1
  81. package/server-managed/repository-browser.ts +123 -33
  82. package/server-managed/repository-code.ts +28 -11
  83. package/server-managed/repository-dependency-reviews.ts +41 -19
  84. package/server-managed/repository-history.ts +24 -8
  85. package/server-managed/repository-pulse.ts +9 -2
  86. package/server-managed/repository-risk-audit.ts +13 -0
  87. package/server-managed/repository-risk-read.ts +11 -6
  88. package/server-managed/repository-risk.ts +27 -20
  89. package/server-managed/session.ts +18 -4
  90. package/server-managed/solidity-entry-points.ts +5 -3
  91. package/server-managed/sql-postgres.ts +3 -0
  92. package/server-managed/sql.ts +6 -2
  93. package/server-managed/storage-db.ts +11 -3
  94. package/server-managed/storage-encryption.ts +7 -4
  95. package/server-managed/storage.ts +8 -4
  96. package/server-managed/team-feed.ts +3 -1
  97. package/server-managed/team-reports.ts +30 -13
  98. package/server-managed/uploads.ts +29 -9
  99. package/server-managed/upstream-cache.ts +12 -0
  100. package/server.ts +14 -5
  101. package/out/chunk-5DHL5NG3.js +0 -2
  102. package/out/chunk-6HNXNIQY.js +0 -52
  103. package/out/chunk-AFLZH7WG.js +0 -1
  104. package/out/chunk-NV7ES4EW.js +0 -1
  105. package/out/chunk-PTGDYLNS.js +0 -36
  106. package/out/chunk-Q6MVYGYV.js +0 -3
  107. package/out/chunk-QRD6GIG5.js +0 -229
  108. package/out/chunk-WI7Z5A2T.js +0 -6
  109. package/out/chunk-WQ74ZBZF.js +0 -7
  110. package/out/chunk-XVBEOGOS.js +0 -3
@@ -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, ownKeptPrunes, 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'
@@ -150,7 +151,7 @@ import { contentAccess } from './content-access.ts'
150
151
  import type { BundleStore } from './bundle-store.ts'
151
152
  import { Buffer } from 'node:buffer'
152
153
  import { createHash, randomUUID } from 'node:crypto'
153
- import type { AvatarStore } from './avatar-store.ts'
154
+ import type { AvatarStore, CachedAvatar } from './avatar-store.ts'
154
155
  import type { BlobStore, OpenedBlob } from './blob-store.ts'
155
156
  import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
156
157
  import { resolveRepositoryImportLocation } from './repository-aliases.ts'
@@ -180,8 +181,9 @@ import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines
180
181
  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'
184
+ import { type Lru, lru } from './lru.ts'
183
185
  import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
184
- import { clearCookie, endSession, readSession } from './session.ts'
186
+ import { clearCookie, endSession, readSession, runRequestPresence } from './session.ts'
185
187
  import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
186
188
  import type { ActivityContext, ActivityInput } from './activity.ts'
187
189
  import { acceptsReportMetadata } from './report-response.ts'
@@ -196,7 +198,7 @@ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallb
196
198
  import type { ProfileChoice } from './github-links.ts'
197
199
  import { withAppUsers } from './github-users.ts'
198
200
  import { isBlobId } from './object-storage.ts'
199
- import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
201
+ import { attachmentDisposition, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
200
202
  import { triageWireEntry } from './triage-response.ts'
201
203
  import { handlePublicWorkspace } from './public-workspace.ts'
202
204
  import { serveUserTeamFeed } from './team-feed.ts'
@@ -205,7 +207,8 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
205
207
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
206
208
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
207
209
  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'
210
+ import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions,
211
+ readTogether } from './npm-packages.ts'
209
212
  import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
210
213
  import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
211
214
  import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
@@ -214,8 +217,8 @@ import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE,
214
217
  repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
215
218
  import { AGENTS_READ_VERSION, APP_AGENTS, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
216
219
  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'
220
+ import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
221
+ import { type GithubAsked, lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
219
222
  import { MAX_REQUIRED_REVIEWS, SINCE_COMMIT } from './repository-dependency-reviews.ts'
220
223
  import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
221
224
  import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
@@ -335,7 +338,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
335
338
  if (!s) return
336
339
  const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
337
340
  const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
338
- const manualUrls = storedFixUrls(await deps.db.listTriage(ids))
341
+ const manualUrls = storedFixUrls(await deps.db.listTriageFixes(ids))
339
342
  const storedIssues = await deps.db.listManagedIssues(ids)
340
343
  const issues = visibleManagedIssues(storedIssues, snapshot)
341
344
  const visibleIds = new Set(issues.map(issue => issue.findingId))
@@ -346,7 +349,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
346
349
  : { fixes: await lookupFixes(deps.config, deps.db, snapshot, urls), updates: [] }
347
350
  // Cold report reads, token refresh and upstream batches can outlive changes
348
351
  // to security/links, team grants, publication, sessions or the Fix links.
349
- const currentUrls = storedFixUrls(await deps.db.listTriage(ids))
352
+ const currentUrls = storedFixUrls(await deps.db.listTriageFixes(ids))
350
353
  if (await readWorkspaceSession(res, deps, cookie) == null) return
351
354
  await recheckTeam(deps.db, s.session.id, snapshot)
352
355
  if (JSON.stringify(currentUrls) !== JSON.stringify(manualUrls)) {
@@ -385,9 +388,8 @@ function activity(deps: ManagedHttpDeps, user: StoredUser, kind: ActivityInput['
385
388
  return deps.db.recordActivity({ kind, actor: user.login, actorId: user.id, action, ...context }, Date.now())
386
389
  }
387
390
 
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
391
+ function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
392
+ return repoId == null ? Promise.resolve(null) : deps.db.getRepoFullName(repoId)
391
393
  }
392
394
 
393
395
  // Workspace history is authorized BEFORE filtering, counting, or paging.
@@ -411,8 +413,14 @@ async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie:
411
413
  if (s.user.role !== 'admin') {
412
414
  const findings = new Map<string, ActivityContext>()
413
415
  const reports = kind === 'all' || kind === 'triage' ? await deps.db.listActivityReports(s.user.id) : []
416
+ // Every report's record in one read, as visibleFindingIds reads each one's.
417
+ const snapshot = reports.length > 0 ? await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), reports.map(report => report.reportId)) : { reports: [] }
418
+ if (snapshot == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
419
+ const records = new Map(snapshot.reports.map(record => [record.id, record]))
414
420
  for (const report of reports) {
415
- for (const finding of await visibleFindingIds(deps, s.user, report.reportId, s.session.id, null)) {
421
+ const record = records.get(report.reportId)
422
+ if (record == null) continue
423
+ for (const finding of await reportFindingIds(deps, record, record.repo.github)) {
416
424
  if (!findings.has(finding)) findings.set(finding, { finding, ...report })
417
425
  }
418
426
  }
@@ -436,6 +444,9 @@ export interface ManagedHttpDeps {
436
444
  originGate: OriginGate
437
445
  isShuttingDown: () => boolean
438
446
  track: (p: Promise<unknown>) => void
447
+ // Serverless (Vercel's): keeps the invocation alive for work that goes on
448
+ // past its answer, as a kept read read again behind it does.
449
+ waitUntil?: (p: Promise<unknown>) => void
439
450
  // Combined boot delegates unmatched paths to e2e and overrides discovery.
440
451
  serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
441
452
  next?: Handler
@@ -458,14 +469,18 @@ function firstHeader(v: string | string[] | undefined): string | null {
458
469
  }
459
470
 
460
471
  // `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
461
- async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id): Promise<void> {
462
- 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 {
463
478
  if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
464
479
  res.writeHead(200, {
465
480
  'content-type': avatar.contentType,
466
481
  'content-length': String(avatar.bytes.length),
467
482
  'x-content-type-options': 'nosniff',
468
- 'cache-control': 'no-store',
483
+ 'cache-control': cacheControl,
469
484
  })
470
485
  res.end(avatar.bytes)
471
486
  }
@@ -479,15 +494,20 @@ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: st
479
494
  const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
480
495
  .flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
481
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'
482
501
 
483
502
  async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
484
503
  const [, kind, id] = AGENT_AVATAR.exec(avatar)!
485
504
  const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
486
- if (await avatarStore.get(key) == null) {
487
- await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
488
- .catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
489
- }
490
- 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)
491
511
  }
492
512
 
493
513
  const MAX_JSON_BODY_BYTES = 4096
@@ -508,14 +528,17 @@ async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Bu
508
528
  }
509
529
 
510
530
  // A file upload's content, opening a body the browser sealed to this session
511
- // (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.
512
533
  async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: ManagedSession, kind: UploadKind | 'links'): Promise<Buffer> {
513
534
  const maxBytes = kind === 'bundles' ? deps.config.maxBundleBytes : deps.config.maxReportBytes
514
535
  const sealed = req.headers[UPLOAD_SEAL_HEADER] !== undefined
515
536
  const limit = sealed ? maxSealedBytes(maxBytes) : maxBytes
516
537
  let bytes
517
538
  if (kind === 'links' || req.headers['x-upload-id'] == null) bytes = await readBodyBytes(req, limit)
518
- 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
+ }
519
542
  else throw new Error('bad-upload')
520
543
  return sealed ? await openSessionUpload(session.uploadKey, bytes, maxBytes) : bytes
521
544
  }
@@ -630,7 +653,7 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
630
653
  const userId = (body as { userId?: unknown } | null)?.userId
631
654
  if (typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
632
655
  if (userId === s.user.id) { sendJson(res, 400, { error: 'cannot-view-as-self' }); return }
633
- const target = (await deps.db.listUsers()).find(user => user.id === userId)
656
+ const target = await deps.db.getUser(userId)
634
657
  if (!target) { sendJson(res, 404, { error: 'not-found' }); return }
635
658
  // The insert itself rechecks the admin session and role.
636
659
  const setCookie = await startViewSession(deps.config, deps.db, s.session, userId, Date.now())
@@ -713,7 +736,7 @@ async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: Ma
713
736
  const role = (body as { role?: unknown } | null)?.role
714
737
  if (typeof userId !== 'string' || !isRole(role)) { sendJson(res, 400, { error: 'bad-request' }); return }
715
738
  if (userId === s.user.id) { sendJson(res, 403, { error: 'cannot-change-own-role' }); return }
716
- const target = (await deps.db.listUsers()).find(user => user.id === userId)
739
+ const target = await deps.db.getUser(userId)
717
740
  // A caller can hold the body open while their admin access is revoked.
718
741
  if (await readAdminSession(res, deps, cookie) == null) return
719
742
  const ok = await deps.db.setUserRole(userId, role)
@@ -1250,8 +1273,8 @@ async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, ses
1250
1273
  // declared integrity that has a stored bundle wins (bundleId set). When none is
1251
1274
  // stored yet, keep the first declared integrity so a later bundle upload of it
1252
1275
  // re-links (see linkReportsToBundle). Returns the (bundleId, integrity) to store.
1253
- async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, bytes: Buffer): Promise<{ bundleId: string | null; integrity: string | null }> {
1254
- 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)
1255
1278
  for (const h of hashes) {
1256
1279
  const b = await deps.db.getBundleByIntegrity(h)
1257
1280
  if (b != null && await canAccessBundle(deps, user, b.id)) return { bundleId: b.id, integrity: h }
@@ -1361,7 +1384,8 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
1361
1384
  if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
1362
1385
  const id = randomUUID()
1363
1386
  const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
1364
- 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)
1365
1389
  const dataKey = await deps.reportStore.put(id, bytes)
1366
1390
  let report: ReportRecord
1367
1391
  try {
@@ -1451,8 +1475,10 @@ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
1451
1475
  // user's repository access, for the next catalog to send.
1452
1476
  async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, missing: readonly CatalogCommit[]): Promise<void> {
1453
1477
  let browser: ReturnType<typeof createRepositoryBrowser> | undefined
1478
+ let repos: ReturnType<ManagedDb['listAllRepos']> | undefined
1454
1479
  await backfillBundleCommits(deps.db, userId, missing, async repoId => {
1455
- 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)
1456
1482
  if (!repo) return null
1457
1483
  browser ??= createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, userId))
1458
1484
  return browser.reader(repo)
@@ -1524,9 +1550,11 @@ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
1524
1550
  async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
1525
1551
  const s = await readWorkspaceSession(res, deps, cookie)
1526
1552
  if (!s) return null
1527
- 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)
1528
1556
  if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1529
- 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)
1530
1558
  return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
1531
1559
  }
1532
1560
 
@@ -1592,6 +1620,22 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1592
1620
  // read, before any of it is sent. The load stays held until this response is
1593
1621
  // written or abandoned, and a reader who stops reading it gives it up after
1594
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
+
1595
1639
  async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1596
1640
  const asked = repositoryQuery(res, query)
1597
1641
  if (!asked) return
@@ -1608,10 +1652,12 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
1608
1652
  const commit = await reader.commit(ref)
1609
1653
  const repo = access.repo.fullName
1610
1654
  const client = createClient({ token: reader.readToken() })
1655
+ const treeKey = `${access.repo.repoId}\0${commit}\0${path}`, trees = repositoryTreesOf(deps.db)
1611
1656
  // A directory the commit has none at is none to read, as browsing it says.
1612
- 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) => {
1613
1658
  throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
1614
1659
  }))
1660
+ trees.ids.set(treeKey, tree)
1615
1661
  // Access still held, and the same repository, before anything read is sent.
1616
1662
  const fenced = async () => {
1617
1663
  await reader.recheckAccess()
@@ -1621,12 +1667,15 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
1621
1667
  return true
1622
1668
  }
1623
1669
  // Too large names the commit it was read at, for History to read.
1624
- 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)) {
1625
1674
  if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
1626
1675
  return
1627
1676
  }
1628
1677
  if (res.destroyed) return
1629
- 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 })))
1630
1679
  res.once('close', load.release)
1631
1680
  const bytes = await load.result
1632
1681
  if (!(await fenced()) || res.destroyed) return
@@ -1679,12 +1728,22 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1679
1728
  }
1680
1729
  const access = await authorize()
1681
1730
  if (!access) return
1731
+ let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
1682
1732
  try {
1683
1733
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1684
1734
  const repo = access.repo.fullName
1685
1735
  const token = reader.readToken()
1686
1736
  // With a token, as GraphQL reads them.
1687
1737
  const pages = historyPages(deps, repoId, token !== null)
1738
+ const quiet = <T>(fallback: T) => (err: unknown) => {
1739
+ if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
1740
+ return fallback
1741
+ }
1742
+ // Open pull heads do not depend on the commit history. Start them with
1743
+ // it, handling failures immediately even if the history fails first.
1744
+ if (token && path === '' && page === 1) {
1745
+ readingPulls = keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null))
1746
+ }
1688
1747
  let body: object
1689
1748
  if (all) {
1690
1749
  if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
@@ -1719,13 +1778,9 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1719
1778
  // naming commits: with a token, as far as GitHub answers them, the
1720
1779
  // history sent all the same where it doesn't.
1721
1780
  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
1781
  const [pulls, openPulls] = token && path === '' ? await Promise.all([
1727
1782
  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,
1783
+ readingPulls,
1729
1784
  ]) : [[], null]
1730
1785
  await reader.recheckAccess()
1731
1786
  const current = await authorize()
@@ -1740,6 +1795,10 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1740
1795
  } catch (err) {
1741
1796
  if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1742
1797
  throw err
1798
+ } finally {
1799
+ // A failed history must not release the request's database lease while
1800
+ // its independent pull read is still writing to the cache.
1801
+ await readingPulls
1743
1802
  }
1744
1803
  }
1745
1804
 
@@ -1840,9 +1899,9 @@ const refusedRead = (err: unknown) => err instanceof GithubApiError
1840
1899
  // What `read` answers of a repository, kept by `key` (its id first) for every
1841
1900
  // reader (repositoryReadStore), each one's access checked as they ask before
1842
1901
  // 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
1902
+ // kept, answered at once and read again behind it for the next to ask, the
1903
+ // read tracked (serverless, its invocation kept alive till it settles,
1904
+ // deps.waitUntil); `kept` where it was, or where reading it again
1846
1905
  // failed as `keepThrough` lets it. One asked for afresh (`fresh`) is read
1847
1906
  // now, unless it was read REPOSITORY_READ_FLOOR_MS since, what was kept
1848
1907
  // answered where that read fails. One GitHub refuses to read again is no
@@ -1877,10 +1936,12 @@ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string,
1877
1936
  }
1878
1937
  const age = held ? Date.now() - held.at : Infinity
1879
1938
  const now = fresh && age >= REPOSITORY_READ_FLOOR_MS
1880
- if (held && !now && (age < REPOSITORY_READ_FRESH_MS || !deps.config.serverless)) {
1939
+ if (held && !now) {
1881
1940
  if (age >= REPOSITORY_READ_FRESH_MS && !repositoryRereads.has(key)) {
1882
1941
  // 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) }))
1942
+ const behind = reread().catch((err: unknown) => { console.warn('managed: repository read failed:', err) })
1943
+ deps.track(behind)
1944
+ deps.waitUntil?.(behind)
1884
1945
  }
1885
1946
  return { value: held.value as T, kept: true, at: held.at }
1886
1947
  }
@@ -1902,14 +1963,16 @@ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string,
1902
1963
  function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): HistoryPages {
1903
1964
  const store = historyPageStore<HistoryCommit[]>(deps.db, deps.config.debug)
1904
1965
  const keyOf = (top: string) => `${repoId}\0${COAUTHORS_READ}\0${top}`
1966
+ const told = (tops: string[], kept: Map<string, { value: HistoryCommit[] }>) => new Map(tops.flatMap(top => {
1967
+ const page = kept.get(keyOf(top))?.value
1968
+ return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
1969
+ ? [[top, page] as const] : []
1970
+ }))
1905
1971
  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
- }))
1972
+ read: async tops => told(tops, await store.readMany(tops.map(keyOf))),
1973
+ async readFirst(first, then) {
1974
+ const kept = await store.readFirst(first.map(keyOf), then.map(keyOf))
1975
+ return { first: kept.first, pages: told(kept.first ? first : then, kept.found) }
1913
1976
  },
1914
1977
  write: (top, commits) => store.write(keyOf(top), commits, Date.now()),
1915
1978
  }
@@ -1955,9 +2018,11 @@ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, c
1955
2018
  // What `answer` gives of the whole of repository `repoId` (with its name and
1956
2019
  // a token), as readWholeRepository reads it: sent at once where it was kept,
1957
2020
  // else once access is checked again, with the app's users it names
1958
- // (github-users.ts), never kept with it.
2021
+ // (github-users.ts), never kept with it; or what `unread` tells of it, the
2022
+ // app's alone, once the app's access is checked, GitHub then never asked.
1959
2023
  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> {
2024
+ answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
2025
+ unread: () => Promise<object | null> = () => Promise.resolve(null)): Promise<void> {
1961
2026
  const authorize = async () => {
1962
2027
  const access = await authorizeRepository(res, deps, cookie, repoId, '')
1963
2028
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
@@ -1965,6 +2030,8 @@ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps,
1965
2030
  }
1966
2031
  const access = await authorize()
1967
2032
  if (!access) return
2033
+ const told = await unread()
2034
+ if (told) { sendJson(res, 200, await withAppUsers(deps.db, told)); return }
1968
2035
  try {
1969
2036
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1970
2037
  const token = reader.readToken()
@@ -2033,72 +2100,108 @@ function itemNumber(query: URLSearchParams): number | null {
2033
2100
  // GET /api/admin/repositories/pull-dependencies: the package versions pull
2034
2101
  // request `number`'s lockfiles add (pull-dependencies.ts), what its base's and head's
2035
2102
  // 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`,
2103
+ // They are the whole repository's (answerWholeRepository), the commits they
2104
+ // are of kept by its number as its popup's item is, its head moving with each
2105
+ // push, what they tell kept by them alone, not again. With `review=1`,
2038
2106
  // as an open one's are asked, where the repository reviews dependencies,
2039
2107
  // each version's review (pull-dependency-reviews.ts), read afresh as each
2040
2108
  // asks, reviews being published meanwhile; its baseline kept, where the host
2041
2109
  // registered no reviewed dependencies, which are asked in its place.
2110
+ // Up to MAX_ASKED_PULLS pull requests asked together (`numbers=1,2,3`) are
2111
+ // answered together, as many read at once as PULLS_AT_ONCE, `{ pulls }`,
2112
+ // each's answer, else `{ number, error }` where GitHub fails it, with one
2113
+ // check of its access before and one after, but where each was kept.
2114
+ const MAX_ASKED_PULLS = 20
2115
+ const PULLS_AT_ONCE = 3
2042
2116
  async function handleRepositoryPullDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2043
2117
  const repoId = Number(query.get('repoId'))
2044
- const number = itemNumber(query)
2045
2118
  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 }
2119
+ const listed = query.get('numbers')
2120
+ const numbers = listed === null ? [itemNumber(query)] : listed.split(',').map(text => itemNumber(new URLSearchParams({ number: text })))
2121
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || numbers.length > MAX_ASKED_PULLS || numbers.some(number => number === null) || new Set(numbers).size < numbers.length
2122
+ || !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
2047
2123
  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 }
2124
+ if (listed === null) return pullDependencyAnswer(deps, fullName, token, user, repoId, numbers[0]!, review)
2125
+ const answers = await eachOf(numbers as number[], PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, review).catch((err: unknown) => {
2126
+ // GitHub asked, though it failed: its access asked again after.
2127
+ if (err instanceof GithubApiError) return { value: { number, error: err.message }, kept: false }
2128
+ throw err
2129
+ }))
2130
+ return { value: { pulls: answers.map(({ value }) => value) }, kept: answers.every(({ kept }) => kept) }
2069
2131
  })
2070
2132
  }
2071
2133
 
2134
+ // Pull request `number`'s answer, as the endpoint gives it, and whether all
2135
+ // of it was kept, GitHub asked for none of it.
2136
+ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, number: number, review: boolean) {
2137
+ const kept = pullDependencyKept(deps, repoId)
2138
+ const reading: { told?: PullDependencies } = {}
2139
+ const answered = await keptAnswer(deps, `${repoId}\0pull-commits\0${PULL_DEPENDENCIES_READ}\0${number}`, async () => {
2140
+ const { commits, told } = await pullDependenciesAt(fullName, token, number, kept)
2141
+ reading.told = told
2142
+ return { commits }
2143
+ })
2144
+ // What its commits tell, as read, else as kept by them, else, no longer
2145
+ // kept, read again.
2146
+ const held = reading.told ?? (typeof answered.value.commits === 'string' ? await kept.read(answered.value.commits) : null)
2147
+ const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept)).told }
2148
+ // Where its review is on, those of lockfiles it leaves out told to none,
2149
+ // whoever asks.
2150
+ const setting = await deps.db.repoDependencyReview(repoId)
2151
+ const scoped = setting?.enabled ? heldDependencies(pulled, setting.lockfiles) : pulled
2152
+ const now = review ? await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(scoped)) : null
2153
+ // Its review by the lockfiles they were told by, though they have changed
2154
+ // since; turned off meanwhile, all of them, as where it is off.
2155
+ const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
2156
+ const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
2157
+ const reviewed = deps.reviewedDependencies ?? null
2158
+ const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
2159
+ // Its comments read for reviews, where the host reads them, while it is
2160
+ // open (pull-comment-reviews.ts reads whether it is with them).
2161
+ const parse = reviewed?.parseComments
2162
+ const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
2163
+ // The comment the host formats of the reader's reviews of its versions, for its copy button.
2164
+ const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
2165
+ return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
2166
+ ...formatted ? { formatted } : {} }, kept: answered.kept && held !== null }
2167
+
2168
+ }
2169
+
2072
2170
  // GET /api/admin/repositories/branch-dependencies: what the lockfiles of the
2073
2171
  // 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.
2172
+ // effect for the reader (`review`, else none, told without asking GitHub
2173
+ // where it reviews none), as a team's Dependencies tab shows it: its head, as
2174
+ // its pulse reads it, kept a while; every lockfile there its review holds to
2175
+ // (lockfile-scope.ts, commit-lockfiles.ts) as one directory, its versions as
2176
+ // a pull request's others are (pull-dependency-reviews.ts), each left unread
2177
+ // a directory of its own failing the review, as a pull request's are. Kept,
2178
+ // its access not asked again, where its head was kept and its lockfiles and
2179
+ // baseline held, GitHub asked for none of them.
2080
2180
  async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2081
2181
  const repoId = Number(query.get('repoId'))
2082
2182
  if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2083
2183
  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
2184
+ const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2185
+ const head = headRead.value.sha
2085
2186
  if (!head) return { value: { head: null, review: null }, kept: false }
2187
+ const asked: GithubAsked = { github: !headRead.kept }
2086
2188
  // Those its review holds to alone read, as many as are read of them.
2087
- const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
2189
+ const setting = await deps.db.repoDependencyReview(repoId)
2088
2190
  if (!setting?.enabled) return { value: { head, review: null }, kept: false }
2089
- const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path))
2191
+ const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path), asked)
2090
2192
  const { packages, paths } = lockedIn(read)
2091
2193
  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
2194
  ...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
2093
2195
  const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
2094
- if (!now) return { value: { head, review: null }, kept: false }
2196
+ if (!now) return { value: { head, review: null }, kept: !asked.github }
2095
2197
  // Told by the lockfiles it held to as they were read, though they have
2096
2198
  // changed since.
2097
2199
  const state = { ...now, lockfiles: setting.lockfiles }
2098
2200
  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
- })
2201
+ const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
2202
+ : await baselineFor(fullName, token, repoId, state, baselineKept(deps), asked)
2203
+ return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: !asked.github }
2204
+ }, async () => (await deps.db.repoDependencyReview(repoId))?.enabled ? null : { review: null })
2102
2205
  }
2103
2206
 
2104
2207
  // GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
@@ -2699,9 +2802,8 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
2699
2802
  throw new BundleBuildError(409, 'repository-changed')
2700
2803
  }
2701
2804
  signal.throwIfAborted()
2702
- const bytes = Buffer.from(built.bytes)
2703
- await storeUploadedBundle(req, res, deps, cookie, current, bytes, input.repoId, built.directory, built.filename, 'build',
2704
- 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)
2705
2807
  })
2706
2808
  } catch (error) {
2707
2809
  if (res.destroyed || controller.signal.aborted) return
@@ -2782,15 +2884,17 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
2782
2884
  repo.repoId = null
2783
2885
  directory = ''
2784
2886
  }
2785
- 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)
2786
2888
  }
2787
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.
2788
2892
  async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
2789
2893
  s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
2790
- 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> {
2791
2895
  const kind = bundleKind(filename)
2792
2896
  const reuse = { provenance, filename, kind, buildConditions }
2793
- const existing = await deps.db.getBundleByIntegrity(integrity)
2897
+ const existing = absent ? null : await deps.db.getBundleByIntegrity(integrity)
2794
2898
  if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, reuse); return }
2795
2899
  const id = randomUUID()
2796
2900
  const dataKey = await deps.bundleStore.put(id, bytes, kind)
@@ -2920,12 +3024,11 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
2920
3024
  // need a published report in their teams; ownership never overrides role none.
2921
3025
  async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
2922
3026
  if (!roleAtLeast(user.role, 'view')) return false
2923
- const report = await deps.db.getReport(reportId)
3027
+ const report = await deps.db.getReportView(user.id, reportId)
2924
3028
  if (report == null) return false
2925
3029
  if (user.role === 'admin') return true
2926
- if (user.role === 'manage') return report.uploadedBy === user.id || deps.db.userCanReadReport(user.id, reportId)
2927
- if (!report.visible) return false
2928
- 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
2929
3032
  }
2930
3033
 
2931
3034
  // GET/HEAD /api/reports/:id/sources. Only the linked bundle's files cited by
@@ -3205,7 +3308,7 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
3205
3308
  if (outputBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
3206
3309
  parts.push(suffix)
3207
3310
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
3208
- writeResponse(res, Buffer.concat(parts, outputBytes))
3311
+ writeResponseParts(res, parts, outputBytes)
3209
3312
  }
3210
3313
 
3211
3314
  // The server selects the complete published workspace for classification.
@@ -3235,24 +3338,23 @@ async function visibleFindingIds(deps: ManagedHttpDeps, user: StoredUser, report
3235
3338
  }
3236
3339
  const rec = await deps.db.getReport(reportId)
3237
3340
  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])
3341
+ return reportFindingIds(deps, rec, await repositoryName(deps, rec.repoId))
3342
+ }
3343
+ // A whole report's finding ids, kept by its content, name and repository,
3344
+ // the one read least recently dropped first.
3345
+ async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256: string; filename: string }, github: string | null): Promise<Set<string>> {
3346
+ const key = JSON.stringify([rec.id, rec.sha256, rec.filename, { github }])
3240
3347
  let cache = visibleIdsCaches.get(deps)
3241
3348
  if (cache == null) { cache = new Map(); visibleIdsCaches.set(deps, cache) }
3242
3349
  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
- }
3350
+ if (hit != null) { cache.delete(key); cache.set(key, hit); return hit }
3351
+ const bytes = await deps.reportStore.get(rec.id)
3352
+ if (bytes == null) return new Set()
3353
+ const { data } = readManagedReport(bytes.toString('utf8'), rec.filename)
3354
+ if (data == null) return new Set()
3355
+ // The ids the team workspace derives (teamFindingIds), from the one parse.
3356
+ const ids = await reportIds(data)
3357
+ ids.delete('')
3256
3358
  while (cache.size >= VISIBLE_IDS_CACHE_MAX) {
3257
3359
  const oldest = cache.keys().next().value
3258
3360
  if (oldest === undefined) break
@@ -3663,8 +3765,12 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
3663
3765
  // Null for none.
3664
3766
  async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
3665
3767
  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 }
3768
+ return s == null ? null : npmReaderOf(deps, s.user, names)
3769
+ }
3770
+ // The same of `user`, as a session the request read just now tells them.
3771
+ async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
3772
+ if (!roleAtLeast(user.role, 'view')) return null
3773
+ return { role: user.role, scopes: new Set(npmScopesCount(user.role, names) ? await deps.db.listUserNpmScopes(user.id) : []), userId: user.id }
3668
3774
  }
3669
3775
 
3670
3776
  // What a registry read found, or a 404 for the package (handleNpm's catch
@@ -3680,20 +3786,25 @@ function found<T>(value: T | null | undefined): T {
3680
3786
  const REVIEW_READS_AT_ONCE = 8
3681
3787
 
3682
3788
  // 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> {
3789
+ // including permission changes while the registry requests are in flight;
3790
+ // first as `session`, the one the request read just now, where given.
3791
+ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[],
3792
+ session?: { id: string; role: NpmReader['role'] }): Promise<T[] | null> {
3685
3793
  const controller = new AbortController()
3686
3794
  const onClose = () => { if (!res.writableEnded) controller.abort() }
3687
3795
  res.on('close', onClose)
3688
3796
  try {
3689
- const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
3797
+ const names = reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []])
3798
+ const reader = session ? await npmReaderOf(deps, session, names) : await npmReader(deps, cookie, names)
3690
3799
  if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
3691
3800
  // 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)
3801
+ // day, all read of the database at once: a list asks npm for none it read
3802
+ // within it. Each version once, as many at once as REVIEW_READS_AT_ONCE.
3695
3803
  const versionsOf = (review: T) => [[review.package, review.version], ...review.basePackage ? [[review.basePackage, review.baseVersion!]] : []] as [string, string][]
3696
3804
  const asked = new Map(reviews.flatMap(versionsOf).map(([name, version]) => [`${name}\0${version}`, [name, version] as const]))
3805
+ const store = npmVersionStore(deps.db, deps.config.debug)
3806
+ const held = await store.readMany?.([...asked.values()].map(([name, version]) => ({ name, version }))) ?? new Map()
3807
+ const kept: NpmVersionStore = { read: (name, version) => Promise.resolve(held.get(`${name}@${version}`) ?? null), write: store.write }
3697
3808
  const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
3698
3809
  const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
3699
3810
  const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
@@ -3809,7 +3920,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3809
3920
  try {
3810
3921
  const reader = await npmReader(deps, cookie, [context.package, ...context.basePackage ? [context.basePackage] : []])
3811
3922
  if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3812
- const kept = npmVersionStore(deps.db, deps.config.debug)
3923
+ const kept = readTogether(npmVersionStore(deps.db, deps.config.debug), [{ name, version }, ...context.basePackage ? [{ name: context.basePackage, version: context.baseVersion! }] : []])
3813
3924
  const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
3814
3925
  const [doc, base] = await Promise.all([read(name, version), context.basePackage ? read(context.basePackage, context.baseVersion!) : null])
3815
3926
  if (!doc || (context.basePackage && !base)) { sendJson(res, 404, { error: 'package-not-found' }); return }
@@ -3844,7 +3955,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3844
3955
  if (!change) {
3845
3956
  const allBases = params.get('allBases') === 'true'
3846
3957
  const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
3847
- const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
3958
+ const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows, { id: current.userId, role: current.role }) : rows
3848
3959
  if (reviews) sendJson(res, 200, { reviews: await withFormatted(deps.reviewedDependencies ?? null, current.userId, reviews), teams: await deps.db.listDependencyReviewTeams(current.userId) })
3849
3960
  return
3850
3961
  }
@@ -3949,7 +4060,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3949
4060
  await reply(doc.private, { name, downloads, github })
3950
4061
  return
3951
4062
  }
3952
- const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptVersions))
4063
+ // The version it is compared with, where its route names one, read
4064
+ // beside it, the two read of those kept at once.
4065
+ const against: [string, string, boolean] | null = (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)') || (path === NPM_DELTA_PATH && otherSpec !== '(empty)')
4066
+ || ((path === NPM_TYPE_ONLY_PATH || path === NPM_RENAME_DIFF_PATH || (path === NPM_PRETTY_PATH && !params.has('path'))) && otherSpec !== '')
4067
+ ? [name, otherSpec, privileged]
4068
+ : path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' ? [otherName, otherSpec, canReadPrivateNpm(reader, otherName)] : null
4069
+ const keptTogether = against ? readTogether(keptVersions, [{ name, version: spec }, { name: against[0], version: against[1] }]) : keptVersions
4070
+ const otherAsked = against && readNpmVersion(against[0], against[1], against[2], controller.signal, keptTogether)
4071
+ otherAsked?.catch(() => {})
4072
+ const otherDoc = async () => found(await otherAsked!)
4073
+ const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptTogether))
3953
4074
  if (spec === 'latest') latest = Promise.resolve(doc)
3954
4075
  const repo = githubRepoOf(doc), sha = bundleCommitHash(doc.manifest['gitHead'])
3955
4076
  // What the viewer asks about a version beside its files, by its route,
@@ -3972,7 +4093,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3972
4093
  // new and however few download it where a team lists its scope, its own
3973
4094
  // packages, as a new dependency on a package of such a scope is.
3974
4095
  if (path === NPM_RISK_PATH) {
3975
- const old = otherSpec === '' || otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4096
+ const old = otherSpec === '' || otherSpec === '(empty)' ? null : await otherDoc()
3976
4097
  const scope = npmPackageScope(name)
3977
4098
  const teamScoped = scope !== null && await deps.db.hasTeamNpmScope(scope)
3978
4099
  const assessment = await npmRisk(doc, otherSpec === '' ? 'pick' : old, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped, fromEmpty: otherSpec === '(empty)',
@@ -3987,7 +4108,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3987
4108
  // package's, and those but for declaration files changed in types alone
3988
4109
  // (npm-delta.ts), as a list of versions wanting reviews tells them.
3989
4110
  if (path === NPM_DELTA_PATH) {
3990
- const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4111
+ const old = otherSpec === '(empty)' ? null : await otherDoc()
3991
4112
  res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
3992
4113
  const side = (version: NpmVersionDocument) => ({ integrity: version.dist.integrity, files: async () => {
3993
4114
  const load = loadNpmFiles(version)
@@ -4003,7 +4124,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4003
4124
  // the one compared with, or `(empty)`, none, as Review reads a first
4004
4125
  // version. Read of both, each package's access checked again once worked out.
4005
4126
  if (path === NPM_COMPARISON_PATH) {
4006
- const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(otherName, otherSpec, canReadPrivateNpm(reader, otherName), controller.signal, keptVersions))
4127
+ const old = otherSpec === '(empty)' ? null : await otherDoc()
4007
4128
  res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
4008
4129
  const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
4009
4130
  let body
@@ -4041,7 +4162,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4041
4162
  // and those that changed in types alone (type-only.ts).
4042
4163
  if (path === NPM_PRETTY_PATH || path === NPM_TYPE_ONLY_PATH || (path === NPM_RENAME_DIFF_PATH && !diffed)) {
4043
4164
  if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
4044
- const other = otherSpec === '' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4165
+ const other = otherSpec === '' ? null : await otherDoc()
4045
4166
  const sides = other && await deps.prettyCache.npmSides(doc, other)
4046
4167
  const lines = path === NPM_RENAME_DIFF_PATH ? renamedPairs(sides!, !doc.private && !other!.private)
4047
4168
  : path === NPM_TYPE_ONLY_PATH ? typeOnlyFiles(sides!) : sides ? compared(sides) : await deps.prettyCache.npmAll(doc)
@@ -4059,7 +4180,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4059
4180
  if (diffed) {
4060
4181
  const { prettyCache } = deps
4061
4182
  if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
4062
- const other = found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4183
+ const other = await otherDoc()
4063
4184
  const diff = await renameDiffOf(diffed, async () => [await prettyText(await prettyCache.npm(doc, diffed.base)),
4064
4185
  await prettyText(await prettyCache.npm(other, diffed.other))], !doc.private && !other.private)
4065
4186
  if (!(await recheck(doc.private || other.private))) { diff.stream.destroy(); return }
@@ -4079,18 +4200,20 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4079
4200
  // viewer's own request to meet. Its whole document, read for its
4080
4201
  // advisories' version list and the time each version was published
4081
4202
  // (npmPublishTime), is read only where registry reads leave room for
4082
- // readers'.
4083
- if (!download) {
4203
+ // readers'; a public version's time is then kept in the database
4204
+ // (npm-kept.ts), to come with the version on any instance.
4205
+ const published = download ? undefined : npmPublishTime(doc, npmPublishTimeStore(deps.db, deps.config.debug))
4206
+ if (published) {
4084
4207
  const quiet = (asked: Promise<unknown>) => { asked.catch(() => {}) }
4085
4208
  quiet(npmDownloads(name))
4086
4209
  quiet(latestDoc().then(newest => newest && githubFigures(newest)))
4087
4210
  quiet(readNpmVersions(name, privileged, controller.signal, { times: true, spare: true })
4088
- .then(versions => versions && npmAdvisories(name, versions.versions, advisoryOptions)))
4211
+ .then(versions => versions && Promise.all([published.keep(), npmAdvisories(name, versions.versions, advisoryOptions)])))
4089
4212
  quiet(tagsOf())
4090
4213
  quiet(socketOf())
4091
4214
  quiet(provenanceOf())
4092
4215
  }
4093
- const load = download ? loadNpmTarball(doc) : loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha))
4216
+ const load = published ? loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha), published.read) : loadNpmTarball(doc)
4094
4217
  res.once('close', load.release)
4095
4218
  const bytes = await load.result
4096
4219
  if (!(await recheck(doc.private))) return
@@ -4146,6 +4269,9 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
4146
4269
  export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4147
4270
  const { config, db, avatarStore, isShuttingDown, track } = deps
4148
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) })
4149
4275
 
4150
4276
  async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
4151
4277
  const url = new URL(req.url ?? '/', 'http://localhost')
@@ -4562,7 +4688,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4562
4688
  sendJson(res, 400, { error: 'bad-review-context' }); return
4563
4689
  }
4564
4690
  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] : [])
4691
+ const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [], s.user)
4566
4692
  if (visible) sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, s.user.id, visible))[0] ?? null })
4567
4693
  return
4568
4694
  }
@@ -4592,7 +4718,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4592
4718
  sendJson(res, 400, { error: 'bad-review-cursor' }); return
4593
4719
  }
4594
4720
  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)
4721
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
4596
4722
  if (reviews) sendJson(res, 200, { ...page, reviews: await withFormatted(deps.reviewedDependencies ?? null, s.user.id, reviews) })
4597
4723
  return
4598
4724
  }
@@ -4609,7 +4735,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4609
4735
  }
4610
4736
  const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
4611
4737
  if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
4612
- const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
4738
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
4613
4739
  if (!reviews) return
4614
4740
  // Its access asked again, as it may have been taken while npm was read.
4615
4741
  if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
@@ -4634,7 +4760,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4634
4760
  // Feeds lease a connection per poll, never across streaming sleeps. This
4635
4761
  // also covers capability-authenticated feeds handled by public-workspace.
4636
4762
  const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
4637
- const serve = () => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)
4763
+ const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
4638
4764
  const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
4639
4765
  if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
4640
4766
  console.warn('managed: request handler error:', err)