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

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 (82) 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-JEAUAJVF.js +232 -0
  11. package/out/chunk-O6M574JI.js +1 -0
  12. package/out/{chunk-VL43BTSO.js → chunk-PBSOL2SL.js} +1 -1
  13. package/out/{chunk-ZCS4UYHI.js → chunk-PBZPYIG7.js} +1 -1
  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-UENXT2HK.js → chunk-ULIF7A7N.js} +1 -1
  17. package/out/{chunk-FH24VHSJ.js → chunk-WYLQWYLA.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 +38 -22
  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 +751 -273
  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-comment-reviews.ts +1 -1
  58. package/server-managed/pull-dependencies.ts +84 -36
  59. package/server-managed/pull-dependency-reviews.ts +28 -18
  60. package/server-managed/rename-diff-worker.js +1 -1
  61. package/server-managed/repository-agents.ts +9 -6
  62. package/server-managed/repository-browser.ts +13 -3
  63. package/server-managed/repository-code.ts +153 -7
  64. package/server-managed/repository-dependency-reviews.ts +12 -4
  65. package/server-managed/repository-history.ts +85 -152
  66. package/server-managed/repository-issues.ts +27 -13
  67. package/server-managed/repository-pulse.ts +31 -11
  68. package/server-managed/repository-squashes.ts +145 -0
  69. package/server-managed/request-timing.ts +63 -0
  70. package/server-managed/reviewed-dependencies.ts +3 -2
  71. package/server-managed/session.ts +34 -12
  72. package/server-managed/sql.ts +16 -8
  73. package/server-managed/storage-encryption.ts +16 -8
  74. package/server-managed/storage-maintenance.ts +5 -2
  75. package/server-managed/storage-payload.ts +18 -0
  76. package/server-managed/storage-stores.ts +3 -2
  77. package/server-managed/team-feed.ts +16 -11
  78. package/server-managed/team-npm-scopes.ts +29 -0
  79. package/server-managed/team-reports.ts +68 -16
  80. package/out/chunk-6ANSHRFQ.js +0 -6
  81. package/out/chunk-S6U2J62U.js +0 -232
  82. 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
@@ -468,46 +476,50 @@ function firstHeader(v: string | string[] | undefined): string | null {
468
476
  return null
469
477
  }
470
478
 
479
+ // A user's avatar, drawn on every tile, comment and review by them: kept by
480
+ // the browser five minutes, then shown as kept for a day while the browser
481
+ // asks again behind it, its image named by its ETag, so that an unchanged one
482
+ // answers with none (304). One changed on another's profile shows within
483
+ // minutes; a user's own change, at once, as their page asks for it again
484
+ // (ui/view/github-users.js refreshAvatar).
485
+ const USER_AVATAR_CACHE = 'private, max-age=300, stale-while-revalidate=86400'
486
+
471
487
  // `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
472
- // A user's is never kept by the browser, as it changes with their profile.
473
- async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id, cacheControl = 'no-store'): Promise<void> {
474
- sendAvatar(res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
488
+ async function serveAvatar(req: IncomingMessage, res: ServerResponse, avatarStore: AvatarStore, id: string, key = id, cacheControl = USER_AVATAR_CACHE): Promise<void> {
489
+ sendAvatar(req, res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
475
490
  }
476
491
 
477
- function sendAvatar(res: ServerResponse, avatar: CachedAvatar | null, cacheControl = 'no-store'): void {
492
+ function sendAvatar(req: IncomingMessage, res: ServerResponse, avatar: CachedAvatar | null, cacheControl: string): void {
478
493
  if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
479
- res.writeHead(200, {
480
- 'content-type': avatar.contentType,
481
- 'content-length': String(avatar.bytes.length),
482
- 'x-content-type-options': 'nosniff',
483
- 'cache-control': cacheControl,
484
- })
494
+ const etag = `"${createHash('sha256').update(`${avatar.contentType}\0`).update(avatar.bytes).digest('base64url').slice(0, 22)}"`
495
+ const headers = { 'etag': etag, 'cache-control': cacheControl, 'x-content-type-options': 'nosniff' }
496
+ const asked = req.headers['if-none-match']
497
+ if (typeof asked === 'string' && asked.split(',').some(tag => ['*', etag, `W/${etag}`].includes(tag.trim()))) { res.writeHead(304, headers); res.end(); return }
498
+ res.writeHead(200, { ...headers, 'content-type': avatar.contentType, 'content-length': String(avatar.bytes.length) })
485
499
  res.end(avatar.bytes)
486
500
  }
487
501
 
488
502
  // 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
503
+ // alone: those the app draws agents by (GITHUB_AGENTS), on the Agents tab and
504
+ // wherever their work shows. Each is fetched from GitHub once and
492
505
  // kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
493
506
  // 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] : []))
507
+ const AGENT_AVATARS: ReadonlySet<string> = new Set(Object.values(GITHUB_AGENTS).flatMap(agent => 'avatar' in agent ? [agent.avatar] : []))
496
508
  const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
497
509
  // An agent's mark is a public GitHub image that never changes with anyone's
498
510
  // profile, drawn on every tile and comment by it: kept by the browser a day,
499
511
  // for it alone.
500
512
  const AGENT_AVATAR_CACHE = 'private, max-age=86400'
501
513
 
502
- async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
514
+ async function serveAgentAvatar(req: IncomingMessage, res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
503
515
  const [, kind, id] = AGENT_AVATAR.exec(avatar)!
504
516
  const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
505
517
  // Read once where it is kept; read again only past fetching it.
506
518
  const kept = await avatarStore.get(key)
507
- if (kept != null) { sendAvatar(res, kept, AGENT_AVATAR_CACHE); return }
519
+ if (kept != null) { sendAvatar(req, res, kept, AGENT_AVATAR_CACHE); return }
508
520
  await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
509
521
  .catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
510
- await serveAvatar(res, avatarStore, key, key, AGENT_AVATAR_CACHE)
522
+ await serveAvatar(req, res, avatarStore, key, key, AGENT_AVATAR_CACHE)
511
523
  }
512
524
 
513
525
  const MAX_JSON_BODY_BYTES = 4096
@@ -547,7 +559,7 @@ async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, sessi
547
559
  // first use. Only this session's server-side private key opens what is sealed to it.
548
560
  async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
549
561
  if (req.method !== 'GET') { send405(res, 'GET'); return }
550
- const s = await readManageSession(res, deps, cookie)
562
+ const s = await readManageSession(res, deps, cookie, { handed: true })
551
563
  if (!s) return
552
564
  const key = s.session.uploadKey ?? await deps.db.ensureSessionUploadKey(s.session.id, newUploadKey())
553
565
  if (!key) { sendJson(res, 401, { error: 'unauthenticated' }); return }
@@ -557,7 +569,7 @@ async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps:
557
569
  async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
558
570
  const cancel = req.method === 'DELETE'
559
571
  if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
560
- const s = await checkMutation(req, res, deps, cookie)
572
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
561
573
  if (!s || !requireManageRole(res, s.user)) return
562
574
  if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
563
575
  const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
@@ -598,8 +610,8 @@ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES
598
610
  // Returns the session, or null after having already sent the 401/403. An
599
611
  // admin's view as another user writes nothing; `view` admits only the
600
612
  // 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())
613
+ async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false, handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
614
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
603
615
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
604
616
  const csrf = firstHeader(req.headers['x-csrf-token'])
605
617
  if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
@@ -727,7 +739,7 @@ function parseProfileChoice(body: unknown): ProfileChoice | null {
727
739
  // at least one admin).
728
740
  async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
729
741
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
730
- const s = await checkMutation(req, res, deps, cookie)
742
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
731
743
  if (s == null) return
732
744
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
733
745
  let body: unknown
@@ -757,22 +769,22 @@ function requireManageRole(res: ServerResponse, user: StoredUser): boolean {
757
769
  // A read endpoint open to admin|manage: resolve the session (401 if absent),
758
770
  // then gate on the role (403). Returns the session, or null after the response
759
771
  // 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())
772
+ async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
773
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
762
774
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
763
775
  if (!requireManageRole(res, s.user)) return null
764
776
  return s
765
777
  }
766
778
 
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())
779
+ async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
780
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
769
781
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
770
782
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
771
783
  return s
772
784
  }
773
785
 
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())
786
+ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
787
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
776
788
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
777
789
  if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
778
790
  return s
@@ -802,7 +814,7 @@ async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: stri
802
814
  // GET /api/admin/scan/models — the server's canonical scan model ids and effort
803
815
  // levels. Names are intentionally absent; the client derives them from ids.
804
816
  async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
805
- const s = await readManageSession(res, deps, cookie)
817
+ const s = await readManageSession(res, deps, cookie, { handed: true })
806
818
  if (s == null) return
807
819
  sendJson(res, 200, { models: MANAGED_SCAN_MODELS, defaultModel: DEFAULT_MANAGED_SCAN_MODEL })
808
820
  }
@@ -813,7 +825,7 @@ async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cook
813
825
  // filtering. Admin-only. No CSRF.
814
826
  async function handleListRepositories(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, discovery: RepositoryDiscovery): Promise<void> {
815
827
  if ((req.method ?? 'GET') !== 'GET') { send405(res, 'GET'); return }
816
- const s = await readAdminSession(res, deps, cookie)
828
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
817
829
  if (s == null) return
818
830
  const url = new URL(req.url ?? '/', 'http://localhost')
819
831
  // Keep the original unparameterized response shape for older clients. The
@@ -931,7 +943,7 @@ async function connectRepository(res: ServerResponse, deps: ManagedHttpDeps, use
931
943
  // Authentication, server admin permission, origin and CSRF remain mandatory.
932
944
  async function handleAddPublicRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
933
945
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
934
- const s = await adminMutation(req, res, deps, cookie)
946
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
935
947
  if (s == null) return
936
948
  if (!canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id))) { sendJson(res, 403, { error: 'forbidden' }); return }
937
949
  let body: unknown
@@ -955,7 +967,7 @@ async function handleAddPublicRepository(req: IncomingMessage, res: ServerRespon
955
967
  // from the per-user checks required for browsing source.
956
968
  async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
957
969
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
958
- const s = await adminMutation(req, res, deps, cookie)
970
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
959
971
  if (s == null) return
960
972
  let body: unknown
961
973
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -990,10 +1002,12 @@ async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerRespo
990
1002
  // POST /api/admin/repositories/select — an admin toggles whether a repo is
991
1003
  // active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
992
1004
  // selected }. selected:true verifies + records the read context; selected:false
993
- // deactivates the row while retaining its reports, bundles, and metadata.
1005
+ // deactivates the row while retaining its reports, bundles, and metadata, but
1006
+ // for the code the code viewer kept of it, which no one reads meanwhile
1007
+ // (removeKeptCode).
994
1008
  async function handleSelectRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
995
1009
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
996
- const s = await adminMutation(req, res, deps, cookie)
1010
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
997
1011
  if (s == null) return
998
1012
  let body: unknown
999
1013
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1006,6 +1020,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
1006
1020
  const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
1007
1021
  if (await readAdminSession(res, deps, cookie) == null) return
1008
1022
  await deps.db.deactivateRepo(repoId)
1023
+ await removeKeptCode(deps, repoId)
1009
1024
  if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
1010
1025
  sendJson(res, 200, { ok: true, selected: false }); return
1011
1026
  }
@@ -1021,7 +1036,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
1021
1036
  // stored kept as it is). Answers the setting then. Admin, CSRF.
1022
1037
  async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1023
1038
  if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
1024
- const s = await adminMutation(req, res, deps, cookie)
1039
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
1025
1040
  if (s == null) return
1026
1041
  let body: Record<string, unknown> | null
1027
1042
  try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1093,7 +1108,7 @@ async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id
1093
1108
  // removal would destroy. This is intentionally separate from the repository
1094
1109
  // list so the normal page stays cheap even with many reports.
1095
1110
  async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1096
- const s = await readAdminSession(res, deps, cookie)
1111
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
1097
1112
  if (s == null) return
1098
1113
  const repoId = Number(new URL(req.url ?? '/', 'http://localhost').searchParams.get('repoId'))
1099
1114
  if (!Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-repo' }); return }
@@ -1106,7 +1121,7 @@ async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse,
1106
1121
  // attached reports/bundles. The exact name + explicit acknowledgement are
1107
1122
  // checked server-side too; the UI's dialog is a usability guard, not the policy.
1108
1123
  async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1109
- const s = await checkMutation(req, res, deps, cookie)
1124
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
1110
1125
  if (s == null) return
1111
1126
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
1112
1127
  let body: unknown
@@ -1139,9 +1154,17 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
1139
1154
  await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
1140
1155
  await deps.bundleStore.delete(bundle.id).catch(() => {})
1141
1156
  }
1157
+ await removeKeptCode(deps, repo.repoId)
1142
1158
  sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
1143
1159
  }
1144
1160
 
1161
+ // The code the code viewer kept of a repository no longer read, its
1162
+ // directories' files (repositoryCodeKeptOf), removed once its row is: as a
1163
+ // bundle's derivatives are, at best, a failure warned of.
1164
+ async function removeKeptCode(deps: ManagedHttpDeps, repoId: number): Promise<void> {
1165
+ await repositoryCodeKeptOf(deps)?.remove(repoId).catch((err: unknown) => { console.warn('managed: kept repository code delete failed:', err) })
1166
+ }
1167
+
1145
1168
  // Strip a client-supplied upload filename to a safe display string. The bytes
1146
1169
  // are keyed by a server uuid. This name is used for display, Content-Disposition,
1147
1170
  // and format detection: decode URLs, remove controls/paths, and cap the basename
@@ -1193,7 +1216,7 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
1193
1216
  // { reportId, repoId, directory }, where repoId is null (detach) or a
1194
1217
  // currently-selected repo id. Directory is relative to the repository root.
1195
1218
  async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1196
- const s = await manageMutation(req, res, deps, cookie)
1219
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1197
1220
  if (s == null) return
1198
1221
  let body: unknown
1199
1222
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1214,7 +1237,7 @@ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, de
1214
1237
  // POST /api/admin/reports/set-visible — publish or hide a stored report. New
1215
1238
  // reports start hidden so an admin can inspect the parsed metadata first.
1216
1239
  async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1217
- const s = await manageMutation(req, res, deps, cookie)
1240
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1218
1241
  if (s == null) return
1219
1242
  let body: unknown
1220
1243
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1227,7 +1250,7 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
1227
1250
  }
1228
1251
 
1229
1252
  async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1230
- const s = await manageMutation(req, res, deps, cookie)
1253
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1231
1254
  if (s == null) return
1232
1255
  let body: unknown
1233
1256
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1242,7 +1265,7 @@ async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse,
1242
1265
  // POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
1243
1266
  // and directory (same shape as reports). Body { bundleId, repoId, directory }.
1244
1267
  async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1245
- const s = await manageMutation(req, res, deps, cookie)
1268
+ const s = await manageMutation(req, res, deps, cookie, { handed: true })
1246
1269
  if (s == null) return
1247
1270
  let body: unknown
1248
1271
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -1306,7 +1329,7 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
1306
1329
  // insert failure or concurrent duplicate drops the orphan blob; an uncertain
1307
1330
  // commit retains it. 413 over the cap, 400 on empty.
1308
1331
  async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1309
- let s = await checkMutation(req, res, deps, cookie)
1332
+ let s = await checkMutation(req, res, deps, cookie, { handed: true })
1310
1333
  if (s == null) return
1311
1334
  if (!requireManageRole(res, s.user)) return
1312
1335
  let bytes: Buffer
@@ -1414,7 +1437,7 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
1414
1437
  // or rendered inline against the app origin. 404 when there's no such report;
1415
1438
  // 503 when the row exists but the bytes don't (store desync).
1416
1439
  async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1417
- const s = await readManageSession(res, deps, cookie)
1440
+ const s = await readManageSession(res, deps, cookie, { handed: true })
1418
1441
  if (s == null) return
1419
1442
  if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1420
1443
  const rec = await deps.db.getReport(id)
@@ -1437,7 +1460,7 @@ async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cooki
1437
1460
  // same-origin + CSRF. Drops the row, then best-effort the bytes; 404 when there
1438
1461
  // was no such report.
1439
1462
  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)
1463
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
1441
1464
  if (s == null) return
1442
1465
  if (!requireManageRole(res, s.user)) return
1443
1466
  const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
@@ -1490,7 +1513,7 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
1490
1513
  // Any team member (view or above) lists those their teams reach, for the code
1491
1514
  // viewer as for Create a bundle, whose builds stay admin|manage.
1492
1515
  async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1493
- const s = await readWorkspaceSession(res, deps, cookie)
1516
+ const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
1494
1517
  if (!s) return
1495
1518
  sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
1496
1519
  }
@@ -1547,15 +1570,28 @@ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
1547
1570
  // answered, where it is none of theirs. Every team member (view or above)
1548
1571
  // reads the repositories their teams reach; the reader then checks their own
1549
1572
  // 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)
1573
+ async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string, { handed = false } = {}) {
1574
+ const s = await readWorkspaceSession(res, deps, cookie, { handed })
1552
1575
  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 }
1576
+ const access = repositoryAccessIn(await repositoryGrants(deps, s.user), repoId, path)
1577
+ if (!access) { sendJson(res, 404, { error: 'no-repository' }); return null }
1578
+ return { ...access, user: s.user }
1579
+ }
1580
+
1581
+ // What a reader's grants let them read of the repositories (bundleRepos's
1582
+ // reads, the reader's scopes read once for both), for repositoryAccessIn to
1583
+ // tell of each: read once for as many repositories as a request asks of.
1584
+ async function repositoryGrants(deps: ManagedHttpDeps, user: StoredUser) {
1585
+ const access = await contentAccess(deps.db, user)
1586
+ return { access, repos: (await deps.db.listSelectedRepos()).filter(access.bundle) }
1587
+ }
1588
+ // Repository `repoId` as `grants` let it be read at `path`, as
1589
+ // authorizeRepository answers it; null where it is none of the reader's.
1590
+ function repositoryAccessIn({ access, repos }: Awaited<ReturnType<typeof repositoryGrants>>, repoId: number, path: string) {
1591
+ const repo = repos.find(item => item.repoId === repoId)
1592
+ if (!repo) return null
1557
1593
  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 }
1594
+ return { repo, virtualEntries: scopedDirectory(path, scopes) }
1559
1595
  }
1560
1596
 
1561
1597
  // The repository as it was authorized, but for the shared default branch hint,
@@ -1575,12 +1611,12 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1575
1611
  const { repoId, ref } = asked
1576
1612
  const withDefault = refs && query.get('withDefault') === 'true'
1577
1613
  const path = refs ? '' : asked.path
1578
- const authorize = async () => {
1579
- const access = await authorizeRepository(res, deps, cookie, repoId, path)
1614
+ const authorize = async (handed = false) => {
1615
+ const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
1580
1616
  if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1581
1617
  return access
1582
1618
  }
1583
- const access = await authorize()
1619
+ const access = await authorize(true)
1584
1620
  if (!access) return
1585
1621
  try {
1586
1622
  const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
@@ -1590,8 +1626,9 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1590
1626
  const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
1591
1627
  const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
1592
1628
  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.
1629
+ // Every refs read (Create a bundle's, and the code viewer's) refreshes the
1630
+ // tags bundle catalogs show, and a tag read keeps the one its revision
1631
+ // input resolves even when the listed page leaves it out.
1595
1632
  // Like other GitHub caches, written before the access fence below.
1596
1633
  const tags = refs ? tagCommits : ref.length > 5 && ref.startsWith('tags/') && contents ? [{ name: ref.slice(5), sha: contents.commit }] : []
1597
1634
  if (refs || tags.length > 0) {
@@ -1619,45 +1656,78 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1619
1656
  // tarball is asked for; the reader's access is checked again once it is
1620
1657
  // read, before any of it is sent. The load stays held until this response is
1621
1658
  // written or abandoned, and a reader who stops reading it gives it up after
1622
- // a while.
1659
+ // a while. A directory of the default branch's head is kept in byte storage
1660
+ // as it was sent, for the next to open it (repositoryCodeKeptOf).
1623
1661
  // What GitHub answered of a repository's trees for the code viewer, which a
1624
1662
  // commit or tree it names never changes: the tree at a directory of a commit
1625
1663
  // (getRepoTreeId reads the commit, then a listing a level down to it), and
1626
1664
  // how many files a tree holds under it and their bytes (repositoryTreeFiles),
1627
1665
  // by the repository's id, for the next to open it. Asked of them only once
1628
1666
  // the reader's own access has named the commit, as GitHub would have
1629
- // answered them; by the database, as each instance has one.
1667
+ // answered them; held by each instance a while, by the database it has, and
1668
+ // kept in that database for every instance (npm-kept.ts codeTreeStore).
1630
1669
  const REPOSITORY_TREES_KEPT = 4096
1631
1670
  type TreeFiles = Awaited<ReturnType<typeof repositoryTreeFiles>>
1632
1671
  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
1672
+ const treeId = (value: unknown): value is string => typeof value === 'string' && /^[a-f\d]{40}$/u.test(value)
1673
+ const treeFiles = (value: unknown): value is TreeFiles => value === null || (plainObject(value)
1674
+ && Number.isSafeInteger(value['files']) && (value['files'] as number) >= 0 && Number.isSafeInteger(value['bytes']) && (value['bytes'] as number) >= 0)
1675
+ function repositoryTreesOf(deps: ManagedHttpDeps) {
1676
+ let held = repositoryTrees.get(deps.db)
1677
+ if (!held) repositoryTrees.set(deps.db, held = { ids: lru(REPOSITORY_TREES_KEPT), files: lru(REPOSITORY_TREES_KEPT) })
1678
+ const { ids, files } = held
1679
+ return {
1680
+ id: (key: string, read: () => Promise<string>) => keptTree(ids, codeTreeStore<string>(deps.db, 'trees', deps.config.debug), key, treeId, read),
1681
+ files: (key: string, read: () => Promise<TreeFiles>) => keptTree(files, codeTreeStore<TreeFiles>(deps.db, 'tree-files', deps.config.debug), key, treeFiles, read),
1682
+ }
1683
+ }
1684
+ // What `memory` holds by `key`, else what `store` keeps of it as `kept`
1685
+ // takes it, else what `read` answers, then held and kept.
1686
+ async function keptTree<T>(memory: Lru<T>, store: KeptStore<T>, key: string, kept: (value: unknown) => value is T, read: () => Promise<T>): Promise<T> {
1687
+ const held = memory.get(key)
1688
+ if (held !== undefined) return held
1689
+ const stored = await store.read(key)
1690
+ let value: T
1691
+ if (stored && kept(stored.value)) value = stored.value
1692
+ else {
1693
+ value = await read()
1694
+ await store.write(key, value, Date.now())
1695
+ }
1696
+ memory.set(key, value)
1697
+ return value
1698
+ }
1699
+
1700
+ // The default branch's head's directories as kept in byte storage
1701
+ // (repository-code.ts repositoryCodeKept), the commit they are kept at in
1702
+ // the database, for a repository active as it is selected; none where this
1703
+ // server has no byte storage for them. Work behind an answer is tracked
1704
+ // (serverless, its invocation kept alive till it settles), as a kept read
1705
+ // read again behind one is.
1706
+ function repositoryCodeKeptOf(deps: ManagedHttpDeps): RepositoryCodeKept | null {
1707
+ if (!deps.repositoryCodeStorage) return null
1708
+ const active = async (repoId: number) => (await deps.db.listSelectedRepos()).some(row => row.repoId === repoId)
1709
+ return repositoryCodeKept(deps.repositoryCodeStorage, codeHeadStore(deps.db, deps.config.debug), active, work => {
1710
+ deps.track(work)
1711
+ deps.waitUntil?.(work)
1712
+ }, deps.config.debug)
1637
1713
  }
1638
1714
 
1639
1715
  async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1640
1716
  const asked = repositoryQuery(res, query)
1641
1717
  if (!asked) return
1642
1718
  const { repoId, ref, path } = asked
1643
- const authorize = async () => {
1644
- const access = await authorizeRepository(res, deps, cookie, repoId, path)
1719
+ const authorize = async (handed = false) => {
1720
+ const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
1645
1721
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1646
1722
  return access
1647
1723
  }
1648
- const access = await authorize()
1724
+ const access = await authorize(true)
1649
1725
  if (!access) return
1650
1726
  try {
1651
1727
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1652
1728
  const commit = await reader.commit(ref)
1653
1729
  const repo = access.repo.fullName
1654
1730
  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
1731
  // Access still held, and the same repository, before anything read is sent.
1662
1732
  const fenced = async () => {
1663
1733
  await reader.recheckAccess()
@@ -1666,25 +1736,54 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
1666
1736
  if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
1667
1737
  return true
1668
1738
  }
1739
+ const send = (bytes: Buffer) => {
1740
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
1741
+ res.writeHead(200, {
1742
+ 'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
1743
+ 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
1744
+ })
1745
+ writeResponse(res, bytes)
1746
+ }
1747
+ // The default branch's head's directory as kept, once the reader's own
1748
+ // access has named its commit, sent as one read is, access checked again;
1749
+ // its place among the active loads held till it is written or abandoned,
1750
+ // else given up before a read from GitHub asks for its own.
1751
+ const at = { repoId, repo, commit, path }, kept = repositoryCodeKeptOf(deps)
1752
+ const atHead = kept !== null && await kept.at(at, ref, reader.defaultBranch)
1753
+ if (atHead) {
1754
+ const keptLoad = kept.read(at)
1755
+ res.once('close', keptLoad.release)
1756
+ const held = await keptLoad.result
1757
+ if (held) {
1758
+ if (await fenced() && !res.destroyed) send(held)
1759
+ return
1760
+ }
1761
+ keptLoad.release()
1762
+ }
1763
+ const trees = repositoryTreesOf(deps)
1764
+ // A directory the commit has none at is none to read, as browsing it says.
1765
+ const tree = await trees.id(`${repoId}\0${commit}\0${path}`, () => upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
1766
+ throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
1767
+ })))
1669
1768
  // 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))
1769
+ const listed = await trees.files(`${repoId}\0${tree}`, () => repositoryTreeFiles(repo, reader.readToken(), tree))
1673
1770
  if (!repositoryCodeFits(listed)) {
1674
1771
  if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
1675
1772
  return
1676
1773
  }
1677
1774
  if (res.destroyed) return
1678
- const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree, listed }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
1775
+ // A tarball upstream finds no tree in, as @preventive/archive refuses
1776
+ // one with a symlink past the repository's root, is one the viewer
1777
+ // cannot read; one holding another tree is GitHub's answer gone wrong.
1778
+ const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree, listed }, symlinksAbove => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree, symlinksAbove }).catch((err: unknown) => {
1779
+ throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(', got no tree: ') ? new GithubApiError(422, 'directory-unreadable') : err
1780
+ })))
1679
1781
  res.once('close', load.release)
1680
1782
  const bytes = await load.result
1783
+ // Like other GitHub caches, kept before the access fence below.
1784
+ if (atHead) kept.keep(at, bytes)
1681
1785
  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)
1786
+ send(bytes)
1688
1787
  } catch (err) {
1689
1788
  if (res.headersSent || res.destroyed) return
1690
1789
  if (err instanceof GithubApiError) { sendGithubError(res, err); return }
@@ -1721,12 +1820,12 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1721
1820
  const starts = from === null ? null : historyStarts(from)
1722
1821
  if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
1723
1822
  || (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)
1823
+ const authorize = async (handed = false) => {
1824
+ const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
1726
1825
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1727
1826
  return access
1728
1827
  }
1729
- const access = await authorize()
1828
+ const access = await authorize(true)
1730
1829
  if (!access) return
1731
1830
  let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
1732
1831
  try {
@@ -1776,12 +1875,24 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1776
1875
  // The pull requests squashed into a whole repository's commits, drawn as
1777
1876
  // the merges they were, and on its first page the open ones' heads,
1778
1877
  // naming commits: with a token, as far as GitHub answers them, the
1779
- // history sent all the same where it doesn't.
1878
+ // history sent all the same where it doesn't. Those GitHub is still
1879
+ // looking up SQUASH_WAIT_MS on are left to the lookup, kept alive behind
1880
+ // the answer, as a kept answer's read is (`pullsPending`, for the client
1881
+ // to ask again).
1780
1882
  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[]>([])),
1883
+ const none = { pulls: [] as SquashedPull[], pending: false }
1884
+ const [squashed, openPulls] = token && path === '' ? await Promise.all([
1885
+ squashedPulls(repo, token, listed, historySquashes(deps, repoId), SQUASH_WAIT_MS).then(read => {
1886
+ if (read.behind) {
1887
+ const behind = read.behind.catch(quiet(null))
1888
+ deps.track(behind)
1889
+ deps.waitUntil?.(behind)
1890
+ }
1891
+ return read
1892
+ }, quiet(none)),
1783
1893
  readingPulls,
1784
- ]) : [[], null]
1894
+ ]) : [none, null]
1895
+ const { pulls } = squashed
1785
1896
  await reader.recheckAccess()
1786
1897
  const current = await authorize()
1787
1898
  if (!current) return
@@ -1791,7 +1902,8 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1791
1902
  const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
1792
1903
  ({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
1793
1904
  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 } : {} }))
1905
+ ...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...squashed.pending ? { pullsPending: true } : {},
1906
+ ...openPulls?.length ? { openPulls } : {} }))
1795
1907
  } catch (err) {
1796
1908
  if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1797
1909
  throw err
@@ -1845,12 +1957,12 @@ async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie:
1845
1957
  const asked = repositoryQuery(res, query)
1846
1958
  if (!asked) return
1847
1959
  const { repoId, path } = asked
1848
- const authorize = async () => {
1849
- const access = await authorizeRepository(res, deps, cookie, repoId, path)
1960
+ const authorize = async (handed = false) => {
1961
+ const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
1850
1962
  if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1851
1963
  return access
1852
1964
  }
1853
- const access = await authorize()
1965
+ const access = await authorize(true)
1854
1966
  if (!access) return
1855
1967
  try {
1856
1968
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
@@ -1887,6 +1999,9 @@ function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
1887
1999
  // and how long one read is answered as kept even where it is asked for afresh.
1888
2000
  const REPOSITORY_READ_FRESH_MS = 2 * 60_000
1889
2001
  const REPOSITORY_READ_FLOOR_MS = 10_000
2002
+ // How long a history's answer waits on GitHub looking up the pull requests
2003
+ // squashed into its commits (handleRepositoryHistory).
2004
+ const SQUASH_WAIT_MS = 1500
1890
2005
  // The repositories' reads being read again, behind a kept answer or as asked
1891
2006
  // for afresh, by key: one at a time, those asking meanwhile answered by it.
1892
2007
  const repositoryRereads = new Map<string, Promise<unknown>>()
@@ -1980,7 +2095,7 @@ function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): H
1980
2095
 
1981
2096
  // The pull requests squashed into repository `repoId`'s commits, kept by
1982
2097
  // each commit and the rules their commits' co-authors were read by
1983
- // (COAUTHORS_READ), null for one none was (repository-history.ts
2098
+ // (COAUTHORS_READ), null for one none was (repository-squashes.ts
1984
2099
  // squashedPulls); one kept before its commits' co-authors were read, read
1985
2100
  // again.
1986
2101
  function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
@@ -2023,28 +2138,55 @@ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, c
2023
2138
  async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
2024
2139
  answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
2025
2140
  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
2141
+ const access = await wholeRepositoryAccess(deps, cookie, repoId, { handed: true })
2142
+ if ('error' in access) { sendRefusal(res, access); return }
2033
2143
  const told = await unread()
2034
2144
  if (told) { sendJson(res, 200, await withAppUsers(deps.db, told)); return }
2145
+ const read = await readWholeRepositoryOf(deps, access, answer, () => wholeRepositoryAccess(deps, cookie, repoId))
2146
+ if ('error' in read) { sendRefusal(res, read); return }
2147
+ sendJson(res, 200, await withAppUsers(deps.db, read.value))
2148
+ }
2149
+
2150
+ // What a request is refused, as its answer tells it.
2151
+ type Refusal = { status: number; error: string; retryAfter?: number }
2152
+ const refusal = (status: number, error: string, retryAfter?: number | null): Refusal => ({ status, error, ...retryAfter == null ? {} : { retryAfter } })
2153
+ function sendRefusal(res: ServerResponse, { status, error, retryAfter }: Refusal) {
2154
+ sendJson(res, status, { error }, retryAfter == null ? {} : { 'retry-after': String(retryAfter) })
2155
+ }
2156
+ // The whole of repository `repoId`, where the session `cookie` names may
2157
+ // read all of it, and who reads it; else why not: none of it
2158
+ // (`no-repository`), or only some (`no-directory`).
2159
+ type WholeAccess = { repo: SelectedRepo; user: StoredUser }
2160
+ async function wholeRepositoryAccess(deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, { handed = false } = {}): Promise<WholeAccess | Refusal> {
2161
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
2162
+ if (s == null) return refusal(401, 'unauthenticated')
2163
+ if (!roleAtLeast(s.user.role, 'view')) return refusal(403, 'forbidden')
2164
+ return wholeAccessIn(await repositoryGrants(deps, s.user), s.user, repoId)
2165
+ }
2166
+ function wholeAccessIn(grants: Awaited<ReturnType<typeof repositoryGrants>>, user: StoredUser, repoId: number): WholeAccess | Refusal {
2167
+ const access = repositoryAccessIn(grants, repoId, '')
2168
+ if (!access) return refusal(404, 'no-repository')
2169
+ if (access.virtualEntries !== null) return refusal(404, 'no-directory')
2170
+ return { repo: access.repo, user }
2171
+ }
2172
+ // What `answer` reads of the whole of the repository `access` lets its
2173
+ // reader read, with their own GitHub access: as kept, else with that access
2174
+ // and `reauthorize`'s checked again after; else why it was refused.
2175
+ async function readWholeRepositoryOf<T>(deps: ManagedHttpDeps, access: WholeAccess, answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: T; kept: boolean }>,
2176
+ reauthorize: () => Promise<WholeAccess | Refusal>): Promise<{ value: T; kept: boolean } | Refusal> {
2035
2177
  try {
2036
2178
  const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
2037
2179
  const token = reader.readToken()
2038
- if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
2180
+ if (!token) return refusal(403, 'github-token-required')
2039
2181
  const answered = await answer(access.repo.fullName, token, access.user)
2040
- if (answered.kept) { sendJson(res, 200, await withAppUsers(deps.db, answered.value)); return }
2182
+ if (answered.kept) return answered
2041
2183
  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))
2184
+ const current = await reauthorize()
2185
+ if ('error' in current) return current
2186
+ if (!sameRepository(current.repo, access.repo)) return refusal(409, 'repository-changed')
2187
+ return answered
2046
2188
  } catch (err) {
2047
- if (err instanceof GithubApiError) { sendGithubError(res, err); return }
2189
+ if (err instanceof GithubApiError) return refusal(err.status === 401 ? 502 : err.status, err.message, err.retryAfter)
2048
2190
  throw err
2049
2191
  }
2050
2192
  }
@@ -2052,7 +2194,9 @@ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps,
2052
2194
  // GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
2053
2195
  // requests tabs show (repository-issues.ts): `kind`'s open or closed
2054
2196
  // (`state`), a page at a time, on from the cursor `after` names. They are
2055
- // the whole repository's (readWholeRepository).
2197
+ // the whole repository's, read as readWholeRepository reads them, kept with
2198
+ // open pull requests' commits, which they never tell (teamOpenPulls reads
2199
+ // them).
2056
2200
  async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2057
2201
  const repoId = Number(query.get('repoId'))
2058
2202
  const kind = query.get('kind')
@@ -2060,8 +2204,11 @@ async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps
2060
2204
  const after = query.get('after')
2061
2205
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
2062
2206
  || (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)
2207
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2208
+ const { value: { commits: _commits, ...page }, kept, at } = await keptAnswer(deps, `${repoId}\0issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`,
2209
+ async () => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }))
2210
+ return { value: { ...page, readAt: at }, kept }
2211
+ })
2065
2212
  }
2066
2213
 
2067
2214
  // GET /api/admin/repositories/agents: what the code viewer's Agents tab
@@ -2132,19 +2279,23 @@ async function handleRepositoryPullDependencies(res: ServerResponse, deps: Manag
2132
2279
  }
2133
2280
 
2134
2281
  // 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) {
2282
+ // of it was kept, GitHub asked for none of it: its base's and head's commits
2283
+ // as a page of its repository's open pull requests read them (`commits`,
2284
+ // kept and read again as that page is), else as kept by its number.
2285
+ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, number: number, review: boolean,
2286
+ commits?: PullCommits) {
2137
2287
  const kept = pullDependencyKept(deps, repoId)
2138
2288
  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
- })
2289
+ const answered = commits ? { value: { commits: `${commits.base}...${commits.head}` }, kept: true }
2290
+ : await keptAnswer(deps, `${repoId}\0pull-commits\0${PULL_DEPENDENCIES_READ}\0${number}`, async () => {
2291
+ const read = await pullDependenciesAt(fullName, token, number, kept)
2292
+ reading.told = read.told
2293
+ return { commits: read.commits }
2294
+ })
2144
2295
  // What its commits tell, as read, else as kept by them, else, no longer
2145
- // kept, read again.
2296
+ // kept, read again: by those given, without asking GitHub for them.
2146
2297
  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 }
2298
+ const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept, undefined, commits)).told }
2148
2299
  // Where its review is on, those of lockfiles it leaves out told to none,
2149
2300
  // whoever asks.
2150
2301
  const setting = await deps.db.repoDependencyReview(repoId)
@@ -2155,7 +2306,8 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
2155
2306
  const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
2156
2307
  const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
2157
2308
  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 }
2309
+ const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps), lockfileKept(deps, repoId))
2310
+ : { baseline: null, unread: [], error: null }
2159
2311
  // Its comments read for reviews, where the host reads them, while it is
2160
2312
  // open (pull-comment-reviews.ts reads whether it is with them).
2161
2313
  const parse = reviewed?.parseComments
@@ -2176,32 +2328,40 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
2176
2328
  // a pull request's others are (pull-dependency-reviews.ts), each left unread
2177
2329
  // a directory of its own failing the review, as a pull request's are. Kept,
2178
2330
  // its access not asked again, where its head was kept and its lockfiles and
2179
- // baseline held, GitHub asked for none of them.
2331
+ // baseline held or kept (lockfileKept), GitHub asked for none of them.
2180
2332
  async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2181
2333
  const repoId = Number(query.get('repoId'))
2182
2334
  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 })
2335
+ await answerWholeRepository(res, deps, cookie, repoId, (fullName, token, user) => branchDependencyAnswer(deps, fullName, token, user, repoId),
2336
+ async () => (await deps.db.repoDependencyReview(repoId))?.enabled ? null : { review: null })
2337
+ }
2338
+
2339
+ // The default branch's answer, as GET branch-dependencies gives it, and
2340
+ // whether all of it was kept, GitHub asked for none of it; its review's
2341
+ // setting as read already (`known`), where it was.
2342
+ async function branchDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number,
2343
+ known?: RepoDependencyReview | null): Promise<{ value: object; kept: boolean }> {
2344
+ const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2345
+ const head = headRead.value.sha
2346
+ if (!head) return { value: { head: null, review: null }, kept: false }
2347
+ const asked: GithubAsked = { github: !headRead.kept }
2348
+ // Those its review holds to alone read, as many as are read of them.
2349
+ const setting = known === undefined ? await deps.db.repoDependencyReview(repoId) : known
2350
+ if (!setting?.enabled) return { value: { head, review: null }, kept: false }
2351
+ const reads = lockfileKept(deps, repoId)
2352
+ const read = await lockedAt(fullName, token, head, reads, path => heldTo(setting.lockfiles, path), asked)
2353
+ const { packages, paths } = lockedIn(read)
2354
+ 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) },
2355
+ ...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
2356
+ const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
2357
+ if (!now) return { value: { head, review: null }, kept: !asked.github }
2358
+ // Told by the lockfiles it held to as they were read, though they have
2359
+ // changed since.
2360
+ const state = { ...now, lockfiles: setting.lockfiles }
2361
+ const reviewed = deps.reviewedDependencies ?? null
2362
+ const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
2363
+ : await baselineFor(fullName, token, repoId, state, baselineKept(deps), reads, asked)
2364
+ return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: !asked.github }
2205
2365
  }
2206
2366
 
2207
2367
  // GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
@@ -2213,7 +2373,195 @@ async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpD
2213
2373
  if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2214
2374
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2215
2375
  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 }
2376
+ return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head, lockfileKept(deps, repoId))).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
2377
+ })
2378
+ }
2379
+
2380
+ // GET /api/teams/:id/dependencies: what the team's Dependencies tab shows
2381
+ // (team-dependencies.js), in one request rather than one a repository, a
2382
+ // page of its pull requests and a batch of their dependencies: of each of
2383
+ // the team's repositories (those `repoIds` names alone, where it names
2384
+ // some), as lines of JSON as each is read, as the endpoints of each read
2385
+ // them: its default branch's dependencies and its open pull requests
2386
+ // (`{ repoId, branch, pulls: { items, more } }`) where its review is in
2387
+ // effect for the reader, else `{ repoId, branch: null }`, as for one they
2388
+ // may read only some of, or `{ repoId, error, status }`; then its pull
2389
+ // requests' dependencies, of those changing a lockfile or that may,
2390
+ // TEAM_PULLS_TOGETHER at a time (`{ repoId, dependencies }`); each line of
2391
+ // dependencies with the lines their versions wanting reviews change, where
2392
+ // they are counted already (`deltas`, npm-delta.ts keptNpmDeltas), told
2393
+ // before the rows asking for them are drawn. Last, `{ done: true
2394
+ // }`, or, its time up, `{ done: false, unread }`, the repositories left for
2395
+ // the reader to ask for again. The team's repositories, the reader's grants
2396
+ // and the repositories' reviews read once, those reviewing none answered at
2397
+ // once, GitHub asked nothing of them; each read with GitHub has its access
2398
+ // checked again after (readWholeRepositoryOf), and the reader's session
2399
+ // before each line, their team and grants before each telling a
2400
+ // repository's.
2401
+ const TEAM_REPOSITORIES_AT_ONCE = 4
2402
+ const TEAM_DEPENDENCIES_MS = 240_000
2403
+ const TEAM_PULL_PAGES = 20
2404
+ const TEAM_PULLS_TOGETHER = 20
2405
+ const PARTIAL_ACCESS = new Set(['no-directory', 'no-repository'])
2406
+ type TeamDependencyLine = Record<string, unknown> & { repoId?: number }
2407
+ type TeamPull = { number: number; touchesLockfile?: boolean | null }
2408
+ async function handleTeamDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, s: { session: ManagedSession; user: StoredUser },
2409
+ teamId: string, query: URLSearchParams): Promise<void> {
2410
+ const deadline = Date.now() + TEAM_DEPENDENCIES_MS
2411
+ const named = query.get('repoIds')
2412
+ const only = named === null ? null : new Set(named.split(',').map(Number))
2413
+ if (only && (only.size === 0 || only.size > 1000 || [...only].some(id => !Number.isSafeInteger(id) || id <= 0))) { sendJson(res, 400, { error: 'bad-request' }); return }
2414
+ const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
2415
+ const repoIds = [...new Set(snapshot.repositories.map(repo => repo.repoId))].filter(id => !only || only.has(id))
2416
+ const grants = await repositoryGrants(deps, s.user)
2417
+ const settings = await deps.db.getRepoDependencyReviews(repoIds)
2418
+ const out = openLines(res)
2419
+ let stopped = false
2420
+ // Lines written once their reader still reads them as at the start, the
2421
+ // stream running minutes and a kept answer asking nothing again: their
2422
+ // session theirs, and, where a line tells what a repository holds, the
2423
+ // team still theirs and holding it, their grants still reaching the whole
2424
+ // of it. None, and the answer cut short, where not, or the reader is gone.
2425
+ const send = async (...lines: TeamDependencyLine[]) => {
2426
+ if (stopped) return false
2427
+ const now = await readSession(deps.config, deps.db, cookie, Date.now())
2428
+ if (now?.user.id !== s.user.id || !roleAtLeast(now.user.role, 'view')) stopped = true
2429
+ const telling = lines.filter(line => line.repoId !== undefined && (line['branch'] || line['dependencies'])).map(line => line.repoId!)
2430
+ if (!stopped && telling.length > 0 && !await stillReads(now!.user, telling)) stopped = true
2431
+ for (const line of lines) if (stopped || !await out.write(line)) stopped = true
2432
+ if (stopped) out.cut()
2433
+ return !stopped
2434
+ }
2435
+ const stillReads = async (user: StoredUser, ids: number[]) => {
2436
+ const team = await teamSnapshot(deps.db, s.session.id, teamId).catch((err: unknown) => {
2437
+ if (err instanceof TeamReportsError) return null
2438
+ throw err
2439
+ })
2440
+ if (!team) return false
2441
+ const current = await repositoryGrants(deps, user)
2442
+ return ids.every(id => team.repositories.some(repo => repo.repoId === id) && !('error' in wholeAccessIn(current, user, id)))
2443
+ }
2444
+ const reading: { access: WholeAccess; setting: RepoDependencyReview }[] = []
2445
+ const answered: TeamDependencyLine[] = []
2446
+ for (const repoId of repoIds) {
2447
+ const access = wholeAccessIn(grants, s.user, repoId), setting = settings.get(repoId)
2448
+ if ('error' in access || !setting?.enabled) answered.push({ repoId, branch: null })
2449
+ else reading.push({ access, setting })
2450
+ }
2451
+ if (answered.length > 0 && !await send(...answered)) return
2452
+ const done = await eachOf(reading, TEAM_REPOSITORIES_AT_ONCE, ({ access, setting }) => stopped || Date.now() > deadline ? Promise.resolve(false)
2453
+ : teamRepositoryDependencies(deps, access, setting, deadline, send, () => wholeRepositoryAccess(deps, cookie, access.repo.repoId)))
2454
+ if (stopped) return
2455
+ const unread = reading.filter((_, at) => !done[at]).map(({ access }) => access.repo.repoId)
2456
+ if (!await send(unread.length === 0 ? { done: true } : { done: false, unread })) return
2457
+ await out.end()
2458
+ }
2459
+
2460
+ // A repository's lines of its team's Dependencies, as handleTeamDependencies
2461
+ // tells them; whether all were written, none left for its time.
2462
+ async function teamRepositoryDependencies(deps: ManagedHttpDeps, access: WholeAccess, setting: RepoDependencyReview, deadline: number,
2463
+ send: (...lines: TeamDependencyLine[]) => Promise<boolean>, reauthorize: () => Promise<WholeAccess | Refusal>): Promise<boolean> {
2464
+ const repoId = access.repo.repoId
2465
+ type Read = { branch: object | null; pulls?: { items: TeamPull[]; more: boolean }; commits?: Map<number, PullCommits> }
2466
+ const read = await readWholeRepositoryOf<Read>(deps, access, async (fullName, token, user) => {
2467
+ const branch = await branchDependencyAnswer(deps, fullName, token, user, repoId, setting)
2468
+ if (!(branch.value as { review?: unknown }).review) return { value: { branch: null }, kept: branch.kept }
2469
+ const pulls = await teamOpenPulls(deps, repoId, fullName, token)
2470
+ return { value: { branch: branch.value, pulls: { items: pulls.items, more: pulls.more }, commits: pulls.commits }, kept: branch.kept && pulls.kept }
2471
+ }, reauthorize)
2472
+ if ('error' in read) return send({ repoId, ...PARTIAL_ACCESS.has(read.error) ? { branch: null } : { error: read.error, status: read.status } })
2473
+ const { branch, pulls, commits } = read.value
2474
+ if (!branch) return send({ repoId, branch: null })
2475
+ if (!await send({ repoId, branch, pulls, ...await withDeltas(deps, [branch]) })) return false
2476
+ // Each by the commits its page names, where it names them, GitHub not asked
2477
+ // for them (pullDependencyAnswer); never told.
2478
+ const numbers = pulls!.items.filter(item => item.touchesLockfile === true || item.touchesLockfile === null).map(item => item.number)
2479
+ for (let at = 0; at < numbers.length; at += TEAM_PULLS_TOGETHER) {
2480
+ if (Date.now() > deadline) return false
2481
+ const batch = numbers.slice(at, at + TEAM_PULLS_TOGETHER)
2482
+ const pulled = await readWholeRepositoryOf(deps, access, async (fullName, token, user) => {
2483
+ const answers = await eachOf(batch, PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, true, commits?.get(number)).catch((err: unknown) => {
2484
+ // GitHub asked, though it failed: its access asked again after.
2485
+ if (err instanceof GithubApiError) return { value: { number, error: err.message }, kept: false }
2486
+ throw err
2487
+ }))
2488
+ return { value: answers.map(({ value }) => value), kept: answers.every(({ kept }) => kept) }
2489
+ }, reauthorize)
2490
+ const dependencies = 'error' in pulled ? batch.map(number => ({ number, error: pulled.error })) : pulled.value
2491
+ if (!await send({ repoId, dependencies, ...await withDeltas(deps, dependencies) })) return false
2492
+ }
2493
+ return true
2494
+ }
2495
+ const withDeltas = async (deps: ManagedHttpDeps, answers: object[]) => {
2496
+ const deltas = await teamDeltas(deps, answers)
2497
+ return deltas.length > 0 ? { deltas } : {}
2498
+ }
2499
+
2500
+ // A commit's or a blob's id, as GitHub names one.
2501
+ const OBJECT_ID = /^[a-f\d]{40}$/u
2502
+
2503
+ // A repository's open pull requests, as many pages of them as
2504
+ // TEAM_PULL_PAGES, each read and kept as GET issues reads it; each's base's
2505
+ // and head's commits as its page has them (`commits`, by its number), where
2506
+ // it has them; whether it has more, and whether all were kept.
2507
+ async function teamOpenPulls(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
2508
+ const items: TeamPull[] = []
2509
+ const commits = new Map<number, PullCommits>()
2510
+ let after: string | null = null, kept = true
2511
+ for (let page = 0; page < TEAM_PULL_PAGES; page++) {
2512
+ const cursor = after
2513
+ const answer = await keptAnswer(deps, `${repoId}\0issues\0${ISSUES_READ_VERSION}\0pulls\0open\0${cursor ?? ''}`,
2514
+ async () => ({ kind: 'pulls', state: 'open', ...await repositoryIssues(fullName, token, 'pulls', 'open', cursor) }))
2515
+ kept &&= answer.kept
2516
+ const value = answer.value as { items?: unknown; more?: unknown; after?: unknown; commits?: unknown }
2517
+ items.push(...(Array.isArray(value.items) ? value.items : []).filter(item => Number.isSafeInteger(item?.number) && item.number > 0))
2518
+ for (const pull of Array.isArray(value.commits) ? value.commits as { number?: unknown; base?: unknown; head?: unknown }[] : []) {
2519
+ const { number, base, head } = pull ?? {}
2520
+ if (Number.isSafeInteger(number) && typeof base === 'string' && OBJECT_ID.test(base) && typeof head === 'string' && OBJECT_ID.test(head)) {
2521
+ commits.set(number as number, { base, head })
2522
+ }
2523
+ }
2524
+ if (value.more !== true || typeof value.after !== 'string') return { items, commits, more: false, kept }
2525
+ after = value.after
2526
+ }
2527
+ return { items, commits, more: true, kept }
2528
+ }
2529
+
2530
+ // The lines the versions wanting reviews of `answers` change, as their rows
2531
+ // show them (npm-delta.js deltaBase), where they are counted already: of
2532
+ // public versions alone, those npm answered for in a day (npmVersionStore),
2533
+ // their integrities those npmDelta keys them by. `[name, version, base,
2534
+ // delta]` each, none counted here.
2535
+ async function teamDeltas(deps: ManagedHttpDeps, answers: object[]) {
2536
+ const asked = new Map<string, [string, string, string]>()
2537
+ for (const answer of answers as { lockfiles?: { added?: unknown; unreviewed?: unknown }[]; review?: { required?: number } }[]) {
2538
+ const required = answer.review?.required
2539
+ if (!Number.isSafeInteger(required)) continue
2540
+ for (const pkg of (answer.lockfiles ?? []).flatMap(dir => [...Array.isArray(dir.added) ? dir.added : [], ...Array.isArray(dir.unreviewed) ? dir.unreviewed : []]) as
2541
+ { name?: unknown; version?: unknown; source?: unknown; was?: unknown; reviews?: unknown; rejected?: unknown; baseline?: unknown; reviewed?: unknown; unsupported?: unknown }[]) {
2542
+ if (pkg.source !== 'registry' || typeof pkg.name !== 'string' || typeof pkg.version !== 'string' || !isExactVersion(pkg.version)) continue
2543
+ if (pkg.unsupported || pkg.baseline || pkg.reviewed || typeof pkg.reviews !== 'number' || (pkg.reviews >= required! && !pkg.rejected)) continue
2544
+ const was = Array.isArray(pkg.was) ? pkg.was.find(prior => typeof prior === 'string' && prior !== pkg.version) as string | undefined : undefined
2545
+ asked.set(`${pkg.name}\0${pkg.version}\0${was ?? '(empty)'}`, [pkg.name, pkg.version, was ?? '(empty)'])
2546
+ }
2547
+ }
2548
+ if (asked.size === 0) return []
2549
+ const versions = [...asked.values()].flatMap(([name, version, base]) => [{ name, version }, ...base === '(empty)' || !isExactVersion(base) ? [] : [{ name, version: base }]])
2550
+ const kept = await npmVersionStore(deps.db, deps.config.debug).readMany!(versions)
2551
+ const integrity = (name: string, version: string) => {
2552
+ const hit = kept.get(`${name}@${version}`)
2553
+ const dist = hit && Date.now() - hit.at < VERSION_KEPT_MS ? hit.json['dist'] : null
2554
+ return typeof dist === 'object' && dist !== null && typeof (dist as { integrity?: unknown }).integrity === 'string' ? (dist as { integrity: string }).integrity : null
2555
+ }
2556
+ const sides = [...asked.values()].flatMap(([name, version, base]) => {
2557
+ const against = base === '(empty)' ? null : integrity(name, base), other = integrity(name, version)
2558
+ return other && (base === '(empty)' || against) ? [{ name, version, base, key: [against, other] as [string | null, string] }] : []
2559
+ })
2560
+ if (sides.length === 0) return []
2561
+ const counted = await keptNpmDeltas(sides.map(({ key }) => key), npmDeltaStore<NpmDelta>(deps.db, deps.config.debug))
2562
+ return sides.flatMap(({ name, version, base, key: [against, other] }) => {
2563
+ const delta = counted.get(`${against ?? '(empty)'}\0${other}`)
2564
+ return delta ? [[name, version, base, delta]] : []
2217
2565
  })
2218
2566
  }
2219
2567
 
@@ -2254,6 +2602,34 @@ function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependen
2254
2602
  }
2255
2603
  }
2256
2604
 
2605
+ // What reading repository `repoId`'s lockfiles for its dependency review
2606
+ // keeps, by the rules they were read by (PULL_DEPENDENCIES_READ,
2607
+ // lockfileReadStore): each commit's listing, and each lockfile's read, of
2608
+ // those GitHub gave (pull-dependencies.ts LockfileStore). One kept otherwise
2609
+ // is none.
2610
+ function lockfileKept(deps: ManagedHttpDeps, repoId: number): LockfileStore {
2611
+ const listings = lockfileReadStore<ListedLockfile[]>(deps.db, 'listings', deps.config.debug)
2612
+ const lockfiles = lockfileReadStore<LockfileRead>(deps.db, 'lockfiles', deps.config.debug)
2613
+ const keyOf = (key: string) => `${repoId}\0${PULL_DEPENDENCIES_READ}\0${key}`
2614
+ return {
2615
+ listings: {
2616
+ async read(commit) {
2617
+ const value: unknown = (await listings.read(keyOf(commit)))?.value
2618
+ return Array.isArray(value) && value.every(item => typeof item?.path === 'string' && typeof item.format === 'string' && lockfileFormat(item.path) === item.format
2619
+ && typeof item.sha === 'string' && OBJECT_ID.test(item.sha)) ? value : null
2620
+ },
2621
+ write: (commit, listed) => listings.write(keyOf(commit), listed, Date.now()),
2622
+ },
2623
+ lockfiles: {
2624
+ async read(key) {
2625
+ const value = (await lockfiles.read(keyOf(key)))?.value as { packages?: unknown; error?: unknown } | null | undefined
2626
+ return value && (Array.isArray(value.packages) || value.error === 'lockfile-unreadable') ? value as LockfileRead : null
2627
+ },
2628
+ write: (key, read) => lockfiles.write(keyOf(key), read, Date.now()),
2629
+ },
2630
+ }
2631
+ }
2632
+
2257
2633
  // GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
2258
2634
  // (repository-pulse.ts): the default branch's year, counted from its commits
2259
2635
  // (pulseCommits), and the pull requests and issues of the last `weeks`
@@ -2269,7 +2645,7 @@ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps,
2269
2645
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2270
2646
  const [period, year] = await Promise.all([
2271
2647
  keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => repositoryActivity(fullName, token, weeks)),
2272
- pulseCommits(deps, repoId, fullName, token),
2648
+ pulseCommits(deps, repoId, fullName, token, weeks),
2273
2649
  ])
2274
2650
  return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
2275
2651
  pending: year.pending }, kept: period.kept && year.kept }
@@ -2284,14 +2660,21 @@ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps,
2284
2660
  // read on by the next answer as the year's commits are, and read again as
2285
2661
  // an answer kept is, a label changing whose a pull request is without
2286
2662
  // moving the head, the year counted again where what they bring changed.
2287
- // A year not read whole yet is how many of its commits are (`reading`), or
2288
- // of the pull requests merged (`pulls`), beside the year last counted from
2289
- // an earlier head over the same weeks, where one is kept, so that a head
2290
- // moved on shows the year as it was till it is counted again; one past
2663
+ // Read no further back than the period asked (`weeks`) needs, its weeks and
2664
+ // as many before them, the rest read only as a longer one asks, its count
2665
+ // so said (`from`). A year not read as far as it needs yet is how many of
2666
+ // its commits are (`reading`), or of the pull requests merged (`pulls`),
2667
+ // beside the year last counted from an earlier head, where one is kept over
2668
+ // the same weeks or a few weeks earlier, so that a head moved on, or the
2669
+ // rules its commits are read by, shows the year as it was till it is
2670
+ // counted again; one past
2291
2671
  // YEAR_COMMITS is GitHub's statistics' (repositoryStats, repositoryLines),
2292
2672
  // kept together as an issue's page is and through GitHub computing them
2293
2673
  // again, `pending` while it first computes them, the year last counted
2294
2674
  // beside it so too.
2675
+ // How many weeks behind the year shown a year last counted may be shown
2676
+ // while it is counted again: the year as it was, its latest weeks missing.
2677
+ const PULSE_LAST_MS = 28 * 24 * 60 * 60_000
2295
2678
  // A day as a pulse's items are narrowed by (YYYY-MM-DD, UTC), when it starts;
2296
2679
  // a GitHub login, as many of them as PULSE_AUTHORS.
2297
2680
  const PULSE_DAY = /^\d{4}-\d{2}-\d{2}$/u
@@ -2323,8 +2706,13 @@ async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttp
2323
2706
  })
2324
2707
  }
2325
2708
 
2326
- async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
2327
- const first = pulseSince(52, Date.now())
2709
+ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, weeks = 52) {
2710
+ const now = Date.now()
2711
+ const first = pulseSince(52, now)
2712
+ // As far back as a period of `weeks` weeks needs the year read whole: its
2713
+ // weeks and as many before them, which it is shown beside; the year's,
2714
+ // all of it.
2715
+ const need = Math.max(first, pulseSince(2 * weeks, now))
2328
2716
  const head = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2329
2717
  if (!head.value.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
2330
2718
  const store = repositoryReadStore(deps.db, deps.config.debug)
@@ -2333,7 +2721,7 @@ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: str
2333
2721
  const mergesKey = `${repoId}\0pulse-merges\0${PULSE_YEAR_VERSION}`
2334
2722
  const last = async () => {
2335
2723
  const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
2336
- return kept?.first === first ? kept.stats : null
2724
+ return kept && first - kept.first <= PULSE_LAST_MS ? kept.stats : null
2337
2725
  }
2338
2726
  const sha = head.value.sha
2339
2727
  // What tells whose the year's commits are, kept as read: the agents' pull
@@ -2341,47 +2729,60 @@ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: str
2341
2729
  // repository (`releases`, release-commits.ts releaseCommits).
2342
2730
  type Attribution = AgentMerges & { releases?: string[] }
2343
2731
  const keptAttribution = async () => ((await store.read(mergesKey))?.value as Attribution | undefined) ?? null
2344
- const attribution = async (kept: Attribution | null): Promise<Attribution> => {
2345
- const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept), releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
2732
+ const attribution = async (kept: Attribution | null, whole: number): Promise<Attribution> => {
2733
+ const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept, globalThis.fetch, whole),
2734
+ releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
2346
2735
  return { ...merges, releases }
2347
2736
  }
2737
+ // Since when every pull request merged is read (agentMerges `reach`, or
2738
+ // a read not finished's `to`): all of them, -∞; none yet, ∞.
2739
+ const readTo = (read: Attribution) => read.next ? read.next.to ?? Number.POSITIVE_INFINITY : read.reach ?? Number.NEGATIVE_INFINITY
2348
2740
  // The year counted from its `commits` as `read` tells whose they are and
2349
2741
  // which were pushed directly, the branch followed through those it was
2350
- // read on past (`passed`, yearCommits) too; kept by its head and as the
2351
- // year last counted.
2352
- const count = async ({ commits, passed }: { commits: HistoryCommit[]; passed: HistoryCommit[] }, read: Attribution) => {
2742
+ // read on past (`passed`, yearCommits) too, whole from when both the
2743
+ // commits and the pull requests merged are read (`from`, none where all
2744
+ // are); kept by its head and as the year last counted.
2745
+ const count = async ({ commits, passed, from }: { commits: HistoryCommit[]; passed: HistoryCommit[]; from: number | null }, read: Attribution) => {
2353
2746
  const walked = [...commits, ...passed]
2354
- const stats = commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha))
2747
+ const whole = Math.max(from ?? Number.NEGATIVE_INFINITY, readTo(read))
2748
+ const stats = { ...commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha)),
2749
+ ...Number.isFinite(whole) ? { from: whole } : {} }
2355
2750
  await Promise.all([store.write(key, stats, Date.now()), store.write(lastKey, { first, stats }, Date.now())])
2356
2751
  return stats
2357
2752
  }
2358
2753
  // A year kept by its head, what tells whose its commits are read again as
2359
2754
  // an answer kept is (keptAnswer), a label or a tag changing it without
2360
- // moving the head: counted again from its pages kept where what agents'
2361
- // pull requests bring, or the releases, changed, or a read of the pull
2362
- // requests left unfinished is finished; the year as kept answered
2363
- // meanwhile, and where reading them fails.
2755
+ // moving the head: counted again, as far back as it was, from its pages
2756
+ // kept where what agents' pull requests bring, or the releases, changed,
2757
+ // or the pull requests merged are read further back; the year as kept
2758
+ // answered meanwhile, and where reading them fails.
2364
2759
  const relabelled = async (stats: PulseStats) => {
2365
2760
  const recounted: { stats?: PulseStats } = {}
2761
+ const whole = stats.from ?? first
2366
2762
  await keptAnswer(deps, mergesKey, async () => {
2367
2763
  const kept = await keptAttribution()
2368
- const read = await attribution(kept)
2369
- const same = kept && !kept.next && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join()
2370
- if (read.next || same) return read
2371
- const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
2764
+ const read = await attribution(kept, whole)
2765
+ const same = kept && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join() && readTo(kept) <= readTo(read)
2766
+ if (readTo(read) >= whole || same) return read
2767
+ const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true), globalThis.fetch, whole)
2372
2768
  if (year.complete) recounted.stats = await count(year, read)
2373
2769
  return read
2374
2770
  }, () => true).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
2375
2771
  return recounted.stats ?? stats
2376
2772
  }
2773
+ // A year kept, read whole as far back as the period needs, as it was;
2774
+ // else its commits and the pull requests merged read on together as far
2775
+ // as it needs, and counted once both are.
2377
2776
  const held = (await store.read(key))?.value as PulseStats | { tooMany: true } | undefined
2378
- if (held && !('tooMany' in held)) return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
2379
- if (!held) {
2380
- const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
2777
+ if (held && !('tooMany' in held) && (held.from === undefined || held.from < need)) {
2778
+ return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
2779
+ }
2780
+ if (!held || !('tooMany' in held)) {
2781
+ const [year, read] = await Promise.all([yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true), globalThis.fetch, need),
2782
+ keptAttribution().then(kept => attribution(kept, need))])
2783
+ await store.write(mergesKey, read, Date.now())
2381
2784
  if (year.complete) {
2382
- const read = await attribution(await keptAttribution())
2383
- await store.write(mergesKey, read, Date.now())
2384
- if (read.next) return { stats: await last(), reading: null, pulls: read.next.read, pending: false, kept: false }
2785
+ if (readTo(read) >= need) return { stats: await last(), reading: null, pulls: read.next?.read ?? 0, pending: false, kept: false }
2385
2786
  return { stats: await count(year, read), reading: null, pulls: null, pending: false, kept: false }
2386
2787
  }
2387
2788
  if (!year.tooMany) return { stats: await last(), reading: year.read, pulls: null, pending: false, kept: false }
@@ -2480,7 +2881,7 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
2480
2881
  // reader leaving, losing their admin role or
2481
2882
  // the server shutting down stops it.
2482
2883
  async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2483
- const s = await readAdminSession(res, deps, cookie)
2884
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
2484
2885
  if (!s) return
2485
2886
  const repos = await riskRepositories(deps.db, query)
2486
2887
  if (!repos) {
@@ -2763,7 +3164,7 @@ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, dep
2763
3164
  }
2764
3165
 
2765
3166
  async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2766
- const initial = await manageMutation(req, res, deps, cookie)
3167
+ const initial = await manageMutation(req, res, deps, cookie, { handed: true })
2767
3168
  if (!initial) return
2768
3169
  const controller = new AbortController()
2769
3170
  const onClose = () => controller.abort()
@@ -2826,7 +3227,7 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
2826
3227
  // After storing, any reports that declared this integrity but weren't linked yet
2827
3228
  // get attached (auto-link). 413 over the cap, 400 on empty.
2828
3229
  async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2829
- let s = await checkMutation(req, res, deps, cookie)
3230
+ let s = await checkMutation(req, res, deps, cookie, { handed: true })
2830
3231
  if (s == null) return
2831
3232
  if (!requireManageRole(res, s.user)) return
2832
3233
  let bytes: Buffer
@@ -2956,7 +3357,7 @@ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps:
2956
3357
  // the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
2957
3358
  // best-effort. 404 when there was no such bundle.
2958
3359
  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)
3360
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
2960
3361
  if (s == null) return
2961
3362
  if (!requireManageRole(res, s.user)) return
2962
3363
  const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
@@ -2969,8 +3370,8 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
2969
3370
 
2970
3371
  // admin|manage mutation guard: same-origin + CSRF + role, in one step. Returns
2971
3372
  // 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)
3373
+ async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
3374
+ const s = await checkMutation(req, res, deps, cookie, { handed })
2974
3375
  if (s == null) return null
2975
3376
  if (!requireManageRole(res, s.user)) return null
2976
3377
  return s
@@ -2978,8 +3379,8 @@ async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: M
2978
3379
 
2979
3380
  // Workspace access and repository connections are administered separately
2980
3381
  // 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)
3382
+ async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
3383
+ const s = await checkMutation(req, res, deps, cookie, { handed })
2983
3384
  if (s == null) return null
2984
3385
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
2985
3386
  return s
@@ -3035,7 +3436,7 @@ async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId:
3035
3436
  // visible findings (location AND evidence) are returned. Missing/unavailable
3036
3437
  // bundles are a quiet no-op; cached gzip bodies stream directly from disk.
3037
3438
  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())
3439
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
3039
3440
  if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3040
3441
  if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3041
3442
  const sourcePaths = roleAtLeast(s.user.role, 'manage') ? undefined : await teamSourcePaths(deps.db, deps.reportStore, s.session.id, teamId ?? '', id)
@@ -3079,7 +3480,7 @@ async function reportIds(data: unknown): Promise<Set<string>> {
3079
3480
  async function handleDeduplication(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
3080
3481
  const method = req.method ?? 'GET'
3081
3482
  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)
3483
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
3083
3484
  if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
3084
3485
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
3085
3486
  if (method === 'GET') { sendJson(res, 200, id ? await deps.db.getLinkReport(s.session.id, id) : { reports: await deps.db.listLinkReports() }); return }
@@ -3102,7 +3503,7 @@ async function handleDeduplication(req: IncomingMessage, res: ServerResponse, de
3102
3503
  async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
3103
3504
  const method = req.method ?? 'GET'
3104
3505
  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)
3506
+ const s = method === 'GET' ? await readAdminSession(res, deps, cookie, { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
3106
3507
  if (!s) return
3107
3508
  if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
3108
3509
  if (method === 'GET') {
@@ -3122,7 +3523,7 @@ async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse
3122
3523
  // Managers can resolve only destinations their current repository grants cover.
3123
3524
  async function handleRepositorySuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3124
3525
  if (req.method !== 'GET') { send405(res, 'GET'); return }
3125
- if (!await readManageSession(res, deps, cookie)) return
3526
+ if (!await readManageSession(res, deps, cookie, { handed: true })) return
3126
3527
  const params = new URL(req.url!, 'http://localhost').searchParams
3127
3528
  const github = reportRepoGithub({ repo: { github: params.get('repo') } })
3128
3529
  const directory = normalizeTeamPath(params.get('directory'))
@@ -3160,7 +3561,7 @@ async function suggestReportLocation(deps: ManagedHttpDeps, user: StoredUser, da
3160
3561
  // location editor. It never changes the stored assignment.
3161
3562
  async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
3162
3563
  if (req.method !== 'GET') { send405(res, 'GET'); return }
3163
- const s = await readManageSession(res, deps, cookie)
3564
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3164
3565
  if (!s) return
3165
3566
  const report = await deps.db.getReport(id)
3166
3567
  if (!report || !(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
@@ -3182,7 +3583,7 @@ async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerR
3182
3583
  // "not authorized" (so neither existence nor membership is probeable); 503 = row
3183
3584
  // without bytes (store desync).
3184
3585
  async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
3185
- const s = await readManageSession(res, deps, cookie)
3586
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3186
3587
  if (s == null) return
3187
3588
  const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
3188
3589
  const access = snapshot?.reports[0]
@@ -3219,7 +3620,7 @@ async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps:
3219
3620
  const REPORT_QUERY_CONCURRENCY = 8
3220
3621
  const REPORT_QUERY_IN_FLIGHT_BYTES = 64 * 1024 * 1024
3221
3622
  async function handleQueryReports(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3222
- const s = await readManageSession(res, deps, cookie)
3623
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3223
3624
  if (!s) return
3224
3625
  let body
3225
3626
  try { body = await readJsonBody(req, 1024 * 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3313,9 +3714,11 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
3313
3714
 
3314
3715
  // The server selects the complete published workspace for classification.
3315
3716
  // 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> {
3717
+ // Its reports' file trees are asked for apart, by its graph or Files alone.
3718
+ async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string, reportId: string | null,
3719
+ part: 'reports' | 'trees' = 'reports'): Promise<void> {
3317
3720
  const snapshot = await teamSnapshot(deps.db, session.id, teamId, reportId)
3318
- const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
3721
+ const body = await (part === 'trees' ? loadTeamTreesResponse : loadTeamReportsResponse)(deps.db, deps.reportStore, snapshot)
3319
3722
  await recheckTeam(deps.db, session.id, snapshot)
3320
3723
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
3321
3724
  writeResponse(res, body)
@@ -3372,7 +3775,7 @@ async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256
3372
3775
  // stripped finding must not leak that the finding exists. A cleared entry is
3373
3776
  // sent as null (the server's tombstone), distinct from one never set.
3374
3777
  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())
3778
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
3376
3779
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3377
3780
  if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3378
3781
  await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
@@ -3391,7 +3794,7 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
3391
3794
  // exists). Each event is the entry as written then (null = a clear), who
3392
3795
  // wrote it and when; "what changed" is the diff against the next-older event.
3393
3796
  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())
3797
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
3395
3798
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3396
3799
  if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3397
3800
  const finding = query.get('finding') ?? ''
@@ -3422,7 +3825,7 @@ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHt
3422
3825
  // scope for rows that are themselves per finding id — so a stripped (or
3423
3826
  // foreign) finding id 404s without revealing whether it exists.
3424
3827
  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)
3828
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
3426
3829
  if (s == null) return
3427
3830
  if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
3428
3831
  let body: unknown
@@ -3456,7 +3859,7 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
3456
3859
  // GET only: clients match local annotations against these IDs without sending
3457
3860
  // their local finding IDs or annotation bodies to discover which reports exist.
3458
3861
  async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
3459
- const s = await readAdminSession(res, deps, cookie)
3862
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
3460
3863
  if (!s) return
3461
3864
  const after = search.get('after') ?? ''
3462
3865
  if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
@@ -3478,7 +3881,7 @@ async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps
3478
3881
  // Admin imports compare the exact snapshot shown by the conflict dialog before
3479
3882
  // writing. The store commits shared finding triage and imported comments together.
3480
3883
  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)
3884
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3482
3885
  if (!s) return
3483
3886
  let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
3484
3887
  try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof body }
@@ -3517,7 +3920,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
3517
3920
  if (commentId == null ? method !== 'GET' && method !== 'POST' : method !== 'PATCH' && method !== 'DELETE') {
3518
3921
  send405(res, commentId == null ? 'GET, POST' : 'PATCH, DELETE'); return
3519
3922
  }
3520
- const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
3923
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
3521
3924
  if (s == null) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
3522
3925
  const allowed = method === 'GET' ? await canViewReport(deps, s.user, reportId) : await canTriageReport(deps, s.user, reportId)
3523
3926
  if (!allowed) { sendJson(res, 404, { error: 'no-report' }); return }
@@ -3565,7 +3968,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
3565
3968
  // the page needs: all users (member dropdown), selected repos (repo dropdown),
3566
3969
  // and the visibility-permission keys. admin, read-only (no CSRF).
3567
3970
  async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3568
- const s = await readAdminSession(res, deps, cookie)
3971
+ const s = await readAdminSession(res, deps, cookie, { handed: true })
3569
3972
  if (s == null) return
3570
3973
  const scopes = await deps.db.listTeamNpmScopes()
3571
3974
  const reviewTeams = new Set(await deps.db.listDependencyReviewEnabledTeams())
@@ -3579,7 +3982,7 @@ async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cooki
3579
3982
 
3580
3983
  // POST /api/admin/teams — create a team. Body { name }. 409 if the name's taken.
3581
3984
  async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3582
- const s = await adminMutation(req, res, deps, cookie)
3985
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3583
3986
  if (s == null) return
3584
3987
  let body: unknown
3585
3988
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3596,7 +3999,7 @@ async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps:
3596
3999
  // POST /api/admin/teams/rename — rename a team. Body { teamId, name }. 404 if no
3597
4000
  // such team; 409 if the new name is already taken by another team.
3598
4001
  async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3599
- const s = await adminMutation(req, res, deps, cookie)
4002
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3600
4003
  if (s == null) return
3601
4004
  let body: unknown
3602
4005
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3615,7 +4018,7 @@ async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps:
3615
4018
 
3616
4019
  // Hidden teams retain their configuration but grant no access or sidebar presence.
3617
4020
  async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3618
- const s = await adminMutation(req, res, deps, cookie)
4021
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3619
4022
  if (s == null) return
3620
4023
  let body: unknown
3621
4024
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3631,7 +4034,7 @@ async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, de
3631
4034
 
3632
4035
  // POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
3633
4036
  async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3634
- const s = await adminMutation(req, res, deps, cookie)
4037
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3635
4038
  if (s == null) return
3636
4039
  let body: unknown
3637
4040
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3648,7 +4051,7 @@ async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps:
3648
4051
  // path links with the whole repository. Body { teamId, repoId, path? }.
3649
4052
  // Repo must be in the selected set.
3650
4053
  async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3651
- const s = await adminMutation(req, res, deps, cookie)
4054
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3652
4055
  if (s == null) return
3653
4056
  let body: unknown
3654
4057
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3673,7 +4076,7 @@ async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps
3673
4076
  // POST /api/admin/teams/remove-repo — unlink one path. Omitting path retains
3674
4077
  // the legacy remove-all behavior. Body { teamId, repoId, path? }.
3675
4078
  async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3676
- const s = await adminMutation(req, res, deps, cookie)
4079
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3677
4080
  if (s == null) return
3678
4081
  let body: unknown
3679
4082
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3693,7 +4096,7 @@ async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, d
3693
4096
  // POST /api/admin/teams/set-member — add/update a member + their visibility
3694
4097
  // permissions. Body { teamId, userId, dependencies?, security? }.
3695
4098
  async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3696
- const s = await adminMutation(req, res, deps, cookie)
4099
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3697
4100
  if (s == null) return
3698
4101
  let body: unknown
3699
4102
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3716,7 +4119,7 @@ async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, de
3716
4119
 
3717
4120
  // POST /api/admin/teams/remove-member — remove a membership. Body { teamId, userId }.
3718
4121
  async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3719
- const s = await adminMutation(req, res, deps, cookie)
4122
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3720
4123
  if (s == null) return
3721
4124
  let body: unknown
3722
4125
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3732,7 +4135,7 @@ async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse,
3732
4135
  }
3733
4136
 
3734
4137
  async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3735
- const s = await adminMutation(req, res, deps, cookie)
4138
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3736
4139
  if (!s) return
3737
4140
  let body
3738
4141
  try { body = await readJsonBody(req) as { teamId?: unknown; enabled?: unknown } } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3744,7 +4147,7 @@ async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerRe
3744
4147
  // POST /api/admin/teams/set-npm-scopes — replace the npm scopes whose private
3745
4148
  // packages the team's members may read. Body { teamId, scopes: ['@scope'] }.
3746
4149
  async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
3747
- const s = await adminMutation(req, res, deps, cookie)
4150
+ const s = await adminMutation(req, res, deps, cookie, { handed: true })
3748
4151
  if (s == null) return
3749
4152
  let body: unknown
3750
4153
  try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
@@ -3763,10 +4166,18 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
3763
4166
  // npm scopes of its visible teams, else none: none are read for an unscoped
3764
4167
  // package, an admin or a manager, or an answer checked again that is public.
3765
4168
  // 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())
4169
+ async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[], { handed = false } = {}): Promise<NpmReader | null> {
4170
+ const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
3768
4171
  return s == null ? null : npmReaderOf(deps, s.user, names)
3769
4172
  }
4173
+ // The reader of the session `cookie` names now, as npmReader reads them,
4174
+ // their npm scopes whatever the packages, and whether they read team
4175
+ // `teamId`'s reviews: one statement.
4176
+ async function npmReviewReader(deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<(NpmReader & { readsTeam: boolean }) | null> {
4177
+ const ids = sessionIds(deps.config, cookie)
4178
+ const reader = ids && await deps.db.getNpmReviewReader(ids.id, ids.viewer, Date.now(), teamId)
4179
+ return reader && roleAtLeast(reader.role, 'view') ? { role: reader.role, scopes: new Set(reader.scopes), userId: reader.id, readsTeam: reader.readsTeam } : null
4180
+ }
3770
4181
  // The same of `user`, as a session the request read just now tells them.
3771
4182
  async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
3772
4183
  if (!roleAtLeast(user.role, 'view')) return null
@@ -3787,9 +4198,11 @@ const REVIEW_READS_AT_ONCE = 8
3787
4198
 
3788
4199
  // Lists must enforce the same target and base access as the review itself,
3789
4200
  // including permission changes while the registry requests are in flight;
3790
- // first as `session`, the one the request read just now, where given.
4201
+ // first as `session`, the one the request read just now, where given. A
4202
+ // team's (`teamId`) only to one who still reads its reviews there, asked
4203
+ // again with the rest (npmReviewReader).
3791
4204
  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> {
4205
+ session?: { id: string; role: NpmReader['role'] }, teamId?: string): Promise<T[] | null> {
3793
4206
  const controller = new AbortController()
3794
4207
  const onClose = () => { if (!res.writableEnded) controller.abort() }
3795
4208
  res.on('close', onClose)
@@ -3808,8 +4221,10 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
3808
4221
  const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
3809
4222
  const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
3810
4223
  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] : []))
4224
+ const current = teamId === undefined ? await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
4225
+ : await npmReviewReader(deps, cookie, teamId)
3812
4226
  if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
4227
+ if ('readsTeam' in current && !current.readsTeam) { sendJson(res, 404, { error: 'no-team' }); return null }
3813
4228
  return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
3814
4229
  } catch (error) {
3815
4230
  if (controller.signal.aborted) return null
@@ -3847,13 +4262,16 @@ async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReade
3847
4262
  return npmReviewVerdictLimit(doc, list, socket, risk)
3848
4263
  }
3849
4264
 
3850
- // Reviews are scoped to the bytes actually displayed, including the base.
3851
- // Access to both versions is rechecked before returning or saving annotations.
4265
+ // Reviews are scoped to the bytes actually displayed, including the base:
4266
+ // each change but a revocation loads both versions' files, of tarballs held
4267
+ // to their integrity and to their shasum where npm tells one (npm-loads.ts),
4268
+ // and the review keeps those shasums. Access to both versions is rechecked
4269
+ // before returning or saving annotations.
3852
4270
  async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
3853
4271
  const method = req.method ?? 'GET'
3854
4272
  if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
3855
4273
  const mutation = method !== 'GET'
3856
- const session = mutation ? await checkMutation(req, res, deps, cookie) : await readSession(deps.config, deps.db, cookie, Date.now())
4274
+ const session = mutation ? await checkMutation(req, res, deps, cookie, { handed: true }) : await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
3857
4275
  if (!session) { if (!mutation) sendJson(res, 401, { error: 'unauthenticated' }); return }
3858
4276
  if (!roleAtLeast(session.user.role, mutation ? 'triage' : 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
3859
4277
  let body: Record<string, unknown> = {}
@@ -3928,10 +4346,14 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3928
4346
  || (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
3929
4347
  sendJson(res, 409, { error: 'review-content-changed' }); return
3930
4348
  }
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))
4349
+ // Each change but a revocation loads both versions' files; a save or a
4350
+ // publication is held to them.
4351
+ const loads = change && change.action !== 'revoke' ? [doc, ...base ? [base] : []].map(loadNpmFiles) : []
4352
+ let tarballs: ReviewedTarballs | undefined
4353
+ try {
4354
+ const files = await Promise.all(loads.map(load => load.result))
4355
+ if (loads.length > 0) tarballs = { shasum: doc.dist.shasum, baseShasum: base?.dist.shasum ?? null }
4356
+ if (change?.action === 'publish' || change?.action === 'save') {
3935
4357
  const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
3936
4358
  const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
3937
4359
  const diff = computeBundleDiff(before, after, () => '.')
@@ -3945,8 +4367,8 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3945
4367
  change.files = changed.map(file => file.path)
3946
4368
  change.nonTextFiles = changed.filter(file => !reviewable(file)).map(file => file.path)
3947
4369
  }
3948
- } finally { loads.forEach(load => load.release()) }
3949
- }
4370
+ }
4371
+ } finally { loads.forEach(load => load.release()) }
3950
4372
  const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
3951
4373
  ? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
3952
4374
  const current = await npmReader(deps, cookie, [doc, base].flatMap(item => item?.private ? [item.name] : []))
@@ -3965,7 +4387,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3965
4387
  if (change.action === 'publish' && change.final && !allowedReviewVerdicts([], limit).includes(change.final.status)) {
3966
4388
  sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
3967
4389
  }
3968
- const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
4390
+ const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now(), tarballs)
3969
4391
  sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
3970
4392
  } catch (err) {
3971
4393
  if (controller.signal.aborted) return
@@ -3975,6 +4397,62 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
3975
4397
  } finally { res.off('close', onClose) }
3976
4398
  }
3977
4399
 
4400
+ // GET /api/npm/deltas?versions=[[name, version, otherVersion], …]: the lines
4401
+ // each of up to MAX_ASKED_DELTAS versions changes against another, or
4402
+ // `(empty)`, as GET delta counts them, as lines of JSON as each is counted
4403
+ // (`{ name, version, otherVersion, …delta }`, or `{ name, version,
4404
+ // otherVersion, error, status }`), as a list's rows near the viewport ask
4405
+ // for them together (npm-delta.js), DELTAS_AT_ONCE at a time. Each read as
4406
+ // GET delta reads it, with the reader's access to its package, which is
4407
+ // checked again once it is counted: a private one's told only to a reader
4408
+ // who may still read it, none told to one whose session is gone.
4409
+ const MAX_ASKED_DELTAS = 50
4410
+ const DELTAS_AT_ONCE = 2
4411
+ async function handleNpmDeltas(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
4412
+ if (req.method !== 'GET') { send405(res, 'GET'); return }
4413
+ let asked: unknown = null
4414
+ try { asked = JSON.parse(params.get('versions') ?? '') } catch {}
4415
+ if (!Array.isArray(asked) || asked.length === 0 || asked.length > MAX_ASKED_DELTAS || !asked.every(item => Array.isArray(item) && item.length === 3
4416
+ && isNpmPackageName(item[0]) && isNpmPackageSpec(item[1]) && (item[2] === '(empty)' || isNpmPackageSpec(item[2])))) { sendJson(res, 400, { error: 'bad-package' }); return }
4417
+ const versions = asked as [string, string, string][]
4418
+ const reader = await npmReader(deps, cookie, [...new Set(versions.map(([name]) => name))], { handed: true })
4419
+ if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4420
+ const controller = new AbortController()
4421
+ res.once('close', () => { if (!res.writableEnded) controller.abort() })
4422
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
4423
+ const kept = npmDeltaStore<NpmDelta>(deps.db, deps.config.debug), keptVersions = npmVersionStore(deps.db, deps.config.debug)
4424
+ const out = openLines(res)
4425
+ let stopped = false
4426
+ await eachOf(versions, DELTAS_AT_ONCE, async ([name, version, otherVersion]) => {
4427
+ if (stopped || controller.signal.aborted) return
4428
+ const asOne = { name, version, otherVersion }
4429
+ let isPrivate = false, line: Record<string, unknown>
4430
+ try {
4431
+ const privileged = canReadPrivateNpm(reader, name)
4432
+ const together = otherVersion === '(empty)' ? keptVersions : readTogether(keptVersions, [{ name, version }, { name, version: otherVersion }])
4433
+ const [doc, old] = await Promise.all([readNpmVersion(name, version, privileged, controller.signal, together).then(found),
4434
+ otherVersion === '(empty)' ? null : readNpmVersion(name, otherVersion, privileged, controller.signal, together).then(found)])
4435
+ isPrivate = doc.private || old?.private === true
4436
+ const side = (of: NpmVersionDocument) => ({ integrity: of.dist.integrity, files: async () => {
4437
+ const load = loadNpmFiles(of)
4438
+ try { return await load.result } finally { load.release() }
4439
+ } })
4440
+ line = { ...asOne, ...await npmDelta(old ? side(old) : null, side(doc), kept) }
4441
+ } catch (err) {
4442
+ if (controller.signal.aborted) return
4443
+ if (!(err instanceof NpmPackageError)) console.warn('managed: a package delta failed:', err)
4444
+ line = err instanceof NpmPackageError ? { ...asOne, error: err.message, status: err.status } : { ...asOne, error: 'delta-failed', status: 500 }
4445
+ }
4446
+ // A reader who lost their session, or their access to a private one,
4447
+ // while it was counted, is told nothing of it.
4448
+ const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
4449
+ if (current == null) { stopped = true; out.cut(); return }
4450
+ if (isPrivate && !canReadPrivateNpm(current, name)) line = { ...asOne, error: 'package-not-found', status: 404 }
4451
+ if (!stopped && !await out.write(line)) stopped = true
4452
+ })
4453
+ if (!stopped) await out.end()
4454
+ }
4455
+
3978
4456
  // GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
3979
4457
  // (canReadPrivateNpm) can be answered from the server's npm token; the rest get
3980
4458
  // what the registry answers anonymously, every time (see npm-packages.ts).
@@ -3995,7 +4473,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3995
4473
  }
3996
4474
  const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
3997
4475
  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])
4476
+ const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName], { handed: true })
3999
4477
  if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4000
4478
  const controller = new AbortController()
4001
4479
  const onClose = () => { if (!res.writableEnded) controller.abort() }
@@ -4240,7 +4718,7 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
4240
4718
  const method = req.method ?? 'GET'
4241
4719
  const allowed = id ? ['PATCH', 'DELETE'] : ['GET', 'POST', 'DELETE']
4242
4720
  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)
4721
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
4244
4722
  if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
4245
4723
  if (!requireManageRole(res, s.user)) return
4246
4724
  const { db } = deps
@@ -4371,6 +4849,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4371
4849
  const managedDataPath = underPrefix(path, MANAGED_DATA_PREFIXES)
4372
4850
  const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
4373
4851
  if (managedDataPath && !workspaceSession) return
4852
+ // What it read, for the handler's own first read to take.
4853
+ if (workspaceSession) handOverSession(cookie, workspaceSession)
4374
4854
  const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
4375
4855
  if (issueRoute) {
4376
4856
  await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
@@ -4383,8 +4863,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4383
4863
  if (path === '/api/admin/links') {
4384
4864
  if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
4385
4865
  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 }
4866
+ const s = workspaceSession!
4388
4867
  if (!requireManageRole(res, s.user)) return
4389
4868
  const shares = await db.listManagedWorkspaceShares(s.session.id, Date.now())
4390
4869
  sendJson(res, shares ? 200 : 403, shares ? { shares } : { error: 'forbidden' }); return
@@ -4402,11 +4881,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4402
4881
  // sign-in account's. A linked account's is served by its own id.
4403
4882
  if (path.startsWith(AVATAR_PREFIX)) {
4404
4883
  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
4884
  const id = path.slice(AVATAR_PREFIX.length)
4408
- if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
4409
- await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
4885
+ if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(req, res, avatarStore, id); return }
4886
+ await serveAvatar(req, res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
4410
4887
  return
4411
4888
  }
4412
4889
  // Admin: list users (admin-only).
@@ -4416,9 +4893,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4416
4893
  }
4417
4894
  if (path === ADMIN_USERS_PATH) {
4418
4895
  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 }
4896
+ if (workspaceSession!.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
4422
4897
  sendJson(res, 200, { users: await db.listUsers() })
4423
4898
  return
4424
4899
  }
@@ -4590,13 +5065,17 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4590
5065
  const teamFeed = /^\/api\/teams\/([^/]+)\/feed$/u.exec(path)
4591
5066
  if (teamFeed || path === '/api/teams/feed') {
4592
5067
  if (method !== 'GET') { send405(res, 'GET'); return }
4593
- const s = await readWorkspaceSession(res, deps, cookie)
4594
- if (!s) return
5068
+ const s = workspaceSession!
4595
5069
  const teamId = teamFeed?.[1] ?? null
4596
5070
  const reportId = url.searchParams.get('reportId')
4597
5071
  if (teamId) await teamSnapshot(db, s.session.id, teamId, reportId)
4598
5072
  await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId, { reportId }); return
4599
5073
  }
5074
+ const teamDependencies = /^\/api\/teams\/([^/]+)\/dependencies$/u.exec(path)
5075
+ if (teamDependencies) {
5076
+ if (method !== 'GET') { send405(res, 'GET'); return }
5077
+ await handleTeamDependencies(res, deps, cookie, workspaceSession!, teamDependencies[1]!, url.searchParams); return
5078
+ }
4600
5079
  const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
4601
5080
  if (teamFixes) {
4602
5081
  if (method !== 'GET') { send405(res, 'GET'); return }
@@ -4610,10 +5089,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4610
5089
  await recheckTeam(db, workspaceSession!.session.id, snapshot)
4611
5090
  sendJson(res, 200, annotations); return
4612
5091
  }
4613
- const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
5092
+ const teamReports = /^\/api\/teams\/([^/]+)\/(reports|trees)$/u.exec(path)
4614
5093
  if (teamReports) {
4615
5094
  if (method !== 'GET') { send405(res, 'GET'); return }
4616
- await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId')); return
5095
+ await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId'), teamReports[2] as 'reports' | 'trees'); return
4617
5096
  }
4618
5097
  if (path === REPORT_QUERY_PATH) {
4619
5098
  if (method !== 'POST') { send405(res, 'POST'); return }
@@ -4681,8 +5160,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4681
5160
  }
4682
5161
  if (path === '/api/npm/reviews/latest') {
4683
5162
  if (method !== 'GET') { send405(res, 'GET'); return }
4684
- const s = await readWorkspaceSession(res, deps, cookie)
4685
- if (!s) return
5163
+ const s = workspaceSession!
4686
5164
  const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
4687
5165
  if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
4688
5166
  sendJson(res, 400, { error: 'bad-review-context' }); return
@@ -4694,7 +5172,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4694
5172
  }
4695
5173
  if (path === '/api/npm/reviews/discard') {
4696
5174
  if (method !== 'POST') { send405(res, 'POST'); return }
4697
- const s = await checkMutation(req, res, deps, cookie)
5175
+ const s = await checkMutation(req, res, deps, cookie, { handed: true })
4698
5176
  if (!s) return
4699
5177
  let body: Record<string, unknown>
4700
5178
  try {
@@ -4711,8 +5189,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4711
5189
  }
4712
5190
  if (path === '/api/npm/reviews/mine') {
4713
5191
  if (method !== 'GET') { send405(res, 'GET'); return }
4714
- const s = await readWorkspaceSession(res, deps, cookie)
4715
- if (!s) return
5192
+ const s = workspaceSession!
4716
5193
  const id = url.searchParams.get('beforeId'), time = url.searchParams.get('beforeTime')
4717
5194
  if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
4718
5195
  sendJson(res, 400, { error: 'bad-review-cursor' }); return
@@ -4726,8 +5203,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4726
5203
  // Dependencies tab lists them, fifty a page.
4727
5204
  if (path === '/api/npm/reviews/team') {
4728
5205
  if (method !== 'GET') { send405(res, 'GET'); return }
4729
- const s = await readWorkspaceSession(res, deps, cookie)
4730
- if (!s) return
5206
+ const s = workspaceSession!
4731
5207
  const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
4732
5208
  if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
4733
5209
  if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
@@ -4735,16 +5211,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4735
5211
  }
4736
5212
  const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
4737
5213
  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
5214
  // 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 })
5215
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user, teamId)
5216
+ if (reviews) sendJson(res, 200, { ...page, reviews })
4743
5217
  return
4744
5218
  }
4745
5219
  if (path === '/api/npm/reviews') {
4746
5220
  await handleNpmReviews(req, res, deps, cookie, url.searchParams); return
4747
5221
  }
5222
+ if (path === NPM_DELTAS_PATH) { await handleNpmDeltas(req, res, deps, cookie, url.searchParams); return }
4748
5223
  if (NPM_PATHS.has(path)) {
4749
5224
  await handleNpm(req, res, deps, cookie, path, url.searchParams); return
4750
5225
  }
@@ -4754,12 +5229,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4754
5229
  sendJson(res, 404, { error: 'not-found' }, { connection: 'close' })
4755
5230
  }
4756
5231
 
4757
- return (req, res) => {
5232
+ // Each request's time told in its Server-Timing header (request-timing.ts).
5233
+ return (req, res) => runTimed(res, () => {
4758
5234
  if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
4759
5235
  if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return }
4760
5236
  // 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 ?? '')
5237
+ // also covers capability-authenticated feeds handled by public-workspace;
5238
+ // and a team's dependencies and npm's deltas, streamed as GitHub and npm
5239
+ // are waited on, lease one a statement.
5240
+ const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '') || /^\/api\/(?:teams\/[^/?]+\/dependencies|npm\/deltas)(?:\?|$)/u.test(req.url ?? '')
4763
5241
  const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
4764
5242
  const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
4765
5243
  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 +5247,5 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4769
5247
  })
4770
5248
  track(work)
4771
5249
  return work
4772
- }
5250
+ })
4773
5251
  }