@preventive/triage 1.0.0-alpha.21 → 1.0.0-alpha.23

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.21",
3
+ "version": "1.0.0-alpha.23",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -109,12 +109,16 @@
109
109
  "server-managed/db-table-names.ts",
110
110
  "server-managed/db.ts",
111
111
  "server-managed/github-app.ts",
112
+ "server-managed/github-errors.ts",
112
113
  "server-managed/github-metadata.ts",
114
+ "server-managed/github-issue-oauth.ts",
115
+ "server-managed/github-issues.ts",
113
116
  "server-managed/github-oauth.ts",
114
117
  "server-managed/github-pulls.ts",
115
118
  "server-managed/http-response.ts",
116
119
  "server-managed/http.ts",
117
120
  "server-managed/import-triage.ts",
121
+ "server-managed/managed-issues.ts",
118
122
  "server-managed/index.ts",
119
123
  "server-managed/public-workspace.ts",
120
124
  "server-managed/repo-path.ts",
@@ -122,6 +126,7 @@
122
126
  "server-managed/report-query.ts",
123
127
  "server-managed/report-response.ts",
124
128
  "server-managed/report-sources.ts",
129
+ "server-managed/repository-browser.ts",
125
130
  "server-managed/repository-discovery.ts",
126
131
  "server-managed/repository-policy.ts",
127
132
  "server-managed/session.ts",
@@ -172,12 +177,16 @@
172
177
  "common/bundle-reasons.js",
173
178
  "common/bundle-sources.d.ts",
174
179
  "common/bundle-sources.js",
180
+ "common/bundle-source-links.d.ts",
181
+ "common/bundle-source-links.js",
175
182
  "common/bundle-stats.js",
176
183
  "common/bundle-tabs.js",
177
184
  "common/code-language.js",
178
185
  "common/default-scan-models.ts",
179
186
  "common/finding-id.js",
180
187
  "common/github-pr.ts",
188
+ "common/github-issue-labels.js",
189
+ "common/github-issue-labels.d.ts",
181
190
  "common/managed/comments.ts",
182
191
  "common/managed/permissions.ts",
183
192
  "common/managed/report-content.ts",
@@ -200,6 +209,8 @@
200
209
  "out/client-managed-import.js",
201
210
  "out/client-sync.js",
202
211
  "out/graph.js",
212
+ "out/github-issue-authorized.html",
213
+ "out/github-issue-authorized.js",
203
214
  "out/icon-maskable.svg",
204
215
  "out/icon.svg",
205
216
  "out/index.html",
package/server-e2e/app.ts CHANGED
@@ -348,7 +348,8 @@ async function assembleE2eApp(config: Config, rollback: AsyncDisposableStack) {
348
348
  // The sync protocol this build advertises — emitted as a `server-info` frame
349
349
  // right after the challenge on every connection. This is the e2e boot, so it
350
350
  // always advertises e2e.
351
- const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null, ...(config.deepviewScanServer ? { deepviewScanServer: config.deepviewScanServer } : {}) }
351
+ const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null, ...(config.deepviewScanServer ? { deepviewScanServer: config.deepviewScanServer } : {}),
352
+ ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}) }
352
353
 
353
354
  // Shared per-connection dispatch surface. Both the WS plane
354
355
  // (installWsServer below) and the SSE+POST fallback (installSseServer
@@ -28,6 +28,7 @@ export type Config = {
28
28
  password: string | null
29
29
  trustProxyEnv: string | undefined
30
30
  deepviewScanServer: string | null
31
+ githubNewIssueLabels?: string
31
32
  }
32
33
 
33
34
  // Parse + range-validate an integer env var, throwing a clear
@@ -207,5 +208,6 @@ export function loadConfig(): Config {
207
208
  debug, neonUrl, blobToken, tokenSecret, password,
208
209
  trustProxyEnv: env['TRUST_PROXY'],
209
210
  deepviewScanServer: configuredScanServer(env['DEEPVIEW_SCAN_SERVER']),
211
+ githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
210
212
  }
211
213
  }
@@ -1,10 +1,6 @@
1
- // Managed-server boot config, parsed once at startup and
2
- // failing fast on a missing / invalid required value — the same discipline as
3
- // server-e2e/config.ts. Auth (GitHub identity + sessions) is required. An
4
- // optional, SEPARATE GitHub App (id + private key + slug) enables PRIVATE repo
5
- // listing via installation tokens — kept distinct from the login App so its
6
- // Contents permission never lands on the login consent. The sync protocol is
7
- // not parsed here yet.
1
+ // Managed-server boot config. GitHub user authorization establishes identity;
2
+ // optional App installation credentials enable repository access. A single
3
+ // GitHub App can provide both flows, with repository grants at installation.
8
4
  import { env } from 'node:process'
9
5
  import { databaseUrls } from '../server-common/database-config.ts'
10
6
 
@@ -35,21 +31,17 @@ export interface ManagedConfig {
35
31
  cookieSecure: boolean
36
32
  sessionCookieName: string
37
33
  sessionTtlMs: number
38
- // Optional SEPARATE GitHub App for PRIVATE repository access — distinct from
39
- // the login App above. It carries the Contents: Read permission (so its
40
- // consent never touches login) and is read via installation tokens: the id +
41
- // PEM private key mint them; the slug builds the "Connect a repository"
42
- // install URL. id/key absent → private repos are off (public-only listing);
43
- // slug absent → no install link.
34
+ // Optional installation credentials for the same GitHub App as login.
35
+ // Repository permissions are approved when connecting repositories.
44
36
  githubAppId: string | null
45
37
  githubAppPrivateKey: string | null
46
38
  githubAppSlug: string | null
39
+ githubNewIssueLabels?: string
47
40
  // Max accepted size (bytes) for an uploaded report on the "Manage reports"
48
41
  // page. Reports are findings dumps (JSON / markdown / CSV), small to a few MB.
49
42
  maxReportBytes: number
50
43
  // Max accepted size (bytes) for an uploaded bundle (sourcemap / stasis
51
- // archive). Bundles run larger than reports, so a higher cap (default 100 MiB,
52
- // matching the e2e objstore per-upload cap).
44
+ // archive). Bundles run larger than reports, so a higher cap (default 200 MiB).
53
45
  maxBundleBytes: number
54
46
  // How many triage-trail events to keep per finding (managed_finding_triage_event):
55
47
  // 0, the default, keeps everything — the trail is the record. An operator
@@ -130,8 +122,9 @@ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
130
122
  githubAppId: env['GITHUB_APP_ID'] ?? null,
131
123
  githubAppPrivateKey: normalizePem(env['GITHUB_APP_PRIVATE_KEY']),
132
124
  githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
125
+ githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
133
126
  maxReportBytes: intEnv('MAX_REPORT_BYTES', 10_485_760, 1, 104_857_600),
134
- maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 104_857_600, 1, 1_073_741_824),
127
+ maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 209_715_200, 1, 1_073_741_824),
135
128
  triageHistoryLimit: intEnv('TRIAGE_HISTORY_LIMIT', 0, 0, 1_000_000_000),
136
129
  }
137
130
  }
@@ -7,6 +7,7 @@ import { preferredSlug } from './slugs.ts'
7
7
  import { type CommentStore, commentMethods } from './comments.ts'
8
8
  import { type ActivityStore, activityMethods } from './activity.ts'
9
9
  import { type GithubMetadataStore, githubMetadataMethods } from './github-metadata.ts'
10
+ import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
10
11
  import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
11
12
  import type { ManagedSql } from './sql.ts'
12
13
  import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
@@ -280,6 +281,7 @@ export interface UserTeamReport {
280
281
  id: string
281
282
  slug: string
282
283
  filename: string
284
+ analyzer: string | null
283
285
  repoFullName: string | null
284
286
  repoDirectory: string
285
287
  cacheKey: string
@@ -305,7 +307,7 @@ export interface UserTeam {
305
307
  }
306
308
 
307
309
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
308
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, WorkspaceShareStore, ImportTriageStore {
310
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore {
309
311
  // Upsert the identity; returns the user's opaque id (stable across logins).
310
312
  // Initial-admin approval comes only from trusted login configuration. It
311
313
  // promotes a matching No access identity only while it is the sole user.
@@ -325,6 +327,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
325
327
  // mutable context while keeping the original added_by/added_at; deselectRepo
326
328
  // resolves true iff a row was removed.
327
329
  selectRepo(repo: SelectedRepoInput, now: number): Promise<void>
330
+ connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean>
328
331
  deselectRepo(repoId: number): Promise<boolean>
329
332
  listSelectedRepos(): Promise<SelectedRepo[]>
330
333
  listAllRepos(): Promise<ManagedRepo[]>
@@ -344,6 +347,10 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
344
347
  // getReport reads one row (for download); deleteReport resolves true iff a row
345
348
  // was removed.
346
349
  insertReport(report: ReportRecordInput, now: number): Promise<void>
350
+ // Content reuse is atomic with insertion, including across server instances.
351
+ // Existing copies are kept; the oldest matching row is the stable identity.
352
+ insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord>
353
+ getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
347
354
  listReports(userId?: string): Promise<AdminReport[]>
348
355
  getReport(id: string): Promise<ReportRecord | null>
349
356
  getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
@@ -504,6 +511,12 @@ function prepareStatements(db: ManagedSql) {
504
511
  installation_id = excluded.installation_id, default_branch = excluded.default_branch,
505
512
  html_url = excluded.html_url, updated_at = excluded.updated_at, active = 1`,
506
513
  ),
514
+ // Never reactivate or recreate a repo, overwrite another connection, or
515
+ // commit after the administrator's session/role was revoked during GitHub I/O.
516
+ connectRepoInstallationStmt: db.prepare(`UPDATE managed_selected_repo SET installation_id = ?, updated_at = ?
517
+ WHERE repo_id = ? AND full_name = ? AND added_at = ? AND installation_id IS NULL
518
+ AND EXISTS (SELECT 1 FROM managed_session s JOIN managed_user u ON u.id = s.user_id
519
+ WHERE s.id = ? AND s.expires_at > ? AND u.role = 'admin')`),
507
520
  deleteRepoStmt: db.prepare(`DELETE FROM managed_selected_repo WHERE repo_id = ?`),
508
521
  deactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 0 WHERE repo_id = ? AND active = 1`),
509
522
  reactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 1 WHERE repo_id = ? AND active = 0`),
@@ -563,6 +576,10 @@ function prepareStatements(db: ManagedSql) {
563
576
  analyzer AS analyzer, visible AS visible, bundle_id AS bundleId
564
577
  FROM managed_report WHERE id = ?`,
565
578
  ),
579
+ selectReportByHashStmt: db.prepare(
580
+ `SELECT id FROM managed_report WHERE sha256 = ? AND COALESCE(analyzer, '') = COALESCE(?, '')
581
+ ORDER BY uploaded_at, id LIMIT 1`,
582
+ ),
566
583
  selectReportAccessStmt: db.prepare(
567
584
  `WITH requested AS (SELECT value AS id FROM json_each(?)), report_grants AS (
568
585
  SELECT r.id AS id, MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
@@ -732,7 +749,7 @@ function prepareStatements(db: ManagedSql) {
732
749
  // Reports attached to the repos of the user's teams, tagged by team (a report
733
750
  // shows under every team whose repo it's attached to). Newest first.
734
751
  selectUserTeamReportsStmt: db.prepare(
735
- `SELECT DISTINCT tr.team_id AS teamId, r.id AS id, r.slug AS slug, r.filename AS filename, r.uploaded_at,
752
+ `SELECT DISTINCT tr.team_id AS teamId, r.id AS id, r.slug AS slug, r.filename AS filename, r.analyzer, r.uploaded_at,
736
753
  r.sha256 AS sha256, r.bundle_id AS bundleId, r.repo_id AS repoId, r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
737
754
  tu.view_dependencies AS dependencies, tu.view_security AS security,
738
755
  (SELECT role FROM managed_user WHERE id = tu.user_id) AS role
@@ -837,6 +854,9 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
837
854
  async deselectRepo(repoId: number): Promise<boolean> {
838
855
  return Number((await deleteRepoStmt.run(repoId)).changes) > 0
839
856
  },
857
+ async connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean> {
858
+ return Number((await stmts.connectRepoInstallationStmt.run(installationId, now, repo.repoId, repo.fullName, repo.addedAt, sessionId, now)).changes) > 0
859
+ },
840
860
  async listSelectedRepos(): Promise<SelectedRepo[]> {
841
861
  const rows = (await selectReposStmt.all()) as RepoRow[]
842
862
  return rows.map(readRepo)
@@ -888,14 +908,34 @@ type ReportRow = {
888
908
  // openSqliteManagedDb small. Closes over its prepared statements.
889
909
  function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
890
910
  const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
911
+ async function getReport(id: string): Promise<ReportRecord | null> {
912
+ const row = (await selectReportStmt.get(id)) as ReportRow | undefined
913
+ if (row == null) return null
914
+ return { ...row, repoEmbedded: row.repoEmbedded === 1, visible: row.visible === 1 }
915
+ }
916
+ async function getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null> {
917
+ const row = await stmts.selectReportByHashStmt.get(sha256, analyzer) as { id: string } | undefined
918
+ return row ? getReport(row.id) : null
919
+ }
920
+ async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
921
+ await insertReportStmt.run(
922
+ report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
923
+ report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
924
+ report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
925
+ report.bundleIntegrity ?? null, now,
926
+ )
927
+ }
891
928
  return {
892
- async insertReport(report: ReportRecordInput, now: number): Promise<void> {
893
- await insertReportStmt.run(
894
- report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
895
- report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
896
- report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
897
- report.bundleIntegrity ?? null, now,
898
- )
929
+ insertReport,
930
+ getReport,
931
+ getReportByHash,
932
+ async insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord> {
933
+ // The driver holds its writer lock for this whole operation. Checking
934
+ // again here closes the race between the HTTP lookup and blob upload.
935
+ const existing = await getReportByHash(report.sha256, report.analyzer ?? null)
936
+ if (existing) return existing
937
+ await insertReport(report, now)
938
+ return (await getReport(report.id))!
899
939
  },
900
940
  async listReports(userId?: string): Promise<AdminReport[]> {
901
941
  const rows = (await selectReportsStmt.all(userId ?? null, userId ?? null, userId ?? null)) as ReportListRow[]
@@ -908,16 +948,6 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
908
948
  uploadedAt: r.uploadedAt,
909
949
  }))
910
950
  },
911
- async getReport(id: string): Promise<ReportRecord | null> {
912
- const row = (await selectReportStmt.get(id)) as ReportRow | undefined
913
- if (row == null) return null
914
- return {
915
- id: row.id, slug: row.slug, filename: row.filename, contentType: row.contentType, byteSize: row.byteSize,
916
- sha256: row.sha256, uploadedBy: row.uploadedBy, uploadedAt: row.uploadedAt,
917
- repoId: row.repoId, repoDirectory: row.repoDirectory, repoEmbedded: row.repoEmbedded === 1, analyzer: row.analyzer, visible: row.visible === 1,
918
- bundleId: row.bundleId,
919
- }
920
- },
921
951
  async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
922
952
  const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
923
953
  if (!session) return null
@@ -1165,14 +1195,14 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1165
1195
  const scopes = await stmts.selectUserTeamScopesStmt.all(userId) as { teamId: string; repoId: number; path: string; fullName: string }[]
1166
1196
  const reportsByTeam = new Map<string, UserTeamReport[]>()
1167
1197
  for (const r of (await selectUserTeamReportsStmt.all(userId)) as {
1168
- teamId: string; id: string; slug: string; filename: string; sha256: string; bundleId: string | null; repoId: number
1198
+ teamId: string; id: string; slug: string; filename: string; analyzer: string | null; sha256: string; bundleId: string | null; repoId: number
1169
1199
  repoDirectory: string; repoFullName: string | null; dependencies: number; security: number; role: string
1170
1200
  }[]) {
1171
1201
  const list = reportsByTeam.get(r.teamId) ?? []
1172
1202
  const cacheKey = createHash('sha256').update(JSON.stringify([
1173
1203
  r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role,
1174
1204
  ])).digest('base64url')
1175
- list.push({ id: r.id, slug: r.slug, filename: r.filename, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, cacheKey })
1205
+ list.push({ id: r.id, slug: r.slug, filename: r.filename, analyzer: r.analyzer, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, cacheKey })
1176
1206
  reportsByTeam.set(r.teamId, list)
1177
1207
  }
1178
1208
  const bundlesByTeam = new Map<string, UserTeamBundle[]>()
@@ -1320,6 +1350,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1320
1350
  return row?.githubId ?? null
1321
1351
  },
1322
1352
  ...githubMetadataMethods(db),
1353
+ ...managedIssueMethods(db),
1323
1354
  ...selectedRepoMethods(stmts),
1324
1355
  ...activity,
1325
1356
  ...comments,
@@ -3,6 +3,7 @@ import { WebSocket } from 'ws'
3
3
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
4
4
  import { MANAGED_SCHEMA } from './db-schema.ts'
5
5
  import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
6
+ import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
6
7
  import { COMMENT_SCHEMA } from './comments.ts'
7
8
  import { ACTIVITY_SCHEMA } from './activity.ts'
8
9
  import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
@@ -56,7 +57,7 @@ async function initialize(db: PgConnection): Promise<void> {
56
57
  await db.query(postgresSchema(WORKSPACE_SHARE_SCHEMA))
57
58
  await db.query('INSERT INTO managed_schema_version VALUES (4)')
58
59
  }
59
- await db.query(postgresSchema(GITHUB_METADATA_SCHEMA))
60
+ await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA))
60
61
  await db.query(`ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS state_reason ${GITHUB_STATE_REASON_COLUMN}`)
61
62
  await db.query('ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS attempted_at BIGINT')
62
63
  if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 5')).rows.length === 0) {
@@ -78,6 +79,10 @@ async function initialize(db: PgConnection): Promise<void> {
78
79
  await db.query("ALTER TABLE managed_bundle ADD COLUMN IF NOT EXISTS repo_directory TEXT NOT NULL DEFAULT ''")
79
80
  await db.query('INSERT INTO managed_schema_version VALUES (7)')
80
81
  }
82
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 8')).rows.length === 0) {
83
+ await db.query('CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id)')
84
+ await db.query('INSERT INTO managed_schema_version VALUES (8)')
85
+ }
81
86
  await db.query('COMMIT')
82
87
  } catch (err) {
83
88
  await db.query('ROLLBACK')
@@ -108,6 +108,7 @@ CREATE TABLE IF NOT EXISTS managed_report (
108
108
  ) STRICT;
109
109
 
110
110
  CREATE INDEX IF NOT EXISTS managed_report_uploaded_at_idx ON managed_report(uploaded_at);
111
+ CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id);
111
112
  CREATE INDEX IF NOT EXISTS managed_report_bundle_integrity_idx ON managed_report(bundle_integrity);
112
113
  CREATE INDEX IF NOT EXISTS managed_report_bundle_hash_idx ON managed_report(bundle_id, sha256);
113
114
 
@@ -8,6 +8,7 @@ import { initCommentMethods } from './comments.ts'
8
8
  import { initActivityMethods } from './activity.ts'
9
9
  import { MANAGED_SCHEMA } from './db-schema.ts'
10
10
  import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
11
+ import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
11
12
  import { managedTableRenames } from './db-table-names.ts'
12
13
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
13
14
  import { createSqliteDriver, scopeManagedMethods } from './sql.ts'
@@ -64,7 +65,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
64
65
  db.exec('PRAGMA synchronous = FULL;')
65
66
  db.exec('PRAGMA foreign_keys = ON;')
66
67
  migrateManagedTableNames(db)
67
- db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA)
68
+ db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA)
68
69
  ensureColumn(db, 'managed_github_metadata', 'state_reason', GITHUB_STATE_REASON_COLUMN)
69
70
  ensureColumn(db, 'managed_github_metadata', 'attempted_at', 'INTEGER')
70
71
  // Existing public links must opt in too; never preserve an implicit grant.
@@ -1,23 +1,32 @@
1
- // Repository listing for the "Manage repositories" page, READ-ONLY. Two sources,
2
- // merged + deduped by full name:
3
- //
4
- // PUBLIC — the logged-in user's own repos (GET /user/repos) read with their
5
- // identity-only login token. No installation; the login App needs no
6
- // repository permissions, so login never shows "Act on your behalf".
7
- // PRIVATE — a SEPARATE GitHub App (Contents: Read) the user installs on the
8
- // repos they want; read server-side via installation tokens. That
9
- // App's Contents permission lives on IT, never on the login App, so
10
- // login stays clean. "Connect a repository" installs it (installUrl).
11
- //
12
- // Archived repos are skipped. Nothing here writes.
1
+ // Repository discovery uses the signed-in user token and App installation
2
+ // tokens. Login and repository installation can use one GitHub App: user
3
+ // authorization requests account permissions, installation grants repository
4
+ // permissions. Discovery itself is read-only and skips archived repositories.
13
5
  import { Buffer } from 'node:buffer'
14
- import { createSign } from 'node:crypto'
6
+ import { createHash, createSign } from 'node:crypto'
15
7
  import type { ManagedConfig } from './config.ts'
8
+ import { GithubApiError, throwGithubResponseError } from './github-errors.ts'
9
+
10
+ export { GithubApiError } from './github-errors.ts'
16
11
 
17
12
  const GITHUB_API = 'https://api.github.com'
18
13
  const API_VERSION = '2022-11-28'
19
14
  const USER_AGENT = 'deepview-triage'
20
15
  const PER_PAGE = 100
16
+ const INSTALLATION_TOKEN_TTL_MS = 5 * 60_000
17
+ const INSTALLATION_TOKEN_EXPIRY_MARGIN_MS = 60_000
18
+ const MAX_INSTALLATION_TOKENS = 256
19
+ interface InstallationTokenEntry { token: string | null; expiresAt: number; pending: Promise<string> | null }
20
+ // Credentials only: visibility, user identities, and permissions are never
21
+ // cached here. Bound memory per transport and separate App/key/install scopes.
22
+ const installationTokens = new WeakMap<typeof fetch, Map<string, InstallationTokenEntry>>()
23
+
24
+ function discardInstallationToken(token: string | null, fetchImpl: typeof fetch): void {
25
+ if (!token) return
26
+ const cache = installationTokens.get(fetchImpl)
27
+ if (!cache) return
28
+ for (const [key, entry] of cache) if (entry.token === token) cache.delete(key)
29
+ }
21
30
 
22
31
  // One listed repository. Carries the context to read its contents later:
23
32
  // `installationId` mints an App installation token (Contents: Read) for a repo
@@ -33,18 +42,7 @@ export interface ConnectedRepo {
33
42
  installationId: number | null
34
43
  }
35
44
 
36
- // A failure carrying the HTTP status the router should surface. 401 passes
37
- // through so the user-token path can map it to "log in again".
38
- export class GithubApiError extends Error {
39
- status: number
40
- constructor(status: number, message: string) {
41
- super(message)
42
- this.name = 'GithubApiError'
43
- this.status = status
44
- }
45
- }
46
-
47
- // The separate repositories App is configured (id + private key present) →
45
+ // App installation credentials are configured (id + private key present) →
48
46
  // private repos can be listed via its installation tokens.
49
47
  export function githubAppConfigured(config: ManagedConfig): boolean {
50
48
  return config.githubAppId != null && config.githubAppPrivateKey != null
@@ -70,7 +68,7 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
70
68
  // One GitHub API call returning parsed JSON, optionally Bearer-authed by a
71
69
  // user token, App JWT, or installation token. Network / non-2xx / malformed
72
70
  // fold into a GithubApiError (401 passes through for the caller to handle).
73
- async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET'): Promise<unknown> {
71
+ export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET'): Promise<unknown> {
74
72
  let res: Response
75
73
  try {
76
74
  res = await fetchImpl(url, {
@@ -83,9 +81,8 @@ async function githubJson(url: string, token: string | null, fetchImpl: typeof f
83
81
  },
84
82
  })
85
83
  } catch { throw new GithubApiError(502, 'github-unreachable') }
86
- if (res.status === 401) throw new GithubApiError(401, 'github-unauthorized')
87
- if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
88
- if (!res.ok) throw new GithubApiError(502, `github-status-${res.status}`)
84
+ if (res.status === 401) discardInstallationToken(token, fetchImpl)
85
+ if (!res.ok) await throwGithubResponseError(res)
89
86
  try { return await res.json() } catch { throw new GithubApiError(502, 'github-malformed') }
90
87
  }
91
88
 
@@ -156,7 +153,7 @@ export async function listUserRepos(accessToken: string, fetchImpl: typeof fetch
156
153
  return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
157
154
  }
158
155
 
159
- // ── PRIVATE: the separate App's installations via installation tokens ──
156
+ // ── PRIVATE: App installations via installation tokens ──
160
157
 
161
158
  function base64url(input: Buffer | string): string {
162
159
  return Buffer.from(input).toString('base64url')
@@ -188,13 +185,65 @@ async function listInstallationIds(jwt: string, fetchImpl: typeof fetch): Promis
188
185
  return ids
189
186
  }
190
187
 
191
- // Exchange the App JWT for a scoped installation access token (inherits the
192
- // App's Contents: Read).
193
- async function mintInstallationToken(jwt: string, installId: number, fetchImpl: typeof fetch): Promise<string> {
194
- const body = await githubJson(`${GITHUB_API}/app/installations/${installId}/access_tokens`, jwt, fetchImpl, 'POST')
195
- const token = (body as { token?: unknown }).token
196
- if (typeof token !== 'string' || token === '') throw new GithubApiError(502, 'github-install-token-denied')
197
- return token
188
+ // Reuse installation credentials only in this process. Refresh well before
189
+ // expiry and coalesce concurrent mints; failed or undated tokens are not cached.
190
+ async function installationAccessToken(appId: string, privateKey: string, installId: number, fetchImpl: typeof fetch): Promise<string> {
191
+ let cache = installationTokens.get(fetchImpl)
192
+ if (!cache) { cache = new Map(); installationTokens.set(fetchImpl, cache) }
193
+ const now = Date.now()
194
+ for (const [key, entry] of cache) if (!entry.pending && entry.expiresAt <= now) cache.delete(key)
195
+ const key = JSON.stringify([appId, createHash('sha256').update(privateKey).digest('hex'), installId])
196
+ const cached = cache.get(key)
197
+ if (cached) {
198
+ cache.delete(key)
199
+ cache.set(key, cached)
200
+ if (cached.pending) return cached.pending
201
+ return cached.token!
202
+ }
203
+ const entry: InstallationTokenEntry = { token: null, expiresAt: 0, pending: null }
204
+ const pending = Promise.resolve().then(async () => {
205
+ try {
206
+ const jwt = appJwt(appId, privateKey)
207
+ const body = await githubJson(`${GITHUB_API}/app/installations/${installId}/access_tokens`, jwt, fetchImpl, 'POST') as { token?: unknown; expires_at?: unknown }
208
+ if (typeof body?.token !== 'string' || body.token === '') throw new GithubApiError(502, 'github-install-token-denied')
209
+ entry.token = body.token
210
+ const expiresAt = typeof body.expires_at === 'string' ? Date.parse(body.expires_at) : NaN
211
+ entry.expiresAt = Number.isFinite(expiresAt) ? Math.min(now + INSTALLATION_TOKEN_TTL_MS, expiresAt - INSTALLATION_TOKEN_EXPIRY_MARGIN_MS) : 0
212
+ if (entry.expiresAt <= Date.now() && cache.get(key) === entry) cache.delete(key)
213
+ return body.token
214
+ } catch (err) {
215
+ if (cache.get(key) === entry) cache.delete(key)
216
+ throw err
217
+ } finally { entry.pending = null }
218
+ })
219
+ entry.pending = pending
220
+ cache.set(key, entry)
221
+ if (cache.size > MAX_INSTALLATION_TOKENS) cache.delete(cache.keys().next().value!)
222
+ return await pending
223
+ }
224
+
225
+ // Resolve this exact repository with the App's identity, then confirm the
226
+ // installation token reaches the same numeric repository. A missing grant is
227
+ // actionable through installation; transport/auth/rate errors must stay errors.
228
+ export async function repositoryInstallation(config: ManagedConfig, repoId: number, fullName: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<number | null> {
229
+ const { githubAppId, githubAppPrivateKey } = config
230
+ if (githubAppId == null || githubAppPrivateKey == null) throw new GithubApiError(503, 'github-app-not-configured')
231
+ if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(409, 'repo-identity-changed')
232
+ const path = fullName.split('/').map(encodeURIComponent).join('/')
233
+ const jwt = appJwt(githubAppId, githubAppPrivateKey)
234
+ let raw
235
+ try { raw = await githubJson(`${GITHUB_API}/repos/${path}/installation`, jwt, fetchImpl) } catch (err) {
236
+ if (err instanceof GithubApiError && err.status === 404) return null
237
+ throw err
238
+ }
239
+ const installation = raw as { id?: unknown; suspended_at?: unknown; permissions?: { contents?: unknown } } | null
240
+ const id = installation?.id
241
+ if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) throw new GithubApiError(502, 'github-malformed')
242
+ if (installation?.suspended_at != null || !['read', 'write'].includes(String(installation?.permissions?.contents))) return null
243
+ const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
244
+ const repo = parseRepo(await githubJson(`${GITHUB_API}/repos/${path}`, token, fetchImpl), id)
245
+ if (repo == null || repo.id !== repoId || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(409, 'repo-identity-changed')
246
+ return id
198
247
  }
199
248
 
200
249
  // The `repositories` array of one /installation/repositories page → ConnectedRepo[],
@@ -232,7 +281,7 @@ export async function listInstalledRepos(config: ManagedConfig, fetchImpl: typeo
232
281
  if (githubAppId == null || githubAppPrivateKey == null) return []
233
282
  const jwt = appJwt(githubAppId, githubAppPrivateKey)
234
283
  const lists = await mapGithubRequests(await listInstallationIds(jwt, fetchImpl), async (id) => {
235
- const token = await mintInstallationToken(jwt, id, fetchImpl)
284
+ const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
236
285
  return listInstallationRepos(token, id, fetchImpl)
237
286
  })
238
287
  return mergeRepos(...lists)
@@ -260,9 +309,25 @@ async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>
260
309
  // https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
261
310
  // Resolve the current login from /user so renamed handles cannot check someone
262
311
  // else's access. All repo paths come from GitHub's installation catalogue.
263
- export async function filterInstalledRepos(config: ManagedConfig, repositories: ConnectedRepo[], userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
312
+ export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<{ id: number; login: string }> {
264
313
  const identity = await githubJson(`${GITHUB_API}/user`, userToken, fetchImpl) as { id?: unknown; login?: unknown }
265
314
  if (typeof identity?.login !== 'string' || !identity.login || typeof identity.id !== 'number' || !Number.isSafeInteger(identity.id) || identity.id <= 0) throw new GithubApiError(502, 'github-malformed')
315
+ return { id: identity.id, login: identity.login }
316
+ }
317
+
318
+ export async function githubRepoReadPermission(fullName: string, token: string, identity: { id: number; login: string }, fetchImpl: typeof fetch = globalThis.fetch): Promise<boolean> {
319
+ const path = fullName.split('/').map(encodeURIComponent).join('/')
320
+ try {
321
+ const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login)}/permission`, token, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
322
+ return body?.user?.id === identity.id && ['read', 'write', 'admin'].includes(String(body?.permission))
323
+ } catch (err) {
324
+ if (err instanceof GithubApiError && err.status === 404) return false
325
+ throw err // Never broaden access when GitHub cannot verify permission.
326
+ }
327
+ }
328
+
329
+ export async function filterInstalledRepos(config: ManagedConfig, repositories: ConnectedRepo[], userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
330
+ const identity = await githubUserIdentity(userToken, fetchImpl)
266
331
  const tokens = new Map<number, Promise<string | null>>()
267
332
  const allowed = await mapGithubRequests(repositories, async (repo) => {
268
333
  // Internal repos may have private=false; missing visibility is not proof
@@ -276,26 +341,19 @@ export async function filterInstalledRepos(config: ManagedConfig, repositories:
276
341
  }
277
342
  const accessToken = await token
278
343
  if (!accessToken) return false
279
- const path = repo.fullName.split('/').map(encodeURIComponent).join('/')
280
- try {
281
- const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login as string)}/permission`, accessToken, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
282
- return body?.user?.id === identity.id && ['read', 'write', 'admin'].includes(String(body?.permission))
283
- } catch (err) {
284
- if (err instanceof GithubApiError && err.status === 404) return false
285
- throw err // Never broaden the list when GitHub cannot verify access.
286
- }
344
+ return githubRepoReadPermission(repo.fullName, accessToken, identity, fetchImpl)
287
345
  })
288
346
  return repositories.filter((_, index) => allowed[index])
289
347
  }
290
348
 
291
- // Mint a token to READ a selected repo's contents: an installation token (the
349
+ // Get a token to READ a selected repo's contents: an installation token (the
292
350
  // App's Contents: Read) when the repo was reached through an installation, or
293
351
  // null for a public repo readable unauthenticated. This is the whole point of
294
352
  // persisting `installationId` — the stored context is enough to read.
295
353
  export async function repoAccessToken(config: ManagedConfig, installationId: number | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
296
354
  const { githubAppId, githubAppPrivateKey } = config
297
355
  if (installationId == null || githubAppId == null || githubAppPrivateKey == null) return null
298
- return await mintInstallationToken(appJwt(githubAppId, githubAppPrivateKey), installationId, fetchImpl)
356
+ return await installationAccessToken(githubAppId, githubAppPrivateKey, installationId, fetchImpl)
299
357
  }
300
358
 
301
359
  // ── merged listing ──
@@ -0,0 +1,28 @@
1
+ // A failure carrying the HTTP status the router should surface. 401 passes
2
+ // through so the user-token path can map it to "log in again".
3
+ export class GithubApiError extends Error {
4
+ status: number
5
+ retryAfter: number | null
6
+ constructor(status: number, message: string, retryAfter: number | null = null) {
7
+ super(message)
8
+ this.name = 'GithubApiError'
9
+ this.status = status
10
+ this.retryAfter = retryAfter
11
+ }
12
+ }
13
+
14
+ export async function throwGithubResponseError(res: Response): Promise<never> {
15
+ if (res.status === 403 || res.status === 429) {
16
+ const body = await res.json().catch(() => null) as { message?: unknown } | null
17
+ if (res.status === 429 || res.headers.get('x-ratelimit-remaining') === '0' || res.headers.has('retry-after')
18
+ || (typeof body?.message === 'string' && /rate limit|abuse detection/iu.test(body.message))) {
19
+ const retry = Number(res.headers.get('retry-after'))
20
+ const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000
21
+ const wait = Math.max(Number.isFinite(retry) ? retry : 0, Number.isFinite(reset) ? (reset - Date.now()) / 1000 : 0)
22
+ throw new GithubApiError(429, 'github-rate-limited', Math.max(60, Math.ceil(wait)))
23
+ }
24
+ }
25
+ if (res.status === 401) throw new GithubApiError(401, 'github-unauthorized')
26
+ if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
27
+ throw new GithubApiError(502, `github-status-${res.status}`)
28
+ }