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

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 (186) hide show
  1. package/common/bundle-compare-diff.js +2 -167
  2. package/common/bundle-compare-versions.js +172 -0
  3. package/common/bundle-comparison.d.ts +8 -0
  4. package/common/bundle-comparison.js +62 -0
  5. package/common/bundle-sources.js +20 -1
  6. package/common/default-scan-models.ts +1 -1
  7. package/common/dependency-reviews.d.ts +0 -5
  8. package/common/dependency-reviews.js +0 -17
  9. package/common/managed/agent-tasks.ts +292 -0
  10. package/common/managed/github-agents.ts +92 -0
  11. package/common/managed/github-logins.ts +49 -0
  12. package/common/managed/roles.ts +7 -0
  13. package/common/managed/routes.js +91 -10
  14. package/common/managed/team-app.d.ts +5 -2
  15. package/common/managed/team-app.js +2 -2
  16. package/common/server-info.ts +11 -0
  17. package/common/workspace-app.js +19 -3
  18. package/out/brotli-fallback.js +3 -3
  19. package/out/chunk-2XUEZHIP.js +18 -18
  20. package/out/chunk-2ZDZWLFY.js +1 -0
  21. package/out/chunk-3PS4KWD2.js +10 -0
  22. package/out/chunk-5DHL5NG3.js +2 -0
  23. package/out/chunk-6HNXNIQY.js +52 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-AFLZH7WG.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-C4Z6ZBXV.js +6 -6
  28. package/out/chunk-GDPCLSOS.js +2 -0
  29. package/out/chunk-GO7FT4OD.js +1 -0
  30. package/out/chunk-HMAJDVXN.js +1 -0
  31. package/out/chunk-I2MDGSQX.js +3 -0
  32. package/out/chunk-IYPY47MC.js +1 -0
  33. package/out/chunk-IZ7POWI3.js +18 -0
  34. package/out/chunk-JCIIBLKR.js +1 -0
  35. package/out/chunk-NPW52BM2.js +17 -0
  36. package/out/chunk-NRPC74AM.js +1 -1
  37. package/out/chunk-NV7ES4EW.js +1 -0
  38. package/out/chunk-OKRLLE5I.js +17 -0
  39. package/out/chunk-OOJM4CTU.js +1 -1
  40. package/out/chunk-PNHBZGDD.js +1 -1
  41. package/out/chunk-PTGDYLNS.js +36 -0
  42. package/out/chunk-PXK7EU4V.js +1 -0
  43. package/out/chunk-Q6MVYGYV.js +3 -0
  44. package/out/chunk-QB4CBOX2.js +1 -0
  45. package/out/chunk-QRD6GIG5.js +229 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-SXV2T5C4.js +80 -0
  49. package/out/chunk-TUII34K2.js +1 -1
  50. package/out/chunk-VFWMQ3LG.js +2 -0
  51. package/out/chunk-VH4XMZIW.js +2 -0
  52. package/out/chunk-WFYLMF6Z.js +10 -0
  53. package/out/chunk-WI7Z5A2T.js +6 -0
  54. package/out/chunk-WQ74ZBZF.js +7 -0
  55. package/out/chunk-XVBEOGOS.js +3 -0
  56. package/out/client-managed-import.js +3 -3
  57. package/out/client-managed.js +31 -35
  58. package/out/client-sync.js +11 -11
  59. package/out/compare.js +35 -0
  60. package/out/dialogs.js +51 -0
  61. package/out/github-issue-authorized.html +1 -1
  62. package/out/github-issue-authorized.js +1 -1
  63. package/out/graph.js +17 -17
  64. package/out/index.html +31 -31
  65. package/out/prism.js +14 -14
  66. package/out/scan.js +2 -0
  67. package/out/terminal.js +294 -293
  68. package/out/view.css +1 -1
  69. package/out/view.js +16 -107
  70. package/package.json +55 -6
  71. package/risk/actions/findings.js +156 -0
  72. package/risk/actions/resolve.js +219 -0
  73. package/risk/actions/scripts/commands.js +182 -0
  74. package/risk/actions/scripts/grade.js +113 -0
  75. package/risk/actions/scripts/packages.js +123 -0
  76. package/risk/actions/scripts/shell.js +165 -0
  77. package/risk/actions/uses.js +76 -0
  78. package/risk/actions/workflow.js +110 -0
  79. package/risk/files/hosts.js +6 -6
  80. package/risk/files/readability.js +23 -17
  81. package/risk/files/runs-code.js +18 -6
  82. package/risk/files/script.js +7 -3
  83. package/risk/files/stamps.js +8 -7
  84. package/risk/index.d.ts +68 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +13 -8
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/dependencies.js +11 -0
  89. package/risk/npm/files.js +413 -2
  90. package/risk/npm/tarball.js +56 -73
  91. package/server-managed/agent-merges.ts +198 -0
  92. package/server-managed/app-agents.ts +30 -0
  93. package/server-managed/avatar-store.ts +1 -0
  94. package/server-managed/commit-coauthors.ts +83 -0
  95. package/server-managed/commit-lockfiles.ts +62 -0
  96. package/server-managed/comparison.ts +41 -0
  97. package/server-managed/db-methods.ts +29 -10
  98. package/server-managed/db-neon.ts +30 -2
  99. package/server-managed/db-schema.ts +9 -2
  100. package/server-managed/db.ts +7 -0
  101. package/server-managed/dependabot-access.ts +60 -0
  102. package/server-managed/dependabot-alerts.ts +185 -0
  103. package/server-managed/dependency-review-comments.ts +97 -0
  104. package/server-managed/dependency-reviews.ts +65 -15
  105. package/server-managed/github-accounts.ts +33 -0
  106. package/server-managed/github-actions.ts +71 -0
  107. package/server-managed/github-app.ts +23 -9
  108. package/server-managed/github-errors.ts +2 -0
  109. package/server-managed/github-link-oauth.ts +69 -0
  110. package/server-managed/github-links.ts +204 -0
  111. package/server-managed/github-oauth.ts +5 -2
  112. package/server-managed/github-users.ts +30 -0
  113. package/server-managed/http.ts +1219 -81
  114. package/server-managed/index.ts +6 -2
  115. package/server-managed/locked-packages.ts +48 -16
  116. package/server-managed/lockfile-scope.ts +49 -0
  117. package/server-managed/npm-delta-worker.js +23 -0
  118. package/server-managed/npm-delta.ts +173 -0
  119. package/server-managed/npm-insights.ts +20 -11
  120. package/server-managed/npm-kept.ts +51 -4
  121. package/server-managed/npm-loads.ts +20 -13
  122. package/server-managed/npm-packages.ts +24 -64
  123. package/server-managed/pretty-print.ts +14 -9
  124. package/server-managed/public-workspace.ts +26 -8
  125. package/server-managed/pull-comment-reviews.ts +161 -0
  126. package/server-managed/pull-dependencies.ts +280 -0
  127. package/server-managed/pull-dependency-reviews.ts +318 -0
  128. package/server-managed/pull-reviews.ts +205 -0
  129. package/server-managed/release-commits.ts +84 -0
  130. package/server-managed/repository-actions.ts +209 -0
  131. package/server-managed/repository-agents.ts +220 -0
  132. package/server-managed/repository-browser.ts +54 -13
  133. package/server-managed/repository-changes.ts +284 -0
  134. package/server-managed/repository-code.ts +52 -0
  135. package/server-managed/repository-dependency-reviews.ts +135 -0
  136. package/server-managed/repository-history.ts +445 -0
  137. package/server-managed/repository-issues.ts +181 -0
  138. package/server-managed/repository-pulse.ts +432 -0
  139. package/server-managed/repository-risk-read.ts +165 -0
  140. package/server-managed/repository-risk.ts +116 -106
  141. package/server-managed/reviewed-dependencies.ts +149 -0
  142. package/server-managed/session.ts +9 -5
  143. package/server-managed/sql.ts +1 -1
  144. package/server-managed/storage-stores.ts +5 -0
  145. package/server-managed/team-app.ts +54 -14
  146. package/server-managed/workspace-shares.ts +4 -1
  147. package/server.ts +11 -0
  148. package/out/chunk-2KBF5TZ7.js +0 -2
  149. package/out/chunk-35VE6CDH.js +0 -2
  150. package/out/chunk-3BZ7JERB.js +0 -1
  151. package/out/chunk-4F6JSF57.js +0 -3
  152. package/out/chunk-4XCDFGDP.js +0 -10
  153. package/out/chunk-7RU5EDRO.js +0 -226
  154. package/out/chunk-A7M4GV7M.js +0 -1
  155. package/out/chunk-CLPKNNC7.js +0 -51
  156. package/out/chunk-CMQA4DM6.js +0 -1
  157. package/out/chunk-CVVJAXVY.js +0 -2
  158. package/out/chunk-DIJJNTRH.js +0 -10
  159. package/out/chunk-GJ4IUCNS.js +0 -1
  160. package/out/chunk-GNXOKIGP.js +0 -80
  161. package/out/chunk-GZ4MS3TE.js +0 -13
  162. package/out/chunk-IU3CCINR.js +0 -3
  163. package/out/chunk-K3X5EJBQ.js +0 -2
  164. package/out/chunk-KEX2ATEW.js +0 -2
  165. package/out/chunk-KYCNJVB7.js +0 -1
  166. package/out/chunk-LAAB5SZT.js +0 -2
  167. package/out/chunk-NWZ4PVPN.js +0 -1
  168. package/out/chunk-OSIMGFUF.js +0 -1
  169. package/out/chunk-RFZQQXSG.js +0 -17
  170. package/out/chunk-RSHMVYNS.js +0 -1
  171. package/out/chunk-TZ5KB2Y5.js +0 -10
  172. package/out/chunk-UNTRGE26.js +0 -2
  173. package/out/chunk-UXNUAIIC.js +0 -33
  174. package/out/chunk-UYAXVRVL.js +0 -1
  175. package/out/chunk-V3ME6FI5.js +0 -2
  176. package/out/chunk-V5O7M2YT.js +0 -1
  177. package/out/chunk-VIHGO77Y.js +0 -1
  178. package/out/chunk-VQJCXGIY.js +0 -7
  179. package/out/chunk-W4J3MGLB.js +0 -1
  180. package/out/chunk-WGG7KQUH.js +0 -1
  181. package/out/chunk-WZDTZE64.js +0 -1
  182. package/out/chunk-XOCAPDCM.js +0 -1
  183. package/out/chunk-XYFXXB2G.js +0 -10
  184. package/out/chunk-YVZH5SHP.js +0 -1
  185. package/out/chunk-ZBBOBQ43.js +0 -1
  186. package/out/chunk-ZMQQCGF6.js +0 -2
@@ -3,10 +3,16 @@
3
3
  // server doesn't speak the e2e sync protocol yet). Every handler is async; the
4
4
  // boot's `track()` keeps in-flight requests drainable on shutdown.
5
5
  //
6
- // GET /api/config → { mode:'managed', managed:{ loginPath, cookieName } } (public)
6
+ // GET /api/config → { mode:'managed', managed:{ loginPath, cookieName }, session } (public;
7
+ // `session` is /api/auth/session's answer, null signed out, absent when it could not be read)
7
8
  // GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
8
9
  // GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
9
10
  // GET /api/auth/session → { user, csrfToken } | 401
11
+ // GET /api/auth/profile → { profile: { login, name, githubName, nameAccount, avatarAccount, accounts } }, the
12
+ // GitHub accounts linked to the user's and which of them the displayed name and avatar come from | 401/403
13
+ // POST /api/auth/profile → CSRF, chooses those ({ name?, avatar? }: a linked account's id, null the sign-in one) | 400/401/403/404
14
+ // DELETE /api/auth/profile/accounts/<id> → CSRF, unlinks a linked GitHub account | 401/403/404
15
+ // GET /api/oauth/github/link → 302 to GitHub to link another account (+ state cookie); the callback lands on /my/profile
10
16
  // GET /api/teams → the current user's teams + their reports and bundles | 401
11
17
  // GET /api/teams/<id>/fixes → PR/issue metadata from visible findings' stored Fix links
12
18
  // GET /api/teams/<id>/reports → all reports and links filtered through this team | 401/404
@@ -16,14 +22,48 @@
16
22
  // GET /api/reports/<id>/triage → triage entries (by finding id, shared across reports) for a viewable report's findings | 401/404
17
23
  // POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
18
24
  // GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
19
- // GET /api/avatar/<id> → cached avatar bytes by user id | 401/404
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
20
27
  // GET /api/admin/users → admin-only user list | 401/403
21
28
  // GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
22
29
  // POST /api/admin/set-role → admin sets another user's role | 401/403/404
23
30
  // GET /api/admin/repositories → admin repo list (each flagged selected) | 401/403
24
31
  // POST /api/admin/repositories/select → admin selects/deactivates a repo | 401/403
32
+ // POST /api/admin/repositories/dependency-review → admin sets a repo's dependency review: enabled, the reviews each
33
+ // version needs, and the commit its lockfiles' versions need none since | 400/401/403/404
25
34
  // GET /api/admin/repositories/impact → admin attached data summary
26
- // GET /api/admin/repositories/{browsable,refs,contents} → admin|manage + GitHub read access
35
+ // GET /api/admin/repositories/{browsable,refs,contents} → view or above, within the reader's team grants, + GitHub read access
36
+ // (browsable: the names alone; these and the reads below are the code viewer's, open to every team member)
37
+ // GET /api/admin/repositories/code?repoId=&ref=&path= → the files of a directory at a revision, within the reader's grants,
38
+ // Brotli-encoded | 400/401/403/404/409/413/429/502
39
+ // GET /api/admin/repositories/history?repoId=&ref=&path=&page=[&all=true[&from=]] → the commits a revision reaches, a page at a time,
40
+ // or across the most recently updated branches, with the branches and tags naming them, then on from the commits `from`
41
+ // names | 400/401/403/404/409/429/502
42
+ // GET /api/admin/repositories/changes?repoId=&path=&shas= → the files each of the commits `shas` names (fifty at most)
43
+ // adds, modifies, removes and renames within `path`, as `git log --name-status` lists them | 400/401/403/404/409/429/502
44
+ // GET /api/admin/repositories/tree?repoId=&path=&commit= → the files commit `commit` holds within `path`, as many as
45
+ // 75,000, for Replay to start from | 400/401/403/404/409/429/502
46
+ // GET /api/admin/repositories/issues?repoId=&kind=issues|pulls&state=open|closed[&after=] → a page of the repository's issues
47
+ // or pull requests, the most recently updated first, with how many are open and closed | 400/401/403/404/409/429/502
48
+ // GET /api/admin/repositories/agents?repoId=[&pulls=&commits=] → a page of what agents left: pull requests and commits to
49
+ // the default branch no pull request merged, with issues, agents' branches and the app agents first | 400/401/403/404/409/429/502
50
+ // GET /api/admin/repositories/item?repoId=&number= → an issue or pull request, as its popup shows it, with its
51
+ // description's Markdown | 400/401/403/404/409/429/502
52
+ // GET /api/admin/repositories/pull-dependencies?repoId=&number= → the package versions a pull request's npm, pnpm and
53
+ // Yarn 1 lockfiles lock that they didn't at its merge base, an update's with those it was, by directory; with review=1, each's
54
+ // review, where its repository reviews dependencies | 400/401/403/404/409/429/502
55
+ // GET /api/admin/repositories/branch-dependencies?repoId= → the versions the default branch's lockfiles lock that want review, where
56
+ // its review is in effect for the reader | 400/401/403/404/409/429/502
57
+ // GET /api/admin/repositories/lockfiles?repoId= → the lockfiles of the default branch its dependency review reads, by their
58
+ // paths, for its settings to name | 400/401/403/404/409/429/502
59
+ // GET /api/admin/repositories/pulse?repoId=&weeks=1|4|13|52 → the default branch's commits and lines a week and a day over
60
+ // the last year, its contributors and when in the period they commit, counted from its commits as Commits keeps them (how many are
61
+ // read while they are), and the pull requests
62
+ // merged and opened and the issues closed and opened in the last `weeks` weeks and the same days a period earlier | 400/401/403/404/409/429/502
63
+ // GET /api/admin/repositories/pulse/items?repoId=&weeks=&from=[&to=&authors=] → those pull requests and issues of the days
64
+ // `from` to `to` (YYYY-MM-DD, UTC; no `to`, on), and of any of `authors` (GitHub logins, comma-separated) | 400/401/403/404/409/429/502
65
+ // History, issues, item, agents and pulse each carry `users`: the app's users and agents the GitHub accounts they
66
+ // name are (github-users.ts), by login
27
67
  // POST /api/admin/repositories/remove → admin permanently removes a repo
28
68
  // GET /api/admin/repositories/risk[?team=|?repo=] → admin: each connected repo's npm lockfiles, or a team's or one repo's,
29
69
  // their packages and what risk tells of each, Brotli-encoded ndjson as each is found; the reader leaving stops it | 400/401/403/404
@@ -63,6 +103,8 @@
63
103
  // GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set | 400/401/403/404/409
64
104
  // POST /api/npm/reviews/discard → author discards an unpublished review and its file rows | 400/401/403/409
65
105
  // GET /api/npm/reviews/latest → latest own review for exact target bytes, across all bases | 400/401/403
106
+ // GET /api/npm/reviews/team?teamId=&beforeTime=&beforeId= → the reviews published to a team, the latest first, for one who
107
+ // reads them there | 400/401/404
66
108
  // GET /api/npm/package?name=&version= → a published version's manifest and files | 400/401/404/413/429/502
67
109
  // GET /api/npm/versions?name= → its dist-tags, versions, when each was published and its first was, and whether a team lists its scope | 400/401/404/429/502
68
110
  // GET /api/npm/download?name=&version= → its tarball | 400/401/404/413/429/502
@@ -81,6 +123,9 @@
81
123
  // Brotli-encoded ndjson | 400/401/404/413/429/502/503
82
124
  // GET /api/npm/type-only?name=&version=&otherVersion= → the files that changed between two versions in types alone,
83
125
  // Brotli-encoded ndjson | 400/401/404/413/429/502/503
126
+ // GET /api/npm/delta?name=&version=&otherVersion= → the lines a version adds and removes against another, or `(empty)`, and
127
+ // those but for declaration files changed in types alone, where any are, and whether binary files changed
128
+ // | 400/401/404/413/422/429/502/503/504
84
129
  // GET /api/npm/risk?name=&version=&otherVersion= → what calls for a look at a version, as risk tells it, against another where given | 400/401/404/413/429/502
85
130
  // POST /api/auth/logout → same-origin + CSRF, drops the session (and any view)
86
131
  // POST /api/auth/view-as → admin opens a read-only view as another user | 400/401/403/404
@@ -89,7 +134,9 @@ import { mergeLinkGroups } from './link-reports.ts'
89
134
  import { backfillFindingIds, reportEntries } from '@preventive/report'
90
135
  import type { IncomingMessage, ServerResponse } from 'node:http'
91
136
  import { pipeline } from 'node:stream/promises'
92
- import { npmVersionStore, riskLineStore, scanReadStore } from './npm-kept.ts'
137
+ import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmVersionStore, pullDependencyStore,
138
+ repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
139
+ import { type NpmDelta, npmDelta } from './npm-delta.ts'
93
140
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
94
141
  import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
95
142
  import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
@@ -114,13 +161,14 @@ import { isRole, roleAtLeast } from '../common/managed/roles.ts'
114
161
  import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
115
162
  import { filterReportData } from '../common/managed/report-filter.ts'
116
163
  import type { TriageEntryPatch } from '../common/managed/triage.ts'
164
+ import { GITHUB_AGENTS } from '../common/managed/github-agents.ts'
117
165
  import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
118
166
  import { reportRepoGithub } from '@preventive/report'
119
167
  import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
120
168
  import type { ReportSourcesCache } from './report-sources.ts'
121
169
  import { normalizeTeamPath } from './repo-path.ts'
122
170
  import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
123
- import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
171
+ import { CONFIG_PATH, type ManagedSessionInfo, type ServerInfo } from '../common/server-info.ts'
124
172
  import { bundleCommitHash } from '../common/bundle-commit.js'
125
173
  import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
126
174
  import { upstreamGithub } from './github-errors.ts'
@@ -129,8 +177,10 @@ import type { ConnectedRepo } from './github-app.ts'
129
177
  import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
130
178
  import { RepositoryDiscovery } from './repository-discovery.ts'
131
179
  import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines } from './repository-risk.ts'
132
- import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
133
- import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
180
+ import { WORKFLOWS_DIR } from './repository-actions.ts'
181
+ import { githubActionsSource } from './github-actions.ts'
182
+ import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
183
+ import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
134
184
  import { clearCookie, endSession, readSession } from './session.ts'
135
185
  import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
136
186
  import type { ActivityContext, ActivityInput } from './activity.ts'
@@ -142,17 +192,40 @@ import { lookupIssueLinks } from './github-issue-links.ts'
142
192
  import { visibleManagedIssues } from './managed-issues.ts'
143
193
  import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
144
194
  import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
195
+ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallback } from './github-link-oauth.ts'
196
+ import type { ProfileChoice } from './github-links.ts'
197
+ import { withAppUsers } from './github-users.ts'
198
+ import { isBlobId } from './object-storage.ts'
145
199
  import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
146
200
  import { triageWireEntry } from './triage-response.ts'
147
201
  import { handlePublicWorkspace } from './public-workspace.ts'
148
202
  import { serveUserTeamFeed } from './team-feed.ts'
149
- import { teamAppKey, teamAppStates } from './team-app.ts'
203
+ import { teamAppKey, teamAppStates, withWorkspaceSummary } from './team-app.ts'
150
204
  import { hashToken, randomToken, safeEqual } from './crypto.ts'
151
205
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
152
206
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
153
207
  import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
154
208
  import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
155
209
  import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
210
+ import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
211
+ import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
212
+ import { COAUTHORS_READ } from './commit-coauthors.ts'
213
+ import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, type SquashedPull, type Squashes, historyFrom, historyStarts, openPullHeads,
214
+ repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
215
+ import { AGENTS_READ_VERSION, APP_AGENTS, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
216
+ import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
217
+ import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependencies } from './pull-dependencies.ts'
218
+ import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
219
+ import { MAX_REQUIRED_REVIEWS, SINCE_COMMIT } from './repository-dependency-reviews.ts'
220
+ import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
221
+ import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
222
+ import { formattedPull, withFormatted } from './dependency-review-comments.ts'
223
+ import type { ReviewedDependencies } from './reviewed-dependencies.ts'
224
+ import { commentReviews } from './pull-comment-reviews.ts'
225
+ import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitStats, defaultHead, periodStats, pulseSince, repositoryActivity, repositoryLines,
226
+ repositoryStats, withLines, yearCommits } from './repository-pulse.ts'
227
+ import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
228
+ import { releaseCommits } from './release-commits.ts'
156
229
  import { computeBundleDiff } from '../common/bundle-compare-diff.js'
157
230
  import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
158
231
  import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
@@ -163,9 +236,11 @@ import { NpmRiskError } from '../risk/index.js'
163
236
  import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
164
237
  import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
165
238
  import { typeOnlyFiles } from './type-only.ts'
239
+ import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
166
240
  import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
167
241
 
168
242
  const SESSION_PATH = '/api/auth/session'
243
+ const PROFILE_PATH = '/api/auth/profile'
169
244
  const AVATAR_PREFIX = '/api/avatar/'
170
245
  const LOGOUT_PATH = '/api/auth/logout'
171
246
  const ADMIN_USERS_PATH = '/api/admin/users'
@@ -176,7 +251,20 @@ const ADMIN_REPOS_PATH = '/api/admin/repositories'
176
251
  const BROWSABLE_REPOS_PATH = '/api/admin/repositories/browsable'
177
252
  const REPO_REFS_PATH = '/api/admin/repositories/refs'
178
253
  const REPO_CONTENTS_PATH = '/api/admin/repositories/contents'
254
+ const REPO_CODE_PATH = '/api/admin/repositories/code'
255
+ const REPO_HISTORY_PATH = '/api/admin/repositories/history'
256
+ const REPO_CHANGES_PATH = '/api/admin/repositories/changes'
257
+ const REPO_TREE_PATH = '/api/admin/repositories/tree'
258
+ const REPO_ISSUES_PATH = '/api/admin/repositories/issues'
259
+ const REPO_AGENTS_PATH = '/api/admin/repositories/agents'
260
+ const REPO_ITEM_PATH = '/api/admin/repositories/item'
261
+ const REPO_PULL_DEPENDENCIES_PATH = '/api/admin/repositories/pull-dependencies'
262
+ const REPO_BRANCH_DEPENDENCIES_PATH = '/api/admin/repositories/branch-dependencies'
263
+ const REPO_LOCKFILES_PATH = '/api/admin/repositories/lockfiles'
264
+ const REPO_PULSE_PATH = '/api/admin/repositories/pulse'
265
+ const REPO_PULSE_ITEMS_PATH = '/api/admin/repositories/pulse/items'
179
266
  const SELECT_REPO_PATH = '/api/admin/repositories/select'
267
+ const REPO_DEPENDENCY_REVIEW_PATH = '/api/admin/repositories/dependency-review'
180
268
  const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
181
269
  const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
182
270
  const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
@@ -216,7 +304,9 @@ const NPM_PROVENANCE_PATH = '/api/npm/provenance'
216
304
  const NPM_PRETTY_PATH = '/api/npm/pretty'
217
305
  const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
218
306
  const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
307
+ const NPM_COMPARISON_PATH = '/api/npm/comparison'
219
308
  const NPM_RISK_PATH = '/api/npm/risk'
309
+ const NPM_DELTA_PATH = '/api/npm/delta'
220
310
  // What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
221
311
  // having no such package or version, or a tarball unpacking past risk's
222
312
  // bounds; npm failing otherwise a 502.
@@ -229,7 +319,7 @@ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
229
319
  sendJson(res, status, { error: err.code })
230
320
  }
231
321
  const NPM_PATHS = new Set([NPM_PACKAGE_PATH, NPM_VERSIONS_PATH, NPM_DOWNLOAD_PATH, NPM_STATS_PATH, NPM_ADVISORIES_PATH, NPM_TAGS_PATH, NPM_SOCKET_PATH, NPM_PROVENANCE_PATH, NPM_PRETTY_PATH,
232
- NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH])
322
+ NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH, NPM_DELTA_PATH])
233
323
  // How long an npm response may go unread before its connection is dropped.
234
324
  const NPM_RESPONSE_IDLE_MS = 60_000
235
325
  const MAX_TEAM_NAME = 100
@@ -350,6 +440,9 @@ export interface ManagedHttpDeps {
350
440
  serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
351
441
  next?: Handler
352
442
  serverInfo?: ServerInfo
443
+ // The reviewed dependencies the host registered, as the server was
444
+ // assembled (reviewed-dependencies.ts).
445
+ reviewedDependencies?: ReviewedDependencies
353
446
  }
354
447
 
355
448
  type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
@@ -364,8 +457,9 @@ function firstHeader(v: string | string[] | undefined): string | null {
364
457
  return null
365
458
  }
366
459
 
367
- async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId: string): Promise<void> {
368
- const avatar = await avatarStore.get(userId)
460
+ // `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
461
+ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id): Promise<void> {
462
+ const avatar = await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id))
369
463
  if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
370
464
  res.writeHead(200, {
371
465
  'content-type': avatar.contentType,
@@ -376,6 +470,26 @@ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId
376
470
  res.end(avatar.bytes)
377
471
  }
378
472
 
473
+ // The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
474
+ // alone: the agents' known by their GitHub Apps or accounts (APP_AGENTS),
475
+ // each its mark on the Agents tab, and those the app draws agents' accounts
476
+ // by (GITHUB_AGENTS). Each is fetched from GitHub once and
477
+ // kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
478
+ // an account's, `…-0001-<id>`.
479
+ const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
480
+ .flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
481
+ const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
482
+
483
+ async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
484
+ const [, kind, id] = AGENT_AVATAR.exec(avatar)!
485
+ const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
486
+ if (await avatarStore.get(key) == null) {
487
+ await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
488
+ .catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
489
+ }
490
+ await serveAvatar(res, avatarStore, key)
491
+ }
492
+
379
493
  const MAX_JSON_BODY_BYTES = 4096
380
494
 
381
495
  // Buffer the request body, aborting (and throwing 'too-large') once it exceeds
@@ -478,9 +592,10 @@ function sendViewOnly(res: ServerResponse): void {
478
592
  // Reads stay available while an admin views as another user. Everything else
479
593
  // is refused before dispatch, except ending the view or signing out, and the
480
594
  // batch report preview, a POST without effects. Issue authorization would
481
- // grant GitHub access on the viewed user's behalf.
595
+ // grant GitHub access on the viewed user's behalf, and linking an account
596
+ // would link it to the viewed user.
482
597
  function viewAllows(method: string, path: string): boolean {
483
- if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH
598
+ if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH && path !== LINK_LOGIN_PATH
484
599
  return (method === 'DELETE' && path === VIEW_AS_PATH) || (method === 'POST' && (path === LOGOUT_PATH || path === REPORT_QUERY_PATH))
485
600
  }
486
601
 
@@ -525,6 +640,65 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
525
640
  res.end()
526
641
  }
527
642
 
643
+ // GET /api/auth/profile — the user's profile (github-links.ts). POST chooses
644
+ // the accounts their displayed name and avatar come from. Workspace access is
645
+ // required: the accounts a user links are reserved for them.
646
+ async function handleProfile(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
647
+ const method = req.method ?? 'GET'
648
+ if (method === 'GET') {
649
+ const s = await readWorkspaceSession(res, deps, cookie)
650
+ if (s) await sendProfile(res, deps, s.user.id)
651
+ return
652
+ }
653
+ if (method !== 'POST') { send405(res, 'GET, POST'); return }
654
+ const s = await profileMutation(req, res, deps, cookie)
655
+ if (s == null) return
656
+ let body: unknown
657
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
658
+ const choice = parseProfileChoice(body)
659
+ if (choice == null) { sendJson(res, 400, { error: 'bad-request' }); return }
660
+ await deps.db.setProfileChoice(s.session.id, choice, Date.now())
661
+ await sendProfile(res, deps, s.user.id)
662
+ }
663
+
664
+ // DELETE /api/auth/profile/accounts/<id> — unlink one of the user's linked
665
+ // GitHub accounts, which may then sign in or register again.
666
+ async function handleUnlinkAccount(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
667
+ if ((req.method ?? 'GET') !== 'DELETE') { send405(res, 'DELETE'); return }
668
+ const s = await profileMutation(req, res, deps, cookie)
669
+ if (s == null) return
670
+ const link = await deps.db.unlinkGithubAccount(s.session.id, id, Date.now())
671
+ if (link == null) { sendJson(res, 404, { error: 'no-account' }); return }
672
+ await deps.avatarStore.delete(link.id).catch((err) => { console.warn('managed: linked avatar removal failed:', err) })
673
+ await activity(deps, s.user, 'access', `unlinked GitHub account @${link.login}`)
674
+ await sendProfile(res, deps, s.user.id)
675
+ }
676
+
677
+ async function profileMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
678
+ const s = await checkMutation(req, res, deps, cookie)
679
+ if (s != null && !roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
680
+ return s
681
+ }
682
+
683
+ async function sendProfile(res: ServerResponse, deps: ManagedHttpDeps, userId: string): Promise<void> {
684
+ const profile = await deps.db.getProfile(userId)
685
+ if (profile == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
686
+ sendJson(res, 200, { profile })
687
+ }
688
+
689
+ // { name?, avatar? }, each a linked account's id or null; at least one.
690
+ function parseProfileChoice(body: unknown): ProfileChoice | null {
691
+ if (body == null || typeof body !== 'object' || Array.isArray(body)) return null
692
+ const choice: ProfileChoice = {}
693
+ for (const key of ['name', 'avatar'] as const) {
694
+ if (!Object.hasOwn(body, key)) continue
695
+ const value = (body as Record<string, unknown>)[key]
696
+ if (value !== null && (typeof value !== 'string' || !isBlobId(value))) return null
697
+ choice[key] = value
698
+ }
699
+ return Object.keys(choice).length > 0 ? choice : null
700
+ }
701
+
528
702
  // POST /api/admin/set-role — an admin sets ANOTHER user's role. Admin-only, and
529
703
  // refused for the caller's own id so an admin can't drop their own admin (keeps
530
704
  // at least one admin).
@@ -581,6 +755,27 @@ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps,
581
755
  return s
582
756
  }
583
757
 
758
+ // Who am I? While an admin views as another user, that user, plus the admin
759
+ // as `viewer`. A view that has ended (with the admin's session or role, or the
760
+ // viewed account) is cleared, returning to the own session. Null signed out.
761
+ // `dependencyReview` where they publish dependency reviews to some team.
762
+ async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: string | undefined): Promise<{ body: ManagedSessionInfo | null; headers: Record<string, string> }> {
763
+ const now = Date.now()
764
+ let s = await readSession(config, db, cookie, now)
765
+ const ended = s == null && viewToken(config, cookie) != null
766
+ if (ended) s = await readSession(config, db, cookie, now, { own: true })
767
+ const reviews = s != null && (await db.listDependencyReviewTeams(s.user.id)).length > 0
768
+ return {
769
+ body: s && {
770
+ user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
771
+ csrfToken: s.session.csrfToken,
772
+ ...(s.session.viewer ? { viewer: s.session.viewer } : {}),
773
+ ...(reviews ? { dependencyReview: true as const } : {}),
774
+ },
775
+ headers: ended ? { 'set-cookie': clearViewCookie(config) } : {},
776
+ }
777
+ }
778
+
584
779
  // GET /api/admin/scan/models — the server's canonical scan model ids and effort
585
780
  // levels. Names are intentionally absent; the client derives them from ids.
586
781
  async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
@@ -623,9 +818,13 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
623
818
  let tokenMissing = false
624
819
  let repositories
625
820
  if (scope === 'connected') {
821
+ const reviews = await deps.db.listRepoDependencyReviews()
822
+ const reviewTeams = await deps.db.listRepoReviewTeams()
626
823
  repositories = allRows.map((r) => ({
627
824
  id: r.repoId, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl,
628
825
  installed: r.installationId != null, selected: r.active, active: r.active,
826
+ dependencyReview: reviews.get(r.repoId) ?? { enabled: false, required: 1, since: null, lockfiles: EVERY_LOCKFILE },
827
+ reviewTeams: reviewTeams.get(r.repoId) ?? [],
629
828
  }))
630
829
  } else {
631
830
  const showAll = scope === 'installed' && url.searchParams.get('showAll') === 'true'
@@ -655,6 +854,7 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
655
854
  inactiveCount: allRows.filter((r) => !r.active).length,
656
855
  total,
657
856
  tokenMissing,
857
+ ...deps.reviewedDependencies ? { reviewedDependencies: true } : {},
658
858
  })
659
859
  }
660
860
 
@@ -789,6 +989,39 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
789
989
  await selectRepository(res, deps, s.user, repoId, cookie)
790
990
  }
791
991
 
992
+ // POST /api/admin/repositories/dependency-review — a repository's dependency
993
+ // review (repository-dependency-reviews.ts): `enabled`, the published reviews
994
+ // each version its pull requests' lockfiles lock needs (`required`, 1 to
995
+ // MAX_REQUIRED_REVIEWS), and the commit whose lockfiles' versions need none
996
+ // (`since`, a full commit id, or null; where the host registered reviewed
997
+ // dependencies, which replace it, none, `400 since-unsupported`, the one
998
+ // stored kept as it is). Answers the setting then. Admin, CSRF.
999
+ async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1000
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
1001
+ const s = await adminMutation(req, res, deps, cookie)
1002
+ if (s == null) return
1003
+ let body: Record<string, unknown> | null
1004
+ try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1005
+ const repoId = body?.['repoId']
1006
+ const enabled = body?.['enabled']
1007
+ const required = body?.['required']
1008
+ const since = body?.['since'] ?? null
1009
+ // The lockfiles it holds to, kept as they are where none is sent.
1010
+ const lockfiles = body?.['lockfiles'] === undefined ? undefined : scopeOf(body['lockfiles'])
1011
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof enabled !== 'boolean'
1012
+ || typeof required !== 'number' || !Number.isSafeInteger(required) || required < 1 || required > MAX_REQUIRED_REVIEWS
1013
+ || (since !== null && (typeof since !== 'string' || !SINCE_COMMIT.test(since))) || lockfiles === null) {
1014
+ sendJson(res, 400, { error: 'bad-request' }); return
1015
+ }
1016
+ if (since !== null && deps.reviewedDependencies) { sendJson(res, 400, { error: 'since-unsupported' }); return }
1017
+ const repo = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
1018
+ const setting = repo ? await deps.db.setRepoDependencyReview(repoId, { enabled, required, ...deps.reviewedDependencies ? {} : { since: since as string | null },
1019
+ ...lockfiles ? { lockfiles } : {} }) : null
1020
+ if (!repo || !setting) { sendJson(res, 404, { error: 'no-repository' }); return }
1021
+ await activity(deps, s.user, 'repository', enabled ? 'enabled dependency review' : 'disabled dependency review', { repo: repo.fullName })
1022
+ sendJson(res, 200, { ok: true, dependencyReview: setting })
1023
+ }
1024
+
792
1025
  async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
793
1026
  const ids = new Set<string>()
794
1027
  for (const report of reports) {
@@ -1228,8 +1461,10 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
1228
1461
 
1229
1462
  // Listing connected repository names uses managed access only. Verify GitHub
1230
1463
  // access after selection, before returning any refs or source directory data.
1464
+ // Any team member (view or above) lists those their teams reach, for the code
1465
+ // viewer as for Create a bundle, whose builds stay admin|manage.
1231
1466
  async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1232
- const s = await readManageSession(res, deps, cookie)
1467
+ const s = await readWorkspaceSession(res, deps, cookie)
1233
1468
  if (!s) return
1234
1469
  sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
1235
1470
  }
@@ -1268,25 +1503,54 @@ function scopeRepositoryContents(contents: RepositoryContents | null, virtualEnt
1268
1503
  } : {}) }
1269
1504
  }
1270
1505
 
1271
- // Recheck repository/path grants after upstream reads, before returning names.
1272
- async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
1506
+ // A browse's repository, revision and directory, as its query names them;
1507
+ // null, answered, where it names none.
1508
+ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
1273
1509
  const repoId = Number(query.get('repoId'))
1274
1510
  const normalized = normalizeTeamPath(query.get('path') ?? '')
1275
1511
  const ref = query.get('ref') ?? ''
1276
1512
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
1277
- sendJson(res, 400, { error: 'bad-request' }); return
1513
+ sendJson(res, 400, { error: 'bad-request' }); return null
1278
1514
  }
1515
+ return { repoId, ref, path: normalized.path ?? '' }
1516
+ }
1517
+
1518
+ // The repository `repoId` as the reader may browse it at `path`: one of their
1519
+ // bundle repositories, and what their grants show of that directory
1520
+ // (`virtualEntries`, null where they reach all of it: scopedDirectory). Null,
1521
+ // answered, where it is none of theirs. Every team member (view or above)
1522
+ // reads the repositories their teams reach; the reader then checks their own
1523
+ // GitHub read permission (repository-browser.ts), on each read.
1524
+ async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
1525
+ const s = await readWorkspaceSession(res, deps, cookie)
1526
+ if (!s) return null
1527
+ const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
1528
+ if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1529
+ const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1530
+ return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
1531
+ }
1532
+
1533
+ // The repository as it was authorized, but for the shared default branch hint,
1534
+ // which a read may refresh meanwhile: that changes none of its identity,
1535
+ // credentials or managed grants.
1536
+ const sameRepository = (current: SelectedRepo, authorized: SelectedRepo) =>
1537
+ JSON.stringify({ ...current, cachedDefaultBranch: authorized.cachedDefaultBranch }) === JSON.stringify(authorized)
1538
+
1539
+ function sendGithubError(res: ServerResponse, err: GithubApiError) {
1540
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
1541
+ }
1542
+
1543
+ // Recheck repository/path grants after upstream reads, before returning names.
1544
+ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
1545
+ const asked = repositoryQuery(res, query)
1546
+ if (!asked) return
1547
+ const { repoId, ref } = asked
1279
1548
  const withDefault = refs && query.get('withDefault') === 'true'
1280
- const path = refs ? '' : normalized.path ?? ''
1549
+ const path = refs ? '' : asked.path
1281
1550
  const authorize = async () => {
1282
- const s = await readManageSession(res, deps, cookie)
1283
- if (!s) return null
1284
- const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
1285
- if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1286
- const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1287
- const virtualEntries = scopedDirectory(path, scopes)
1288
- if ((!refs || withDefault) && virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1289
- return { repo, virtualEntries, user: s.user }
1551
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1552
+ if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1553
+ return access
1290
1554
  }
1291
1555
  const access = await authorize()
1292
1556
  if (!access) return
@@ -1308,23 +1572,729 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1308
1572
  await reader.recheckAccess()
1309
1573
  const current = await authorize()
1310
1574
  if (!current) return
1311
- // This request (or another reader) may have refreshed the shared hint. It
1312
- // does not change repository identity, credentials, or managed grants.
1313
- if (JSON.stringify({ ...current.repo, cachedDefaultBranch: access.repo.cachedDefaultBranch }) !== JSON.stringify(access.repo)) {
1314
- sendJson(res, 409, { error: 'repository-changed' }); return
1315
- }
1316
- sendJson(res, 200, refs ? { ...listed, ...(withDefault ? {
1575
+ // This request (or another reader) may have refreshed the shared hint.
1576
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1577
+ sendJson(res, 200, refs ? { ...listed, tagCommits, ...(withDefault ? {
1317
1578
  defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
1318
1579
  } : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
1319
1580
  } catch (err) {
1320
- if (err instanceof GithubApiError) {
1321
- sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
1581
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1582
+ throw err
1583
+ }
1584
+ }
1585
+
1586
+ // GET /api/admin/repositories/code: the files of a directory of a repository
1587
+ // at a revision, for the code viewer (repository-code.ts), read as browsing
1588
+ // it is, with the reader's own GitHub access, and only where their grants
1589
+ // reach the whole directory: one they reach only the ancestor of is none to
1590
+ // read. Its tree's listing says first whether its files fit, before its
1591
+ // tarball is asked for; the reader's access is checked again once it is
1592
+ // read, before any of it is sent. The load stays held until this response is
1593
+ // written or abandoned, and a reader who stops reading it gives it up after
1594
+ // a while.
1595
+ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1596
+ const asked = repositoryQuery(res, query)
1597
+ if (!asked) return
1598
+ const { repoId, ref, path } = asked
1599
+ const authorize = async () => {
1600
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1601
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1602
+ return access
1603
+ }
1604
+ const access = await authorize()
1605
+ if (!access) return
1606
+ try {
1607
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1608
+ const commit = await reader.commit(ref)
1609
+ const repo = access.repo.fullName
1610
+ const client = createClient({ token: reader.readToken() })
1611
+ // A directory the commit has none at is none to read, as browsing it says.
1612
+ const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
1613
+ throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
1614
+ }))
1615
+ // Access still held, and the same repository, before anything read is sent.
1616
+ const fenced = async () => {
1617
+ await reader.recheckAccess()
1618
+ const current = await authorize()
1619
+ if (!current) return false
1620
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
1621
+ return true
1622
+ }
1623
+ // Too large names the commit it was read at, for History to read.
1624
+ if (!repositoryCodeFits(await repositoryTreeFiles(repo, reader.readToken(), tree))) {
1625
+ if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
1322
1626
  return
1323
1627
  }
1628
+ if (res.destroyed) return
1629
+ const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
1630
+ res.once('close', load.release)
1631
+ const bytes = await load.result
1632
+ if (!(await fenced()) || res.destroyed) return
1633
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
1634
+ res.writeHead(200, {
1635
+ 'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
1636
+ 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
1637
+ })
1638
+ writeResponse(res, bytes)
1639
+ } catch (err) {
1640
+ if (res.headersSent || res.destroyed) return
1641
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1642
+ if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
1643
+ throw err
1644
+ }
1645
+ }
1646
+
1647
+ // GET /api/admin/repositories/history: what the code viewer's History tab
1648
+ // draws (repository-history.ts), read as browsing is, with the reader's own
1649
+ // GitHub access, within their grants as the code is. From a revision (`ref`),
1650
+ // a page at a time (`page`), the commits touching `path` where it names a
1651
+ // directory, with the branches and tags naming commits on the first page; or,
1652
+ // with `all`, across the repository's most recently updated branches, which
1653
+ // needs a GitHub token. A whole repository's history, from a revision or all
1654
+ // branches, is read from commits a page each, kept by the commit each starts
1655
+ // at, and those kept glued on (historyFrom); a later page reads on from the
1656
+ // commits `from` names. The branches and tags naming commits are kept as an
1657
+ // issue's page is (keptAnswer). Access is checked again once it is read,
1658
+ // before any of it is sent, with the app's users its authors are
1659
+ // (github-users.ts).
1660
+ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1661
+ const asked = repositoryQuery(res, query)
1662
+ if (!asked) return
1663
+ const { repoId, ref, path } = asked
1664
+ const page = Number(query.get('page') ?? '1')
1665
+ const all = query.get('all') === 'true'
1666
+ // Every branch's heads read now, not as kept: the client asks so where
1667
+ // they miss the commit shown, as a branch moved since may.
1668
+ const fresh = query.get('fresh') === 'true'
1669
+ // A whole repository's later pages read on from where those before stop:
1670
+ // all branches', always; a revision's, where the client names them.
1671
+ const from = query.get('from')
1672
+ const starts = from === null ? null : historyStarts(from)
1673
+ if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
1674
+ || (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
1675
+ const authorize = async () => {
1676
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1677
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1678
+ return access
1679
+ }
1680
+ const access = await authorize()
1681
+ if (!access) return
1682
+ try {
1683
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1684
+ const repo = access.repo.fullName
1685
+ const token = reader.readToken()
1686
+ // With a token, as GraphQL reads them.
1687
+ const pages = historyPages(deps, repoId, token !== null)
1688
+ let body: object
1689
+ if (all) {
1690
+ if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
1691
+ // What a later page reads on from, the client knows; whether one may
1692
+ // follow, the deepest page says.
1693
+ // Asked for afresh, those kept answered as just read: how long till
1694
+ // they may be read again (`retryIn`), for the client to ask then.
1695
+ const heads = starts ? null : await keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token), undefined, fresh)
1696
+ const retryIn = heads && fresh && heads.kept ? heads.at + REPOSITORY_READ_FLOOR_MS - Date.now() : 0
1697
+ body = starts ? { all: true, page, commits: (await historyFrom(repo, token, starts, pages)).commits, more: page < MAX_HISTORY_PAGE }
1698
+ : { all: true, ...await repositoryAllHistory(repo, token, heads!.value, pages), ...retryIn > 0 ? { retryIn } : {} }
1699
+ } else {
1700
+ const commit = await reader.commit(ref)
1701
+ // The branches and tags naming commits: with a token, every branch All
1702
+ // branches reads and the latest tags, kept for both; without, GitHub's
1703
+ // first page of each.
1704
+ const naming = () => token
1705
+ ? keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token)).then(({ value }) => ({
1706
+ branches: value.heads.map(({ name, sha }) => ({ name, sha })), tags: value.tags }))
1707
+ : keptAnswer(deps, `${repoId}\0refs`, () => repositoryRefTips(repo, token)).then(answer => answer.value)
1708
+ const tips = page === 1 ? naming() : null
1709
+ // A whole repository's from its commit, or on from those `from` names
1710
+ // (a page by its number where none are, as GitHub lists them); a
1711
+ // directory's by its number.
1712
+ const history = path !== '' || (page > 1 && !starts) ? repositoryHistory(repo, token, commit, path, page)
1713
+ : historyFrom(repo, token, starts ?? [commit], pages).then(read => ({ commits: read.commits, more: (starts !== null || read.more) && page < MAX_HISTORY_PAGE }))
1714
+ const [read, named] = await Promise.all([history, tips])
1715
+ body = { commit, path, page, ...read, ...named }
1716
+ }
1717
+ // The pull requests squashed into a whole repository's commits, drawn as
1718
+ // the merges they were, and on its first page the open ones' heads,
1719
+ // naming commits: with a token, as far as GitHub answers them, the
1720
+ // history sent all the same where it doesn't.
1721
+ const listed = (body as { commits: HistoryCommit[] }).commits
1722
+ const quiet = <T>(fallback: T) => (err: unknown) => {
1723
+ if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
1724
+ return fallback
1725
+ }
1726
+ const [pulls, openPulls] = token && path === '' ? await Promise.all([
1727
+ squashedPulls(repo, token, listed, historySquashes(deps, repoId)).catch(quiet<SquashedPull[]>([])),
1728
+ page === 1 ? keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null)) : null,
1729
+ ]) : [[], null]
1730
+ await reader.recheckAccess()
1731
+ const current = await authorize()
1732
+ if (!current) return
1733
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1734
+ // Each commit as the tab draws it, its co-authors with it: what Pulse
1735
+ // counts of it (its lines, its author's time) not sent.
1736
+ const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
1737
+ ({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
1738
+ sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
1739
+ ...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...openPulls?.length ? { openPulls } : {} }))
1740
+ } catch (err) {
1741
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1742
+ throw err
1743
+ }
1744
+ }
1745
+
1746
+ // GET /api/admin/repositories/changes: the files each commit `shas` names
1747
+ // changes, as the code viewer's Replay plays them (repository-changes.ts),
1748
+ // within `path`, each commit's kept whole by the commit (historyChanges);
1749
+ // read as Replay's reads are (readForReplay).
1750
+ async function handleRepositoryChanges(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1751
+ const shas = changesAsked(query.get('shas'))
1752
+ if (!shas) { sendJson(res, 400, { error: 'bad-request' }); return }
1753
+ await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
1754
+ const changes = await repositoryChanges(fullName, token, shas, historyChanges(deps, repoId))
1755
+ return { path, changes: [...changes].map(([sha, read]) => read ? { sha, ...changesWithin(read, path) } : { sha, files: null, more: false }) }
1756
+ })
1757
+ }
1758
+
1759
+ // GET /api/admin/repositories/tree: the files commit `commit` holds within
1760
+ // `path` and their bytes (`sizes`), which Replay starts from and the Treemap
1761
+ // of a directory too large to open draws (repository-changes.ts
1762
+ // directoryFiles: the directory's own tree where the commit's is cut short),
1763
+ // kept whole by the commit and the rules it is read by (historyTreeStore,
1764
+ // TREES_READ); none (null) for one GitHub has none of. Read as Replay's
1765
+ // reads are (readForReplay).
1766
+ async function handleRepositoryTree(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1767
+ const commit = query.get('commit') ?? ''
1768
+ if (!/^[a-f\d]{40}$/u.test(commit)) { sendJson(res, 400, { error: 'bad-request' }); return }
1769
+ const store = historyTreeStore<CommitTree>(deps.db, deps.config.debug)
1770
+ await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
1771
+ const kept = { read: async (sha: string) => {
1772
+ const value = (await store.read(`${repoId}\0${TREES_READ}\0${sha}`))?.value
1773
+ return value && Array.isArray(value.files) && Array.isArray(value.sizes) && value.sizes.length === value.files.length ? value : null
1774
+ }, write: (sha: string, tree: CommitTree) => store.write(`${repoId}\0${TREES_READ}\0${sha}`, tree, Date.now()) }
1775
+ const tree = await directoryFiles(fullName, token, commit, path, kept)
1776
+ return { commit, path, ...tree ?? { files: null, sizes: null, cut: false } }
1777
+ })
1778
+ }
1779
+
1780
+ // What Replay reads of repository `repoId` within `path` (`read`, with its
1781
+ // name and the reader's token), as the history is read: with the reader's
1782
+ // own GitHub access, within their grants as the code is, a directory they
1783
+ // reach all of, and sent once access is checked again.
1784
+ async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams,
1785
+ read: (fullName: string, token: string | null, repoId: number, path: string) => Promise<object>): Promise<void> {
1786
+ const asked = repositoryQuery(res, query)
1787
+ if (!asked) return
1788
+ const { repoId, path } = asked
1789
+ const authorize = async () => {
1790
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1791
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1792
+ return access
1793
+ }
1794
+ const access = await authorize()
1795
+ if (!access) return
1796
+ try {
1797
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1798
+ const body = await read(access.repo.fullName, reader.readToken(), repoId, path)
1799
+ await reader.recheckAccess()
1800
+ const current = await authorize()
1801
+ if (!current) return
1802
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1803
+ sendJson(res, 200, body)
1804
+ } catch (err) {
1805
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1806
+ throw err
1807
+ }
1808
+ }
1809
+
1810
+ // The files repository `repoId`'s commits change, kept by each commit
1811
+ // (historyChangeStore, repository-changes.ts repositoryChanges).
1812
+ function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
1813
+ const store = historyChangeStore<CommitChanges>(deps.db, deps.config.debug)
1814
+ const keyOf = (sha: string) => `${repoId}\0${sha}`
1815
+ return {
1816
+ async read(shas) {
1817
+ const kept = await store.readMany(shas.map(keyOf))
1818
+ return new Map(shas.flatMap(sha => {
1819
+ const value = kept.get(keyOf(sha))?.value
1820
+ return value && Array.isArray(value.files) ? [[sha, value] as const] : []
1821
+ }))
1822
+ },
1823
+ write: (sha, changes) => store.write(keyOf(sha), changes, Date.now()),
1824
+ }
1825
+ }
1826
+
1827
+ // How long what readWholeRepository read is answered as kept, unread again;
1828
+ // and how long one read is answered as kept even where it is asked for afresh.
1829
+ const REPOSITORY_READ_FRESH_MS = 2 * 60_000
1830
+ const REPOSITORY_READ_FLOOR_MS = 10_000
1831
+ // The repositories' reads being read again, behind a kept answer or as asked
1832
+ // for afresh, by key: one at a time, those asking meanwhile answered by it.
1833
+ const repositoryRereads = new Map<string, Promise<unknown>>()
1834
+ // A read GitHub refused, not failed: its credential or the GitHub App's
1835
+ // permission for it withdrawn (Issues, Pull requests, Checks), the
1836
+ // repository or what was asked of it gone. What was kept of it goes too.
1837
+ const refusedRead = (err: unknown) => err instanceof GithubApiError
1838
+ && (err.status === 401 || err.status === 403 || err.status === 404 || err.message === 'github-status-403')
1839
+
1840
+ // What `read` answers of a repository, kept by `key` (its id first) for every
1841
+ // reader (repositoryReadStore), each one's access checked as they ask before
1842
+ // this: answered as kept for REPOSITORY_READ_FRESH_MS, then, while it is
1843
+ // kept, answered at once and read again behind it for the next to ask, where
1844
+ // the instance outlives its answer (not serverless, where it is read again
1845
+ // before it is answered); `kept` where it was, or where reading it again
1846
+ // failed as `keepThrough` lets it. One asked for afresh (`fresh`) is read
1847
+ // now, unless it was read REPOSITORY_READ_FLOOR_MS since, what was kept
1848
+ // answered where that read fails. One GitHub refuses to read again is no
1849
+ // longer kept: the next to ask is refused as GitHub refused it. `at` is when
1850
+ // what it answers was read.
1851
+ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>,
1852
+ keepThrough: (err: unknown) => boolean = () => false, fresh = false): Promise<{ value: T; kept: boolean; at: number }> {
1853
+ const store = repositoryReadStore(deps.db, deps.config.debug)
1854
+ // One kept longer than a day, not yet removed (pruneKept removes them as
1855
+ // others are written), is none.
1856
+ const stored = await store.read(key)
1857
+ const held = stored && Date.now() - stored.at < REPOSITORY_READ_KEPT_MS ? stored : null
1858
+ // Read, and kept as read when it was, which it is said read at.
1859
+ const readKept = async () => {
1860
+ try {
1861
+ const value = await read()
1862
+ const at = Date.now()
1863
+ await store.write(key, value, at)
1864
+ return { value, at }
1865
+ } catch (err) {
1866
+ if (refusedRead(err)) await store.remove(key)
1867
+ throw err
1868
+ }
1869
+ }
1870
+ const reread = () => {
1871
+ let reading = repositoryRereads.get(key)
1872
+ if (!reading) {
1873
+ reading = readKept().finally(() => repositoryRereads.delete(key))
1874
+ repositoryRereads.set(key, reading)
1875
+ }
1876
+ return reading as Promise<{ value: T; at: number }>
1877
+ }
1878
+ const age = held ? Date.now() - held.at : Infinity
1879
+ const now = fresh && age >= REPOSITORY_READ_FLOOR_MS
1880
+ if (held && !now && (age < REPOSITORY_READ_FRESH_MS || !deps.config.serverless)) {
1881
+ if (age >= REPOSITORY_READ_FRESH_MS && !repositoryRereads.has(key)) {
1882
+ // Its failure leaves the kept answer sent until it is a day old, so it is said.
1883
+ deps.track(reread().catch((err: unknown) => { console.warn('managed: repository read failed:', err) }))
1884
+ }
1885
+ return { value: held.value as T, kept: true, at: held.at }
1886
+ }
1887
+ try {
1888
+ return { ...now ? await reread() : await readKept(), kept: false }
1889
+ } catch (err) {
1890
+ if (held && ((now && !refusedRead(err)) || keepThrough(err))) return { value: held.value as T, kept: true, at: held.at }
1324
1891
  throw err
1325
1892
  }
1326
1893
  }
1327
1894
 
1895
+ // The pages of repository `repoId`'s history kept (historyPageStore), each by
1896
+ // the commit it starts at (repository-history.ts historyFrom) and the rules
1897
+ // its co-authors were read by (commit-coauthors.ts COAUTHORS_READ), those
1898
+ // telling each commit's co-authors (kept since they are read), and with
1899
+ // `graphql`, only those read with GraphQL, telling its lines and the accounts
1900
+ // its co-authors' addresses are (a page GitHub's REST API listed tells
1901
+ // neither); the rest read again.
1902
+ function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): HistoryPages {
1903
+ const store = historyPageStore<HistoryCommit[]>(deps.db, deps.config.debug)
1904
+ const keyOf = (top: string) => `${repoId}\0${COAUTHORS_READ}\0${top}`
1905
+ return {
1906
+ async read(tops) {
1907
+ const kept = await store.readMany(tops.map(keyOf))
1908
+ return new Map(tops.flatMap(top => {
1909
+ const page = kept.get(keyOf(top))?.value
1910
+ return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
1911
+ ? [[top, page] as const] : []
1912
+ }))
1913
+ },
1914
+ write: (top, commits) => store.write(keyOf(top), commits, Date.now()),
1915
+ }
1916
+ }
1917
+
1918
+ // The pull requests squashed into repository `repoId`'s commits, kept by
1919
+ // each commit and the rules their commits' co-authors were read by
1920
+ // (COAUTHORS_READ), null for one none was (repository-history.ts
1921
+ // squashedPulls); one kept before its commits' co-authors were read, read
1922
+ // again.
1923
+ function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
1924
+ const store = historySquashStore<SquashedPull | null>(deps.db, deps.config.debug)
1925
+ const keyOf = (sha: string) => `${repoId}\0${COAUTHORS_READ}\0${sha}`
1926
+ return {
1927
+ async read(shas) {
1928
+ const kept = await store.readMany(shas.map(keyOf))
1929
+ return new Map(shas.flatMap(sha => {
1930
+ const entry = kept.get(keyOf(sha))
1931
+ return entry && (entry.value === null || (Array.isArray(entry.value.commits) && entry.value.commits.every(commit => Array.isArray(commit.coauthors))))
1932
+ ? [[sha, entry.value] as const] : []
1933
+ }))
1934
+ },
1935
+ write: (sha, pull) => store.write(keyOf(sha), pull, Date.now()),
1936
+ }
1937
+ }
1938
+
1939
+ // What the whole of repository `repoId` gives in GraphQL (`read`, with its
1940
+ // name and a token), read as browsing is, with the reader's own GitHub
1941
+ // access: only by a reader whose grants reach all of it, with a token, and
1942
+ // sent once access is checked again after it is read; what `key` names kept
1943
+ // (keptAnswer), sent as kept, its reader's access checked as they ask; with
1944
+ // `readAt`, when it was read from GitHub, one kept as old as it was kept, so
1945
+ // that a reader holding two reads of the same can tell the later.
1946
+ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
1947
+ read: (fullName: string, token: string) => Promise<object>, key: string | null = null, readAt = false): Promise<void> {
1948
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
1949
+ const { value, kept, at } = key === null ? { value: await read(fullName, token), kept: false, at: Date.now() }
1950
+ : await keptAnswer(deps, `${repoId}\0${key}`, () => read(fullName, token))
1951
+ return { value: readAt ? { ...value, readAt: at } : value, kept }
1952
+ })
1953
+ }
1954
+
1955
+ // What `answer` gives of the whole of repository `repoId` (with its name and
1956
+ // a token), as readWholeRepository reads it: sent at once where it was kept,
1957
+ // else once access is checked again, with the app's users it names
1958
+ // (github-users.ts), never kept with it.
1959
+ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
1960
+ answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>): Promise<void> {
1961
+ const authorize = async () => {
1962
+ const access = await authorizeRepository(res, deps, cookie, repoId, '')
1963
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1964
+ return access
1965
+ }
1966
+ const access = await authorize()
1967
+ if (!access) return
1968
+ try {
1969
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1970
+ const token = reader.readToken()
1971
+ if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
1972
+ const answered = await answer(access.repo.fullName, token, access.user)
1973
+ if (answered.kept) { sendJson(res, 200, await withAppUsers(deps.db, answered.value)); return }
1974
+ await reader.recheckAccess()
1975
+ const current = await authorize()
1976
+ if (!current) return
1977
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1978
+ sendJson(res, 200, await withAppUsers(deps.db, answered.value))
1979
+ } catch (err) {
1980
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1981
+ throw err
1982
+ }
1983
+ }
1984
+
1985
+ // GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
1986
+ // requests tabs show (repository-issues.ts): `kind`'s open or closed
1987
+ // (`state`), a page at a time, on from the cursor `after` names. They are
1988
+ // the whole repository's (readWholeRepository).
1989
+ async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1990
+ const repoId = Number(query.get('repoId'))
1991
+ const kind = query.get('kind')
1992
+ const state = query.get('state') ?? 'open'
1993
+ const after = query.get('after')
1994
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
1995
+ || (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
1996
+ await readWholeRepository(res, deps, cookie, repoId, async (fullName, token) => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }),
1997
+ `issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`, true)
1998
+ }
1999
+
2000
+ // GET /api/admin/repositories/agents: what the code viewer's Agents tab
2001
+ // reconstructs agents' sessions from (repository-agents.ts): a page
2002
+ // of pull requests on from the cursor `pulls` names, and of the default
2003
+ // branch's commits on from `commits`, the first page, naming neither, with
2004
+ // issues and branches. They are the whole repository's (readWholeRepository).
2005
+ async function handleRepositoryAgents(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2006
+ const repoId = Number(query.get('repoId'))
2007
+ const cursors = { pulls: query.get('pulls'), commits: query.get('commits') }
2008
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || (cursors.pulls !== null && !ISSUES_CURSOR.test(cursors.pulls))
2009
+ || (cursors.commits !== null && !COMMITS_CURSOR.test(cursors.commits))) {
2010
+ sendJson(res, 400, { error: 'bad-request' }); return
2011
+ }
2012
+ await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryAgents(fullName, token, cursors),
2013
+ `agents\0${AGENTS_READ_VERSION}\0${cursors.pulls ?? ''}\0${cursors.commits ?? ''}`)
2014
+ }
2015
+
2016
+ // GET /api/admin/repositories/item: the issue or pull request numbered
2017
+ // `number`, as the code viewer's popup for it shows it (repository-issues.ts
2018
+ // repositoryItem). It is the whole repository's (readWholeRepository).
2019
+ async function handleRepositoryItem(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2020
+ const repoId = Number(query.get('repoId'))
2021
+ const number = itemNumber(query)
2022
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null) { sendJson(res, 400, { error: 'bad-request' }); return }
2023
+ await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryItem(fullName, token, number), `item\0${ISSUES_READ_VERSION}\0${number}`, true)
2024
+ }
2025
+
2026
+ // The issue's or pull request's number a request names, as GraphQL takes one
2027
+ // (an Int, at most 2³¹ − 1); null for none.
2028
+ function itemNumber(query: URLSearchParams): number | null {
2029
+ const number = Number(query.get('number'))
2030
+ return /^[1-9]\d{0,9}$/u.test(query.get('number') ?? '') && number <= 2 ** 31 - 1 ? number : null
2031
+ }
2032
+
2033
+ // GET /api/admin/repositories/pull-dependencies: the package versions pull
2034
+ // request `number`'s lockfiles add (pull-dependencies.ts), what its base's and head's
2035
+ // commits tell kept by them (pullDependencyKept), its `number` with them.
2036
+ // They are the whole repository's (answerWholeRepository), kept by its number
2037
+ // as its popup's item is, its head moving with each push. With `review=1`,
2038
+ // as an open one's are asked, where the repository reviews dependencies,
2039
+ // each version's review (pull-dependency-reviews.ts), read afresh as each
2040
+ // asks, reviews being published meanwhile; its baseline kept, where the host
2041
+ // registered no reviewed dependencies, which are asked in its place.
2042
+ async function handleRepositoryPullDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2043
+ const repoId = Number(query.get('repoId'))
2044
+ const number = itemNumber(query)
2045
+ const review = query.get('review') === '1'
2046
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null || !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
2047
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2048
+ const answered = await keptAnswer(deps, `${repoId}\0pull-dependencies\0${PULL_DEPENDENCIES_READ}\0${number}`,
2049
+ async () => ({ number, ...await pullDependencies(fullName, token, number, pullDependencyKept(deps, repoId)) }))
2050
+ // Where its review is on, those of lockfiles it leaves out told to none,
2051
+ // whoever asks.
2052
+ const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
2053
+ const held = setting?.enabled ? heldDependencies(answered.value, setting.lockfiles) : answered.value
2054
+ const now = review ? await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(held)) : null
2055
+ // Its review by the lockfiles they were told by, though they have changed
2056
+ // since; turned off meanwhile, all of them, as where it is off.
2057
+ const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
2058
+ const told = review && !now && setting?.enabled && !(await deps.db.listRepoDependencyReviews()).get(repoId)?.enabled ? answered.value : held
2059
+ const reviewed = deps.reviewedDependencies ?? null
2060
+ const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
2061
+ // Its comments read for reviews, where the host reads them, while it is
2062
+ // open (pull-comment-reviews.ts reads whether it is with them).
2063
+ const parse = reviewed?.parseComments
2064
+ const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
2065
+ // The comment the host formats of the reader's reviews of its versions, for its copy button.
2066
+ const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
2067
+ return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
2068
+ ...formatted ? { formatted } : {} }, kept: answered.kept }
2069
+ })
2070
+ }
2071
+
2072
+ // GET /api/admin/repositories/branch-dependencies: what the lockfiles of the
2073
+ // repository's default branch lock that wants review, where its review is in
2074
+ // effect for the reader (`review`, else none), as a team's Dependencies tab
2075
+ // shows it: its head, as its pulse reads it, kept a while; every lockfile
2076
+ // there its review holds to (lockfile-scope.ts, commit-lockfiles.ts) as one
2077
+ // directory, its versions as a pull request's others are
2078
+ // (pull-dependency-reviews.ts), each left unread a directory of its own
2079
+ // failing the review, as a pull request's are.
2080
+ async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2081
+ const repoId = Number(query.get('repoId'))
2082
+ if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2083
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2084
+ const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
2085
+ if (!head) return { value: { head: null, review: null }, kept: false }
2086
+ // Those its review holds to alone read, as many as are read of them.
2087
+ const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
2088
+ if (!setting?.enabled) return { value: { head, review: null }, kept: false }
2089
+ const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path))
2090
+ const { packages, paths } = lockedIn(read)
2091
+ const told = { base: head, head, cut: false, lockfiles: [{ paths, added: [], more: 0, locked: packages.slice(0, MAX_LOCKED), moreLocked: Math.max(0, packages.length - MAX_LOCKED) },
2092
+ ...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
2093
+ const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
2094
+ if (!now) return { value: { head, review: null }, kept: false }
2095
+ // Told by the lockfiles it held to as they were read, though they have
2096
+ // changed since.
2097
+ const state = { ...now, lockfiles: setting.lockfiles }
2098
+ const reviewed = deps.reviewedDependencies ?? null
2099
+ const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null } : await baselineFor(fullName, token, repoId, state, baselineKept(deps))
2100
+ return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: false }
2101
+ })
2102
+ }
2103
+
2104
+ // GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
2105
+ // default branch its dependency review reads (commit-lockfiles.ts), by their
2106
+ // paths, with their formats, at its head, as its pulse reads it, for its
2107
+ // settings to name those it holds to (lockfile-scope.ts).
2108
+ async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2109
+ const repoId = Number(query.get('repoId'))
2110
+ if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2111
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2112
+ const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
2113
+ return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head)).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
2114
+ })
2115
+ }
2116
+
2117
+ // The app's users `accounts` are, by their logins in lower case, of those
2118
+ // who publish reviews to a team counting for repository `repoId`.
2119
+ async function repoReviewers(deps: ManagedHttpDeps, repoId: number, accounts: { login: string; githubId: number | null }[]) {
2120
+ const users = await deps.db.listUsersByAccount(accounts)
2121
+ const counted = await deps.db.listRepoReviewers(repoId, [...new Set(Object.values(users).map(user => user.id))])
2122
+ return new Map(Object.entries(users).flatMap(([login, user]) => counted.has(user.id) ? [[login.toLowerCase(), user.id] as const] : []))
2123
+ }
2124
+
2125
+ // The versions repository lockfiles locked at the commits their dependencies
2126
+ // are reviewed since, kept by each (reviewBaselineStore).
2127
+ function baselineKept(deps: ManagedHttpDeps): ReviewBaselineStore {
2128
+ const store = reviewBaselineStore(deps.db, deps.config.debug)
2129
+ return {
2130
+ async read(key) {
2131
+ const value = (await store.read(key))?.value
2132
+ const strings = (list: unknown) => Array.isArray(list) && list.every(item => typeof item === 'string')
2133
+ return strings(value?.versions) && strings(value?.unread) ? value : null
2134
+ },
2135
+ write: (key, baseline) => store.write(key, baseline, Date.now()),
2136
+ }
2137
+ }
2138
+
2139
+ // What repository `repoId`'s pull requests' lockfiles add, kept by the
2140
+ // commits of each's base and head and the rules they were told by
2141
+ // (PULL_DEPENDENCIES_READ, pullDependencyStore).
2142
+ function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependencyStore {
2143
+ const store = pullDependencyStore<PullDependencies>(deps.db, deps.config.debug)
2144
+ const keyOf = (commits: string) => `${repoId}\0${PULL_DEPENDENCIES_READ}\0${commits}`
2145
+ return {
2146
+ async read(commits) {
2147
+ const value = (await store.read(keyOf(commits)))?.value
2148
+ return value && Array.isArray(value.lockfiles) ? value : null
2149
+ },
2150
+ write: (commits, told) => store.write(keyOf(commits), told, Date.now()),
2151
+ }
2152
+ }
2153
+
2154
+ // GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
2155
+ // (repository-pulse.ts): the default branch's year, counted from its commits
2156
+ // (pulseCommits), and the pull requests and issues of the last `weeks`
2157
+ // weeks, each kept apart. A year not read whole yet answers how many of its
2158
+ // commits are (`reading`), or of the pull requests merged in it (`pulls`);
2159
+ // one GitHub's statistics count, while it is still computing them,
2160
+ // `pending`, for the client to ask again. They are the whole repository's
2161
+ // (readWholeRepository).
2162
+ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2163
+ const repoId = Number(query.get('repoId'))
2164
+ const weeks = Number(query.get('weeks') ?? '4')
2165
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks)) { sendJson(res, 400, { error: 'bad-request' }); return }
2166
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2167
+ const [period, year] = await Promise.all([
2168
+ keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => repositoryActivity(fullName, token, weeks)),
2169
+ pulseCommits(deps, repoId, fullName, token),
2170
+ ])
2171
+ return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
2172
+ pending: year.pending }, kept: period.kept && year.kept }
2173
+ })
2174
+ }
2175
+
2176
+ // The default branch's year (repository-pulse.ts commitStats), counted from
2177
+ // its commits as the Commits tab keeps them, with their lines
2178
+ // (yearCommits): kept by the head it was read from (repositoryReadStore),
2179
+ // that head kept as an issue's page is; its commits merged from an agent's
2180
+ // pull request told by those merged (agentMerges), kept as read, the rest
2181
+ // read on by the next answer as the year's commits are, and read again as
2182
+ // an answer kept is, a label changing whose a pull request is without
2183
+ // moving the head, the year counted again where what they bring changed.
2184
+ // A year not read whole yet is how many of its commits are (`reading`), or
2185
+ // of the pull requests merged (`pulls`), beside the year last counted from
2186
+ // an earlier head over the same weeks, where one is kept, so that a head
2187
+ // moved on shows the year as it was till it is counted again; one past
2188
+ // YEAR_COMMITS is GitHub's statistics' (repositoryStats, repositoryLines),
2189
+ // kept together as an issue's page is and through GitHub computing them
2190
+ // again, `pending` while it first computes them, the year last counted
2191
+ // beside it so too.
2192
+ // A day as a pulse's items are narrowed by (YYYY-MM-DD, UTC), when it starts;
2193
+ // a GitHub login, as many of them as PULSE_AUTHORS.
2194
+ const PULSE_DAY = /^\d{4}-\d{2}-\d{2}$/u
2195
+ const GITHUB_LOGIN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?:\[bot\])?$/iu
2196
+ const PULSE_AUTHORS = 10
2197
+ const pulseDay = (value: string | null) => {
2198
+ const at = value !== null && PULSE_DAY.test(value) ? Date.parse(`${value}T00:00:00Z`) : Number.NaN
2199
+ return Number.isSafeInteger(at) ? at : null
2200
+ }
2201
+
2202
+ // GET /api/admin/repositories/pulse/items: the Pulse tab's pull requests and
2203
+ // issues narrowed to a picked day or week (`from` to `to`, else on from
2204
+ // `from`) and the GitHub accounts a picked contributor committed as
2205
+ // (`authors`), as repositoryActivity reads them, kept apart for each.
2206
+ async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2207
+ const repoId = Number(query.get('repoId'))
2208
+ const weeks = Number(query.get('weeks') ?? '4')
2209
+ const [from, to] = [pulseDay(query.get('from')), query.has('to') ? pulseDay(query.get('to')) : null]
2210
+ const logins = (query.get('authors') ?? '').split(',').filter(Boolean)
2211
+ // Each once, whatever its case, as GitHub matches logins.
2212
+ const authors = [...new Set(logins.map(login => login.toLowerCase()))].toSorted()
2213
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks) || from === null || (query.has('to') && (to === null || to < from))
2214
+ || authors.length > PULSE_AUTHORS || !logins.every(login => GITHUB_LOGIN.test(login))) { sendJson(res, 400, { error: 'bad-request' }); return }
2215
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2216
+ const narrowing = { from, to, authors }
2217
+ const items = await keptAnswer(deps, `${repoId}\0pulse-items\0${weeks}\0${from}\0${to ?? ''}\0${narrowing.authors.join(',')}`,
2218
+ () => repositoryActivity(fullName, token, weeks, Date.now(), globalThis.fetch, narrowing))
2219
+ return { value: { weeks, ...items.value }, kept: items.kept }
2220
+ })
2221
+ }
2222
+
2223
+ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
2224
+ const first = pulseSince(52, Date.now())
2225
+ const head = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2226
+ if (!head.value.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
2227
+ const store = repositoryReadStore(deps.db, deps.config.debug)
2228
+ const key = `${repoId}\0pulse-year\0${PULSE_YEAR_VERSION}\0${head.value.sha}\0${first}`
2229
+ const lastKey = `${repoId}\0pulse-year-last`
2230
+ const mergesKey = `${repoId}\0pulse-merges\0${PULSE_YEAR_VERSION}`
2231
+ const last = async () => {
2232
+ const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
2233
+ return kept?.first === first ? kept.stats : null
2234
+ }
2235
+ const sha = head.value.sha
2236
+ // What tells whose the year's commits are, kept as read: the agents' pull
2237
+ // requests merged (agentMerges), and the commits that release the
2238
+ // repository (`releases`, release-commits.ts releaseCommits).
2239
+ type Attribution = AgentMerges & { releases?: string[] }
2240
+ const keptAttribution = async () => ((await store.read(mergesKey))?.value as Attribution | undefined) ?? null
2241
+ const attribution = async (kept: Attribution | null): Promise<Attribution> => {
2242
+ const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept), releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
2243
+ return { ...merges, releases }
2244
+ }
2245
+ // The year counted from its `commits` as `read` tells whose they are and
2246
+ // which were pushed directly, the branch followed through those it was
2247
+ // read on past (`passed`, yearCommits) too; kept by its head and as the
2248
+ // year last counted.
2249
+ const count = async ({ commits, passed }: { commits: HistoryCommit[]; passed: HistoryCommit[] }, read: Attribution) => {
2250
+ const walked = [...commits, ...passed]
2251
+ const stats = commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha))
2252
+ await Promise.all([store.write(key, stats, Date.now()), store.write(lastKey, { first, stats }, Date.now())])
2253
+ return stats
2254
+ }
2255
+ // A year kept by its head, what tells whose its commits are read again as
2256
+ // an answer kept is (keptAnswer), a label or a tag changing it without
2257
+ // moving the head: counted again from its pages kept where what agents'
2258
+ // pull requests bring, or the releases, changed, or a read of the pull
2259
+ // requests left unfinished is finished; the year as kept answered
2260
+ // meanwhile, and where reading them fails.
2261
+ const relabelled = async (stats: PulseStats) => {
2262
+ const recounted: { stats?: PulseStats } = {}
2263
+ await keptAnswer(deps, mergesKey, async () => {
2264
+ const kept = await keptAttribution()
2265
+ const read = await attribution(kept)
2266
+ const same = kept && !kept.next && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join()
2267
+ if (read.next || same) return read
2268
+ const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
2269
+ if (year.complete) recounted.stats = await count(year, read)
2270
+ return read
2271
+ }, () => true).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
2272
+ return recounted.stats ?? stats
2273
+ }
2274
+ const held = (await store.read(key))?.value as PulseStats | { tooMany: true } | undefined
2275
+ if (held && !('tooMany' in held)) return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
2276
+ if (!held) {
2277
+ const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
2278
+ if (year.complete) {
2279
+ const read = await attribution(await keptAttribution())
2280
+ await store.write(mergesKey, read, Date.now())
2281
+ if (read.next) return { stats: await last(), reading: null, pulls: read.next.read, pending: false, kept: false }
2282
+ return { stats: await count(year, read), reading: null, pulls: null, pending: false, kept: false }
2283
+ }
2284
+ if (!year.tooMany) return { stats: await last(), reading: year.read, pulls: null, pending: false, kept: false }
2285
+ await store.write(key, { tooMany: true }, Date.now())
2286
+ }
2287
+ // Its commits and lines read together, kept as one: never the one GitHub
2288
+ // has counted since a push beside the other kept from before it.
2289
+ const computing = (err: unknown) => err instanceof StatsPending
2290
+ const counted = await keptAnswer(deps, `${repoId}\0pulse-stats`, async () => {
2291
+ const [stats, lines] = await Promise.all([repositoryStats(fullName, token), repositoryLines(fullName, token, first)])
2292
+ return withLines(stats, lines)
2293
+ }, computing).catch((err: unknown) => { if (computing(err)) return null; throw err })
2294
+ if (counted) return { stats: counted.value, reading: null, pulls: null, pending: false, kept: counted.kept }
2295
+ return { stats: await last(), reading: null, pulls: null, pending: true, kept: false }
2296
+ }
2297
+
1328
2298
  function prebuildBundle(deps: ManagedHttpDeps, id: string) {
1329
2299
  if (!deps.bundleCache || deps.config.serverless) return
1330
2300
  deps.track((async () => {
@@ -1399,9 +2369,13 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
1399
2369
  // (repository-browser.ts), and that access checked again once it is read,
1400
2370
  // before any of it is told; packages as the npm viewer's admins read them, in
1401
2371
  // spare assessments (npm-risk.ts), trusted however new where a team lists
1402
- // their scope, and each lockfile's asked of npm's advisories at once. The
1403
- // reader leaving, losing their admin role or the server shutting down stops
1404
- // it.
2372
+ // their scope, and each lockfile's asked of npm's advisories at once; each
2373
+ // repository's Dependabot alerts, open or dismissed, read with the App's
2374
+ // installation token where the admin's own access to it lets them see them on
2375
+ // GitHub (dependabot-access.ts); and its GitHub Actions, the references they
2376
+ // make resolved with the admin's own GitHub token (github-actions.ts). The
2377
+ // reader leaving, losing their admin role or
2378
+ // the server shutting down stops it.
1405
2379
  async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1406
2380
  const s = await readAdminSession(res, deps, cookie)
1407
2381
  if (!s) return
@@ -1414,7 +2388,8 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1414
2388
  const controller = new AbortController()
1415
2389
  res.on('close', () => { controller.abort() })
1416
2390
  res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
1417
- const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
2391
+ const user = await repositoryBrowserUser(deps, s.user.id)
2392
+ const browser = createRepositoryBrowser(deps.config, user)
1418
2393
  const scopes = new Map<string, Promise<boolean>>()
1419
2394
  const teamScoped = (name: string) => {
1420
2395
  const scope = npmPackageScope(name)
@@ -1439,8 +2414,15 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1439
2414
  lockfiles: async () => (await upstreamGithub(() => client.findRepoLockfiles({ repo: repo.fullName, sha: commit }))).lockfiles,
1440
2415
  file: path => upstreamGithub(() => client.getRepoFile({ repo: repo.fullName, path, ref: commit })),
1441
2416
  recheck: async () => { await reader.recheckAccess() },
2417
+ // As GitHub has them now, where the admin's own access lets them see them.
2418
+ dependabot: () => reader.dependabotAlerts(),
2419
+ workflows: () => reader.directory(WORKFLOWS_DIR, commit).then(({ entries }) => entries.filter(entry => entry.type === 'file' && /\.ya?ml$/iu.test(entry.name)).map(entry => entry.path),
2420
+ (error: unknown) => { if (error instanceof GithubApiError && error.status === 404) return []; throw error }),
1442
2421
  }
1443
2422
  },
2423
+ actions: githubActionsSource(user.token),
2424
+ actionFiles: scanReadStore(deps.db, 'action-files', SCAN_READ_KEPT_MS, deps.config.debug),
2425
+ actionLines: scanReadStore(deps.db, 'actions', RISK_KEPT_MS, deps.config.debug),
1444
2426
  teamScoped,
1445
2427
  // Each package's downloads readied before it is assessed
1446
2428
  // (scan-downloads.ts), so that none waits on npm's downloads API; a
@@ -1515,18 +2497,25 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
1515
2497
  await sendEncoded(req, res, cached, contentType, headers)
1516
2498
  }
1517
2499
 
2500
+ // Two bundles a reader asks about together, where they may read both, else
2501
+ // null, told as 401 or 404: asked before what is worked out of them and
2502
+ // again before it is sent.
2503
+ function bothBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, otherId: string) {
2504
+ return async () => {
2505
+ const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
2506
+ if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2507
+ if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
2508
+ return [base.bundle, other.bundle] as const
2509
+ }
2510
+ }
2511
+
1518
2512
  // GET /api/bundles/:id/rename-diff: one of its JavaScript files diffed with
1519
2513
  // one of the `other` bundle's, both pretty-printed, renamed names hidden
1520
2514
  // (rename-diff.ts), for a reader of both, checked again once it is worked out.
1521
2515
  async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
1522
2516
  const otherId = params.get('other') ?? '', request = renameDiffRequest(params)
1523
2517
  if (!request || !/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-file' }); return }
1524
- const readable = async () => {
1525
- const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
1526
- if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
1527
- if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
1528
- return [base.bundle, other.bundle] as const
1529
- }
2518
+ const readable = bothBundles(res, deps, session, id, otherId)
1530
2519
  const records = await readable()
1531
2520
  if (!records) return
1532
2521
  const { prettyCache } = deps
@@ -1542,6 +2531,25 @@ async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse,
1542
2531
  else diff.stream.destroy()
1543
2532
  }
1544
2533
 
2534
+ // GET /api/bundles/:id/comparison?other=: its files paired with the `other`
2535
+ // bundle's as Compare pairs them, with what its Overview reads beside them
2536
+ // (comparison.ts), within a reason `scope` where given; for a reader of
2537
+ // both, checked again once it is worked out.
2538
+ async function handleBundleComparison(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
2539
+ const otherId = params.get('other') ?? ''
2540
+ if (!/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-bundle' }); return }
2541
+ const readable = bothBundles(res, deps, session, id, otherId)
2542
+ const records = await readable()
2543
+ if (!records) return
2544
+ const { prettyCache } = deps
2545
+ if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
2546
+ let body
2547
+ try { body = await comparisonBody(await prettyCache.bundleDetails(records[0]), await prettyCache.bundleDetails(records[1]), params.get('scope') ?? '') }
2548
+ catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
2549
+ if (await readable()) await sendEncoded(req, res, body, 'application/json')
2550
+ else body.stream.destroy()
2551
+ }
2552
+
1545
2553
  // GET /api/bundles/:id/pretty?other=: the files that changed between it and
1546
2554
  // the `other` bundle, both sides' pretty-printed (pretty-print.ts compared), a
1547
2555
  // line each as it is found kept or formatted; GET
@@ -1898,7 +2906,7 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
1898
2906
  // The revision sent is the reloaded catalog's, so classify what changed now.
1899
2907
  const apps = await teamAppStates(deps.db, deps.reportStore, session.id, teams, classified)
1900
2908
  sendJson(res, 200, {
1901
- teams: teams.map(team => ({ ...team, app: apps.get(teamAppKey(team)) ?? null,
2909
+ teams: teams.map(team => ({ ...withWorkspaceSummary(team, apps.get(teamAppKey(team))),
1902
2910
  bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
1903
2911
  commitInfo: commits.commitInfo(bundle) })) })),
1904
2912
  revision,
@@ -2666,6 +3674,11 @@ function found<T>(value: T | null | undefined): T {
2666
3674
  return value
2667
3675
  }
2668
3676
 
3677
+ // Version documents a list of reviews reads at once: each holds a version
3678
+ // document's room in what npm's reads may hold at once (npm-packages.ts),
3679
+ // which refuses past thirty-two of them.
3680
+ const REVIEW_READS_AT_ONCE = 8
3681
+
2669
3682
  // Lists must enforce the same target and base access as the review itself,
2670
3683
  // including permission changes while the registry requests are in flight.
2671
3684
  async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[]): Promise<T[] | null> {
@@ -2675,18 +3688,15 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
2675
3688
  try {
2676
3689
  const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
2677
3690
  if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2678
- const documents = new Map<string, Promise<NpmVersionDocument | null>>()
2679
3691
  // Public versions' documents as the viewer keeps them (npm-kept.ts), a
2680
- // day: a list asks npm for none it read within it.
3692
+ // day: a list asks npm for none it read within it. Each version once, as
3693
+ // many at once as REVIEW_READS_AT_ONCE.
2681
3694
  const kept = npmVersionStore(deps.db, deps.config.debug)
2682
- const read = (name: string, version: string) => {
2683
- const key = `${name}@${version}`
2684
- if (!documents.has(key)) documents.set(key, readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
2685
- return documents.get(key)!
2686
- }
2687
- const packages = await Promise.all(reviews.map(review => Promise.all([
2688
- read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
2689
- ])))
3695
+ const versionsOf = (review: T) => [[review.package, review.version], ...review.basePackage ? [[review.basePackage, review.baseVersion!]] : []] as [string, string][]
3696
+ const asked = new Map(reviews.flatMap(versionsOf).map(([name, version]) => [`${name}\0${version}`, [name, version] as const]))
3697
+ const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
3698
+ const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
3699
+ const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
2690
3700
  const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
2691
3701
  if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2692
3702
  return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
@@ -2835,7 +3845,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2835
3845
  const allBases = params.get('allBases') === 'true'
2836
3846
  const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
2837
3847
  const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
2838
- if (reviews) sendJson(res, 200, { reviews, teams: await deps.db.listDependencyReviewTeams(current.userId) })
3848
+ if (reviews) sendJson(res, 200, { reviews: await withFormatted(deps.reviewedDependencies ?? null, current.userId, reviews), teams: await deps.db.listDependencyReviewTeams(current.userId) })
2839
3849
  return
2840
3850
  }
2841
3851
  const checked = await checkMutation(req, res, deps, cookie)
@@ -2845,7 +3855,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2845
3855
  sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
2846
3856
  }
2847
3857
  const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
2848
- sendJson(res, 200, { review })
3858
+ sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
2849
3859
  } catch (err) {
2850
3860
  if (controller.signal.aborted) return
2851
3861
  if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
@@ -2854,7 +3864,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2854
3864
  } finally { res.off('close', onClose) }
2855
3865
  }
2856
3866
 
2857
- // GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only}. Only readers with private access
3867
+ // GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
2858
3868
  // (canReadPrivateNpm) can be answered from the server's npm token; the rest get
2859
3869
  // what the registry answers anonymously, every time (see npm-packages.ts).
2860
3870
  // Access is checked again after the registry's answer.
@@ -2864,14 +3874,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2864
3874
  // All but the package's own (its versions, figures and advisories) are a version's.
2865
3875
  const versioned = path !== NPM_VERSIONS_PATH && path !== NPM_STATS_PATH && path !== NPM_ADVISORIES_PATH
2866
3876
  const otherSpec = params.get('otherVersion') ?? ''
2867
- if (!isNpmPackageName(name) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
3877
+ // The package a comparison's other side is a version of, another where Review compares a dependency replaced.
3878
+ const otherName = path === NPM_COMPARISON_PATH ? params.get('otherName') ?? name : name
3879
+ if (!isNpmPackageName(name) || !isNpmPackageName(otherName) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
2868
3880
  || (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
2869
- || (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
3881
+ || (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
3882
+ || ((path === NPM_COMPARISON_PATH || path === NPM_DELTA_PATH) && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
2870
3883
  sendJson(res, 400, { error: 'bad-package' }); return
2871
3884
  }
2872
3885
  const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
2873
3886
  if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
2874
- const reader = await npmReader(deps, cookie, [name])
3887
+ const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
2875
3888
  if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2876
3889
  const controller = new AbortController()
2877
3890
  const onClose = () => { if (!res.writableEnded) controller.abort() }
@@ -2970,6 +3983,42 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2970
3983
  return
2971
3984
  }
2972
3985
  if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
3986
+ // The lines it changes against `otherVersion`, or `(empty)`, its whole
3987
+ // package's, and those but for declaration files changed in types alone
3988
+ // (npm-delta.ts), as a list of versions wanting reviews tells them.
3989
+ if (path === NPM_DELTA_PATH) {
3990
+ const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
3991
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
3992
+ const side = (version: NpmVersionDocument) => ({ integrity: version.dist.integrity, files: async () => {
3993
+ const load = loadNpmFiles(version)
3994
+ try { return await load.result } finally { load.release() }
3995
+ } })
3996
+ const delta = await npmDelta(old ? side(old) : null, side(doc), npmDeltaStore<NpmDelta>(deps.db, deps.config.debug))
3997
+ await reply(doc.private || old?.private === true, { name, version: doc.version, otherVersion: old?.version ?? null, ...delta })
3998
+ return
3999
+ }
4000
+ // Its files paired with those of `otherVersion` (of `otherName`, else the
4001
+ // same package), as Compare and Review pair them, with what their
4002
+ // Overview reads beside them (comparison.ts): the version shown against
4003
+ // the one compared with, or `(empty)`, none, as Review reads a first
4004
+ // version. Read of both, each package's access checked again once worked out.
4005
+ if (path === NPM_COMPARISON_PATH) {
4006
+ const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(otherName, otherSpec, canReadPrivateNpm(reader, otherName), controller.signal, keptVersions))
4007
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
4008
+ const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
4009
+ let body
4010
+ try {
4011
+ const [files, oldFiles] = await Promise.all(loads.map(load => load.result))
4012
+ body = await comparisonBody(old ? npmComparedSide(old.dist.integrity, oldFiles!) : emptySide(), npmComparedSide(doc.dist.integrity, files!))
4013
+ } finally { for (const load of loads) load.release() }
4014
+ const current = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
4015
+ if (current == null) { body.stream.destroy(); sendJson(res, 401, { error: 'unauthenticated' }); return }
4016
+ if ((doc.private && !canReadPrivateNpm(current, name)) || (old?.private && !canReadPrivateNpm(current, otherName))) {
4017
+ body.stream.destroy(); sendJson(res, 404, { error: 'package-not-found' }); return
4018
+ }
4019
+ if (!res.destroyed) await sendEncoded(req, res, body, 'application/json')
4020
+ return
4021
+ }
2973
4022
  // Every one of its files that needs it pretty-printed (pretty-print.ts
2974
4023
  // npmAll), or, against another version, the files that changed between
2975
4024
  // them, both sides' (compared), a line each as it is formatted, or found
@@ -3110,13 +4159,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3110
4159
  }
3111
4160
 
3112
4161
  // Public mode probe — lets a client detect the managed protocol up front.
4162
+ // It also sends the session, sparing the client's GET /api/auth/session;
4163
+ // a failed read leaves that request to the client, never failing the probe.
3113
4164
  if (path === CONFIG_PATH) {
3114
4165
  if (method !== 'GET') { send405(res, 'GET'); return }
3115
4166
  const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
4167
+ const current = await currentSession(config, db, cookie).catch((err) => { console.warn('managed: config session read failed:', err); return null })
3116
4168
  sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
3117
4169
  managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
3118
4170
  uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
3119
- } : {}) } })
4171
+ } : {}) }, ...(current ? { session: current.body } : {}) }, current?.headers)
3120
4172
  return
3121
4173
  }
3122
4174
  if (viewing && underPrefix(path, VIEW_PREFIXES) && !viewAllows(method, path)) { sendViewOnly(res); return }
@@ -3132,6 +4184,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3132
4184
  if (path === CALLBACK_PATH) {
3133
4185
  if (method !== 'GET') { send405(res, 'GET'); return }
3134
4186
  try {
4187
+ if (isLinkOAuthCallback(config, url.searchParams, cookie)) {
4188
+ if (viewing) { sendViewOnly(res); return }
4189
+ const s = await readWorkspaceSession(res, deps, cookie)
4190
+ if (!s) return
4191
+ const result = await linkOAuthCallback(config, db, s.session, url.searchParams, cookie, { avatarStore })
4192
+ if (result.linked != null) await activity(deps, s.user, 'access', `linked GitHub account @${result.linked}`)
4193
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
4194
+ res.end(); return
4195
+ }
3135
4196
  if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
3136
4197
  if (viewing) { sendViewOnly(res); return }
3137
4198
  const s = await readWorkspaceSession(res, deps, cookie)
@@ -3159,22 +4220,22 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3159
4220
  res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
3160
4221
  res.end(); return
3161
4222
  }
3162
- // Who am I? While an admin views as another user, that user, plus the
3163
- // admin as `viewer`. A view that has ended (with the admin's session or
3164
- // role, or the viewed account) is cleared, returning to the own session.
4223
+ if (path === LINK_LOGIN_PATH) {
4224
+ if (method !== 'GET') { send405(res, 'GET'); return }
4225
+ const s = await readWorkspaceSession(res, deps, cookie)
4226
+ if (!s) return
4227
+ const result = linkLoginRedirect(config, s.session)
4228
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
4229
+ res.end(); return
4230
+ }
4231
+ if (path === PROFILE_PATH) { await handleProfile(req, res, deps, cookie); return }
4232
+ const unlink = /^\/api\/auth\/profile\/accounts\/([a-f\d-]{36})$/iu.exec(path)
4233
+ if (unlink) { await handleUnlinkAccount(req, res, deps, cookie, unlink[1]!); return }
3165
4234
  if (path === SESSION_PATH) {
3166
4235
  if (method !== 'GET') { send405(res, 'GET'); return }
3167
- const now = Date.now()
3168
- let s = await readSession(config, db, cookie, now)
3169
- const ended = s == null && viewing
3170
- if (ended) s = await readSession(config, db, cookie, now, { own: true })
3171
- const headers: Record<string, string> = ended ? { 'set-cookie': clearViewCookie(config) } : {}
3172
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
3173
- sendJson(res, 200, {
3174
- user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
3175
- csrfToken: s.session.csrfToken,
3176
- ...(s.session.viewer ? { viewer: s.session.viewer } : {}),
3177
- }, headers)
4236
+ const { body, headers } = await currentSession(config, db, cookie)
4237
+ if (body == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
4238
+ sendJson(res, 200, body, headers)
3178
4239
  return
3179
4240
  }
3180
4241
  if (path === VIEW_AS_PATH) { await handleViewAs(req, res, deps, cookie); return }
@@ -3210,12 +4271,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3210
4271
  if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
3211
4272
  // Cached avatar by user id, served same-origin (the page CSP forbids the
3212
4273
  // github CDN). The id in the path keys the browser cache per user, so a user
3213
- // switch never serves a stale avatar. Workspace access is required.
4274
+ // switch never serves a stale avatar. Workspace access is required. A user's
4275
+ // is that of the account chosen on their profile; `own` asks for the
4276
+ // sign-in account's. A linked account's is served by its own id.
3214
4277
  if (path.startsWith(AVATAR_PREFIX)) {
3215
4278
  if (method !== 'GET') { send405(res, 'GET'); return }
3216
4279
  const s = await readSession(config, db, cookie, Date.now())
3217
4280
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3218
- await serveAvatar(res, avatarStore, path.slice(AVATAR_PREFIX.length))
4281
+ const id = path.slice(AVATAR_PREFIX.length)
4282
+ if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
4283
+ await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
3219
4284
  return
3220
4285
  }
3221
4286
  // Admin: list users (admin-only).
@@ -3256,8 +4321,57 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3256
4321
  if (method !== 'GET') { send405(res, 'GET'); return }
3257
4322
  await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
3258
4323
  }
4324
+ if (path === REPO_CODE_PATH) {
4325
+ if (method !== 'GET') { send405(res, 'GET'); return }
4326
+ await handleRepositoryCode(res, deps, cookie, url.searchParams); return
4327
+ }
4328
+ if (path === REPO_HISTORY_PATH) {
4329
+ if (method !== 'GET') { send405(res, 'GET'); return }
4330
+ await handleRepositoryHistory(res, deps, cookie, url.searchParams); return
4331
+ }
4332
+ if (path === REPO_CHANGES_PATH) {
4333
+ if (method !== 'GET') { send405(res, 'GET'); return }
4334
+ await handleRepositoryChanges(res, deps, cookie, url.searchParams); return
4335
+ }
4336
+ if (path === REPO_TREE_PATH) {
4337
+ if (method !== 'GET') { send405(res, 'GET'); return }
4338
+ await handleRepositoryTree(res, deps, cookie, url.searchParams); return
4339
+ }
4340
+ if (path === REPO_ISSUES_PATH) {
4341
+ if (method !== 'GET') { send405(res, 'GET'); return }
4342
+ await handleRepositoryIssues(res, deps, cookie, url.searchParams); return
4343
+ }
4344
+ if (path === REPO_AGENTS_PATH) {
4345
+ if (method !== 'GET') { send405(res, 'GET'); return }
4346
+ await handleRepositoryAgents(res, deps, cookie, url.searchParams); return
4347
+ }
4348
+ if (path === REPO_ITEM_PATH) {
4349
+ if (method !== 'GET') { send405(res, 'GET'); return }
4350
+ await handleRepositoryItem(res, deps, cookie, url.searchParams); return
4351
+ }
4352
+ if (path === REPO_PULL_DEPENDENCIES_PATH) {
4353
+ if (method !== 'GET') { send405(res, 'GET'); return }
4354
+ await handleRepositoryPullDependencies(res, deps, cookie, url.searchParams); return
4355
+ }
4356
+ if (path === REPO_BRANCH_DEPENDENCIES_PATH) {
4357
+ if (method !== 'GET') { send405(res, 'GET'); return }
4358
+ await handleRepositoryBranchDependencies(res, deps, cookie, url.searchParams); return
4359
+ }
4360
+ if (path === REPO_LOCKFILES_PATH) {
4361
+ if (method !== 'GET') { send405(res, 'GET'); return }
4362
+ await handleRepositoryLockfiles(res, deps, cookie, url.searchParams); return
4363
+ }
4364
+ if (path === REPO_PULSE_PATH) {
4365
+ if (method !== 'GET') { send405(res, 'GET'); return }
4366
+ await handleRepositoryPulse(res, deps, cookie, url.searchParams); return
4367
+ }
4368
+ if (path === REPO_PULSE_ITEMS_PATH) {
4369
+ if (method !== 'GET') { send405(res, 'GET'); return }
4370
+ await handleRepositoryPulseItems(res, deps, cookie, url.searchParams); return
4371
+ }
3259
4372
  if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
3260
4373
  if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
4374
+ if (path === REPO_DEPENDENCY_REVIEW_PATH) { await handleRepositoryDependencyReview(req, res, deps, cookie); return }
3261
4375
  if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
3262
4376
  if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
3263
4377
  // Reports: list / upload on the exact path, download / delete per-id on the
@@ -3302,9 +4416,13 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3302
4416
  await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
3303
4417
  return
3304
4418
  }
3305
- const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only)$/iu.exec(path)
4419
+ const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only|comparison)$/iu.exec(path)
3306
4420
  if (bundleRead) {
3307
4421
  const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
4422
+ if (part === 'comparison') {
4423
+ if (method !== 'GET') { send405(res, 'GET'); return }
4424
+ await handleBundleComparison(req, res, deps, session, id, url.searchParams); return
4425
+ }
3308
4426
  if (part === 'rename-diff' || part === 'type-only' || (part === 'pretty' && url.searchParams.has('other'))) {
3309
4427
  if (method !== 'GET') { send405(res, 'GET'); return }
3310
4428
  if (part === 'rename-diff' && url.searchParams.has('path')) { await handleBundleRenameDiff(req, res, deps, session, id, url.searchParams); return }
@@ -3445,7 +4563,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3445
4563
  }
3446
4564
  const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
3447
4565
  const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
3448
- if (visible) sendJson(res, 200, { review: visible[0] ?? null })
4566
+ if (visible) sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, s.user.id, visible))[0] ?? null })
3449
4567
  return
3450
4568
  }
3451
4569
  if (path === '/api/npm/reviews/discard') {
@@ -3475,7 +4593,27 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3475
4593
  }
3476
4594
  const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
3477
4595
  const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
3478
- if (reviews) sendJson(res, 200, { ...page, reviews })
4596
+ if (reviews) sendJson(res, 200, { ...page, reviews: await withFormatted(deps.reviewedDependencies ?? null, s.user.id, reviews) })
4597
+ return
4598
+ }
4599
+ // Those published to a team, of one who reads them there, as its
4600
+ // Dependencies tab lists them, fifty a page.
4601
+ if (path === '/api/npm/reviews/team') {
4602
+ if (method !== 'GET') { send405(res, 'GET'); return }
4603
+ const s = await readWorkspaceSession(res, deps, cookie)
4604
+ if (!s) return
4605
+ const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
4606
+ if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
4607
+ if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
4608
+ sendJson(res, 400, { error: 'bad-review-cursor' }); return
4609
+ }
4610
+ const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
4611
+ if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
4612
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
4613
+ if (!reviews) return
4614
+ // Its access asked again, as it may have been taken while npm was read.
4615
+ if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
4616
+ sendJson(res, 200, { ...page, reviews })
3479
4617
  return
3480
4618
  }
3481
4619
  if (path === '/api/npm/reviews') {