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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/common/bundle-sources.js +20 -1
  2. package/common/default-scan-models.ts +1 -1
  3. package/common/dependency-reviews.d.ts +0 -5
  4. package/common/dependency-reviews.js +0 -17
  5. package/common/managed/agent-tasks.ts +292 -0
  6. package/common/managed/github-agents.ts +92 -0
  7. package/common/managed/github-logins.ts +49 -0
  8. package/common/managed/roles.ts +7 -0
  9. package/common/managed/routes.js +88 -7
  10. package/common/managed/team-app.d.ts +5 -2
  11. package/common/managed/team-app.js +2 -2
  12. package/common/server-info.ts +3 -0
  13. package/common/workspace-app.js +6 -3
  14. package/out/brotli-fallback.js +3 -3
  15. package/out/chunk-2XUEZHIP.js +18 -18
  16. package/out/chunk-2ZDZWLFY.js +1 -0
  17. package/out/chunk-3PS4KWD2.js +10 -0
  18. package/out/chunk-5DHL5NG3.js +2 -0
  19. package/out/chunk-6HNXNIQY.js +52 -0
  20. package/out/chunk-7C7DWL7I.js +1 -0
  21. package/out/chunk-AFLZH7WG.js +1 -0
  22. package/out/chunk-BC2UCLFJ.js +1 -0
  23. package/out/chunk-C4Z6ZBXV.js +6 -6
  24. package/out/chunk-GDPCLSOS.js +2 -0
  25. package/out/chunk-GO7FT4OD.js +1 -0
  26. package/out/chunk-HMAJDVXN.js +1 -0
  27. package/out/chunk-I2MDGSQX.js +3 -0
  28. package/out/chunk-IYPY47MC.js +1 -0
  29. package/out/chunk-IZ7POWI3.js +18 -0
  30. package/out/chunk-JCIIBLKR.js +1 -1
  31. package/out/chunk-NPW52BM2.js +17 -0
  32. package/out/chunk-NRPC74AM.js +1 -1
  33. package/out/chunk-NV7ES4EW.js +1 -0
  34. package/out/chunk-OKRLLE5I.js +17 -0
  35. package/out/chunk-OOJM4CTU.js +1 -1
  36. package/out/chunk-PNHBZGDD.js +1 -1
  37. package/out/chunk-PTGDYLNS.js +36 -0
  38. package/out/chunk-PXK7EU4V.js +1 -0
  39. package/out/chunk-Q6MVYGYV.js +3 -0
  40. package/out/chunk-QB4CBOX2.js +1 -0
  41. package/out/chunk-QRD6GIG5.js +229 -0
  42. package/out/chunk-R3NZFXVR.js +1 -0
  43. package/out/chunk-R5O6FAW4.js +1 -1
  44. package/out/chunk-SXV2T5C4.js +80 -0
  45. package/out/chunk-TUII34K2.js +1 -1
  46. package/out/chunk-VFWMQ3LG.js +2 -0
  47. package/out/chunk-VH4XMZIW.js +2 -0
  48. package/out/chunk-WFYLMF6Z.js +10 -0
  49. package/out/chunk-WI7Z5A2T.js +6 -0
  50. package/out/chunk-WQ74ZBZF.js +7 -0
  51. package/out/chunk-XVBEOGOS.js +3 -0
  52. package/out/client-managed-import.js +3 -3
  53. package/out/client-managed.js +31 -35
  54. package/out/client-sync.js +11 -11
  55. package/out/compare.js +35 -0
  56. package/out/dialogs.js +51 -0
  57. package/out/github-issue-authorized.html +1 -1
  58. package/out/github-issue-authorized.js +1 -1
  59. package/out/graph.js +17 -17
  60. package/out/index.html +30 -34
  61. package/out/prism.js +14 -14
  62. package/out/scan.js +2 -0
  63. package/out/terminal.js +294 -293
  64. package/out/view.css +1 -1
  65. package/out/view.js +16 -107
  66. package/package.json +51 -6
  67. package/risk/actions/findings.js +156 -0
  68. package/risk/actions/resolve.js +219 -0
  69. package/risk/actions/scripts/commands.js +182 -0
  70. package/risk/actions/scripts/grade.js +113 -0
  71. package/risk/actions/scripts/packages.js +123 -0
  72. package/risk/actions/scripts/shell.js +165 -0
  73. package/risk/actions/uses.js +76 -0
  74. package/risk/actions/workflow.js +110 -0
  75. package/risk/files/hosts.js +6 -6
  76. package/risk/files/readability.js +23 -17
  77. package/risk/files/runs-code.js +18 -6
  78. package/risk/files/script.js +7 -3
  79. package/risk/files/stamps.js +8 -7
  80. package/risk/index.d.ts +68 -1
  81. package/risk/index.js +6 -2
  82. package/risk/npm/assess.js +1 -1
  83. package/risk/npm/compare.js +34 -10
  84. package/risk/npm/files.js +413 -2
  85. package/risk/npm/tarball.js +56 -73
  86. package/server-managed/agent-merges.ts +198 -0
  87. package/server-managed/app-agents.ts +30 -0
  88. package/server-managed/avatar-store.ts +1 -0
  89. package/server-managed/commit-coauthors.ts +83 -0
  90. package/server-managed/commit-lockfiles.ts +62 -0
  91. package/server-managed/db-methods.ts +29 -10
  92. package/server-managed/db-neon.ts +30 -2
  93. package/server-managed/db-schema.ts +9 -2
  94. package/server-managed/db.ts +7 -0
  95. package/server-managed/dependabot-access.ts +60 -0
  96. package/server-managed/dependabot-alerts.ts +185 -0
  97. package/server-managed/dependency-review-comments.ts +97 -0
  98. package/server-managed/dependency-reviews.ts +65 -15
  99. package/server-managed/github-accounts.ts +33 -0
  100. package/server-managed/github-actions.ts +71 -0
  101. package/server-managed/github-app.ts +23 -9
  102. package/server-managed/github-errors.ts +2 -0
  103. package/server-managed/github-link-oauth.ts +69 -0
  104. package/server-managed/github-links.ts +204 -0
  105. package/server-managed/github-oauth.ts +5 -2
  106. package/server-managed/github-users.ts +30 -0
  107. package/server-managed/http.ts +1122 -52
  108. package/server-managed/index.ts +6 -2
  109. package/server-managed/locked-packages.ts +37 -16
  110. package/server-managed/lockfile-scope.ts +49 -0
  111. package/server-managed/npm-delta-worker.js +23 -0
  112. package/server-managed/npm-delta.ts +173 -0
  113. package/server-managed/npm-insights.ts +20 -11
  114. package/server-managed/npm-kept.ts +51 -4
  115. package/server-managed/npm-loads.ts +20 -13
  116. package/server-managed/npm-packages.ts +24 -64
  117. package/server-managed/pull-comment-reviews.ts +161 -0
  118. package/server-managed/pull-dependencies.ts +280 -0
  119. package/server-managed/pull-dependency-reviews.ts +318 -0
  120. package/server-managed/pull-reviews.ts +205 -0
  121. package/server-managed/release-commits.ts +84 -0
  122. package/server-managed/repository-actions.ts +209 -0
  123. package/server-managed/repository-agents.ts +220 -0
  124. package/server-managed/repository-browser.ts +54 -13
  125. package/server-managed/repository-changes.ts +284 -0
  126. package/server-managed/repository-code.ts +52 -0
  127. package/server-managed/repository-dependency-reviews.ts +135 -0
  128. package/server-managed/repository-history.ts +445 -0
  129. package/server-managed/repository-issues.ts +181 -0
  130. package/server-managed/repository-pulse.ts +432 -0
  131. package/server-managed/repository-risk-read.ts +165 -0
  132. package/server-managed/repository-risk.ts +113 -105
  133. package/server-managed/reviewed-dependencies.ts +149 -0
  134. package/server-managed/session.ts +9 -5
  135. package/server-managed/sql.ts +1 -1
  136. package/server-managed/storage-stores.ts +5 -0
  137. package/server-managed/team-app.ts +19 -3
  138. package/server-managed/workspace-shares.ts +4 -1
  139. package/server.ts +11 -0
  140. package/out/chunk-2ABLKCOY.js +0 -46
  141. package/out/chunk-2KBF5TZ7.js +0 -2
  142. package/out/chunk-35VE6CDH.js +0 -2
  143. package/out/chunk-3BZ7JERB.js +0 -1
  144. package/out/chunk-4XCDFGDP.js +0 -10
  145. package/out/chunk-5LSXOLUS.js +0 -1
  146. package/out/chunk-5RCM2J63.js +0 -1
  147. package/out/chunk-7B6HB4D7.js +0 -1
  148. package/out/chunk-AJ4GTATZ.js +0 -3
  149. package/out/chunk-CH4UN6WK.js +0 -225
  150. package/out/chunk-CMQA4DM6.js +0 -1
  151. package/out/chunk-CVVJAXVY.js +0 -2
  152. package/out/chunk-DIJJNTRH.js +0 -10
  153. package/out/chunk-EDKLBQQN.js +0 -17
  154. package/out/chunk-F47DXCEV.js +0 -33
  155. package/out/chunk-FK3DZEKL.js +0 -1
  156. package/out/chunk-G6AJWENO.js +0 -1
  157. package/out/chunk-GJ4IUCNS.js +0 -1
  158. package/out/chunk-GNXOKIGP.js +0 -80
  159. package/out/chunk-GZ4MS3TE.js +0 -13
  160. package/out/chunk-GZWM7UQM.js +0 -2
  161. package/out/chunk-IU3CCINR.js +0 -3
  162. package/out/chunk-K3X5EJBQ.js +0 -2
  163. package/out/chunk-KEX2ATEW.js +0 -2
  164. package/out/chunk-KYCNJVB7.js +0 -1
  165. package/out/chunk-LPCTJYNA.js +0 -7
  166. package/out/chunk-O4RJY5TP.js +0 -2
  167. package/out/chunk-OD4LPN3H.js +0 -1
  168. package/out/chunk-QWDWTBM7.js +0 -1
  169. package/out/chunk-QYWKC557.js +0 -1
  170. package/out/chunk-R75XOKUP.js +0 -1
  171. package/out/chunk-SWTLADW5.js +0 -1
  172. package/out/chunk-T7OKH4VW.js +0 -1
  173. package/out/chunk-TZ5KB2Y5.js +0 -10
  174. package/out/chunk-UNTRGE26.js +0 -2
  175. package/out/chunk-UTR2LVME.js +0 -2
  176. package/out/chunk-UYAXVRVL.js +0 -1
  177. package/out/chunk-V3ME6FI5.js +0 -2
  178. package/out/chunk-V5O7M2YT.js +0 -1
  179. package/out/chunk-VIHGO77Y.js +0 -1
  180. package/out/chunk-W4J3MGLB.js +0 -1
  181. package/out/chunk-WGG7KQUH.js +0 -1
  182. package/out/chunk-XYFXXB2G.js +0 -10
  183. 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,48 @@
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 | 400/401/403/404/409/429/502
55
+ // GET /api/admin/repositories/branch-dependencies?repoId= → the versions the default branch's lockfiles lock that want review, where
56
+ // its review is in effect for the reader | 400/401/403/404/409/429/502
57
+ // GET /api/admin/repositories/lockfiles?repoId= → the lockfiles of the default branch its dependency review reads, by their
58
+ // paths, for its settings to name | 400/401/403/404/409/429/502
59
+ // GET /api/admin/repositories/pulse?repoId=&weeks=1|4|13|52 → the default branch's commits and lines a week and a day over
60
+ // the last year, its contributors and when in the period they commit, counted from its commits as Commits keeps them (how many are
61
+ // read while they are), and the pull requests
62
+ // merged and opened and the issues closed and opened in the last `weeks` weeks and the same days a period earlier | 400/401/403/404/409/429/502
63
+ // GET /api/admin/repositories/pulse/items?repoId=&weeks=&from=[&to=&authors=] → those pull requests and issues of the days
64
+ // `from` to `to` (YYYY-MM-DD, UTC; no `to`, on), and of any of `authors` (GitHub logins, comma-separated) | 400/401/403/404/409/429/502
65
+ // History, issues, item, agents and pulse each carry `users`: the app's users and agents the GitHub accounts they
66
+ // name are (github-users.ts), by login
28
67
  // POST /api/admin/repositories/remove → admin permanently removes a repo
29
68
  // GET /api/admin/repositories/risk[?team=|?repo=] → admin: each connected repo's npm lockfiles, or a team's or one repo's,
30
69
  // their packages and what risk tells of each, Brotli-encoded ndjson as each is found; the reader leaving stops it | 400/401/403/404
@@ -64,6 +103,8 @@
64
103
  // GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set | 400/401/403/404/409
65
104
  // POST /api/npm/reviews/discard → author discards an unpublished review and its file rows | 400/401/403/409
66
105
  // GET /api/npm/reviews/latest → latest own review for exact target bytes, across all bases | 400/401/403
106
+ // GET /api/npm/reviews/team?teamId=&beforeTime=&beforeId= → the reviews published to a team, the latest first, for one who
107
+ // reads them there | 400/401/404
67
108
  // GET /api/npm/package?name=&version= → a published version's manifest and files | 400/401/404/413/429/502
68
109
  // GET /api/npm/versions?name= → its dist-tags, versions, when each was published and its first was, and whether a team lists its scope | 400/401/404/429/502
69
110
  // GET /api/npm/download?name=&version= → its tarball | 400/401/404/413/429/502
@@ -82,6 +123,9 @@
82
123
  // Brotli-encoded ndjson | 400/401/404/413/429/502/503
83
124
  // GET /api/npm/type-only?name=&version=&otherVersion= → the files that changed between two versions in types alone,
84
125
  // Brotli-encoded ndjson | 400/401/404/413/429/502/503
126
+ // GET /api/npm/delta?name=&version=&otherVersion= → the lines a version adds and removes against another, or `(empty)`, and
127
+ // those but for declaration files changed in types alone, where any are, and whether binary files changed
128
+ // | 400/401/404/413/422/429/502/503/504
85
129
  // GET /api/npm/risk?name=&version=&otherVersion= → what calls for a look at a version, as risk tells it, against another where given | 400/401/404/413/429/502
86
130
  // POST /api/auth/logout → same-origin + CSRF, drops the session (and any view)
87
131
  // POST /api/auth/view-as → admin opens a read-only view as another user | 400/401/403/404
@@ -90,7 +134,9 @@ import { mergeLinkGroups } from './link-reports.ts'
90
134
  import { backfillFindingIds, reportEntries } from '@preventive/report'
91
135
  import type { IncomingMessage, ServerResponse } from 'node:http'
92
136
  import { pipeline } from 'node:stream/promises'
93
- import { npmVersionStore, riskLineStore, scanReadStore } from './npm-kept.ts'
137
+ import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmVersionStore, pullDependencyStore,
138
+ repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
139
+ import { type NpmDelta, npmDelta } from './npm-delta.ts'
94
140
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
95
141
  import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
96
142
  import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
@@ -115,6 +161,7 @@ import { isRole, roleAtLeast } from '../common/managed/roles.ts'
115
161
  import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
116
162
  import { filterReportData } from '../common/managed/report-filter.ts'
117
163
  import type { TriageEntryPatch } from '../common/managed/triage.ts'
164
+ import { GITHUB_AGENTS } from '../common/managed/github-agents.ts'
118
165
  import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
119
166
  import { reportRepoGithub } from '@preventive/report'
120
167
  import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
@@ -130,8 +177,10 @@ import type { ConnectedRepo } from './github-app.ts'
130
177
  import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
131
178
  import { RepositoryDiscovery } from './repository-discovery.ts'
132
179
  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'
180
+ import { WORKFLOWS_DIR } from './repository-actions.ts'
181
+ import { githubActionsSource } from './github-actions.ts'
182
+ import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
183
+ import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
135
184
  import { clearCookie, endSession, readSession } from './session.ts'
136
185
  import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
137
186
  import type { ActivityContext, ActivityInput } from './activity.ts'
@@ -143,6 +192,10 @@ import { lookupIssueLinks } from './github-issue-links.ts'
143
192
  import { visibleManagedIssues } from './managed-issues.ts'
144
193
  import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
145
194
  import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
195
+ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallback } from './github-link-oauth.ts'
196
+ import type { ProfileChoice } from './github-links.ts'
197
+ import { withAppUsers } from './github-users.ts'
198
+ import { isBlobId } from './object-storage.ts'
146
199
  import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
147
200
  import { triageWireEntry } from './triage-response.ts'
148
201
  import { handlePublicWorkspace } from './public-workspace.ts'
@@ -154,6 +207,25 @@ import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
154
207
  import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
155
208
  import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
156
209
  import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
210
+ import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
211
+ import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
212
+ import { COAUTHORS_READ } from './commit-coauthors.ts'
213
+ import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, type SquashedPull, type Squashes, historyFrom, historyStarts, openPullHeads,
214
+ repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
215
+ import { AGENTS_READ_VERSION, APP_AGENTS, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
216
+ import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
217
+ import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependencies } from './pull-dependencies.ts'
218
+ import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
219
+ import { MAX_REQUIRED_REVIEWS, SINCE_COMMIT } from './repository-dependency-reviews.ts'
220
+ import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
221
+ import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
222
+ import { formattedPull, withFormatted } from './dependency-review-comments.ts'
223
+ import type { ReviewedDependencies } from './reviewed-dependencies.ts'
224
+ import { commentReviews } from './pull-comment-reviews.ts'
225
+ import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitStats, defaultHead, periodStats, pulseSince, repositoryActivity, repositoryLines,
226
+ repositoryStats, withLines, yearCommits } from './repository-pulse.ts'
227
+ import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
228
+ import { releaseCommits } from './release-commits.ts'
157
229
  import { computeBundleDiff } from '../common/bundle-compare-diff.js'
158
230
  import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
159
231
  import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
@@ -168,6 +240,7 @@ import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
168
240
  import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
169
241
 
170
242
  const SESSION_PATH = '/api/auth/session'
243
+ const PROFILE_PATH = '/api/auth/profile'
171
244
  const AVATAR_PREFIX = '/api/avatar/'
172
245
  const LOGOUT_PATH = '/api/auth/logout'
173
246
  const ADMIN_USERS_PATH = '/api/admin/users'
@@ -178,7 +251,20 @@ const ADMIN_REPOS_PATH = '/api/admin/repositories'
178
251
  const BROWSABLE_REPOS_PATH = '/api/admin/repositories/browsable'
179
252
  const REPO_REFS_PATH = '/api/admin/repositories/refs'
180
253
  const REPO_CONTENTS_PATH = '/api/admin/repositories/contents'
254
+ const REPO_CODE_PATH = '/api/admin/repositories/code'
255
+ const REPO_HISTORY_PATH = '/api/admin/repositories/history'
256
+ const REPO_CHANGES_PATH = '/api/admin/repositories/changes'
257
+ const REPO_TREE_PATH = '/api/admin/repositories/tree'
258
+ const REPO_ISSUES_PATH = '/api/admin/repositories/issues'
259
+ const REPO_AGENTS_PATH = '/api/admin/repositories/agents'
260
+ const REPO_ITEM_PATH = '/api/admin/repositories/item'
261
+ const REPO_PULL_DEPENDENCIES_PATH = '/api/admin/repositories/pull-dependencies'
262
+ const REPO_BRANCH_DEPENDENCIES_PATH = '/api/admin/repositories/branch-dependencies'
263
+ const REPO_LOCKFILES_PATH = '/api/admin/repositories/lockfiles'
264
+ const REPO_PULSE_PATH = '/api/admin/repositories/pulse'
265
+ const REPO_PULSE_ITEMS_PATH = '/api/admin/repositories/pulse/items'
181
266
  const SELECT_REPO_PATH = '/api/admin/repositories/select'
267
+ const REPO_DEPENDENCY_REVIEW_PATH = '/api/admin/repositories/dependency-review'
182
268
  const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
183
269
  const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
184
270
  const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
@@ -220,6 +306,7 @@ const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
220
306
  const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
221
307
  const NPM_COMPARISON_PATH = '/api/npm/comparison'
222
308
  const NPM_RISK_PATH = '/api/npm/risk'
309
+ const NPM_DELTA_PATH = '/api/npm/delta'
223
310
  // What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
224
311
  // having no such package or version, or a tarball unpacking past risk's
225
312
  // bounds; npm failing otherwise a 502.
@@ -232,7 +319,7 @@ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
232
319
  sendJson(res, status, { error: err.code })
233
320
  }
234
321
  const NPM_PATHS = new Set([NPM_PACKAGE_PATH, NPM_VERSIONS_PATH, NPM_DOWNLOAD_PATH, NPM_STATS_PATH, NPM_ADVISORIES_PATH, NPM_TAGS_PATH, NPM_SOCKET_PATH, NPM_PROVENANCE_PATH, NPM_PRETTY_PATH,
235
- NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH])
322
+ NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH, NPM_DELTA_PATH])
236
323
  // How long an npm response may go unread before its connection is dropped.
237
324
  const NPM_RESPONSE_IDLE_MS = 60_000
238
325
  const MAX_TEAM_NAME = 100
@@ -353,6 +440,9 @@ export interface ManagedHttpDeps {
353
440
  serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
354
441
  next?: Handler
355
442
  serverInfo?: ServerInfo
443
+ // The reviewed dependencies the host registered, as the server was
444
+ // assembled (reviewed-dependencies.ts).
445
+ reviewedDependencies?: ReviewedDependencies
356
446
  }
357
447
 
358
448
  type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
@@ -367,8 +457,9 @@ function firstHeader(v: string | string[] | undefined): string | null {
367
457
  return null
368
458
  }
369
459
 
370
- async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId: string): Promise<void> {
371
- const avatar = await avatarStore.get(userId)
460
+ // `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
461
+ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id): Promise<void> {
462
+ const avatar = await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id))
372
463
  if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
373
464
  res.writeHead(200, {
374
465
  'content-type': avatar.contentType,
@@ -379,6 +470,26 @@ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId
379
470
  res.end(avatar.bytes)
380
471
  }
381
472
 
473
+ // The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
474
+ // alone: the agents' known by their GitHub Apps or accounts (APP_AGENTS),
475
+ // each its mark on the Agents tab, and those the app draws agents' accounts
476
+ // by (GITHUB_AGENTS). Each is fetched from GitHub once and
477
+ // kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
478
+ // an account's, `…-0001-<id>`.
479
+ const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
480
+ .flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
481
+ const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
482
+
483
+ async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
484
+ const [, kind, id] = AGENT_AVATAR.exec(avatar)!
485
+ const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
486
+ if (await avatarStore.get(key) == null) {
487
+ await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
488
+ .catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
489
+ }
490
+ await serveAvatar(res, avatarStore, key)
491
+ }
492
+
382
493
  const MAX_JSON_BODY_BYTES = 4096
383
494
 
384
495
  // Buffer the request body, aborting (and throwing 'too-large') once it exceeds
@@ -481,9 +592,10 @@ function sendViewOnly(res: ServerResponse): void {
481
592
  // Reads stay available while an admin views as another user. Everything else
482
593
  // is refused before dispatch, except ending the view or signing out, and the
483
594
  // batch report preview, a POST without effects. Issue authorization would
484
- // grant GitHub access on the viewed user's behalf.
595
+ // grant GitHub access on the viewed user's behalf, and linking an account
596
+ // would link it to the viewed user.
485
597
  function viewAllows(method: string, path: string): boolean {
486
- if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH
598
+ if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH && path !== LINK_LOGIN_PATH
487
599
  return (method === 'DELETE' && path === VIEW_AS_PATH) || (method === 'POST' && (path === LOGOUT_PATH || path === REPORT_QUERY_PATH))
488
600
  }
489
601
 
@@ -528,6 +640,65 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
528
640
  res.end()
529
641
  }
530
642
 
643
+ // GET /api/auth/profile — the user's profile (github-links.ts). POST chooses
644
+ // the accounts their displayed name and avatar come from. Workspace access is
645
+ // required: the accounts a user links are reserved for them.
646
+ async function handleProfile(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
647
+ const method = req.method ?? 'GET'
648
+ if (method === 'GET') {
649
+ const s = await readWorkspaceSession(res, deps, cookie)
650
+ if (s) await sendProfile(res, deps, s.user.id)
651
+ return
652
+ }
653
+ if (method !== 'POST') { send405(res, 'GET, POST'); return }
654
+ const s = await profileMutation(req, res, deps, cookie)
655
+ if (s == null) return
656
+ let body: unknown
657
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
658
+ const choice = parseProfileChoice(body)
659
+ if (choice == null) { sendJson(res, 400, { error: 'bad-request' }); return }
660
+ await deps.db.setProfileChoice(s.session.id, choice, Date.now())
661
+ await sendProfile(res, deps, s.user.id)
662
+ }
663
+
664
+ // DELETE /api/auth/profile/accounts/<id> — unlink one of the user's linked
665
+ // GitHub accounts, which may then sign in or register again.
666
+ async function handleUnlinkAccount(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
667
+ if ((req.method ?? 'GET') !== 'DELETE') { send405(res, 'DELETE'); return }
668
+ const s = await profileMutation(req, res, deps, cookie)
669
+ if (s == null) return
670
+ const link = await deps.db.unlinkGithubAccount(s.session.id, id, Date.now())
671
+ if (link == null) { sendJson(res, 404, { error: 'no-account' }); return }
672
+ await deps.avatarStore.delete(link.id).catch((err) => { console.warn('managed: linked avatar removal failed:', err) })
673
+ await activity(deps, s.user, 'access', `unlinked GitHub account @${link.login}`)
674
+ await sendProfile(res, deps, s.user.id)
675
+ }
676
+
677
+ async function profileMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
678
+ const s = await checkMutation(req, res, deps, cookie)
679
+ if (s != null && !roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
680
+ return s
681
+ }
682
+
683
+ async function sendProfile(res: ServerResponse, deps: ManagedHttpDeps, userId: string): Promise<void> {
684
+ const profile = await deps.db.getProfile(userId)
685
+ if (profile == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
686
+ sendJson(res, 200, { profile })
687
+ }
688
+
689
+ // { name?, avatar? }, each a linked account's id or null; at least one.
690
+ function parseProfileChoice(body: unknown): ProfileChoice | null {
691
+ if (body == null || typeof body !== 'object' || Array.isArray(body)) return null
692
+ const choice: ProfileChoice = {}
693
+ for (const key of ['name', 'avatar'] as const) {
694
+ if (!Object.hasOwn(body, key)) continue
695
+ const value = (body as Record<string, unknown>)[key]
696
+ if (value !== null && (typeof value !== 'string' || !isBlobId(value))) return null
697
+ choice[key] = value
698
+ }
699
+ return Object.keys(choice).length > 0 ? choice : null
700
+ }
701
+
531
702
  // POST /api/admin/set-role — an admin sets ANOTHER user's role. Admin-only, and
532
703
  // refused for the caller's own id so an admin can't drop their own admin (keeps
533
704
  // at least one admin).
@@ -587,16 +758,19 @@ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps,
587
758
  // Who am I? While an admin views as another user, that user, plus the admin
588
759
  // as `viewer`. A view that has ended (with the admin's session or role, or the
589
760
  // viewed account) is cleared, returning to the own session. Null signed out.
761
+ // `dependencyReview` where they publish dependency reviews to some team.
590
762
  async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: string | undefined): Promise<{ body: ManagedSessionInfo | null; headers: Record<string, string> }> {
591
763
  const now = Date.now()
592
764
  let s = await readSession(config, db, cookie, now)
593
765
  const ended = s == null && viewToken(config, cookie) != null
594
766
  if (ended) s = await readSession(config, db, cookie, now, { own: true })
767
+ const reviews = s != null && (await db.listDependencyReviewTeams(s.user.id)).length > 0
595
768
  return {
596
769
  body: s && {
597
770
  user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
598
771
  csrfToken: s.session.csrfToken,
599
772
  ...(s.session.viewer ? { viewer: s.session.viewer } : {}),
773
+ ...(reviews ? { dependencyReview: true as const } : {}),
600
774
  },
601
775
  headers: ended ? { 'set-cookie': clearViewCookie(config) } : {},
602
776
  }
@@ -644,9 +818,13 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
644
818
  let tokenMissing = false
645
819
  let repositories
646
820
  if (scope === 'connected') {
821
+ const reviews = await deps.db.listRepoDependencyReviews()
822
+ const reviewTeams = await deps.db.listRepoReviewTeams()
647
823
  repositories = allRows.map((r) => ({
648
824
  id: r.repoId, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl,
649
825
  installed: r.installationId != null, selected: r.active, active: r.active,
826
+ dependencyReview: reviews.get(r.repoId) ?? { enabled: false, required: 1, since: null, lockfiles: EVERY_LOCKFILE },
827
+ reviewTeams: reviewTeams.get(r.repoId) ?? [],
650
828
  }))
651
829
  } else {
652
830
  const showAll = scope === 'installed' && url.searchParams.get('showAll') === 'true'
@@ -676,6 +854,7 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
676
854
  inactiveCount: allRows.filter((r) => !r.active).length,
677
855
  total,
678
856
  tokenMissing,
857
+ ...deps.reviewedDependencies ? { reviewedDependencies: true } : {},
679
858
  })
680
859
  }
681
860
 
@@ -810,6 +989,39 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
810
989
  await selectRepository(res, deps, s.user, repoId, cookie)
811
990
  }
812
991
 
992
+ // POST /api/admin/repositories/dependency-review — a repository's dependency
993
+ // review (repository-dependency-reviews.ts): `enabled`, the published reviews
994
+ // each version its pull requests' lockfiles lock needs (`required`, 1 to
995
+ // MAX_REQUIRED_REVIEWS), and the commit whose lockfiles' versions need none
996
+ // (`since`, a full commit id, or null; where the host registered reviewed
997
+ // dependencies, which replace it, none, `400 since-unsupported`, the one
998
+ // stored kept as it is). Answers the setting then. Admin, CSRF.
999
+ async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1000
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
1001
+ const s = await adminMutation(req, res, deps, cookie)
1002
+ if (s == null) return
1003
+ let body: Record<string, unknown> | null
1004
+ try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1005
+ const repoId = body?.['repoId']
1006
+ const enabled = body?.['enabled']
1007
+ const required = body?.['required']
1008
+ const since = body?.['since'] ?? null
1009
+ // The lockfiles it holds to, kept as they are where none is sent.
1010
+ const lockfiles = body?.['lockfiles'] === undefined ? undefined : scopeOf(body['lockfiles'])
1011
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof enabled !== 'boolean'
1012
+ || typeof required !== 'number' || !Number.isSafeInteger(required) || required < 1 || required > MAX_REQUIRED_REVIEWS
1013
+ || (since !== null && (typeof since !== 'string' || !SINCE_COMMIT.test(since))) || lockfiles === null) {
1014
+ sendJson(res, 400, { error: 'bad-request' }); return
1015
+ }
1016
+ if (since !== null && deps.reviewedDependencies) { sendJson(res, 400, { error: 'since-unsupported' }); return }
1017
+ const repo = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
1018
+ const setting = repo ? await deps.db.setRepoDependencyReview(repoId, { enabled, required, ...deps.reviewedDependencies ? {} : { since: since as string | null },
1019
+ ...lockfiles ? { lockfiles } : {} }) : null
1020
+ if (!repo || !setting) { sendJson(res, 404, { error: 'no-repository' }); return }
1021
+ await activity(deps, s.user, 'repository', enabled ? 'enabled dependency review' : 'disabled dependency review', { repo: repo.fullName })
1022
+ sendJson(res, 200, { ok: true, dependencyReview: setting })
1023
+ }
1024
+
813
1025
  async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
814
1026
  const ids = new Set<string>()
815
1027
  for (const report of reports) {
@@ -1249,8 +1461,10 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
1249
1461
 
1250
1462
  // Listing connected repository names uses managed access only. Verify GitHub
1251
1463
  // access after selection, before returning any refs or source directory data.
1464
+ // Any team member (view or above) lists those their teams reach, for the code
1465
+ // viewer as for Create a bundle, whose builds stay admin|manage.
1252
1466
  async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1253
- const s = await readManageSession(res, deps, cookie)
1467
+ const s = await readWorkspaceSession(res, deps, cookie)
1254
1468
  if (!s) return
1255
1469
  sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
1256
1470
  }
@@ -1289,25 +1503,54 @@ function scopeRepositoryContents(contents: RepositoryContents | null, virtualEnt
1289
1503
  } : {}) }
1290
1504
  }
1291
1505
 
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> {
1506
+ // A browse's repository, revision and directory, as its query names them;
1507
+ // null, answered, where it names none.
1508
+ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
1294
1509
  const repoId = Number(query.get('repoId'))
1295
1510
  const normalized = normalizeTeamPath(query.get('path') ?? '')
1296
1511
  const ref = query.get('ref') ?? ''
1297
1512
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
1298
- sendJson(res, 400, { error: 'bad-request' }); return
1513
+ sendJson(res, 400, { error: 'bad-request' }); return null
1299
1514
  }
1515
+ return { repoId, ref, path: normalized.path ?? '' }
1516
+ }
1517
+
1518
+ // The repository `repoId` as the reader may browse it at `path`: one of their
1519
+ // bundle repositories, and what their grants show of that directory
1520
+ // (`virtualEntries`, null where they reach all of it: scopedDirectory). Null,
1521
+ // answered, where it is none of theirs. Every team member (view or above)
1522
+ // reads the repositories their teams reach; the reader then checks their own
1523
+ // GitHub read permission (repository-browser.ts), on each read.
1524
+ async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
1525
+ const s = await readWorkspaceSession(res, deps, cookie)
1526
+ if (!s) return null
1527
+ const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
1528
+ if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1529
+ const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1530
+ return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
1531
+ }
1532
+
1533
+ // The repository as it was authorized, but for the shared default branch hint,
1534
+ // which a read may refresh meanwhile: that changes none of its identity,
1535
+ // credentials or managed grants.
1536
+ const sameRepository = (current: SelectedRepo, authorized: SelectedRepo) =>
1537
+ JSON.stringify({ ...current, cachedDefaultBranch: authorized.cachedDefaultBranch }) === JSON.stringify(authorized)
1538
+
1539
+ function sendGithubError(res: ServerResponse, err: GithubApiError) {
1540
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
1541
+ }
1542
+
1543
+ // Recheck repository/path grants after upstream reads, before returning names.
1544
+ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
1545
+ const asked = repositoryQuery(res, query)
1546
+ if (!asked) return
1547
+ const { repoId, ref } = asked
1300
1548
  const withDefault = refs && query.get('withDefault') === 'true'
1301
- const path = refs ? '' : normalized.path ?? ''
1549
+ const path = refs ? '' : asked.path
1302
1550
  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 }
1551
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1552
+ if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1553
+ return access
1311
1554
  }
1312
1555
  const access = await authorize()
1313
1556
  if (!access) return
@@ -1329,23 +1572,729 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1329
1572
  await reader.recheckAccess()
1330
1573
  const current = await authorize()
1331
1574
  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 ? {
1575
+ // This request (or another reader) may have refreshed the shared hint.
1576
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1577
+ sendJson(res, 200, refs ? { ...listed, tagCommits, ...(withDefault ? {
1338
1578
  defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
1339
1579
  } : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
1340
1580
  } 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) })
1581
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1582
+ throw err
1583
+ }
1584
+ }
1585
+
1586
+ // GET /api/admin/repositories/code: the files of a directory of a repository
1587
+ // at a revision, for the code viewer (repository-code.ts), read as browsing
1588
+ // it is, with the reader's own GitHub access, and only where their grants
1589
+ // reach the whole directory: one they reach only the ancestor of is none to
1590
+ // read. Its tree's listing says first whether its files fit, before its
1591
+ // tarball is asked for; the reader's access is checked again once it is
1592
+ // read, before any of it is sent. The load stays held until this response is
1593
+ // written or abandoned, and a reader who stops reading it gives it up after
1594
+ // a while.
1595
+ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1596
+ const asked = repositoryQuery(res, query)
1597
+ if (!asked) return
1598
+ const { repoId, ref, path } = asked
1599
+ const authorize = async () => {
1600
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1601
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1602
+ return access
1603
+ }
1604
+ const access = await authorize()
1605
+ if (!access) return
1606
+ try {
1607
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1608
+ const commit = await reader.commit(ref)
1609
+ const repo = access.repo.fullName
1610
+ const client = createClient({ token: reader.readToken() })
1611
+ // A directory the commit has none at is none to read, as browsing it says.
1612
+ const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
1613
+ throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
1614
+ }))
1615
+ // Access still held, and the same repository, before anything read is sent.
1616
+ const fenced = async () => {
1617
+ await reader.recheckAccess()
1618
+ const current = await authorize()
1619
+ if (!current) return false
1620
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
1621
+ return true
1622
+ }
1623
+ // Too large names the commit it was read at, for History to read.
1624
+ if (!repositoryCodeFits(await repositoryTreeFiles(repo, reader.readToken(), tree))) {
1625
+ if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
1343
1626
  return
1344
1627
  }
1628
+ if (res.destroyed) return
1629
+ const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
1630
+ res.once('close', load.release)
1631
+ const bytes = await load.result
1632
+ if (!(await fenced()) || res.destroyed) return
1633
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
1634
+ res.writeHead(200, {
1635
+ 'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
1636
+ 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
1637
+ })
1638
+ writeResponse(res, bytes)
1639
+ } catch (err) {
1640
+ if (res.headersSent || res.destroyed) return
1641
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1642
+ if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
1643
+ throw err
1644
+ }
1645
+ }
1646
+
1647
+ // GET /api/admin/repositories/history: what the code viewer's History tab
1648
+ // draws (repository-history.ts), read as browsing is, with the reader's own
1649
+ // GitHub access, within their grants as the code is. From a revision (`ref`),
1650
+ // a page at a time (`page`), the commits touching `path` where it names a
1651
+ // directory, with the branches and tags naming commits on the first page; or,
1652
+ // with `all`, across the repository's most recently updated branches, which
1653
+ // needs a GitHub token. A whole repository's history, from a revision or all
1654
+ // branches, is read from commits a page each, kept by the commit each starts
1655
+ // at, and those kept glued on (historyFrom); a later page reads on from the
1656
+ // commits `from` names. The branches and tags naming commits are kept as an
1657
+ // issue's page is (keptAnswer). Access is checked again once it is read,
1658
+ // before any of it is sent, with the app's users its authors are
1659
+ // (github-users.ts).
1660
+ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1661
+ const asked = repositoryQuery(res, query)
1662
+ if (!asked) return
1663
+ const { repoId, ref, path } = asked
1664
+ const page = Number(query.get('page') ?? '1')
1665
+ const all = query.get('all') === 'true'
1666
+ // Every branch's heads read now, not as kept: the client asks so where
1667
+ // they miss the commit shown, as a branch moved since may.
1668
+ const fresh = query.get('fresh') === 'true'
1669
+ // A whole repository's later pages read on from where those before stop:
1670
+ // all branches', always; a revision's, where the client names them.
1671
+ const from = query.get('from')
1672
+ const starts = from === null ? null : historyStarts(from)
1673
+ if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
1674
+ || (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
1675
+ const authorize = async () => {
1676
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1677
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1678
+ return access
1679
+ }
1680
+ const access = await authorize()
1681
+ if (!access) return
1682
+ try {
1683
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1684
+ const repo = access.repo.fullName
1685
+ const token = reader.readToken()
1686
+ // With a token, as GraphQL reads them.
1687
+ const pages = historyPages(deps, repoId, token !== null)
1688
+ let body: object
1689
+ if (all) {
1690
+ if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
1691
+ // What a later page reads on from, the client knows; whether one may
1692
+ // follow, the deepest page says.
1693
+ // Asked for afresh, those kept answered as just read: how long till
1694
+ // they may be read again (`retryIn`), for the client to ask then.
1695
+ const heads = starts ? null : await keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token), undefined, fresh)
1696
+ const retryIn = heads && fresh && heads.kept ? heads.at + REPOSITORY_READ_FLOOR_MS - Date.now() : 0
1697
+ body = starts ? { all: true, page, commits: (await historyFrom(repo, token, starts, pages)).commits, more: page < MAX_HISTORY_PAGE }
1698
+ : { all: true, ...await repositoryAllHistory(repo, token, heads!.value, pages), ...retryIn > 0 ? { retryIn } : {} }
1699
+ } else {
1700
+ const commit = await reader.commit(ref)
1701
+ // The branches and tags naming commits: with a token, every branch All
1702
+ // branches reads and the latest tags, kept for both; without, GitHub's
1703
+ // first page of each.
1704
+ const naming = () => token
1705
+ ? keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token)).then(({ value }) => ({
1706
+ branches: value.heads.map(({ name, sha }) => ({ name, sha })), tags: value.tags }))
1707
+ : keptAnswer(deps, `${repoId}\0refs`, () => repositoryRefTips(repo, token)).then(answer => answer.value)
1708
+ const tips = page === 1 ? naming() : null
1709
+ // A whole repository's from its commit, or on from those `from` names
1710
+ // (a page by its number where none are, as GitHub lists them); a
1711
+ // directory's by its number.
1712
+ const history = path !== '' || (page > 1 && !starts) ? repositoryHistory(repo, token, commit, path, page)
1713
+ : historyFrom(repo, token, starts ?? [commit], pages).then(read => ({ commits: read.commits, more: (starts !== null || read.more) && page < MAX_HISTORY_PAGE }))
1714
+ const [read, named] = await Promise.all([history, tips])
1715
+ body = { commit, path, page, ...read, ...named }
1716
+ }
1717
+ // The pull requests squashed into a whole repository's commits, drawn as
1718
+ // the merges they were, and on its first page the open ones' heads,
1719
+ // naming commits: with a token, as far as GitHub answers them, the
1720
+ // history sent all the same where it doesn't.
1721
+ const listed = (body as { commits: HistoryCommit[] }).commits
1722
+ const quiet = <T>(fallback: T) => (err: unknown) => {
1723
+ if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
1724
+ return fallback
1725
+ }
1726
+ const [pulls, openPulls] = token && path === '' ? await Promise.all([
1727
+ squashedPulls(repo, token, listed, historySquashes(deps, repoId)).catch(quiet<SquashedPull[]>([])),
1728
+ page === 1 ? keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null)) : null,
1729
+ ]) : [[], null]
1730
+ await reader.recheckAccess()
1731
+ const current = await authorize()
1732
+ if (!current) return
1733
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1734
+ // Each commit as the tab draws it, its co-authors with it: what Pulse
1735
+ // counts of it (its lines, its author's time) not sent.
1736
+ const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
1737
+ ({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
1738
+ sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
1739
+ ...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...openPulls?.length ? { openPulls } : {} }))
1740
+ } catch (err) {
1741
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1742
+ throw err
1743
+ }
1744
+ }
1745
+
1746
+ // GET /api/admin/repositories/changes: the files each commit `shas` names
1747
+ // changes, as the code viewer's Replay plays them (repository-changes.ts),
1748
+ // within `path`, each commit's kept whole by the commit (historyChanges);
1749
+ // read as Replay's reads are (readForReplay).
1750
+ async function handleRepositoryChanges(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1751
+ const shas = changesAsked(query.get('shas'))
1752
+ if (!shas) { sendJson(res, 400, { error: 'bad-request' }); return }
1753
+ await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
1754
+ const changes = await repositoryChanges(fullName, token, shas, historyChanges(deps, repoId))
1755
+ return { path, changes: [...changes].map(([sha, read]) => read ? { sha, ...changesWithin(read, path) } : { sha, files: null, more: false }) }
1756
+ })
1757
+ }
1758
+
1759
+ // GET /api/admin/repositories/tree: the files commit `commit` holds within
1760
+ // `path` and their bytes (`sizes`), which Replay starts from and the Treemap
1761
+ // of a directory too large to open draws (repository-changes.ts
1762
+ // directoryFiles: the directory's own tree where the commit's is cut short),
1763
+ // kept whole by the commit and the rules it is read by (historyTreeStore,
1764
+ // TREES_READ); none (null) for one GitHub has none of. Read as Replay's
1765
+ // reads are (readForReplay).
1766
+ async function handleRepositoryTree(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1767
+ const commit = query.get('commit') ?? ''
1768
+ if (!/^[a-f\d]{40}$/u.test(commit)) { sendJson(res, 400, { error: 'bad-request' }); return }
1769
+ const store = historyTreeStore<CommitTree>(deps.db, deps.config.debug)
1770
+ await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
1771
+ const kept = { read: async (sha: string) => {
1772
+ const value = (await store.read(`${repoId}\0${TREES_READ}\0${sha}`))?.value
1773
+ return value && Array.isArray(value.files) && Array.isArray(value.sizes) && value.sizes.length === value.files.length ? value : null
1774
+ }, write: (sha: string, tree: CommitTree) => store.write(`${repoId}\0${TREES_READ}\0${sha}`, tree, Date.now()) }
1775
+ const tree = await directoryFiles(fullName, token, commit, path, kept)
1776
+ return { commit, path, ...tree ?? { files: null, sizes: null, cut: false } }
1777
+ })
1778
+ }
1779
+
1780
+ // What Replay reads of repository `repoId` within `path` (`read`, with its
1781
+ // name and the reader's token), as the history is read: with the reader's
1782
+ // own GitHub access, within their grants as the code is, a directory they
1783
+ // reach all of, and sent once access is checked again.
1784
+ async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams,
1785
+ read: (fullName: string, token: string | null, repoId: number, path: string) => Promise<object>): Promise<void> {
1786
+ const asked = repositoryQuery(res, query)
1787
+ if (!asked) return
1788
+ const { repoId, path } = asked
1789
+ const authorize = async () => {
1790
+ const access = await authorizeRepository(res, deps, cookie, repoId, path)
1791
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1792
+ return access
1793
+ }
1794
+ const access = await authorize()
1795
+ if (!access) return
1796
+ try {
1797
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1798
+ const body = await read(access.repo.fullName, reader.readToken(), repoId, path)
1799
+ await reader.recheckAccess()
1800
+ const current = await authorize()
1801
+ if (!current) return
1802
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1803
+ sendJson(res, 200, body)
1804
+ } catch (err) {
1805
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1806
+ throw err
1807
+ }
1808
+ }
1809
+
1810
+ // The files repository `repoId`'s commits change, kept by each commit
1811
+ // (historyChangeStore, repository-changes.ts repositoryChanges).
1812
+ function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
1813
+ const store = historyChangeStore<CommitChanges>(deps.db, deps.config.debug)
1814
+ const keyOf = (sha: string) => `${repoId}\0${sha}`
1815
+ return {
1816
+ async read(shas) {
1817
+ const kept = await store.readMany(shas.map(keyOf))
1818
+ return new Map(shas.flatMap(sha => {
1819
+ const value = kept.get(keyOf(sha))?.value
1820
+ return value && Array.isArray(value.files) ? [[sha, value] as const] : []
1821
+ }))
1822
+ },
1823
+ write: (sha, changes) => store.write(keyOf(sha), changes, Date.now()),
1824
+ }
1825
+ }
1826
+
1827
+ // How long what readWholeRepository read is answered as kept, unread again;
1828
+ // and how long one read is answered as kept even where it is asked for afresh.
1829
+ const REPOSITORY_READ_FRESH_MS = 2 * 60_000
1830
+ const REPOSITORY_READ_FLOOR_MS = 10_000
1831
+ // The repositories' reads being read again, behind a kept answer or as asked
1832
+ // for afresh, by key: one at a time, those asking meanwhile answered by it.
1833
+ const repositoryRereads = new Map<string, Promise<unknown>>()
1834
+ // A read GitHub refused, not failed: its credential or the GitHub App's
1835
+ // permission for it withdrawn (Issues, Pull requests, Checks), the
1836
+ // repository or what was asked of it gone. What was kept of it goes too.
1837
+ const refusedRead = (err: unknown) => err instanceof GithubApiError
1838
+ && (err.status === 401 || err.status === 403 || err.status === 404 || err.message === 'github-status-403')
1839
+
1840
+ // What `read` answers of a repository, kept by `key` (its id first) for every
1841
+ // reader (repositoryReadStore), each one's access checked as they ask before
1842
+ // this: answered as kept for REPOSITORY_READ_FRESH_MS, then, while it is
1843
+ // kept, answered at once and read again behind it for the next to ask, where
1844
+ // the instance outlives its answer (not serverless, where it is read again
1845
+ // before it is answered); `kept` where it was, or where reading it again
1846
+ // failed as `keepThrough` lets it. One asked for afresh (`fresh`) is read
1847
+ // now, unless it was read REPOSITORY_READ_FLOOR_MS since, what was kept
1848
+ // answered where that read fails. One GitHub refuses to read again is no
1849
+ // longer kept: the next to ask is refused as GitHub refused it. `at` is when
1850
+ // what it answers was read.
1851
+ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>,
1852
+ keepThrough: (err: unknown) => boolean = () => false, fresh = false): Promise<{ value: T; kept: boolean; at: number }> {
1853
+ const store = repositoryReadStore(deps.db, deps.config.debug)
1854
+ // One kept longer than a day, not yet removed (pruneKept removes them as
1855
+ // others are written), is none.
1856
+ const stored = await store.read(key)
1857
+ const held = stored && Date.now() - stored.at < REPOSITORY_READ_KEPT_MS ? stored : null
1858
+ // Read, and kept as read when it was, which it is said read at.
1859
+ const readKept = async () => {
1860
+ try {
1861
+ const value = await read()
1862
+ const at = Date.now()
1863
+ await store.write(key, value, at)
1864
+ return { value, at }
1865
+ } catch (err) {
1866
+ if (refusedRead(err)) await store.remove(key)
1867
+ throw err
1868
+ }
1869
+ }
1870
+ const reread = () => {
1871
+ let reading = repositoryRereads.get(key)
1872
+ if (!reading) {
1873
+ reading = readKept().finally(() => repositoryRereads.delete(key))
1874
+ repositoryRereads.set(key, reading)
1875
+ }
1876
+ return reading as Promise<{ value: T; at: number }>
1877
+ }
1878
+ const age = held ? Date.now() - held.at : Infinity
1879
+ const now = fresh && age >= REPOSITORY_READ_FLOOR_MS
1880
+ if (held && !now && (age < REPOSITORY_READ_FRESH_MS || !deps.config.serverless)) {
1881
+ if (age >= REPOSITORY_READ_FRESH_MS && !repositoryRereads.has(key)) {
1882
+ // Its failure leaves the kept answer sent until it is a day old, so it is said.
1883
+ deps.track(reread().catch((err: unknown) => { console.warn('managed: repository read failed:', err) }))
1884
+ }
1885
+ return { value: held.value as T, kept: true, at: held.at }
1886
+ }
1887
+ try {
1888
+ return { ...now ? await reread() : await readKept(), kept: false }
1889
+ } catch (err) {
1890
+ if (held && ((now && !refusedRead(err)) || keepThrough(err))) return { value: held.value as T, kept: true, at: held.at }
1891
+ throw err
1892
+ }
1893
+ }
1894
+
1895
+ // The pages of repository `repoId`'s history kept (historyPageStore), each by
1896
+ // the commit it starts at (repository-history.ts historyFrom) and the rules
1897
+ // its co-authors were read by (commit-coauthors.ts COAUTHORS_READ), those
1898
+ // telling each commit's co-authors (kept since they are read), and with
1899
+ // `graphql`, only those read with GraphQL, telling its lines and the accounts
1900
+ // its co-authors' addresses are (a page GitHub's REST API listed tells
1901
+ // neither); the rest read again.
1902
+ function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): HistoryPages {
1903
+ const store = historyPageStore<HistoryCommit[]>(deps.db, deps.config.debug)
1904
+ const keyOf = (top: string) => `${repoId}\0${COAUTHORS_READ}\0${top}`
1905
+ return {
1906
+ async read(tops) {
1907
+ const kept = await store.readMany(tops.map(keyOf))
1908
+ return new Map(tops.flatMap(top => {
1909
+ const page = kept.get(keyOf(top))?.value
1910
+ return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
1911
+ ? [[top, page] as const] : []
1912
+ }))
1913
+ },
1914
+ write: (top, commits) => store.write(keyOf(top), commits, Date.now()),
1915
+ }
1916
+ }
1917
+
1918
+ // The pull requests squashed into repository `repoId`'s commits, kept by
1919
+ // each commit and the rules their commits' co-authors were read by
1920
+ // (COAUTHORS_READ), null for one none was (repository-history.ts
1921
+ // squashedPulls); one kept before its commits' co-authors were read, read
1922
+ // again.
1923
+ function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
1924
+ const store = historySquashStore<SquashedPull | null>(deps.db, deps.config.debug)
1925
+ const keyOf = (sha: string) => `${repoId}\0${COAUTHORS_READ}\0${sha}`
1926
+ return {
1927
+ async read(shas) {
1928
+ const kept = await store.readMany(shas.map(keyOf))
1929
+ return new Map(shas.flatMap(sha => {
1930
+ const entry = kept.get(keyOf(sha))
1931
+ return entry && (entry.value === null || (Array.isArray(entry.value.commits) && entry.value.commits.every(commit => Array.isArray(commit.coauthors))))
1932
+ ? [[sha, entry.value] as const] : []
1933
+ }))
1934
+ },
1935
+ write: (sha, pull) => store.write(keyOf(sha), pull, Date.now()),
1936
+ }
1937
+ }
1938
+
1939
+ // What the whole of repository `repoId` gives in GraphQL (`read`, with its
1940
+ // name and a token), read as browsing is, with the reader's own GitHub
1941
+ // access: only by a reader whose grants reach all of it, with a token, and
1942
+ // sent once access is checked again after it is read; what `key` names kept
1943
+ // (keptAnswer), sent as kept, its reader's access checked as they ask; with
1944
+ // `readAt`, when it was read from GitHub, one kept as old as it was kept, so
1945
+ // that a reader holding two reads of the same can tell the later.
1946
+ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
1947
+ read: (fullName: string, token: string) => Promise<object>, key: string | null = null, readAt = false): Promise<void> {
1948
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
1949
+ const { value, kept, at } = key === null ? { value: await read(fullName, token), kept: false, at: Date.now() }
1950
+ : await keptAnswer(deps, `${repoId}\0${key}`, () => read(fullName, token))
1951
+ return { value: readAt ? { ...value, readAt: at } : value, kept }
1952
+ })
1953
+ }
1954
+
1955
+ // What `answer` gives of the whole of repository `repoId` (with its name and
1956
+ // a token), as readWholeRepository reads it: sent at once where it was kept,
1957
+ // else once access is checked again, with the app's users it names
1958
+ // (github-users.ts), never kept with it.
1959
+ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
1960
+ answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>): Promise<void> {
1961
+ const authorize = async () => {
1962
+ const access = await authorizeRepository(res, deps, cookie, repoId, '')
1963
+ if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
1964
+ return access
1965
+ }
1966
+ const access = await authorize()
1967
+ if (!access) return
1968
+ try {
1969
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1970
+ const token = reader.readToken()
1971
+ if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
1972
+ const answered = await answer(access.repo.fullName, token, access.user)
1973
+ if (answered.kept) { sendJson(res, 200, await withAppUsers(deps.db, answered.value)); return }
1974
+ await reader.recheckAccess()
1975
+ const current = await authorize()
1976
+ if (!current) return
1977
+ if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1978
+ sendJson(res, 200, await withAppUsers(deps.db, answered.value))
1979
+ } catch (err) {
1980
+ if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1345
1981
  throw err
1346
1982
  }
1347
1983
  }
1348
1984
 
1985
+ // GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
1986
+ // requests tabs show (repository-issues.ts): `kind`'s open or closed
1987
+ // (`state`), a page at a time, on from the cursor `after` names. They are
1988
+ // the whole repository's (readWholeRepository).
1989
+ async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1990
+ const repoId = Number(query.get('repoId'))
1991
+ const kind = query.get('kind')
1992
+ const state = query.get('state') ?? 'open'
1993
+ const after = query.get('after')
1994
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
1995
+ || (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
1996
+ await readWholeRepository(res, deps, cookie, repoId, async (fullName, token) => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }),
1997
+ `issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`, true)
1998
+ }
1999
+
2000
+ // GET /api/admin/repositories/agents: what the code viewer's Agents tab
2001
+ // reconstructs agents' sessions from (repository-agents.ts): a page
2002
+ // of pull requests on from the cursor `pulls` names, and of the default
2003
+ // branch's commits on from `commits`, the first page, naming neither, with
2004
+ // issues and branches. They are the whole repository's (readWholeRepository).
2005
+ async function handleRepositoryAgents(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2006
+ const repoId = Number(query.get('repoId'))
2007
+ const cursors = { pulls: query.get('pulls'), commits: query.get('commits') }
2008
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || (cursors.pulls !== null && !ISSUES_CURSOR.test(cursors.pulls))
2009
+ || (cursors.commits !== null && !COMMITS_CURSOR.test(cursors.commits))) {
2010
+ sendJson(res, 400, { error: 'bad-request' }); return
2011
+ }
2012
+ await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryAgents(fullName, token, cursors),
2013
+ `agents\0${AGENTS_READ_VERSION}\0${cursors.pulls ?? ''}\0${cursors.commits ?? ''}`)
2014
+ }
2015
+
2016
+ // GET /api/admin/repositories/item: the issue or pull request numbered
2017
+ // `number`, as the code viewer's popup for it shows it (repository-issues.ts
2018
+ // repositoryItem). It is the whole repository's (readWholeRepository).
2019
+ async function handleRepositoryItem(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2020
+ const repoId = Number(query.get('repoId'))
2021
+ const number = itemNumber(query)
2022
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null) { sendJson(res, 400, { error: 'bad-request' }); return }
2023
+ await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryItem(fullName, token, number), `item\0${ISSUES_READ_VERSION}\0${number}`, true)
2024
+ }
2025
+
2026
+ // The issue's or pull request's number a request names, as GraphQL takes one
2027
+ // (an Int, at most 2³¹ − 1); null for none.
2028
+ function itemNumber(query: URLSearchParams): number | null {
2029
+ const number = Number(query.get('number'))
2030
+ return /^[1-9]\d{0,9}$/u.test(query.get('number') ?? '') && number <= 2 ** 31 - 1 ? number : null
2031
+ }
2032
+
2033
+ // GET /api/admin/repositories/pull-dependencies: the package versions pull
2034
+ // request `number`'s lockfiles add (pull-dependencies.ts), what its base's and head's
2035
+ // commits tell kept by them (pullDependencyKept), its `number` with them.
2036
+ // They are the whole repository's (answerWholeRepository), kept by its number
2037
+ // as its popup's item is, its head moving with each push. With `review=1`,
2038
+ // as an open one's are asked, where the repository reviews dependencies,
2039
+ // each version's review (pull-dependency-reviews.ts), read afresh as each
2040
+ // asks, reviews being published meanwhile; its baseline kept, where the host
2041
+ // registered no reviewed dependencies, which are asked in its place.
2042
+ async function handleRepositoryPullDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2043
+ const repoId = Number(query.get('repoId'))
2044
+ const number = itemNumber(query)
2045
+ const review = query.get('review') === '1'
2046
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null || !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
2047
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2048
+ const answered = await keptAnswer(deps, `${repoId}\0pull-dependencies\0${PULL_DEPENDENCIES_READ}\0${number}`,
2049
+ async () => ({ number, ...await pullDependencies(fullName, token, number, pullDependencyKept(deps, repoId)) }))
2050
+ // Where its review is on, those of lockfiles it leaves out told to none,
2051
+ // whoever asks.
2052
+ const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
2053
+ const held = setting?.enabled ? heldDependencies(answered.value, setting.lockfiles) : answered.value
2054
+ const now = review ? await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(held)) : null
2055
+ // Its review by the lockfiles they were told by, though they have changed
2056
+ // since; turned off meanwhile, all of them, as where it is off.
2057
+ const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
2058
+ const told = review && !now && setting?.enabled && !(await deps.db.listRepoDependencyReviews()).get(repoId)?.enabled ? answered.value : held
2059
+ const reviewed = deps.reviewedDependencies ?? null
2060
+ const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
2061
+ // Its comments read for reviews, where the host reads them, while it is
2062
+ // open (pull-comment-reviews.ts reads whether it is with them).
2063
+ const parse = reviewed?.parseComments
2064
+ const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
2065
+ // The comment the host formats of the reader's reviews of its versions, for its copy button.
2066
+ const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
2067
+ return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
2068
+ ...formatted ? { formatted } : {} }, kept: answered.kept }
2069
+ })
2070
+ }
2071
+
2072
+ // GET /api/admin/repositories/branch-dependencies: what the lockfiles of the
2073
+ // repository's default branch lock that wants review, where its review is in
2074
+ // effect for the reader (`review`, else none), as a team's Dependencies tab
2075
+ // shows it: its head, as its pulse reads it, kept a while; every lockfile
2076
+ // there its review holds to (lockfile-scope.ts, commit-lockfiles.ts) as one
2077
+ // directory, its versions as a pull request's others are
2078
+ // (pull-dependency-reviews.ts), each left unread a directory of its own
2079
+ // failing the review, as a pull request's are.
2080
+ async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2081
+ const repoId = Number(query.get('repoId'))
2082
+ if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2083
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2084
+ const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
2085
+ if (!head) return { value: { head: null, review: null }, kept: false }
2086
+ // Those its review holds to alone read, as many as are read of them.
2087
+ const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
2088
+ if (!setting?.enabled) return { value: { head, review: null }, kept: false }
2089
+ const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path))
2090
+ const { packages, paths } = lockedIn(read)
2091
+ const told = { base: head, head, cut: false, lockfiles: [{ paths, added: [], more: 0, locked: packages.slice(0, MAX_LOCKED), moreLocked: Math.max(0, packages.length - MAX_LOCKED) },
2092
+ ...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
2093
+ const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
2094
+ if (!now) return { value: { head, review: null }, kept: false }
2095
+ // Told by the lockfiles it held to as they were read, though they have
2096
+ // changed since.
2097
+ const state = { ...now, lockfiles: setting.lockfiles }
2098
+ const reviewed = deps.reviewedDependencies ?? null
2099
+ const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null } : await baselineFor(fullName, token, repoId, state, baselineKept(deps))
2100
+ return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: false }
2101
+ })
2102
+ }
2103
+
2104
+ // GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
2105
+ // default branch its dependency review reads (commit-lockfiles.ts), by their
2106
+ // paths, with their formats, at its head, as its pulse reads it, for its
2107
+ // settings to name those it holds to (lockfile-scope.ts).
2108
+ async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2109
+ const repoId = Number(query.get('repoId'))
2110
+ if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2111
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2112
+ const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
2113
+ return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head)).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
2114
+ })
2115
+ }
2116
+
2117
+ // The app's users `accounts` are, by their logins in lower case, of those
2118
+ // who publish reviews to a team counting for repository `repoId`.
2119
+ async function repoReviewers(deps: ManagedHttpDeps, repoId: number, accounts: { login: string; githubId: number | null }[]) {
2120
+ const users = await deps.db.listUsersByAccount(accounts)
2121
+ const counted = await deps.db.listRepoReviewers(repoId, [...new Set(Object.values(users).map(user => user.id))])
2122
+ return new Map(Object.entries(users).flatMap(([login, user]) => counted.has(user.id) ? [[login.toLowerCase(), user.id] as const] : []))
2123
+ }
2124
+
2125
+ // The versions repository lockfiles locked at the commits their dependencies
2126
+ // are reviewed since, kept by each (reviewBaselineStore).
2127
+ function baselineKept(deps: ManagedHttpDeps): ReviewBaselineStore {
2128
+ const store = reviewBaselineStore(deps.db, deps.config.debug)
2129
+ return {
2130
+ async read(key) {
2131
+ const value = (await store.read(key))?.value
2132
+ const strings = (list: unknown) => Array.isArray(list) && list.every(item => typeof item === 'string')
2133
+ return strings(value?.versions) && strings(value?.unread) ? value : null
2134
+ },
2135
+ write: (key, baseline) => store.write(key, baseline, Date.now()),
2136
+ }
2137
+ }
2138
+
2139
+ // What repository `repoId`'s pull requests' lockfiles add, kept by the
2140
+ // commits of each's base and head and the rules they were told by
2141
+ // (PULL_DEPENDENCIES_READ, pullDependencyStore).
2142
+ function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependencyStore {
2143
+ const store = pullDependencyStore<PullDependencies>(deps.db, deps.config.debug)
2144
+ const keyOf = (commits: string) => `${repoId}\0${PULL_DEPENDENCIES_READ}\0${commits}`
2145
+ return {
2146
+ async read(commits) {
2147
+ const value = (await store.read(keyOf(commits)))?.value
2148
+ return value && Array.isArray(value.lockfiles) ? value : null
2149
+ },
2150
+ write: (commits, told) => store.write(keyOf(commits), told, Date.now()),
2151
+ }
2152
+ }
2153
+
2154
+ // GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
2155
+ // (repository-pulse.ts): the default branch's year, counted from its commits
2156
+ // (pulseCommits), and the pull requests and issues of the last `weeks`
2157
+ // weeks, each kept apart. A year not read whole yet answers how many of its
2158
+ // commits are (`reading`), or of the pull requests merged in it (`pulls`);
2159
+ // one GitHub's statistics count, while it is still computing them,
2160
+ // `pending`, for the client to ask again. They are the whole repository's
2161
+ // (readWholeRepository).
2162
+ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2163
+ const repoId = Number(query.get('repoId'))
2164
+ const weeks = Number(query.get('weeks') ?? '4')
2165
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks)) { sendJson(res, 400, { error: 'bad-request' }); return }
2166
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2167
+ const [period, year] = await Promise.all([
2168
+ keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => repositoryActivity(fullName, token, weeks)),
2169
+ pulseCommits(deps, repoId, fullName, token),
2170
+ ])
2171
+ return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
2172
+ pending: year.pending }, kept: period.kept && year.kept }
2173
+ })
2174
+ }
2175
+
2176
+ // The default branch's year (repository-pulse.ts commitStats), counted from
2177
+ // its commits as the Commits tab keeps them, with their lines
2178
+ // (yearCommits): kept by the head it was read from (repositoryReadStore),
2179
+ // that head kept as an issue's page is; its commits merged from an agent's
2180
+ // pull request told by those merged (agentMerges), kept as read, the rest
2181
+ // read on by the next answer as the year's commits are, and read again as
2182
+ // an answer kept is, a label changing whose a pull request is without
2183
+ // moving the head, the year counted again where what they bring changed.
2184
+ // A year not read whole yet is how many of its commits are (`reading`), or
2185
+ // of the pull requests merged (`pulls`), beside the year last counted from
2186
+ // an earlier head over the same weeks, where one is kept, so that a head
2187
+ // moved on shows the year as it was till it is counted again; one past
2188
+ // YEAR_COMMITS is GitHub's statistics' (repositoryStats, repositoryLines),
2189
+ // kept together as an issue's page is and through GitHub computing them
2190
+ // again, `pending` while it first computes them, the year last counted
2191
+ // beside it so too.
2192
+ // A day as a pulse's items are narrowed by (YYYY-MM-DD, UTC), when it starts;
2193
+ // a GitHub login, as many of them as PULSE_AUTHORS.
2194
+ const PULSE_DAY = /^\d{4}-\d{2}-\d{2}$/u
2195
+ const GITHUB_LOGIN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?:\[bot\])?$/iu
2196
+ const PULSE_AUTHORS = 10
2197
+ const pulseDay = (value: string | null) => {
2198
+ const at = value !== null && PULSE_DAY.test(value) ? Date.parse(`${value}T00:00:00Z`) : Number.NaN
2199
+ return Number.isSafeInteger(at) ? at : null
2200
+ }
2201
+
2202
+ // GET /api/admin/repositories/pulse/items: the Pulse tab's pull requests and
2203
+ // issues narrowed to a picked day or week (`from` to `to`, else on from
2204
+ // `from`) and the GitHub accounts a picked contributor committed as
2205
+ // (`authors`), as repositoryActivity reads them, kept apart for each.
2206
+ async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2207
+ const repoId = Number(query.get('repoId'))
2208
+ const weeks = Number(query.get('weeks') ?? '4')
2209
+ const [from, to] = [pulseDay(query.get('from')), query.has('to') ? pulseDay(query.get('to')) : null]
2210
+ const logins = (query.get('authors') ?? '').split(',').filter(Boolean)
2211
+ // Each once, whatever its case, as GitHub matches logins.
2212
+ const authors = [...new Set(logins.map(login => login.toLowerCase()))].toSorted()
2213
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks) || from === null || (query.has('to') && (to === null || to < from))
2214
+ || authors.length > PULSE_AUTHORS || !logins.every(login => GITHUB_LOGIN.test(login))) { sendJson(res, 400, { error: 'bad-request' }); return }
2215
+ await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2216
+ const narrowing = { from, to, authors }
2217
+ const items = await keptAnswer(deps, `${repoId}\0pulse-items\0${weeks}\0${from}\0${to ?? ''}\0${narrowing.authors.join(',')}`,
2218
+ () => repositoryActivity(fullName, token, weeks, Date.now(), globalThis.fetch, narrowing))
2219
+ return { value: { weeks, ...items.value }, kept: items.kept }
2220
+ })
2221
+ }
2222
+
2223
+ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
2224
+ const first = pulseSince(52, Date.now())
2225
+ const head = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2226
+ if (!head.value.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
2227
+ const store = repositoryReadStore(deps.db, deps.config.debug)
2228
+ const key = `${repoId}\0pulse-year\0${PULSE_YEAR_VERSION}\0${head.value.sha}\0${first}`
2229
+ const lastKey = `${repoId}\0pulse-year-last`
2230
+ const mergesKey = `${repoId}\0pulse-merges\0${PULSE_YEAR_VERSION}`
2231
+ const last = async () => {
2232
+ const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
2233
+ return kept?.first === first ? kept.stats : null
2234
+ }
2235
+ const sha = head.value.sha
2236
+ // What tells whose the year's commits are, kept as read: the agents' pull
2237
+ // requests merged (agentMerges), and the commits that release the
2238
+ // repository (`releases`, release-commits.ts releaseCommits).
2239
+ type Attribution = AgentMerges & { releases?: string[] }
2240
+ const keptAttribution = async () => ((await store.read(mergesKey))?.value as Attribution | undefined) ?? null
2241
+ const attribution = async (kept: Attribution | null): Promise<Attribution> => {
2242
+ const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept), releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
2243
+ return { ...merges, releases }
2244
+ }
2245
+ // The year counted from its `commits` as `read` tells whose they are and
2246
+ // which were pushed directly, the branch followed through those it was
2247
+ // read on past (`passed`, yearCommits) too; kept by its head and as the
2248
+ // year last counted.
2249
+ const count = async ({ commits, passed }: { commits: HistoryCommit[]; passed: HistoryCommit[] }, read: Attribution) => {
2250
+ const walked = [...commits, ...passed]
2251
+ const stats = commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha))
2252
+ await Promise.all([store.write(key, stats, Date.now()), store.write(lastKey, { first, stats }, Date.now())])
2253
+ return stats
2254
+ }
2255
+ // A year kept by its head, what tells whose its commits are read again as
2256
+ // an answer kept is (keptAnswer), a label or a tag changing it without
2257
+ // moving the head: counted again from its pages kept where what agents'
2258
+ // pull requests bring, or the releases, changed, or a read of the pull
2259
+ // requests left unfinished is finished; the year as kept answered
2260
+ // meanwhile, and where reading them fails.
2261
+ const relabelled = async (stats: PulseStats) => {
2262
+ const recounted: { stats?: PulseStats } = {}
2263
+ await keptAnswer(deps, mergesKey, async () => {
2264
+ const kept = await keptAttribution()
2265
+ const read = await attribution(kept)
2266
+ const same = kept && !kept.next && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join()
2267
+ if (read.next || same) return read
2268
+ const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
2269
+ if (year.complete) recounted.stats = await count(year, read)
2270
+ return read
2271
+ }, () => true).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
2272
+ return recounted.stats ?? stats
2273
+ }
2274
+ const held = (await store.read(key))?.value as PulseStats | { tooMany: true } | undefined
2275
+ if (held && !('tooMany' in held)) return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
2276
+ if (!held) {
2277
+ const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
2278
+ if (year.complete) {
2279
+ const read = await attribution(await keptAttribution())
2280
+ await store.write(mergesKey, read, Date.now())
2281
+ if (read.next) return { stats: await last(), reading: null, pulls: read.next.read, pending: false, kept: false }
2282
+ return { stats: await count(year, read), reading: null, pulls: null, pending: false, kept: false }
2283
+ }
2284
+ if (!year.tooMany) return { stats: await last(), reading: year.read, pulls: null, pending: false, kept: false }
2285
+ await store.write(key, { tooMany: true }, Date.now())
2286
+ }
2287
+ // Its commits and lines read together, kept as one: never the one GitHub
2288
+ // has counted since a push beside the other kept from before it.
2289
+ const computing = (err: unknown) => err instanceof StatsPending
2290
+ const counted = await keptAnswer(deps, `${repoId}\0pulse-stats`, async () => {
2291
+ const [stats, lines] = await Promise.all([repositoryStats(fullName, token), repositoryLines(fullName, token, first)])
2292
+ return withLines(stats, lines)
2293
+ }, computing).catch((err: unknown) => { if (computing(err)) return null; throw err })
2294
+ if (counted) return { stats: counted.value, reading: null, pulls: null, pending: false, kept: counted.kept }
2295
+ return { stats: await last(), reading: null, pulls: null, pending: true, kept: false }
2296
+ }
2297
+
1349
2298
  function prebuildBundle(deps: ManagedHttpDeps, id: string) {
1350
2299
  if (!deps.bundleCache || deps.config.serverless) return
1351
2300
  deps.track((async () => {
@@ -1420,9 +2369,13 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
1420
2369
  // (repository-browser.ts), and that access checked again once it is read,
1421
2370
  // before any of it is told; packages as the npm viewer's admins read them, in
1422
2371
  // 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.
2372
+ // their scope, and each lockfile's asked of npm's advisories at once; each
2373
+ // repository's Dependabot alerts, open or dismissed, read with the App's
2374
+ // installation token where the admin's own access to it lets them see them on
2375
+ // GitHub (dependabot-access.ts); and its GitHub Actions, the references they
2376
+ // make resolved with the admin's own GitHub token (github-actions.ts). The
2377
+ // reader leaving, losing their admin role or
2378
+ // the server shutting down stops it.
1426
2379
  async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
1427
2380
  const s = await readAdminSession(res, deps, cookie)
1428
2381
  if (!s) return
@@ -1435,7 +2388,8 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1435
2388
  const controller = new AbortController()
1436
2389
  res.on('close', () => { controller.abort() })
1437
2390
  res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
1438
- const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
2391
+ const user = await repositoryBrowserUser(deps, s.user.id)
2392
+ const browser = createRepositoryBrowser(deps.config, user)
1439
2393
  const scopes = new Map<string, Promise<boolean>>()
1440
2394
  const teamScoped = (name: string) => {
1441
2395
  const scope = npmPackageScope(name)
@@ -1460,8 +2414,15 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1460
2414
  lockfiles: async () => (await upstreamGithub(() => client.findRepoLockfiles({ repo: repo.fullName, sha: commit }))).lockfiles,
1461
2415
  file: path => upstreamGithub(() => client.getRepoFile({ repo: repo.fullName, path, ref: commit })),
1462
2416
  recheck: async () => { await reader.recheckAccess() },
2417
+ // As GitHub has them now, where the admin's own access lets them see them.
2418
+ dependabot: () => reader.dependabotAlerts(),
2419
+ workflows: () => reader.directory(WORKFLOWS_DIR, commit).then(({ entries }) => entries.filter(entry => entry.type === 'file' && /\.ya?ml$/iu.test(entry.name)).map(entry => entry.path),
2420
+ (error: unknown) => { if (error instanceof GithubApiError && error.status === 404) return []; throw error }),
1463
2421
  }
1464
2422
  },
2423
+ actions: githubActionsSource(user.token),
2424
+ actionFiles: scanReadStore(deps.db, 'action-files', SCAN_READ_KEPT_MS, deps.config.debug),
2425
+ actionLines: scanReadStore(deps.db, 'actions', RISK_KEPT_MS, deps.config.debug),
1465
2426
  teamScoped,
1466
2427
  // Each package's downloads readied before it is assessed
1467
2428
  // (scan-downloads.ts), so that none waits on npm's downloads API; a
@@ -2713,6 +3674,11 @@ function found<T>(value: T | null | undefined): T {
2713
3674
  return value
2714
3675
  }
2715
3676
 
3677
+ // Version documents a list of reviews reads at once: each holds a version
3678
+ // document's room in what npm's reads may hold at once (npm-packages.ts),
3679
+ // which refuses past thirty-two of them.
3680
+ const REVIEW_READS_AT_ONCE = 8
3681
+
2716
3682
  // Lists must enforce the same target and base access as the review itself,
2717
3683
  // including permission changes while the registry requests are in flight.
2718
3684
  async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[]): Promise<T[] | null> {
@@ -2722,18 +3688,15 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
2722
3688
  try {
2723
3689
  const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
2724
3690
  if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2725
- const documents = new Map<string, Promise<NpmVersionDocument | null>>()
2726
3691
  // Public versions' documents as the viewer keeps them (npm-kept.ts), a
2727
- // day: a list asks npm for none it read within it.
3692
+ // day: a list asks npm for none it read within it. Each version once, as
3693
+ // many at once as REVIEW_READS_AT_ONCE.
2728
3694
  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
- ])))
3695
+ const versionsOf = (review: T) => [[review.package, review.version], ...review.basePackage ? [[review.basePackage, review.baseVersion!]] : []] as [string, string][]
3696
+ const asked = new Map(reviews.flatMap(versionsOf).map(([name, version]) => [`${name}\0${version}`, [name, version] as const]))
3697
+ const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
3698
+ const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
3699
+ const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
2737
3700
  const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
2738
3701
  if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2739
3702
  return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
@@ -2882,7 +3845,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2882
3845
  const allBases = params.get('allBases') === 'true'
2883
3846
  const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
2884
3847
  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) })
3848
+ if (reviews) sendJson(res, 200, { reviews: await withFormatted(deps.reviewedDependencies ?? null, current.userId, reviews), teams: await deps.db.listDependencyReviewTeams(current.userId) })
2886
3849
  return
2887
3850
  }
2888
3851
  const checked = await checkMutation(req, res, deps, cookie)
@@ -2892,7 +3855,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2892
3855
  sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
2893
3856
  }
2894
3857
  const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
2895
- sendJson(res, 200, { review })
3858
+ sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
2896
3859
  } catch (err) {
2897
3860
  if (controller.signal.aborted) return
2898
3861
  if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
@@ -2916,7 +3879,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2916
3879
  if (!isNpmPackageName(name) || !isNpmPackageName(otherName) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
2917
3880
  || (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
2918
3881
  || (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
2919
- || (path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
3882
+ || ((path === NPM_COMPARISON_PATH || path === NPM_DELTA_PATH) && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
2920
3883
  sendJson(res, 400, { error: 'bad-package' }); return
2921
3884
  }
2922
3885
  const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
@@ -3020,6 +3983,20 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
3020
3983
  return
3021
3984
  }
3022
3985
  if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
3986
+ // The lines it changes against `otherVersion`, or `(empty)`, its whole
3987
+ // package's, and those but for declaration files changed in types alone
3988
+ // (npm-delta.ts), as a list of versions wanting reviews tells them.
3989
+ if (path === NPM_DELTA_PATH) {
3990
+ const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
3991
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
3992
+ const side = (version: NpmVersionDocument) => ({ integrity: version.dist.integrity, files: async () => {
3993
+ const load = loadNpmFiles(version)
3994
+ try { return await load.result } finally { load.release() }
3995
+ } })
3996
+ const delta = await npmDelta(old ? side(old) : null, side(doc), npmDeltaStore<NpmDelta>(deps.db, deps.config.debug))
3997
+ await reply(doc.private || old?.private === true, { name, version: doc.version, otherVersion: old?.version ?? null, ...delta })
3998
+ return
3999
+ }
3023
4000
  // Its files paired with those of `otherVersion` (of `otherName`, else the
3024
4001
  // same package), as Compare and Review pair them, with what their
3025
4002
  // Overview reads beside them (comparison.ts): the version shown against
@@ -3207,6 +4184,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3207
4184
  if (path === CALLBACK_PATH) {
3208
4185
  if (method !== 'GET') { send405(res, 'GET'); return }
3209
4186
  try {
4187
+ if (isLinkOAuthCallback(config, url.searchParams, cookie)) {
4188
+ if (viewing) { sendViewOnly(res); return }
4189
+ const s = await readWorkspaceSession(res, deps, cookie)
4190
+ if (!s) return
4191
+ const result = await linkOAuthCallback(config, db, s.session, url.searchParams, cookie, { avatarStore })
4192
+ if (result.linked != null) await activity(deps, s.user, 'access', `linked GitHub account @${result.linked}`)
4193
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
4194
+ res.end(); return
4195
+ }
3210
4196
  if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
3211
4197
  if (viewing) { sendViewOnly(res); return }
3212
4198
  const s = await readWorkspaceSession(res, deps, cookie)
@@ -3234,6 +4220,17 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3234
4220
  res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
3235
4221
  res.end(); return
3236
4222
  }
4223
+ if (path === LINK_LOGIN_PATH) {
4224
+ if (method !== 'GET') { send405(res, 'GET'); return }
4225
+ const s = await readWorkspaceSession(res, deps, cookie)
4226
+ if (!s) return
4227
+ const result = linkLoginRedirect(config, s.session)
4228
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
4229
+ res.end(); return
4230
+ }
4231
+ if (path === PROFILE_PATH) { await handleProfile(req, res, deps, cookie); return }
4232
+ const unlink = /^\/api\/auth\/profile\/accounts\/([a-f\d-]{36})$/iu.exec(path)
4233
+ if (unlink) { await handleUnlinkAccount(req, res, deps, cookie, unlink[1]!); return }
3237
4234
  if (path === SESSION_PATH) {
3238
4235
  if (method !== 'GET') { send405(res, 'GET'); return }
3239
4236
  const { body, headers } = await currentSession(config, db, cookie)
@@ -3274,12 +4271,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3274
4271
  if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
3275
4272
  // Cached avatar by user id, served same-origin (the page CSP forbids the
3276
4273
  // 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.
4274
+ // switch never serves a stale avatar. Workspace access is required. A user's
4275
+ // is that of the account chosen on their profile; `own` asks for the
4276
+ // sign-in account's. A linked account's is served by its own id.
3278
4277
  if (path.startsWith(AVATAR_PREFIX)) {
3279
4278
  if (method !== 'GET') { send405(res, 'GET'); return }
3280
4279
  const s = await readSession(config, db, cookie, Date.now())
3281
4280
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
3282
- await serveAvatar(res, avatarStore, path.slice(AVATAR_PREFIX.length))
4281
+ const id = path.slice(AVATAR_PREFIX.length)
4282
+ if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
4283
+ await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
3283
4284
  return
3284
4285
  }
3285
4286
  // Admin: list users (admin-only).
@@ -3320,8 +4321,57 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3320
4321
  if (method !== 'GET') { send405(res, 'GET'); return }
3321
4322
  await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
3322
4323
  }
4324
+ if (path === REPO_CODE_PATH) {
4325
+ if (method !== 'GET') { send405(res, 'GET'); return }
4326
+ await handleRepositoryCode(res, deps, cookie, url.searchParams); return
4327
+ }
4328
+ if (path === REPO_HISTORY_PATH) {
4329
+ if (method !== 'GET') { send405(res, 'GET'); return }
4330
+ await handleRepositoryHistory(res, deps, cookie, url.searchParams); return
4331
+ }
4332
+ if (path === REPO_CHANGES_PATH) {
4333
+ if (method !== 'GET') { send405(res, 'GET'); return }
4334
+ await handleRepositoryChanges(res, deps, cookie, url.searchParams); return
4335
+ }
4336
+ if (path === REPO_TREE_PATH) {
4337
+ if (method !== 'GET') { send405(res, 'GET'); return }
4338
+ await handleRepositoryTree(res, deps, cookie, url.searchParams); return
4339
+ }
4340
+ if (path === REPO_ISSUES_PATH) {
4341
+ if (method !== 'GET') { send405(res, 'GET'); return }
4342
+ await handleRepositoryIssues(res, deps, cookie, url.searchParams); return
4343
+ }
4344
+ if (path === REPO_AGENTS_PATH) {
4345
+ if (method !== 'GET') { send405(res, 'GET'); return }
4346
+ await handleRepositoryAgents(res, deps, cookie, url.searchParams); return
4347
+ }
4348
+ if (path === REPO_ITEM_PATH) {
4349
+ if (method !== 'GET') { send405(res, 'GET'); return }
4350
+ await handleRepositoryItem(res, deps, cookie, url.searchParams); return
4351
+ }
4352
+ if (path === REPO_PULL_DEPENDENCIES_PATH) {
4353
+ if (method !== 'GET') { send405(res, 'GET'); return }
4354
+ await handleRepositoryPullDependencies(res, deps, cookie, url.searchParams); return
4355
+ }
4356
+ if (path === REPO_BRANCH_DEPENDENCIES_PATH) {
4357
+ if (method !== 'GET') { send405(res, 'GET'); return }
4358
+ await handleRepositoryBranchDependencies(res, deps, cookie, url.searchParams); return
4359
+ }
4360
+ if (path === REPO_LOCKFILES_PATH) {
4361
+ if (method !== 'GET') { send405(res, 'GET'); return }
4362
+ await handleRepositoryLockfiles(res, deps, cookie, url.searchParams); return
4363
+ }
4364
+ if (path === REPO_PULSE_PATH) {
4365
+ if (method !== 'GET') { send405(res, 'GET'); return }
4366
+ await handleRepositoryPulse(res, deps, cookie, url.searchParams); return
4367
+ }
4368
+ if (path === REPO_PULSE_ITEMS_PATH) {
4369
+ if (method !== 'GET') { send405(res, 'GET'); return }
4370
+ await handleRepositoryPulseItems(res, deps, cookie, url.searchParams); return
4371
+ }
3323
4372
  if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
3324
4373
  if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
4374
+ if (path === REPO_DEPENDENCY_REVIEW_PATH) { await handleRepositoryDependencyReview(req, res, deps, cookie); return }
3325
4375
  if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
3326
4376
  if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
3327
4377
  // Reports: list / upload on the exact path, download / delete per-id on the
@@ -3513,7 +4563,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3513
4563
  }
3514
4564
  const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
3515
4565
  const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
3516
- if (visible) sendJson(res, 200, { review: visible[0] ?? null })
4566
+ if (visible) sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, s.user.id, visible))[0] ?? null })
3517
4567
  return
3518
4568
  }
3519
4569
  if (path === '/api/npm/reviews/discard') {
@@ -3543,7 +4593,27 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
3543
4593
  }
3544
4594
  const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
3545
4595
  const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
3546
- if (reviews) sendJson(res, 200, { ...page, reviews })
4596
+ if (reviews) sendJson(res, 200, { ...page, reviews: await withFormatted(deps.reviewedDependencies ?? null, s.user.id, reviews) })
4597
+ return
4598
+ }
4599
+ // Those published to a team, of one who reads them there, as its
4600
+ // Dependencies tab lists them, fifty a page.
4601
+ if (path === '/api/npm/reviews/team') {
4602
+ if (method !== 'GET') { send405(res, 'GET'); return }
4603
+ const s = await readWorkspaceSession(res, deps, cookie)
4604
+ if (!s) return
4605
+ const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
4606
+ if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
4607
+ if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
4608
+ sendJson(res, 400, { error: 'bad-review-cursor' }); return
4609
+ }
4610
+ const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
4611
+ if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
4612
+ const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
4613
+ if (!reviews) return
4614
+ // Its access asked again, as it may have been taken while npm was read.
4615
+ if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
4616
+ sendJson(res, 200, { ...page, reviews })
3547
4617
  return
3548
4618
  }
3549
4619
  if (path === '/api/npm/reviews') {