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

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 (207) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +25 -2
  5. package/common/default-scan-models.ts +1 -1
  6. package/common/dependency-reviews.d.ts +0 -5
  7. package/common/dependency-reviews.js +0 -17
  8. package/common/line-diff.js +5 -4
  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 +88 -7
  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 +3 -0
  17. package/common/workspace-app.js +6 -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-4HI2MSAV.js +17 -0
  23. package/out/chunk-4UFAMJAX.js +1 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-7XRR4OFU.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-BOIXMUU5.js +3 -0
  28. package/out/chunk-C4Z6ZBXV.js +6 -6
  29. package/out/chunk-CC4OM6TZ.js +3 -0
  30. package/out/chunk-G24A42V6.js +2 -0
  31. package/out/chunk-GDPCLSOS.js +2 -0
  32. package/out/chunk-GO7FT4OD.js +1 -0
  33. package/out/chunk-I2MDGSQX.js +3 -0
  34. package/out/chunk-IYPY47MC.js +1 -0
  35. package/out/chunk-IZ7POWI3.js +18 -0
  36. package/out/chunk-JCIIBLKR.js +1 -1
  37. package/out/chunk-JKGQ3O7O.js +6 -0
  38. package/out/chunk-LE5ZWLG4.js +235 -0
  39. package/out/chunk-MSION3G4.js +36 -0
  40. package/out/chunk-NRPC74AM.js +1 -1
  41. package/out/chunk-OKRLLE5I.js +17 -0
  42. package/out/chunk-OOJM4CTU.js +1 -1
  43. package/out/chunk-PNHBZGDD.js +1 -1
  44. package/out/chunk-PXK7EU4V.js +1 -0
  45. package/out/chunk-QB4CBOX2.js +1 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-S6GIXWQW.js +52 -0
  49. package/out/chunk-SXV2T5C4.js +80 -0
  50. package/out/chunk-TUII34K2.js +1 -1
  51. package/out/chunk-V3KWNNMY.js +1 -0
  52. package/out/chunk-VFWMQ3LG.js +2 -0
  53. package/out/chunk-VH4XMZIW.js +2 -0
  54. package/out/chunk-WFYLMF6Z.js +10 -0
  55. package/out/chunk-WQ74ZBZF.js +7 -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 +30 -34
  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 +53 -6
  71. package/risk/actions/findings.js +161 -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 +169 -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 +71 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +1 -1
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/files.js +413 -2
  89. package/risk/npm/tarball.js +56 -73
  90. package/server-e2e/app.ts +5 -4
  91. package/server-e2e/http.ts +5 -2
  92. package/server-managed/agent-merges.ts +198 -0
  93. package/server-managed/app-agents.ts +30 -0
  94. package/server-managed/avatar-store.ts +1 -0
  95. package/server-managed/bundle-cache.ts +7 -1
  96. package/server-managed/combined.ts +3 -2
  97. package/server-managed/commit-coauthors.ts +83 -0
  98. package/server-managed/commit-lockfiles.ts +72 -0
  99. package/server-managed/config.ts +6 -0
  100. package/server-managed/db-methods.ts +66 -19
  101. package/server-managed/db-neon.ts +89 -21
  102. package/server-managed/db-schema.ts +13 -2
  103. package/server-managed/db.ts +9 -1
  104. package/server-managed/dependabot-access.ts +60 -0
  105. package/server-managed/dependabot-alerts.ts +185 -0
  106. package/server-managed/dependency-review-comments.ts +97 -0
  107. package/server-managed/dependency-reviews.ts +78 -18
  108. package/server-managed/github-accounts.ts +33 -0
  109. package/server-managed/github-actions.ts +71 -0
  110. package/server-managed/github-app.ts +33 -13
  111. package/server-managed/github-errors.ts +2 -0
  112. package/server-managed/github-issue-links.ts +11 -5
  113. package/server-managed/github-link-oauth.ts +69 -0
  114. package/server-managed/github-links.ts +204 -0
  115. package/server-managed/github-oauth.ts +5 -2
  116. package/server-managed/github-pulls.ts +2 -2
  117. package/server-managed/github-requests.ts +39 -0
  118. package/server-managed/github-users.ts +30 -0
  119. package/server-managed/http-response.ts +10 -2
  120. package/server-managed/http.ts +1251 -97
  121. package/server-managed/index.ts +13 -8
  122. package/server-managed/kept-briefly.ts +37 -0
  123. package/server-managed/locked-packages.ts +37 -16
  124. package/server-managed/lockfile-scope.ts +49 -0
  125. package/server-managed/npm-delta-worker.js +23 -0
  126. package/server-managed/npm-delta.ts +178 -0
  127. package/server-managed/npm-insights.ts +20 -11
  128. package/server-managed/npm-kept.ts +83 -6
  129. package/server-managed/npm-loads.ts +114 -37
  130. package/server-managed/npm-packages.ts +110 -64
  131. package/server-managed/npm-risk.ts +4 -1
  132. package/server-managed/pretty-print.ts +67 -23
  133. package/server-managed/pull-comment-reviews.ts +161 -0
  134. package/server-managed/pull-dependencies.ts +300 -0
  135. package/server-managed/pull-dependency-reviews.ts +318 -0
  136. package/server-managed/pull-reviews.ts +205 -0
  137. package/server-managed/queue.ts +16 -0
  138. package/server-managed/release-commits.ts +84 -0
  139. package/server-managed/rename-diff.ts +22 -13
  140. package/server-managed/report-sources.ts +1 -1
  141. package/server-managed/report-store.ts +42 -9
  142. package/server-managed/repository-actions.ts +218 -0
  143. package/server-managed/repository-agents.ts +220 -0
  144. package/server-managed/repository-browser.ts +166 -37
  145. package/server-managed/repository-changes.ts +284 -0
  146. package/server-managed/repository-code.ts +52 -0
  147. package/server-managed/repository-dependency-reviews.ts +157 -0
  148. package/server-managed/repository-history.ts +461 -0
  149. package/server-managed/repository-issues.ts +181 -0
  150. package/server-managed/repository-pulse.ts +439 -0
  151. package/server-managed/repository-risk-audit.ts +13 -0
  152. package/server-managed/repository-risk-read.ts +170 -0
  153. package/server-managed/repository-risk.ts +133 -118
  154. package/server-managed/reviewed-dependencies.ts +149 -0
  155. package/server-managed/session.ts +27 -9
  156. package/server-managed/sql.ts +6 -3
  157. package/server-managed/storage-stores.ts +5 -0
  158. package/server-managed/storage.ts +4 -2
  159. package/server-managed/team-app.ts +19 -3
  160. package/server-managed/team-reports.ts +24 -12
  161. package/server-managed/upstream-cache.ts +12 -0
  162. package/server-managed/workspace-shares.ts +4 -1
  163. package/server.ts +25 -5
  164. package/out/chunk-2ABLKCOY.js +0 -46
  165. package/out/chunk-2KBF5TZ7.js +0 -2
  166. package/out/chunk-35VE6CDH.js +0 -2
  167. package/out/chunk-3BZ7JERB.js +0 -1
  168. package/out/chunk-4XCDFGDP.js +0 -10
  169. package/out/chunk-5LSXOLUS.js +0 -1
  170. package/out/chunk-5RCM2J63.js +0 -1
  171. package/out/chunk-7B6HB4D7.js +0 -1
  172. package/out/chunk-AJ4GTATZ.js +0 -3
  173. package/out/chunk-CH4UN6WK.js +0 -225
  174. package/out/chunk-CMQA4DM6.js +0 -1
  175. package/out/chunk-CVVJAXVY.js +0 -2
  176. package/out/chunk-DIJJNTRH.js +0 -10
  177. package/out/chunk-EDKLBQQN.js +0 -17
  178. package/out/chunk-F47DXCEV.js +0 -33
  179. package/out/chunk-FK3DZEKL.js +0 -1
  180. package/out/chunk-G6AJWENO.js +0 -1
  181. package/out/chunk-GJ4IUCNS.js +0 -1
  182. package/out/chunk-GNXOKIGP.js +0 -80
  183. package/out/chunk-GZ4MS3TE.js +0 -13
  184. package/out/chunk-GZWM7UQM.js +0 -2
  185. package/out/chunk-IU3CCINR.js +0 -3
  186. package/out/chunk-K3X5EJBQ.js +0 -2
  187. package/out/chunk-KEX2ATEW.js +0 -2
  188. package/out/chunk-KYCNJVB7.js +0 -1
  189. package/out/chunk-LPCTJYNA.js +0 -7
  190. package/out/chunk-O4RJY5TP.js +0 -2
  191. package/out/chunk-OD4LPN3H.js +0 -1
  192. package/out/chunk-QWDWTBM7.js +0 -1
  193. package/out/chunk-QYWKC557.js +0 -1
  194. package/out/chunk-R75XOKUP.js +0 -1
  195. package/out/chunk-SWTLADW5.js +0 -1
  196. package/out/chunk-T7OKH4VW.js +0 -1
  197. package/out/chunk-TZ5KB2Y5.js +0 -10
  198. package/out/chunk-UNTRGE26.js +0 -2
  199. package/out/chunk-UTR2LVME.js +0 -2
  200. package/out/chunk-UYAXVRVL.js +0 -1
  201. package/out/chunk-V3ME6FI5.js +0 -2
  202. package/out/chunk-V5O7M2YT.js +0 -1
  203. package/out/chunk-VIHGO77Y.js +0 -1
  204. package/out/chunk-W4J3MGLB.js +0 -1
  205. package/out/chunk-WGG7KQUH.js +0 -1
  206. package/out/chunk-XYFXXB2G.js +0 -10
  207. package/out/chunk-ZESLQCTF.js +0 -6
@@ -8,6 +8,11 @@
8
8
  // GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
9
9
  // GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
10
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
11
16
  // GET /api/teams → the current user's teams + their reports and bundles | 401
12
17
  // GET /api/teams/<id>/fixes → PR/issue metadata from visible findings' stored Fix links
13
18
  // GET /api/teams/<id>/reports → all reports and links filtered through this team | 401/404
@@ -17,14 +22,49 @@
17
22
  // GET /api/reports/<id>/triage → triage entries (by finding id, shared across reports) for a viewable report's findings | 401/404
18
23
  // POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
19
24
  // GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
20
- // 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
21
27
  // GET /api/admin/users → admin-only user list | 401/403
22
28
  // GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
23
29
  // POST /api/admin/set-role → admin sets another user's role | 401/403/404
24
30
  // GET /api/admin/repositories → admin repo list (each flagged selected) | 401/403
25
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
26
34
  // GET /api/admin/repositories/impact → admin attached data summary
27
- // 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; with numbers=1,2,3 (twenty at most) in place of number, those of each,
55
+ // { pulls }, one GitHub fails told { number, error } | 400/401/403/404/409/429/502
56
+ // GET /api/admin/repositories/branch-dependencies?repoId= → the versions the default branch's lockfiles lock that want review, where
57
+ // its review is in effect for the reader, GitHub not asked where it reviews none | 400/401/403/404/409/429/502
58
+ // GET /api/admin/repositories/lockfiles?repoId= → the lockfiles of the default branch its dependency review reads, by their
59
+ // paths, for its settings to name | 400/401/403/404/409/429/502
60
+ // GET /api/admin/repositories/pulse?repoId=&weeks=1|4|13|52 → the default branch's commits and lines a week and a day over
61
+ // the last year, its contributors and when in the period they commit, counted from its commits as Commits keeps them (how many are
62
+ // read while they are), and the pull requests
63
+ // 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
64
+ // GET /api/admin/repositories/pulse/items?repoId=&weeks=&from=[&to=&authors=] → those pull requests and issues of the days
65
+ // `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
66
+ // History, issues, item, agents and pulse each carry `users`: the app's users and agents the GitHub accounts they
67
+ // name are (github-users.ts), by login
28
68
  // POST /api/admin/repositories/remove → admin permanently removes a repo
29
69
  // GET /api/admin/repositories/risk[?team=|?repo=] → admin: each connected repo's npm lockfiles, or a team's or one repo's,
30
70
  // their packages and what risk tells of each, Brotli-encoded ndjson as each is found; the reader leaving stops it | 400/401/403/404
@@ -64,6 +104,8 @@
64
104
  // GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set | 400/401/403/404/409
65
105
  // POST /api/npm/reviews/discard → author discards an unpublished review and its file rows | 400/401/403/409
66
106
  // GET /api/npm/reviews/latest → latest own review for exact target bytes, across all bases | 400/401/403
107
+ // GET /api/npm/reviews/team?teamId=&beforeTime=&beforeId= → the reviews published to a team, the latest first, for one who
108
+ // reads them there | 400/401/404
67
109
  // GET /api/npm/package?name=&version= → a published version's manifest and files | 400/401/404/413/429/502
68
110
  // 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
69
111
  // GET /api/npm/download?name=&version= → its tarball | 400/401/404/413/429/502
@@ -82,6 +124,9 @@
82
124
  // Brotli-encoded ndjson | 400/401/404/413/429/502/503
83
125
  // GET /api/npm/type-only?name=&version=&otherVersion= → the files that changed between two versions in types alone,
84
126
  // Brotli-encoded ndjson | 400/401/404/413/429/502/503
127
+ // GET /api/npm/delta?name=&version=&otherVersion= → the lines a version adds and removes against another, or `(empty)`, and
128
+ // those but for declaration files changed in types alone, where any are, and whether binary files changed
129
+ // | 400/401/404/413/422/429/502/503/504
85
130
  // 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
86
131
  // POST /api/auth/logout → same-origin + CSRF, drops the session (and any view)
87
132
  // POST /api/auth/view-as → admin opens a read-only view as another user | 400/401/403/404
@@ -90,7 +135,9 @@ import { mergeLinkGroups } from './link-reports.ts'
90
135
  import { backfillFindingIds, reportEntries } from '@preventive/report'
91
136
  import type { IncomingMessage, ServerResponse } from 'node:http'
92
137
  import { pipeline } from 'node:stream/promises'
93
- import { npmVersionStore, riskLineStore, scanReadStore } from './npm-kept.ts'
138
+ import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmPublishTimeStore, npmVersionStore, pullDependencyStore,
139
+ repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
140
+ import { type NpmDelta, npmDelta } from './npm-delta.ts'
94
141
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
95
142
  import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
96
143
  import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
@@ -115,6 +162,7 @@ import { isRole, roleAtLeast } from '../common/managed/roles.ts'
115
162
  import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
116
163
  import { filterReportData } from '../common/managed/report-filter.ts'
117
164
  import type { TriageEntryPatch } from '../common/managed/triage.ts'
165
+ import { GITHUB_AGENTS } from '../common/managed/github-agents.ts'
118
166
  import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
119
167
  import { reportRepoGithub } from '@preventive/report'
120
168
  import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
@@ -130,9 +178,11 @@ import type { ConnectedRepo } from './github-app.ts'
130
178
  import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
131
179
  import { RepositoryDiscovery } from './repository-discovery.ts'
132
180
  import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines } from './repository-risk.ts'
133
- import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
134
- import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
135
- import { clearCookie, endSession, readSession } from './session.ts'
181
+ import { WORKFLOWS_DIR } from './repository-actions.ts'
182
+ import { githubActionsSource } from './github-actions.ts'
183
+ import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
184
+ import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
185
+ import { clearCookie, endSession, readSession, runRequestPresence } from './session.ts'
136
186
  import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
137
187
  import type { ActivityContext, ActivityInput } from './activity.ts'
138
188
  import { acceptsReportMetadata } from './report-response.ts'
@@ -143,7 +193,11 @@ import { lookupIssueLinks } from './github-issue-links.ts'
143
193
  import { visibleManagedIssues } from './managed-issues.ts'
144
194
  import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
145
195
  import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
146
- import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
196
+ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallback } from './github-link-oauth.ts'
197
+ import type { ProfileChoice } from './github-links.ts'
198
+ import { withAppUsers } from './github-users.ts'
199
+ import { isBlobId } from './object-storage.ts'
200
+ import { attachmentDisposition, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
147
201
  import { triageWireEntry } from './triage-response.ts'
148
202
  import { handlePublicWorkspace } from './public-workspace.ts'
149
203
  import { serveUserTeamFeed } from './team-feed.ts'
@@ -152,8 +206,28 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
152
206
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
153
207
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
154
208
  import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
155
- import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
209
+ import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions,
210
+ readTogether } from './npm-packages.ts'
156
211
  import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
212
+ import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
213
+ import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
214
+ import { COAUTHORS_READ } from './commit-coauthors.ts'
215
+ import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, type SquashedPull, type Squashes, historyFrom, historyStarts, openPullHeads,
216
+ repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
217
+ import { AGENTS_READ_VERSION, APP_AGENTS, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
218
+ import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
219
+ import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
220
+ import { type GithubAsked, lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
221
+ import { MAX_REQUIRED_REVIEWS, SINCE_COMMIT } from './repository-dependency-reviews.ts'
222
+ import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
223
+ import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
224
+ import { formattedPull, withFormatted } from './dependency-review-comments.ts'
225
+ import type { ReviewedDependencies } from './reviewed-dependencies.ts'
226
+ import { commentReviews } from './pull-comment-reviews.ts'
227
+ import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitStats, defaultHead, periodStats, pulseSince, repositoryActivity, repositoryLines,
228
+ repositoryStats, withLines, yearCommits } from './repository-pulse.ts'
229
+ import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
230
+ import { releaseCommits } from './release-commits.ts'
157
231
  import { computeBundleDiff } from '../common/bundle-compare-diff.js'
158
232
  import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
159
233
  import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
@@ -168,6 +242,7 @@ import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
168
242
  import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
169
243
 
170
244
  const SESSION_PATH = '/api/auth/session'
245
+ const PROFILE_PATH = '/api/auth/profile'
171
246
  const AVATAR_PREFIX = '/api/avatar/'
172
247
  const LOGOUT_PATH = '/api/auth/logout'
173
248
  const ADMIN_USERS_PATH = '/api/admin/users'
@@ -178,7 +253,20 @@ const ADMIN_REPOS_PATH = '/api/admin/repositories'
178
253
  const BROWSABLE_REPOS_PATH = '/api/admin/repositories/browsable'
179
254
  const REPO_REFS_PATH = '/api/admin/repositories/refs'
180
255
  const REPO_CONTENTS_PATH = '/api/admin/repositories/contents'
256
+ const REPO_CODE_PATH = '/api/admin/repositories/code'
257
+ const REPO_HISTORY_PATH = '/api/admin/repositories/history'
258
+ const REPO_CHANGES_PATH = '/api/admin/repositories/changes'
259
+ const REPO_TREE_PATH = '/api/admin/repositories/tree'
260
+ const REPO_ISSUES_PATH = '/api/admin/repositories/issues'
261
+ const REPO_AGENTS_PATH = '/api/admin/repositories/agents'
262
+ const REPO_ITEM_PATH = '/api/admin/repositories/item'
263
+ const REPO_PULL_DEPENDENCIES_PATH = '/api/admin/repositories/pull-dependencies'
264
+ const REPO_BRANCH_DEPENDENCIES_PATH = '/api/admin/repositories/branch-dependencies'
265
+ const REPO_LOCKFILES_PATH = '/api/admin/repositories/lockfiles'
266
+ const REPO_PULSE_PATH = '/api/admin/repositories/pulse'
267
+ const REPO_PULSE_ITEMS_PATH = '/api/admin/repositories/pulse/items'
181
268
  const SELECT_REPO_PATH = '/api/admin/repositories/select'
269
+ const REPO_DEPENDENCY_REVIEW_PATH = '/api/admin/repositories/dependency-review'
182
270
  const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
183
271
  const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
184
272
  const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
@@ -220,6 +308,7 @@ const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
220
308
  const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
221
309
  const NPM_COMPARISON_PATH = '/api/npm/comparison'
222
310
  const NPM_RISK_PATH = '/api/npm/risk'
311
+ const NPM_DELTA_PATH = '/api/npm/delta'
223
312
  // What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
224
313
  // having no such package or version, or a tarball unpacking past risk's
225
314
  // bounds; npm failing otherwise a 502.
@@ -232,7 +321,7 @@ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
232
321
  sendJson(res, status, { error: err.code })
233
322
  }
234
323
  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,
235
- NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH])
324
+ NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH, NPM_DELTA_PATH])
236
325
  // How long an npm response may go unread before its connection is dropped.
237
326
  const NPM_RESPONSE_IDLE_MS = 60_000
238
327
  const MAX_TEAM_NAME = 100
@@ -248,7 +337,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
248
337
  if (!s) return
249
338
  const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
250
339
  const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
251
- const manualUrls = storedFixUrls(await deps.db.listTriage(ids))
340
+ const manualUrls = storedFixUrls(await deps.db.listTriageFixes(ids))
252
341
  const storedIssues = await deps.db.listManagedIssues(ids)
253
342
  const issues = visibleManagedIssues(storedIssues, snapshot)
254
343
  const visibleIds = new Set(issues.map(issue => issue.findingId))
@@ -259,7 +348,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
259
348
  : { fixes: await lookupFixes(deps.config, deps.db, snapshot, urls), updates: [] }
260
349
  // Cold report reads, token refresh and upstream batches can outlive changes
261
350
  // to security/links, team grants, publication, sessions or the Fix links.
262
- const currentUrls = storedFixUrls(await deps.db.listTriage(ids))
351
+ const currentUrls = storedFixUrls(await deps.db.listTriageFixes(ids))
263
352
  if (await readWorkspaceSession(res, deps, cookie) == null) return
264
353
  await recheckTeam(deps.db, s.session.id, snapshot)
265
354
  if (JSON.stringify(currentUrls) !== JSON.stringify(manualUrls)) {
@@ -298,9 +387,8 @@ function activity(deps: ManagedHttpDeps, user: StoredUser, kind: ActivityInput['
298
387
  return deps.db.recordActivity({ kind, actor: user.login, actorId: user.id, action, ...context }, Date.now())
299
388
  }
300
389
 
301
- async function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
302
- if (repoId == null) return null
303
- return (await deps.db.listAllRepos()).find(repo => repo.repoId === repoId)?.fullName ?? null
390
+ function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
391
+ return repoId == null ? Promise.resolve(null) : deps.db.getRepoFullName(repoId)
304
392
  }
305
393
 
306
394
  // Workspace history is authorized BEFORE filtering, counting, or paging.
@@ -324,8 +412,14 @@ async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie:
324
412
  if (s.user.role !== 'admin') {
325
413
  const findings = new Map<string, ActivityContext>()
326
414
  const reports = kind === 'all' || kind === 'triage' ? await deps.db.listActivityReports(s.user.id) : []
415
+ // Every report's record in one read, as visibleFindingIds reads each one's.
416
+ const snapshot = reports.length > 0 ? await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), reports.map(report => report.reportId)) : { reports: [] }
417
+ if (snapshot == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
418
+ const records = new Map(snapshot.reports.map(record => [record.id, record]))
327
419
  for (const report of reports) {
328
- for (const finding of await visibleFindingIds(deps, s.user, report.reportId, s.session.id, null)) {
420
+ const record = records.get(report.reportId)
421
+ if (record == null) continue
422
+ for (const finding of await reportFindingIds(deps, record, record.repo.github)) {
329
423
  if (!findings.has(finding)) findings.set(finding, { finding, ...report })
330
424
  }
331
425
  }
@@ -349,10 +443,16 @@ export interface ManagedHttpDeps {
349
443
  originGate: OriginGate
350
444
  isShuttingDown: () => boolean
351
445
  track: (p: Promise<unknown>) => void
446
+ // Serverless (Vercel's): keeps the invocation alive for work that goes on
447
+ // past its answer, as a kept read read again behind it does.
448
+ waitUntil?: (p: Promise<unknown>) => void
352
449
  // Combined boot delegates unmatched paths to e2e and overrides discovery.
353
450
  serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
354
451
  next?: Handler
355
452
  serverInfo?: ServerInfo
453
+ // The reviewed dependencies the host registered, as the server was
454
+ // assembled (reviewed-dependencies.ts).
455
+ reviewedDependencies?: ReviewedDependencies
356
456
  }
357
457
 
358
458
  type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
@@ -367,8 +467,9 @@ function firstHeader(v: string | string[] | undefined): string | null {
367
467
  return null
368
468
  }
369
469
 
370
- async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId: string): Promise<void> {
371
- const avatar = await avatarStore.get(userId)
470
+ // `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
471
+ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id): Promise<void> {
472
+ const avatar = await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id))
372
473
  if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
373
474
  res.writeHead(200, {
374
475
  'content-type': avatar.contentType,
@@ -379,6 +480,26 @@ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId
379
480
  res.end(avatar.bytes)
380
481
  }
381
482
 
483
+ // The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
484
+ // alone: the agents' known by their GitHub Apps or accounts (APP_AGENTS),
485
+ // each its mark on the Agents tab, and those the app draws agents' accounts
486
+ // by (GITHUB_AGENTS). Each is fetched from GitHub once and
487
+ // kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
488
+ // an account's, `…-0001-<id>`.
489
+ const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
490
+ .flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
491
+ const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
492
+
493
+ async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
494
+ const [, kind, id] = AGENT_AVATAR.exec(avatar)!
495
+ const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
496
+ if (await avatarStore.get(key) == null) {
497
+ await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
498
+ .catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
499
+ }
500
+ await serveAvatar(res, avatarStore, key)
501
+ }
502
+
382
503
  const MAX_JSON_BODY_BYTES = 4096
383
504
 
384
505
  // Buffer the request body, aborting (and throwing 'too-large') once it exceeds
@@ -481,9 +602,10 @@ function sendViewOnly(res: ServerResponse): void {
481
602
  // Reads stay available while an admin views as another user. Everything else
482
603
  // is refused before dispatch, except ending the view or signing out, and the
483
604
  // batch report preview, a POST without effects. Issue authorization would
484
- // grant GitHub access on the viewed user's behalf.
605
+ // grant GitHub access on the viewed user's behalf, and linking an account
606
+ // would link it to the viewed user.
485
607
  function viewAllows(method: string, path: string): boolean {
486
- if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH
608
+ if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH && path !== LINK_LOGIN_PATH
487
609
  return (method === 'DELETE' && path === VIEW_AS_PATH) || (method === 'POST' && (path === LOGOUT_PATH || path === REPORT_QUERY_PATH))
488
610
  }
489
611
 
@@ -528,6 +650,65 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
528
650
  res.end()
529
651
  }
530
652
 
653
+ // GET /api/auth/profile — the user's profile (github-links.ts). POST chooses
654
+ // the accounts their displayed name and avatar come from. Workspace access is
655
+ // required: the accounts a user links are reserved for them.
656
+ async function handleProfile(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
657
+ const method = req.method ?? 'GET'
658
+ if (method === 'GET') {
659
+ const s = await readWorkspaceSession(res, deps, cookie)
660
+ if (s) await sendProfile(res, deps, s.user.id)
661
+ return
662
+ }
663
+ if (method !== 'POST') { send405(res, 'GET, POST'); return }
664
+ const s = await profileMutation(req, res, deps, cookie)
665
+ if (s == null) return
666
+ let body: unknown
667
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
668
+ const choice = parseProfileChoice(body)
669
+ if (choice == null) { sendJson(res, 400, { error: 'bad-request' }); return }
670
+ await deps.db.setProfileChoice(s.session.id, choice, Date.now())
671
+ await sendProfile(res, deps, s.user.id)
672
+ }
673
+
674
+ // DELETE /api/auth/profile/accounts/<id> — unlink one of the user's linked
675
+ // GitHub accounts, which may then sign in or register again.
676
+ async function handleUnlinkAccount(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
677
+ if ((req.method ?? 'GET') !== 'DELETE') { send405(res, 'DELETE'); return }
678
+ const s = await profileMutation(req, res, deps, cookie)
679
+ if (s == null) return
680
+ const link = await deps.db.unlinkGithubAccount(s.session.id, id, Date.now())
681
+ if (link == null) { sendJson(res, 404, { error: 'no-account' }); return }
682
+ await deps.avatarStore.delete(link.id).catch((err) => { console.warn('managed: linked avatar removal failed:', err) })
683
+ await activity(deps, s.user, 'access', `unlinked GitHub account @${link.login}`)
684
+ await sendProfile(res, deps, s.user.id)
685
+ }
686
+
687
+ async function profileMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
688
+ const s = await checkMutation(req, res, deps, cookie)
689
+ if (s != null && !roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
690
+ return s
691
+ }
692
+
693
+ async function sendProfile(res: ServerResponse, deps: ManagedHttpDeps, userId: string): Promise<void> {
694
+ const profile = await deps.db.getProfile(userId)
695
+ if (profile == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
696
+ sendJson(res, 200, { profile })
697
+ }
698
+
699
+ // { name?, avatar? }, each a linked account's id or null; at least one.
700
+ function parseProfileChoice(body: unknown): ProfileChoice | null {
701
+ if (body == null || typeof body !== 'object' || Array.isArray(body)) return null
702
+ const choice: ProfileChoice = {}
703
+ for (const key of ['name', 'avatar'] as const) {
704
+ if (!Object.hasOwn(body, key)) continue
705
+ const value = (body as Record<string, unknown>)[key]
706
+ if (value !== null && (typeof value !== 'string' || !isBlobId(value))) return null
707
+ choice[key] = value
708
+ }
709
+ return Object.keys(choice).length > 0 ? choice : null
710
+ }
711
+
531
712
  // POST /api/admin/set-role — an admin sets ANOTHER user's role. Admin-only, and
532
713
  // refused for the caller's own id so an admin can't drop their own admin (keeps
533
714
  // at least one admin).
@@ -587,16 +768,19 @@ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps,
587
768
  // Who am I? While an admin views as another user, that user, plus the admin
588
769
  // as `viewer`. A view that has ended (with the admin's session or role, or the
589
770
  // viewed account) is cleared, returning to the own session. Null signed out.
771
+ // `dependencyReview` where they publish dependency reviews to some team.
590
772
  async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: string | undefined): Promise<{ body: ManagedSessionInfo | null; headers: Record<string, string> }> {
591
773
  const now = Date.now()
592
774
  let s = await readSession(config, db, cookie, now)
593
775
  const ended = s == null && viewToken(config, cookie) != null
594
776
  if (ended) s = await readSession(config, db, cookie, now, { own: true })
777
+ const reviews = s != null && (await db.listDependencyReviewTeams(s.user.id)).length > 0
595
778
  return {
596
779
  body: s && {
597
780
  user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
598
781
  csrfToken: s.session.csrfToken,
599
782
  ...(s.session.viewer ? { viewer: s.session.viewer } : {}),
783
+ ...(reviews ? { dependencyReview: true as const } : {}),
600
784
  },
601
785
  headers: ended ? { 'set-cookie': clearViewCookie(config) } : {},
602
786
  }
@@ -644,9 +828,13 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
644
828
  let tokenMissing = false
645
829
  let repositories
646
830
  if (scope === 'connected') {
831
+ const reviews = await deps.db.listRepoDependencyReviews()
832
+ const reviewTeams = await deps.db.listRepoReviewTeams()
647
833
  repositories = allRows.map((r) => ({
648
834
  id: r.repoId, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl,
649
835
  installed: r.installationId != null, selected: r.active, active: r.active,
836
+ dependencyReview: reviews.get(r.repoId) ?? { enabled: false, required: 1, since: null, lockfiles: EVERY_LOCKFILE },
837
+ reviewTeams: reviewTeams.get(r.repoId) ?? [],
650
838
  }))
651
839
  } else {
652
840
  const showAll = scope === 'installed' && url.searchParams.get('showAll') === 'true'
@@ -676,6 +864,7 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
676
864
  inactiveCount: allRows.filter((r) => !r.active).length,
677
865
  total,
678
866
  tokenMissing,
867
+ ...deps.reviewedDependencies ? { reviewedDependencies: true } : {},
679
868
  })
680
869
  }
681
870
 
@@ -810,6 +999,39 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
810
999
  await selectRepository(res, deps, s.user, repoId, cookie)
811
1000
  }
812
1001
 
1002
+ // POST /api/admin/repositories/dependency-review — a repository's dependency
1003
+ // review (repository-dependency-reviews.ts): `enabled`, the published reviews
1004
+ // each version its pull requests' lockfiles lock needs (`required`, 1 to
1005
+ // MAX_REQUIRED_REVIEWS), and the commit whose lockfiles' versions need none
1006
+ // (`since`, a full commit id, or null; where the host registered reviewed
1007
+ // dependencies, which replace it, none, `400 since-unsupported`, the one
1008
+ // stored kept as it is). Answers the setting then. Admin, CSRF.
1009
+ async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1010
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
1011
+ const s = await adminMutation(req, res, deps, cookie)
1012
+ if (s == null) return
1013
+ let body: Record<string, unknown> | null
1014
+ try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1015
+ const repoId = body?.['repoId']
1016
+ const enabled = body?.['enabled']
1017
+ const required = body?.['required']
1018
+ const since = body?.['since'] ?? null
1019
+ // The lockfiles it holds to, kept as they are where none is sent.
1020
+ const lockfiles = body?.['lockfiles'] === undefined ? undefined : scopeOf(body['lockfiles'])
1021
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof enabled !== 'boolean'
1022
+ || typeof required !== 'number' || !Number.isSafeInteger(required) || required < 1 || required > MAX_REQUIRED_REVIEWS
1023
+ || (since !== null && (typeof since !== 'string' || !SINCE_COMMIT.test(since))) || lockfiles === null) {
1024
+ sendJson(res, 400, { error: 'bad-request' }); return
1025
+ }
1026
+ if (since !== null && deps.reviewedDependencies) { sendJson(res, 400, { error: 'since-unsupported' }); return }
1027
+ const repo = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
1028
+ const setting = repo ? await deps.db.setRepoDependencyReview(repoId, { enabled, required, ...deps.reviewedDependencies ? {} : { since: since as string | null },
1029
+ ...lockfiles ? { lockfiles } : {} }) : null
1030
+ if (!repo || !setting) { sendJson(res, 404, { error: 'no-repository' }); return }
1031
+ await activity(deps, s.user, 'repository', enabled ? 'enabled dependency review' : 'disabled dependency review', { repo: repo.fullName })
1032
+ sendJson(res, 200, { ok: true, dependencyReview: setting })
1033
+ }
1034
+
813
1035
  async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
814
1036
  const ids = new Set<string>()
815
1037
  for (const report of reports) {
@@ -1249,8 +1471,10 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
1249
1471
 
1250
1472
  // Listing connected repository names uses managed access only. Verify GitHub
1251
1473
  // access after selection, before returning any refs or source directory data.
1474
+ // Any team member (view or above) lists those their teams reach, for the code
1475
+ // viewer as for Create a bundle, whose builds stay admin|manage.
1252
1476
  async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1253
- const s = await readManageSession(res, deps, cookie)
1477
+ const s = await readWorkspaceSession(res, deps, cookie)
1254
1478
  if (!s) return
1255
1479
  sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
1256
1480
  }
@@ -1289,25 +1513,54 @@ function scopeRepositoryContents(contents: RepositoryContents | null, virtualEnt
1289
1513
  } : {}) }
1290
1514
  }
1291
1515
 
1292
- // Recheck repository/path grants after upstream reads, before returning names.
1293
- async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
1516
+ // A browse's repository, revision and directory, as its query names them;
1517
+ // null, answered, where it names none.
1518
+ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
1294
1519
  const repoId = Number(query.get('repoId'))
1295
1520
  const normalized = normalizeTeamPath(query.get('path') ?? '')
1296
1521
  const ref = query.get('ref') ?? ''
1297
1522
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
1298
- sendJson(res, 400, { error: 'bad-request' }); return
1523
+ sendJson(res, 400, { error: 'bad-request' }); return null
1299
1524
  }
1525
+ return { repoId, ref, path: normalized.path ?? '' }
1526
+ }
1527
+
1528
+ // The repository `repoId` as the reader may browse it at `path`: one of their
1529
+ // bundle repositories, and what their grants show of that directory
1530
+ // (`virtualEntries`, null where they reach all of it: scopedDirectory). Null,
1531
+ // answered, where it is none of theirs. Every team member (view or above)
1532
+ // reads the repositories their teams reach; the reader then checks their own
1533
+ // GitHub read permission (repository-browser.ts), on each read.
1534
+ async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
1535
+ const s = await readWorkspaceSession(res, deps, cookie)
1536
+ if (!s) return null
1537
+ const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
1538
+ if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1539
+ const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1540
+ return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
1541
+ }
1542
+
1543
+ // The repository as it was authorized, but for the shared default branch hint,
1544
+ // which a read may refresh meanwhile: that changes none of its identity,
1545
+ // credentials or managed grants.
1546
+ const sameRepository = (current: SelectedRepo, authorized: SelectedRepo) =>
1547
+ JSON.stringify({ ...current, cachedDefaultBranch: authorized.cachedDefaultBranch }) === JSON.stringify(authorized)
1548
+
1549
+ function sendGithubError(res: ServerResponse, err: GithubApiError) {
1550
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
1551
+ }
1552
+
1553
+ // Recheck repository/path grants after upstream reads, before returning names.
1554
+ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
1555
+ const asked = repositoryQuery(res, query)
1556
+ if (!asked) return
1557
+ const { repoId, ref } = asked
1300
1558
  const withDefault = refs && query.get('withDefault') === 'true'
1301
- const path = refs ? '' : normalized.path ?? ''
1559
+ const path = refs ? '' : asked.path
1302
1560
  const authorize = async () => {
1303
- const s = await readManageSession(res, deps, cookie)
1304
- if (!s) return null
1305
- const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
1306
- if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1307
- const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1308
- const virtualEntries = scopedDirectory(path, scopes)
1309
- if ((!refs || withDefault) && virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1310
- return { repo, virtualEntries, user: s.user }
1561
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1562
+ if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1563
+ return access
1311
1564
  }
1312
1565
  const access = await authorize()
1313
1566
  if (!access) return
@@ -1329,23 +1582,783 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1329
1582
  await reader.recheckAccess()
1330
1583
  const current = await authorize()
1331
1584
  if (!current) return
1332
- // This request (or another reader) may have refreshed the shared hint. It
1333
- // does not change repository identity, credentials, or managed grants.
1334
- if (JSON.stringify({ ...current.repo, cachedDefaultBranch: access.repo.cachedDefaultBranch }) !== JSON.stringify(access.repo)) {
1335
- sendJson(res, 409, { error: 'repository-changed' }); return
1336
- }
1337
- sendJson(res, 200, refs ? { ...listed, ...(withDefault ? {
1585
+ // This request (or another reader) may have refreshed the shared hint.
1586
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1587
+ sendJson(res, 200, refs ? { ...listed, tagCommits, ...(withDefault ? {
1338
1588
  defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
1339
1589
  } : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
1340
1590
  } catch (err) {
1341
- if (err instanceof GithubApiError) {
1342
- sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
1591
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1592
+ throw err
1593
+ }
1594
+ }
1595
+
1596
+ // GET /api/admin/repositories/code: the files of a directory of a repository
1597
+ // at a revision, for the code viewer (repository-code.ts), read as browsing
1598
+ // it is, with the reader's own GitHub access, and only where their grants
1599
+ // reach the whole directory: one they reach only the ancestor of is none to
1600
+ // read. Its tree's listing says first whether its files fit, before its
1601
+ // tarball is asked for; the reader's access is checked again once it is
1602
+ // read, before any of it is sent. The load stays held until this response is
1603
+ // written or abandoned, and a reader who stops reading it gives it up after
1604
+ // a while.
1605
+ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1606
+ const asked = repositoryQuery(res, query)
1607
+ if (!asked) return
1608
+ const { repoId, ref, path } = asked
1609
+ const authorize = async () => {
1610
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1611
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1612
+ return access
1613
+ }
1614
+ const access = await authorize()
1615
+ if (!access) return
1616
+ try {
1617
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1618
+ const commit = await reader.commit(ref)
1619
+ const repo = access.repo.fullName
1620
+ const client = createClient({ token: reader.readToken() })
1621
+ // A directory the commit has none at is none to read, as browsing it says.
1622
+ const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
1623
+ throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
1624
+ }))
1625
+ // Access still held, and the same repository, before anything read is sent.
1626
+ const fenced = async () => {
1627
+ await reader.recheckAccess()
1628
+ const current = await authorize()
1629
+ if (!current) return false
1630
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
1631
+ return true
1632
+ }
1633
+ // Too large names the commit it was read at, for History to read.
1634
+ if (!repositoryCodeFits(await repositoryTreeFiles(repo, reader.readToken(), tree))) {
1635
+ if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
1343
1636
  return
1344
1637
  }
1638
+ if (res.destroyed) return
1639
+ const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
1640
+ res.once('close', load.release)
1641
+ const bytes = await load.result
1642
+ if (!(await fenced()) || res.destroyed) return
1643
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
1644
+ res.writeHead(200, {
1645
+ 'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
1646
+ 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
1647
+ })
1648
+ writeResponse(res, bytes)
1649
+ } catch (err) {
1650
+ if (res.headersSent || res.destroyed) return
1651
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1652
+ if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
1653
+ throw err
1654
+ }
1655
+ }
1656
+
1657
+ // GET /api/admin/repositories/history: what the code viewer's History tab
1658
+ // draws (repository-history.ts), read as browsing is, with the reader's own
1659
+ // GitHub access, within their grants as the code is. From a revision (`ref`),
1660
+ // a page at a time (`page`), the commits touching `path` where it names a
1661
+ // directory, with the branches and tags naming commits on the first page; or,
1662
+ // with `all`, across the repository's most recently updated branches, which
1663
+ // needs a GitHub token. A whole repository's history, from a revision or all
1664
+ // branches, is read from commits a page each, kept by the commit each starts
1665
+ // at, and those kept glued on (historyFrom); a later page reads on from the
1666
+ // commits `from` names. The branches and tags naming commits are kept as an
1667
+ // issue's page is (keptAnswer). Access is checked again once it is read,
1668
+ // before any of it is sent, with the app's users its authors are
1669
+ // (github-users.ts).
1670
+ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1671
+ const asked = repositoryQuery(res, query)
1672
+ if (!asked) return
1673
+ const { repoId, ref, path } = asked
1674
+ const page = Number(query.get('page') ?? '1')
1675
+ const all = query.get('all') === 'true'
1676
+ // Every branch's heads read now, not as kept: the client asks so where
1677
+ // they miss the commit shown, as a branch moved since may.
1678
+ const fresh = query.get('fresh') === 'true'
1679
+ // A whole repository's later pages read on from where those before stop:
1680
+ // all branches', always; a revision's, where the client names them.
1681
+ const from = query.get('from')
1682
+ const starts = from === null ? null : historyStarts(from)
1683
+ if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
1684
+ || (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
1685
+ const authorize = async () => {
1686
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1687
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1688
+ return access
1689
+ }
1690
+ const access = await authorize()
1691
+ if (!access) return
1692
+ let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
1693
+ try {
1694
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1695
+ const repo = access.repo.fullName
1696
+ const token = reader.readToken()
1697
+ // With a token, as GraphQL reads them.
1698
+ const pages = historyPages(deps, repoId, token !== null)
1699
+ const quiet = <T>(fallback: T) => (err: unknown) => {
1700
+ if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
1701
+ return fallback
1702
+ }
1703
+ // Open pull heads do not depend on the commit history. Start them with
1704
+ // it, handling failures immediately even if the history fails first.
1705
+ if (token && path === '' && page === 1) {
1706
+ readingPulls = keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null))
1707
+ }
1708
+ let body: object
1709
+ if (all) {
1710
+ if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
1711
+ // What a later page reads on from, the client knows; whether one may
1712
+ // follow, the deepest page says.
1713
+ // Asked for afresh, those kept answered as just read: how long till
1714
+ // they may be read again (`retryIn`), for the client to ask then.
1715
+ const heads = starts ? null : await keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token), undefined, fresh)
1716
+ const retryIn = heads && fresh && heads.kept ? heads.at + REPOSITORY_READ_FLOOR_MS - Date.now() : 0
1717
+ body = starts ? { all: true, page, commits: (await historyFrom(repo, token, starts, pages)).commits, more: page < MAX_HISTORY_PAGE }
1718
+ : { all: true, ...await repositoryAllHistory(repo, token, heads!.value, pages), ...retryIn > 0 ? { retryIn } : {} }
1719
+ } else {
1720
+ const commit = await reader.commit(ref)
1721
+ // The branches and tags naming commits: with a token, every branch All
1722
+ // branches reads and the latest tags, kept for both; without, GitHub's
1723
+ // first page of each.
1724
+ const naming = () => token
1725
+ ? keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token)).then(({ value }) => ({
1726
+ branches: value.heads.map(({ name, sha }) => ({ name, sha })), tags: value.tags }))
1727
+ : keptAnswer(deps, `${repoId}\0refs`, () => repositoryRefTips(repo, token)).then(answer => answer.value)
1728
+ const tips = page === 1 ? naming() : null
1729
+ // A whole repository's from its commit, or on from those `from` names
1730
+ // (a page by its number where none are, as GitHub lists them); a
1731
+ // directory's by its number.
1732
+ const history = path !== '' || (page > 1 && !starts) ? repositoryHistory(repo, token, commit, path, page)
1733
+ : historyFrom(repo, token, starts ?? [commit], pages).then(read => ({ commits: read.commits, more: (starts !== null || read.more) && page < MAX_HISTORY_PAGE }))
1734
+ const [read, named] = await Promise.all([history, tips])
1735
+ body = { commit, path, page, ...read, ...named }
1736
+ }
1737
+ // The pull requests squashed into a whole repository's commits, drawn as
1738
+ // the merges they were, and on its first page the open ones' heads,
1739
+ // naming commits: with a token, as far as GitHub answers them, the
1740
+ // history sent all the same where it doesn't.
1741
+ const listed = (body as { commits: HistoryCommit[] }).commits
1742
+ const [pulls, openPulls] = token && path === '' ? await Promise.all([
1743
+ squashedPulls(repo, token, listed, historySquashes(deps, repoId)).catch(quiet<SquashedPull[]>([])),
1744
+ readingPulls,
1745
+ ]) : [[], null]
1746
+ await reader.recheckAccess()
1747
+ const current = await authorize()
1748
+ if (!current) return
1749
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1750
+ // Each commit as the tab draws it, its co-authors with it: what Pulse
1751
+ // counts of it (its lines, its author's time) not sent.
1752
+ const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
1753
+ ({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
1754
+ sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
1755
+ ...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...openPulls?.length ? { openPulls } : {} }))
1756
+ } catch (err) {
1757
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1758
+ throw err
1759
+ } finally {
1760
+ // A failed history must not release the request's database lease while
1761
+ // its independent pull read is still writing to the cache.
1762
+ await readingPulls
1763
+ }
1764
+ }
1765
+
1766
+ // GET /api/admin/repositories/changes: the files each commit `shas` names
1767
+ // changes, as the code viewer's Replay plays them (repository-changes.ts),
1768
+ // within `path`, each commit's kept whole by the commit (historyChanges);
1769
+ // read as Replay's reads are (readForReplay).
1770
+ async function handleRepositoryChanges(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1771
+ const shas = changesAsked(query.get('shas'))
1772
+ if (!shas) { sendJson(res, 400, { error: 'bad-request' }); return }
1773
+ await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
1774
+ const changes = await repositoryChanges(fullName, token, shas, historyChanges(deps, repoId))
1775
+ return { path, changes: [...changes].map(([sha, read]) => read ? { sha, ...changesWithin(read, path) } : { sha, files: null, more: false }) }
1776
+ })
1777
+ }
1778
+
1779
+ // GET /api/admin/repositories/tree: the files commit `commit` holds within
1780
+ // `path` and their bytes (`sizes`), which Replay starts from and the Treemap
1781
+ // of a directory too large to open draws (repository-changes.ts
1782
+ // directoryFiles: the directory's own tree where the commit's is cut short),
1783
+ // kept whole by the commit and the rules it is read by (historyTreeStore,
1784
+ // TREES_READ); none (null) for one GitHub has none of. Read as Replay's
1785
+ // reads are (readForReplay).
1786
+ async function handleRepositoryTree(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1787
+ const commit = query.get('commit') ?? ''
1788
+ if (!/^[a-f\d]{40}$/u.test(commit)) { sendJson(res, 400, { error: 'bad-request' }); return }
1789
+ const store = historyTreeStore<CommitTree>(deps.db, deps.config.debug)
1790
+ await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
1791
+ const kept = { read: async (sha: string) => {
1792
+ const value = (await store.read(`${repoId}\0${TREES_READ}\0${sha}`))?.value
1793
+ return value && Array.isArray(value.files) && Array.isArray(value.sizes) && value.sizes.length === value.files.length ? value : null
1794
+ }, write: (sha: string, tree: CommitTree) => store.write(`${repoId}\0${TREES_READ}\0${sha}`, tree, Date.now()) }
1795
+ const tree = await directoryFiles(fullName, token, commit, path, kept)
1796
+ return { commit, path, ...tree ?? { files: null, sizes: null, cut: false } }
1797
+ })
1798
+ }
1799
+
1800
+ // What Replay reads of repository `repoId` within `path` (`read`, with its
1801
+ // name and the reader's token), as the history is read: with the reader's
1802
+ // own GitHub access, within their grants as the code is, a directory they
1803
+ // reach all of, and sent once access is checked again.
1804
+ async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams,
1805
+ read: (fullName: string, token: string | null, repoId: number, path: string) => Promise<object>): Promise<void> {
1806
+ const asked = repositoryQuery(res, query)
1807
+ if (!asked) return
1808
+ const { repoId, path } = asked
1809
+ const authorize = async () => {
1810
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1811
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1812
+ return access
1813
+ }
1814
+ const access = await authorize()
1815
+ if (!access) return
1816
+ try {
1817
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1818
+ const body = await read(access.repo.fullName, reader.readToken(), repoId, path)
1819
+ await reader.recheckAccess()
1820
+ const current = await authorize()
1821
+ if (!current) return
1822
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1823
+ sendJson(res, 200, body)
1824
+ } catch (err) {
1825
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1826
+ throw err
1827
+ }
1828
+ }
1829
+
1830
+ // The files repository `repoId`'s commits change, kept by each commit
1831
+ // (historyChangeStore, repository-changes.ts repositoryChanges).
1832
+ function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
1833
+ const store = historyChangeStore<CommitChanges>(deps.db, deps.config.debug)
1834
+ const keyOf = (sha: string) => `${repoId}\0${sha}`
1835
+ return {
1836
+ async read(shas) {
1837
+ const kept = await store.readMany(shas.map(keyOf))
1838
+ return new Map(shas.flatMap(sha => {
1839
+ const value = kept.get(keyOf(sha))?.value
1840
+ return value && Array.isArray(value.files) ? [[sha, value] as const] : []
1841
+ }))
1842
+ },
1843
+ write: (sha, changes) => store.write(keyOf(sha), changes, Date.now()),
1844
+ }
1845
+ }
1846
+
1847
+ // How long what readWholeRepository read is answered as kept, unread again;
1848
+ // and how long one read is answered as kept even where it is asked for afresh.
1849
+ const REPOSITORY_READ_FRESH_MS = 2 * 60_000
1850
+ const REPOSITORY_READ_FLOOR_MS = 10_000
1851
+ // The repositories' reads being read again, behind a kept answer or as asked
1852
+ // for afresh, by key: one at a time, those asking meanwhile answered by it.
1853
+ const repositoryRereads = new Map<string, Promise<unknown>>()
1854
+ // A read GitHub refused, not failed: its credential or the GitHub App's
1855
+ // permission for it withdrawn (Issues, Pull requests, Checks), the
1856
+ // repository or what was asked of it gone. What was kept of it goes too.
1857
+ const refusedRead = (err: unknown) => err instanceof GithubApiError
1858
+ && (err.status === 401 || err.status === 403 || err.status === 404 || err.message === 'github-status-403')
1859
+
1860
+ // What `read` answers of a repository, kept by `key` (its id first) for every
1861
+ // reader (repositoryReadStore), each one's access checked as they ask before
1862
+ // this: answered as kept for REPOSITORY_READ_FRESH_MS, then, while it is
1863
+ // kept, answered at once and read again behind it for the next to ask, the
1864
+ // read tracked (serverless, its invocation kept alive till it settles,
1865
+ // deps.waitUntil); `kept` where it was, or where reading it again
1866
+ // failed as `keepThrough` lets it. One asked for afresh (`fresh`) is read
1867
+ // now, unless it was read REPOSITORY_READ_FLOOR_MS since, what was kept
1868
+ // answered where that read fails. One GitHub refuses to read again is no
1869
+ // longer kept: the next to ask is refused as GitHub refused it. `at` is when
1870
+ // what it answers was read.
1871
+ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>,
1872
+ keepThrough: (err: unknown) => boolean = () => false, fresh = false): Promise<{ value: T; kept: boolean; at: number }> {
1873
+ const store = repositoryReadStore(deps.db, deps.config.debug)
1874
+ // One kept longer than a day, not yet removed (pruneKept removes them as
1875
+ // others are written), is none.
1876
+ const stored = await store.read(key)
1877
+ const held = stored && Date.now() - stored.at < REPOSITORY_READ_KEPT_MS ? stored : null
1878
+ // Read, and kept as read when it was, which it is said read at.
1879
+ const readKept = async () => {
1880
+ try {
1881
+ const value = await read()
1882
+ const at = Date.now()
1883
+ await store.write(key, value, at)
1884
+ return { value, at }
1885
+ } catch (err) {
1886
+ if (refusedRead(err)) await store.remove(key)
1887
+ throw err
1888
+ }
1889
+ }
1890
+ const reread = () => {
1891
+ let reading = repositoryRereads.get(key)
1892
+ if (!reading) {
1893
+ reading = readKept().finally(() => repositoryRereads.delete(key))
1894
+ repositoryRereads.set(key, reading)
1895
+ }
1896
+ return reading as Promise<{ value: T; at: number }>
1897
+ }
1898
+ const age = held ? Date.now() - held.at : Infinity
1899
+ const now = fresh && age >= REPOSITORY_READ_FLOOR_MS
1900
+ if (held && !now) {
1901
+ if (age >= REPOSITORY_READ_FRESH_MS && !repositoryRereads.has(key)) {
1902
+ // Its failure leaves the kept answer sent until it is a day old, so it is said.
1903
+ const behind = reread().catch((err: unknown) => { console.warn('managed: repository read failed:', err) })
1904
+ deps.track(behind)
1905
+ deps.waitUntil?.(behind)
1906
+ }
1907
+ return { value: held.value as T, kept: true, at: held.at }
1908
+ }
1909
+ try {
1910
+ return { ...now ? await reread() : await readKept(), kept: false }
1911
+ } catch (err) {
1912
+ if (held && ((now && !refusedRead(err)) || keepThrough(err))) return { value: held.value as T, kept: true, at: held.at }
1345
1913
  throw err
1346
1914
  }
1347
1915
  }
1348
1916
 
1917
+ // The pages of repository `repoId`'s history kept (historyPageStore), each by
1918
+ // the commit it starts at (repository-history.ts historyFrom) and the rules
1919
+ // its co-authors were read by (commit-coauthors.ts COAUTHORS_READ), those
1920
+ // telling each commit's co-authors (kept since they are read), and with
1921
+ // `graphql`, only those read with GraphQL, telling its lines and the accounts
1922
+ // its co-authors' addresses are (a page GitHub's REST API listed tells
1923
+ // neither); the rest read again.
1924
+ function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): HistoryPages {
1925
+ const store = historyPageStore<HistoryCommit[]>(deps.db, deps.config.debug)
1926
+ const keyOf = (top: string) => `${repoId}\0${COAUTHORS_READ}\0${top}`
1927
+ const told = (tops: string[], kept: Map<string, { value: HistoryCommit[] }>) => new Map(tops.flatMap(top => {
1928
+ const page = kept.get(keyOf(top))?.value
1929
+ return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
1930
+ ? [[top, page] as const] : []
1931
+ }))
1932
+ return {
1933
+ read: async tops => told(tops, await store.readMany(tops.map(keyOf))),
1934
+ async readFirst(first, then) {
1935
+ const kept = await store.readFirst(first.map(keyOf), then.map(keyOf))
1936
+ return { first: kept.first, pages: told(kept.first ? first : then, kept.found) }
1937
+ },
1938
+ write: (top, commits) => store.write(keyOf(top), commits, Date.now()),
1939
+ }
1940
+ }
1941
+
1942
+ // The pull requests squashed into repository `repoId`'s commits, kept by
1943
+ // each commit and the rules their commits' co-authors were read by
1944
+ // (COAUTHORS_READ), null for one none was (repository-history.ts
1945
+ // squashedPulls); one kept before its commits' co-authors were read, read
1946
+ // again.
1947
+ function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
1948
+ const store = historySquashStore<SquashedPull | null>(deps.db, deps.config.debug)
1949
+ const keyOf = (sha: string) => `${repoId}\0${COAUTHORS_READ}\0${sha}`
1950
+ return {
1951
+ async read(shas) {
1952
+ const kept = await store.readMany(shas.map(keyOf))
1953
+ return new Map(shas.flatMap(sha => {
1954
+ const entry = kept.get(keyOf(sha))
1955
+ return entry && (entry.value === null || (Array.isArray(entry.value.commits) && entry.value.commits.every(commit => Array.isArray(commit.coauthors))))
1956
+ ? [[sha, entry.value] as const] : []
1957
+ }))
1958
+ },
1959
+ write: (sha, pull) => store.write(keyOf(sha), pull, Date.now()),
1960
+ }
1961
+ }
1962
+
1963
+ // What the whole of repository `repoId` gives in GraphQL (`read`, with its
1964
+ // name and a token), read as browsing is, with the reader's own GitHub
1965
+ // access: only by a reader whose grants reach all of it, with a token, and
1966
+ // sent once access is checked again after it is read; what `key` names kept
1967
+ // (keptAnswer), sent as kept, its reader's access checked as they ask; with
1968
+ // `readAt`, when it was read from GitHub, one kept as old as it was kept, so
1969
+ // that a reader holding two reads of the same can tell the later.
1970
+ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
1971
+ read: (fullName: string, token: string) => Promise<object>, key: string | null = null, readAt = false): Promise<void> {
1972
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
1973
+ const { value, kept, at } = key === null ? { value: await read(fullName, token), kept: false, at: Date.now() }
1974
+ : await keptAnswer(deps, `${repoId}\0${key}`, () => read(fullName, token))
1975
+ return { value: readAt ? { ...value, readAt: at } : value, kept }
1976
+ })
1977
+ }
1978
+
1979
+ // What `answer` gives of the whole of repository `repoId` (with its name and
1980
+ // a token), as readWholeRepository reads it: sent at once where it was kept,
1981
+ // else once access is checked again, with the app's users it names
1982
+ // (github-users.ts), never kept with it; or what `unread` tells of it, the
1983
+ // app's alone, once the app's access is checked, GitHub then never asked.
1984
+ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
1985
+ answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
1986
+ unread: () => Promise<object | null> = () => Promise.resolve(null)): Promise<void> {
1987
+ const authorize = async () => {
1988
+ const access = await authorizeRepository(res, deps, cookie, repoId, '')
1989
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1990
+ return access
1991
+ }
1992
+ const access = await authorize()
1993
+ if (!access) return
1994
+ const told = await unread()
1995
+ if (told) { sendJson(res, 200, await withAppUsers(deps.db, told)); return }
1996
+ try {
1997
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1998
+ const token = reader.readToken()
1999
+ if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
2000
+ const answered = await answer(access.repo.fullName, token, access.user)
2001
+ if (answered.kept) { sendJson(res, 200, await withAppUsers(deps.db, answered.value)); return }
2002
+ await reader.recheckAccess()
2003
+ const current = await authorize()
2004
+ if (!current) return
2005
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
2006
+ sendJson(res, 200, await withAppUsers(deps.db, answered.value))
2007
+ } catch (err) {
2008
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
2009
+ throw err
2010
+ }
2011
+ }
2012
+
2013
+ // GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
2014
+ // requests tabs show (repository-issues.ts): `kind`'s open or closed
2015
+ // (`state`), a page at a time, on from the cursor `after` names. They are
2016
+ // the whole repository's (readWholeRepository).
2017
+ async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2018
+ const repoId = Number(query.get('repoId'))
2019
+ const kind = query.get('kind')
2020
+ const state = query.get('state') ?? 'open'
2021
+ const after = query.get('after')
2022
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
2023
+ || (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
2024
+ await readWholeRepository(res, deps, cookie, repoId, async (fullName, token) => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }),
2025
+ `issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`, true)
2026
+ }
2027
+
2028
+ // GET /api/admin/repositories/agents: what the code viewer's Agents tab
2029
+ // reconstructs agents' sessions from (repository-agents.ts): a page
2030
+ // of pull requests on from the cursor `pulls` names, and of the default
2031
+ // branch's commits on from `commits`, the first page, naming neither, with
2032
+ // issues and branches. They are the whole repository's (readWholeRepository).
2033
+ async function handleRepositoryAgents(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2034
+ const repoId = Number(query.get('repoId'))
2035
+ const cursors = { pulls: query.get('pulls'), commits: query.get('commits') }
2036
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || (cursors.pulls !== null && !ISSUES_CURSOR.test(cursors.pulls))
2037
+ || (cursors.commits !== null && !COMMITS_CURSOR.test(cursors.commits))) {
2038
+ sendJson(res, 400, { error: 'bad-request' }); return
2039
+ }
2040
+ await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryAgents(fullName, token, cursors),
2041
+ `agents\0${AGENTS_READ_VERSION}\0${cursors.pulls ?? ''}\0${cursors.commits ?? ''}`)
2042
+ }
2043
+
2044
+ // GET /api/admin/repositories/item: the issue or pull request numbered
2045
+ // `number`, as the code viewer's popup for it shows it (repository-issues.ts
2046
+ // repositoryItem). It is the whole repository's (readWholeRepository).
2047
+ async function handleRepositoryItem(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2048
+ const repoId = Number(query.get('repoId'))
2049
+ const number = itemNumber(query)
2050
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null) { sendJson(res, 400, { error: 'bad-request' }); return }
2051
+ await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryItem(fullName, token, number), `item\0${ISSUES_READ_VERSION}\0${number}`, true)
2052
+ }
2053
+
2054
+ // The issue's or pull request's number a request names, as GraphQL takes one
2055
+ // (an Int, at most 2³¹ − 1); null for none.
2056
+ function itemNumber(query: URLSearchParams): number | null {
2057
+ const number = Number(query.get('number'))
2058
+ return /^[1-9]\d{0,9}$/u.test(query.get('number') ?? '') && number <= 2 ** 31 - 1 ? number : null
2059
+ }
2060
+
2061
+ // GET /api/admin/repositories/pull-dependencies: the package versions pull
2062
+ // request `number`'s lockfiles add (pull-dependencies.ts), what its base's and head's
2063
+ // commits tell kept by them (pullDependencyKept), its `number` with them.
2064
+ // They are the whole repository's (answerWholeRepository), the commits they
2065
+ // are of kept by its number as its popup's item is, its head moving with each
2066
+ // push, what they tell kept by them alone, not again. With `review=1`,
2067
+ // as an open one's are asked, where the repository reviews dependencies,
2068
+ // each version's review (pull-dependency-reviews.ts), read afresh as each
2069
+ // asks, reviews being published meanwhile; its baseline kept, where the host
2070
+ // registered no reviewed dependencies, which are asked in its place.
2071
+ // Up to MAX_ASKED_PULLS pull requests asked together (`numbers=1,2,3`) are
2072
+ // answered together, as many read at once as PULLS_AT_ONCE, `{ pulls }`,
2073
+ // each's answer, else `{ number, error }` where GitHub fails it, with one
2074
+ // check of its access before and one after, but where each was kept.
2075
+ const MAX_ASKED_PULLS = 20
2076
+ const PULLS_AT_ONCE = 3
2077
+ async function handleRepositoryPullDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2078
+ const repoId = Number(query.get('repoId'))
2079
+ const review = query.get('review') === '1'
2080
+ const listed = query.get('numbers')
2081
+ const numbers = listed === null ? [itemNumber(query)] : listed.split(',').map(text => itemNumber(new URLSearchParams({ number: text })))
2082
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || numbers.length > MAX_ASKED_PULLS || numbers.some(number => number === null) || new Set(numbers).size < numbers.length
2083
+ || !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
2084
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2085
+ if (listed === null) return pullDependencyAnswer(deps, fullName, token, user, repoId, numbers[0]!, review)
2086
+ const answers = await eachOf(numbers as number[], PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, review).catch((err: unknown) => {
2087
+ // GitHub asked, though it failed: its access asked again after.
2088
+ if (err instanceof GithubApiError) return { value: { number, error: err.message }, kept: false }
2089
+ throw err
2090
+ }))
2091
+ return { value: { pulls: answers.map(({ value }) => value) }, kept: answers.every(({ kept }) => kept) }
2092
+ })
2093
+ }
2094
+
2095
+ // Pull request `number`'s answer, as the endpoint gives it, and whether all
2096
+ // of it was kept, GitHub asked for none of it.
2097
+ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, number: number, review: boolean) {
2098
+ const kept = pullDependencyKept(deps, repoId)
2099
+ const reading: { told?: PullDependencies } = {}
2100
+ const answered = await keptAnswer(deps, `${repoId}\0pull-commits\0${PULL_DEPENDENCIES_READ}\0${number}`, async () => {
2101
+ const { commits, told } = await pullDependenciesAt(fullName, token, number, kept)
2102
+ reading.told = told
2103
+ return { commits }
2104
+ })
2105
+ // What its commits tell, as read, else as kept by them, else, no longer
2106
+ // kept, read again.
2107
+ const held = reading.told ?? (typeof answered.value.commits === 'string' ? await kept.read(answered.value.commits) : null)
2108
+ const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept)).told }
2109
+ // Where its review is on, those of lockfiles it leaves out told to none,
2110
+ // whoever asks.
2111
+ const setting = await deps.db.repoDependencyReview(repoId)
2112
+ const scoped = setting?.enabled ? heldDependencies(pulled, setting.lockfiles) : pulled
2113
+ const now = review ? await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(scoped)) : null
2114
+ // Its review by the lockfiles they were told by, though they have changed
2115
+ // since; turned off meanwhile, all of them, as where it is off.
2116
+ const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
2117
+ const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
2118
+ const reviewed = deps.reviewedDependencies ?? null
2119
+ const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
2120
+ // Its comments read for reviews, where the host reads them, while it is
2121
+ // open (pull-comment-reviews.ts reads whether it is with them).
2122
+ const parse = reviewed?.parseComments
2123
+ const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
2124
+ // The comment the host formats of the reader's reviews of its versions, for its copy button.
2125
+ const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
2126
+ return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
2127
+ ...formatted ? { formatted } : {} }, kept: answered.kept && held !== null }
2128
+
2129
+ }
2130
+
2131
+ // GET /api/admin/repositories/branch-dependencies: what the lockfiles of the
2132
+ // repository's default branch lock that wants review, where its review is in
2133
+ // effect for the reader (`review`, else none, told without asking GitHub
2134
+ // where it reviews none), as a team's Dependencies tab shows it: its head, as
2135
+ // its pulse reads it, kept a while; every lockfile there its review holds to
2136
+ // (lockfile-scope.ts, commit-lockfiles.ts) as one directory, its versions as
2137
+ // a pull request's others are (pull-dependency-reviews.ts), each left unread
2138
+ // a directory of its own failing the review, as a pull request's are. Kept,
2139
+ // its access not asked again, where its head was kept and its lockfiles and
2140
+ // baseline held, GitHub asked for none of them.
2141
+ async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2142
+ const repoId = Number(query.get('repoId'))
2143
+ if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2144
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2145
+ const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2146
+ const head = headRead.value.sha
2147
+ if (!head) return { value: { head: null, review: null }, kept: false }
2148
+ const asked: GithubAsked = { github: !headRead.kept }
2149
+ // Those its review holds to alone read, as many as are read of them.
2150
+ const setting = await deps.db.repoDependencyReview(repoId)
2151
+ if (!setting?.enabled) return { value: { head, review: null }, kept: false }
2152
+ const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path), asked)
2153
+ const { packages, paths } = lockedIn(read)
2154
+ const told = { base: head, head, cut: false, lockfiles: [{ paths, added: [], more: 0, locked: packages.slice(0, MAX_LOCKED), moreLocked: Math.max(0, packages.length - MAX_LOCKED) },
2155
+ ...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
2156
+ const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
2157
+ if (!now) return { value: { head, review: null }, kept: !asked.github }
2158
+ // Told by the lockfiles it held to as they were read, though they have
2159
+ // changed since.
2160
+ const state = { ...now, lockfiles: setting.lockfiles }
2161
+ const reviewed = deps.reviewedDependencies ?? null
2162
+ const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
2163
+ : await baselineFor(fullName, token, repoId, state, baselineKept(deps), asked)
2164
+ return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: !asked.github }
2165
+ }, async () => (await deps.db.repoDependencyReview(repoId))?.enabled ? null : { review: null })
2166
+ }
2167
+
2168
+ // GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
2169
+ // default branch its dependency review reads (commit-lockfiles.ts), by their
2170
+ // paths, with their formats, at its head, as its pulse reads it, for its
2171
+ // settings to name those it holds to (lockfile-scope.ts).
2172
+ async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2173
+ const repoId = Number(query.get('repoId'))
2174
+ if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2175
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2176
+ const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
2177
+ return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head)).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
2178
+ })
2179
+ }
2180
+
2181
+ // The app's users `accounts` are, by their logins in lower case, of those
2182
+ // who publish reviews to a team counting for repository `repoId`.
2183
+ async function repoReviewers(deps: ManagedHttpDeps, repoId: number, accounts: { login: string; githubId: number | null }[]) {
2184
+ const users = await deps.db.listUsersByAccount(accounts)
2185
+ const counted = await deps.db.listRepoReviewers(repoId, [...new Set(Object.values(users).map(user => user.id))])
2186
+ return new Map(Object.entries(users).flatMap(([login, user]) => counted.has(user.id) ? [[login.toLowerCase(), user.id] as const] : []))
2187
+ }
2188
+
2189
+ // The versions repository lockfiles locked at the commits their dependencies
2190
+ // are reviewed since, kept by each (reviewBaselineStore).
2191
+ function baselineKept(deps: ManagedHttpDeps): ReviewBaselineStore {
2192
+ const store = reviewBaselineStore(deps.db, deps.config.debug)
2193
+ return {
2194
+ async read(key) {
2195
+ const value = (await store.read(key))?.value
2196
+ const strings = (list: unknown) => Array.isArray(list) && list.every(item => typeof item === 'string')
2197
+ return strings(value?.versions) && strings(value?.unread) ? value : null
2198
+ },
2199
+ write: (key, baseline) => store.write(key, baseline, Date.now()),
2200
+ }
2201
+ }
2202
+
2203
+ // What repository `repoId`'s pull requests' lockfiles add, kept by the
2204
+ // commits of each's base and head and the rules they were told by
2205
+ // (PULL_DEPENDENCIES_READ, pullDependencyStore).
2206
+ function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependencyStore {
2207
+ const store = pullDependencyStore<PullDependencies>(deps.db, deps.config.debug)
2208
+ const keyOf = (commits: string) => `${repoId}\0${PULL_DEPENDENCIES_READ}\0${commits}`
2209
+ return {
2210
+ async read(commits) {
2211
+ const value = (await store.read(keyOf(commits)))?.value
2212
+ return value && Array.isArray(value.lockfiles) ? value : null
2213
+ },
2214
+ write: (commits, told) => store.write(keyOf(commits), told, Date.now()),
2215
+ }
2216
+ }
2217
+
2218
+ // GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
2219
+ // (repository-pulse.ts): the default branch's year, counted from its commits
2220
+ // (pulseCommits), and the pull requests and issues of the last `weeks`
2221
+ // weeks, each kept apart. A year not read whole yet answers how many of its
2222
+ // commits are (`reading`), or of the pull requests merged in it (`pulls`);
2223
+ // one GitHub's statistics count, while it is still computing them,
2224
+ // `pending`, for the client to ask again. They are the whole repository's
2225
+ // (readWholeRepository).
2226
+ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2227
+ const repoId = Number(query.get('repoId'))
2228
+ const weeks = Number(query.get('weeks') ?? '4')
2229
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks)) { sendJson(res, 400, { error: 'bad-request' }); return }
2230
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2231
+ const [period, year] = await Promise.all([
2232
+ keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => repositoryActivity(fullName, token, weeks)),
2233
+ pulseCommits(deps, repoId, fullName, token),
2234
+ ])
2235
+ return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
2236
+ pending: year.pending }, kept: period.kept && year.kept }
2237
+ })
2238
+ }
2239
+
2240
+ // The default branch's year (repository-pulse.ts commitStats), counted from
2241
+ // its commits as the Commits tab keeps them, with their lines
2242
+ // (yearCommits): kept by the head it was read from (repositoryReadStore),
2243
+ // that head kept as an issue's page is; its commits merged from an agent's
2244
+ // pull request told by those merged (agentMerges), kept as read, the rest
2245
+ // read on by the next answer as the year's commits are, and read again as
2246
+ // an answer kept is, a label changing whose a pull request is without
2247
+ // moving the head, the year counted again where what they bring changed.
2248
+ // A year not read whole yet is how many of its commits are (`reading`), or
2249
+ // of the pull requests merged (`pulls`), beside the year last counted from
2250
+ // an earlier head over the same weeks, where one is kept, so that a head
2251
+ // moved on shows the year as it was till it is counted again; one past
2252
+ // YEAR_COMMITS is GitHub's statistics' (repositoryStats, repositoryLines),
2253
+ // kept together as an issue's page is and through GitHub computing them
2254
+ // again, `pending` while it first computes them, the year last counted
2255
+ // beside it so too.
2256
+ // A day as a pulse's items are narrowed by (YYYY-MM-DD, UTC), when it starts;
2257
+ // a GitHub login, as many of them as PULSE_AUTHORS.
2258
+ const PULSE_DAY = /^\d{4}-\d{2}-\d{2}$/u
2259
+ const GITHUB_LOGIN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?:\[bot\])?$/iu
2260
+ const PULSE_AUTHORS = 10
2261
+ const pulseDay = (value: string | null) => {
2262
+ const at = value !== null && PULSE_DAY.test(value) ? Date.parse(`${value}T00:00:00Z`) : Number.NaN
2263
+ return Number.isSafeInteger(at) ? at : null
2264
+ }
2265
+
2266
+ // GET /api/admin/repositories/pulse/items: the Pulse tab's pull requests and
2267
+ // issues narrowed to a picked day or week (`from` to `to`, else on from
2268
+ // `from`) and the GitHub accounts a picked contributor committed as
2269
+ // (`authors`), as repositoryActivity reads them, kept apart for each.
2270
+ async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2271
+ const repoId = Number(query.get('repoId'))
2272
+ const weeks = Number(query.get('weeks') ?? '4')
2273
+ const [from, to] = [pulseDay(query.get('from')), query.has('to') ? pulseDay(query.get('to')) : null]
2274
+ const logins = (query.get('authors') ?? '').split(',').filter(Boolean)
2275
+ // Each once, whatever its case, as GitHub matches logins.
2276
+ const authors = [...new Set(logins.map(login => login.toLowerCase()))].toSorted()
2277
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks) || from === null || (query.has('to') && (to === null || to < from))
2278
+ || authors.length > PULSE_AUTHORS || !logins.every(login => GITHUB_LOGIN.test(login))) { sendJson(res, 400, { error: 'bad-request' }); return }
2279
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2280
+ const narrowing = { from, to, authors }
2281
+ const items = await keptAnswer(deps, `${repoId}\0pulse-items\0${weeks}\0${from}\0${to ?? ''}\0${narrowing.authors.join(',')}`,
2282
+ () => repositoryActivity(fullName, token, weeks, Date.now(), globalThis.fetch, narrowing))
2283
+ return { value: { weeks, ...items.value }, kept: items.kept }
2284
+ })
2285
+ }
2286
+
2287
+ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
2288
+ const first = pulseSince(52, Date.now())
2289
+ const head = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2290
+ if (!head.value.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
2291
+ const store = repositoryReadStore(deps.db, deps.config.debug)
2292
+ const key = `${repoId}\0pulse-year\0${PULSE_YEAR_VERSION}\0${head.value.sha}\0${first}`
2293
+ const lastKey = `${repoId}\0pulse-year-last`
2294
+ const mergesKey = `${repoId}\0pulse-merges\0${PULSE_YEAR_VERSION}`
2295
+ const last = async () => {
2296
+ const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
2297
+ return kept?.first === first ? kept.stats : null
2298
+ }
2299
+ const sha = head.value.sha
2300
+ // What tells whose the year's commits are, kept as read: the agents' pull
2301
+ // requests merged (agentMerges), and the commits that release the
2302
+ // repository (`releases`, release-commits.ts releaseCommits).
2303
+ type Attribution = AgentMerges & { releases?: string[] }
2304
+ const keptAttribution = async () => ((await store.read(mergesKey))?.value as Attribution | undefined) ?? null
2305
+ const attribution = async (kept: Attribution | null): Promise<Attribution> => {
2306
+ const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept), releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
2307
+ return { ...merges, releases }
2308
+ }
2309
+ // The year counted from its `commits` as `read` tells whose they are and
2310
+ // which were pushed directly, the branch followed through those it was
2311
+ // read on past (`passed`, yearCommits) too; kept by its head and as the
2312
+ // year last counted.
2313
+ const count = async ({ commits, passed }: { commits: HistoryCommit[]; passed: HistoryCommit[] }, read: Attribution) => {
2314
+ const walked = [...commits, ...passed]
2315
+ const stats = commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha))
2316
+ await Promise.all([store.write(key, stats, Date.now()), store.write(lastKey, { first, stats }, Date.now())])
2317
+ return stats
2318
+ }
2319
+ // A year kept by its head, what tells whose its commits are read again as
2320
+ // an answer kept is (keptAnswer), a label or a tag changing it without
2321
+ // moving the head: counted again from its pages kept where what agents'
2322
+ // pull requests bring, or the releases, changed, or a read of the pull
2323
+ // requests left unfinished is finished; the year as kept answered
2324
+ // meanwhile, and where reading them fails.
2325
+ const relabelled = async (stats: PulseStats) => {
2326
+ const recounted: { stats?: PulseStats } = {}
2327
+ await keptAnswer(deps, mergesKey, async () => {
2328
+ const kept = await keptAttribution()
2329
+ const read = await attribution(kept)
2330
+ const same = kept && !kept.next && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join()
2331
+ if (read.next || same) return read
2332
+ const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
2333
+ if (year.complete) recounted.stats = await count(year, read)
2334
+ return read
2335
+ }, () => true).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
2336
+ return recounted.stats ?? stats
2337
+ }
2338
+ const held = (await store.read(key))?.value as PulseStats | { tooMany: true } | undefined
2339
+ if (held && !('tooMany' in held)) return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
2340
+ if (!held) {
2341
+ const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
2342
+ if (year.complete) {
2343
+ const read = await attribution(await keptAttribution())
2344
+ await store.write(mergesKey, read, Date.now())
2345
+ if (read.next) return { stats: await last(), reading: null, pulls: read.next.read, pending: false, kept: false }
2346
+ return { stats: await count(year, read), reading: null, pulls: null, pending: false, kept: false }
2347
+ }
2348
+ if (!year.tooMany) return { stats: await last(), reading: year.read, pulls: null, pending: false, kept: false }
2349
+ await store.write(key, { tooMany: true }, Date.now())
2350
+ }
2351
+ // Its commits and lines read together, kept as one: never the one GitHub
2352
+ // has counted since a push beside the other kept from before it.
2353
+ const computing = (err: unknown) => err instanceof StatsPending
2354
+ const counted = await keptAnswer(deps, `${repoId}\0pulse-stats`, async () => {
2355
+ const [stats, lines] = await Promise.all([repositoryStats(fullName, token), repositoryLines(fullName, token, first)])
2356
+ return withLines(stats, lines)
2357
+ }, computing).catch((err: unknown) => { if (computing(err)) return null; throw err })
2358
+ if (counted) return { stats: counted.value, reading: null, pulls: null, pending: false, kept: counted.kept }
2359
+ return { stats: await last(), reading: null, pulls: null, pending: true, kept: false }
2360
+ }
2361
+
1349
2362
  function prebuildBundle(deps: ManagedHttpDeps, id: string) {
1350
2363
  if (!deps.bundleCache || deps.config.serverless) return
1351
2364
  deps.track((async () => {
@@ -1420,9 +2433,13 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
1420
2433
  // (repository-browser.ts), and that access checked again once it is read,
1421
2434
  // before any of it is told; packages as the npm viewer's admins read them, in
1422
2435
  // spare assessments (npm-risk.ts), trusted however new where a team lists
1423
- // their scope, and each lockfile's asked of npm's advisories at once. The
1424
- // reader leaving, losing their admin role or the server shutting down stops
1425
- // it.
2436
+ // their scope, and each lockfile's asked of npm's advisories at once; each
2437
+ // repository's Dependabot alerts, open or dismissed, read with the App's
2438
+ // installation token where the admin's own access to it lets them see them on
2439
+ // GitHub (dependabot-access.ts); and its GitHub Actions, the references they
2440
+ // make resolved with the admin's own GitHub token (github-actions.ts). The
2441
+ // reader leaving, losing their admin role or
2442
+ // the server shutting down stops it.
1426
2443
  async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1427
2444
  const s = await readAdminSession(res, deps, cookie)
1428
2445
  if (!s) return
@@ -1435,7 +2452,8 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1435
2452
  const controller = new AbortController()
1436
2453
  res.on('close', () => { controller.abort() })
1437
2454
  res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
1438
- const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
2455
+ const user = await repositoryBrowserUser(deps, s.user.id)
2456
+ const browser = createRepositoryBrowser(deps.config, user)
1439
2457
  const scopes = new Map<string, Promise<boolean>>()
1440
2458
  const teamScoped = (name: string) => {
1441
2459
  const scope = npmPackageScope(name)
@@ -1460,8 +2478,15 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1460
2478
  lockfiles: async () => (await upstreamGithub(() => client.findRepoLockfiles({ repo: repo.fullName, sha: commit }))).lockfiles,
1461
2479
  file: path => upstreamGithub(() => client.getRepoFile({ repo: repo.fullName, path, ref: commit })),
1462
2480
  recheck: async () => { await reader.recheckAccess() },
2481
+ // As GitHub has them now, where the admin's own access lets them see them.
2482
+ dependabot: () => reader.dependabotAlerts(),
2483
+ workflows: () => reader.directory(WORKFLOWS_DIR, commit).then(({ entries }) => entries.filter(entry => entry.type === 'file' && /\.ya?ml$/iu.test(entry.name)).map(entry => entry.path),
2484
+ (error: unknown) => { if (error instanceof GithubApiError && error.status === 404) return []; throw error }),
1463
2485
  }
1464
2486
  },
2487
+ actions: githubActionsSource(user.token),
2488
+ actionFiles: scanReadStore(deps.db, 'action-files', SCAN_READ_KEPT_MS, deps.config.debug),
2489
+ actionLines: scanReadStore(deps.db, 'actions', RISK_KEPT_MS, deps.config.debug),
1465
2490
  teamScoped,
1466
2491
  // Each package's downloads readied before it is assessed
1467
2492
  // (scan-downloads.ts), so that none waits on npm's downloads API; a
@@ -2244,7 +3269,7 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
2244
3269
  if (outputBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
2245
3270
  parts.push(suffix)
2246
3271
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
2247
- writeResponse(res, Buffer.concat(parts, outputBytes))
3272
+ writeResponseParts(res, parts, outputBytes)
2248
3273
  }
2249
3274
 
2250
3275
  // The server selects the complete published workspace for classification.
@@ -2274,24 +3299,23 @@ async function visibleFindingIds(deps: ManagedHttpDeps, user: StoredUser, report
2274
3299
  }
2275
3300
  const rec = await deps.db.getReport(reportId)
2276
3301
  if (rec == null) return new Set()
2277
- const repo = { github: await repositoryName(deps, rec.repoId) }
2278
- const key = JSON.stringify([reportId, rec.sha256, rec.filename, repo])
3302
+ return reportFindingIds(deps, rec, await repositoryName(deps, rec.repoId))
3303
+ }
3304
+ // A whole report's finding ids, kept by its content, name and repository,
3305
+ // the one read least recently dropped first.
3306
+ async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256: string; filename: string }, github: string | null): Promise<Set<string>> {
3307
+ const key = JSON.stringify([rec.id, rec.sha256, rec.filename, { github }])
2279
3308
  let cache = visibleIdsCaches.get(deps)
2280
3309
  if (cache == null) { cache = new Map(); visibleIdsCaches.set(deps, cache) }
2281
3310
  const hit = cache.get(key)
2282
- if (hit != null) return hit
2283
- const ids = new Set<string>()
2284
- const bytes = await deps.reportStore.get(reportId)
2285
- if (bytes == null) return ids
2286
- const text = bytes.toString('utf8')
2287
- const { data } = readManagedReport(text, rec.filename)
2288
- if (data == null) return ids
2289
- const report = await loadManagedFindings(JSON.stringify(data), rec.filename)
2290
- if (report == null) return ids
2291
- for (const f of report.findings) {
2292
- const id = (f as { id?: unknown }).id
2293
- if (typeof id === 'string' && id !== '') ids.add(id)
2294
- }
3311
+ if (hit != null) { cache.delete(key); cache.set(key, hit); return hit }
3312
+ const bytes = await deps.reportStore.get(rec.id)
3313
+ if (bytes == null) return new Set()
3314
+ const { data } = readManagedReport(bytes.toString('utf8'), rec.filename)
3315
+ if (data == null) return new Set()
3316
+ // The ids the team workspace derives (teamFindingIds), from the one parse.
3317
+ const ids = await reportIds(data)
3318
+ ids.delete('')
2295
3319
  while (cache.size >= VISIBLE_IDS_CACHE_MAX) {
2296
3320
  const oldest = cache.keys().next().value
2297
3321
  if (oldest === undefined) break
@@ -2702,8 +3726,12 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
2702
3726
  // Null for none.
2703
3727
  async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
2704
3728
  const s = await readSession(deps.config, deps.db, cookie, Date.now())
2705
- if (s == null || !roleAtLeast(s.user.role, 'view')) return null
2706
- return { role: s.user.role, scopes: new Set(npmScopesCount(s.user.role, names) ? await deps.db.listUserNpmScopes(s.user.id) : []), userId: s.user.id }
3729
+ return s == null ? null : npmReaderOf(deps, s.user, names)
3730
+ }
3731
+ // The same of `user`, as a session the request read just now tells them.
3732
+ async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
3733
+ if (!roleAtLeast(user.role, 'view')) return null
3734
+ return { role: user.role, scopes: new Set(npmScopesCount(user.role, names) ? await deps.db.listUserNpmScopes(user.id) : []), userId: user.id }
2707
3735
  }
2708
3736
 
2709
3737
  // What a registry read found, or a 404 for the package (handleNpm's catch
@@ -2713,27 +3741,34 @@ function found<T>(value: T | null | undefined): T {
2713
3741
  return value
2714
3742
  }
2715
3743
 
3744
+ // Version documents a list of reviews reads at once: each holds a version
3745
+ // document's room in what npm's reads may hold at once (npm-packages.ts),
3746
+ // which refuses past thirty-two of them.
3747
+ const REVIEW_READS_AT_ONCE = 8
3748
+
2716
3749
  // Lists must enforce the same target and base access as the review itself,
2717
- // including permission changes while the registry requests are in flight.
2718
- async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[]): Promise<T[] | null> {
3750
+ // including permission changes while the registry requests are in flight;
3751
+ // first as `session`, the one the request read just now, where given.
3752
+ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[],
3753
+ session?: { id: string; role: NpmReader['role'] }): Promise<T[] | null> {
2719
3754
  const controller = new AbortController()
2720
3755
  const onClose = () => { if (!res.writableEnded) controller.abort() }
2721
3756
  res.on('close', onClose)
2722
3757
  try {
2723
- const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
3758
+ const names = reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []])
3759
+ const reader = session ? await npmReaderOf(deps, session, names) : await npmReader(deps, cookie, names)
2724
3760
  if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2725
- const documents = new Map<string, Promise<NpmVersionDocument | null>>()
2726
3761
  // Public versions' documents as the viewer keeps them (npm-kept.ts), a
2727
- // day: a list asks npm for none it read within it.
2728
- const kept = npmVersionStore(deps.db, deps.config.debug)
2729
- const read = (name: string, version: string) => {
2730
- const key = `${name}@${version}`
2731
- if (!documents.has(key)) documents.set(key, readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
2732
- return documents.get(key)!
2733
- }
2734
- const packages = await Promise.all(reviews.map(review => Promise.all([
2735
- read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
2736
- ])))
3762
+ // day, all read of the database at once: a list asks npm for none it read
3763
+ // within it. Each version once, as many at once as REVIEW_READS_AT_ONCE.
3764
+ const versionsOf = (review: T) => [[review.package, review.version], ...review.basePackage ? [[review.basePackage, review.baseVersion!]] : []] as [string, string][]
3765
+ const asked = new Map(reviews.flatMap(versionsOf).map(([name, version]) => [`${name}\0${version}`, [name, version] as const]))
3766
+ const store = npmVersionStore(deps.db, deps.config.debug)
3767
+ const held = await store.readMany?.([...asked.values()].map(([name, version]) => ({ name, version }))) ?? new Map()
3768
+ const kept: NpmVersionStore = { read: (name, version) => Promise.resolve(held.get(`${name}@${version}`) ?? null), write: store.write }
3769
+ const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
3770
+ const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
3771
+ const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
2737
3772
  const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
2738
3773
  if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2739
3774
  return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
@@ -2846,7 +3881,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2846
3881
  try {
2847
3882
  const reader = await npmReader(deps, cookie, [context.package, ...context.basePackage ? [context.basePackage] : []])
2848
3883
  if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2849
- const kept = npmVersionStore(deps.db, deps.config.debug)
3884
+ const kept = readTogether(npmVersionStore(deps.db, deps.config.debug), [{ name, version }, ...context.basePackage ? [{ name: context.basePackage, version: context.baseVersion! }] : []])
2850
3885
  const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
2851
3886
  const [doc, base] = await Promise.all([read(name, version), context.basePackage ? read(context.basePackage, context.baseVersion!) : null])
2852
3887
  if (!doc || (context.basePackage && !base)) { sendJson(res, 404, { error: 'package-not-found' }); return }
@@ -2881,8 +3916,8 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2881
3916
  if (!change) {
2882
3917
  const allBases = params.get('allBases') === 'true'
2883
3918
  const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
2884
- const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
2885
- if (reviews) sendJson(res, 200, { reviews, teams: await deps.db.listDependencyReviewTeams(current.userId) })
3919
+ const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows, { id: current.userId, role: current.role }) : rows
3920
+ if (reviews) sendJson(res, 200, { reviews: await withFormatted(deps.reviewedDependencies ?? null, current.userId, reviews), teams: await deps.db.listDependencyReviewTeams(current.userId) })
2886
3921
  return
2887
3922
  }
2888
3923
  const checked = await checkMutation(req, res, deps, cookie)
@@ -2892,7 +3927,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2892
3927
  sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
2893
3928
  }
2894
3929
  const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
2895
- sendJson(res, 200, { review })
3930
+ sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
2896
3931
  } catch (err) {
2897
3932
  if (controller.signal.aborted) return
2898
3933
  if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
@@ -2916,7 +3951,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2916
3951
  if (!isNpmPackageName(name) || !isNpmPackageName(otherName) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
2917
3952
  || (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
2918
3953
  || (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
2919
- || (path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
3954
+ || ((path === NPM_COMPARISON_PATH || path === NPM_DELTA_PATH) && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
2920
3955
  sendJson(res, 400, { error: 'bad-package' }); return
2921
3956
  }
2922
3957
  const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
@@ -2986,7 +4021,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2986
4021
  await reply(doc.private, { name, downloads, github })
2987
4022
  return
2988
4023
  }
2989
- const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptVersions))
4024
+ // The version it is compared with, where its route names one, read
4025
+ // beside it, the two read of those kept at once.
4026
+ const against: [string, string, boolean] | null = (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)') || (path === NPM_DELTA_PATH && otherSpec !== '(empty)')
4027
+ || ((path === NPM_TYPE_ONLY_PATH || path === NPM_RENAME_DIFF_PATH || (path === NPM_PRETTY_PATH && !params.has('path'))) && otherSpec !== '')
4028
+ ? [name, otherSpec, privileged]
4029
+ : path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' ? [otherName, otherSpec, canReadPrivateNpm(reader, otherName)] : null
4030
+ const keptTogether = against ? readTogether(keptVersions, [{ name, version: spec }, { name: against[0], version: against[1] }]) : keptVersions
4031
+ const otherAsked = against && readNpmVersion(against[0], against[1], against[2], controller.signal, keptTogether)
4032
+ otherAsked?.catch(() => {})
4033
+ const otherDoc = async () => found(await otherAsked!)
4034
+ const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptTogether))
2990
4035
  if (spec === 'latest') latest = Promise.resolve(doc)
2991
4036
  const repo = githubRepoOf(doc), sha = bundleCommitHash(doc.manifest['gitHead'])
2992
4037
  // What the viewer asks about a version beside its files, by its route,
@@ -3009,7 +4054,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3009
4054
  // new and however few download it where a team lists its scope, its own
3010
4055
  // packages, as a new dependency on a package of such a scope is.
3011
4056
  if (path === NPM_RISK_PATH) {
3012
- const old = otherSpec === '' || otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4057
+ const old = otherSpec === '' || otherSpec === '(empty)' ? null : await otherDoc()
3013
4058
  const scope = npmPackageScope(name)
3014
4059
  const teamScoped = scope !== null && await deps.db.hasTeamNpmScope(scope)
3015
4060
  const assessment = await npmRisk(doc, otherSpec === '' ? 'pick' : old, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped, fromEmpty: otherSpec === '(empty)',
@@ -3020,13 +4065,27 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3020
4065
  return
3021
4066
  }
3022
4067
  if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
4068
+ // The lines it changes against `otherVersion`, or `(empty)`, its whole
4069
+ // package's, and those but for declaration files changed in types alone
4070
+ // (npm-delta.ts), as a list of versions wanting reviews tells them.
4071
+ if (path === NPM_DELTA_PATH) {
4072
+ const old = otherSpec === '(empty)' ? null : await otherDoc()
4073
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
4074
+ const side = (version: NpmVersionDocument) => ({ integrity: version.dist.integrity, files: async () => {
4075
+ const load = loadNpmFiles(version)
4076
+ try { return await load.result } finally { load.release() }
4077
+ } })
4078
+ const delta = await npmDelta(old ? side(old) : null, side(doc), npmDeltaStore<NpmDelta>(deps.db, deps.config.debug))
4079
+ await reply(doc.private || old?.private === true, { name, version: doc.version, otherVersion: old?.version ?? null, ...delta })
4080
+ return
4081
+ }
3023
4082
  // Its files paired with those of `otherVersion` (of `otherName`, else the
3024
4083
  // same package), as Compare and Review pair them, with what their
3025
4084
  // Overview reads beside them (comparison.ts): the version shown against
3026
4085
  // the one compared with, or `(empty)`, none, as Review reads a first
3027
4086
  // version. Read of both, each package's access checked again once worked out.
3028
4087
  if (path === NPM_COMPARISON_PATH) {
3029
- const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(otherName, otherSpec, canReadPrivateNpm(reader, otherName), controller.signal, keptVersions))
4088
+ const old = otherSpec === '(empty)' ? null : await otherDoc()
3030
4089
  res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
3031
4090
  const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
3032
4091
  let body
@@ -3064,7 +4123,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3064
4123
  // and those that changed in types alone (type-only.ts).
3065
4124
  if (path === NPM_PRETTY_PATH || path === NPM_TYPE_ONLY_PATH || (path === NPM_RENAME_DIFF_PATH && !diffed)) {
3066
4125
  if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
3067
- const other = otherSpec === '' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4126
+ const other = otherSpec === '' ? null : await otherDoc()
3068
4127
  const sides = other && await deps.prettyCache.npmSides(doc, other)
3069
4128
  const lines = path === NPM_RENAME_DIFF_PATH ? renamedPairs(sides!, !doc.private && !other!.private)
3070
4129
  : path === NPM_TYPE_ONLY_PATH ? typeOnlyFiles(sides!) : sides ? compared(sides) : await deps.prettyCache.npmAll(doc)
@@ -3082,7 +4141,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3082
4141
  if (diffed) {
3083
4142
  const { prettyCache } = deps
3084
4143
  if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
3085
- const other = found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
4144
+ const other = await otherDoc()
3086
4145
  const diff = await renameDiffOf(diffed, async () => [await prettyText(await prettyCache.npm(doc, diffed.base)),
3087
4146
  await prettyText(await prettyCache.npm(other, diffed.other))], !doc.private && !other.private)
3088
4147
  if (!(await recheck(doc.private || other.private))) { diff.stream.destroy(); return }
@@ -3102,18 +4161,20 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3102
4161
  // viewer's own request to meet. Its whole document, read for its
3103
4162
  // advisories' version list and the time each version was published
3104
4163
  // (npmPublishTime), is read only where registry reads leave room for
3105
- // readers'.
3106
- if (!download) {
4164
+ // readers'; a public version's time is then kept in the database
4165
+ // (npm-kept.ts), to come with the version on any instance.
4166
+ const published = download ? undefined : npmPublishTime(doc, npmPublishTimeStore(deps.db, deps.config.debug))
4167
+ if (published) {
3107
4168
  const quiet = (asked: Promise<unknown>) => { asked.catch(() => {}) }
3108
4169
  quiet(npmDownloads(name))
3109
4170
  quiet(latestDoc().then(newest => newest && githubFigures(newest)))
3110
4171
  quiet(readNpmVersions(name, privileged, controller.signal, { times: true, spare: true })
3111
- .then(versions => versions && npmAdvisories(name, versions.versions, advisoryOptions)))
4172
+ .then(versions => versions && Promise.all([published.keep(), npmAdvisories(name, versions.versions, advisoryOptions)])))
3112
4173
  quiet(tagsOf())
3113
4174
  quiet(socketOf())
3114
4175
  quiet(provenanceOf())
3115
4176
  }
3116
- const load = download ? loadNpmTarball(doc) : loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha))
4177
+ const load = published ? loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha), published.read) : loadNpmTarball(doc)
3117
4178
  res.once('close', load.release)
3118
4179
  const bytes = await load.result
3119
4180
  if (!(await recheck(doc.private))) return
@@ -3207,6 +4268,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3207
4268
  if (path === CALLBACK_PATH) {
3208
4269
  if (method !== 'GET') { send405(res, 'GET'); return }
3209
4270
  try {
4271
+ if (isLinkOAuthCallback(config, url.searchParams, cookie)) {
4272
+ if (viewing) { sendViewOnly(res); return }
4273
+ const s = await readWorkspaceSession(res, deps, cookie)
4274
+ if (!s) return
4275
+ const result = await linkOAuthCallback(config, db, s.session, url.searchParams, cookie, { avatarStore })
4276
+ if (result.linked != null) await activity(deps, s.user, 'access', `linked GitHub account @${result.linked}`)
4277
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
4278
+ res.end(); return
4279
+ }
3210
4280
  if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
3211
4281
  if (viewing) { sendViewOnly(res); return }
3212
4282
  const s = await readWorkspaceSession(res, deps, cookie)
@@ -3234,6 +4304,17 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3234
4304
  res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
3235
4305
  res.end(); return
3236
4306
  }
4307
+ if (path === LINK_LOGIN_PATH) {
4308
+ if (method !== 'GET') { send405(res, 'GET'); return }
4309
+ const s = await readWorkspaceSession(res, deps, cookie)
4310
+ if (!s) return
4311
+ const result = linkLoginRedirect(config, s.session)
4312
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
4313
+ res.end(); return
4314
+ }
4315
+ if (path === PROFILE_PATH) { await handleProfile(req, res, deps, cookie); return }
4316
+ const unlink = /^\/api\/auth\/profile\/accounts\/([a-f\d-]{36})$/iu.exec(path)
4317
+ if (unlink) { await handleUnlinkAccount(req, res, deps, cookie, unlink[1]!); return }
3237
4318
  if (path === SESSION_PATH) {
3238
4319
  if (method !== 'GET') { send405(res, 'GET'); return }
3239
4320
  const { body, headers } = await currentSession(config, db, cookie)
@@ -3274,12 +4355,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3274
4355
  if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
3275
4356
  // Cached avatar by user id, served same-origin (the page CSP forbids the
3276
4357
  // github CDN). The id in the path keys the browser cache per user, so a user
3277
- // switch never serves a stale avatar. Workspace access is required.
4358
+ // switch never serves a stale avatar. Workspace access is required. A user's
4359
+ // is that of the account chosen on their profile; `own` asks for the
4360
+ // sign-in account's. A linked account's is served by its own id.
3278
4361
  if (path.startsWith(AVATAR_PREFIX)) {
3279
4362
  if (method !== 'GET') { send405(res, 'GET'); return }
3280
4363
  const s = await readSession(config, db, cookie, Date.now())
3281
4364
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3282
- await serveAvatar(res, avatarStore, path.slice(AVATAR_PREFIX.length))
4365
+ const id = path.slice(AVATAR_PREFIX.length)
4366
+ if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
4367
+ await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
3283
4368
  return
3284
4369
  }
3285
4370
  // Admin: list users (admin-only).
@@ -3320,8 +4405,57 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3320
4405
  if (method !== 'GET') { send405(res, 'GET'); return }
3321
4406
  await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
3322
4407
  }
4408
+ if (path === REPO_CODE_PATH) {
4409
+ if (method !== 'GET') { send405(res, 'GET'); return }
4410
+ await handleRepositoryCode(res, deps, cookie, url.searchParams); return
4411
+ }
4412
+ if (path === REPO_HISTORY_PATH) {
4413
+ if (method !== 'GET') { send405(res, 'GET'); return }
4414
+ await handleRepositoryHistory(res, deps, cookie, url.searchParams); return
4415
+ }
4416
+ if (path === REPO_CHANGES_PATH) {
4417
+ if (method !== 'GET') { send405(res, 'GET'); return }
4418
+ await handleRepositoryChanges(res, deps, cookie, url.searchParams); return
4419
+ }
4420
+ if (path === REPO_TREE_PATH) {
4421
+ if (method !== 'GET') { send405(res, 'GET'); return }
4422
+ await handleRepositoryTree(res, deps, cookie, url.searchParams); return
4423
+ }
4424
+ if (path === REPO_ISSUES_PATH) {
4425
+ if (method !== 'GET') { send405(res, 'GET'); return }
4426
+ await handleRepositoryIssues(res, deps, cookie, url.searchParams); return
4427
+ }
4428
+ if (path === REPO_AGENTS_PATH) {
4429
+ if (method !== 'GET') { send405(res, 'GET'); return }
4430
+ await handleRepositoryAgents(res, deps, cookie, url.searchParams); return
4431
+ }
4432
+ if (path === REPO_ITEM_PATH) {
4433
+ if (method !== 'GET') { send405(res, 'GET'); return }
4434
+ await handleRepositoryItem(res, deps, cookie, url.searchParams); return
4435
+ }
4436
+ if (path === REPO_PULL_DEPENDENCIES_PATH) {
4437
+ if (method !== 'GET') { send405(res, 'GET'); return }
4438
+ await handleRepositoryPullDependencies(res, deps, cookie, url.searchParams); return
4439
+ }
4440
+ if (path === REPO_BRANCH_DEPENDENCIES_PATH) {
4441
+ if (method !== 'GET') { send405(res, 'GET'); return }
4442
+ await handleRepositoryBranchDependencies(res, deps, cookie, url.searchParams); return
4443
+ }
4444
+ if (path === REPO_LOCKFILES_PATH) {
4445
+ if (method !== 'GET') { send405(res, 'GET'); return }
4446
+ await handleRepositoryLockfiles(res, deps, cookie, url.searchParams); return
4447
+ }
4448
+ if (path === REPO_PULSE_PATH) {
4449
+ if (method !== 'GET') { send405(res, 'GET'); return }
4450
+ await handleRepositoryPulse(res, deps, cookie, url.searchParams); return
4451
+ }
4452
+ if (path === REPO_PULSE_ITEMS_PATH) {
4453
+ if (method !== 'GET') { send405(res, 'GET'); return }
4454
+ await handleRepositoryPulseItems(res, deps, cookie, url.searchParams); return
4455
+ }
3323
4456
  if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
3324
4457
  if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
4458
+ if (path === REPO_DEPENDENCY_REVIEW_PATH) { await handleRepositoryDependencyReview(req, res, deps, cookie); return }
3325
4459
  if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
3326
4460
  if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
3327
4461
  // Reports: list / upload on the exact path, download / delete per-id on the
@@ -3512,8 +4646,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3512
4646
  sendJson(res, 400, { error: 'bad-review-context' }); return
3513
4647
  }
3514
4648
  const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
3515
- const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
3516
- if (visible) sendJson(res, 200, { review: visible[0] ?? null })
4649
+ const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [], s.user)
4650
+ if (visible) sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, s.user.id, visible))[0] ?? null })
3517
4651
  return
3518
4652
  }
3519
4653
  if (path === '/api/npm/reviews/discard') {
@@ -3542,8 +4676,28 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3542
4676
  sendJson(res, 400, { error: 'bad-review-cursor' }); return
3543
4677
  }
3544
4678
  const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
3545
- const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
3546
- if (reviews) sendJson(res, 200, { ...page, reviews })
4679
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
4680
+ if (reviews) sendJson(res, 200, { ...page, reviews: await withFormatted(deps.reviewedDependencies ?? null, s.user.id, reviews) })
4681
+ return
4682
+ }
4683
+ // Those published to a team, of one who reads them there, as its
4684
+ // Dependencies tab lists them, fifty a page.
4685
+ if (path === '/api/npm/reviews/team') {
4686
+ if (method !== 'GET') { send405(res, 'GET'); return }
4687
+ const s = await readWorkspaceSession(res, deps, cookie)
4688
+ if (!s) return
4689
+ const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
4690
+ if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
4691
+ if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
4692
+ sendJson(res, 400, { error: 'bad-review-cursor' }); return
4693
+ }
4694
+ const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
4695
+ if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
4696
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
4697
+ if (!reviews) return
4698
+ // Its access asked again, as it may have been taken while npm was read.
4699
+ if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
4700
+ sendJson(res, 200, { ...page, reviews })
3547
4701
  return
3548
4702
  }
3549
4703
  if (path === '/api/npm/reviews') {
@@ -3564,7 +4718,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3564
4718
  // Feeds lease a connection per poll, never across streaming sleeps. This
3565
4719
  // also covers capability-authenticated feeds handled by public-workspace.
3566
4720
  const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
3567
- const serve = () => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)
4721
+ const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
3568
4722
  const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
3569
4723
  if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
3570
4724
  console.warn('managed: request handler error:', err)