@preventive/triage 1.0.0-alpha.47 → 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 (80) 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 -14
  4. package/common/managed/github-agents.ts +7 -6
  5. package/out/activity.js +4 -4
  6. package/out/{chunk-NOZJULWS.js → chunk-6LNQTKAV.js} +1 -1
  7. package/out/chunk-7VAWINQF.js +6 -0
  8. package/out/{chunk-5AHIQFFG.js → chunk-C2AVODGZ.js} +1 -1
  9. package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
  10. package/out/{chunk-UENXT2HK.js → chunk-MOACFRJ4.js} +1 -1
  11. package/out/chunk-O6M574JI.js +1 -0
  12. package/out/{chunk-ZCS4UYHI.js → chunk-PBZPYIG7.js} +1 -1
  13. package/out/{chunk-S6U2J62U.js → chunk-PENGGKX7.js} +42 -41
  14. package/out/{chunk-6WF3KNTL.js → chunk-SZD5CW5R.js} +2 -2
  15. package/out/{chunk-SPEFPML6.js → chunk-TQJDYHVE.js} +1 -1
  16. package/out/{chunk-FH24VHSJ.js → chunk-WYLQWYLA.js} +1 -1
  17. package/out/{chunk-VL43BTSO.js → chunk-X6PJAK3T.js} +1 -1
  18. package/out/client-managed-import.js +1 -1
  19. package/out/client-managed.js +26 -26
  20. package/out/compare.js +13 -13
  21. package/out/dialogs.js +17 -17
  22. package/out/graph.js +1 -1
  23. package/out/index.html +7 -7
  24. package/out/scan.js +1 -1
  25. package/out/terminal.js +214 -214
  26. package/out/view.css +1 -1
  27. package/out/view.js +15 -15
  28. package/package.json +8 -5
  29. package/risk/npm/score.js +1 -1
  30. package/server-managed/agent-merges.ts +10 -8
  31. package/server-managed/app-agents.ts +6 -8
  32. package/server-managed/cache-storage.ts +3 -2
  33. package/server-managed/commit-coauthors.ts +7 -2
  34. package/server-managed/commit-lockfiles.ts +22 -21
  35. package/server-managed/db-methods.ts +52 -38
  36. package/server-managed/db-neon.ts +21 -5
  37. package/server-managed/db.ts +2 -0
  38. package/server-managed/dependabot-access.ts +1 -1
  39. package/server-managed/dependency-review-comments.ts +7 -4
  40. package/server-managed/dependency-reviews.ts +71 -33
  41. package/server-managed/github-accounts.ts +28 -1
  42. package/server-managed/github-app.ts +27 -23
  43. package/server-managed/github-graphql.ts +39 -0
  44. package/server-managed/github-issue-links.ts +8 -1
  45. package/server-managed/github-metadata.ts +2 -2
  46. package/server-managed/http-response.ts +28 -10
  47. package/server-managed/http.ts +677 -230
  48. package/server-managed/index.ts +2 -2
  49. package/server-managed/managed-issues.ts +3 -0
  50. package/server-managed/npm-delta.ts +16 -0
  51. package/server-managed/npm-kept.ts +32 -5
  52. package/server-managed/npm-loads.ts +21 -13
  53. package/server-managed/npm-manifest.ts +1 -1
  54. package/server-managed/npm-packages.ts +13 -4
  55. package/server-managed/object-storage.ts +4 -3
  56. package/server-managed/public-workspace.ts +3 -3
  57. package/server-managed/pull-dependencies.ts +84 -36
  58. package/server-managed/pull-dependency-reviews.ts +13 -10
  59. package/server-managed/rename-diff-worker.js +1 -1
  60. package/server-managed/repository-agents.ts +9 -6
  61. package/server-managed/repository-browser.ts +13 -3
  62. package/server-managed/repository-code.ts +153 -7
  63. package/server-managed/repository-dependency-reviews.ts +7 -0
  64. package/server-managed/repository-history.ts +85 -152
  65. package/server-managed/repository-issues.ts +27 -13
  66. package/server-managed/repository-pulse.ts +9 -3
  67. package/server-managed/repository-squashes.ts +145 -0
  68. package/server-managed/request-timing.ts +63 -0
  69. package/server-managed/reviewed-dependencies.ts +3 -2
  70. package/server-managed/session.ts +34 -12
  71. package/server-managed/sql.ts +16 -8
  72. package/server-managed/storage-encryption.ts +16 -8
  73. package/server-managed/storage-maintenance.ts +5 -2
  74. package/server-managed/storage-payload.ts +18 -0
  75. package/server-managed/storage-stores.ts +3 -2
  76. package/server-managed/team-feed.ts +16 -11
  77. package/server-managed/team-npm-scopes.ts +29 -0
  78. package/server-managed/team-reports.ts +68 -16
  79. package/out/chunk-6ANSHRFQ.js +0 -6
  80. package/out/chunk-ZGNMDGSB.js +0 -1
@@ -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, ownKeptPrunes, 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'
@@ -183,11 +184,13 @@ import { githubActionsSource } from './github-actions.ts'
183
184
  import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
184
185
  import { type Lru, lru } from './lru.ts'
185
186
  import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
186
- 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'
187
190
  import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
188
191
  import type { ActivityContext, ActivityInput } from './activity.ts'
189
192
  import { acceptsReportMetadata } from './report-response.ts'
190
- 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'
191
194
  import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
192
195
  import { lookupFixes, storedFixUrls } from './github-pulls.ts'
193
196
  import { lookupIssueLinks } from './github-issue-links.ts'
@@ -198,7 +201,7 @@ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallb
198
201
  import type { ProfileChoice } from './github-links.ts'
199
202
  import { withAppUsers } from './github-users.ts'
200
203
  import { isBlobId } from './object-storage.ts'
201
- import { attachmentDisposition, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
204
+ import { attachmentDisposition, openLines, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
202
205
  import { triageWireEntry } from './triage-response.ts'
203
206
  import { handlePublicWorkspace } from './public-workspace.ts'
204
207
  import { serveUserTeamFeed } from './team-feed.ts'
@@ -207,19 +210,20 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
207
210
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
208
211
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
209
212
  import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
210
- import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions,
211
- 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'
212
214
  import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
213
- import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
215
+ import { type RepositoryCodeKept, loadRepositoryCodeBody, repositoryCodeFits, repositoryCodeKept } from './repository-code.ts'
214
216
  import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
215
217
  import { COAUTHORS_READ } from './commit-coauthors.ts'
216
- import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, type SquashedPull, type Squashes, historyFrom, historyStarts, openPullHeads,
217
- repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
218
- 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'
219
222
  import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
220
- import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
221
- import { type GithubAsked, lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
222
- 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'
223
227
  import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
224
228
  import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
225
229
  import { formattedPull, withFormatted } from './dependency-review-comments.ts'
@@ -230,7 +234,7 @@ import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitS
230
234
  import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
231
235
  import { releaseCommits } from './release-commits.ts'
232
236
  import { computeBundleDiff } from '../common/bundle-compare-diff.js'
233
- import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
237
+ import type { ReviewChange, ReviewContext, ReviewInput, ReviewedTarballs } from './dependency-reviews.ts'
234
238
  import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
235
239
  import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
236
240
  import { npmProvenance } from './npm-provenance.ts'
@@ -310,6 +314,7 @@ const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
310
314
  const NPM_COMPARISON_PATH = '/api/npm/comparison'
311
315
  const NPM_RISK_PATH = '/api/npm/risk'
312
316
  const NPM_DELTA_PATH = '/api/npm/delta'
317
+ const NPM_DELTAS_PATH = '/api/npm/deltas'
313
318
  // What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
314
319
  // having no such package or version, or a tarball unpacking past risk's
315
320
  // bounds; npm failing otherwise a 502.
@@ -334,7 +339,7 @@ const VIEW_PREFIXES = [...MANAGED_DATA_PREFIXES, '/api/auth', '/api/oauth']
334
339
  const underPrefix = (path: string, prefixes: readonly string[]) => prefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))
335
340
 
336
341
  async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
337
- const s = await readWorkspaceSession(res, deps, cookie)
342
+ const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
338
343
  if (!s) return
339
344
  const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
340
345
  const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
@@ -364,7 +369,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
364
369
  async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
365
370
  cookie: string | undefined, teamId: string, query: URLSearchParams): Promise<void> {
366
371
  if (req.method !== 'GET' && req.method !== 'POST') { send405(res, 'GET, POST'); return }
367
- 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 })
368
373
  if (!s) return
369
374
  if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
370
375
  let body: unknown = Object.fromEntries(query)
@@ -396,7 +401,7 @@ function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined
396
401
  // Managers get content activity for their currently accessible team content;
397
402
  // even the displayed report context must come from those accessible reports.
398
403
  async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
399
- const s = await readManageSession(res, deps, cookie)
404
+ const s = await readManageSession(res, deps, cookie, { handed: true })
400
405
  if (s == null) return
401
406
  const page = Number(query.get('page') ?? 1)
402
407
  const limit = Number(query.get('limit') ?? 100)
@@ -440,6 +445,9 @@ export interface ManagedHttpDeps {
440
445
  prettyCache?: PrettyCache
441
446
  // The public npm cache, where risk keeps public packages' assessments (npm-risk.ts).
442
447
  npmCacheStorage?: CacheStorage
448
+ // Where the code viewer keeps the default branch's head's directories
449
+ // (repository-code.ts repositoryCodeKept).
450
+ repositoryCodeStorage?: CacheStorage
443
451
  uploadStore?: BlobStore
444
452
  originGate: OriginGate
445
453
  isShuttingDown: () => boolean
@@ -486,13 +494,11 @@ function sendAvatar(res: ServerResponse, avatar: CachedAvatar | null, cacheContr
486
494
  }
487
495
 
488
496
  // The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
489
- // alone: the agents' known by their GitHub Apps or accounts (APP_AGENTS),
490
- // each its mark on the Agents tab, and those the app draws agents' accounts
491
- // 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
492
499
  // kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
493
500
  // an account's, `…-0001-<id>`.
494
- const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
495
- .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] : []))
496
502
  const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
497
503
  // An agent's mark is a public GitHub image that never changes with anyone's
498
504
  // profile, drawn on every tile and comment by it: kept by the browser a day,
@@ -547,7 +553,7 @@ async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, sessi
547
553
  // first use. Only this session's server-side private key opens what is sealed to it.
548
554
  async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
549
555
  if (req.method !== 'GET') { send405(res, 'GET'); return }
550
- const s = await readManageSession(res, deps, cookie)
556
+ const s = await readManageSession(res, deps, cookie, { handed: true })
551
557
  if (!s) return
552
558
  const key = s.session.uploadKey ?? await deps.db.ensureSessionUploadKey(s.session.id, newUploadKey())
553
559
  if (!key) { sendJson(res, 401, { error: 'unauthenticated' }); return }
@@ -557,7 +563,7 @@ async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps:
557
563
  async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
558
564
  const cancel = req.method === 'DELETE'
559
565
  if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
560
- const s = await checkMutation(req, res, deps, cookie)
566
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
561
567
  if (!s || !requireManageRole(res, s.user)) return
562
568
  if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
563
569
  const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
@@ -598,8 +604,8 @@ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES
598
604
  // Returns the session, or null after having already sent the 401/403. An
599
605
  // admin's view as another user writes nothing; `view` admits only the
600
606
  // endpoints that end it.
601
- async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
602
- 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 })
603
609
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
604
610
  const csrf = firstHeader(req.headers['x-csrf-token'])
605
611
  if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
@@ -727,7 +733,7 @@ function parseProfileChoice(body: unknown): ProfileChoice | null {
727
733
  // at least one admin).
728
734
  async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
729
735
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
730
- const s = await checkMutation(req, res, deps, cookie)
736
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
731
737
  if (s == null) return
732
738
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
733
739
  let body: unknown
@@ -757,22 +763,22 @@ function requireManageRole(res: ServerResponse, user: StoredUser): boolean {
757
763
  // A read endpoint open to admin|manage: resolve the session (401 if absent),
758
764
  // then gate on the role (403). Returns the session, or null after the response
759
765
  // was already sent. No CSRF — reads aren't mutations (cf. /api/admin/users).
760
- async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
761
- 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 })
762
768
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
763
769
  if (!requireManageRole(res, s.user)) return null
764
770
  return s
765
771
  }
766
772
 
767
- async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
768
- 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 })
769
775
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
770
776
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
771
777
  return s
772
778
  }
773
779
 
774
- async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
775
- 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 })
776
782
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
777
783
  if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
778
784
  return s
@@ -802,7 +808,7 @@ async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: stri
802
808
  // GET /api/admin/scan/models — the server's canonical scan model ids and effort
803
809
  // levels. Names are intentionally absent; the client derives them from ids.
804
810
  async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
805
- const s = await readManageSession(res, deps, cookie)
811
+ const s = await readManageSession(res, deps, cookie, { handed: true })
806
812
  if (s == null) return
807
813
  sendJson(res, 200, { models: MANAGED_SCAN_MODELS, defaultModel: DEFAULT_MANAGED_SCAN_MODEL })
808
814
  }
@@ -813,7 +819,7 @@ async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cook
813
819
  // filtering. Admin-only. No CSRF.
814
820
  async function handleListRepositories(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, discovery: RepositoryDiscovery): Promise<void> {
815
821
  if ((req.method ?? 'GET') !== 'GET') { send405(res, 'GET'); return }
816
- const s = await readAdminSession(res, deps, cookie)
822
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
817
823
  if (s == null) return
818
824
  const url = new URL(req.url ?? '/', 'http://localhost')
819
825
  // Keep the original unparameterized response shape for older clients. The
@@ -931,7 +937,7 @@ async function connectRepository(res: ServerResponse, deps: ManagedHttpDeps, use
931
937
  // Authentication, server admin permission, origin and CSRF remain mandatory.
932
938
  async function handleAddPublicRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
933
939
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
934
- const s = await adminMutation(req, res, deps, cookie)
940
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
935
941
  if (s == null) return
936
942
  if (!canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id))) { sendJson(res, 403, { error: 'forbidden' }); return }
937
943
  let body: unknown
@@ -955,7 +961,7 @@ async function handleAddPublicRepository(req: IncomingMessage, res: ServerRespon
955
961
  // from the per-user checks required for browsing source.
956
962
  async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
957
963
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
958
- const s = await adminMutation(req, res, deps, cookie)
964
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
959
965
  if (s == null) return
960
966
  let body: unknown
961
967
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -990,10 +996,12 @@ async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerRespo
990
996
  // POST /api/admin/repositories/select — an admin toggles whether a repo is
991
997
  // active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
992
998
  // selected }. selected:true verifies + records the read context; selected:false
993
- // 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).
994
1002
  async function handleSelectRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
995
1003
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
996
- const s = await adminMutation(req, res, deps, cookie)
1004
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
997
1005
  if (s == null) return
998
1006
  let body: unknown
999
1007
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1006,6 +1014,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
1006
1014
  const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
1007
1015
  if (await readAdminSession(res, deps, cookie) == null) return
1008
1016
  await deps.db.deactivateRepo(repoId)
1017
+ await removeKeptCode(deps, repoId)
1009
1018
  if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
1010
1019
  sendJson(res, 200, { ok: true, selected: false }); return
1011
1020
  }
@@ -1021,7 +1030,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
1021
1030
  // stored kept as it is). Answers the setting then. Admin, CSRF.
1022
1031
  async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1023
1032
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
1024
- const s = await adminMutation(req, res, deps, cookie)
1033
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
1025
1034
  if (s == null) return
1026
1035
  let body: Record<string, unknown> | null
1027
1036
  try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1093,7 +1102,7 @@ async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id
1093
1102
  // removal would destroy. This is intentionally separate from the repository
1094
1103
  // list so the normal page stays cheap even with many reports.
1095
1104
  async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1096
- const s = await readAdminSession(res, deps, cookie)
1105
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
1097
1106
  if (s == null) return
1098
1107
  const repoId = Number(new URL(req.url ?? '/', 'http://localhost').searchParams.get('repoId'))
1099
1108
  if (!Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-repo' }); return }
@@ -1106,7 +1115,7 @@ async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse,
1106
1115
  // attached reports/bundles. The exact name + explicit acknowledgement are
1107
1116
  // checked server-side too; the UI's dialog is a usability guard, not the policy.
1108
1117
  async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1109
- const s = await checkMutation(req, res, deps, cookie)
1118
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
1110
1119
  if (s == null) return
1111
1120
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
1112
1121
  let body: unknown
@@ -1139,9 +1148,17 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
1139
1148
  await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
1140
1149
  await deps.bundleStore.delete(bundle.id).catch(() => {})
1141
1150
  }
1151
+ await removeKeptCode(deps, repo.repoId)
1142
1152
  sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
1143
1153
  }
1144
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
+
1145
1162
  // Strip a client-supplied upload filename to a safe display string. The bytes
1146
1163
  // are keyed by a server uuid. This name is used for display, Content-Disposition,
1147
1164
  // and format detection: decode URLs, remove controls/paths, and cap the basename
@@ -1193,7 +1210,7 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
1193
1210
  // { reportId, repoId, directory }, where repoId is null (detach) or a
1194
1211
  // currently-selected repo id. Directory is relative to the repository root.
1195
1212
  async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1196
- const s = await manageMutation(req, res, deps, cookie)
1213
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1197
1214
  if (s == null) return
1198
1215
  let body: unknown
1199
1216
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1214,7 +1231,7 @@ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, de
1214
1231
  // POST /api/admin/reports/set-visible — publish or hide a stored report. New
1215
1232
  // reports start hidden so an admin can inspect the parsed metadata first.
1216
1233
  async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1217
- const s = await manageMutation(req, res, deps, cookie)
1234
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1218
1235
  if (s == null) return
1219
1236
  let body: unknown
1220
1237
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1227,7 +1244,7 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
1227
1244
  }
1228
1245
 
1229
1246
  async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1230
- const s = await manageMutation(req, res, deps, cookie)
1247
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1231
1248
  if (s == null) return
1232
1249
  let body: unknown
1233
1250
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1242,7 +1259,7 @@ async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse,
1242
1259
  // POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
1243
1260
  // and directory (same shape as reports). Body { bundleId, repoId, directory }.
1244
1261
  async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1245
- const s = await manageMutation(req, res, deps, cookie)
1262
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1246
1263
  if (s == null) return
1247
1264
  let body: unknown
1248
1265
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1306,7 +1323,7 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
1306
1323
  // insert failure or concurrent duplicate drops the orphan blob; an uncertain
1307
1324
  // commit retains it. 413 over the cap, 400 on empty.
1308
1325
  async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1309
- let s = await checkMutation(req, res, deps, cookie)
1326
+ let s = await checkMutation(req, res, deps, cookie, { handed: true })
1310
1327
  if (s == null) return
1311
1328
  if (!requireManageRole(res, s.user)) return
1312
1329
  let bytes: Buffer
@@ -1414,7 +1431,7 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
1414
1431
  // or rendered inline against the app origin. 404 when there's no such report;
1415
1432
  // 503 when the row exists but the bytes don't (store desync).
1416
1433
  async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1417
- const s = await readManageSession(res, deps, cookie)
1434
+ const s = await readManageSession(res, deps, cookie, { handed: true })
1418
1435
  if (s == null) return
1419
1436
  if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1420
1437
  const rec = await deps.db.getReport(id)
@@ -1437,7 +1454,7 @@ async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cooki
1437
1454
  // same-origin + CSRF. Drops the row, then best-effort the bytes; 404 when there
1438
1455
  // was no such report.
1439
1456
  async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1440
- const s = await checkMutation(req, res, deps, cookie)
1457
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
1441
1458
  if (s == null) return
1442
1459
  if (!requireManageRole(res, s.user)) return
1443
1460
  const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
@@ -1490,7 +1507,7 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
1490
1507
  // Any team member (view or above) lists those their teams reach, for the code
1491
1508
  // viewer as for Create a bundle, whose builds stay admin|manage.
1492
1509
  async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1493
- const s = await readWorkspaceSession(res, deps, cookie)
1510
+ const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
1494
1511
  if (!s) return
1495
1512
  sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
1496
1513
  }
@@ -1547,15 +1564,28 @@ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
1547
1564
  // answered, where it is none of theirs. Every team member (view or above)
1548
1565
  // reads the repositories their teams reach; the reader then checks their own
1549
1566
  // GitHub read permission (repository-browser.ts), on each read.
1550
- async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
1551
- 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 })
1552
1569
  if (!s) return null
1553
- // bundleRepos's reads, the reader's scopes read once for both.
1554
- const access = await contentAccess(deps.db, s.user)
1555
- const repo = (await deps.db.listSelectedRepos()).filter(access.bundle).find(item => item.repoId === repoId)
1556
- if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
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
1557
1587
  const scopes = access.scopes == null ? [null] : access.scopes.filter(scope => scope.repoId === repoId).map(scope => scope.path)
1558
- return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
1588
+ return { repo, virtualEntries: scopedDirectory(path, scopes) }
1559
1589
  }
1560
1590
 
1561
1591
  // The repository as it was authorized, but for the shared default branch hint,
@@ -1575,12 +1605,12 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1575
1605
  const { repoId, ref } = asked
1576
1606
  const withDefault = refs && query.get('withDefault') === 'true'
1577
1607
  const path = refs ? '' : asked.path
1578
- const authorize = async () => {
1579
- 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 })
1580
1610
  if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1581
1611
  return access
1582
1612
  }
1583
- const access = await authorize()
1613
+ const access = await authorize(true)
1584
1614
  if (!access) return
1585
1615
  try {
1586
1616
  const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
@@ -1590,8 +1620,9 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1590
1620
  const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
1591
1621
  const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
1592
1622
  const { tagCommits, tagsComplete, ...listed } = revisions ?? { tagCommits: [], tagsComplete: false }
1593
- // Create a bundle refreshes the tags bundle catalogs show, and keeps a tag
1594
- // 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.
1595
1626
  // Like other GitHub caches, written before the access fence below.
1596
1627
  const tags = refs ? tagCommits : ref.length > 5 && ref.startsWith('tags/') && contents ? [{ name: ref.slice(5), sha: contents.commit }] : []
1597
1628
  if (refs || tags.length > 0) {
@@ -1619,45 +1650,78 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1619
1650
  // tarball is asked for; the reader's access is checked again once it is
1620
1651
  // read, before any of it is sent. The load stays held until this response is
1621
1652
  // written or abandoned, and a reader who stops reading it gives it up after
1622
- // 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).
1623
1655
  // What GitHub answered of a repository's trees for the code viewer, which a
1624
1656
  // commit or tree it names never changes: the tree at a directory of a commit
1625
1657
  // (getRepoTreeId reads the commit, then a listing a level down to it), and
1626
1658
  // how many files a tree holds under it and their bytes (repositoryTreeFiles),
1627
1659
  // by the repository's id, for the next to open it. Asked of them only once
1628
1660
  // the reader's own access has named the commit, as GitHub would have
1629
- // answered them; by the database, as each instance has one.
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).
1630
1663
  const REPOSITORY_TREES_KEPT = 4096
1631
1664
  type TreeFiles = Awaited<ReturnType<typeof repositoryTreeFiles>>
1632
1665
  const repositoryTrees = new WeakMap<ManagedDb, { ids: Lru<string>; files: Lru<TreeFiles> }>()
1633
- function repositoryTreesOf(db: ManagedDb) {
1634
- let kept = repositoryTrees.get(db)
1635
- if (!kept) repositoryTrees.set(db, kept = { ids: lru(REPOSITORY_TREES_KEPT), files: lru(REPOSITORY_TREES_KEPT) })
1636
- return kept
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)
1637
1707
  }
1638
1708
 
1639
1709
  async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1640
1710
  const asked = repositoryQuery(res, query)
1641
1711
  if (!asked) return
1642
1712
  const { repoId, ref, path } = asked
1643
- const authorize = async () => {
1644
- 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 })
1645
1715
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1646
1716
  return access
1647
1717
  }
1648
- const access = await authorize()
1718
+ const access = await authorize(true)
1649
1719
  if (!access) return
1650
1720
  try {
1651
1721
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1652
1722
  const commit = await reader.commit(ref)
1653
1723
  const repo = access.repo.fullName
1654
1724
  const client = createClient({ token: reader.readToken() })
1655
- const treeKey = `${access.repo.repoId}\0${commit}\0${path}`, trees = repositoryTreesOf(deps.db)
1656
- // A directory the commit has none at is none to read, as browsing it says.
1657
- const tree = trees.ids.get(treeKey) ?? await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
1658
- throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
1659
- }))
1660
- trees.ids.set(treeKey, tree)
1661
1725
  // Access still held, and the same repository, before anything read is sent.
1662
1726
  const fenced = async () => {
1663
1727
  await reader.recheckAccess()
@@ -1666,25 +1730,54 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
1666
1730
  if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
1667
1731
  return true
1668
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
+ })))
1669
1762
  // Too large names the commit it was read at, for History to read.
1670
- const filesKey = `${access.repo.repoId}\0${tree}`
1671
- let listed = trees.files.get(filesKey)
1672
- if (listed === undefined) trees.files.set(filesKey, listed = await repositoryTreeFiles(repo, reader.readToken(), tree))
1763
+ const listed = await trees.files(`${repoId}\0${tree}`, () => repositoryTreeFiles(repo, reader.readToken(), tree))
1673
1764
  if (!repositoryCodeFits(listed)) {
1674
1765
  if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
1675
1766
  return
1676
1767
  }
1677
1768
  if (res.destroyed) return
1678
- const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree, listed }, () => 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
+ })))
1679
1775
  res.once('close', load.release)
1680
1776
  const bytes = await load.result
1777
+ // Like other GitHub caches, kept before the access fence below.
1778
+ if (atHead) kept.keep(at, bytes)
1681
1779
  if (!(await fenced()) || res.destroyed) return
1682
- res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
1683
- res.writeHead(200, {
1684
- 'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
1685
- 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
1686
- })
1687
- writeResponse(res, bytes)
1780
+ send(bytes)
1688
1781
  } catch (err) {
1689
1782
  if (res.headersSent || res.destroyed) return
1690
1783
  if (err instanceof GithubApiError) { sendGithubError(res, err); return }
@@ -1721,12 +1814,12 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1721
1814
  const starts = from === null ? null : historyStarts(from)
1722
1815
  if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
1723
1816
  || (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
1724
- const authorize = async () => {
1725
- 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 })
1726
1819
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1727
1820
  return access
1728
1821
  }
1729
- const access = await authorize()
1822
+ const access = await authorize(true)
1730
1823
  if (!access) return
1731
1824
  let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
1732
1825
  try {
@@ -1776,12 +1869,24 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1776
1869
  // The pull requests squashed into a whole repository's commits, drawn as
1777
1870
  // the merges they were, and on its first page the open ones' heads,
1778
1871
  // naming commits: with a token, as far as GitHub answers them, the
1779
- // 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).
1780
1876
  const listed = (body as { commits: HistoryCommit[] }).commits
1781
- const [pulls, openPulls] = token && path === '' ? await Promise.all([
1782
- 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)),
1783
1887
  readingPulls,
1784
- ]) : [[], null]
1888
+ ]) : [none, null]
1889
+ const { pulls } = squashed
1785
1890
  await reader.recheckAccess()
1786
1891
  const current = await authorize()
1787
1892
  if (!current) return
@@ -1791,7 +1896,8 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1791
1896
  const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
1792
1897
  ({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
1793
1898
  sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
1794
- ...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 } : {} }))
1795
1901
  } catch (err) {
1796
1902
  if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1797
1903
  throw err
@@ -1845,12 +1951,12 @@ async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie:
1845
1951
  const asked = repositoryQuery(res, query)
1846
1952
  if (!asked) return
1847
1953
  const { repoId, path } = asked
1848
- const authorize = async () => {
1849
- 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 })
1850
1956
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1851
1957
  return access
1852
1958
  }
1853
- const access = await authorize()
1959
+ const access = await authorize(true)
1854
1960
  if (!access) return
1855
1961
  try {
1856
1962
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
@@ -1887,6 +1993,9 @@ function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
1887
1993
  // and how long one read is answered as kept even where it is asked for afresh.
1888
1994
  const REPOSITORY_READ_FRESH_MS = 2 * 60_000
1889
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
1890
1999
  // The repositories' reads being read again, behind a kept answer or as asked
1891
2000
  // for afresh, by key: one at a time, those asking meanwhile answered by it.
1892
2001
  const repositoryRereads = new Map<string, Promise<unknown>>()
@@ -1980,7 +2089,7 @@ function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): H
1980
2089
 
1981
2090
  // The pull requests squashed into repository `repoId`'s commits, kept by
1982
2091
  // each commit and the rules their commits' co-authors were read by
1983
- // (COAUTHORS_READ), null for one none was (repository-history.ts
2092
+ // (COAUTHORS_READ), null for one none was (repository-squashes.ts
1984
2093
  // squashedPulls); one kept before its commits' co-authors were read, read
1985
2094
  // again.
1986
2095
  function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
@@ -2023,28 +2132,55 @@ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, c
2023
2132
  async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
2024
2133
  answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
2025
2134
  unread: () => Promise<object | null> = () => Promise.resolve(null)): Promise<void> {
2026
- const authorize = async () => {
2027
- const access = await authorizeRepository(res, deps, cookie, repoId, '')
2028
- if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
2029
- return access
2030
- }
2031
- const access = await authorize()
2032
- if (!access) return
2135
+ const access = await wholeRepositoryAccess(deps, cookie, repoId, { handed: true })
2136
+ if ('error' in access) { sendRefusal(res, access); return }
2033
2137
  const told = await unread()
2034
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> {
2035
2171
  try {
2036
2172
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
2037
2173
  const token = reader.readToken()
2038
- if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
2174
+ if (!token) return refusal(403, 'github-token-required')
2039
2175
  const answered = await answer(access.repo.fullName, token, access.user)
2040
- if (answered.kept) { sendJson(res, 200, await withAppUsers(deps.db, answered.value)); return }
2176
+ if (answered.kept) return answered
2041
2177
  await reader.recheckAccess()
2042
- const current = await authorize()
2043
- if (!current) return
2044
- if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
2045
- 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
2046
2182
  } catch (err) {
2047
- 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)
2048
2184
  throw err
2049
2185
  }
2050
2186
  }
@@ -2052,7 +2188,9 @@ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps,
2052
2188
  // GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
2053
2189
  // requests tabs show (repository-issues.ts): `kind`'s open or closed
2054
2190
  // (`state`), a page at a time, on from the cursor `after` names. They are
2055
- // 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).
2056
2194
  async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2057
2195
  const repoId = Number(query.get('repoId'))
2058
2196
  const kind = query.get('kind')
@@ -2060,8 +2198,11 @@ async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps
2060
2198
  const after = query.get('after')
2061
2199
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
2062
2200
  || (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
2063
- await readWholeRepository(res, deps, cookie, repoId, async (fullName, token) => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }),
2064
- `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
+ })
2065
2206
  }
2066
2207
 
2067
2208
  // GET /api/admin/repositories/agents: what the code viewer's Agents tab
@@ -2132,19 +2273,23 @@ async function handleRepositoryPullDependencies(res: ServerResponse, deps: Manag
2132
2273
  }
2133
2274
 
2134
2275
  // Pull request `number`'s answer, as the endpoint gives it, and whether all
2135
- // of it was kept, GitHub asked for none of it.
2136
- async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, number: number, review: boolean) {
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) {
2137
2281
  const kept = pullDependencyKept(deps, repoId)
2138
2282
  const reading: { told?: PullDependencies } = {}
2139
- const answered = await keptAnswer(deps, `${repoId}\0pull-commits\0${PULL_DEPENDENCIES_READ}\0${number}`, async () => {
2140
- const { commits, told } = await pullDependenciesAt(fullName, token, number, kept)
2141
- reading.told = told
2142
- return { commits }
2143
- })
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
+ })
2144
2289
  // What its commits tell, as read, else as kept by them, else, no longer
2145
- // kept, read again.
2290
+ // kept, read again: by those given, without asking GitHub for them.
2146
2291
  const held = reading.told ?? (typeof answered.value.commits === 'string' ? await kept.read(answered.value.commits) : null)
2147
- const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept)).told }
2292
+ const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept, undefined, commits)).told }
2148
2293
  // Where its review is on, those of lockfiles it leaves out told to none,
2149
2294
  // whoever asks.
2150
2295
  const setting = await deps.db.repoDependencyReview(repoId)
@@ -2155,7 +2300,8 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
2155
2300
  const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
2156
2301
  const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
2157
2302
  const reviewed = deps.reviewedDependencies ?? null
2158
- const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
2303
+ const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps), lockfileKept(deps, repoId))
2304
+ : { baseline: null, unread: [], error: null }
2159
2305
  // Its comments read for reviews, where the host reads them, while it is
2160
2306
  // open (pull-comment-reviews.ts reads whether it is with them).
2161
2307
  const parse = reviewed?.parseComments
@@ -2176,32 +2322,40 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
2176
2322
  // a pull request's others are (pull-dependency-reviews.ts), each left unread
2177
2323
  // a directory of its own failing the review, as a pull request's are. Kept,
2178
2324
  // its access not asked again, where its head was kept and its lockfiles and
2179
- // baseline held, GitHub asked for none of them.
2325
+ // baseline held or kept (lockfileKept), GitHub asked for none of them.
2180
2326
  async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2181
2327
  const repoId = Number(query.get('repoId'))
2182
2328
  if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2183
- await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2184
- const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2185
- const head = headRead.value.sha
2186
- if (!head) return { value: { head: null, review: null }, kept: false }
2187
- const asked: GithubAsked = { github: !headRead.kept }
2188
- // Those its review holds to alone read, as many as are read of them.
2189
- const setting = await deps.db.repoDependencyReview(repoId)
2190
- if (!setting?.enabled) return { value: { head, review: null }, kept: false }
2191
- const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path), asked)
2192
- const { packages, paths } = lockedIn(read)
2193
- const told = { base: head, head, cut: false, lockfiles: [{ paths, added: [], more: 0, locked: packages.slice(0, MAX_LOCKED), moreLocked: Math.max(0, packages.length - MAX_LOCKED) },
2194
- ...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
2195
- const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
2196
- if (!now) return { value: { head, review: null }, kept: !asked.github }
2197
- // Told by the lockfiles it held to as they were read, though they have
2198
- // changed since.
2199
- const state = { ...now, lockfiles: setting.lockfiles }
2200
- const reviewed = deps.reviewedDependencies ?? null
2201
- const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
2202
- : await baselineFor(fullName, token, repoId, state, baselineKept(deps), asked)
2203
- return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: !asked.github }
2204
- }, async () => (await deps.db.repoDependencyReview(repoId))?.enabled ? null : { review: null })
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 }
2205
2359
  }
2206
2360
 
2207
2361
  // GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
@@ -2213,7 +2367,195 @@ async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpD
2213
2367
  if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2214
2368
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2215
2369
  const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
2216
- 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]] : []
2217
2559
  })
2218
2560
  }
2219
2561
 
@@ -2254,6 +2596,34 @@ function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependen
2254
2596
  }
2255
2597
  }
2256
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
+
2257
2627
  // GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
2258
2628
  // (repository-pulse.ts): the default branch's year, counted from its commits
2259
2629
  // (pulseCommits), and the pull requests and issues of the last `weeks`
@@ -2480,7 +2850,7 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
2480
2850
  // reader leaving, losing their admin role or
2481
2851
  // the server shutting down stops it.
2482
2852
  async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2483
- const s = await readAdminSession(res, deps, cookie)
2853
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
2484
2854
  if (!s) return
2485
2855
  const repos = await riskRepositories(deps.db, query)
2486
2856
  if (!repos) {
@@ -2763,7 +3133,7 @@ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, dep
2763
3133
  }
2764
3134
 
2765
3135
  async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2766
- const initial = await manageMutation(req, res, deps, cookie)
3136
+ const initial = await manageMutation(req, res, deps, cookie, { handed: true })
2767
3137
  if (!initial) return
2768
3138
  const controller = new AbortController()
2769
3139
  const onClose = () => controller.abort()
@@ -2826,7 +3196,7 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
2826
3196
  // After storing, any reports that declared this integrity but weren't linked yet
2827
3197
  // get attached (auto-link). 413 over the cap, 400 on empty.
2828
3198
  async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2829
- let s = await checkMutation(req, res, deps, cookie)
3199
+ let s = await checkMutation(req, res, deps, cookie, { handed: true })
2830
3200
  if (s == null) return
2831
3201
  if (!requireManageRole(res, s.user)) return
2832
3202
  let bytes: Buffer
@@ -2956,7 +3326,7 @@ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps:
2956
3326
  // the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
2957
3327
  // best-effort. 404 when there was no such bundle.
2958
3328
  async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
2959
- const s = await checkMutation(req, res, deps, cookie)
3329
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
2960
3330
  if (s == null) return
2961
3331
  if (!requireManageRole(res, s.user)) return
2962
3332
  const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
@@ -2969,8 +3339,8 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
2969
3339
 
2970
3340
  // admin|manage mutation guard: same-origin + CSRF + role, in one step. Returns
2971
3341
  // the session, or null after the 401/403 was sent.
2972
- async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
2973
- 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 })
2974
3344
  if (s == null) return null
2975
3345
  if (!requireManageRole(res, s.user)) return null
2976
3346
  return s
@@ -2978,8 +3348,8 @@ async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: M
2978
3348
 
2979
3349
  // Workspace access and repository connections are administered separately
2980
3350
  // from content management. Managers cannot change their own access via teams.
2981
- async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
2982
- 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 })
2983
3353
  if (s == null) return null
2984
3354
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
2985
3355
  return s
@@ -3035,7 +3405,7 @@ async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId:
3035
3405
  // visible findings (location AND evidence) are returned. Missing/unavailable
3036
3406
  // bundles are a quiet no-op; cached gzip bodies stream directly from disk.
3037
3407
  async function handleReportSources(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null) {
3038
- 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 })
3039
3409
  if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3040
3410
  if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3041
3411
  const sourcePaths = roleAtLeast(s.user.role, 'manage') ? undefined : await teamSourcePaths(deps.db, deps.reportStore, s.session.id, teamId ?? '', id)
@@ -3079,7 +3449,7 @@ async function reportIds(data: unknown): Promise<Set<string>> {
3079
3449
  async function handleDeduplication(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
3080
3450
  const method = req.method ?? 'GET'
3081
3451
  if (!(id ? ['GET', 'PATCH'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'GET, PATCH' : 'GET, POST'); return }
3082
- 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 })
3083
3453
  if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
3084
3454
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
3085
3455
  if (method === 'GET') { sendJson(res, 200, id ? await deps.db.getLinkReport(s.session.id, id) : { reports: await deps.db.listLinkReports() }); return }
@@ -3102,7 +3472,7 @@ async function handleDeduplication(req: IncomingMessage, res: ServerResponse, de
3102
3472
  async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
3103
3473
  const method = req.method ?? 'GET'
3104
3474
  if (!(id ? ['PATCH', 'DELETE'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'PATCH, DELETE' : 'GET, POST'); return }
3105
- 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 })
3106
3476
  if (!s) return
3107
3477
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
3108
3478
  if (method === 'GET') {
@@ -3122,7 +3492,7 @@ async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse
3122
3492
  // Managers can resolve only destinations their current repository grants cover.
3123
3493
  async function handleRepositorySuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3124
3494
  if (req.method !== 'GET') { send405(res, 'GET'); return }
3125
- if (!await readManageSession(res, deps, cookie)) return
3495
+ if (!await readManageSession(res, deps, cookie, { handed: true })) return
3126
3496
  const params = new URL(req.url!, 'http://localhost').searchParams
3127
3497
  const github = reportRepoGithub({ repo: { github: params.get('repo') } })
3128
3498
  const directory = normalizeTeamPath(params.get('directory'))
@@ -3160,7 +3530,7 @@ async function suggestReportLocation(deps: ManagedHttpDeps, user: StoredUser, da
3160
3530
  // location editor. It never changes the stored assignment.
3161
3531
  async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
3162
3532
  if (req.method !== 'GET') { send405(res, 'GET'); return }
3163
- const s = await readManageSession(res, deps, cookie)
3533
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3164
3534
  if (!s) return
3165
3535
  const report = await deps.db.getReport(id)
3166
3536
  if (!report || !(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
@@ -3182,7 +3552,7 @@ async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerR
3182
3552
  // "not authorized" (so neither existence nor membership is probeable); 503 = row
3183
3553
  // without bytes (store desync).
3184
3554
  async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
3185
- const s = await readManageSession(res, deps, cookie)
3555
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3186
3556
  if (s == null) return
3187
3557
  const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
3188
3558
  const access = snapshot?.reports[0]
@@ -3219,7 +3589,7 @@ async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps:
3219
3589
  const REPORT_QUERY_CONCURRENCY = 8
3220
3590
  const REPORT_QUERY_IN_FLIGHT_BYTES = 64 * 1024 * 1024
3221
3591
  async function handleQueryReports(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3222
- const s = await readManageSession(res, deps, cookie)
3592
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3223
3593
  if (!s) return
3224
3594
  let body
3225
3595
  try { body = await readJsonBody(req, 1024 * 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3313,9 +3683,11 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
3313
3683
 
3314
3684
  // The server selects the complete published workspace for classification.
3315
3685
  // Privileged hidden-report previews are independently scoped and bounded.
3316
- 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> {
3317
3689
  const snapshot = await teamSnapshot(deps.db, session.id, teamId, reportId)
3318
- const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
3690
+ const body = await (part === 'trees' ? loadTeamTreesResponse : loadTeamReportsResponse)(deps.db, deps.reportStore, snapshot)
3319
3691
  await recheckTeam(deps.db, session.id, snapshot)
3320
3692
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
3321
3693
  writeResponse(res, body)
@@ -3372,7 +3744,7 @@ async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256
3372
3744
  // stripped finding must not leak that the finding exists. A cleared entry is
3373
3745
  // sent as null (the server's tombstone), distinct from one never set.
3374
3746
  async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
3375
- 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 })
3376
3748
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3377
3749
  if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3378
3750
  await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
@@ -3391,7 +3763,7 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
3391
3763
  // exists). Each event is the entry as written then (null = a clear), who
3392
3764
  // wrote it and when; "what changed" is the diff against the next-older event.
3393
3765
  async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
3394
- 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 })
3395
3767
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3396
3768
  if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3397
3769
  const finding = query.get('finding') ?? ''
@@ -3422,7 +3794,7 @@ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHt
3422
3794
  // scope for rows that are themselves per finding id — so a stripped (or
3423
3795
  // foreign) finding id 404s without revealing whether it exists.
3424
3796
  async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
3425
- const s = await checkMutation(req, res, deps, cookie)
3797
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
3426
3798
  if (s == null) return
3427
3799
  if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3428
3800
  let body: unknown
@@ -3456,7 +3828,7 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
3456
3828
  // GET only: clients match local annotations against these IDs without sending
3457
3829
  // their local finding IDs or annotation bodies to discover which reports exist.
3458
3830
  async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
3459
- const s = await readAdminSession(res, deps, cookie)
3831
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
3460
3832
  if (!s) return
3461
3833
  const after = search.get('after') ?? ''
3462
3834
  if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
@@ -3478,7 +3850,7 @@ async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps
3478
3850
  // Admin imports compare the exact snapshot shown by the conflict dialog before
3479
3851
  // writing. The store commits shared finding triage and imported comments together.
3480
3852
  async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
3481
- const s = await adminMutation(req, res, deps, cookie)
3853
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3482
3854
  if (!s) return
3483
3855
  let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
3484
3856
  try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof body }
@@ -3517,7 +3889,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
3517
3889
  if (commentId == null ? method !== 'GET' && method !== 'POST' : method !== 'PATCH' && method !== 'DELETE') {
3518
3890
  send405(res, commentId == null ? 'GET, POST' : 'PATCH, DELETE'); return
3519
3891
  }
3520
- 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 })
3521
3893
  if (s == null) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
3522
3894
  const allowed = method === 'GET' ? await canViewReport(deps, s.user, reportId) : await canTriageReport(deps, s.user, reportId)
3523
3895
  if (!allowed) { sendJson(res, 404, { error: 'no-report' }); return }
@@ -3565,7 +3937,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
3565
3937
  // the page needs: all users (member dropdown), selected repos (repo dropdown),
3566
3938
  // and the visibility-permission keys. admin, read-only (no CSRF).
3567
3939
  async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3568
- const s = await readAdminSession(res, deps, cookie)
3940
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
3569
3941
  if (s == null) return
3570
3942
  const scopes = await deps.db.listTeamNpmScopes()
3571
3943
  const reviewTeams = new Set(await deps.db.listDependencyReviewEnabledTeams())
@@ -3579,7 +3951,7 @@ async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cooki
3579
3951
 
3580
3952
  // POST /api/admin/teams — create a team. Body { name }. 409 if the name's taken.
3581
3953
  async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3582
- const s = await adminMutation(req, res, deps, cookie)
3954
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3583
3955
  if (s == null) return
3584
3956
  let body: unknown
3585
3957
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3596,7 +3968,7 @@ async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps:
3596
3968
  // POST /api/admin/teams/rename — rename a team. Body { teamId, name }. 404 if no
3597
3969
  // such team; 409 if the new name is already taken by another team.
3598
3970
  async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3599
- const s = await adminMutation(req, res, deps, cookie)
3971
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3600
3972
  if (s == null) return
3601
3973
  let body: unknown
3602
3974
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3615,7 +3987,7 @@ async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps:
3615
3987
 
3616
3988
  // Hidden teams retain their configuration but grant no access or sidebar presence.
3617
3989
  async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3618
- const s = await adminMutation(req, res, deps, cookie)
3990
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3619
3991
  if (s == null) return
3620
3992
  let body: unknown
3621
3993
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3631,7 +4003,7 @@ async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, de
3631
4003
 
3632
4004
  // POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
3633
4005
  async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3634
- const s = await adminMutation(req, res, deps, cookie)
4006
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3635
4007
  if (s == null) return
3636
4008
  let body: unknown
3637
4009
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3648,7 +4020,7 @@ async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps:
3648
4020
  // path links with the whole repository. Body { teamId, repoId, path? }.
3649
4021
  // Repo must be in the selected set.
3650
4022
  async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3651
- const s = await adminMutation(req, res, deps, cookie)
4023
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3652
4024
  if (s == null) return
3653
4025
  let body: unknown
3654
4026
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3673,7 +4045,7 @@ async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps
3673
4045
  // POST /api/admin/teams/remove-repo — unlink one path. Omitting path retains
3674
4046
  // the legacy remove-all behavior. Body { teamId, repoId, path? }.
3675
4047
  async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3676
- const s = await adminMutation(req, res, deps, cookie)
4048
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3677
4049
  if (s == null) return
3678
4050
  let body: unknown
3679
4051
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3693,7 +4065,7 @@ async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, d
3693
4065
  // POST /api/admin/teams/set-member — add/update a member + their visibility
3694
4066
  // permissions. Body { teamId, userId, dependencies?, security? }.
3695
4067
  async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3696
- const s = await adminMutation(req, res, deps, cookie)
4068
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3697
4069
  if (s == null) return
3698
4070
  let body: unknown
3699
4071
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3716,7 +4088,7 @@ async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, de
3716
4088
 
3717
4089
  // POST /api/admin/teams/remove-member — remove a membership. Body { teamId, userId }.
3718
4090
  async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3719
- const s = await adminMutation(req, res, deps, cookie)
4091
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3720
4092
  if (s == null) return
3721
4093
  let body: unknown
3722
4094
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3732,7 +4104,7 @@ async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse,
3732
4104
  }
3733
4105
 
3734
4106
  async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3735
- const s = await adminMutation(req, res, deps, cookie)
4107
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3736
4108
  if (!s) return
3737
4109
  let body
3738
4110
  try { body = await readJsonBody(req) as { teamId?: unknown; enabled?: unknown } } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3744,7 +4116,7 @@ async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerRe
3744
4116
  // POST /api/admin/teams/set-npm-scopes — replace the npm scopes whose private
3745
4117
  // packages the team's members may read. Body { teamId, scopes: ['@scope'] }.
3746
4118
  async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3747
- const s = await adminMutation(req, res, deps, cookie)
4119
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3748
4120
  if (s == null) return
3749
4121
  let body: unknown
3750
4122
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3763,10 +4135,18 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
3763
4135
  // npm scopes of its visible teams, else none: none are read for an unscoped
3764
4136
  // package, an admin or a manager, or an answer checked again that is public.
3765
4137
  // Null for none.
3766
- async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
3767
- 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 })
3768
4140
  return s == null ? null : npmReaderOf(deps, s.user, names)
3769
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
+ }
3770
4150
  // The same of `user`, as a session the request read just now tells them.
3771
4151
  async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
3772
4152
  if (!roleAtLeast(user.role, 'view')) return null
@@ -3787,9 +4167,11 @@ const REVIEW_READS_AT_ONCE = 8
3787
4167
 
3788
4168
  // Lists must enforce the same target and base access as the review itself,
3789
4169
  // including permission changes while the registry requests are in flight;
3790
- // 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).
3791
4173
  async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[],
3792
- session?: { id: string; role: NpmReader['role'] }): Promise<T[] | null> {
4174
+ session?: { id: string; role: NpmReader['role'] }, teamId?: string): Promise<T[] | null> {
3793
4175
  const controller = new AbortController()
3794
4176
  const onClose = () => { if (!res.writableEnded) controller.abort() }
3795
4177
  res.on('close', onClose)
@@ -3808,8 +4190,10 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
3808
4190
  const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
3809
4191
  const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
3810
4192
  const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
3811
- 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)
3812
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 }
3813
4197
  return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
3814
4198
  } catch (error) {
3815
4199
  if (controller.signal.aborted) return null
@@ -3847,13 +4231,16 @@ async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReade
3847
4231
  return npmReviewVerdictLimit(doc, list, socket, risk)
3848
4232
  }
3849
4233
 
3850
- // Reviews are scoped to the bytes actually displayed, including the base.
3851
- // 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.
3852
4239
  async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
3853
4240
  const method = req.method ?? 'GET'
3854
4241
  if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
3855
4242
  const mutation = method !== 'GET'
3856
- 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 })
3857
4244
  if (!session) { if (!mutation) sendJson(res, 401, { error: 'unauthenticated' }); return }
3858
4245
  if (!roleAtLeast(session.user.role, mutation ? 'triage' : 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
3859
4246
  let body: Record<string, unknown> = {}
@@ -3928,10 +4315,14 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3928
4315
  || (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
3929
4316
  sendJson(res, 409, { error: 'review-content-changed' }); return
3930
4317
  }
3931
- if (change?.action === 'publish' || change?.action === 'save') {
3932
- const loads = [doc, ...base ? [base] : []].map(loadNpmFiles)
3933
- try {
3934
- 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') {
3935
4326
  const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
3936
4327
  const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
3937
4328
  const diff = computeBundleDiff(before, after, () => '.')
@@ -3945,8 +4336,8 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3945
4336
  change.files = changed.map(file => file.path)
3946
4337
  change.nonTextFiles = changed.filter(file => !reviewable(file)).map(file => file.path)
3947
4338
  }
3948
- } finally { loads.forEach(load => load.release()) }
3949
- }
4339
+ }
4340
+ } finally { loads.forEach(load => load.release()) }
3950
4341
  const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
3951
4342
  ? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
3952
4343
  const current = await npmReader(deps, cookie, [doc, base].flatMap(item => item?.private ? [item.name] : []))
@@ -3965,7 +4356,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3965
4356
  if (change.action === 'publish' && change.final && !allowedReviewVerdicts([], limit).includes(change.final.status)) {
3966
4357
  sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
3967
4358
  }
3968
- 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)
3969
4360
  sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
3970
4361
  } catch (err) {
3971
4362
  if (controller.signal.aborted) return
@@ -3975,6 +4366,62 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3975
4366
  } finally { res.off('close', onClose) }
3976
4367
  }
3977
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
+
3978
4425
  // GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
3979
4426
  // (canReadPrivateNpm) can be answered from the server's npm token; the rest get
3980
4427
  // what the registry answers anonymously, every time (see npm-packages.ts).
@@ -3995,7 +4442,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3995
4442
  }
3996
4443
  const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
3997
4444
  if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
3998
- 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 })
3999
4446
  if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4000
4447
  const controller = new AbortController()
4001
4448
  const onClose = () => { if (!res.writableEnded) controller.abort() }
@@ -4240,7 +4687,7 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
4240
4687
  const method = req.method ?? 'GET'
4241
4688
  const allowed = id ? ['PATCH', 'DELETE'] : ['GET', 'POST', 'DELETE']
4242
4689
  if (!allowed.includes(method)) { send405(res, allowed.join(', ')); return }
4243
- 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 })
4244
4691
  if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
4245
4692
  if (!requireManageRole(res, s.user)) return
4246
4693
  const { db } = deps
@@ -4371,6 +4818,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4371
4818
  const managedDataPath = underPrefix(path, MANAGED_DATA_PREFIXES)
4372
4819
  const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
4373
4820
  if (managedDataPath && !workspaceSession) return
4821
+ // What it read, for the handler's own first read to take.
4822
+ if (workspaceSession) handOverSession(cookie, workspaceSession)
4374
4823
  const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
4375
4824
  if (issueRoute) {
4376
4825
  await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
@@ -4383,8 +4832,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4383
4832
  if (path === '/api/admin/links') {
4384
4833
  if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
4385
4834
  if (method !== 'GET') { send405(res, 'GET'); return }
4386
- const s = await readSession(config, db, cookie, Date.now())
4387
- if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4835
+ const s = workspaceSession!
4388
4836
  if (!requireManageRole(res, s.user)) return
4389
4837
  const shares = await db.listManagedWorkspaceShares(s.session.id, Date.now())
4390
4838
  sendJson(res, shares ? 200 : 403, shares ? { shares } : { error: 'forbidden' }); return
@@ -4402,8 +4850,6 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4402
4850
  // sign-in account's. A linked account's is served by its own id.
4403
4851
  if (path.startsWith(AVATAR_PREFIX)) {
4404
4852
  if (method !== 'GET') { send405(res, 'GET'); return }
4405
- const s = await readSession(config, db, cookie, Date.now())
4406
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4407
4853
  const id = path.slice(AVATAR_PREFIX.length)
4408
4854
  if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
4409
4855
  await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
@@ -4416,9 +4862,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4416
4862
  }
4417
4863
  if (path === ADMIN_USERS_PATH) {
4418
4864
  if (method !== 'GET') { send405(res, 'GET'); return }
4419
- const s = await readSession(config, db, cookie, Date.now())
4420
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4421
- if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
4865
+ if (workspaceSession!.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
4422
4866
  sendJson(res, 200, { users: await db.listUsers() })
4423
4867
  return
4424
4868
  }
@@ -4590,13 +5034,17 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4590
5034
  const teamFeed = /^\/api\/teams\/([^/]+)\/feed$/u.exec(path)
4591
5035
  if (teamFeed || path === '/api/teams/feed') {
4592
5036
  if (method !== 'GET') { send405(res, 'GET'); return }
4593
- const s = await readWorkspaceSession(res, deps, cookie)
4594
- if (!s) return
5037
+ const s = workspaceSession!
4595
5038
  const teamId = teamFeed?.[1] ?? null
4596
5039
  const reportId = url.searchParams.get('reportId')
4597
5040
  if (teamId) await teamSnapshot(db, s.session.id, teamId, reportId)
4598
5041
  await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId, { reportId }); return
4599
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
+ }
4600
5048
  const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
4601
5049
  if (teamFixes) {
4602
5050
  if (method !== 'GET') { send405(res, 'GET'); return }
@@ -4610,10 +5058,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4610
5058
  await recheckTeam(db, workspaceSession!.session.id, snapshot)
4611
5059
  sendJson(res, 200, annotations); return
4612
5060
  }
4613
- const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
5061
+ const teamReports = /^\/api\/teams\/([^/]+)\/(reports|trees)$/u.exec(path)
4614
5062
  if (teamReports) {
4615
5063
  if (method !== 'GET') { send405(res, 'GET'); return }
4616
- 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
4617
5065
  }
4618
5066
  if (path === REPORT_QUERY_PATH) {
4619
5067
  if (method !== 'POST') { send405(res, 'POST'); return }
@@ -4681,8 +5129,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4681
5129
  }
4682
5130
  if (path === '/api/npm/reviews/latest') {
4683
5131
  if (method !== 'GET') { send405(res, 'GET'); return }
4684
- const s = await readWorkspaceSession(res, deps, cookie)
4685
- if (!s) return
5132
+ const s = workspaceSession!
4686
5133
  const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
4687
5134
  if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
4688
5135
  sendJson(res, 400, { error: 'bad-review-context' }); return
@@ -4694,7 +5141,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4694
5141
  }
4695
5142
  if (path === '/api/npm/reviews/discard') {
4696
5143
  if (method !== 'POST') { send405(res, 'POST'); return }
4697
- const s = await checkMutation(req, res, deps, cookie)
5144
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
4698
5145
  if (!s) return
4699
5146
  let body: Record<string, unknown>
4700
5147
  try {
@@ -4711,8 +5158,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4711
5158
  }
4712
5159
  if (path === '/api/npm/reviews/mine') {
4713
5160
  if (method !== 'GET') { send405(res, 'GET'); return }
4714
- const s = await readWorkspaceSession(res, deps, cookie)
4715
- if (!s) return
5161
+ const s = workspaceSession!
4716
5162
  const id = url.searchParams.get('beforeId'), time = url.searchParams.get('beforeTime')
4717
5163
  if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
4718
5164
  sendJson(res, 400, { error: 'bad-review-cursor' }); return
@@ -4726,8 +5172,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4726
5172
  // Dependencies tab lists them, fifty a page.
4727
5173
  if (path === '/api/npm/reviews/team') {
4728
5174
  if (method !== 'GET') { send405(res, 'GET'); return }
4729
- const s = await readWorkspaceSession(res, deps, cookie)
4730
- if (!s) return
5175
+ const s = workspaceSession!
4731
5176
  const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
4732
5177
  if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
4733
5178
  if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
@@ -4735,16 +5180,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4735
5180
  }
4736
5181
  const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
4737
5182
  if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
4738
- const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
4739
- if (!reviews) return
4740
5183
  // Its access asked again, as it may have been taken while npm was read.
4741
- if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
4742
- 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 })
4743
5186
  return
4744
5187
  }
4745
5188
  if (path === '/api/npm/reviews') {
4746
5189
  await handleNpmReviews(req, res, deps, cookie, url.searchParams); return
4747
5190
  }
5191
+ if (path === NPM_DELTAS_PATH) { await handleNpmDeltas(req, res, deps, cookie, url.searchParams); return }
4748
5192
  if (NPM_PATHS.has(path)) {
4749
5193
  await handleNpm(req, res, deps, cookie, path, url.searchParams); return
4750
5194
  }
@@ -4754,12 +5198,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4754
5198
  sendJson(res, 404, { error: 'not-found' }, { connection: 'close' })
4755
5199
  }
4756
5200
 
4757
- return (req, res) => {
5201
+ // Each request's time told in its Server-Timing header (request-timing.ts).
5202
+ return (req, res) => runTimed(res, () => {
4758
5203
  if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
4759
5204
  if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return }
4760
5205
  // Feeds lease a connection per poll, never across streaming sleeps. This
4761
- // also covers capability-authenticated feeds handled by public-workspace.
4762
- 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 ?? '')
4763
5210
  const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
4764
5211
  const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
4765
5212
  if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
@@ -4769,5 +5216,5 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4769
5216
  })
4770
5217
  track(work)
4771
5218
  return work
4772
- }
5219
+ })
4773
5220
  }