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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +25 -2
  5. package/common/default-scan-models.ts +1 -1
  6. package/common/dependency-reviews.d.ts +0 -5
  7. package/common/dependency-reviews.js +0 -17
  8. package/common/line-diff.js +5 -4
  9. package/common/managed/agent-tasks.ts +292 -0
  10. package/common/managed/github-agents.ts +92 -0
  11. package/common/managed/github-logins.ts +49 -0
  12. package/common/managed/roles.ts +7 -0
  13. package/common/managed/routes.js +88 -7
  14. package/common/managed/team-app.d.ts +5 -2
  15. package/common/managed/team-app.js +2 -2
  16. package/common/server-info.ts +3 -0
  17. package/common/workspace-app.js +6 -3
  18. package/out/brotli-fallback.js +3 -3
  19. package/out/chunk-2XUEZHIP.js +18 -18
  20. package/out/chunk-2ZDZWLFY.js +1 -0
  21. package/out/chunk-3PS4KWD2.js +10 -0
  22. package/out/chunk-4HI2MSAV.js +17 -0
  23. package/out/chunk-4UFAMJAX.js +1 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-7XRR4OFU.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-BOIXMUU5.js +3 -0
  28. package/out/chunk-C4Z6ZBXV.js +6 -6
  29. package/out/chunk-CC4OM6TZ.js +3 -0
  30. package/out/chunk-G24A42V6.js +2 -0
  31. package/out/chunk-GDPCLSOS.js +2 -0
  32. package/out/chunk-GO7FT4OD.js +1 -0
  33. package/out/chunk-I2MDGSQX.js +3 -0
  34. package/out/chunk-IYPY47MC.js +1 -0
  35. package/out/chunk-IZ7POWI3.js +18 -0
  36. package/out/chunk-JCIIBLKR.js +1 -1
  37. package/out/chunk-JKGQ3O7O.js +6 -0
  38. package/out/chunk-LE5ZWLG4.js +235 -0
  39. package/out/chunk-MSION3G4.js +36 -0
  40. package/out/chunk-NRPC74AM.js +1 -1
  41. package/out/chunk-OKRLLE5I.js +17 -0
  42. package/out/chunk-OOJM4CTU.js +1 -1
  43. package/out/chunk-PNHBZGDD.js +1 -1
  44. package/out/chunk-PXK7EU4V.js +1 -0
  45. package/out/chunk-QB4CBOX2.js +1 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-S6GIXWQW.js +52 -0
  49. package/out/chunk-SXV2T5C4.js +80 -0
  50. package/out/chunk-TUII34K2.js +1 -1
  51. package/out/chunk-V3KWNNMY.js +1 -0
  52. package/out/chunk-VFWMQ3LG.js +2 -0
  53. package/out/chunk-VH4XMZIW.js +2 -0
  54. package/out/chunk-WFYLMF6Z.js +10 -0
  55. package/out/chunk-WQ74ZBZF.js +7 -0
  56. package/out/client-managed-import.js +3 -3
  57. package/out/client-managed.js +31 -35
  58. package/out/client-sync.js +11 -11
  59. package/out/compare.js +35 -0
  60. package/out/dialogs.js +51 -0
  61. package/out/github-issue-authorized.html +1 -1
  62. package/out/github-issue-authorized.js +1 -1
  63. package/out/graph.js +17 -17
  64. package/out/index.html +30 -34
  65. package/out/prism.js +14 -14
  66. package/out/scan.js +2 -0
  67. package/out/terminal.js +294 -293
  68. package/out/view.css +1 -1
  69. package/out/view.js +16 -107
  70. package/package.json +53 -6
  71. package/risk/actions/findings.js +161 -0
  72. package/risk/actions/resolve.js +219 -0
  73. package/risk/actions/scripts/commands.js +182 -0
  74. package/risk/actions/scripts/grade.js +113 -0
  75. package/risk/actions/scripts/packages.js +123 -0
  76. package/risk/actions/scripts/shell.js +169 -0
  77. package/risk/actions/uses.js +76 -0
  78. package/risk/actions/workflow.js +110 -0
  79. package/risk/files/hosts.js +6 -6
  80. package/risk/files/readability.js +23 -17
  81. package/risk/files/runs-code.js +18 -6
  82. package/risk/files/script.js +7 -3
  83. package/risk/files/stamps.js +8 -7
  84. package/risk/index.d.ts +71 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +1 -1
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/files.js +413 -2
  89. package/risk/npm/tarball.js +56 -73
  90. package/server-e2e/app.ts +5 -4
  91. package/server-e2e/http.ts +5 -2
  92. package/server-managed/agent-merges.ts +198 -0
  93. package/server-managed/app-agents.ts +30 -0
  94. package/server-managed/avatar-store.ts +1 -0
  95. package/server-managed/bundle-cache.ts +7 -1
  96. package/server-managed/combined.ts +3 -2
  97. package/server-managed/commit-coauthors.ts +83 -0
  98. package/server-managed/commit-lockfiles.ts +72 -0
  99. package/server-managed/config.ts +6 -0
  100. package/server-managed/db-methods.ts +66 -19
  101. package/server-managed/db-neon.ts +89 -21
  102. package/server-managed/db-schema.ts +13 -2
  103. package/server-managed/db.ts +9 -1
  104. package/server-managed/dependabot-access.ts +60 -0
  105. package/server-managed/dependabot-alerts.ts +185 -0
  106. package/server-managed/dependency-review-comments.ts +97 -0
  107. package/server-managed/dependency-reviews.ts +78 -18
  108. package/server-managed/github-accounts.ts +33 -0
  109. package/server-managed/github-actions.ts +71 -0
  110. package/server-managed/github-app.ts +33 -13
  111. package/server-managed/github-errors.ts +2 -0
  112. package/server-managed/github-issue-links.ts +11 -5
  113. package/server-managed/github-link-oauth.ts +69 -0
  114. package/server-managed/github-links.ts +204 -0
  115. package/server-managed/github-oauth.ts +5 -2
  116. package/server-managed/github-pulls.ts +2 -2
  117. package/server-managed/github-requests.ts +39 -0
  118. package/server-managed/github-users.ts +30 -0
  119. package/server-managed/http-response.ts +10 -2
  120. package/server-managed/http.ts +1251 -97
  121. package/server-managed/index.ts +13 -8
  122. package/server-managed/kept-briefly.ts +37 -0
  123. package/server-managed/locked-packages.ts +37 -16
  124. package/server-managed/lockfile-scope.ts +49 -0
  125. package/server-managed/npm-delta-worker.js +23 -0
  126. package/server-managed/npm-delta.ts +178 -0
  127. package/server-managed/npm-insights.ts +20 -11
  128. package/server-managed/npm-kept.ts +83 -6
  129. package/server-managed/npm-loads.ts +114 -37
  130. package/server-managed/npm-packages.ts +110 -64
  131. package/server-managed/npm-risk.ts +4 -1
  132. package/server-managed/pretty-print.ts +67 -23
  133. package/server-managed/pull-comment-reviews.ts +161 -0
  134. package/server-managed/pull-dependencies.ts +300 -0
  135. package/server-managed/pull-dependency-reviews.ts +318 -0
  136. package/server-managed/pull-reviews.ts +205 -0
  137. package/server-managed/queue.ts +16 -0
  138. package/server-managed/release-commits.ts +84 -0
  139. package/server-managed/rename-diff.ts +22 -13
  140. package/server-managed/report-sources.ts +1 -1
  141. package/server-managed/report-store.ts +42 -9
  142. package/server-managed/repository-actions.ts +218 -0
  143. package/server-managed/repository-agents.ts +220 -0
  144. package/server-managed/repository-browser.ts +166 -37
  145. package/server-managed/repository-changes.ts +284 -0
  146. package/server-managed/repository-code.ts +52 -0
  147. package/server-managed/repository-dependency-reviews.ts +157 -0
  148. package/server-managed/repository-history.ts +461 -0
  149. package/server-managed/repository-issues.ts +181 -0
  150. package/server-managed/repository-pulse.ts +439 -0
  151. package/server-managed/repository-risk-audit.ts +13 -0
  152. package/server-managed/repository-risk-read.ts +170 -0
  153. package/server-managed/repository-risk.ts +133 -118
  154. package/server-managed/reviewed-dependencies.ts +149 -0
  155. package/server-managed/session.ts +27 -9
  156. package/server-managed/sql.ts +6 -3
  157. package/server-managed/storage-stores.ts +5 -0
  158. package/server-managed/storage.ts +4 -2
  159. package/server-managed/team-app.ts +19 -3
  160. package/server-managed/team-reports.ts +24 -12
  161. package/server-managed/upstream-cache.ts +12 -0
  162. package/server-managed/workspace-shares.ts +4 -1
  163. package/server.ts +25 -5
  164. package/out/chunk-2ABLKCOY.js +0 -46
  165. package/out/chunk-2KBF5TZ7.js +0 -2
  166. package/out/chunk-35VE6CDH.js +0 -2
  167. package/out/chunk-3BZ7JERB.js +0 -1
  168. package/out/chunk-4XCDFGDP.js +0 -10
  169. package/out/chunk-5LSXOLUS.js +0 -1
  170. package/out/chunk-5RCM2J63.js +0 -1
  171. package/out/chunk-7B6HB4D7.js +0 -1
  172. package/out/chunk-AJ4GTATZ.js +0 -3
  173. package/out/chunk-CH4UN6WK.js +0 -225
  174. package/out/chunk-CMQA4DM6.js +0 -1
  175. package/out/chunk-CVVJAXVY.js +0 -2
  176. package/out/chunk-DIJJNTRH.js +0 -10
  177. package/out/chunk-EDKLBQQN.js +0 -17
  178. package/out/chunk-F47DXCEV.js +0 -33
  179. package/out/chunk-FK3DZEKL.js +0 -1
  180. package/out/chunk-G6AJWENO.js +0 -1
  181. package/out/chunk-GJ4IUCNS.js +0 -1
  182. package/out/chunk-GNXOKIGP.js +0 -80
  183. package/out/chunk-GZ4MS3TE.js +0 -13
  184. package/out/chunk-GZWM7UQM.js +0 -2
  185. package/out/chunk-IU3CCINR.js +0 -3
  186. package/out/chunk-K3X5EJBQ.js +0 -2
  187. package/out/chunk-KEX2ATEW.js +0 -2
  188. package/out/chunk-KYCNJVB7.js +0 -1
  189. package/out/chunk-LPCTJYNA.js +0 -7
  190. package/out/chunk-O4RJY5TP.js +0 -2
  191. package/out/chunk-OD4LPN3H.js +0 -1
  192. package/out/chunk-QWDWTBM7.js +0 -1
  193. package/out/chunk-QYWKC557.js +0 -1
  194. package/out/chunk-R75XOKUP.js +0 -1
  195. package/out/chunk-SWTLADW5.js +0 -1
  196. package/out/chunk-T7OKH4VW.js +0 -1
  197. package/out/chunk-TZ5KB2Y5.js +0 -10
  198. package/out/chunk-UNTRGE26.js +0 -2
  199. package/out/chunk-UTR2LVME.js +0 -2
  200. package/out/chunk-UYAXVRVL.js +0 -1
  201. package/out/chunk-V3ME6FI5.js +0 -2
  202. package/out/chunk-V5O7M2YT.js +0 -1
  203. package/out/chunk-VIHGO77Y.js +0 -1
  204. package/out/chunk-W4J3MGLB.js +0 -1
  205. package/out/chunk-WGG7KQUH.js +0 -1
  206. package/out/chunk-XYFXXB2G.js +0 -10
  207. package/out/chunk-ZESLQCTF.js +0 -6
@@ -7,8 +7,10 @@ import { createHash, createSign } from 'node:crypto'
7
7
  import { createClient } from '@preventive/upstream/github.js'
8
8
  import type { ManagedConfig } from './config.ts'
9
9
  import { GithubApiError, githubUnreachable, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
10
+ import { onTokenRejected, shareGithubRead, tokenRejected } from './github-requests.ts'
10
11
 
11
12
  export { GithubApiError } from './github-errors.ts'
13
+ export { onTokenRejected } from './github-requests.ts'
12
14
 
13
15
  const GITHUB_API = 'https://api.github.com'
14
16
  const API_VERSION = '2022-11-28'
@@ -22,12 +24,12 @@ interface InstallationTokenEntry { token: string | null; expiresAt: number; pend
22
24
  // cached here. Bound memory per transport and separate App/key/install scopes.
23
25
  const installationTokens = new WeakMap<typeof fetch, Map<string, InstallationTokenEntry>>()
24
26
 
25
- function discardInstallationToken(token: string | null, fetchImpl: typeof fetch): void {
26
- if (!token) return
27
+ // A token GitHub rejected (401): an installation's no longer reused.
28
+ onTokenRejected((token, fetchImpl) => {
27
29
  const cache = installationTokens.get(fetchImpl)
28
30
  if (!cache) return
29
31
  for (const [key, entry] of cache) if (entry.token === token) cache.delete(key)
30
- }
32
+ })
31
33
 
32
34
  // One listed repository. Carries the context to read its contents later:
33
35
  // `installationId` mints an App installation token (Contents: Read) for a repo
@@ -67,23 +69,29 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
67
69
  }
68
70
 
69
71
  // One GitHub API call returning parsed JSON, optionally Bearer-authed by a
70
- // user token, App JWT, or installation token. Network / non-2xx / malformed
71
- // fold into a GithubApiError (401 passes through for the caller to handle).
72
- export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET'): Promise<unknown> {
72
+ // user token, App JWT, or installation token, `body` sent as JSON (a GraphQL
73
+ // query's, given longer to answer). Network / non-2xx / malformed fold into a
74
+ // GithubApiError (401 passes through for the caller to handle). Identical GETs
75
+ // share an outstanding read through its body. POSTs only opt in for read-only
76
+ // GraphQL queries (repository-history.ts), never for mutations or token mints.
77
+ export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
78
+ readOnly = method === 'GET'): Promise<unknown> {
79
+ if (readOnly) return shareGithubRead(fetchImpl, JSON.stringify([url, token, method, body]), () => githubJson(url, token, fetchImpl, method, body, false))
73
80
  let res: Response
74
81
  const started = Date.now()
75
82
  try {
76
83
  res = await fetchImpl(url, {
77
84
  method,
78
- signal: AbortSignal.timeout(10_000),
85
+ signal: AbortSignal.timeout(body === undefined ? 10_000 : 20_000),
79
86
  redirect: 'error',
80
87
  headers: {
81
88
  ...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
82
- 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
89
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }), 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
83
90
  },
91
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
84
92
  })
85
93
  } catch (error) { throw githubUnreachable(error, started) }
86
- if (res.status === 401) discardInstallationToken(token, fetchImpl)
94
+ if (res.status === 401 && token) tokenRejected(token, fetchImpl)
87
95
  if (!res.ok) await throwGithubResponseError(res)
88
96
  // The answer's body is read within the same timeout: one not read whole is
89
97
  // GitHub not reached, as one not answered is; only one that isn't JSON is
@@ -302,6 +310,9 @@ async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>
302
310
  return results
303
311
  }
304
312
 
313
+ // A GitHub account, as GitHub's /user tells it.
314
+ export interface GithubIdentity { id: number; login: string }
315
+
305
316
  // The login App and repository App are separate: /user/repos with the login
306
317
  // token is not evidence of private-repo access. Ask the repository App for the
307
318
  // user's effective GitHub permission (including teams/org/enterprise grants).
@@ -310,23 +321,32 @@ async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>
310
321
  // https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
311
322
  // Resolve the current login from /user so renamed handles cannot check someone
312
323
  // else's access. All repo paths come from GitHub's installation catalogue.
313
- export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<{ id: number; login: string }> {
324
+ export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<GithubIdentity> {
314
325
  const identity = await githubJson(`${GITHUB_API}/user`, userToken, fetchImpl) as { id?: unknown; login?: unknown }
315
326
  if (typeof identity?.login !== 'string' || !identity.login || typeof identity.id !== 'number' || !Number.isSafeInteger(identity.id) || identity.id <= 0) throw new GithubApiError(502, 'github-malformed')
316
327
  return { id: identity.id, login: identity.login }
317
328
  }
318
329
 
319
- export async function githubRepoReadPermission(fullName: string, token: string, identity: { id: number; login: string }, fetchImpl: typeof fetch = globalThis.fetch): Promise<boolean> {
330
+ // The permission's level (`admin`, `write`, `read`), as GitHub tells it of
331
+ // `identity` alone; `none` where it tells of none, or of another account.
332
+ export async function githubRepoPermission(fullName: string, token: string, identity: GithubIdentity, fetchImpl: typeof fetch = globalThis.fetch): Promise<string> {
320
333
  const path = fullName.split('/').map(encodeURIComponent).join('/')
321
334
  try {
322
335
  const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login)}/permission`, token, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
323
- return body?.user?.id === identity.id && ['read', 'write', 'admin'].includes(String(body?.permission))
336
+ return body?.user?.id === identity.id && typeof body.permission === 'string' ? body.permission : 'none'
324
337
  } catch (err) {
325
- if (err instanceof GithubApiError && err.status === 404) return false
338
+ if (err instanceof GithubApiError && err.status === 404) return 'none'
326
339
  throw err // Never broaden access when GitHub cannot verify permission.
327
340
  }
328
341
  }
329
342
 
343
+ // The levels that read a repository.
344
+ export const READ_PERMISSIONS: ReadonlySet<string> = new Set(['read', 'write', 'admin'])
345
+
346
+ export async function githubRepoReadPermission(fullName: string, token: string, identity: GithubIdentity, fetchImpl: typeof fetch = globalThis.fetch): Promise<boolean> {
347
+ return READ_PERMISSIONS.has(await githubRepoPermission(fullName, token, identity, fetchImpl))
348
+ }
349
+
330
350
  export async function filterInstalledRepos(config: ManagedConfig, repositories: ConnectedRepo[], userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
331
351
  const identity = await githubUserIdentity(userToken, fetchImpl)
332
352
  const tokens = new Map<number, Promise<string | null>>()
@@ -50,6 +50,8 @@ export async function throwGithubResponseError(res: Response): Promise<never> {
50
50
  export async function upstreamGithub<T>(request: () => Promise<T>): Promise<T> {
51
51
  const started = Date.now()
52
52
  try { return await request() } catch (error) {
53
+ // Already told as the router surfaces it.
54
+ if (error instanceof GithubApiError) throw error
53
55
  if (error instanceof HttpError) {
54
56
  // The upstream error includes GitHub's response text. Retain managed
55
57
  // status handling, including rate limits, without exposing that text.
@@ -52,6 +52,15 @@ function readPage(job: Job, item: Issue | Pull, metadata: Map<string, Metadata>)
52
52
 
53
53
  function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string, Repository>, checkedAt: number, metadata: Map<string, Metadata>) {
54
54
  const updates: IssueFixUpdate[] = []
55
+ // The issues each job's issue is, by repository and number, read once.
56
+ const byIssue = new Map<string, ManagedIssue[]>()
57
+ for (const issue of issues) {
58
+ const ref = parseGithubFixUrl(issue.issueUrl)
59
+ if (ref?.kind !== 'issue') continue
60
+ const key = `${issue.repoId}:${ref.number}`, same = byIssue.get(key)
61
+ if (same) same.push(issue)
62
+ else byIssue.set(key, [issue])
63
+ }
55
64
  for (const job of jobs) {
56
65
  if (!job.linked || !job.complete) continue
57
66
  // "Last" means the newest PR by creation time, never its latest comment.
@@ -62,11 +71,8 @@ function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string,
62
71
  if (latest && (!repo || repo.repoId !== latest.repository.databaseId)) continue
63
72
  const next = latest ? `https://github.com/${repo!.github}/pull/${latest.number}` : null
64
73
  if (latest && next) metadata.set(next, { url: next, ...itemMetadata(latest, 'pull')! })
65
- for (const issue of issues) {
66
- const ref = parseGithubFixUrl(issue.issueUrl)
67
- if (ref?.kind === 'issue' && ref.number === job.number && issue.repoId === job.repo.repoId) {
68
- updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
69
- }
74
+ for (const issue of byIssue.get(`${job.repo.repoId}:${job.number}`) ?? []) {
75
+ updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
70
76
  }
71
77
  }
72
78
  return updates
@@ -0,0 +1,69 @@
1
+ // Linking another GitHub account to the signed-in user (github-links.ts): the
2
+ // App's user authorization again, completed by the account to link. GitHub is
3
+ // asked for its account picker, as the browser is usually signed in to the
4
+ // account the user signs in here with. The token GitHub grants reads the
5
+ // account's identity and avatar; it is never stored.
6
+ import type { AvatarStore } from './avatar-store.ts'
7
+ import type { ManagedConfig } from './config.ts'
8
+ import type { ManagedDb, ManagedSession } from './db.ts'
9
+ import { randomToken, safeEqual } from './crypto.ts'
10
+ import { LINK_STATE_COOKIE, buildCookie, clearCookie, cookieName, parseCookies } from './session.ts'
11
+ import { OAuthError, cacheAvatar, exchangeCode, fetchIdentity } from './github-oauth.ts'
12
+ import { ManagedMutationError } from './management.ts'
13
+
14
+ export const LINK_LOGIN_PATH = '/api/oauth/github/link'
15
+ // The profile page, where linking starts and its callback lands.
16
+ const PROFILE_PATH = '/my/profile'
17
+ const STATE_TTL_MS = 600_000
18
+
19
+ export function isLinkOAuthCallback(config: ManagedConfig, query: URLSearchParams, cookie: string | undefined): boolean {
20
+ const expected = parseCookies(cookie).get(cookieName(config, LINK_STATE_COOKIE))?.split('.')[0]
21
+ return !!expected && safeEqual(query.get('state') ?? '', expected)
22
+ }
23
+
24
+ // The state is bound to the session that started linking, so the account
25
+ // links to the user who asked, never to whoever is signed in on return.
26
+ export function linkLoginRedirect(config: ManagedConfig, session: ManagedSession, now = Date.now()): { location: string; setCookie: string } {
27
+ const state = randomToken()
28
+ const url = new URL('https://github.com/login/oauth/authorize')
29
+ url.searchParams.set('client_id', config.githubClientId)
30
+ url.searchParams.set('redirect_uri', config.oauthCallbackUrl)
31
+ url.searchParams.set('state', state)
32
+ url.searchParams.set('allow_signup', 'false')
33
+ url.searchParams.set('prompt', 'select_account')
34
+ return { location: url.href, setCookie: buildCookie(cookieName(config, LINK_STATE_COOKIE), `${state}.${session.id}.${now}`,
35
+ { secure: config.cookieSecure, maxAgeS: STATE_TTL_MS / 1000, sameSite: 'Lax' }) }
36
+ }
37
+
38
+ // Back from GitHub: link the account that authorized, then land on the
39
+ // profile, naming the refusal (`link-error`) when it was refused.
40
+ // `linked` is the login of an account linked anew.
41
+ export async function linkOAuthCallback(config: ManagedConfig, db: ManagedDb, session: ManagedSession,
42
+ query: URLSearchParams, cookie: string | undefined,
43
+ { avatarStore, fetchImpl = fetch, now = Date.now() }: { avatarStore?: AvatarStore; fetchImpl?: typeof fetch; now?: number } = {},
44
+ ): Promise<{ location: string; setCookie: string; linked: string | null }> {
45
+ const setCookie = clearCookie(cookieName(config, LINK_STATE_COOKIE), config.cookieSecure)
46
+ try {
47
+ const [expected, sessionId, timestamp] = (parseCookies(cookie).get(cookieName(config, LINK_STATE_COOKIE)) ?? '').split('.')
48
+ const code = query.get('code'), started = Number(timestamp), state = query.get('state')
49
+ if (!state || !expected || !safeEqual(state, expected) || sessionId !== session.id
50
+ || !timestamp || !Number.isFinite(started) || now < started || now - started > STATE_TTL_MS) {
51
+ throw new OAuthError(400, 'invalid-oauth-state')
52
+ }
53
+ // Declined on GitHub's side, which sends an error in place of a code.
54
+ if (!code) throw new OAuthError(400, query.get('error') ? 'github-authorization-declined' : 'invalid-oauth-state')
55
+ const tokens = await exchangeCode(config, code, now, fetchImpl)
56
+ const account = await fetchIdentity(tokens.accessToken, fetchImpl)
57
+ const link = await db.linkGithubAccount(session.id, account, now)
58
+ // Best-effort, as at sign-in: the avatar falls back to an initial.
59
+ if (avatarStore != null && account.avatarUrl != null) {
60
+ await cacheAvatar(avatarStore, link.id, account.avatarUrl, fetchImpl).catch((err) => {
61
+ console.warn('managed: linked avatar cache failed:', err)
62
+ })
63
+ }
64
+ return { location: PROFILE_PATH, setCookie, linked: link.created ? account.login : null }
65
+ } catch (err) {
66
+ if (!(err instanceof OAuthError || err instanceof ManagedMutationError)) throw err
67
+ return { location: `${PROFILE_PATH}?${new URLSearchParams({ 'link-error': err.message })}`, setCookie, linked: null }
68
+ }
69
+ }
@@ -0,0 +1,204 @@
1
+ // GitHub accounts a user links to their own on the profile page. A linked
2
+ // account's identity is reserved for that user: it can neither sign in nor
3
+ // register (upsertUser refuses it in the transaction that would create the
4
+ // user), and an account that has registered cannot be linked. The user's
5
+ // login and GitHub id stay the sign-in account's; the displayed name and
6
+ // avatar may come from any of their accounts.
7
+ import { randomUUID } from 'node:crypto'
8
+ import type { ManagedUser } from './db-methods.ts'
9
+ import { ManagedMutationError } from './management.ts'
10
+ import type { ManagedSql } from './sql.ts'
11
+
12
+ // login/name are snapshots of the account as it was last linked; linking it
13
+ // again refreshes them. Its avatar is cached under the link id (avatar-store.ts).
14
+ export const GITHUB_LINK_SCHEMA = `
15
+ CREATE TABLE IF NOT EXISTS managed_github_link (
16
+ id TEXT PRIMARY KEY,
17
+ user_id TEXT NOT NULL REFERENCES managed_user(id) ON DELETE CASCADE,
18
+ github_user_id INTEGER NOT NULL UNIQUE,
19
+ login TEXT NOT NULL,
20
+ name TEXT,
21
+ linked_at INTEGER NOT NULL
22
+ ) STRICT;
23
+
24
+ CREATE INDEX IF NOT EXISTS managed_github_link_user_idx ON managed_github_link(user_id);
25
+ `
26
+
27
+ // managed_user columns added with the table. managed_user.name is the
28
+ // displayed name; github_name keeps the sign-in account's own. name_link and
29
+ // avatar_link name the linked account each comes from, NULL the sign-in one.
30
+ export const GITHUB_LINK_USER_COLUMNS: readonly (readonly [string, string])[] = [
31
+ ['github_name', 'TEXT'],
32
+ ['name_link', 'TEXT REFERENCES managed_github_link(id) ON DELETE SET NULL'],
33
+ ['avatar_link', 'TEXT REFERENCES managed_github_link(id) ON DELETE SET NULL'],
34
+ ]
35
+
36
+ export interface LinkedGithubAccount {
37
+ id: string
38
+ // Its GitHub account's numeric id, which GitHub never gives another.
39
+ githubId: number
40
+ login: string
41
+ name: string | null
42
+ linkedAt: number
43
+ }
44
+
45
+ export interface ManagedProfile {
46
+ // Always the sign-in account's.
47
+ login: string
48
+ // The displayed name, and the sign-in account's own GitHub name.
49
+ name: string | null
50
+ githubName: string | null
51
+ // The linked account the displayed name or avatar comes from, null the sign-in one.
52
+ nameAccount: string | null
53
+ avatarAccount: string | null
54
+ accounts: LinkedGithubAccount[]
55
+ }
56
+
57
+ // A user as the app shows them where a GitHub login of theirs is named: by
58
+ // their id, whose avatar /api/avatar/<id> serves (the one they chose), their
59
+ // sign-in login and the name they chose.
60
+ export interface AppUser {
61
+ id: string
62
+ login: string
63
+ name: string | null
64
+ }
65
+
66
+ // Which account the displayed name and avatar each come from: a linked
67
+ // account's id, or null for the sign-in account. An absent key is unchanged.
68
+ export interface ProfileChoice {
69
+ name?: string | null
70
+ avatar?: string | null
71
+ }
72
+
73
+ export interface GithubLinkStore {
74
+ getProfile(userId: string): Promise<ManagedProfile | null>
75
+ // Link `account` to the session's own user, or refresh the snapshot of one
76
+ // they linked before. Refused for the sign-in account itself, an account
77
+ // another user linked, and any account that has registered.
78
+ linkGithubAccount(sessionId: string, account: ManagedUser, now: number): Promise<{ id: string; created: boolean }>
79
+ // Unlink one of the session user's accounts, which may then sign in or
80
+ // register again; a name or avatar it gave falls back to the sign-in
81
+ // account's. Null when the user has no such link.
82
+ unlinkGithubAccount(sessionId: string, id: string, now: number): Promise<LinkedGithubAccount | null>
83
+ setProfileChoice(sessionId: string, choice: ProfileChoice, now: number): Promise<void>
84
+ // The avatar to serve for `id`: a user's chosen linked account's, else `id`'s own.
85
+ getAvatarKey(id: string): Promise<string>
86
+ // The approved users signing in with, or having linked, each of
87
+ // `accounts`, by the login as asked: by the account's numeric id where it
88
+ // is known, which GitHub never gives another; else by its login (matched
89
+ // regardless of case, as GitHub matches them), one more than one user
90
+ // claims, renamed on GitHub since another signed in with it or linked it,
91
+ // left out. No access users are left out.
92
+ listUsersByAccount(accounts: readonly GithubAccount[]): Promise<Record<string, AppUser>>
93
+ }
94
+
95
+ // A GitHub account a repository's activity names: by its login, and its
96
+ // numeric id where GitHub told it.
97
+ export interface GithubAccount { login: string; githubId: number | null }
98
+
99
+ type LinkRow = { id: string; githubId: number; login: string; name: string | null; linkedAt: number }
100
+ const accountOf = (row: LinkRow): LinkedGithubAccount => ({ id: row.id, githubId: row.githubId, login: row.login, name: row.name, linkedAt: row.linkedAt })
101
+
102
+ // GithubLinkStore.listUsersByAccount.
103
+ async function usersByAccount(db: ManagedSql, accounts: readonly GithubAccount[]): Promise<Record<string, AppUser>> {
104
+ if (accounts.length === 0) return {}
105
+ const ids = accounts.flatMap(account => account.githubId ?? [])
106
+ const logins = accounts.flatMap(account => account.githubId === null ? [account.login] : [])
107
+ const rows = await db.prepare(`SELECT a.login, a.githubId, u.id, u.login AS appLogin, u.name, u.role FROM (
108
+ SELECT login, github_user_id AS githubId, id AS claimant FROM managed_user
109
+ UNION ALL SELECT login, github_user_id AS githubId, user_id AS claimant FROM managed_github_link
110
+ ) a JOIN managed_user u ON u.id = a.claimant
111
+ WHERE a.githubId IN (SELECT CAST(value AS BIGINT) FROM json_each(?)) OR lower(a.login) IN (SELECT lower(value) FROM json_each(?))`)
112
+ .all(JSON.stringify(ids), JSON.stringify(logins)) as { login: string; githubId: number; id: string; appLogin: string; name: string | null; role: string }[]
113
+ const byId = new Map(rows.map(row => [row.githubId, row]))
114
+ const claims = new Map<string, typeof rows>()
115
+ for (const row of rows) claims.set(row.login.toLowerCase(), [...claims.get(row.login.toLowerCase()) ?? [], row])
116
+ const users: Record<string, AppUser> = {}
117
+ for (const { login, githubId } of accounts) {
118
+ const [user, ...others] = githubId === null ? claims.get(login.toLowerCase()) ?? [] : byId.has(githubId) ? [byId.get(githubId)!] : []
119
+ if (!user || user.role === 'none' || others.some(other => other.id !== user.id)) continue
120
+ users[login] = { id: user.id, login: user.appLogin, name: user.name }
121
+ }
122
+ return users
123
+ }
124
+
125
+ export function githubLinkMethods(db: ManagedSql): GithubLinkStore {
126
+ // Linking is the session user's own act: never an admin's view as them,
127
+ // nor an account that has not been approved for the workspace.
128
+ async function sessionUser(sessionId: string, now: number): Promise<{ id: string; githubId: number }> {
129
+ const row = await db.prepare(`SELECT u.id, u.github_user_id AS githubId, u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
130
+ WHERE s.id = ? AND s.expires_at > ? AND s.viewer_session IS NULL`).get(sessionId, now) as { id: string; githubId: number; role: string } | undefined
131
+ if (!row) throw new ManagedMutationError(401, 'unauthenticated')
132
+ if (row.role === 'none') throw new ManagedMutationError(403, 'forbidden')
133
+ return row
134
+ }
135
+ const linkOf = (userId: string, id: string) => db.prepare(`SELECT id, github_user_id AS githubId, login, name, linked_at AS linkedAt FROM managed_github_link
136
+ WHERE id = ? AND user_id = ?`).get(id, userId) as Promise<LinkRow | undefined>
137
+
138
+ return {
139
+ async getProfile(userId) {
140
+ const user = await db.prepare(`SELECT login, name, github_name AS githubName, name_link AS nameAccount, avatar_link AS avatarAccount
141
+ FROM managed_user WHERE id = ?`).get(userId) as Omit<ManagedProfile, 'accounts'> | undefined
142
+ if (!user) return null
143
+ const accounts = await db.prepare(`SELECT id, github_user_id AS githubId, login, name, linked_at AS linkedAt FROM managed_github_link
144
+ WHERE user_id = ? ORDER BY linked_at, id`).all(userId) as LinkRow[]
145
+ return { ...user, accounts: accounts.map(accountOf) }
146
+ },
147
+ async linkGithubAccount(sessionId, account, now) {
148
+ const user = await sessionUser(sessionId, now)
149
+ if (account.githubUserId === user.githubId) throw new ManagedMutationError(409, 'github-account-own')
150
+ if (await db.prepare('SELECT 1 AS found FROM managed_user WHERE github_user_id = ?').get(account.githubUserId)) {
151
+ throw new ManagedMutationError(409, 'github-account-registered')
152
+ }
153
+ const existing = await db.prepare('SELECT id, user_id AS userId FROM managed_github_link WHERE github_user_id = ?')
154
+ .get(account.githubUserId) as { id: string; userId: string } | undefined
155
+ if (existing && existing.userId !== user.id) throw new ManagedMutationError(409, 'github-account-linked')
156
+ if (existing) {
157
+ await db.prepare('UPDATE managed_github_link SET login = ?, name = ? WHERE id = ?').run(account.login, account.name, existing.id)
158
+ await db.prepare('UPDATE managed_user SET name = ?, updated_at = ? WHERE id = ? AND name_link = ?').run(account.name, now, user.id, existing.id)
159
+ return { id: existing.id, created: false }
160
+ }
161
+ const id = randomUUID()
162
+ await db.prepare(`INSERT INTO managed_github_link (id, user_id, github_user_id, login, name, linked_at)
163
+ VALUES (?, ?, ?, ?, ?, ?)`).run(id, user.id, account.githubUserId, account.login, account.name, now)
164
+ return { id, created: true }
165
+ },
166
+ async unlinkGithubAccount(sessionId, id, now) {
167
+ const user = await sessionUser(sessionId, now)
168
+ const link = await linkOf(user.id, id)
169
+ if (!link) return null
170
+ await db.prepare(`UPDATE managed_user SET
171
+ name = CASE WHEN name_link = :link THEN github_name ELSE name END,
172
+ name_link = CASE WHEN name_link = :link THEN NULL ELSE name_link END,
173
+ avatar_link = CASE WHEN avatar_link = :link THEN NULL ELSE avatar_link END,
174
+ updated_at = :now
175
+ WHERE id = :user`).run({ link: id, now, user: user.id })
176
+ await db.prepare('DELETE FROM managed_github_link WHERE id = ?').run(id)
177
+ return accountOf(link)
178
+ },
179
+ async setProfileChoice(sessionId, choice, now) {
180
+ const user = await sessionUser(sessionId, now)
181
+ const links = new Map<string, LinkRow>()
182
+ for (const id of [choice.name, choice.avatar]) {
183
+ if (id == null || links.has(id)) continue
184
+ const link = await linkOf(user.id, id)
185
+ if (!link) throw new ManagedMutationError(404, 'no-account')
186
+ links.set(id, link)
187
+ }
188
+ if (choice.name !== undefined) {
189
+ // A linked account without a name leaves the user without one too.
190
+ await db.prepare(`UPDATE managed_user SET name_link = :link,
191
+ name = CASE WHEN :link IS NULL THEN github_name ELSE :name END, updated_at = :now
192
+ WHERE id = :user`).run({ link: choice.name, name: choice.name === null ? null : links.get(choice.name)!.name, now, user: user.id })
193
+ }
194
+ if (choice.avatar !== undefined) {
195
+ await db.prepare('UPDATE managed_user SET avatar_link = ?, updated_at = ? WHERE id = ?').run(choice.avatar, now, user.id)
196
+ }
197
+ },
198
+ async getAvatarKey(id) {
199
+ const row = await db.prepare('SELECT avatar_link AS link FROM managed_user WHERE id = ?').get(id) as { link: string | null } | undefined
200
+ return row?.link ?? id
201
+ },
202
+ listUsersByAccount: accounts => usersByAccount(db, accounts),
203
+ }
204
+ }
@@ -63,7 +63,10 @@ export async function handleCallback(
63
63
  }
64
64
  const tokens = await exchangeCode(config, code, now, fetchImpl)
65
65
  const user = await fetchIdentity(tokens.accessToken, fetchImpl)
66
- const { setCookie, userId } = await createSession(config, db, user, now)
66
+ const session = await createSession(config, db, user, now)
67
+ // Linked to another user's account on their profile (github-links.ts).
68
+ if (session == null) throw new OAuthError(403, 'github-account-linked')
69
+ const { setCookie, userId } = session
67
70
  // Persist the user token so the repositories page can list repos on demand.
68
71
  await db.setUserTokens(userId, tokens)
69
72
  // Cache the avatar for same-origin serving (the page's CSP forbids loading
@@ -216,7 +219,7 @@ function parseGithubUser(body: unknown): ManagedUser | null {
216
219
  // Fetch the user's GitHub avatar and cache it for same-origin serving. The host
217
220
  // is validated (githubusercontent.com over https) as SSRF defence-in-depth,
218
221
  // even though the URL comes from GitHub's own /user response.
219
- async function cacheAvatar(store: AvatarStore, id: string, avatarUrl: string, fetchImpl: typeof fetch): Promise<void> {
222
+ export async function cacheAvatar(store: AvatarStore, id: string, avatarUrl: string, fetchImpl: typeof fetch): Promise<void> {
220
223
  if (!isGithubAvatarUrl(avatarUrl)) return
221
224
  // `redirect: 'manual'` so a 3xx off the (validated) avatars host can't bounce
222
225
  // the fetch to an internal address — an opaque-redirect / 3xx fails the
@@ -222,6 +222,6 @@ export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot
222
222
  })
223
223
  }
224
224
 
225
- export function storedFixUrls(entries: { fix: string | null }[]): string[] {
226
- return [...new Set(entries.flatMap(entry => entry.fix && parseGithubFixUrl(entry.fix) ? [entry.fix] : []))].toSorted()
225
+ export function storedFixUrls(fixes: string[]): string[] {
226
+ return [...new Set(fixes.filter(fix => parseGithubFixUrl(fix)))].toSorted()
227
227
  }
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // Only outstanding reads: a completed answer (or failure) is never reused.
4
+ // Keep transports and credentials separate, including anonymous reads. When
5
+ // full, new reads proceed normally rather than waiting for an unrelated one.
6
+ const MAX_PENDING_READS = 1000
7
+ type PendingRead = { promise: Promise<unknown>; shared: boolean }
8
+ const pendingReads = new WeakMap<typeof fetch, Map<string, PendingRead>>()
9
+
10
+ // Invalidate installation credentials and access verdicts when GitHub rejects
11
+ // a token, including when several callers share the rejected request.
12
+ const rejectedTokenListeners = new Set<(token: string, fetchImpl: typeof fetch) => void>()
13
+ export function onTokenRejected(listener: (token: string, fetchImpl: typeof fetch) => void): void {
14
+ rejectedTokenListeners.add(listener)
15
+ }
16
+ export function tokenRejected(token: string, fetchImpl: typeof fetch): void {
17
+ for (const listener of rejectedTokenListeners) listener(token, fetchImpl)
18
+ }
19
+
20
+ export async function shareGithubRead<T>(fetchImpl: typeof fetch, request: string, read: () => Promise<T>): Promise<T> {
21
+ let pending = pendingReads.get(fetchImpl)
22
+ if (!pending) { pending = new Map(); pendingReads.set(fetchImpl, pending) }
23
+ // The request includes credentials and query variables. Retain only its hash.
24
+ const key = createHash('sha256').update(request).digest('hex')
25
+ const held = pending.get(key)
26
+ if (held) {
27
+ held.shared = true
28
+ return structuredClone(await held.promise) as T
29
+ }
30
+ if (pending.size >= MAX_PENDING_READS) return read()
31
+ const entry: PendingRead = { promise: read(), shared: false }
32
+ pending.set(key, entry)
33
+ try {
34
+ const value = await entry.promise
35
+ // Independent objects for coalesced callers, without copying large trees
36
+ // or histories when nobody joined the read.
37
+ return (entry.shared ? structuredClone(value) : value) as T
38
+ } finally { pending.delete(key) }
39
+ }
@@ -0,0 +1,30 @@
1
+ // The app's users and agents where a repository's activity names their
2
+ // GitHub accounts (GET /api/admin/repositories/history, issues, item, agents
3
+ // and pulse): of the accounts an answer names (common/managed/github-logins.ts),
4
+ // the agents' (common/managed/github-agents.ts), and those an approved user
5
+ // signs in with or linked to theirs (github-links.ts), each as that user
6
+ // shows in the app, linked accounts as their user; each by its numeric id
7
+ // where the answer names it, else by its login. Resolved as each answer is
8
+ // sent, never kept with it, and only for the accounts it names: a reader is
9
+ // never sent a list of users.
10
+ import { type GithubAgentId, githubAgent } from '../common/managed/github-agents.ts'
11
+ import { namedAccounts } from '../common/managed/github-logins.ts'
12
+ import type { ManagedDb } from './db.ts'
13
+ import type { AppUser } from './github-links.ts'
14
+
15
+ // An agent, as the app draws it (GITHUB_AGENTS).
16
+ export interface AppAgentUser { agent: GithubAgentId }
17
+
18
+ // `answer` with the app's users and agents its accounts are (`users`, by
19
+ // login), as a new object: what was kept of it, every reader's, is left as
20
+ // it was. A login it names by more than one account is no one's.
21
+ export async function withAppUsers<T extends object>(db: ManagedDb, answer: T): Promise<T & { users: Record<string, AppUser | AppAgentUser> }> {
22
+ const agents: Record<string, AppAgentUser> = {}
23
+ const accounts = [...namedAccounts(answer)].flatMap(([login, githubId]) => {
24
+ if (githubId === 'ambiguous') return []
25
+ const agent = githubAgent(login, githubId)
26
+ if (agent) agents[login] = { agent }
27
+ return agent ? [] : [{ login, githubId }]
28
+ })
29
+ return { ...answer, users: { ...await db.listUsersByAccount(accounts), ...agents } }
30
+ }
@@ -15,8 +15,16 @@ export function attachmentDisposition(filename: string): string {
15
15
  // already materialized by the authorization/filtering layer before we get here.
16
16
  export function writeResponse(res: ServerResponse, body: string | Buffer): void {
17
17
  const bytes = typeof body === 'string' ? Buffer.from(body) : body
18
- if (bytes.length <= UPLOAD_CHUNK_BYTES) { res.end(body); return }
19
- for (let offset = 0; offset < bytes.length; offset += 64 * 1024) res.write(bytes.subarray(offset, offset + 64 * 1024))
18
+ writeResponseParts(res, [bytes], bytes.length)
19
+ }
20
+
21
+ // A body of `length` bytes held in parts, written as writeResponse writes
22
+ // one without first copying them into one buffer.
23
+ export function writeResponseParts(res: ServerResponse, parts: Buffer[], length: number): void {
24
+ if (length <= UPLOAD_CHUNK_BYTES) { res.end(parts.length === 1 ? parts[0] : Buffer.concat(parts, length)); return }
25
+ for (const part of parts) {
26
+ for (let offset = 0; offset < part.length; offset += 64 * 1024) res.write(part.subarray(offset, offset + 64 * 1024))
27
+ }
20
28
  res.end()
21
29
  }
22
30