@preventive/triage 1.0.0-alpha.20 → 1.0.0-alpha.22

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.20",
3
+ "version": "1.0.0-alpha.22",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -110,11 +110,14 @@
110
110
  "server-managed/db.ts",
111
111
  "server-managed/github-app.ts",
112
112
  "server-managed/github-metadata.ts",
113
+ "server-managed/github-issue-oauth.ts",
114
+ "server-managed/github-issues.ts",
113
115
  "server-managed/github-oauth.ts",
114
116
  "server-managed/github-pulls.ts",
115
117
  "server-managed/http-response.ts",
116
118
  "server-managed/http.ts",
117
119
  "server-managed/import-triage.ts",
120
+ "server-managed/managed-issues.ts",
118
121
  "server-managed/index.ts",
119
122
  "server-managed/public-workspace.ts",
120
123
  "server-managed/repo-path.ts",
@@ -122,6 +125,7 @@
122
125
  "server-managed/report-query.ts",
123
126
  "server-managed/report-response.ts",
124
127
  "server-managed/report-sources.ts",
128
+ "server-managed/repository-browser.ts",
125
129
  "server-managed/repository-discovery.ts",
126
130
  "server-managed/repository-policy.ts",
127
131
  "server-managed/session.ts",
@@ -172,12 +176,16 @@
172
176
  "common/bundle-reasons.js",
173
177
  "common/bundle-sources.d.ts",
174
178
  "common/bundle-sources.js",
179
+ "common/bundle-source-links.d.ts",
180
+ "common/bundle-source-links.js",
175
181
  "common/bundle-stats.js",
176
182
  "common/bundle-tabs.js",
177
183
  "common/code-language.js",
178
184
  "common/default-scan-models.ts",
179
185
  "common/finding-id.js",
180
186
  "common/github-pr.ts",
187
+ "common/github-issue-labels.js",
188
+ "common/github-issue-labels.d.ts",
181
189
  "common/managed/comments.ts",
182
190
  "common/managed/permissions.ts",
183
191
  "common/managed/report-content.ts",
@@ -200,6 +208,8 @@
200
208
  "out/client-managed-import.js",
201
209
  "out/client-sync.js",
202
210
  "out/graph.js",
211
+ "out/github-issue-authorized.html",
212
+ "out/github-issue-authorized.js",
203
213
  "out/icon-maskable.svg",
204
214
  "out/icon.svg",
205
215
  "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
 
@@ -24,8 +20,8 @@ export interface ManagedConfig {
24
20
  // + secret, used by the login flow.
25
21
  githubClientId: string
26
22
  githubClientSecret: string
27
- // Preapproved GitHub identity for the first registration in an empty DB.
28
- // This never changes an existing user's role on later logins.
23
+ // Preapproved GitHub identity promoted on login when it is the only user
24
+ // and its role is No access, including a login after registration.
29
25
  initialAdminGithubId: number | null
30
26
  // Absolute callback registered with GitHub, e.g.
31
27
  // 'https://triage.example.com/api/oauth/github/callback'.
@@ -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'
@@ -305,10 +306,10 @@ export interface UserTeam {
305
306
  }
306
307
 
307
308
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
308
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, WorkspaceShareStore, ImportTriageStore {
309
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore {
309
310
  // Upsert the identity; returns the user's opaque id (stable across logins).
310
- // Initial-admin approval comes only from trusted server configuration and
311
- // applies exclusively to the first insertion into an empty user table.
311
+ // Initial-admin approval comes only from trusted login configuration. It
312
+ // promotes a matching No access identity only while it is the sole user.
312
313
  upsertUser(user: ManagedUser, now: number, initialAdminGithubId?: number | null): Promise<string>
313
314
  createSession(session: ManagedSession, now: number): Promise<void>
314
315
  sessionWithUser(id: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
@@ -325,6 +326,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
325
326
  // mutable context while keeping the original added_by/added_at; deselectRepo
326
327
  // resolves true iff a row was removed.
327
328
  selectRepo(repo: SelectedRepoInput, now: number): Promise<void>
329
+ connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean>
328
330
  deselectRepo(repoId: number): Promise<boolean>
329
331
  listSelectedRepos(): Promise<SelectedRepo[]>
330
332
  listAllRepos(): Promise<ManagedRepo[]>
@@ -344,6 +346,10 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
344
346
  // getReport reads one row (for download); deleteReport resolves true iff a row
345
347
  // was removed.
346
348
  insertReport(report: ReportRecordInput, now: number): Promise<void>
349
+ // Content reuse is atomic with insertion, including across server instances.
350
+ // Existing copies are kept; the oldest matching row is the stable identity.
351
+ insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord>
352
+ getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
347
353
  listReports(userId?: string): Promise<AdminReport[]>
348
354
  getReport(id: string): Promise<ReportRecord | null>
349
355
  getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
@@ -447,18 +453,20 @@ const BUNDLE_IN_TEAM_PATH_SQL = REPORT_IN_TEAM_PATH_SQL.replaceAll('r.repo_direc
447
453
  // destructures — keeps openSqliteManagedDb itself small (one place per query).
448
454
  function prepareStatements(db: ManagedSql) {
449
455
  return {
450
- // Only the operator-preapproved identity may bootstrap the empty DB.
451
- // The emptiness check and insert share the store's writer transaction,
452
- // so concurrent first logins cannot both become admins. Re-login never
453
- // overwrites approval or revocation through the ON CONFLICT update.
456
+ // Identity refreshes preserve roles. Login can separately bootstrap the
457
+ // configured sole No access user within this same writer transaction.
454
458
  upsertUserStmt: db.prepare(
455
459
  `INSERT INTO managed_user (id, github_user_id, login, name, avatar_url, role, created_at, updated_at)
456
- VALUES (?, ?, ?, ?, ?, CASE WHEN NOT EXISTS(SELECT 1 FROM managed_user) THEN ? ELSE 'none' END, ?, ?)
460
+ VALUES (?, ?, ?, ?, ?, 'none', ?, ?)
457
461
  ON CONFLICT(github_user_id) DO UPDATE SET
458
462
  login = excluded.login, name = excluded.name,
459
463
  avatar_url = excluded.avatar_url, updated_at = excluded.updated_at`,
460
464
  ),
461
- selectUserIdStmt: db.prepare(`SELECT id FROM managed_user WHERE github_user_id = ?`),
465
+ selectUserIdStmt: db.prepare(`SELECT id, role FROM managed_user WHERE github_user_id = ?`),
466
+ promoteInitialAdminStmt: db.prepare(
467
+ `UPDATE managed_user SET role = 'admin' WHERE id = ? AND role = 'none'
468
+ AND NOT EXISTS(SELECT 1 FROM managed_user WHERE id <> ?)`,
469
+ ),
462
470
  selectGithubIdStmt: db.prepare(`SELECT github_user_id AS githubId FROM managed_user WHERE id = ?`),
463
471
  insertSessionStmt: db.prepare(
464
472
  `INSERT INTO managed_session (id, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
@@ -502,6 +510,12 @@ function prepareStatements(db: ManagedSql) {
502
510
  installation_id = excluded.installation_id, default_branch = excluded.default_branch,
503
511
  html_url = excluded.html_url, updated_at = excluded.updated_at, active = 1`,
504
512
  ),
513
+ // Never reactivate or recreate a repo, overwrite another connection, or
514
+ // commit after the administrator's session/role was revoked during GitHub I/O.
515
+ connectRepoInstallationStmt: db.prepare(`UPDATE managed_selected_repo SET installation_id = ?, updated_at = ?
516
+ WHERE repo_id = ? AND full_name = ? AND added_at = ? AND installation_id IS NULL
517
+ AND EXISTS (SELECT 1 FROM managed_session s JOIN managed_user u ON u.id = s.user_id
518
+ WHERE s.id = ? AND s.expires_at > ? AND u.role = 'admin')`),
505
519
  deleteRepoStmt: db.prepare(`DELETE FROM managed_selected_repo WHERE repo_id = ?`),
506
520
  deactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 0 WHERE repo_id = ? AND active = 1`),
507
521
  reactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 1 WHERE repo_id = ? AND active = 0`),
@@ -561,6 +575,10 @@ function prepareStatements(db: ManagedSql) {
561
575
  analyzer AS analyzer, visible AS visible, bundle_id AS bundleId
562
576
  FROM managed_report WHERE id = ?`,
563
577
  ),
578
+ selectReportByHashStmt: db.prepare(
579
+ `SELECT id FROM managed_report WHERE sha256 = ? AND COALESCE(analyzer, '') = COALESCE(?, '')
580
+ ORDER BY uploaded_at, id LIMIT 1`,
581
+ ),
564
582
  selectReportAccessStmt: db.prepare(
565
583
  `WITH requested AS (SELECT value AS id FROM json_each(?)), report_grants AS (
566
584
  SELECT r.id AS id, MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
@@ -835,6 +853,9 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
835
853
  async deselectRepo(repoId: number): Promise<boolean> {
836
854
  return Number((await deleteRepoStmt.run(repoId)).changes) > 0
837
855
  },
856
+ async connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean> {
857
+ return Number((await stmts.connectRepoInstallationStmt.run(installationId, now, repo.repoId, repo.fullName, repo.addedAt, sessionId, now)).changes) > 0
858
+ },
838
859
  async listSelectedRepos(): Promise<SelectedRepo[]> {
839
860
  const rows = (await selectReposStmt.all()) as RepoRow[]
840
861
  return rows.map(readRepo)
@@ -886,14 +907,34 @@ type ReportRow = {
886
907
  // openSqliteManagedDb small. Closes over its prepared statements.
887
908
  function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
888
909
  const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
910
+ async function getReport(id: string): Promise<ReportRecord | null> {
911
+ const row = (await selectReportStmt.get(id)) as ReportRow | undefined
912
+ if (row == null) return null
913
+ return { ...row, repoEmbedded: row.repoEmbedded === 1, visible: row.visible === 1 }
914
+ }
915
+ async function getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null> {
916
+ const row = await stmts.selectReportByHashStmt.get(sha256, analyzer) as { id: string } | undefined
917
+ return row ? getReport(row.id) : null
918
+ }
919
+ async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
920
+ await insertReportStmt.run(
921
+ report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
922
+ report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
923
+ report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
924
+ report.bundleIntegrity ?? null, now,
925
+ )
926
+ }
889
927
  return {
890
- async insertReport(report: ReportRecordInput, now: number): Promise<void> {
891
- await insertReportStmt.run(
892
- report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
893
- report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
894
- report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
895
- report.bundleIntegrity ?? null, now,
896
- )
928
+ insertReport,
929
+ getReport,
930
+ getReportByHash,
931
+ async insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord> {
932
+ // The driver holds its writer lock for this whole operation. Checking
933
+ // again here closes the race between the HTTP lookup and blob upload.
934
+ const existing = await getReportByHash(report.sha256, report.analyzer ?? null)
935
+ if (existing) return existing
936
+ await insertReport(report, now)
937
+ return (await getReport(report.id))!
897
938
  },
898
939
  async listReports(userId?: string): Promise<AdminReport[]> {
899
940
  const rows = (await selectReportsStmt.all(userId ?? null, userId ?? null, userId ?? null)) as ReportListRow[]
@@ -906,16 +947,6 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
906
947
  uploadedAt: r.uploadedAt,
907
948
  }))
908
949
  },
909
- async getReport(id: string): Promise<ReportRecord | null> {
910
- const row = (await selectReportStmt.get(id)) as ReportRow | undefined
911
- if (row == null) return null
912
- return {
913
- id: row.id, slug: row.slug, filename: row.filename, contentType: row.contentType, byteSize: row.byteSize,
914
- sha256: row.sha256, uploadedBy: row.uploadedBy, uploadedAt: row.uploadedAt,
915
- repoId: row.repoId, repoDirectory: row.repoDirectory, repoEmbedded: row.repoEmbedded === 1, analyzer: row.analyzer, visible: row.visible === 1,
916
- bundleId: row.bundleId,
917
- }
918
- },
919
950
  async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
920
951
  const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
921
952
  if (!session) return null
@@ -1261,7 +1292,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1261
1292
  const stmts = prepareStatements(db)
1262
1293
  const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
1263
1294
  const {
1264
- upsertUserStmt, selectUserIdStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
1295
+ upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
1265
1296
  touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, deleteSessionStmt, deleteExpiredStmt,
1266
1297
  } = stmts
1267
1298
 
@@ -1269,10 +1300,14 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1269
1300
  async upsertUser(user, now, initialAdminGithubId = null) {
1270
1301
  // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1271
1302
  // user's id (DO UPDATE leaves it untouched), so re-read to return it.
1272
- const initialRole = initialAdminGithubId != null && Number.isSafeInteger(initialAdminGithubId) && initialAdminGithubId > 0
1273
- && user.githubUserId === initialAdminGithubId ? 'admin' : 'none'
1274
- await upsertUserStmt.run(randomUUID(), user.githubUserId, user.login, user.name, user.avatarUrl, initialRole, now, now)
1275
- const row = (await selectUserIdStmt.get(user.githubUserId)) as { id: string }
1303
+ await upsertUserStmt.run(randomUUID(), user.githubUserId, user.login, user.name, user.avatarUrl, now, now)
1304
+ const row = (await selectUserIdStmt.get(user.githubUserId)) as { id: string; role: Role }
1305
+ // Avoid the population check unless this login matches the configured
1306
+ // identity and still needs approval. NOT EXISTS stops at any other user.
1307
+ if (initialAdminGithubId != null && Number.isSafeInteger(initialAdminGithubId) && initialAdminGithubId > 0
1308
+ && user.githubUserId === initialAdminGithubId && row.role === 'none') {
1309
+ await promoteInitialAdminStmt.run(row.id, row.id)
1310
+ }
1276
1311
  return row.id
1277
1312
  },
1278
1313
  async createSession(session, now) {
@@ -1314,6 +1349,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1314
1349
  return row?.githubId ?? null
1315
1350
  },
1316
1351
  ...githubMetadataMethods(db),
1352
+ ...managedIssueMethods(db),
1317
1353
  ...selectedRepoMethods(stmts),
1318
1354
  ...activity,
1319
1355
  ...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,29 @@
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'
16
8
 
17
9
  const GITHUB_API = 'https://api.github.com'
18
10
  const API_VERSION = '2022-11-28'
19
11
  const USER_AGENT = 'deepview-triage'
20
12
  const PER_PAGE = 100
13
+ const INSTALLATION_TOKEN_TTL_MS = 5 * 60_000
14
+ const INSTALLATION_TOKEN_EXPIRY_MARGIN_MS = 60_000
15
+ const MAX_INSTALLATION_TOKENS = 256
16
+ interface InstallationTokenEntry { token: string | null; expiresAt: number; pending: Promise<string> | null }
17
+ // Credentials only: visibility, user identities, and permissions are never
18
+ // cached here. Bound memory per transport and separate App/key/install scopes.
19
+ const installationTokens = new WeakMap<typeof fetch, Map<string, InstallationTokenEntry>>()
20
+
21
+ function discardInstallationToken(token: string | null, fetchImpl: typeof fetch): void {
22
+ if (!token) return
23
+ const cache = installationTokens.get(fetchImpl)
24
+ if (!cache) return
25
+ for (const [key, entry] of cache) if (entry.token === token) cache.delete(key)
26
+ }
21
27
 
22
28
  // One listed repository. Carries the context to read its contents later:
23
29
  // `installationId` mints an App installation token (Contents: Read) for a repo
@@ -44,7 +50,7 @@ export class GithubApiError extends Error {
44
50
  }
45
51
  }
46
52
 
47
- // The separate repositories App is configured (id + private key present) →
53
+ // App installation credentials are configured (id + private key present) →
48
54
  // private repos can be listed via its installation tokens.
49
55
  export function githubAppConfigured(config: ManagedConfig): boolean {
50
56
  return config.githubAppId != null && config.githubAppPrivateKey != null
@@ -70,7 +76,7 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
70
76
  // One GitHub API call returning parsed JSON, optionally Bearer-authed by a
71
77
  // user token, App JWT, or installation token. Network / non-2xx / malformed
72
78
  // 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> {
79
+ export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET'): Promise<unknown> {
74
80
  let res: Response
75
81
  try {
76
82
  res = await fetchImpl(url, {
@@ -83,7 +89,10 @@ async function githubJson(url: string, token: string | null, fetchImpl: typeof f
83
89
  },
84
90
  })
85
91
  } catch { throw new GithubApiError(502, 'github-unreachable') }
86
- if (res.status === 401) throw new GithubApiError(401, 'github-unauthorized')
92
+ if (res.status === 401) {
93
+ discardInstallationToken(token, fetchImpl)
94
+ throw new GithubApiError(401, 'github-unauthorized')
95
+ }
87
96
  if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
88
97
  if (!res.ok) throw new GithubApiError(502, `github-status-${res.status}`)
89
98
  try { return await res.json() } catch { throw new GithubApiError(502, 'github-malformed') }
@@ -156,7 +165,7 @@ export async function listUserRepos(accessToken: string, fetchImpl: typeof fetch
156
165
  return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
157
166
  }
158
167
 
159
- // ── PRIVATE: the separate App's installations via installation tokens ──
168
+ // ── PRIVATE: App installations via installation tokens ──
160
169
 
161
170
  function base64url(input: Buffer | string): string {
162
171
  return Buffer.from(input).toString('base64url')
@@ -188,13 +197,65 @@ async function listInstallationIds(jwt: string, fetchImpl: typeof fetch): Promis
188
197
  return ids
189
198
  }
190
199
 
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
200
+ // Reuse installation credentials only in this process. Refresh well before
201
+ // expiry and coalesce concurrent mints; failed or undated tokens are not cached.
202
+ async function installationAccessToken(appId: string, privateKey: string, installId: number, fetchImpl: typeof fetch): Promise<string> {
203
+ let cache = installationTokens.get(fetchImpl)
204
+ if (!cache) { cache = new Map(); installationTokens.set(fetchImpl, cache) }
205
+ const now = Date.now()
206
+ for (const [key, entry] of cache) if (!entry.pending && entry.expiresAt <= now) cache.delete(key)
207
+ const key = JSON.stringify([appId, createHash('sha256').update(privateKey).digest('hex'), installId])
208
+ const cached = cache.get(key)
209
+ if (cached) {
210
+ cache.delete(key)
211
+ cache.set(key, cached)
212
+ if (cached.pending) return cached.pending
213
+ return cached.token!
214
+ }
215
+ const entry: InstallationTokenEntry = { token: null, expiresAt: 0, pending: null }
216
+ const pending = Promise.resolve().then(async () => {
217
+ try {
218
+ const jwt = appJwt(appId, privateKey)
219
+ const body = await githubJson(`${GITHUB_API}/app/installations/${installId}/access_tokens`, jwt, fetchImpl, 'POST') as { token?: unknown; expires_at?: unknown }
220
+ if (typeof body?.token !== 'string' || body.token === '') throw new GithubApiError(502, 'github-install-token-denied')
221
+ entry.token = body.token
222
+ const expiresAt = typeof body.expires_at === 'string' ? Date.parse(body.expires_at) : NaN
223
+ entry.expiresAt = Number.isFinite(expiresAt) ? Math.min(now + INSTALLATION_TOKEN_TTL_MS, expiresAt - INSTALLATION_TOKEN_EXPIRY_MARGIN_MS) : 0
224
+ if (entry.expiresAt <= Date.now() && cache.get(key) === entry) cache.delete(key)
225
+ return body.token
226
+ } catch (err) {
227
+ if (cache.get(key) === entry) cache.delete(key)
228
+ throw err
229
+ } finally { entry.pending = null }
230
+ })
231
+ entry.pending = pending
232
+ cache.set(key, entry)
233
+ if (cache.size > MAX_INSTALLATION_TOKENS) cache.delete(cache.keys().next().value!)
234
+ return await pending
235
+ }
236
+
237
+ // Resolve this exact repository with the App's identity, then confirm the
238
+ // installation token reaches the same numeric repository. A missing grant is
239
+ // actionable through installation; transport/auth/rate errors must stay errors.
240
+ export async function repositoryInstallation(config: ManagedConfig, repoId: number, fullName: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<number | null> {
241
+ const { githubAppId, githubAppPrivateKey } = config
242
+ if (githubAppId == null || githubAppPrivateKey == null) throw new GithubApiError(503, 'github-app-not-configured')
243
+ if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(409, 'repo-identity-changed')
244
+ const path = fullName.split('/').map(encodeURIComponent).join('/')
245
+ const jwt = appJwt(githubAppId, githubAppPrivateKey)
246
+ let raw
247
+ try { raw = await githubJson(`${GITHUB_API}/repos/${path}/installation`, jwt, fetchImpl) } catch (err) {
248
+ if (err instanceof GithubApiError && err.status === 404) return null
249
+ throw err
250
+ }
251
+ const installation = raw as { id?: unknown; suspended_at?: unknown; permissions?: { contents?: unknown } } | null
252
+ const id = installation?.id
253
+ if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) throw new GithubApiError(502, 'github-malformed')
254
+ if (installation?.suspended_at != null || !['read', 'write'].includes(String(installation?.permissions?.contents))) return null
255
+ const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
256
+ const repo = parseRepo(await githubJson(`${GITHUB_API}/repos/${path}`, token, fetchImpl), id)
257
+ if (repo == null || repo.id !== repoId || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(409, 'repo-identity-changed')
258
+ return id
198
259
  }
199
260
 
200
261
  // The `repositories` array of one /installation/repositories page → ConnectedRepo[],
@@ -232,7 +293,7 @@ export async function listInstalledRepos(config: ManagedConfig, fetchImpl: typeo
232
293
  if (githubAppId == null || githubAppPrivateKey == null) return []
233
294
  const jwt = appJwt(githubAppId, githubAppPrivateKey)
234
295
  const lists = await mapGithubRequests(await listInstallationIds(jwt, fetchImpl), async (id) => {
235
- const token = await mintInstallationToken(jwt, id, fetchImpl)
296
+ const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
236
297
  return listInstallationRepos(token, id, fetchImpl)
237
298
  })
238
299
  return mergeRepos(...lists)
@@ -260,9 +321,25 @@ async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>
260
321
  // https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
261
322
  // Resolve the current login from /user so renamed handles cannot check someone
262
323
  // 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[]> {
324
+ export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<{ id: number; login: string }> {
264
325
  const identity = await githubJson(`${GITHUB_API}/user`, userToken, fetchImpl) as { id?: unknown; login?: unknown }
265
326
  if (typeof identity?.login !== 'string' || !identity.login || typeof identity.id !== 'number' || !Number.isSafeInteger(identity.id) || identity.id <= 0) throw new GithubApiError(502, 'github-malformed')
327
+ return { id: identity.id, login: identity.login }
328
+ }
329
+
330
+ export async function githubRepoReadPermission(fullName: string, token: string, identity: { id: number; login: string }, fetchImpl: typeof fetch = globalThis.fetch): Promise<boolean> {
331
+ const path = fullName.split('/').map(encodeURIComponent).join('/')
332
+ try {
333
+ const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login)}/permission`, token, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
334
+ return body?.user?.id === identity.id && ['read', 'write', 'admin'].includes(String(body?.permission))
335
+ } catch (err) {
336
+ if (err instanceof GithubApiError && err.status === 404) return false
337
+ throw err // Never broaden access when GitHub cannot verify permission.
338
+ }
339
+ }
340
+
341
+ export async function filterInstalledRepos(config: ManagedConfig, repositories: ConnectedRepo[], userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
342
+ const identity = await githubUserIdentity(userToken, fetchImpl)
266
343
  const tokens = new Map<number, Promise<string | null>>()
267
344
  const allowed = await mapGithubRequests(repositories, async (repo) => {
268
345
  // Internal repos may have private=false; missing visibility is not proof
@@ -276,26 +353,19 @@ export async function filterInstalledRepos(config: ManagedConfig, repositories:
276
353
  }
277
354
  const accessToken = await token
278
355
  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
- }
356
+ return githubRepoReadPermission(repo.fullName, accessToken, identity, fetchImpl)
287
357
  })
288
358
  return repositories.filter((_, index) => allowed[index])
289
359
  }
290
360
 
291
- // Mint a token to READ a selected repo's contents: an installation token (the
361
+ // Get a token to READ a selected repo's contents: an installation token (the
292
362
  // App's Contents: Read) when the repo was reached through an installation, or
293
363
  // null for a public repo readable unauthenticated. This is the whole point of
294
364
  // persisting `installationId` — the stored context is enough to read.
295
365
  export async function repoAccessToken(config: ManagedConfig, installationId: number | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
296
366
  const { githubAppId, githubAppPrivateKey } = config
297
367
  if (installationId == null || githubAppId == null || githubAppPrivateKey == null) return null
298
- return await mintInstallationToken(appJwt(githubAppId, githubAppPrivateKey), installationId, fetchImpl)
368
+ return await installationAccessToken(githubAppId, githubAppPrivateKey, installationId, fetchImpl)
299
369
  }
300
370
 
301
371
  // ── merged listing ──