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

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 (108) hide show
  1. package/client/dependency-paths.d.ts +2 -1
  2. package/client/dependency-paths.js +16 -4
  3. package/common/managed/agent-tasks.ts +12 -12
  4. package/common/managed/github-agents.ts +29 -10
  5. package/out/activity.js +4 -0
  6. package/out/chunk-6LNQTKAV.js +52 -0
  7. package/out/chunk-7BKKYVWR.js +7 -0
  8. package/out/chunk-7VAWINQF.js +6 -0
  9. package/out/chunk-C2AVODGZ.js +36 -0
  10. package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
  11. package/out/chunk-MOACFRJ4.js +3 -0
  12. package/out/chunk-O6M574JI.js +1 -0
  13. package/out/chunk-PBZPYIG7.js +1 -0
  14. package/out/chunk-PENGGKX7.js +233 -0
  15. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  16. package/out/chunk-SZD5CW5R.js +3 -0
  17. package/out/{chunk-G24A42V6.js → chunk-TQJDYHVE.js} +1 -1
  18. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  19. package/out/{chunk-7XRR4OFU.js → chunk-WYLQWYLA.js} +1 -1
  20. package/out/chunk-X6PJAK3T.js +1 -0
  21. package/out/client-managed-import.js +1 -1
  22. package/out/client-managed.js +26 -26
  23. package/out/client-sync.js +2 -2
  24. package/out/compare.js +14 -14
  25. package/out/dialogs.js +40 -40
  26. package/out/graph.js +11 -11
  27. package/out/index.html +12 -12
  28. package/out/scan.js +2 -2
  29. package/out/terminal.js +215 -215
  30. package/out/view.css +1 -1
  31. package/out/view.js +15 -15
  32. package/package.json +9 -5
  33. package/risk/index.d.ts +4 -2
  34. package/risk/npm/assess.js +15 -4
  35. package/risk/npm/registry.js +6 -1
  36. package/risk/npm/score.js +1 -1
  37. package/server-managed/activity.ts +24 -11
  38. package/server-managed/agent-merges.ts +10 -8
  39. package/server-managed/app-agents.ts +8 -21
  40. package/server-managed/bundle-build-worker.js +7 -3
  41. package/server-managed/bundle-build.ts +5 -3
  42. package/server-managed/bundle-cache.ts +9 -3
  43. package/server-managed/bundle.ts +3 -4
  44. package/server-managed/cache-storage.ts +3 -2
  45. package/server-managed/commit-coauthors.ts +11 -10
  46. package/server-managed/commit-lockfiles.ts +22 -21
  47. package/server-managed/db-methods.ts +338 -187
  48. package/server-managed/db-neon.ts +31 -8
  49. package/server-managed/db.ts +2 -0
  50. package/server-managed/dependabot-access.ts +1 -1
  51. package/server-managed/dependency-review-comments.ts +7 -4
  52. package/server-managed/dependency-reviews.ts +71 -33
  53. package/server-managed/github-accounts.ts +28 -1
  54. package/server-managed/github-app.ts +27 -23
  55. package/server-managed/github-graphql.ts +39 -0
  56. package/server-managed/github-issue-links.ts +8 -1
  57. package/server-managed/github-metadata.ts +2 -2
  58. package/server-managed/http-response.ts +28 -10
  59. package/server-managed/http.ts +736 -247
  60. package/server-managed/index.ts +2 -2
  61. package/server-managed/link-reports.ts +5 -1
  62. package/server-managed/managed-issues.ts +3 -0
  63. package/server-managed/npm-delta.ts +16 -0
  64. package/server-managed/npm-kept.ts +51 -10
  65. package/server-managed/npm-loads.ts +48 -28
  66. package/server-managed/npm-manifest.ts +1 -1
  67. package/server-managed/npm-packages.ts +31 -15
  68. package/server-managed/npm-risk.ts +64 -17
  69. package/server-managed/object-storage.ts +4 -3
  70. package/server-managed/pretty-print.ts +45 -12
  71. package/server-managed/public-workspace.ts +3 -3
  72. package/server-managed/pull-dependencies.ts +84 -36
  73. package/server-managed/pull-dependency-reviews.ts +13 -10
  74. package/server-managed/rename-diff-worker.js +1 -1
  75. package/server-managed/report-store.ts +16 -11
  76. package/server-managed/repository-agents.ts +9 -6
  77. package/server-managed/repository-browser.ts +17 -5
  78. package/server-managed/repository-code.ts +180 -17
  79. package/server-managed/repository-dependency-reviews.ts +7 -0
  80. package/server-managed/repository-history.ts +85 -152
  81. package/server-managed/repository-issues.ts +27 -13
  82. package/server-managed/repository-pulse.ts +9 -3
  83. package/server-managed/repository-squashes.ts +145 -0
  84. package/server-managed/request-timing.ts +63 -0
  85. package/server-managed/reviewed-dependencies.ts +3 -2
  86. package/server-managed/session.ts +34 -12
  87. package/server-managed/solidity-entry-points.ts +5 -3
  88. package/server-managed/sql-postgres.ts +3 -0
  89. package/server-managed/sql.ts +17 -8
  90. package/server-managed/storage-db.ts +11 -3
  91. package/server-managed/storage-encryption.ts +23 -12
  92. package/server-managed/storage-maintenance.ts +5 -2
  93. package/server-managed/storage-payload.ts +18 -0
  94. package/server-managed/storage-stores.ts +3 -2
  95. package/server-managed/storage.ts +4 -2
  96. package/server-managed/team-feed.ts +19 -12
  97. package/server-managed/team-npm-scopes.ts +29 -0
  98. package/server-managed/team-reports.ts +74 -17
  99. package/server-managed/uploads.ts +29 -9
  100. package/out/chunk-4UFAMJAX.js +0 -1
  101. package/out/chunk-BOIXMUU5.js +0 -3
  102. package/out/chunk-CC4OM6TZ.js +0 -3
  103. package/out/chunk-JKGQ3O7O.js +0 -6
  104. package/out/chunk-LE5ZWLG4.js +0 -235
  105. package/out/chunk-MSION3G4.js +0 -36
  106. package/out/chunk-S6GIXWQW.js +0 -52
  107. package/out/chunk-V3KWNNMY.js +0 -1
  108. package/out/chunk-WQ74ZBZF.js +0 -7
@@ -23,7 +23,7 @@
23
23
  // POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
24
24
  // GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
25
25
  // GET /api/avatar/<id>[?own] → cached avatar bytes by user id, of the account chosen on their profile (`own`: the
26
- // sign-in account's), or by linked account id; `app-<id>` or `user-<id>`, an app agent's GitHub App's or account's (APP_AGENTS) | 401/404
26
+ // sign-in account's), or by linked account id; `app-<id>` or `user-<id>`, an agent's GitHub App's or account's (GITHUB_AGENTS) | 401/404
27
27
  // GET /api/admin/users → admin-only user list | 401/403
28
28
  // GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
29
29
  // POST /api/admin/set-role → admin sets another user's role | 401/403/404
@@ -135,9 +135,10 @@ import { mergeLinkGroups } from './link-reports.ts'
135
135
  import { backfillFindingIds, reportEntries } from '@preventive/report'
136
136
  import type { IncomingMessage, ServerResponse } from 'node:http'
137
137
  import { pipeline } from 'node:stream/promises'
138
- import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmPublishTimeStore, npmVersionStore, pullDependencyStore,
139
- repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
140
- import { type NpmDelta, npmDelta } from './npm-delta.ts'
138
+ import { type KeptStore, REPOSITORY_READ_KEPT_MS, codeHeadStore, codeTreeStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, lockfileReadStore,
139
+ npmDeltaStore, npmPublishTimeStore, npmVersionStore, ownKeptPrunes, pullDependencyStore, repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
140
+ import { plainObject } from './npm-manifest.ts'
141
+ import { type NpmDelta, keptNpmDeltas, npmDelta } from './npm-delta.ts'
141
142
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
142
143
  import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
143
144
  import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
@@ -151,7 +152,7 @@ import { contentAccess } from './content-access.ts'
151
152
  import type { BundleStore } from './bundle-store.ts'
152
153
  import { Buffer } from 'node:buffer'
153
154
  import { createHash, randomUUID } from 'node:crypto'
154
- import type { AvatarStore } from './avatar-store.ts'
155
+ import type { AvatarStore, CachedAvatar } from './avatar-store.ts'
155
156
  import type { BlobStore, OpenedBlob } from './blob-store.ts'
156
157
  import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
157
158
  import { resolveRepositoryImportLocation } from './repository-aliases.ts'
@@ -181,12 +182,15 @@ import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines
181
182
  import { WORKFLOWS_DIR } from './repository-actions.ts'
182
183
  import { githubActionsSource } from './github-actions.ts'
183
184
  import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
185
+ import { type Lru, lru } from './lru.ts'
184
186
  import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
185
- import { clearCookie, endSession, readSession, runRequestPresence } from './session.ts'
187
+ import { clearCookie, endSession, handOverSession, readSession, runRequestPresence, sessionIds } from './session.ts'
188
+ import { runTimed } from './request-timing.ts'
189
+ import { isExactVersion } from '@preventive/upstream/semver.js'
186
190
  import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
187
191
  import type { ActivityContext, ActivityInput } from './activity.ts'
188
192
  import { acceptsReportMetadata } from './report-response.ts'
189
- import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
193
+ import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, loadTeamTreesResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
190
194
  import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
191
195
  import { lookupFixes, storedFixUrls } from './github-pulls.ts'
192
196
  import { lookupIssueLinks } from './github-issue-links.ts'
@@ -197,7 +201,7 @@ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallb
197
201
  import type { ProfileChoice } from './github-links.ts'
198
202
  import { withAppUsers } from './github-users.ts'
199
203
  import { isBlobId } from './object-storage.ts'
200
- import { attachmentDisposition, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
204
+ import { attachmentDisposition, openLines, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
201
205
  import { triageWireEntry } from './triage-response.ts'
202
206
  import { handlePublicWorkspace } from './public-workspace.ts'
203
207
  import { serveUserTeamFeed } from './team-feed.ts'
@@ -206,19 +210,20 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
206
210
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
207
211
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
208
212
  import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
209
- import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions,
210
- readTogether } from './npm-packages.ts'
213
+ import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, VERSION_KEPT_MS, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions, readTogether } from './npm-packages.ts'
211
214
  import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
212
- import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
215
+ import { type RepositoryCodeKept, loadRepositoryCodeBody, repositoryCodeFits, repositoryCodeKept } from './repository-code.ts'
213
216
  import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
214
217
  import { COAUTHORS_READ } from './commit-coauthors.ts'
215
- import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, type SquashedPull, type Squashes, historyFrom, historyStarts, openPullHeads,
216
- repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
217
- import { AGENTS_READ_VERSION, APP_AGENTS, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
218
+ import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, historyFrom, historyStarts, openPullHeads,
219
+ repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips } from './repository-history.ts'
220
+ import { type SquashedPull, type Squashes, squashedPulls } from './repository-squashes.ts'
221
+ import { AGENTS_READ_VERSION, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
218
222
  import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.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'
221
- import { MAX_REQUIRED_REVIEWS, SINCE_COMMIT } from './repository-dependency-reviews.ts'
223
+ import { type GithubAsked, type ListedLockfile, type LockfileRead, type LockfileStore, MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullCommits, type PullDependencies,
224
+ type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
225
+ import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
226
+ import { MAX_REQUIRED_REVIEWS, type RepoDependencyReview, SINCE_COMMIT } from './repository-dependency-reviews.ts'
222
227
  import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
223
228
  import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
224
229
  import { formattedPull, withFormatted } from './dependency-review-comments.ts'
@@ -229,7 +234,7 @@ import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitS
229
234
  import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
230
235
  import { releaseCommits } from './release-commits.ts'
231
236
  import { computeBundleDiff } from '../common/bundle-compare-diff.js'
232
- import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
237
+ import type { ReviewChange, ReviewContext, ReviewInput, ReviewedTarballs } from './dependency-reviews.ts'
233
238
  import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
234
239
  import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
235
240
  import { npmProvenance } from './npm-provenance.ts'
@@ -309,6 +314,7 @@ const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
309
314
  const NPM_COMPARISON_PATH = '/api/npm/comparison'
310
315
  const NPM_RISK_PATH = '/api/npm/risk'
311
316
  const NPM_DELTA_PATH = '/api/npm/delta'
317
+ const NPM_DELTAS_PATH = '/api/npm/deltas'
312
318
  // What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
313
319
  // having no such package or version, or a tarball unpacking past risk's
314
320
  // bounds; npm failing otherwise a 502.
@@ -333,7 +339,7 @@ const VIEW_PREFIXES = [...MANAGED_DATA_PREFIXES, '/api/auth', '/api/oauth']
333
339
  const underPrefix = (path: string, prefixes: readonly string[]) => prefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))
334
340
 
335
341
  async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
336
- const s = await readWorkspaceSession(res, deps, cookie)
342
+ const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
337
343
  if (!s) return
338
344
  const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
339
345
  const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
@@ -363,7 +369,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
363
369
  async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
364
370
  cookie: string | undefined, teamId: string, query: URLSearchParams): Promise<void> {
365
371
  if (req.method !== 'GET' && req.method !== 'POST') { send405(res, 'GET, POST'); return }
366
- const s = req.method === 'GET' ? await readWorkspaceSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
372
+ const s = req.method === 'GET' ? await readWorkspaceSession(res, deps, cookie, { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
367
373
  if (!s) return
368
374
  if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
369
375
  let body: unknown = Object.fromEntries(query)
@@ -395,7 +401,7 @@ function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined
395
401
  // Managers get content activity for their currently accessible team content;
396
402
  // even the displayed report context must come from those accessible reports.
397
403
  async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
398
- const s = await readManageSession(res, deps, cookie)
404
+ const s = await readManageSession(res, deps, cookie, { handed: true })
399
405
  if (s == null) return
400
406
  const page = Number(query.get('page') ?? 1)
401
407
  const limit = Number(query.get('limit') ?? 100)
@@ -439,6 +445,9 @@ export interface ManagedHttpDeps {
439
445
  prettyCache?: PrettyCache
440
446
  // The public npm cache, where risk keeps public packages' assessments (npm-risk.ts).
441
447
  npmCacheStorage?: CacheStorage
448
+ // Where the code viewer keeps the default branch's head's directories
449
+ // (repository-code.ts repositoryCodeKept).
450
+ repositoryCodeStorage?: CacheStorage
442
451
  uploadStore?: BlobStore
443
452
  originGate: OriginGate
444
453
  isShuttingDown: () => boolean
@@ -468,36 +477,43 @@ function firstHeader(v: string | string[] | undefined): string | null {
468
477
  }
469
478
 
470
479
  // `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
471
- async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id): Promise<void> {
472
- const avatar = await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id))
480
+ // A user's is never kept by the browser, as it changes with their profile.
481
+ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id, cacheControl = 'no-store'): Promise<void> {
482
+ sendAvatar(res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
483
+ }
484
+
485
+ function sendAvatar(res: ServerResponse, avatar: CachedAvatar | null, cacheControl = 'no-store'): void {
473
486
  if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
474
487
  res.writeHead(200, {
475
488
  'content-type': avatar.contentType,
476
489
  'content-length': String(avatar.bytes.length),
477
490
  'x-content-type-options': 'nosniff',
478
- 'cache-control': 'no-store',
491
+ 'cache-control': cacheControl,
479
492
  })
480
493
  res.end(avatar.bytes)
481
494
  }
482
495
 
483
496
  // The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
484
- // alone: the agents' known by their GitHub Apps or accounts (APP_AGENTS),
485
- // each its mark on the Agents tab, and those the app draws agents' accounts
486
- // by (GITHUB_AGENTS). Each is fetched from GitHub once and
497
+ // alone: those the app draws agents by (GITHUB_AGENTS), on the Agents tab and
498
+ // wherever their work shows. Each is fetched from GitHub once and
487
499
  // kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
488
500
  // an account's, `…-0001-<id>`.
489
- const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
490
- .flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
501
+ const AGENT_AVATARS: ReadonlySet<string> = new Set(Object.values(GITHUB_AGENTS).flatMap(agent => 'avatar' in agent ? [agent.avatar] : []))
491
502
  const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
503
+ // An agent's mark is a public GitHub image that never changes with anyone's
504
+ // profile, drawn on every tile and comment by it: kept by the browser a day,
505
+ // for it alone.
506
+ const AGENT_AVATAR_CACHE = 'private, max-age=86400'
492
507
 
493
508
  async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
494
509
  const [, kind, id] = AGENT_AVATAR.exec(avatar)!
495
510
  const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
496
- if (await avatarStore.get(key) == null) {
497
- await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
498
- .catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
499
- }
500
- await serveAvatar(res, avatarStore, key)
511
+ // Read once where it is kept; read again only past fetching it.
512
+ const kept = await avatarStore.get(key)
513
+ if (kept != null) { sendAvatar(res, kept, AGENT_AVATAR_CACHE); return }
514
+ await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
515
+ .catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
516
+ await serveAvatar(res, avatarStore, key, key, AGENT_AVATAR_CACHE)
501
517
  }
502
518
 
503
519
  const MAX_JSON_BODY_BYTES = 4096
@@ -518,14 +534,17 @@ async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Bu
518
534
  }
519
535
 
520
536
  // A file upload's content, opening a body the browser sealed to this session
521
- // (common/managed/upload-seal.ts). Link reports are never sent in parts.
537
+ // (common/managed/upload-seal.ts). Link reports are never sent in parts; the
538
+ // parts of one sent in them are deleted behind its answer.
522
539
  async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: ManagedSession, kind: UploadKind | 'links'): Promise<Buffer> {
523
540
  const maxBytes = kind === 'bundles' ? deps.config.maxBundleBytes : deps.config.maxReportBytes
524
541
  const sealed = req.headers[UPLOAD_SEAL_HEADER] !== undefined
525
542
  const limit = sealed ? maxSealedBytes(maxBytes) : maxBytes
526
543
  let bytes
527
544
  if (kind === 'links' || req.headers['x-upload-id'] == null) bytes = await readBodyBytes(req, limit)
528
- else if (deps.uploadStore) bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit)
545
+ else if (deps.uploadStore) {
546
+ bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit, cleanup => { deps.track(cleanup); deps.waitUntil?.(cleanup) })
547
+ }
529
548
  else throw new Error('bad-upload')
530
549
  return sealed ? await openSessionUpload(session.uploadKey, bytes, maxBytes) : bytes
531
550
  }
@@ -534,7 +553,7 @@ async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, sessi
534
553
  // first use. Only this session's server-side private key opens what is sealed to it.
535
554
  async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
536
555
  if (req.method !== 'GET') { send405(res, 'GET'); return }
537
- const s = await readManageSession(res, deps, cookie)
556
+ const s = await readManageSession(res, deps, cookie, { handed: true })
538
557
  if (!s) return
539
558
  const key = s.session.uploadKey ?? await deps.db.ensureSessionUploadKey(s.session.id, newUploadKey())
540
559
  if (!key) { sendJson(res, 401, { error: 'unauthenticated' }); return }
@@ -544,7 +563,7 @@ async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps:
544
563
  async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
545
564
  const cancel = req.method === 'DELETE'
546
565
  if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
547
- const s = await checkMutation(req, res, deps, cookie)
566
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
548
567
  if (!s || !requireManageRole(res, s.user)) return
549
568
  if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
550
569
  const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
@@ -585,8 +604,8 @@ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES
585
604
  // Returns the session, or null after having already sent the 401/403. An
586
605
  // admin's view as another user writes nothing; `view` admits only the
587
606
  // endpoints that end it.
588
- async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
589
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
607
+ async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false, handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
608
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
590
609
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
591
610
  const csrf = firstHeader(req.headers['x-csrf-token'])
592
611
  if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
@@ -640,7 +659,7 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
640
659
  const userId = (body as { userId?: unknown } | null)?.userId
641
660
  if (typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
642
661
  if (userId === s.user.id) { sendJson(res, 400, { error: 'cannot-view-as-self' }); return }
643
- const target = (await deps.db.listUsers()).find(user => user.id === userId)
662
+ const target = await deps.db.getUser(userId)
644
663
  if (!target) { sendJson(res, 404, { error: 'not-found' }); return }
645
664
  // The insert itself rechecks the admin session and role.
646
665
  const setCookie = await startViewSession(deps.config, deps.db, s.session, userId, Date.now())
@@ -714,7 +733,7 @@ function parseProfileChoice(body: unknown): ProfileChoice | null {
714
733
  // at least one admin).
715
734
  async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
716
735
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
717
- const s = await checkMutation(req, res, deps, cookie)
736
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
718
737
  if (s == null) return
719
738
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
720
739
  let body: unknown
@@ -723,7 +742,7 @@ async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: Ma
723
742
  const role = (body as { role?: unknown } | null)?.role
724
743
  if (typeof userId !== 'string' || !isRole(role)) { sendJson(res, 400, { error: 'bad-request' }); return }
725
744
  if (userId === s.user.id) { sendJson(res, 403, { error: 'cannot-change-own-role' }); return }
726
- const target = (await deps.db.listUsers()).find(user => user.id === userId)
745
+ const target = await deps.db.getUser(userId)
727
746
  // A caller can hold the body open while their admin access is revoked.
728
747
  if (await readAdminSession(res, deps, cookie) == null) return
729
748
  const ok = await deps.db.setUserRole(userId, role)
@@ -744,22 +763,22 @@ function requireManageRole(res: ServerResponse, user: StoredUser): boolean {
744
763
  // A read endpoint open to admin|manage: resolve the session (401 if absent),
745
764
  // then gate on the role (403). Returns the session, or null after the response
746
765
  // was already sent. No CSRF — reads aren't mutations (cf. /api/admin/users).
747
- async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
748
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
766
+ async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
767
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
749
768
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
750
769
  if (!requireManageRole(res, s.user)) return null
751
770
  return s
752
771
  }
753
772
 
754
- async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
755
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
773
+ async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
774
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
756
775
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
757
776
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
758
777
  return s
759
778
  }
760
779
 
761
- async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
762
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
780
+ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
781
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
763
782
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
764
783
  if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
765
784
  return s
@@ -789,7 +808,7 @@ async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: stri
789
808
  // GET /api/admin/scan/models — the server's canonical scan model ids and effort
790
809
  // levels. Names are intentionally absent; the client derives them from ids.
791
810
  async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
792
- const s = await readManageSession(res, deps, cookie)
811
+ const s = await readManageSession(res, deps, cookie, { handed: true })
793
812
  if (s == null) return
794
813
  sendJson(res, 200, { models: MANAGED_SCAN_MODELS, defaultModel: DEFAULT_MANAGED_SCAN_MODEL })
795
814
  }
@@ -800,7 +819,7 @@ async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cook
800
819
  // filtering. Admin-only. No CSRF.
801
820
  async function handleListRepositories(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, discovery: RepositoryDiscovery): Promise<void> {
802
821
  if ((req.method ?? 'GET') !== 'GET') { send405(res, 'GET'); return }
803
- const s = await readAdminSession(res, deps, cookie)
822
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
804
823
  if (s == null) return
805
824
  const url = new URL(req.url ?? '/', 'http://localhost')
806
825
  // Keep the original unparameterized response shape for older clients. The
@@ -918,7 +937,7 @@ async function connectRepository(res: ServerResponse, deps: ManagedHttpDeps, use
918
937
  // Authentication, server admin permission, origin and CSRF remain mandatory.
919
938
  async function handleAddPublicRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
920
939
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
921
- const s = await adminMutation(req, res, deps, cookie)
940
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
922
941
  if (s == null) return
923
942
  if (!canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id))) { sendJson(res, 403, { error: 'forbidden' }); return }
924
943
  let body: unknown
@@ -942,7 +961,7 @@ async function handleAddPublicRepository(req: IncomingMessage, res: ServerRespon
942
961
  // from the per-user checks required for browsing source.
943
962
  async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
944
963
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
945
- const s = await adminMutation(req, res, deps, cookie)
964
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
946
965
  if (s == null) return
947
966
  let body: unknown
948
967
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -977,10 +996,12 @@ async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerRespo
977
996
  // POST /api/admin/repositories/select — an admin toggles whether a repo is
978
997
  // active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
979
998
  // selected }. selected:true verifies + records the read context; selected:false
980
- // deactivates the row while retaining its reports, bundles, and metadata.
999
+ // deactivates the row while retaining its reports, bundles, and metadata, but
1000
+ // for the code the code viewer kept of it, which no one reads meanwhile
1001
+ // (removeKeptCode).
981
1002
  async function handleSelectRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
982
1003
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
983
- const s = await adminMutation(req, res, deps, cookie)
1004
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
984
1005
  if (s == null) return
985
1006
  let body: unknown
986
1007
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -993,6 +1014,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
993
1014
  const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
994
1015
  if (await readAdminSession(res, deps, cookie) == null) return
995
1016
  await deps.db.deactivateRepo(repoId)
1017
+ await removeKeptCode(deps, repoId)
996
1018
  if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
997
1019
  sendJson(res, 200, { ok: true, selected: false }); return
998
1020
  }
@@ -1008,7 +1030,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
1008
1030
  // stored kept as it is). Answers the setting then. Admin, CSRF.
1009
1031
  async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1010
1032
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
1011
- const s = await adminMutation(req, res, deps, cookie)
1033
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
1012
1034
  if (s == null) return
1013
1035
  let body: Record<string, unknown> | null
1014
1036
  try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1080,7 +1102,7 @@ async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id
1080
1102
  // removal would destroy. This is intentionally separate from the repository
1081
1103
  // list so the normal page stays cheap even with many reports.
1082
1104
  async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1083
- const s = await readAdminSession(res, deps, cookie)
1105
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
1084
1106
  if (s == null) return
1085
1107
  const repoId = Number(new URL(req.url ?? '/', 'http://localhost').searchParams.get('repoId'))
1086
1108
  if (!Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-repo' }); return }
@@ -1093,7 +1115,7 @@ async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse,
1093
1115
  // attached reports/bundles. The exact name + explicit acknowledgement are
1094
1116
  // checked server-side too; the UI's dialog is a usability guard, not the policy.
1095
1117
  async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1096
- const s = await checkMutation(req, res, deps, cookie)
1118
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
1097
1119
  if (s == null) return
1098
1120
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
1099
1121
  let body: unknown
@@ -1126,9 +1148,17 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
1126
1148
  await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
1127
1149
  await deps.bundleStore.delete(bundle.id).catch(() => {})
1128
1150
  }
1151
+ await removeKeptCode(deps, repo.repoId)
1129
1152
  sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
1130
1153
  }
1131
1154
 
1155
+ // The code the code viewer kept of a repository no longer read, its
1156
+ // directories' files (repositoryCodeKeptOf), removed once its row is: as a
1157
+ // bundle's derivatives are, at best, a failure warned of.
1158
+ async function removeKeptCode(deps: ManagedHttpDeps, repoId: number): Promise<void> {
1159
+ await repositoryCodeKeptOf(deps)?.remove(repoId).catch((err: unknown) => { console.warn('managed: kept repository code delete failed:', err) })
1160
+ }
1161
+
1132
1162
  // Strip a client-supplied upload filename to a safe display string. The bytes
1133
1163
  // are keyed by a server uuid. This name is used for display, Content-Disposition,
1134
1164
  // and format detection: decode URLs, remove controls/paths, and cap the basename
@@ -1180,7 +1210,7 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
1180
1210
  // { reportId, repoId, directory }, where repoId is null (detach) or a
1181
1211
  // currently-selected repo id. Directory is relative to the repository root.
1182
1212
  async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1183
- const s = await manageMutation(req, res, deps, cookie)
1213
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1184
1214
  if (s == null) return
1185
1215
  let body: unknown
1186
1216
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1201,7 +1231,7 @@ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, de
1201
1231
  // POST /api/admin/reports/set-visible — publish or hide a stored report. New
1202
1232
  // reports start hidden so an admin can inspect the parsed metadata first.
1203
1233
  async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1204
- const s = await manageMutation(req, res, deps, cookie)
1234
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1205
1235
  if (s == null) return
1206
1236
  let body: unknown
1207
1237
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1214,7 +1244,7 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
1214
1244
  }
1215
1245
 
1216
1246
  async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1217
- const s = await manageMutation(req, res, deps, cookie)
1247
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1218
1248
  if (s == null) return
1219
1249
  let body: unknown
1220
1250
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1229,7 +1259,7 @@ async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse,
1229
1259
  // POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
1230
1260
  // and directory (same shape as reports). Body { bundleId, repoId, directory }.
1231
1261
  async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1232
- const s = await manageMutation(req, res, deps, cookie)
1262
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1233
1263
  if (s == null) return
1234
1264
  let body: unknown
1235
1265
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1260,8 +1290,8 @@ async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, ses
1260
1290
  // declared integrity that has a stored bundle wins (bundleId set). When none is
1261
1291
  // stored yet, keep the first declared integrity so a later bundle upload of it
1262
1292
  // re-links (see linkReportsToBundle). Returns the (bundleId, integrity) to store.
1263
- async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, bytes: Buffer): Promise<{ bundleId: string | null; integrity: string | null }> {
1264
- const hashes = reportBundleHashes(bytes)
1293
+ async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, json: unknown): Promise<{ bundleId: string | null; integrity: string | null }> {
1294
+ const hashes = reportBundleHashes(json)
1265
1295
  for (const h of hashes) {
1266
1296
  const b = await deps.db.getBundleByIntegrity(h)
1267
1297
  if (b != null && await canAccessBundle(deps, user, b.id)) return { bundleId: b.id, integrity: h }
@@ -1293,7 +1323,7 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
1293
1323
  // insert failure or concurrent duplicate drops the orphan blob; an uncertain
1294
1324
  // commit retains it. 413 over the cap, 400 on empty.
1295
1325
  async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1296
- let s = await checkMutation(req, res, deps, cookie)
1326
+ let s = await checkMutation(req, res, deps, cookie, { handed: true })
1297
1327
  if (s == null) return
1298
1328
  if (!requireManageRole(res, s.user)) return
1299
1329
  let bytes: Buffer
@@ -1371,7 +1401,8 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
1371
1401
  if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
1372
1402
  const id = randomUUID()
1373
1403
  const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
1374
- const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
1404
+ // A JSON report's parse already in hand, not parsed again for its hashes.
1405
+ const { bundleId, integrity } = await resolveReportBundle(deps, s.user, parsed.format === 'json' ? parsed.data : null)
1375
1406
  const dataKey = await deps.reportStore.put(id, bytes)
1376
1407
  let report: ReportRecord
1377
1408
  try {
@@ -1400,7 +1431,7 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
1400
1431
  // or rendered inline against the app origin. 404 when there's no such report;
1401
1432
  // 503 when the row exists but the bytes don't (store desync).
1402
1433
  async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1403
- const s = await readManageSession(res, deps, cookie)
1434
+ const s = await readManageSession(res, deps, cookie, { handed: true })
1404
1435
  if (s == null) return
1405
1436
  if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1406
1437
  const rec = await deps.db.getReport(id)
@@ -1423,7 +1454,7 @@ async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cooki
1423
1454
  // same-origin + CSRF. Drops the row, then best-effort the bytes; 404 when there
1424
1455
  // was no such report.
1425
1456
  async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1426
- const s = await checkMutation(req, res, deps, cookie)
1457
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
1427
1458
  if (s == null) return
1428
1459
  if (!requireManageRole(res, s.user)) return
1429
1460
  const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
@@ -1461,8 +1492,10 @@ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
1461
1492
  // user's repository access, for the next catalog to send.
1462
1493
  async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, missing: readonly CatalogCommit[]): Promise<void> {
1463
1494
  let browser: ReturnType<typeof createRepositoryBrowser> | undefined
1495
+ let repos: ReturnType<ManagedDb['listAllRepos']> | undefined
1464
1496
  await backfillBundleCommits(deps.db, userId, missing, async repoId => {
1465
- const repo = (await deps.db.listAllRepos()).find(item => item.repoId === repoId)
1497
+ // Read once for the repositories the backfill reaches, not once each.
1498
+ const repo = (await (repos ??= deps.db.listAllRepos())).find(item => item.repoId === repoId)
1466
1499
  if (!repo) return null
1467
1500
  browser ??= createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, userId))
1468
1501
  return browser.reader(repo)
@@ -1474,7 +1507,7 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
1474
1507
  // Any team member (view or above) lists those their teams reach, for the code
1475
1508
  // viewer as for Create a bundle, whose builds stay admin|manage.
1476
1509
  async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1477
- const s = await readWorkspaceSession(res, deps, cookie)
1510
+ const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
1478
1511
  if (!s) return
1479
1512
  sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
1480
1513
  }
@@ -1531,13 +1564,28 @@ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
1531
1564
  // answered, where it is none of theirs. Every team member (view or above)
1532
1565
  // reads the repositories their teams reach; the reader then checks their own
1533
1566
  // GitHub read permission (repository-browser.ts), on each read.
1534
- async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
1535
- const s = await readWorkspaceSession(res, deps, cookie)
1567
+ async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string, { handed = false } = {}) {
1568
+ const s = await readWorkspaceSession(res, deps, cookie, { handed })
1536
1569
  if (!s) return null
1537
- const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
1538
- if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1539
- const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1540
- return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
1570
+ const access = repositoryAccessIn(await repositoryGrants(deps, s.user), repoId, path)
1571
+ if (!access) { sendJson(res, 404, { error: 'no-repository' }); return null }
1572
+ return { ...access, user: s.user }
1573
+ }
1574
+
1575
+ // What a reader's grants let them read of the repositories (bundleRepos's
1576
+ // reads, the reader's scopes read once for both), for repositoryAccessIn to
1577
+ // tell of each: read once for as many repositories as a request asks of.
1578
+ async function repositoryGrants(deps: ManagedHttpDeps, user: StoredUser) {
1579
+ const access = await contentAccess(deps.db, user)
1580
+ return { access, repos: (await deps.db.listSelectedRepos()).filter(access.bundle) }
1581
+ }
1582
+ // Repository `repoId` as `grants` let it be read at `path`, as
1583
+ // authorizeRepository answers it; null where it is none of the reader's.
1584
+ function repositoryAccessIn({ access, repos }: Awaited<ReturnType<typeof repositoryGrants>>, repoId: number, path: string) {
1585
+ const repo = repos.find(item => item.repoId === repoId)
1586
+ if (!repo) return null
1587
+ const scopes = access.scopes == null ? [null] : access.scopes.filter(scope => scope.repoId === repoId).map(scope => scope.path)
1588
+ return { repo, virtualEntries: scopedDirectory(path, scopes) }
1541
1589
  }
1542
1590
 
1543
1591
  // The repository as it was authorized, but for the shared default branch hint,
@@ -1557,12 +1605,12 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1557
1605
  const { repoId, ref } = asked
1558
1606
  const withDefault = refs && query.get('withDefault') === 'true'
1559
1607
  const path = refs ? '' : asked.path
1560
- const authorize = async () => {
1561
- const access = await authorizeRepository(res, deps, cookie, repoId, path)
1608
+ const authorize = async (handed = false) => {
1609
+ const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
1562
1610
  if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1563
1611
  return access
1564
1612
  }
1565
- const access = await authorize()
1613
+ const access = await authorize(true)
1566
1614
  if (!access) return
1567
1615
  try {
1568
1616
  const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
@@ -1572,8 +1620,9 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1572
1620
  const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
1573
1621
  const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
1574
1622
  const { tagCommits, tagsComplete, ...listed } = revisions ?? { tagCommits: [], tagsComplete: false }
1575
- // Create a bundle refreshes the tags bundle catalogs show, and keeps a tag
1576
- // its revision input resolves even when the listed page leaves it out.
1623
+ // Every refs read (Create a bundle's, and the code viewer's) refreshes the
1624
+ // tags bundle catalogs show, and a tag read keeps the one its revision
1625
+ // input resolves even when the listed page leaves it out.
1577
1626
  // Like other GitHub caches, written before the access fence below.
1578
1627
  const tags = refs ? tagCommits : ref.length > 5 && ref.startsWith('tags/') && contents ? [{ name: ref.slice(5), sha: contents.commit }] : []
1579
1628
  if (refs || tags.length > 0) {
@@ -1601,27 +1650,78 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1601
1650
  // tarball is asked for; the reader's access is checked again once it is
1602
1651
  // read, before any of it is sent. The load stays held until this response is
1603
1652
  // written or abandoned, and a reader who stops reading it gives it up after
1604
- // a while.
1653
+ // a while. A directory of the default branch's head is kept in byte storage
1654
+ // as it was sent, for the next to open it (repositoryCodeKeptOf).
1655
+ // What GitHub answered of a repository's trees for the code viewer, which a
1656
+ // commit or tree it names never changes: the tree at a directory of a commit
1657
+ // (getRepoTreeId reads the commit, then a listing a level down to it), and
1658
+ // how many files a tree holds under it and their bytes (repositoryTreeFiles),
1659
+ // by the repository's id, for the next to open it. Asked of them only once
1660
+ // the reader's own access has named the commit, as GitHub would have
1661
+ // answered them; held by each instance a while, by the database it has, and
1662
+ // kept in that database for every instance (npm-kept.ts codeTreeStore).
1663
+ const REPOSITORY_TREES_KEPT = 4096
1664
+ type TreeFiles = Awaited<ReturnType<typeof repositoryTreeFiles>>
1665
+ const repositoryTrees = new WeakMap<ManagedDb, { ids: Lru<string>; files: Lru<TreeFiles> }>()
1666
+ const treeId = (value: unknown): value is string => typeof value === 'string' && /^[a-f\d]{40}$/u.test(value)
1667
+ const treeFiles = (value: unknown): value is TreeFiles => value === null || (plainObject(value)
1668
+ && Number.isSafeInteger(value['files']) && (value['files'] as number) >= 0 && Number.isSafeInteger(value['bytes']) && (value['bytes'] as number) >= 0)
1669
+ function repositoryTreesOf(deps: ManagedHttpDeps) {
1670
+ let held = repositoryTrees.get(deps.db)
1671
+ if (!held) repositoryTrees.set(deps.db, held = { ids: lru(REPOSITORY_TREES_KEPT), files: lru(REPOSITORY_TREES_KEPT) })
1672
+ const { ids, files } = held
1673
+ return {
1674
+ id: (key: string, read: () => Promise<string>) => keptTree(ids, codeTreeStore<string>(deps.db, 'trees', deps.config.debug), key, treeId, read),
1675
+ files: (key: string, read: () => Promise<TreeFiles>) => keptTree(files, codeTreeStore<TreeFiles>(deps.db, 'tree-files', deps.config.debug), key, treeFiles, read),
1676
+ }
1677
+ }
1678
+ // What `memory` holds by `key`, else what `store` keeps of it as `kept`
1679
+ // takes it, else what `read` answers, then held and kept.
1680
+ async function keptTree<T>(memory: Lru<T>, store: KeptStore<T>, key: string, kept: (value: unknown) => value is T, read: () => Promise<T>): Promise<T> {
1681
+ const held = memory.get(key)
1682
+ if (held !== undefined) return held
1683
+ const stored = await store.read(key)
1684
+ let value: T
1685
+ if (stored && kept(stored.value)) value = stored.value
1686
+ else {
1687
+ value = await read()
1688
+ await store.write(key, value, Date.now())
1689
+ }
1690
+ memory.set(key, value)
1691
+ return value
1692
+ }
1693
+
1694
+ // The default branch's head's directories as kept in byte storage
1695
+ // (repository-code.ts repositoryCodeKept), the commit they are kept at in
1696
+ // the database, for a repository active as it is selected; none where this
1697
+ // server has no byte storage for them. Work behind an answer is tracked
1698
+ // (serverless, its invocation kept alive till it settles), as a kept read
1699
+ // read again behind one is.
1700
+ function repositoryCodeKeptOf(deps: ManagedHttpDeps): RepositoryCodeKept | null {
1701
+ if (!deps.repositoryCodeStorage) return null
1702
+ const active = async (repoId: number) => (await deps.db.listSelectedRepos()).some(row => row.repoId === repoId)
1703
+ return repositoryCodeKept(deps.repositoryCodeStorage, codeHeadStore(deps.db, deps.config.debug), active, work => {
1704
+ deps.track(work)
1705
+ deps.waitUntil?.(work)
1706
+ }, deps.config.debug)
1707
+ }
1708
+
1605
1709
  async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1606
1710
  const asked = repositoryQuery(res, query)
1607
1711
  if (!asked) return
1608
1712
  const { repoId, ref, path } = asked
1609
- const authorize = async () => {
1610
- const access = await authorizeRepository(res, deps, cookie, repoId, path)
1713
+ const authorize = async (handed = false) => {
1714
+ const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
1611
1715
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1612
1716
  return access
1613
1717
  }
1614
- const access = await authorize()
1718
+ const access = await authorize(true)
1615
1719
  if (!access) return
1616
1720
  try {
1617
1721
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1618
1722
  const commit = await reader.commit(ref)
1619
1723
  const repo = access.repo.fullName
1620
1724
  const client = createClient({ token: reader.readToken() })
1621
- // A directory the commit has none at is none to read, as browsing it says.
1622
- const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
1623
- throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
1624
- }))
1625
1725
  // Access still held, and the same repository, before anything read is sent.
1626
1726
  const fenced = async () => {
1627
1727
  await reader.recheckAccess()
@@ -1630,22 +1730,54 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
1630
1730
  if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
1631
1731
  return true
1632
1732
  }
1733
+ const send = (bytes: Buffer) => {
1734
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
1735
+ res.writeHead(200, {
1736
+ 'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
1737
+ 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
1738
+ })
1739
+ writeResponse(res, bytes)
1740
+ }
1741
+ // The default branch's head's directory as kept, once the reader's own
1742
+ // access has named its commit, sent as one read is, access checked again;
1743
+ // its place among the active loads held till it is written or abandoned,
1744
+ // else given up before a read from GitHub asks for its own.
1745
+ const at = { repoId, repo, commit, path }, kept = repositoryCodeKeptOf(deps)
1746
+ const atHead = kept !== null && await kept.at(at, ref, reader.defaultBranch)
1747
+ if (atHead) {
1748
+ const keptLoad = kept.read(at)
1749
+ res.once('close', keptLoad.release)
1750
+ const held = await keptLoad.result
1751
+ if (held) {
1752
+ if (await fenced() && !res.destroyed) send(held)
1753
+ return
1754
+ }
1755
+ keptLoad.release()
1756
+ }
1757
+ const trees = repositoryTreesOf(deps)
1758
+ // A directory the commit has none at is none to read, as browsing it says.
1759
+ const tree = await trees.id(`${repoId}\0${commit}\0${path}`, () => upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
1760
+ throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
1761
+ })))
1633
1762
  // Too large names the commit it was read at, for History to read.
1634
- if (!repositoryCodeFits(await repositoryTreeFiles(repo, reader.readToken(), tree))) {
1763
+ const listed = await trees.files(`${repoId}\0${tree}`, () => repositoryTreeFiles(repo, reader.readToken(), tree))
1764
+ if (!repositoryCodeFits(listed)) {
1635
1765
  if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
1636
1766
  return
1637
1767
  }
1638
1768
  if (res.destroyed) return
1639
- const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
1769
+ // A tarball upstream finds no tree in, as @preventive/archive refuses
1770
+ // one with a symlink past the repository's root, is one the viewer
1771
+ // cannot read; one holding another tree is GitHub's answer gone wrong.
1772
+ const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree, listed }, symlinksAbove => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree, symlinksAbove }).catch((err: unknown) => {
1773
+ throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(', got no tree: ') ? new GithubApiError(422, 'directory-unreadable') : err
1774
+ })))
1640
1775
  res.once('close', load.release)
1641
1776
  const bytes = await load.result
1777
+ // Like other GitHub caches, kept before the access fence below.
1778
+ if (atHead) kept.keep(at, bytes)
1642
1779
  if (!(await fenced()) || res.destroyed) return
1643
- res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
1644
- res.writeHead(200, {
1645
- 'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
1646
- 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
1647
- })
1648
- writeResponse(res, bytes)
1780
+ send(bytes)
1649
1781
  } catch (err) {
1650
1782
  if (res.headersSent || res.destroyed) return
1651
1783
  if (err instanceof GithubApiError) { sendGithubError(res, err); return }
@@ -1682,12 +1814,12 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1682
1814
  const starts = from === null ? null : historyStarts(from)
1683
1815
  if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
1684
1816
  || (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
1685
- const authorize = async () => {
1686
- const access = await authorizeRepository(res, deps, cookie, repoId, path)
1817
+ const authorize = async (handed = false) => {
1818
+ const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
1687
1819
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1688
1820
  return access
1689
1821
  }
1690
- const access = await authorize()
1822
+ const access = await authorize(true)
1691
1823
  if (!access) return
1692
1824
  let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
1693
1825
  try {
@@ -1737,12 +1869,24 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1737
1869
  // The pull requests squashed into a whole repository's commits, drawn as
1738
1870
  // the merges they were, and on its first page the open ones' heads,
1739
1871
  // naming commits: with a token, as far as GitHub answers them, the
1740
- // history sent all the same where it doesn't.
1872
+ // history sent all the same where it doesn't. Those GitHub is still
1873
+ // looking up SQUASH_WAIT_MS on are left to the lookup, kept alive behind
1874
+ // the answer, as a kept answer's read is (`pullsPending`, for the client
1875
+ // to ask again).
1741
1876
  const listed = (body as { commits: HistoryCommit[] }).commits
1742
- const [pulls, openPulls] = token && path === '' ? await Promise.all([
1743
- squashedPulls(repo, token, listed, historySquashes(deps, repoId)).catch(quiet<SquashedPull[]>([])),
1877
+ const none = { pulls: [] as SquashedPull[], pending: false }
1878
+ const [squashed, openPulls] = token && path === '' ? await Promise.all([
1879
+ squashedPulls(repo, token, listed, historySquashes(deps, repoId), SQUASH_WAIT_MS).then(read => {
1880
+ if (read.behind) {
1881
+ const behind = read.behind.catch(quiet(null))
1882
+ deps.track(behind)
1883
+ deps.waitUntil?.(behind)
1884
+ }
1885
+ return read
1886
+ }, quiet(none)),
1744
1887
  readingPulls,
1745
- ]) : [[], null]
1888
+ ]) : [none, null]
1889
+ const { pulls } = squashed
1746
1890
  await reader.recheckAccess()
1747
1891
  const current = await authorize()
1748
1892
  if (!current) return
@@ -1752,7 +1896,8 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1752
1896
  const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
1753
1897
  ({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
1754
1898
  sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
1755
- ...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...openPulls?.length ? { openPulls } : {} }))
1899
+ ...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...squashed.pending ? { pullsPending: true } : {},
1900
+ ...openPulls?.length ? { openPulls } : {} }))
1756
1901
  } catch (err) {
1757
1902
  if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1758
1903
  throw err
@@ -1806,12 +1951,12 @@ async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie:
1806
1951
  const asked = repositoryQuery(res, query)
1807
1952
  if (!asked) return
1808
1953
  const { repoId, path } = asked
1809
- const authorize = async () => {
1810
- const access = await authorizeRepository(res, deps, cookie, repoId, path)
1954
+ const authorize = async (handed = false) => {
1955
+ const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
1811
1956
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1812
1957
  return access
1813
1958
  }
1814
- const access = await authorize()
1959
+ const access = await authorize(true)
1815
1960
  if (!access) return
1816
1961
  try {
1817
1962
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
@@ -1848,6 +1993,9 @@ function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
1848
1993
  // and how long one read is answered as kept even where it is asked for afresh.
1849
1994
  const REPOSITORY_READ_FRESH_MS = 2 * 60_000
1850
1995
  const REPOSITORY_READ_FLOOR_MS = 10_000
1996
+ // How long a history's answer waits on GitHub looking up the pull requests
1997
+ // squashed into its commits (handleRepositoryHistory).
1998
+ const SQUASH_WAIT_MS = 1500
1851
1999
  // The repositories' reads being read again, behind a kept answer or as asked
1852
2000
  // for afresh, by key: one at a time, those asking meanwhile answered by it.
1853
2001
  const repositoryRereads = new Map<string, Promise<unknown>>()
@@ -1941,7 +2089,7 @@ function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): H
1941
2089
 
1942
2090
  // The pull requests squashed into repository `repoId`'s commits, kept by
1943
2091
  // each commit and the rules their commits' co-authors were read by
1944
- // (COAUTHORS_READ), null for one none was (repository-history.ts
2092
+ // (COAUTHORS_READ), null for one none was (repository-squashes.ts
1945
2093
  // squashedPulls); one kept before its commits' co-authors were read, read
1946
2094
  // again.
1947
2095
  function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
@@ -1984,28 +2132,55 @@ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, c
1984
2132
  async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
1985
2133
  answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
1986
2134
  unread: () => Promise<object | null> = () => Promise.resolve(null)): Promise<void> {
1987
- const authorize = async () => {
1988
- const access = await authorizeRepository(res, deps, cookie, repoId, '')
1989
- if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1990
- return access
1991
- }
1992
- const access = await authorize()
1993
- if (!access) return
2135
+ const access = await wholeRepositoryAccess(deps, cookie, repoId, { handed: true })
2136
+ if ('error' in access) { sendRefusal(res, access); return }
1994
2137
  const told = await unread()
1995
2138
  if (told) { sendJson(res, 200, await withAppUsers(deps.db, told)); return }
2139
+ const read = await readWholeRepositoryOf(deps, access, answer, () => wholeRepositoryAccess(deps, cookie, repoId))
2140
+ if ('error' in read) { sendRefusal(res, read); return }
2141
+ sendJson(res, 200, await withAppUsers(deps.db, read.value))
2142
+ }
2143
+
2144
+ // What a request is refused, as its answer tells it.
2145
+ type Refusal = { status: number; error: string; retryAfter?: number }
2146
+ const refusal = (status: number, error: string, retryAfter?: number | null): Refusal => ({ status, error, ...retryAfter == null ? {} : { retryAfter } })
2147
+ function sendRefusal(res: ServerResponse, { status, error, retryAfter }: Refusal) {
2148
+ sendJson(res, status, { error }, retryAfter == null ? {} : { 'retry-after': String(retryAfter) })
2149
+ }
2150
+ // The whole of repository `repoId`, where the session `cookie` names may
2151
+ // read all of it, and who reads it; else why not: none of it
2152
+ // (`no-repository`), or only some (`no-directory`).
2153
+ type WholeAccess = { repo: SelectedRepo; user: StoredUser }
2154
+ async function wholeRepositoryAccess(deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, { handed = false } = {}): Promise<WholeAccess | Refusal> {
2155
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
2156
+ if (s == null) return refusal(401, 'unauthenticated')
2157
+ if (!roleAtLeast(s.user.role, 'view')) return refusal(403, 'forbidden')
2158
+ return wholeAccessIn(await repositoryGrants(deps, s.user), s.user, repoId)
2159
+ }
2160
+ function wholeAccessIn(grants: Awaited<ReturnType<typeof repositoryGrants>>, user: StoredUser, repoId: number): WholeAccess | Refusal {
2161
+ const access = repositoryAccessIn(grants, repoId, '')
2162
+ if (!access) return refusal(404, 'no-repository')
2163
+ if (access.virtualEntries !== null) return refusal(404, 'no-directory')
2164
+ return { repo: access.repo, user }
2165
+ }
2166
+ // What `answer` reads of the whole of the repository `access` lets its
2167
+ // reader read, with their own GitHub access: as kept, else with that access
2168
+ // and `reauthorize`'s checked again after; else why it was refused.
2169
+ async function readWholeRepositoryOf<T>(deps: ManagedHttpDeps, access: WholeAccess, answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: T; kept: boolean }>,
2170
+ reauthorize: () => Promise<WholeAccess | Refusal>): Promise<{ value: T; kept: boolean } | Refusal> {
1996
2171
  try {
1997
2172
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1998
2173
  const token = reader.readToken()
1999
- if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
2174
+ if (!token) return refusal(403, 'github-token-required')
2000
2175
  const answered = await answer(access.repo.fullName, token, access.user)
2001
- if (answered.kept) { sendJson(res, 200, await withAppUsers(deps.db, answered.value)); return }
2176
+ if (answered.kept) return answered
2002
2177
  await reader.recheckAccess()
2003
- const current = await authorize()
2004
- if (!current) return
2005
- if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
2006
- sendJson(res, 200, await withAppUsers(deps.db, answered.value))
2178
+ const current = await reauthorize()
2179
+ if ('error' in current) return current
2180
+ if (!sameRepository(current.repo, access.repo)) return refusal(409, 'repository-changed')
2181
+ return answered
2007
2182
  } catch (err) {
2008
- if (err instanceof GithubApiError) { sendGithubError(res, err); return }
2183
+ if (err instanceof GithubApiError) return refusal(err.status === 401 ? 502 : err.status, err.message, err.retryAfter)
2009
2184
  throw err
2010
2185
  }
2011
2186
  }
@@ -2013,7 +2188,9 @@ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps,
2013
2188
  // GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
2014
2189
  // requests tabs show (repository-issues.ts): `kind`'s open or closed
2015
2190
  // (`state`), a page at a time, on from the cursor `after` names. They are
2016
- // the whole repository's (readWholeRepository).
2191
+ // the whole repository's, read as readWholeRepository reads them, kept with
2192
+ // open pull requests' commits, which they never tell (teamOpenPulls reads
2193
+ // them).
2017
2194
  async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2018
2195
  const repoId = Number(query.get('repoId'))
2019
2196
  const kind = query.get('kind')
@@ -2021,8 +2198,11 @@ async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps
2021
2198
  const after = query.get('after')
2022
2199
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
2023
2200
  || (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
2024
- await readWholeRepository(res, deps, cookie, repoId, async (fullName, token) => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }),
2025
- `issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`, true)
2201
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2202
+ const { value: { commits: _commits, ...page }, kept, at } = await keptAnswer(deps, `${repoId}\0issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`,
2203
+ async () => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }))
2204
+ return { value: { ...page, readAt: at }, kept }
2205
+ })
2026
2206
  }
2027
2207
 
2028
2208
  // GET /api/admin/repositories/agents: what the code viewer's Agents tab
@@ -2093,19 +2273,23 @@ async function handleRepositoryPullDependencies(res: ServerResponse, deps: Manag
2093
2273
  }
2094
2274
 
2095
2275
  // 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) {
2276
+ // of it was kept, GitHub asked for none of it: its base's and head's commits
2277
+ // as a page of its repository's open pull requests read them (`commits`,
2278
+ // kept and read again as that page is), else as kept by its number.
2279
+ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, number: number, review: boolean,
2280
+ commits?: PullCommits) {
2098
2281
  const kept = pullDependencyKept(deps, repoId)
2099
2282
  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
- })
2283
+ const answered = commits ? { value: { commits: `${commits.base}...${commits.head}` }, kept: true }
2284
+ : await keptAnswer(deps, `${repoId}\0pull-commits\0${PULL_DEPENDENCIES_READ}\0${number}`, async () => {
2285
+ const read = await pullDependenciesAt(fullName, token, number, kept)
2286
+ reading.told = read.told
2287
+ return { commits: read.commits }
2288
+ })
2105
2289
  // What its commits tell, as read, else as kept by them, else, no longer
2106
- // kept, read again.
2290
+ // kept, read again: by those given, without asking GitHub for them.
2107
2291
  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 }
2292
+ const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept, undefined, commits)).told }
2109
2293
  // Where its review is on, those of lockfiles it leaves out told to none,
2110
2294
  // whoever asks.
2111
2295
  const setting = await deps.db.repoDependencyReview(repoId)
@@ -2116,7 +2300,8 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
2116
2300
  const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
2117
2301
  const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
2118
2302
  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 }
2303
+ const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps), lockfileKept(deps, repoId))
2304
+ : { baseline: null, unread: [], error: null }
2120
2305
  // Its comments read for reviews, where the host reads them, while it is
2121
2306
  // open (pull-comment-reviews.ts reads whether it is with them).
2122
2307
  const parse = reviewed?.parseComments
@@ -2137,32 +2322,40 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
2137
2322
  // a pull request's others are (pull-dependency-reviews.ts), each left unread
2138
2323
  // a directory of its own failing the review, as a pull request's are. Kept,
2139
2324
  // its access not asked again, where its head was kept and its lockfiles and
2140
- // baseline held, GitHub asked for none of them.
2325
+ // baseline held or kept (lockfileKept), GitHub asked for none of them.
2141
2326
  async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2142
2327
  const repoId = Number(query.get('repoId'))
2143
2328
  if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2144
- await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2145
- const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2146
- const head = headRead.value.sha
2147
- if (!head) return { value: { head: null, review: null }, kept: false }
2148
- const asked: GithubAsked = { github: !headRead.kept }
2149
- // Those its review holds to alone read, as many as are read of them.
2150
- const setting = await deps.db.repoDependencyReview(repoId)
2151
- if (!setting?.enabled) return { value: { head, review: null }, kept: false }
2152
- const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path), asked)
2153
- const { packages, paths } = lockedIn(read)
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) },
2155
- ...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
2156
- const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
2157
- if (!now) return { value: { head, review: null }, kept: !asked.github }
2158
- // Told by the lockfiles it held to as they were read, though they have
2159
- // changed since.
2160
- const state = { ...now, lockfiles: setting.lockfiles }
2161
- const reviewed = deps.reviewedDependencies ?? null
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 })
2329
+ await answerWholeRepository(res, deps, cookie, repoId, (fullName, token, user) => branchDependencyAnswer(deps, fullName, token, user, repoId),
2330
+ async () => (await deps.db.repoDependencyReview(repoId))?.enabled ? null : { review: null })
2331
+ }
2332
+
2333
+ // The default branch's answer, as GET branch-dependencies gives it, and
2334
+ // whether all of it was kept, GitHub asked for none of it; its review's
2335
+ // setting as read already (`known`), where it was.
2336
+ async function branchDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number,
2337
+ known?: RepoDependencyReview | null): Promise<{ value: object; kept: boolean }> {
2338
+ const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2339
+ const head = headRead.value.sha
2340
+ if (!head) return { value: { head: null, review: null }, kept: false }
2341
+ const asked: GithubAsked = { github: !headRead.kept }
2342
+ // Those its review holds to alone read, as many as are read of them.
2343
+ const setting = known === undefined ? await deps.db.repoDependencyReview(repoId) : known
2344
+ if (!setting?.enabled) return { value: { head, review: null }, kept: false }
2345
+ const reads = lockfileKept(deps, repoId)
2346
+ const read = await lockedAt(fullName, token, head, reads, path => heldTo(setting.lockfiles, path), asked)
2347
+ const { packages, paths } = lockedIn(read)
2348
+ 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) },
2349
+ ...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
2350
+ const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
2351
+ if (!now) return { value: { head, review: null }, kept: !asked.github }
2352
+ // Told by the lockfiles it held to as they were read, though they have
2353
+ // changed since.
2354
+ const state = { ...now, lockfiles: setting.lockfiles }
2355
+ const reviewed = deps.reviewedDependencies ?? null
2356
+ const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
2357
+ : await baselineFor(fullName, token, repoId, state, baselineKept(deps), reads, asked)
2358
+ return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: !asked.github }
2166
2359
  }
2167
2360
 
2168
2361
  // GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
@@ -2174,7 +2367,195 @@ async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpD
2174
2367
  if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2175
2368
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2176
2369
  const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
2177
- return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head)).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
2370
+ return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head, lockfileKept(deps, repoId))).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
2371
+ })
2372
+ }
2373
+
2374
+ // GET /api/teams/:id/dependencies: what the team's Dependencies tab shows
2375
+ // (team-dependencies.js), in one request rather than one a repository, a
2376
+ // page of its pull requests and a batch of their dependencies: of each of
2377
+ // the team's repositories (those `repoIds` names alone, where it names
2378
+ // some), as lines of JSON as each is read, as the endpoints of each read
2379
+ // them: its default branch's dependencies and its open pull requests
2380
+ // (`{ repoId, branch, pulls: { items, more } }`) where its review is in
2381
+ // effect for the reader, else `{ repoId, branch: null }`, as for one they
2382
+ // may read only some of, or `{ repoId, error, status }`; then its pull
2383
+ // requests' dependencies, of those changing a lockfile or that may,
2384
+ // TEAM_PULLS_TOGETHER at a time (`{ repoId, dependencies }`); each line of
2385
+ // dependencies with the lines their versions wanting reviews change, where
2386
+ // they are counted already (`deltas`, npm-delta.ts keptNpmDeltas), told
2387
+ // before the rows asking for them are drawn. Last, `{ done: true
2388
+ // }`, or, its time up, `{ done: false, unread }`, the repositories left for
2389
+ // the reader to ask for again. The team's repositories, the reader's grants
2390
+ // and the repositories' reviews read once, those reviewing none answered at
2391
+ // once, GitHub asked nothing of them; each read with GitHub has its access
2392
+ // checked again after (readWholeRepositoryOf), and the reader's session
2393
+ // before each line, their team and grants before each telling a
2394
+ // repository's.
2395
+ const TEAM_REPOSITORIES_AT_ONCE = 4
2396
+ const TEAM_DEPENDENCIES_MS = 240_000
2397
+ const TEAM_PULL_PAGES = 20
2398
+ const TEAM_PULLS_TOGETHER = 20
2399
+ const PARTIAL_ACCESS = new Set(['no-directory', 'no-repository'])
2400
+ type TeamDependencyLine = Record<string, unknown> & { repoId?: number }
2401
+ type TeamPull = { number: number; touchesLockfile?: boolean | null }
2402
+ async function handleTeamDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, s: { session: ManagedSession; user: StoredUser },
2403
+ teamId: string, query: URLSearchParams): Promise<void> {
2404
+ const deadline = Date.now() + TEAM_DEPENDENCIES_MS
2405
+ const named = query.get('repoIds')
2406
+ const only = named === null ? null : new Set(named.split(',').map(Number))
2407
+ if (only && (only.size === 0 || only.size > 1000 || [...only].some(id => !Number.isSafeInteger(id) || id <= 0))) { sendJson(res, 400, { error: 'bad-request' }); return }
2408
+ const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
2409
+ const repoIds = [...new Set(snapshot.repositories.map(repo => repo.repoId))].filter(id => !only || only.has(id))
2410
+ const grants = await repositoryGrants(deps, s.user)
2411
+ const settings = await deps.db.getRepoDependencyReviews(repoIds)
2412
+ const out = openLines(res)
2413
+ let stopped = false
2414
+ // Lines written once their reader still reads them as at the start, the
2415
+ // stream running minutes and a kept answer asking nothing again: their
2416
+ // session theirs, and, where a line tells what a repository holds, the
2417
+ // team still theirs and holding it, their grants still reaching the whole
2418
+ // of it. None, and the answer cut short, where not, or the reader is gone.
2419
+ const send = async (...lines: TeamDependencyLine[]) => {
2420
+ if (stopped) return false
2421
+ const now = await readSession(deps.config, deps.db, cookie, Date.now())
2422
+ if (now?.user.id !== s.user.id || !roleAtLeast(now.user.role, 'view')) stopped = true
2423
+ const telling = lines.filter(line => line.repoId !== undefined && (line['branch'] || line['dependencies'])).map(line => line.repoId!)
2424
+ if (!stopped && telling.length > 0 && !await stillReads(now!.user, telling)) stopped = true
2425
+ for (const line of lines) if (stopped || !await out.write(line)) stopped = true
2426
+ if (stopped) out.cut()
2427
+ return !stopped
2428
+ }
2429
+ const stillReads = async (user: StoredUser, ids: number[]) => {
2430
+ const team = await teamSnapshot(deps.db, s.session.id, teamId).catch((err: unknown) => {
2431
+ if (err instanceof TeamReportsError) return null
2432
+ throw err
2433
+ })
2434
+ if (!team) return false
2435
+ const current = await repositoryGrants(deps, user)
2436
+ return ids.every(id => team.repositories.some(repo => repo.repoId === id) && !('error' in wholeAccessIn(current, user, id)))
2437
+ }
2438
+ const reading: { access: WholeAccess; setting: RepoDependencyReview }[] = []
2439
+ const answered: TeamDependencyLine[] = []
2440
+ for (const repoId of repoIds) {
2441
+ const access = wholeAccessIn(grants, s.user, repoId), setting = settings.get(repoId)
2442
+ if ('error' in access || !setting?.enabled) answered.push({ repoId, branch: null })
2443
+ else reading.push({ access, setting })
2444
+ }
2445
+ if (answered.length > 0 && !await send(...answered)) return
2446
+ const done = await eachOf(reading, TEAM_REPOSITORIES_AT_ONCE, ({ access, setting }) => stopped || Date.now() > deadline ? Promise.resolve(false)
2447
+ : teamRepositoryDependencies(deps, access, setting, deadline, send, () => wholeRepositoryAccess(deps, cookie, access.repo.repoId)))
2448
+ if (stopped) return
2449
+ const unread = reading.filter((_, at) => !done[at]).map(({ access }) => access.repo.repoId)
2450
+ if (!await send(unread.length === 0 ? { done: true } : { done: false, unread })) return
2451
+ await out.end()
2452
+ }
2453
+
2454
+ // A repository's lines of its team's Dependencies, as handleTeamDependencies
2455
+ // tells them; whether all were written, none left for its time.
2456
+ async function teamRepositoryDependencies(deps: ManagedHttpDeps, access: WholeAccess, setting: RepoDependencyReview, deadline: number,
2457
+ send: (...lines: TeamDependencyLine[]) => Promise<boolean>, reauthorize: () => Promise<WholeAccess | Refusal>): Promise<boolean> {
2458
+ const repoId = access.repo.repoId
2459
+ type Read = { branch: object | null; pulls?: { items: TeamPull[]; more: boolean }; commits?: Map<number, PullCommits> }
2460
+ const read = await readWholeRepositoryOf<Read>(deps, access, async (fullName, token, user) => {
2461
+ const branch = await branchDependencyAnswer(deps, fullName, token, user, repoId, setting)
2462
+ if (!(branch.value as { review?: unknown }).review) return { value: { branch: null }, kept: branch.kept }
2463
+ const pulls = await teamOpenPulls(deps, repoId, fullName, token)
2464
+ return { value: { branch: branch.value, pulls: { items: pulls.items, more: pulls.more }, commits: pulls.commits }, kept: branch.kept && pulls.kept }
2465
+ }, reauthorize)
2466
+ if ('error' in read) return send({ repoId, ...PARTIAL_ACCESS.has(read.error) ? { branch: null } : { error: read.error, status: read.status } })
2467
+ const { branch, pulls, commits } = read.value
2468
+ if (!branch) return send({ repoId, branch: null })
2469
+ if (!await send({ repoId, branch, pulls, ...await withDeltas(deps, [branch]) })) return false
2470
+ // Each by the commits its page names, where it names them, GitHub not asked
2471
+ // for them (pullDependencyAnswer); never told.
2472
+ const numbers = pulls!.items.filter(item => item.touchesLockfile === true || item.touchesLockfile === null).map(item => item.number)
2473
+ for (let at = 0; at < numbers.length; at += TEAM_PULLS_TOGETHER) {
2474
+ if (Date.now() > deadline) return false
2475
+ const batch = numbers.slice(at, at + TEAM_PULLS_TOGETHER)
2476
+ const pulled = await readWholeRepositoryOf(deps, access, async (fullName, token, user) => {
2477
+ const answers = await eachOf(batch, PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, true, commits?.get(number)).catch((err: unknown) => {
2478
+ // GitHub asked, though it failed: its access asked again after.
2479
+ if (err instanceof GithubApiError) return { value: { number, error: err.message }, kept: false }
2480
+ throw err
2481
+ }))
2482
+ return { value: answers.map(({ value }) => value), kept: answers.every(({ kept }) => kept) }
2483
+ }, reauthorize)
2484
+ const dependencies = 'error' in pulled ? batch.map(number => ({ number, error: pulled.error })) : pulled.value
2485
+ if (!await send({ repoId, dependencies, ...await withDeltas(deps, dependencies) })) return false
2486
+ }
2487
+ return true
2488
+ }
2489
+ const withDeltas = async (deps: ManagedHttpDeps, answers: object[]) => {
2490
+ const deltas = await teamDeltas(deps, answers)
2491
+ return deltas.length > 0 ? { deltas } : {}
2492
+ }
2493
+
2494
+ // A commit's or a blob's id, as GitHub names one.
2495
+ const OBJECT_ID = /^[a-f\d]{40}$/u
2496
+
2497
+ // A repository's open pull requests, as many pages of them as
2498
+ // TEAM_PULL_PAGES, each read and kept as GET issues reads it; each's base's
2499
+ // and head's commits as its page has them (`commits`, by its number), where
2500
+ // it has them; whether it has more, and whether all were kept.
2501
+ async function teamOpenPulls(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
2502
+ const items: TeamPull[] = []
2503
+ const commits = new Map<number, PullCommits>()
2504
+ let after: string | null = null, kept = true
2505
+ for (let page = 0; page < TEAM_PULL_PAGES; page++) {
2506
+ const cursor = after
2507
+ const answer = await keptAnswer(deps, `${repoId}\0issues\0${ISSUES_READ_VERSION}\0pulls\0open\0${cursor ?? ''}`,
2508
+ async () => ({ kind: 'pulls', state: 'open', ...await repositoryIssues(fullName, token, 'pulls', 'open', cursor) }))
2509
+ kept &&= answer.kept
2510
+ const value = answer.value as { items?: unknown; more?: unknown; after?: unknown; commits?: unknown }
2511
+ items.push(...(Array.isArray(value.items) ? value.items : []).filter(item => Number.isSafeInteger(item?.number) && item.number > 0))
2512
+ for (const pull of Array.isArray(value.commits) ? value.commits as { number?: unknown; base?: unknown; head?: unknown }[] : []) {
2513
+ const { number, base, head } = pull ?? {}
2514
+ if (Number.isSafeInteger(number) && typeof base === 'string' && OBJECT_ID.test(base) && typeof head === 'string' && OBJECT_ID.test(head)) {
2515
+ commits.set(number as number, { base, head })
2516
+ }
2517
+ }
2518
+ if (value.more !== true || typeof value.after !== 'string') return { items, commits, more: false, kept }
2519
+ after = value.after
2520
+ }
2521
+ return { items, commits, more: true, kept }
2522
+ }
2523
+
2524
+ // The lines the versions wanting reviews of `answers` change, as their rows
2525
+ // show them (npm-delta.js deltaBase), where they are counted already: of
2526
+ // public versions alone, those npm answered for in a day (npmVersionStore),
2527
+ // their integrities those npmDelta keys them by. `[name, version, base,
2528
+ // delta]` each, none counted here.
2529
+ async function teamDeltas(deps: ManagedHttpDeps, answers: object[]) {
2530
+ const asked = new Map<string, [string, string, string]>()
2531
+ for (const answer of answers as { lockfiles?: { added?: unknown; unreviewed?: unknown }[]; review?: { required?: number } }[]) {
2532
+ const required = answer.review?.required
2533
+ if (!Number.isSafeInteger(required)) continue
2534
+ for (const pkg of (answer.lockfiles ?? []).flatMap(dir => [...Array.isArray(dir.added) ? dir.added : [], ...Array.isArray(dir.unreviewed) ? dir.unreviewed : []]) as
2535
+ { name?: unknown; version?: unknown; source?: unknown; was?: unknown; reviews?: unknown; rejected?: unknown; baseline?: unknown; reviewed?: unknown; unsupported?: unknown }[]) {
2536
+ if (pkg.source !== 'registry' || typeof pkg.name !== 'string' || typeof pkg.version !== 'string' || !isExactVersion(pkg.version)) continue
2537
+ if (pkg.unsupported || pkg.baseline || pkg.reviewed || typeof pkg.reviews !== 'number' || (pkg.reviews >= required! && !pkg.rejected)) continue
2538
+ const was = Array.isArray(pkg.was) ? pkg.was.find(prior => typeof prior === 'string' && prior !== pkg.version) as string | undefined : undefined
2539
+ asked.set(`${pkg.name}\0${pkg.version}\0${was ?? '(empty)'}`, [pkg.name, pkg.version, was ?? '(empty)'])
2540
+ }
2541
+ }
2542
+ if (asked.size === 0) return []
2543
+ const versions = [...asked.values()].flatMap(([name, version, base]) => [{ name, version }, ...base === '(empty)' || !isExactVersion(base) ? [] : [{ name, version: base }]])
2544
+ const kept = await npmVersionStore(deps.db, deps.config.debug).readMany!(versions)
2545
+ const integrity = (name: string, version: string) => {
2546
+ const hit = kept.get(`${name}@${version}`)
2547
+ const dist = hit && Date.now() - hit.at < VERSION_KEPT_MS ? hit.json['dist'] : null
2548
+ return typeof dist === 'object' && dist !== null && typeof (dist as { integrity?: unknown }).integrity === 'string' ? (dist as { integrity: string }).integrity : null
2549
+ }
2550
+ const sides = [...asked.values()].flatMap(([name, version, base]) => {
2551
+ const against = base === '(empty)' ? null : integrity(name, base), other = integrity(name, version)
2552
+ return other && (base === '(empty)' || against) ? [{ name, version, base, key: [against, other] as [string | null, string] }] : []
2553
+ })
2554
+ if (sides.length === 0) return []
2555
+ const counted = await keptNpmDeltas(sides.map(({ key }) => key), npmDeltaStore<NpmDelta>(deps.db, deps.config.debug))
2556
+ return sides.flatMap(({ name, version, base, key: [against, other] }) => {
2557
+ const delta = counted.get(`${against ?? '(empty)'}\0${other}`)
2558
+ return delta ? [[name, version, base, delta]] : []
2178
2559
  })
2179
2560
  }
2180
2561
 
@@ -2215,6 +2596,34 @@ function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependen
2215
2596
  }
2216
2597
  }
2217
2598
 
2599
+ // What reading repository `repoId`'s lockfiles for its dependency review
2600
+ // keeps, by the rules they were read by (PULL_DEPENDENCIES_READ,
2601
+ // lockfileReadStore): each commit's listing, and each lockfile's read, of
2602
+ // those GitHub gave (pull-dependencies.ts LockfileStore). One kept otherwise
2603
+ // is none.
2604
+ function lockfileKept(deps: ManagedHttpDeps, repoId: number): LockfileStore {
2605
+ const listings = lockfileReadStore<ListedLockfile[]>(deps.db, 'listings', deps.config.debug)
2606
+ const lockfiles = lockfileReadStore<LockfileRead>(deps.db, 'lockfiles', deps.config.debug)
2607
+ const keyOf = (key: string) => `${repoId}\0${PULL_DEPENDENCIES_READ}\0${key}`
2608
+ return {
2609
+ listings: {
2610
+ async read(commit) {
2611
+ const value: unknown = (await listings.read(keyOf(commit)))?.value
2612
+ return Array.isArray(value) && value.every(item => typeof item?.path === 'string' && typeof item.format === 'string' && lockfileFormat(item.path) === item.format
2613
+ && typeof item.sha === 'string' && OBJECT_ID.test(item.sha)) ? value : null
2614
+ },
2615
+ write: (commit, listed) => listings.write(keyOf(commit), listed, Date.now()),
2616
+ },
2617
+ lockfiles: {
2618
+ async read(key) {
2619
+ const value = (await lockfiles.read(keyOf(key)))?.value as { packages?: unknown; error?: unknown } | null | undefined
2620
+ return value && (Array.isArray(value.packages) || value.error === 'lockfile-unreadable') ? value as LockfileRead : null
2621
+ },
2622
+ write: (key, read) => lockfiles.write(keyOf(key), read, Date.now()),
2623
+ },
2624
+ }
2625
+ }
2626
+
2218
2627
  // GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
2219
2628
  // (repository-pulse.ts): the default branch's year, counted from its commits
2220
2629
  // (pulseCommits), and the pull requests and issues of the last `weeks`
@@ -2441,7 +2850,7 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
2441
2850
  // reader leaving, losing their admin role or
2442
2851
  // the server shutting down stops it.
2443
2852
  async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2444
- const s = await readAdminSession(res, deps, cookie)
2853
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
2445
2854
  if (!s) return
2446
2855
  const repos = await riskRepositories(deps.db, query)
2447
2856
  if (!repos) {
@@ -2724,7 +3133,7 @@ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, dep
2724
3133
  }
2725
3134
 
2726
3135
  async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2727
- const initial = await manageMutation(req, res, deps, cookie)
3136
+ const initial = await manageMutation(req, res, deps, cookie, { handed: true })
2728
3137
  if (!initial) return
2729
3138
  const controller = new AbortController()
2730
3139
  const onClose = () => controller.abort()
@@ -2763,9 +3172,8 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
2763
3172
  throw new BundleBuildError(409, 'repository-changed')
2764
3173
  }
2765
3174
  signal.throwIfAborted()
2766
- const bytes = Buffer.from(built.bytes)
2767
- await storeUploadedBundle(req, res, deps, cookie, current, bytes, input.repoId, built.directory, built.filename, 'build',
2768
- bundleIntegrity(bytes), input.conditions)
3175
+ await storeUploadedBundle(req, res, deps, cookie, current, built.bytes, input.repoId, built.directory, built.filename, 'build',
3176
+ bundleIntegrity(built.bytes), input.conditions)
2769
3177
  })
2770
3178
  } catch (error) {
2771
3179
  if (res.destroyed || controller.signal.aborted) return
@@ -2788,7 +3196,7 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
2788
3196
  // After storing, any reports that declared this integrity but weren't linked yet
2789
3197
  // get attached (auto-link). 413 over the cap, 400 on empty.
2790
3198
  async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2791
- let s = await checkMutation(req, res, deps, cookie)
3199
+ let s = await checkMutation(req, res, deps, cookie, { handed: true })
2792
3200
  if (s == null) return
2793
3201
  if (!requireManageRole(res, s.user)) return
2794
3202
  let bytes: Buffer
@@ -2846,15 +3254,17 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
2846
3254
  repo.repoId = null
2847
3255
  directory = ''
2848
3256
  }
2849
- await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity)
3257
+ await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity, null, existing === null)
2850
3258
  }
2851
3259
 
3260
+ // `absent` where the caller has just found no bundle of `integrity`: not read
3261
+ // again, one inserted since meeting the insert's UNIQUE below as a race does.
2852
3262
  async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
2853
3263
  s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
2854
- provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null): Promise<void> {
3264
+ provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null, absent = false): Promise<void> {
2855
3265
  const kind = bundleKind(filename)
2856
3266
  const reuse = { provenance, filename, kind, buildConditions }
2857
- const existing = await deps.db.getBundleByIntegrity(integrity)
3267
+ const existing = absent ? null : await deps.db.getBundleByIntegrity(integrity)
2858
3268
  if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, reuse); return }
2859
3269
  const id = randomUUID()
2860
3270
  const dataKey = await deps.bundleStore.put(id, bytes, kind)
@@ -2916,7 +3326,7 @@ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps:
2916
3326
  // the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
2917
3327
  // best-effort. 404 when there was no such bundle.
2918
3328
  async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
2919
- const s = await checkMutation(req, res, deps, cookie)
3329
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
2920
3330
  if (s == null) return
2921
3331
  if (!requireManageRole(res, s.user)) return
2922
3332
  const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
@@ -2929,8 +3339,8 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
2929
3339
 
2930
3340
  // admin|manage mutation guard: same-origin + CSRF + role, in one step. Returns
2931
3341
  // the session, or null after the 401/403 was sent.
2932
- async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
2933
- const s = await checkMutation(req, res, deps, cookie)
3342
+ async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
3343
+ const s = await checkMutation(req, res, deps, cookie, { handed })
2934
3344
  if (s == null) return null
2935
3345
  if (!requireManageRole(res, s.user)) return null
2936
3346
  return s
@@ -2938,8 +3348,8 @@ async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: M
2938
3348
 
2939
3349
  // Workspace access and repository connections are administered separately
2940
3350
  // from content management. Managers cannot change their own access via teams.
2941
- async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
2942
- const s = await checkMutation(req, res, deps, cookie)
3351
+ async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
3352
+ const s = await checkMutation(req, res, deps, cookie, { handed })
2943
3353
  if (s == null) return null
2944
3354
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
2945
3355
  return s
@@ -2984,19 +3394,18 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
2984
3394
  // need a published report in their teams; ownership never overrides role none.
2985
3395
  async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
2986
3396
  if (!roleAtLeast(user.role, 'view')) return false
2987
- const report = await deps.db.getReport(reportId)
3397
+ const report = await deps.db.getReportView(user.id, reportId)
2988
3398
  if (report == null) return false
2989
3399
  if (user.role === 'admin') return true
2990
- if (user.role === 'manage') return report.uploadedBy === user.id || deps.db.userCanReadReport(user.id, reportId)
2991
- if (!report.visible) return false
2992
- return deps.db.userCanReadReport(user.id, reportId)
3400
+ if (user.role === 'manage') return report.uploadedBy === user.id || report.readable
3401
+ return report.visible && report.readable
2993
3402
  }
2994
3403
 
2995
3404
  // GET/HEAD /api/reports/:id/sources. Only the linked bundle's files cited by
2996
3405
  // visible findings (location AND evidence) are returned. Missing/unavailable
2997
3406
  // bundles are a quiet no-op; cached gzip bodies stream directly from disk.
2998
3407
  async function handleReportSources(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null) {
2999
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
3408
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
3000
3409
  if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3001
3410
  if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3002
3411
  const sourcePaths = roleAtLeast(s.user.role, 'manage') ? undefined : await teamSourcePaths(deps.db, deps.reportStore, s.session.id, teamId ?? '', id)
@@ -3040,7 +3449,7 @@ async function reportIds(data: unknown): Promise<Set<string>> {
3040
3449
  async function handleDeduplication(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
3041
3450
  const method = req.method ?? 'GET'
3042
3451
  if (!(id ? ['GET', 'PATCH'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'GET, PATCH' : 'GET, POST'); return }
3043
- const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
3452
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
3044
3453
  if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
3045
3454
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
3046
3455
  if (method === 'GET') { sendJson(res, 200, id ? await deps.db.getLinkReport(s.session.id, id) : { reports: await deps.db.listLinkReports() }); return }
@@ -3063,7 +3472,7 @@ async function handleDeduplication(req: IncomingMessage, res: ServerResponse, de
3063
3472
  async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
3064
3473
  const method = req.method ?? 'GET'
3065
3474
  if (!(id ? ['PATCH', 'DELETE'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'PATCH, DELETE' : 'GET, POST'); return }
3066
- const s = method === 'GET' ? await readAdminSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
3475
+ const s = method === 'GET' ? await readAdminSession(res, deps, cookie, { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
3067
3476
  if (!s) return
3068
3477
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
3069
3478
  if (method === 'GET') {
@@ -3083,7 +3492,7 @@ async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse
3083
3492
  // Managers can resolve only destinations their current repository grants cover.
3084
3493
  async function handleRepositorySuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3085
3494
  if (req.method !== 'GET') { send405(res, 'GET'); return }
3086
- if (!await readManageSession(res, deps, cookie)) return
3495
+ if (!await readManageSession(res, deps, cookie, { handed: true })) return
3087
3496
  const params = new URL(req.url!, 'http://localhost').searchParams
3088
3497
  const github = reportRepoGithub({ repo: { github: params.get('repo') } })
3089
3498
  const directory = normalizeTeamPath(params.get('directory'))
@@ -3121,7 +3530,7 @@ async function suggestReportLocation(deps: ManagedHttpDeps, user: StoredUser, da
3121
3530
  // location editor. It never changes the stored assignment.
3122
3531
  async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
3123
3532
  if (req.method !== 'GET') { send405(res, 'GET'); return }
3124
- const s = await readManageSession(res, deps, cookie)
3533
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3125
3534
  if (!s) return
3126
3535
  const report = await deps.db.getReport(id)
3127
3536
  if (!report || !(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
@@ -3143,7 +3552,7 @@ async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerR
3143
3552
  // "not authorized" (so neither existence nor membership is probeable); 503 = row
3144
3553
  // without bytes (store desync).
3145
3554
  async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
3146
- const s = await readManageSession(res, deps, cookie)
3555
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3147
3556
  if (s == null) return
3148
3557
  const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
3149
3558
  const access = snapshot?.reports[0]
@@ -3180,7 +3589,7 @@ async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps:
3180
3589
  const REPORT_QUERY_CONCURRENCY = 8
3181
3590
  const REPORT_QUERY_IN_FLIGHT_BYTES = 64 * 1024 * 1024
3182
3591
  async function handleQueryReports(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3183
- const s = await readManageSession(res, deps, cookie)
3592
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3184
3593
  if (!s) return
3185
3594
  let body
3186
3595
  try { body = await readJsonBody(req, 1024 * 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3274,9 +3683,11 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
3274
3683
 
3275
3684
  // The server selects the complete published workspace for classification.
3276
3685
  // Privileged hidden-report previews are independently scoped and bounded.
3277
- async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string, reportId: string | null): Promise<void> {
3686
+ // Its reports' file trees are asked for apart, by its graph or Files alone.
3687
+ async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string, reportId: string | null,
3688
+ part: 'reports' | 'trees' = 'reports'): Promise<void> {
3278
3689
  const snapshot = await teamSnapshot(deps.db, session.id, teamId, reportId)
3279
- const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
3690
+ const body = await (part === 'trees' ? loadTeamTreesResponse : loadTeamReportsResponse)(deps.db, deps.reportStore, snapshot)
3280
3691
  await recheckTeam(deps.db, session.id, snapshot)
3281
3692
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
3282
3693
  writeResponse(res, body)
@@ -3333,7 +3744,7 @@ async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256
3333
3744
  // stripped finding must not leak that the finding exists. A cleared entry is
3334
3745
  // sent as null (the server's tombstone), distinct from one never set.
3335
3746
  async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
3336
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
3747
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
3337
3748
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3338
3749
  if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3339
3750
  await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
@@ -3352,7 +3763,7 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
3352
3763
  // exists). Each event is the entry as written then (null = a clear), who
3353
3764
  // wrote it and when; "what changed" is the diff against the next-older event.
3354
3765
  async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
3355
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
3766
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
3356
3767
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3357
3768
  if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3358
3769
  const finding = query.get('finding') ?? ''
@@ -3383,7 +3794,7 @@ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHt
3383
3794
  // scope for rows that are themselves per finding id — so a stripped (or
3384
3795
  // foreign) finding id 404s without revealing whether it exists.
3385
3796
  async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
3386
- const s = await checkMutation(req, res, deps, cookie)
3797
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
3387
3798
  if (s == null) return
3388
3799
  if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3389
3800
  let body: unknown
@@ -3417,7 +3828,7 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
3417
3828
  // GET only: clients match local annotations against these IDs without sending
3418
3829
  // their local finding IDs or annotation bodies to discover which reports exist.
3419
3830
  async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
3420
- const s = await readAdminSession(res, deps, cookie)
3831
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
3421
3832
  if (!s) return
3422
3833
  const after = search.get('after') ?? ''
3423
3834
  if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
@@ -3439,7 +3850,7 @@ async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps
3439
3850
  // Admin imports compare the exact snapshot shown by the conflict dialog before
3440
3851
  // writing. The store commits shared finding triage and imported comments together.
3441
3852
  async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
3442
- const s = await adminMutation(req, res, deps, cookie)
3853
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3443
3854
  if (!s) return
3444
3855
  let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
3445
3856
  try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof body }
@@ -3478,7 +3889,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
3478
3889
  if (commentId == null ? method !== 'GET' && method !== 'POST' : method !== 'PATCH' && method !== 'DELETE') {
3479
3890
  send405(res, commentId == null ? 'GET, POST' : 'PATCH, DELETE'); return
3480
3891
  }
3481
- const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
3892
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
3482
3893
  if (s == null) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
3483
3894
  const allowed = method === 'GET' ? await canViewReport(deps, s.user, reportId) : await canTriageReport(deps, s.user, reportId)
3484
3895
  if (!allowed) { sendJson(res, 404, { error: 'no-report' }); return }
@@ -3526,7 +3937,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
3526
3937
  // the page needs: all users (member dropdown), selected repos (repo dropdown),
3527
3938
  // and the visibility-permission keys. admin, read-only (no CSRF).
3528
3939
  async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3529
- const s = await readAdminSession(res, deps, cookie)
3940
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
3530
3941
  if (s == null) return
3531
3942
  const scopes = await deps.db.listTeamNpmScopes()
3532
3943
  const reviewTeams = new Set(await deps.db.listDependencyReviewEnabledTeams())
@@ -3540,7 +3951,7 @@ async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cooki
3540
3951
 
3541
3952
  // POST /api/admin/teams — create a team. Body { name }. 409 if the name's taken.
3542
3953
  async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3543
- const s = await adminMutation(req, res, deps, cookie)
3954
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3544
3955
  if (s == null) return
3545
3956
  let body: unknown
3546
3957
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3557,7 +3968,7 @@ async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps:
3557
3968
  // POST /api/admin/teams/rename — rename a team. Body { teamId, name }. 404 if no
3558
3969
  // such team; 409 if the new name is already taken by another team.
3559
3970
  async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3560
- const s = await adminMutation(req, res, deps, cookie)
3971
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3561
3972
  if (s == null) return
3562
3973
  let body: unknown
3563
3974
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3576,7 +3987,7 @@ async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps:
3576
3987
 
3577
3988
  // Hidden teams retain their configuration but grant no access or sidebar presence.
3578
3989
  async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3579
- const s = await adminMutation(req, res, deps, cookie)
3990
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3580
3991
  if (s == null) return
3581
3992
  let body: unknown
3582
3993
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3592,7 +4003,7 @@ async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, de
3592
4003
 
3593
4004
  // POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
3594
4005
  async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3595
- const s = await adminMutation(req, res, deps, cookie)
4006
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3596
4007
  if (s == null) return
3597
4008
  let body: unknown
3598
4009
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3609,7 +4020,7 @@ async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps:
3609
4020
  // path links with the whole repository. Body { teamId, repoId, path? }.
3610
4021
  // Repo must be in the selected set.
3611
4022
  async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3612
- const s = await adminMutation(req, res, deps, cookie)
4023
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3613
4024
  if (s == null) return
3614
4025
  let body: unknown
3615
4026
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3634,7 +4045,7 @@ async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps
3634
4045
  // POST /api/admin/teams/remove-repo — unlink one path. Omitting path retains
3635
4046
  // the legacy remove-all behavior. Body { teamId, repoId, path? }.
3636
4047
  async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3637
- const s = await adminMutation(req, res, deps, cookie)
4048
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3638
4049
  if (s == null) return
3639
4050
  let body: unknown
3640
4051
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3654,7 +4065,7 @@ async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, d
3654
4065
  // POST /api/admin/teams/set-member — add/update a member + their visibility
3655
4066
  // permissions. Body { teamId, userId, dependencies?, security? }.
3656
4067
  async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3657
- const s = await adminMutation(req, res, deps, cookie)
4068
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3658
4069
  if (s == null) return
3659
4070
  let body: unknown
3660
4071
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3677,7 +4088,7 @@ async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, de
3677
4088
 
3678
4089
  // POST /api/admin/teams/remove-member — remove a membership. Body { teamId, userId }.
3679
4090
  async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3680
- const s = await adminMutation(req, res, deps, cookie)
4091
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3681
4092
  if (s == null) return
3682
4093
  let body: unknown
3683
4094
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3693,7 +4104,7 @@ async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse,
3693
4104
  }
3694
4105
 
3695
4106
  async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3696
- const s = await adminMutation(req, res, deps, cookie)
4107
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3697
4108
  if (!s) return
3698
4109
  let body
3699
4110
  try { body = await readJsonBody(req) as { teamId?: unknown; enabled?: unknown } } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3705,7 +4116,7 @@ async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerRe
3705
4116
  // POST /api/admin/teams/set-npm-scopes — replace the npm scopes whose private
3706
4117
  // packages the team's members may read. Body { teamId, scopes: ['@scope'] }.
3707
4118
  async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3708
- const s = await adminMutation(req, res, deps, cookie)
4119
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3709
4120
  if (s == null) return
3710
4121
  let body: unknown
3711
4122
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3724,10 +4135,18 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
3724
4135
  // npm scopes of its visible teams, else none: none are read for an unscoped
3725
4136
  // package, an admin or a manager, or an answer checked again that is public.
3726
4137
  // Null for none.
3727
- async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
3728
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
4138
+ async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[], { handed = false } = {}): Promise<NpmReader | null> {
4139
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
3729
4140
  return s == null ? null : npmReaderOf(deps, s.user, names)
3730
4141
  }
4142
+ // The reader of the session `cookie` names now, as npmReader reads them,
4143
+ // their npm scopes whatever the packages, and whether they read team
4144
+ // `teamId`'s reviews: one statement.
4145
+ async function npmReviewReader(deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<(NpmReader & { readsTeam: boolean }) | null> {
4146
+ const ids = sessionIds(deps.config, cookie)
4147
+ const reader = ids && await deps.db.getNpmReviewReader(ids.id, ids.viewer, Date.now(), teamId)
4148
+ return reader && roleAtLeast(reader.role, 'view') ? { role: reader.role, scopes: new Set(reader.scopes), userId: reader.id, readsTeam: reader.readsTeam } : null
4149
+ }
3731
4150
  // The same of `user`, as a session the request read just now tells them.
3732
4151
  async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
3733
4152
  if (!roleAtLeast(user.role, 'view')) return null
@@ -3748,9 +4167,11 @@ const REVIEW_READS_AT_ONCE = 8
3748
4167
 
3749
4168
  // Lists must enforce the same target and base access as the review itself,
3750
4169
  // including permission changes while the registry requests are in flight;
3751
- // first as `session`, the one the request read just now, where given.
4170
+ // first as `session`, the one the request read just now, where given. A
4171
+ // team's (`teamId`) only to one who still reads its reviews there, asked
4172
+ // again with the rest (npmReviewReader).
3752
4173
  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> {
4174
+ session?: { id: string; role: NpmReader['role'] }, teamId?: string): Promise<T[] | null> {
3754
4175
  const controller = new AbortController()
3755
4176
  const onClose = () => { if (!res.writableEnded) controller.abort() }
3756
4177
  res.on('close', onClose)
@@ -3769,8 +4190,10 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
3769
4190
  const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
3770
4191
  const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
3771
4192
  const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
3772
- const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
4193
+ const current = teamId === undefined ? await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
4194
+ : await npmReviewReader(deps, cookie, teamId)
3773
4195
  if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
4196
+ if ('readsTeam' in current && !current.readsTeam) { sendJson(res, 404, { error: 'no-team' }); return null }
3774
4197
  return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
3775
4198
  } catch (error) {
3776
4199
  if (controller.signal.aborted) return null
@@ -3808,13 +4231,16 @@ async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReade
3808
4231
  return npmReviewVerdictLimit(doc, list, socket, risk)
3809
4232
  }
3810
4233
 
3811
- // Reviews are scoped to the bytes actually displayed, including the base.
3812
- // Access to both versions is rechecked before returning or saving annotations.
4234
+ // Reviews are scoped to the bytes actually displayed, including the base:
4235
+ // each change but a revocation loads both versions' files, of tarballs held
4236
+ // to their integrity and to their shasum where npm tells one (npm-loads.ts),
4237
+ // and the review keeps those shasums. Access to both versions is rechecked
4238
+ // before returning or saving annotations.
3813
4239
  async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
3814
4240
  const method = req.method ?? 'GET'
3815
4241
  if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
3816
4242
  const mutation = method !== 'GET'
3817
- const session = mutation ? await checkMutation(req, res, deps, cookie) : await readSession(deps.config, deps.db, cookie, Date.now())
4243
+ const session = mutation ? await checkMutation(req, res, deps, cookie, { handed: true }) : await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
3818
4244
  if (!session) { if (!mutation) sendJson(res, 401, { error: 'unauthenticated' }); return }
3819
4245
  if (!roleAtLeast(session.user.role, mutation ? 'triage' : 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
3820
4246
  let body: Record<string, unknown> = {}
@@ -3889,10 +4315,14 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3889
4315
  || (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
3890
4316
  sendJson(res, 409, { error: 'review-content-changed' }); return
3891
4317
  }
3892
- if (change?.action === 'publish' || change?.action === 'save') {
3893
- const loads = [doc, ...base ? [base] : []].map(loadNpmFiles)
3894
- try {
3895
- const files = await Promise.all(loads.map(load => load.result))
4318
+ // Each change but a revocation loads both versions' files; a save or a
4319
+ // publication is held to them.
4320
+ const loads = change && change.action !== 'revoke' ? [doc, ...base ? [base] : []].map(loadNpmFiles) : []
4321
+ let tarballs: ReviewedTarballs | undefined
4322
+ try {
4323
+ const files = await Promise.all(loads.map(load => load.result))
4324
+ if (loads.length > 0) tarballs = { shasum: doc.dist.shasum, baseShasum: base?.dist.shasum ?? null }
4325
+ if (change?.action === 'publish' || change?.action === 'save') {
3896
4326
  const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
3897
4327
  const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
3898
4328
  const diff = computeBundleDiff(before, after, () => '.')
@@ -3906,8 +4336,8 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3906
4336
  change.files = changed.map(file => file.path)
3907
4337
  change.nonTextFiles = changed.filter(file => !reviewable(file)).map(file => file.path)
3908
4338
  }
3909
- } finally { loads.forEach(load => load.release()) }
3910
- }
4339
+ }
4340
+ } finally { loads.forEach(load => load.release()) }
3911
4341
  const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
3912
4342
  ? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
3913
4343
  const current = await npmReader(deps, cookie, [doc, base].flatMap(item => item?.private ? [item.name] : []))
@@ -3926,7 +4356,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3926
4356
  if (change.action === 'publish' && change.final && !allowedReviewVerdicts([], limit).includes(change.final.status)) {
3927
4357
  sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
3928
4358
  }
3929
- const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
4359
+ const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now(), tarballs)
3930
4360
  sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
3931
4361
  } catch (err) {
3932
4362
  if (controller.signal.aborted) return
@@ -3936,6 +4366,62 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3936
4366
  } finally { res.off('close', onClose) }
3937
4367
  }
3938
4368
 
4369
+ // GET /api/npm/deltas?versions=[[name, version, otherVersion], …]: the lines
4370
+ // each of up to MAX_ASKED_DELTAS versions changes against another, or
4371
+ // `(empty)`, as GET delta counts them, as lines of JSON as each is counted
4372
+ // (`{ name, version, otherVersion, …delta }`, or `{ name, version,
4373
+ // otherVersion, error, status }`), as a list's rows near the viewport ask
4374
+ // for them together (npm-delta.js), DELTAS_AT_ONCE at a time. Each read as
4375
+ // GET delta reads it, with the reader's access to its package, which is
4376
+ // checked again once it is counted: a private one's told only to a reader
4377
+ // who may still read it, none told to one whose session is gone.
4378
+ const MAX_ASKED_DELTAS = 50
4379
+ const DELTAS_AT_ONCE = 2
4380
+ async function handleNpmDeltas(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
4381
+ if (req.method !== 'GET') { send405(res, 'GET'); return }
4382
+ let asked: unknown = null
4383
+ try { asked = JSON.parse(params.get('versions') ?? '') } catch {}
4384
+ if (!Array.isArray(asked) || asked.length === 0 || asked.length > MAX_ASKED_DELTAS || !asked.every(item => Array.isArray(item) && item.length === 3
4385
+ && isNpmPackageName(item[0]) && isNpmPackageSpec(item[1]) && (item[2] === '(empty)' || isNpmPackageSpec(item[2])))) { sendJson(res, 400, { error: 'bad-package' }); return }
4386
+ const versions = asked as [string, string, string][]
4387
+ const reader = await npmReader(deps, cookie, [...new Set(versions.map(([name]) => name))], { handed: true })
4388
+ if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4389
+ const controller = new AbortController()
4390
+ res.once('close', () => { if (!res.writableEnded) controller.abort() })
4391
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
4392
+ const kept = npmDeltaStore<NpmDelta>(deps.db, deps.config.debug), keptVersions = npmVersionStore(deps.db, deps.config.debug)
4393
+ const out = openLines(res)
4394
+ let stopped = false
4395
+ await eachOf(versions, DELTAS_AT_ONCE, async ([name, version, otherVersion]) => {
4396
+ if (stopped || controller.signal.aborted) return
4397
+ const asOne = { name, version, otherVersion }
4398
+ let isPrivate = false, line: Record<string, unknown>
4399
+ try {
4400
+ const privileged = canReadPrivateNpm(reader, name)
4401
+ const together = otherVersion === '(empty)' ? keptVersions : readTogether(keptVersions, [{ name, version }, { name, version: otherVersion }])
4402
+ const [doc, old] = await Promise.all([readNpmVersion(name, version, privileged, controller.signal, together).then(found),
4403
+ otherVersion === '(empty)' ? null : readNpmVersion(name, otherVersion, privileged, controller.signal, together).then(found)])
4404
+ isPrivate = doc.private || old?.private === true
4405
+ const side = (of: NpmVersionDocument) => ({ integrity: of.dist.integrity, files: async () => {
4406
+ const load = loadNpmFiles(of)
4407
+ try { return await load.result } finally { load.release() }
4408
+ } })
4409
+ line = { ...asOne, ...await npmDelta(old ? side(old) : null, side(doc), kept) }
4410
+ } catch (err) {
4411
+ if (controller.signal.aborted) return
4412
+ if (!(err instanceof NpmPackageError)) console.warn('managed: a package delta failed:', err)
4413
+ line = err instanceof NpmPackageError ? { ...asOne, error: err.message, status: err.status } : { ...asOne, error: 'delta-failed', status: 500 }
4414
+ }
4415
+ // A reader who lost their session, or their access to a private one,
4416
+ // while it was counted, is told nothing of it.
4417
+ const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
4418
+ if (current == null) { stopped = true; out.cut(); return }
4419
+ if (isPrivate && !canReadPrivateNpm(current, name)) line = { ...asOne, error: 'package-not-found', status: 404 }
4420
+ if (!stopped && !await out.write(line)) stopped = true
4421
+ })
4422
+ if (!stopped) await out.end()
4423
+ }
4424
+
3939
4425
  // GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
3940
4426
  // (canReadPrivateNpm) can be answered from the server's npm token; the rest get
3941
4427
  // what the registry answers anonymously, every time (see npm-packages.ts).
@@ -3956,7 +4442,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3956
4442
  }
3957
4443
  const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
3958
4444
  if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
3959
- const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
4445
+ const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName], { handed: true })
3960
4446
  if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3961
4447
  const controller = new AbortController()
3962
4448
  const onClose = () => { if (!res.writableEnded) controller.abort() }
@@ -4201,7 +4687,7 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
4201
4687
  const method = req.method ?? 'GET'
4202
4688
  const allowed = id ? ['PATCH', 'DELETE'] : ['GET', 'POST', 'DELETE']
4203
4689
  if (!allowed.includes(method)) { send405(res, allowed.join(', ')); return }
4204
- const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
4690
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
4205
4691
  if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
4206
4692
  if (!requireManageRole(res, s.user)) return
4207
4693
  const { db } = deps
@@ -4230,6 +4716,9 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
4230
4716
  export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4231
4717
  const { config, db, avatarStore, isShuttingDown, track } = deps
4232
4718
  const repositoryDiscovery = new RepositoryDiscovery(config)
4719
+ // Kept reads' prunes run behind their writes: tracked, so the database
4720
+ // outlives them, and on serverless kept alive past the answer.
4721
+ ownKeptPrunes(db, prune => { track(prune); deps.waitUntil?.(prune) })
4233
4722
 
4234
4723
  async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
4235
4724
  const url = new URL(req.url ?? '/', 'http://localhost')
@@ -4329,6 +4818,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4329
4818
  const managedDataPath = underPrefix(path, MANAGED_DATA_PREFIXES)
4330
4819
  const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
4331
4820
  if (managedDataPath && !workspaceSession) return
4821
+ // What it read, for the handler's own first read to take.
4822
+ if (workspaceSession) handOverSession(cookie, workspaceSession)
4332
4823
  const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
4333
4824
  if (issueRoute) {
4334
4825
  await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
@@ -4341,8 +4832,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4341
4832
  if (path === '/api/admin/links') {
4342
4833
  if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
4343
4834
  if (method !== 'GET') { send405(res, 'GET'); return }
4344
- const s = await readSession(config, db, cookie, Date.now())
4345
- if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4835
+ const s = workspaceSession!
4346
4836
  if (!requireManageRole(res, s.user)) return
4347
4837
  const shares = await db.listManagedWorkspaceShares(s.session.id, Date.now())
4348
4838
  sendJson(res, shares ? 200 : 403, shares ? { shares } : { error: 'forbidden' }); return
@@ -4360,8 +4850,6 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4360
4850
  // sign-in account's. A linked account's is served by its own id.
4361
4851
  if (path.startsWith(AVATAR_PREFIX)) {
4362
4852
  if (method !== 'GET') { send405(res, 'GET'); return }
4363
- const s = await readSession(config, db, cookie, Date.now())
4364
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4365
4853
  const id = path.slice(AVATAR_PREFIX.length)
4366
4854
  if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
4367
4855
  await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
@@ -4374,9 +4862,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4374
4862
  }
4375
4863
  if (path === ADMIN_USERS_PATH) {
4376
4864
  if (method !== 'GET') { send405(res, 'GET'); return }
4377
- const s = await readSession(config, db, cookie, Date.now())
4378
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4379
- if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
4865
+ if (workspaceSession!.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
4380
4866
  sendJson(res, 200, { users: await db.listUsers() })
4381
4867
  return
4382
4868
  }
@@ -4548,13 +5034,17 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4548
5034
  const teamFeed = /^\/api\/teams\/([^/]+)\/feed$/u.exec(path)
4549
5035
  if (teamFeed || path === '/api/teams/feed') {
4550
5036
  if (method !== 'GET') { send405(res, 'GET'); return }
4551
- const s = await readWorkspaceSession(res, deps, cookie)
4552
- if (!s) return
5037
+ const s = workspaceSession!
4553
5038
  const teamId = teamFeed?.[1] ?? null
4554
5039
  const reportId = url.searchParams.get('reportId')
4555
5040
  if (teamId) await teamSnapshot(db, s.session.id, teamId, reportId)
4556
5041
  await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId, { reportId }); return
4557
5042
  }
5043
+ const teamDependencies = /^\/api\/teams\/([^/]+)\/dependencies$/u.exec(path)
5044
+ if (teamDependencies) {
5045
+ if (method !== 'GET') { send405(res, 'GET'); return }
5046
+ await handleTeamDependencies(res, deps, cookie, workspaceSession!, teamDependencies[1]!, url.searchParams); return
5047
+ }
4558
5048
  const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
4559
5049
  if (teamFixes) {
4560
5050
  if (method !== 'GET') { send405(res, 'GET'); return }
@@ -4568,10 +5058,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4568
5058
  await recheckTeam(db, workspaceSession!.session.id, snapshot)
4569
5059
  sendJson(res, 200, annotations); return
4570
5060
  }
4571
- const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
5061
+ const teamReports = /^\/api\/teams\/([^/]+)\/(reports|trees)$/u.exec(path)
4572
5062
  if (teamReports) {
4573
5063
  if (method !== 'GET') { send405(res, 'GET'); return }
4574
- await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId')); return
5064
+ await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId'), teamReports[2] as 'reports' | 'trees'); return
4575
5065
  }
4576
5066
  if (path === REPORT_QUERY_PATH) {
4577
5067
  if (method !== 'POST') { send405(res, 'POST'); return }
@@ -4639,8 +5129,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4639
5129
  }
4640
5130
  if (path === '/api/npm/reviews/latest') {
4641
5131
  if (method !== 'GET') { send405(res, 'GET'); return }
4642
- const s = await readWorkspaceSession(res, deps, cookie)
4643
- if (!s) return
5132
+ const s = workspaceSession!
4644
5133
  const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
4645
5134
  if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
4646
5135
  sendJson(res, 400, { error: 'bad-review-context' }); return
@@ -4652,7 +5141,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4652
5141
  }
4653
5142
  if (path === '/api/npm/reviews/discard') {
4654
5143
  if (method !== 'POST') { send405(res, 'POST'); return }
4655
- const s = await checkMutation(req, res, deps, cookie)
5144
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
4656
5145
  if (!s) return
4657
5146
  let body: Record<string, unknown>
4658
5147
  try {
@@ -4669,8 +5158,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4669
5158
  }
4670
5159
  if (path === '/api/npm/reviews/mine') {
4671
5160
  if (method !== 'GET') { send405(res, 'GET'); return }
4672
- const s = await readWorkspaceSession(res, deps, cookie)
4673
- if (!s) return
5161
+ const s = workspaceSession!
4674
5162
  const id = url.searchParams.get('beforeId'), time = url.searchParams.get('beforeTime')
4675
5163
  if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
4676
5164
  sendJson(res, 400, { error: 'bad-review-cursor' }); return
@@ -4684,8 +5172,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4684
5172
  // Dependencies tab lists them, fifty a page.
4685
5173
  if (path === '/api/npm/reviews/team') {
4686
5174
  if (method !== 'GET') { send405(res, 'GET'); return }
4687
- const s = await readWorkspaceSession(res, deps, cookie)
4688
- if (!s) return
5175
+ const s = workspaceSession!
4689
5176
  const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
4690
5177
  if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
4691
5178
  if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
@@ -4693,16 +5180,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4693
5180
  }
4694
5181
  const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
4695
5182
  if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
4696
- const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
4697
- if (!reviews) return
4698
5183
  // Its access asked again, as it may have been taken while npm was read.
4699
- if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
4700
- sendJson(res, 200, { ...page, reviews })
5184
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user, teamId)
5185
+ if (reviews) sendJson(res, 200, { ...page, reviews })
4701
5186
  return
4702
5187
  }
4703
5188
  if (path === '/api/npm/reviews') {
4704
5189
  await handleNpmReviews(req, res, deps, cookie, url.searchParams); return
4705
5190
  }
5191
+ if (path === NPM_DELTAS_PATH) { await handleNpmDeltas(req, res, deps, cookie, url.searchParams); return }
4706
5192
  if (NPM_PATHS.has(path)) {
4707
5193
  await handleNpm(req, res, deps, cookie, path, url.searchParams); return
4708
5194
  }
@@ -4712,12 +5198,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4712
5198
  sendJson(res, 404, { error: 'not-found' }, { connection: 'close' })
4713
5199
  }
4714
5200
 
4715
- return (req, res) => {
5201
+ // Each request's time told in its Server-Timing header (request-timing.ts).
5202
+ return (req, res) => runTimed(res, () => {
4716
5203
  if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
4717
5204
  if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return }
4718
5205
  // Feeds lease a connection per poll, never across streaming sleeps. This
4719
- // also covers capability-authenticated feeds handled by public-workspace.
4720
- const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
5206
+ // also covers capability-authenticated feeds handled by public-workspace;
5207
+ // and a team's dependencies and npm's deltas, streamed as GitHub and npm
5208
+ // are waited on, lease one a statement.
5209
+ const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '') || /^\/api\/(?:teams\/[^/?]+\/dependencies|npm\/deltas)(?:\?|$)/u.test(req.url ?? '')
4721
5210
  const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
4722
5211
  const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
4723
5212
  if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
@@ -4727,5 +5216,5 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4727
5216
  })
4728
5217
  track(work)
4729
5218
  return work
4730
- }
5219
+ })
4731
5220
  }