@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.51

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 (368) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +3 -0
  50. package/common/managed/finding-detail.js +66 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +7 -0
  54. package/common/managed/npm-packages.js +51 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +359 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +6 -0
  64. package/common/managed/team-app.js +47 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2K6UN3PC.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-6LNQTKAV.js +52 -0
  87. package/out/chunk-7BKKYVWR.js +7 -0
  88. package/out/chunk-7C7DWL7I.js +1 -0
  89. package/out/chunk-7JBSSRD7.js +3 -0
  90. package/out/chunk-7VAWINQF.js +6 -0
  91. package/out/chunk-C4Z6ZBXV.js +10 -0
  92. package/out/chunk-FMXWXDR4.js +36 -0
  93. package/out/chunk-GDPCLSOS.js +2 -0
  94. package/out/chunk-GO7FT4OD.js +1 -0
  95. package/out/chunk-HT2SSA3V.js +1 -0
  96. package/out/chunk-IPAH4LKF.js +232 -0
  97. package/out/chunk-IYPY47MC.js +1 -0
  98. package/out/chunk-IZ7POWI3.js +18 -0
  99. package/out/chunk-JCIIBLKR.js +1 -0
  100. package/out/chunk-KOGNQDSJ.js +1 -0
  101. package/out/chunk-NRPC74AM.js +1 -0
  102. package/out/chunk-OOJM4CTU.js +1 -0
  103. package/out/chunk-PBZPYIG7.js +1 -0
  104. package/out/chunk-PNHBZGDD.js +1 -0
  105. package/out/chunk-PWFUOLZK.js +17 -0
  106. package/out/chunk-PXK7EU4V.js +1 -0
  107. package/out/chunk-QB4CBOX2.js +1 -0
  108. package/out/chunk-R3NZFXVR.js +1 -0
  109. package/out/chunk-R5O6FAW4.js +2 -0
  110. package/out/chunk-SXV2T5C4.js +80 -0
  111. package/out/chunk-TQJDYHVE.js +2 -0
  112. package/out/chunk-TUII34K2.js +1 -0
  113. package/out/chunk-VDRXHIHN.js +3 -0
  114. package/out/chunk-VFWMQ3LG.js +2 -0
  115. package/out/chunk-VH4XMZIW.js +2 -0
  116. package/out/chunk-WCPJDAYA.js +10 -0
  117. package/out/chunk-WFYLMF6Z.js +10 -0
  118. package/out/chunk-WRAJVD35.js +1 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +364 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +23 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +76 -0
  237. package/server-managed/comparison.ts +52 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +468 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +143 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +98 -0
  249. package/server-managed/dependency-reviews.ts +364 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +376 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +136 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5503 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/listed-activity.ts +39 -0
  273. package/server-managed/locked-packages.ts +152 -0
  274. package/server-managed/lockfile-scope.ts +49 -0
  275. package/server-managed/lru.ts +30 -0
  276. package/server-managed/managed-issues.ts +78 -0
  277. package/server-managed/management-catalog.ts +45 -0
  278. package/server-managed/management.ts +99 -0
  279. package/server-managed/npm-delta-worker.js +23 -0
  280. package/server-managed/npm-delta.ts +194 -0
  281. package/server-managed/npm-insights.ts +449 -0
  282. package/server-managed/npm-kept.ts +225 -0
  283. package/server-managed/npm-loads.ts +264 -0
  284. package/server-managed/npm-manifest.ts +115 -0
  285. package/server-managed/npm-packages.ts +454 -0
  286. package/server-managed/npm-patch.ts +325 -0
  287. package/server-managed/npm-provenance.ts +123 -0
  288. package/server-managed/npm-risk.ts +285 -0
  289. package/server-managed/object-storage-disk.ts +148 -0
  290. package/server-managed/object-storage-vercel.ts +67 -0
  291. package/server-managed/object-storage.ts +75 -0
  292. package/server-managed/package-entry-points.ts +51 -0
  293. package/server-managed/pretty-print.ts +434 -0
  294. package/server-managed/public-workspace.ts +238 -0
  295. package/server-managed/pull-comment-reviews.ts +161 -0
  296. package/server-managed/pull-dependencies.ts +351 -0
  297. package/server-managed/pull-dependency-reviews.ts +334 -0
  298. package/server-managed/pull-reviews.ts +205 -0
  299. package/server-managed/queue.ts +39 -0
  300. package/server-managed/release-commits.ts +84 -0
  301. package/server-managed/rename-diff-worker.js +26 -0
  302. package/server-managed/rename-diff.ts +279 -0
  303. package/server-managed/repo-path.ts +21 -0
  304. package/server-managed/report-migration.ts +35 -0
  305. package/server-managed/report-query.ts +10 -0
  306. package/server-managed/report-response.ts +16 -0
  307. package/server-managed/report-sources.ts +178 -0
  308. package/server-managed/report-store.ts +85 -0
  309. package/server-managed/repository-actions.ts +218 -0
  310. package/server-managed/repository-agents.ts +448 -0
  311. package/server-managed/repository-aliases.ts +91 -0
  312. package/server-managed/repository-browser.ts +294 -0
  313. package/server-managed/repository-changes.ts +284 -0
  314. package/server-managed/repository-code.ts +215 -0
  315. package/server-managed/repository-dependency-reviews.ts +166 -0
  316. package/server-managed/repository-discovery.ts +82 -0
  317. package/server-managed/repository-history.ts +432 -0
  318. package/server-managed/repository-issues.ts +195 -0
  319. package/server-managed/repository-policy.ts +27 -0
  320. package/server-managed/repository-pulls.ts +212 -0
  321. package/server-managed/repository-pulse.ts +460 -0
  322. package/server-managed/repository-risk-assess.ts +105 -0
  323. package/server-managed/repository-risk-audit.ts +79 -0
  324. package/server-managed/repository-risk-read.ts +170 -0
  325. package/server-managed/repository-risk.ts +491 -0
  326. package/server-managed/repository-squashes.ts +168 -0
  327. package/server-managed/request-timing.ts +63 -0
  328. package/server-managed/reviewed-dependencies.ts +149 -0
  329. package/server-managed/revisions.ts +35 -0
  330. package/server-managed/scan-downloads.ts +87 -0
  331. package/server-managed/session.ts +132 -0
  332. package/server-managed/slugs.ts +38 -0
  333. package/server-managed/solidity-entry-points.ts +71 -0
  334. package/server-managed/sql-postgres.ts +34 -0
  335. package/server-managed/sql.ts +88 -0
  336. package/server-managed/static.ts +28 -0
  337. package/server-managed/storage-command.ts +16 -0
  338. package/server-managed/storage-db.ts +183 -0
  339. package/server-managed/storage-encryption.ts +290 -0
  340. package/server-managed/storage-maintenance.ts +259 -0
  341. package/server-managed/storage-payload.ts +121 -0
  342. package/server-managed/storage-stores.ts +91 -0
  343. package/server-managed/storage.ts +82 -0
  344. package/server-managed/team-app.ts +134 -0
  345. package/server-managed/team-catalog.ts +7 -0
  346. package/server-managed/team-feed.ts +180 -0
  347. package/server-managed/team-npm-scopes.ts +106 -0
  348. package/server-managed/team-reports.ts +319 -0
  349. package/server-managed/triage-response.ts +16 -0
  350. package/server-managed/type-only-worker.js +19 -0
  351. package/server-managed/type-only.ts +105 -0
  352. package/server-managed/uploads.ts +106 -0
  353. package/server-managed/upstream-cache.ts +243 -0
  354. package/server-managed/view-as.ts +58 -0
  355. package/server-managed/workspace-shares.ts +169 -0
  356. package/server.ts +70 -0
  357. package/server/index.ts +0 -480
  358. package/server/lifecycle.ts +0 -208
  359. /package/{LICENSE → LICENSE-MIT} +0 -0
  360. /package/{server → server-e2e}/auth.ts +0 -0
  361. /package/{server → server-e2e}/config.example.json +0 -0
  362. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  363. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  364. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  365. /package/{server → server-e2e}/peer.ts +0 -0
  366. /package/{server → server-e2e}/sse-session.ts +0 -0
  367. /package/{server → server-e2e}/util.ts +0 -0
  368. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,47 @@
1
+ import type { ManagedConfig } from './config.ts'
2
+ import type { ManagedDb, ManagedSession } from './db.ts'
3
+ import { randomToken, safeEqual } from './crypto.ts'
4
+ import { ISSUE_STATE_COOKIE, buildCookie, clearCookie, cookieName, parseCookies } from './session.ts'
5
+ import { OAuthError, ensureUserAccessToken, exchangeCode, fetchIdentity } from './github-oauth.ts'
6
+
7
+ export const ISSUE_LOGIN_PATH = '/api/oauth/github/issues/login'
8
+
9
+ export function isIssueOAuthCallback(config: ManagedConfig, query: URLSearchParams, cookie: string | undefined): boolean {
10
+ const expected = parseCookies(cookie).get(cookieName(config, ISSUE_STATE_COOKIE))?.split('.')[0]
11
+ return !!expected && safeEqual(query.get('state') ?? '', expected)
12
+ }
13
+
14
+ export function issueLoginRedirect(config: ManagedConfig, session: ManagedSession, now = Date.now()) {
15
+ const state = randomToken()
16
+ const url = new URL('https://github.com/login/oauth/authorize')
17
+ url.searchParams.set('client_id', config.githubClientId)
18
+ url.searchParams.set('redirect_uri', config.oauthCallbackUrl)
19
+ url.searchParams.set('state', state)
20
+ url.searchParams.set('allow_signup', 'false')
21
+ // Reauthorize the same app for the current account, without replacing the
22
+ // DeepView session or permitting an OAuth account switch during creation.
23
+ const value = `${state}.${session.id}.${now}`
24
+ return { location: url.href, setCookie: buildCookie(cookieName(config, ISSUE_STATE_COOKIE), value,
25
+ { secure: config.cookieSecure, maxAgeS: 600, sameSite: 'Lax' }) }
26
+ }
27
+
28
+ export async function issueOAuthCallback(config: ManagedConfig, db: ManagedDb, session: ManagedSession,
29
+ query: URLSearchParams, cookie: string | undefined, fetchImpl: typeof fetch = fetch, now = Date.now()) {
30
+ const [expected, sessionId, timestamp] = (parseCookies(cookie).get(cookieName(config, ISSUE_STATE_COOKIE)) ?? '').split('.')
31
+ const code = query.get('code'), state = query.get('state')
32
+ if (!code || !state || !expected || !safeEqual(state, expected) || sessionId !== session.id
33
+ || !timestamp || !Number.isFinite(Number(timestamp)) || now < Number(timestamp) || now - Number(timestamp) > 600_000) {
34
+ throw new OAuthError(400, 'invalid-oauth-state')
35
+ }
36
+ const tokens = await exchangeCode(config, code, now, fetchImpl)
37
+ const identity = await fetchIdentity(tokens.accessToken, fetchImpl)
38
+ if (identity.githubUserId !== await db.getUserGithubId(session.userId)) throw new OAuthError(403, 'github-account-mismatch')
39
+ const current = await db.sessionWithUser(session.id, Date.now())
40
+ if (!current || current.user.role === 'none' || current.session.userId !== session.userId) throw new OAuthError(401, 'unauthenticated')
41
+ await db.setUserTokens(session.userId, tokens)
42
+ return { location: '/github-issue-authorized.html', setCookie: clearCookie(cookieName(config, ISSUE_STATE_COOKIE), config.cookieSecure) }
43
+ }
44
+
45
+ export function issueUserToken(config: ManagedConfig, db: ManagedDb, userId: string, fetchImpl: typeof fetch = fetch) {
46
+ return ensureUserAccessToken(config, db, userId, Date.now(), fetchImpl)
47
+ }
@@ -0,0 +1,152 @@
1
+ import type { ManagedConfig } from './config.ts'
2
+ import type { ManagedDb, ManagedSession } from './db.ts'
3
+ import type { BlobStore } from './blob-store.ts'
4
+ import type { ManagedIssue } from './managed-issues.ts'
5
+ import { randomToken } from './crypto.ts'
6
+ import { githubIssueClosedReason } from '../common/github-pr.ts'
7
+ import { reportEntries, reportRepoGithub } from '@preventive/report'
8
+ import { newIssueLabels } from '../common/github-issue-labels.js'
9
+ import { appJwt, githubAppConfigured, installUrl, publicRepositoryName } from './github-app.ts'
10
+ import { ISSUE_LOGIN_PATH, issueUserToken } from './github-issue-oauth.ts'
11
+ import { TeamReportsError, loadTeamReports, recheckTeam, teamSnapshot } from './team-reports.ts'
12
+
13
+ export const MAX_ISSUE_BODY_BYTES = 512_000
14
+ export class IssueError extends Error {
15
+ status: number
16
+ constructor(status: number, message: string) { super(message); this.status = status }
17
+ }
18
+
19
+ type Context = { reportId: string; findingId: string; repository: string }
20
+ export function parseIssueContext(raw: unknown): Context {
21
+ const value = raw as Partial<Context> | null
22
+ const repository = publicRepositoryName(value?.repository)
23
+ if (!repository || typeof value?.reportId !== 'string' || !value.reportId || value.reportId.length > 200
24
+ || typeof value.findingId !== 'string' || !value.findingId || value.findingId.length > 4096) throw new IssueError(400, 'bad-request')
25
+ return { repository, reportId: value.reportId, findingId: value.findingId }
26
+ }
27
+
28
+ async function github(path: string, token: string, fetchImpl: typeof fetch, payload?: unknown) {
29
+ let response: Response
30
+ try {
31
+ response = await fetchImpl(`https://api.github.com${path}`, {
32
+ method: payload === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
33
+ headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json',
34
+ 'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28', 'content-type': 'application/json' },
35
+ ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
36
+ })
37
+ } catch { throw new IssueError(502, payload === undefined ? 'github-unavailable' : 'github-create-uncertain') }
38
+ return response
39
+ }
40
+
41
+ function referenceResult(stored: ManagedIssue, repoIds: number[]) {
42
+ // A shared finding ID does not grant access to another team's repository.
43
+ if (!repoIds.includes(stored.repoId)) return { mode: 'unavailable' as const }
44
+ return stored.issueUrl ? { mode: 'existing' as const, url: stored.issueUrl }
45
+ : { mode: 'pending' as const, repositoryUrl: `https://github.com/${stored.repository}/issues` }
46
+ }
47
+
48
+ function issueDetails(raw: unknown, url: string, number: number, fallback: { title: string; body: string; labels: string[] }) {
49
+ const issue = raw as { title?: unknown; body?: unknown; state?: unknown; state_reason?: unknown;
50
+ user?: { login?: unknown }; labels?: ({ name?: unknown } | string)[] } | null
51
+ const status = issue?.state === 'closed' ? 'closed' : 'open'
52
+ return { url, number, title: typeof issue?.title === 'string' ? issue.title : fallback.title,
53
+ description: typeof issue?.body === 'string' ? issue.body : fallback.body, status,
54
+ stateReason: status === 'closed' ? githubIssueClosedReason(issue?.state_reason) : null,
55
+ author: typeof issue?.user?.login === 'string' ? issue.user.login : null,
56
+ labels: Array.isArray(issue?.labels) ? issue.labels.map(label => typeof label === 'string' ? label : label?.name).filter((label): label is string => typeof label === 'string') : fallback.labels }
57
+ }
58
+
59
+ // Authorize before any upstream lookup, including installation/label discovery.
60
+ // Report filtering also supplies the full row/link isSecurity classification.
61
+ export async function prepareGithubIssue(config: ManagedConfig, db: ManagedDb, store: BlobStore,
62
+ session: ManagedSession, teamId: string, context: Context, fetchImpl: typeof fetch = fetch) {
63
+ const snapshot = await teamSnapshot(db, session.id, teamId, context.reportId)
64
+ if (!snapshot.reports.some(report => report.id === context.reportId)) throw new TeamReportsError(404, 'no-report')
65
+ const reports = await loadTeamReports(db, store, snapshot)
66
+ const report = reports.find(item => item.id === context.reportId)!
67
+ const findings = (reportEntries(report.data) ?? []).flat() as Record<string, unknown>[]
68
+ const finding = findings.find(item => item['id'] === context.findingId)
69
+ if (!finding) throw new TeamReportsError(404, 'no-finding')
70
+ const recheck = () => recheckTeam(db, session.id, snapshot)
71
+ await recheck()
72
+ const labels = newIssueLabels(finding['isSecurity'] === true, config.githubNewIssueLabels ?? '')
73
+ const repoIds = snapshot.repositories.map(repo => repo.repoId)
74
+ const stored = await db.getManagedIssue(context.findingId)
75
+ if (stored) return { ...referenceResult(stored, repoIds), labels, recheck }
76
+ // The target comes from this finding's own source, or the report's managed
77
+ // assignment. Team membership alone must not let a caller claim an unrelated
78
+ // repository's issue as the permanent reference for this finding.
79
+ const findingRepo = finding['repo'] as { github?: unknown } | null | undefined
80
+ const targetRepository = publicRepositoryName(reportRepoGithub({ repo: { github: findingRepo?.github || report.repo.github } }))
81
+ if (!targetRepository || targetRepository.toLowerCase() !== context.repository.toLowerCase()) throw new IssueError(400, 'bad-issue-repository')
82
+ const repository = snapshot.repositories.find(repo => repo.github.toLowerCase() === targetRepository.toLowerCase())
83
+ if (!repository || !githubAppConfigured(config)) return { mode: 'form' as const, labels, recheck }
84
+ const path = `/repos/${targetRepository.split('/').map(encodeURIComponent).join('/')}`
85
+ const installation = await github(`${path}/installation`, appJwt(config.githubAppId!, config.githubAppPrivateKey!), fetchImpl)
86
+ if (installation.status === 404) return { mode: 'form' as const, labels, recheck }
87
+ if (!installation.ok) throw new IssueError(502, 'github-unavailable')
88
+ let installed: { permissions?: { issues?: string } }
89
+ try { installed = await installation.json() } catch { throw new IssueError(502, 'github-unavailable') }
90
+ if (installed?.permissions?.issues !== 'write') return { mode: 'permissions' as const, authorizationPath: installUrl(config), labels, recheck }
91
+ const token = await issueUserToken(config, db, session.userId, fetchImpl)
92
+ if (!token) return { mode: 'authorize' as const, authorizationPath: ISSUE_LOGIN_PATH, labels, recheck }
93
+ // All requested labels are optional, including security and operator labels.
94
+ // Omit missing names rather than rejecting issue creation or creating labels.
95
+ const existingLabels: string[] = []
96
+ for (const name of labels) {
97
+ const label = await github(`${path}/labels/${encodeURIComponent(name)}`, token, fetchImpl)
98
+ if (label.status === 401) return { mode: 'authorize' as const, authorizationPath: ISSUE_LOGIN_PATH, labels, recheck }
99
+ if (label.status === 404) continue
100
+ if (!label.ok) throw new IssueError(502, 'github-unavailable')
101
+ existingLabels.push(name)
102
+ }
103
+ return { mode: 'api' as const, token, path, labels: existingLabels, recheck,
104
+ db, findingId: context.findingId, repoId: repository.repoId, repository: targetRepository, repoIds, userId: session.userId }
105
+ }
106
+
107
+ export async function createGithubIssue(prepared: Awaited<ReturnType<typeof prepareGithubIssue>>, input: unknown, fetchImpl: typeof fetch = fetch) {
108
+ if (prepared.mode !== 'api') throw new IssueError(409, 'github-authorization-required')
109
+ const { title, body } = input as { title?: unknown; body?: unknown }
110
+ if (typeof title !== 'string' || !title.trim() || title.length > 256 || typeof body !== 'string' || body.length > 65_536) throw new IssueError(400, 'bad-issue')
111
+ const { db, findingId } = prepared
112
+ const requestId = randomToken()
113
+ if (!await db.claimManagedIssue({ findingId, repoId: prepared.repoId, repository: prepared.repository,
114
+ requestId, createdBy: prepared.userId, createdAt: Date.now() })) {
115
+ const stored = await db.getManagedIssue(findingId)
116
+ await prepared.recheck()
117
+ // The competing request may have released its reservation after a definite
118
+ // failure. Still return a listing, never a fresh creation form, for this race.
119
+ return stored ? referenceResult(stored, prepared.repoIds)
120
+ : { mode: 'pending' as const, repositoryUrl: `https://github.com/${prepared.repository}/issues` }
121
+ }
122
+ // The last awaited operation before the external write checks current access.
123
+ // Release only when we know no issue was created; unknown outcomes stay claimed.
124
+ try { await prepared.recheck() }
125
+ catch (error) { await db.releaseManagedIssue(findingId, requestId); throw error }
126
+ const response = await github(`${prepared.path}/issues`, prepared.token, fetchImpl, { title: title.trim(), body, labels: prepared.labels })
127
+ if (response.status >= 400 && response.status < 500) await db.releaseManagedIssue(findingId, requestId)
128
+ if (response.status === 401) throw new IssueError(409, 'github-authorization-required')
129
+ if (response.status === 403) throw new IssueError(403, 'github-issue-forbidden')
130
+ if (!response.ok) throw new IssueError(response.status === 422 ? 422 : 502, response.status >= 500 ? 'github-create-uncertain' : 'github-create-failed')
131
+ let issue: { number?: unknown }
132
+ try { issue = await response.json() } catch { throw new IssueError(502, 'github-create-uncertain') }
133
+ if (!Number.isSafeInteger(issue?.number) || Number(issue.number) <= 0) throw new IssueError(502, 'github-create-uncertain')
134
+ // Construct the URL from validated inputs, never an upstream redirect target.
135
+ const number = Number(issue.number), url = `https://github.com${prepared.path.slice('/repos'.length)}/issues/${issue.number}`
136
+ if (!await db.finishManagedIssue(findingId, requestId, url)) throw new IssueError(502, 'github-create-uncertain')
137
+ let details = issueDetails(issue, url, number, { title, body, labels: prepared.labels }), detailsUnavailable = true
138
+ try {
139
+ const fetched = await github(`${prepared.path}/issues/${number}`, prepared.token, fetchImpl)
140
+ if (fetched.ok) {
141
+ details = issueDetails(await fetched.json(), url, number, { title, body, labels: prepared.labels })
142
+ detailsUnavailable = false
143
+ }
144
+ } catch { /* Creation and its permanent reference succeeded; detail reads are best effort. */ }
145
+ try { await prepared.recheck() }
146
+ catch {
147
+ // The external write and permanent reference succeeded. Withhold details
148
+ // if current access cannot be confirmed, but never advertise a safe retry.
149
+ return { mode: 'created-unavailable' as const }
150
+ }
151
+ return { url, issue: details, detailsUnavailable }
152
+ }
@@ -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
+ }