@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
@@ -1,7 +1,9 @@
1
1
  import { type LinkReportStore, linkReportMethods, linkRevision } from './link-reports.ts'
2
2
  import { type RepositoryAliasStore, repositoryAliasMethods } from './repository-aliases.ts'
3
3
  import { type DependencyReviewStore, dependencyReviewMethods } from './dependency-reviews.ts'
4
+ import { type RepositoryDependencyReviewStore, repositoryDependencyReviewMethods } from './repository-dependency-reviews.ts'
4
5
  import { type TeamNpmScopeStore, teamNpmScopeMethods } from './team-npm-scopes.ts'
6
+ import { type GithubLinkStore, githubLinkMethods } from './github-links.ts'
5
7
  import { createHash, randomUUID } from 'node:crypto'
6
8
  import { teamCatalogRevision } from './team-catalog.ts'
7
9
  import { type Role, roleAtLeast } from '../common/managed/roles.ts'
@@ -358,21 +360,26 @@ export interface UserTeam {
358
360
  dependencyReview?: boolean
359
361
  permissions: TeamUserPermissions
360
362
  cacheKey?: string
363
+ // The team's repository links, by name: its Repositories, whether or not
364
+ // anything is attached to them yet.
365
+ repos: TeamRepoLink[]
361
366
  reports: UserTeamReport[]
362
367
  bundles: UserTeamBundle[]
363
368
  }
364
369
 
365
370
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
366
- export interface ManagedDb extends DependencyReviewStore, ActivityStore, CommentStore, GithubMetadataStore, GithubCommitStore, ManagedIssueStore, BundleBuildLeaseStore, UpstreamCacheStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb, LinkReportStore, RepositoryAliasStore, TeamNpmScopeStore {
371
+ export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyReviewStore, ActivityStore, CommentStore, GithubMetadataStore, GithubCommitStore, ManagedIssueStore, BundleBuildLeaseStore, UpstreamCacheStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb, LinkReportStore, RepositoryAliasStore, TeamNpmScopeStore, GithubLinkStore {
367
372
  claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
368
373
  finishMaintenanceLease(owner: string, until: number): Promise<void>
369
374
  getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
370
375
  // Reuse connections within a finite request or one feed polling iteration.
371
376
  withRequest?<T>(work: () => Promise<T>): Promise<T>
372
- // Upsert the identity; returns the user's opaque id (stable across logins).
377
+ // Upsert the identity; returns the user's opaque id (stable across logins),
378
+ // or null for an account linked to a user (github-links.ts), which neither
379
+ // signs in nor registers. A name chosen from a linked account is kept.
373
380
  // Initial-admin approval comes only from trusted login configuration. It
374
381
  // promotes a matching No access identity only while it is the sole user.
375
- upsertUser(user: ManagedUser, now: number, initialAdminGithubId?: number | null): Promise<string>
382
+ upsertUser(user: ManagedUser, now: number, initialAdminGithubId?: number | null): Promise<string | null>
376
383
  createSession(session: ManagedSession, now: number): Promise<void>
377
384
  // A sign-in session; never a view session, so a view token in the session
378
385
  // cookie authenticates nothing.
@@ -385,6 +392,10 @@ export interface ManagedDb extends DependencyReviewStore, ActivityStore, Comment
385
392
  // A view session opened by `viewerSessionId`, while that session is current
386
393
  // and its user is still an admin. Presence is the admin's, not the viewed user's.
387
394
  viewSessionWithUser(id: string, viewerSessionId: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
395
+ // The two above as they read, recording no presence: for a read again in a
396
+ // request whose first read recorded it.
397
+ getSessionWithUser(id: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
398
+ getViewSessionWithUser(id: string, viewerSessionId: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
388
399
  // Store `candidate` unless the session already has an upload key; returns
389
400
  // the stored key, or null when the session no longer exists.
390
401
  ensureSessionUploadKey(id: string, candidate: string): Promise<string | null>
@@ -406,6 +417,8 @@ export interface ManagedDb extends DependencyReviewStore, ActivityStore, Comment
406
417
  deselectRepo(repoId: number): Promise<boolean>
407
418
  listSelectedRepos(): Promise<SelectedRepo[]>
408
419
  listAllRepos(): Promise<ManagedRepo[]>
420
+ // A selected repository's full name, active or not; null for none.
421
+ getRepoFullName(repoId: number): Promise<string | null>
409
422
  deactivateRepo(repoId: number): Promise<boolean>
410
423
  reactivateRepo(repoId: number): Promise<boolean>
411
424
  deleteRepo(repoId: number): Promise<boolean>
@@ -452,6 +465,8 @@ export interface ManagedDb extends DependencyReviewStore, ActivityStore, Comment
452
465
  // stamp nor the trail. Every change also appends to managed_finding_triage_event;
453
466
  // listTriageHistory walks one finding's trail, newest first.
454
467
  listTriage(findingIds: readonly string[]): Promise<TriageRow[]>
468
+ // The distinct Fix links of those, without the rest of their entries.
469
+ listTriageFixes(findingIds: readonly string[]): Promise<string[]>
455
470
  getAnnotations(findingIds: readonly string[]): Promise<{ triage: TriageRow[]; comments: Awaited<ReturnType<CommentStore['listComments']>>; issues: Awaited<ReturnType<ManagedIssueStore['listManagedIssues']>> }>
456
471
  applyManagedIssueFixes(sessionId: string, snapshot: TeamReportAccessSnapshot, updates: IssueFixUpdate[]): Promise<boolean>
457
472
  // A content-free fingerprint of current annotations, read in one snapshot.
@@ -536,6 +551,15 @@ type SessionRow = {
536
551
  uid: string; login: string; name: string | null; avatar: string | null; role: Role
537
552
  }
538
553
  type ViewSessionRow = SessionRow & { viewerId: string; viewerLogin: string; viewerName: string | null }
554
+ const ownSession = (row: SessionRow) => ({
555
+ session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey },
556
+ user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
557
+ })
558
+ const viewSession = (row: ViewSessionRow) => ({
559
+ session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey,
560
+ viewer: { id: row.viewerId, login: row.viewerLogin, name: row.viewerName } },
561
+ user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
562
+ })
539
563
 
540
564
  type UserRow = { id: string; login: string; name: string | null; role: Role; created: number; lastSeen: number | null; lastActivity: number | null }
541
565
 
@@ -563,12 +587,14 @@ function prepareStatements(db: ManagedSql) {
563
587
  // Identity refreshes preserve roles. Login can separately bootstrap the
564
588
  // configured sole No access user within this same writer transaction.
565
589
  upsertUserStmt: db.prepare(
566
- `INSERT INTO managed_user (id, github_user_id, login, name, avatar_url, role, created_at, updated_at)
567
- VALUES (?, ?, ?, ?, ?, 'none', ?, ?)
590
+ `INSERT INTO managed_user (id, github_user_id, login, name, github_name, avatar_url, role, created_at, updated_at)
591
+ VALUES (?, ?, ?, ?, ?, ?, 'none', ?, ?)
568
592
  ON CONFLICT(github_user_id) DO UPDATE SET
569
- login = excluded.login, name = excluded.name,
593
+ login = excluded.login, github_name = excluded.github_name,
594
+ name = CASE WHEN managed_user.name_link IS NULL THEN excluded.name ELSE managed_user.name END,
570
595
  avatar_url = excluded.avatar_url, updated_at = excluded.updated_at`,
571
596
  ),
597
+ selectLinkedStmt: db.prepare(`SELECT 1 AS linked FROM managed_github_link WHERE github_user_id = ?`),
572
598
  selectUserIdStmt: db.prepare(`SELECT id, role FROM managed_user WHERE github_user_id = ?`),
573
599
  promoteInitialAdminStmt: db.prepare(
574
600
  `UPDATE managed_user SET role = 'admin' WHERE id = ? AND role = 'none'
@@ -681,6 +707,7 @@ function prepareStatements(db: ManagedSql) {
681
707
  added_by AS addedBy, added_at AS addedAt, active AS active
682
708
  FROM managed_selected_repo WHERE active = 1 ORDER BY full_name ASC`,
683
709
  ),
710
+ selectRepoFullNameStmt: db.prepare(`SELECT full_name AS fullName FROM managed_selected_repo WHERE repo_id = ?`),
684
711
  selectAllReposStmt: db.prepare(
685
712
  `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
686
713
  installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
@@ -811,6 +838,8 @@ function prepareStatements(db: ManagedSql) {
811
838
  WHERE t.finding_id IN (SELECT value FROM json_each(?))
812
839
  ORDER BY t.finding_id ASC`,
813
840
  ),
841
+ selectTriageFixesStmt: db.prepare(`SELECT DISTINCT fix FROM managed_finding_triage
842
+ WHERE finding_id IN (SELECT value FROM json_each(?)) AND fix IS NOT NULL ORDER BY fix`),
814
843
  annotationTriageRevisionStmt: db.prepare(`
815
844
  SELECT t.finding_id AS id,
816
845
  (SELECT e.batch_id FROM managed_finding_triage_event e
@@ -910,10 +939,15 @@ function prepareStatements(db: ManagedSql) {
910
939
  renameTeamStmt: db.prepare(`UPDATE managed_team SET name = ?, updated_at = ? WHERE id = ?`),
911
940
  setTeamHiddenStmt: db.prepare(`UPDATE managed_team SET hidden = ?, updated_at = ? WHERE id = ?`),
912
941
  selectTeamsStmt: db.prepare(`SELECT id, slug, name, hidden FROM managed_team ORDER BY name ASC`),
942
+ // `dependencyReview` where the user reviews dependencies in the team
943
+ // (dependency-reviews.ts reviewsIn): one that reviews them, and a member
944
+ // who manages, or triages and may see its dependencies; an admin in any.
913
945
  selectTeamsForUserStmt: db.prepare(
914
- `SELECT t.id AS id, t.slug AS slug, t.name AS name, t.dependency_review AS dependencyReview,
946
+ `SELECT t.id AS id, t.slug AS slug, t.name AS name,
947
+ CASE WHEN t.dependency_review = 1 AND (u.role IN ('admin', 'manage') OR (u.role = 'triage' AND tu.view_dependencies = 1))
948
+ THEN 1 ELSE 0 END AS dependencyReview,
915
949
  tu.view_dependencies AS dependencies, tu.view_security AS security
916
- FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
950
+ FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id JOIN managed_user u ON u.id = tu.user_id
917
951
  WHERE tu.user_id = ? AND t.hidden = 0 ORDER BY t.name ASC, t.id ASC`,
918
952
  ),
919
953
  selectUserTeamScopesStmt: db.prepare(
@@ -1051,6 +1085,9 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
1051
1085
  const rows = (await selectAllReposStmt.all()) as RepoRow[]
1052
1086
  return rows.map(readManagedRepo)
1053
1087
  },
1088
+ async getRepoFullName(repoId: number): Promise<string | null> {
1089
+ return ((await stmts.selectRepoFullNameStmt.get(repoId)) as { fullName: string } | undefined)?.fullName ?? null
1090
+ },
1054
1091
  async deactivateRepo(repoId: number): Promise<boolean> {
1055
1092
  return Number((await deactivateRepoStmt.run(repoId)).changes) > 0
1056
1093
  },
@@ -1275,6 +1312,10 @@ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit
1275
1312
  actorId: r.actorId, actorLogin: r.actorLogin, actorName: r.actorName, at: r.at,
1276
1313
  }))
1277
1314
  },
1315
+ async listTriageFixes(findingIds: readonly string[]): Promise<string[]> {
1316
+ if (findingIds.length === 0) return []
1317
+ return ((await stmts.selectTriageFixesStmt.all(JSON.stringify(findingIds))) as { fix: string }[]).map(row => row.fix)
1318
+ },
1278
1319
  async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
1279
1320
  if (findingIds.length === 0) return []
1280
1321
  const rows = (await selectTriageStmt.all(JSON.stringify(findingIds))) as TriageDbRow[]
@@ -1513,6 +1554,8 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1513
1554
  cacheKey: createHash('sha256').update(JSON.stringify([
1514
1555
  t.dependencies, t.security, links, scopes.filter(scope => scope.teamId === t.id),
1515
1556
  ])).digest('base64url'),
1557
+ repos: scopes.filter(scope => scope.teamId === t.id).map(scope => ({ repoId: scope.repoId, fullName: scope.fullName, path: scope.path || null }))
1558
+ .toSorted((a, b) => a.fullName.localeCompare(b.fullName) || (a.path ?? '').localeCompare(b.path ?? '')),
1516
1559
  reports: reportsByTeam.get(t.id) ?? [],
1517
1560
  bundles: bundlesByTeam.get(t.id) ?? [],
1518
1561
  }))
@@ -1597,7 +1640,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1597
1640
  const reports = reportMethods(stmts, db, key)
1598
1641
  const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0, db)
1599
1642
  const {
1600
- upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectOwnSessionStmt, selectUsersStmt,
1643
+ upsertUserStmt, selectLinkedStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectOwnSessionStmt, selectUsersStmt,
1601
1644
  touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, ensureUploadKeyStmt, deleteSessionStmt, deleteExpiredStmt,
1602
1645
  selectViewSessionStmt, deleteOtherViewsStmt, insertViewSessionStmt, deleteDemotedViewsStmt,
1603
1646
  } = stmts
@@ -1607,7 +1650,9 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1607
1650
  ...linkReportMethods(db, key),
1608
1651
  ...repositoryAliasMethods(db),
1609
1652
  ...teamNpmScopeMethods(db),
1653
+ ...githubLinkMethods(db),
1610
1654
  ...dependencyReviewMethods(db),
1655
+ ...repositoryDependencyReviewMethods(db),
1611
1656
  ...bundleBuildLeaseMethods(db),
1612
1657
  ...upstreamCacheMethods(db),
1613
1658
  async claimMaintenanceLease(owner, now, until, migration = false) {
@@ -1619,9 +1664,10 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1619
1664
  await db.prepare('UPDATE managed_maintenance_lease SET expires_at = ? WHERE owner = ?').run(until, owner)
1620
1665
  },
1621
1666
  async upsertUser(user, now, initialAdminGithubId = null) {
1667
+ if (await selectLinkedStmt.get(user.githubUserId)) return null
1622
1668
  // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1623
1669
  // user's id (DO UPDATE leaves it untouched), so re-read to return it.
1624
- await upsertUserStmt.run(randomUUID(), user.githubUserId, user.login, user.name, user.avatarUrl, now, now)
1670
+ await upsertUserStmt.run(randomUUID(), user.githubUserId, user.login, user.name, user.name, user.avatarUrl, now, now)
1625
1671
  const row = (await selectUserIdStmt.get(user.githubUserId)) as { id: string; role: Role }
1626
1672
  // Avoid the population check unless this login matches the configured
1627
1673
  // identity and still needs approval. NOT EXISTS stops at any other user.
@@ -1639,10 +1685,11 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1639
1685
  const row = (await selectOwnSessionStmt.get(id, now)) as SessionRow | undefined
1640
1686
  if (row == null) return null
1641
1687
  await touchUserSeenStmt.run(now, row.uid, now)
1642
- return {
1643
- session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey },
1644
- user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
1645
- }
1688
+ return ownSession(row)
1689
+ },
1690
+ async getSessionWithUser(id, now) {
1691
+ const row = (await selectOwnSessionStmt.get(id, now)) as SessionRow | undefined
1692
+ return row == null ? null : ownSession(row)
1646
1693
  },
1647
1694
  async createViewSession(view, now) {
1648
1695
  if (Number((await insertViewSessionStmt.run(view.id, view.csrfToken, now, view.userId, view.viewerSessionId, now)).changes) === 0) return false
@@ -1653,11 +1700,11 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1653
1700
  const row = (await selectViewSessionStmt.get(id, viewerSessionId, now, now)) as ViewSessionRow | undefined
1654
1701
  if (row == null) return null
1655
1702
  await touchUserSeenStmt.run(now, row.viewerId, now)
1656
- return {
1657
- session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey,
1658
- viewer: { id: row.viewerId, login: row.viewerLogin, name: row.viewerName } },
1659
- user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
1660
- }
1703
+ return viewSession(row)
1704
+ },
1705
+ async getViewSessionWithUser(id, viewerSessionId, now) {
1706
+ const row = (await selectViewSessionStmt.get(id, viewerSessionId, now, now)) as ViewSessionRow | undefined
1707
+ return row == null ? null : viewSession(row)
1661
1708
  },
1662
1709
  async ensureSessionUploadKey(id, candidate) {
1663
1710
  const row = (await ensureUploadKeyStmt.get(candidate, id)) as { uploadKey: string } | undefined
@@ -2,11 +2,13 @@ import { DEPENDENCY_REVIEW_SCHEMA } from './dependency-reviews.ts'
2
2
  import { LINK_REPORT_SCHEMA } from './link-reports.ts'
3
3
  import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
4
4
  import { TEAM_NPM_SCOPE_SCHEMA } from './team-npm-scopes.ts'
5
+ import { GITHUB_LINK_SCHEMA, GITHUB_LINK_USER_COLUMNS } from './github-links.ts'
6
+ import { REPOSITORY_DEPENDENCY_REVIEW_COLUMNS } from './repository-dependency-reviews.ts'
5
7
  import { AsyncLocalStorage } from 'node:async_hooks'
6
8
  import { WebSocket } from 'ws'
7
9
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
8
10
  import { revisionSchema } from './revisions.ts'
9
- import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
11
+ import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
10
12
  import { STORAGE_SCHEMA } from './storage-db.ts'
11
13
  import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
12
14
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
@@ -25,7 +27,8 @@ export interface PgConnection {
25
27
  rows: Record<string, unknown>[]; rowCount?: number | null
26
28
  fields?: { name: string; dataTypeID: number }[]
27
29
  }>
28
- release(): Promise<void>
30
+ // After a failure the connection's state is unknown: discard it, never reuse it.
31
+ release(discard?: boolean): Promise<void>
29
32
  }
30
33
  export type PgConnect = () => Promise<PgConnection>
31
34
  // Content/access mutations cooperate on this transaction-scoped lock. Reads,
@@ -36,7 +39,7 @@ async function currentSchema(db: PgConnection): Promise<boolean> {
36
39
  const exists = (await db.query("SELECT to_regclass('managed_schema_version') AS name")).rows[0]?.['name']
37
40
  if (!exists) return false
38
41
  const versions = new Set((await db.query('SELECT version FROM managed_schema_version')).rows.map(row => Number(row['version'])))
39
- return Array.from({ length: 27 }, (_, i) => i + 1).every(version => versions.has(version))
42
+ return Array.from({ length: 31 }, (_, i) => i + 1).every(version => versions.has(version))
40
43
  }
41
44
 
42
45
  async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
@@ -142,6 +145,37 @@ async function migrateDependencyReviews(db: PgConnection): Promise<void> {
142
145
  await db.query('INSERT INTO managed_schema_version VALUES (27)')
143
146
  }
144
147
 
148
+ // Existing names are the sign-in accounts' own: none was chosen yet.
149
+ async function migrateGithubLinks(db: PgConnection): Promise<void> {
150
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 28')).rows.length > 0) return
151
+ await db.query(postgresSchema(GITHUB_LINK_SCHEMA))
152
+ await db.query(`ALTER TABLE managed_user ${GITHUB_LINK_USER_COLUMNS.map(([column, type]) => `ADD COLUMN IF NOT EXISTS ${column} ${type}`).join(', ')}`)
153
+ await db.query('UPDATE managed_user SET github_name = name')
154
+ await db.query('INSERT INTO managed_schema_version VALUES (28)')
155
+ }
156
+
157
+ async function migrateRepositoryDependencyReviews(db: PgConnection): Promise<void> {
158
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 29')).rows.length > 0) return
159
+ await db.query(`ALTER TABLE managed_selected_repo ${REPOSITORY_DEPENDENCY_REVIEW_COLUMNS.map(([column, type]) => `ADD COLUMN IF NOT EXISTS ${column} ${type}`).join(', ')}`)
160
+ await db.query('INSERT INTO managed_schema_version VALUES (29)')
161
+ }
162
+
163
+ // The lockfiles a repository's dependency review holds to, its columns
164
+ // added since 29 added there too; and a team's published reviews, listed by
165
+ // it, indexed so.
166
+ async function migrateRepositoryDependencyReviewLockfiles(db: PgConnection): Promise<void> {
167
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 30')).rows.length > 0) return
168
+ await db.query(`ALTER TABLE managed_selected_repo ${REPOSITORY_DEPENDENCY_REVIEW_COLUMNS.map(([column, type]) => `ADD COLUMN IF NOT EXISTS ${column} ${type}`).join(', ')}`)
169
+ await db.query('CREATE INDEX IF NOT EXISTS managed_dependency_review_team_idx ON managed_dependency_review_team(team_id, review_id)')
170
+ await db.query('INSERT INTO managed_schema_version VALUES (30)')
171
+ }
172
+
173
+ async function migrateRepoUploadIndexes(db: PgConnection): Promise<void> {
174
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 31')).rows.length > 0) return
175
+ await db.query(REPO_UPLOAD_INDEXES)
176
+ await db.query('INSERT INTO managed_schema_version VALUES (31)')
177
+ }
178
+
145
179
  async function initialize(db: PgConnection): Promise<void> {
146
180
  if (await currentSchema(db)) return
147
181
  await db.query('BEGIN')
@@ -215,7 +249,8 @@ async function initialize(db: PgConnection): Promise<void> {
215
249
  if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 11')).rows.length === 0) {
216
250
  await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
217
251
  }
218
- for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews]) await migrate(db)
252
+ for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews, migrateGithubLinks, migrateRepositoryDependencyReviews,
253
+ migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes]) await migrate(db)
219
254
  await db.query('COMMIT')
220
255
  } catch (err) {
221
256
  await db.query('ROLLBACK')
@@ -248,7 +283,7 @@ function requestConnections(connect: PgConnect) {
248
283
  const connection = await connect()
249
284
  let result: T
250
285
  try { result = await work(connection) }
251
- catch (error) { await connection.release().catch(() => {}); throw error }
286
+ catch (error) { await connection.release(true).catch(() => {}); throw error }
252
287
  await connection.release()
253
288
  return result
254
289
  }
@@ -259,7 +294,7 @@ function requestConnections(connect: PgConnect) {
259
294
  try { return await work(connection) }
260
295
  catch (error) {
261
296
  delete request.connection
262
- await connection.release().catch(() => {})
297
+ await connection.release(true).catch(() => {})
263
298
  throw error
264
299
  }
265
300
  })
@@ -275,7 +310,7 @@ function requestConnections(connect: PgConnect) {
275
310
  finally {
276
311
  request.ended = true
277
312
  await request.queue
278
- if (failed) await request.connection?.release().catch(() => {})
313
+ if (failed) await request.connection?.release(true).catch(() => {})
279
314
  else await request.connection?.release()
280
315
  }
281
316
  }
@@ -285,7 +320,8 @@ function requestConnections(connect: PgConnect) {
285
320
  // Exposed for parity tests against real PostgreSQL semantics via PGlite.
286
321
  export async function openPostgresManagedDb(connect: PgConnect, options: ManagedDbOptions = {}, reuseConnections = false): Promise<ManagedDb> {
287
322
  const initial = await connect()
288
- try { await initialize(initial) } finally { await initial.release() }
323
+ try { await initialize(initial) } catch (err) { await initial.release(true).catch(() => {}); throw err }
324
+ await initial.release()
289
325
  const { withConnection, withRequest } = requestConnections(connect)
290
326
  const context = new AsyncLocalStorage<PgConnection>()
291
327
  let closed = false
@@ -348,20 +384,52 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
348
384
  return methods
349
385
  }
350
386
 
351
- export async function openNeonManagedDb(url: string, options: ManagedDbOptions = {}): Promise<ManagedDb> {
352
- // Reuse the e2e optional-driver boundary. Requests lease one connection and
353
- // close it before returning; no socket must survive a frozen invocation.
354
- const { Client, neonConfig } = await import('../server-e2e/neon-driver.ts') as unknown as {
355
- Client: new (url: string) => { connect(): Promise<void>; end(): Promise<void>; query: PgConnection['query']; on?(event: 'error', listener: (error: Error) => void): void }
387
+ // Idle connections stay open this long between leases, so a warm instance's
388
+ // requests and feed polls (three seconds apart) skip the TLS, WebSocket and
389
+ // Postgres handshake a new connection repeats.
390
+ const NEON_POOL_IDLE_MS = 5_000
391
+
392
+ type NeonPoolClient = {
393
+ query: PgConnection['query']
394
+ release(discard?: boolean): void
395
+ on(event: string, listener: (...args: unknown[]) => void): void
396
+ }
397
+ export type NeonPool = {
398
+ connect(): Promise<NeonPoolClient>
399
+ end(): Promise<void>
400
+ on(event: string, listener: (...args: unknown[]) => void): void
401
+ }
402
+
403
+ // `attachPool` is Vercel's attachDatabasePool on serverless instances: each
404
+ // release keeps the invocation alive until idle connections have closed, so
405
+ // no socket survives a frozen instance.
406
+ export async function openNeonManagedDb(url: string, options: ManagedDbOptions = {}, attachPool?: (pool: NeonPool) => void): Promise<ManagedDb> {
407
+ // Reuse the e2e optional-driver boundary.
408
+ const { Pool, neonConfig } = await import('../server-e2e/neon-driver.ts') as unknown as {
409
+ Pool: new (config: { connectionString: string; max: number; idleTimeoutMillis: number }) => NeonPool
356
410
  neonConfig: { webSocketConstructor: typeof WebSocket }
357
411
  }
358
412
  neonConfig.webSocketConstructor = WebSocket
359
- return openPostgresManagedDb(async () => {
360
- const client = new Client(url)
361
- // A remote disconnect between operations must reject the next query, not
362
- // become an unhandled EventEmitter error while the request is reading Blob.
363
- client.on?.('error', () => {})
364
- try { await client.connect() } catch (err) { await client.end().catch(() => {}); throw err }
365
- return { query: (sql, params) => client.query(sql, params), release: () => client.end() }
366
- }, options, true)
413
+ // Leases stay unbounded, one per request or operation as before: a cap would
414
+ // stall a request that holds one while it awaits work needing another.
415
+ const pool = new Pool({ connectionString: url, max: Infinity, idleTimeoutMillis: NEON_POOL_IDLE_MS })
416
+ // The pool evicts an idle connection the server closed. Neither that nor a
417
+ // remote disconnect while leased may become an unhandled EventEmitter error;
418
+ // a leased one must instead reject its next query, and is then discarded.
419
+ pool.on('error', () => {})
420
+ attachPool?.(pool)
421
+ const failed = new WeakSet<NeonPoolClient>(), watched = new WeakSet<NeonPoolClient>()
422
+ let ending: Promise<void> | undefined
423
+ const end = () => ending ??= pool.end()
424
+ let db: ManagedDb
425
+ try {
426
+ db = await openPostgresManagedDb(async () => {
427
+ const client = await pool.connect()
428
+ if (!watched.has(client)) { watched.add(client); client.on('error', () => { failed.add(client) }) }
429
+ return { query: (sql, params) => client.query(sql, params),
430
+ release: discard => Promise.try(() => client.release(discard === true || failed.has(client))) }
431
+ }, options, true)
432
+ } catch (err) { await end().catch(() => {}); throw err }
433
+ const close = db.close
434
+ return { ...db, async close() { try { await close() } finally { await end() } } }
367
435
  }
@@ -3,9 +3,14 @@ import { LINK_REPORT_SCHEMA } from './link-reports.ts'
3
3
  import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
4
4
  import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
5
5
  import { TEAM_NPM_SCOPE_SCHEMA } from './team-npm-scopes.ts'
6
+ import { GITHUB_LINK_SCHEMA } from './github-links.ts'
6
7
 
7
8
  // Created after the column migration, which predates it on older databases.
8
9
  export const VIEW_SESSION_INDEX = 'CREATE INDEX IF NOT EXISTS managed_session_viewer_idx ON managed_session(viewer_session);'
10
+ // What a team's scopes join reports and bundles on: their repository, and the
11
+ // directory in it. Created after the column migrations, as above.
12
+ export const REPO_UPLOAD_INDEXES = `CREATE INDEX IF NOT EXISTS managed_report_repo_idx ON managed_report(repo_id, repo_directory);
13
+ CREATE INDEX IF NOT EXISTS managed_bundle_repo_idx ON managed_bundle(repo_id, repo_directory);`
9
14
 
10
15
  export const MANAGED_SCHEMA = `
11
16
  CREATE TABLE IF NOT EXISTS managed_user (
@@ -64,7 +69,13 @@ CREATE TABLE IF NOT EXISTS managed_selected_repo (
64
69
  added_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
65
70
  added_at INTEGER NOT NULL,
66
71
  updated_at INTEGER NOT NULL,
67
- active INTEGER NOT NULL DEFAULT 1
72
+ active INTEGER NOT NULL DEFAULT 1,
73
+ -- Its pull requests' dependencies reviewed (repository-dependency-reviews.ts):
74
+ -- as many published reviews required of each version, and the commit at
75
+ -- whose lockfiles' versions none is (null for none).
76
+ dependency_review INTEGER NOT NULL DEFAULT 0,
77
+ dependency_reviews_required INTEGER NOT NULL DEFAULT 1,
78
+ dependency_review_since TEXT
68
79
  ) STRICT;
69
80
 
70
81
  CREATE INDEX IF NOT EXISTS selected_repo_full_name_idx ON managed_selected_repo(full_name);
@@ -224,4 +235,4 @@ CREATE TABLE IF NOT EXISTS managed_team_user (
224
235
  ) STRICT;
225
236
 
226
237
  CREATE INDEX IF NOT EXISTS team_user_user_idx ON managed_team_user(user_id);
227
- ` + WORKSPACE_SHARE_SCHEMA + LINK_REPORT_SCHEMA + REPOSITORY_ALIAS_SCHEMA + TEAM_NPM_SCOPE_SCHEMA + DEPENDENCY_REVIEW_SCHEMA
238
+ ` + WORKSPACE_SHARE_SCHEMA + LINK_REPORT_SCHEMA + REPOSITORY_ALIAS_SCHEMA + TEAM_NPM_SCOPE_SCHEMA + DEPENDENCY_REVIEW_SCHEMA + GITHUB_LINK_SCHEMA
@@ -7,7 +7,9 @@ import { migrateReportLocations } from './report-migration.ts'
7
7
  import { initCommentMethods } from './comments.ts'
8
8
  import { initActivityMethods } from './activity.ts'
9
9
  import { revisionSchema } from './revisions.ts'
10
- import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
10
+ import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
11
+ import { GITHUB_LINK_USER_COLUMNS } from './github-links.ts'
12
+ import { REPOSITORY_DEPENDENCY_REVIEW_COLUMNS } from './repository-dependency-reviews.ts'
11
13
  import { STORAGE_SCHEMA } from './storage-db.ts'
12
14
  import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
13
15
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
@@ -79,6 +81,10 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
79
81
  ensureColumn(db, table, 'storage_encrypted', 'INTEGER NOT NULL DEFAULT 0')
80
82
  }
81
83
  ensureColumn(db, 'managed_user', 'gh_tokens_encrypted', 'INTEGER NOT NULL DEFAULT 0')
84
+ // Existing names are the sign-in accounts' own: none was chosen yet.
85
+ for (const [column, type] of GITHUB_LINK_USER_COLUMNS) {
86
+ if (ensureColumn(db, 'managed_user', column, type) && column === 'github_name') db.exec('UPDATE managed_user SET github_name = name')
87
+ }
82
88
  ensureColumn(db, 'managed_github_metadata', 'state_reason', GITHUB_STATE_REASON_COLUMN)
83
89
  ensureColumn(db, 'managed_github_metadata', 'attempted_at', 'INTEGER')
84
90
  // Existing public links must opt in too; never preserve an implicit grant.
@@ -107,6 +113,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
107
113
  if (addedVisible) db.prepare('UPDATE managed_report SET visible = 1 WHERE visible = 0').run()
108
114
  ensureColumn(db, 'managed_bundle', 'uploaded_by_login', 'TEXT')
109
115
  ensureColumn(db, 'managed_bundle', 'repo_directory', "TEXT NOT NULL DEFAULT ''")
116
+ db.exec(REPO_UPLOAD_INDEXES)
110
117
  ensureColumn(db, 'managed_bundle', 'visible', 'INTEGER NOT NULL DEFAULT 1')
111
118
  // Existing rows stay NULL (unknown): some may have been built on the server.
112
119
  ensureColumn(db, 'managed_bundle', 'provenance', 'TEXT')
@@ -116,6 +123,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
116
123
  ensureColumn(db, 'managed_finding_issue', 'auto_fix_url', 'TEXT')
117
124
  ensureColumn(db, 'managed_finding_issue', 'auto_fix_checked_at', 'INTEGER')
118
125
  ensureColumn(db, 'managed_selected_repo', 'active', 'INTEGER NOT NULL DEFAULT 1')
126
+ for (const [column, type] of REPOSITORY_DEPENDENCY_REVIEW_COLUMNS) ensureColumn(db, 'managed_selected_repo', column, type)
119
127
  ensureColumn(db, 'managed_finding_triage_event', 'report_id', 'TEXT')
120
128
  ensureColumn(db, 'managed_finding_triage_event', 'report', 'TEXT')
121
129
  ensureColumn(db, 'managed_finding_triage_event', 'repo', 'TEXT')
@@ -0,0 +1,60 @@
1
+ // A repository's Dependabot alerts for a repository risk scan, read with the
2
+ // App's installation token where the admin's own access lets them see them on
3
+ // GitHub, as the README's repository risk section tells.
4
+ import type { ManagedConfig } from './config.ts'
5
+ import type { SelectedRepo } from './db.ts'
6
+ import { type DependabotAlert, dependabotAlerts } from './dependabot-alerts.ts'
7
+ import { GithubApiError, type GithubIdentity, githubRepoPermission, repoAccessToken, repositoryInstallation } from './github-app.ts'
8
+
9
+ // The levels GitHub lets see a repository's Dependabot alerts by default
10
+ // (maintain's reads `write`).
11
+ const SEES_ALERTS: ReadonlySet<string> = new Set(['admin', 'write'])
12
+ // Failures of verifying the sign-in that are the sign-in's own: no token, or
13
+ // one of another account (repository-browser.ts getIdentity), or one GitHub no
14
+ // longer takes. Any other, as GitHub failing to answer, is told as it is.
15
+ const UNVERIFIED: ReadonlySet<string> = new Set(['no-repository', 'github-unauthorized'])
16
+
17
+ // What of a repository's reader (repository-browser.ts) the alerts are asked
18
+ // with: the user's verified identity, and their permission level where its
19
+ // access gate asked GitHub of it already.
20
+ export interface AlertsReader {
21
+ identity(): Promise<GithubIdentity>
22
+ permission(): string | null
23
+ }
24
+
25
+ // Alerts read, and their `recheck`: the user's access to them checked again,
26
+ // asked of GitHub afresh, just before they are told (repository-risk.ts), as
27
+ // access to the repository is, as it may be withdrawn meanwhile.
28
+ export interface AlertsRead { alerts: DependabotAlert[]; recheck(): Promise<void> }
29
+
30
+ // `repo`'s Dependabot alerts (dependabot-alerts.ts), read with the token of
31
+ // the App's installation GitHub tells covers it now, its token checked to read
32
+ // it (github-app.ts repositoryInstallation), never the one its connection
33
+ // keeps, which may no longer cover it, once the user's own permission on it
34
+ // lets them see them; else refused, the alerts not asked:
35
+ // `dependabot-not-installed`, `dependabot-unverified`, or
36
+ // `dependabot-forbidden`, naming their permission. The token is asked each
37
+ // time of repoAccessToken, which renews it before it expires, as the recheck
38
+ // may come long after the alerts are read.
39
+ export async function appDependabotAlerts(config: ManagedConfig, repo: Pick<SelectedRepo, 'repoId' | 'fullName'>, reader: AlertsReader,
40
+ fetchImpl: typeof fetch = globalThis.fetch): Promise<AlertsRead> {
41
+ const id = await repositoryInstallation(config, repo.repoId, repo.fullName, fetchImpl)
42
+ const tokenNow = async () => {
43
+ const token = await repoAccessToken(config, id, fetchImpl)
44
+ if (token == null) throw new GithubApiError(403, 'dependabot-not-installed')
45
+ return token
46
+ }
47
+ const token = await tokenNow()
48
+ let who
49
+ try { who = await reader.identity() } catch (error) {
50
+ throw error instanceof GithubApiError && UNVERIFIED.has(error.message) ? new GithubApiError(403, 'dependabot-unverified') : error
51
+ }
52
+ const seen = (permission: string) => {
53
+ if (!SEES_ALERTS.has(permission)) {
54
+ throw new GithubApiError(403, 'dependabot-forbidden', null, `@${who.login}'s access to this repository (${permission}) doesn't let them see its Dependabot alerts on GitHub`)
55
+ }
56
+ }
57
+ seen(reader.permission() ?? await githubRepoPermission(repo.fullName, token, who, fetchImpl))
58
+ const alerts = await dependabotAlerts(repo.fullName, token, fetchImpl)
59
+ return { alerts, recheck: async () => { seen(await githubRepoPermission(repo.fullName, await tokenNow(), who, fetchImpl)) } }
60
+ }