@preventive/triage 1.0.0-alpha.22 → 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.22",
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,6 +109,7 @@
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",
113
114
  "server-managed/github-issue-oauth.ts",
114
115
  "server-managed/github-issues.ts",
@@ -281,6 +281,7 @@ export interface UserTeamReport {
281
281
  id: string
282
282
  slug: string
283
283
  filename: string
284
+ analyzer: string | null
284
285
  repoFullName: string | null
285
286
  repoDirectory: string
286
287
  cacheKey: string
@@ -748,7 +749,7 @@ function prepareStatements(db: ManagedSql) {
748
749
  // Reports attached to the repos of the user's teams, tagged by team (a report
749
750
  // shows under every team whose repo it's attached to). Newest first.
750
751
  selectUserTeamReportsStmt: db.prepare(
751
- `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,
752
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,
753
754
  tu.view_dependencies AS dependencies, tu.view_security AS security,
754
755
  (SELECT role FROM managed_user WHERE id = tu.user_id) AS role
@@ -1194,14 +1195,14 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1194
1195
  const scopes = await stmts.selectUserTeamScopesStmt.all(userId) as { teamId: string; repoId: number; path: string; fullName: string }[]
1195
1196
  const reportsByTeam = new Map<string, UserTeamReport[]>()
1196
1197
  for (const r of (await selectUserTeamReportsStmt.all(userId)) as {
1197
- 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
1198
1199
  repoDirectory: string; repoFullName: string | null; dependencies: number; security: number; role: string
1199
1200
  }[]) {
1200
1201
  const list = reportsByTeam.get(r.teamId) ?? []
1201
1202
  const cacheKey = createHash('sha256').update(JSON.stringify([
1202
1203
  r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role,
1203
1204
  ])).digest('base64url')
1204
- 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 })
1205
1206
  reportsByTeam.set(r.teamId, list)
1206
1207
  }
1207
1208
  const bundlesByTeam = new Map<string, UserTeamBundle[]>()
@@ -5,6 +5,9 @@
5
5
  import { Buffer } from 'node:buffer'
6
6
  import { createHash, createSign } from 'node:crypto'
7
7
  import type { ManagedConfig } from './config.ts'
8
+ import { GithubApiError, throwGithubResponseError } from './github-errors.ts'
9
+
10
+ export { GithubApiError } from './github-errors.ts'
8
11
 
9
12
  const GITHUB_API = 'https://api.github.com'
10
13
  const API_VERSION = '2022-11-28'
@@ -39,17 +42,6 @@ export interface ConnectedRepo {
39
42
  installationId: number | null
40
43
  }
41
44
 
42
- // A failure carrying the HTTP status the router should surface. 401 passes
43
- // through so the user-token path can map it to "log in again".
44
- export class GithubApiError extends Error {
45
- status: number
46
- constructor(status: number, message: string) {
47
- super(message)
48
- this.name = 'GithubApiError'
49
- this.status = status
50
- }
51
- }
52
-
53
45
  // App installation credentials are configured (id + private key present) →
54
46
  // private repos can be listed via its installation tokens.
55
47
  export function githubAppConfigured(config: ManagedConfig): boolean {
@@ -89,12 +81,8 @@ export async function githubJson(url: string, token: string | null, fetchImpl: t
89
81
  },
90
82
  })
91
83
  } catch { throw new GithubApiError(502, 'github-unreachable') }
92
- if (res.status === 401) {
93
- discardInstallationToken(token, fetchImpl)
94
- throw new GithubApiError(401, 'github-unauthorized')
95
- }
96
- if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
97
- 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)
98
86
  try { return await res.json() } catch { throw new GithubApiError(502, 'github-malformed') }
99
87
  }
100
88
 
@@ -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
+ }
@@ -1060,7 +1060,10 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1060
1060
  ...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false } : {}), path, commit,
1061
1061
  })
1062
1062
  } catch (err) {
1063
- if (err instanceof GithubApiError) { sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }); return }
1063
+ if (err instanceof GithubApiError) {
1064
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
1065
+ return
1066
+ }
1064
1067
  throw err
1065
1068
  }
1066
1069
  }
@@ -16,6 +16,10 @@ export function scopedDirectory(path: string, scopes: (string | null)[]): Reposi
16
16
  export interface RepositoryBrowserUser { githubUserId: number | null; token: string | null }
17
17
  type GithubIdentity = Awaited<ReturnType<typeof githubUserIdentity>>
18
18
 
19
+ function credentialFailure(err: unknown): err is GithubApiError {
20
+ return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
21
+ }
22
+
19
23
  // Create once per HTTP request. Coalesce concurrent credential lookups without
20
24
  // caching repository visibility or permissions, or sharing credentials across users.
21
25
  export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
@@ -32,31 +36,40 @@ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryB
32
36
  return identity
33
37
  }
34
38
  return {
35
- async reader(repo: SelectedRepo) {
36
- let token = Promise.resolve<string | null>(null)
37
- if (repo.installationId != null) {
39
+ reader(repo: SelectedRepo) {
40
+ const installationToken = () => {
41
+ if (repo.installationId == null) return Promise.resolve(null)
38
42
  const cached = tokens.get(repo.installationId)
39
- token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
40
- if (!(err instanceof GithubApiError)) throw err
41
- // Stale installations cannot prevent verified anonymous public access.
43
+ const token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
44
+ if (!credentialFailure(err)) throw err
45
+ // Stale installations cannot prevent verified public access.
42
46
  return null
43
47
  })
44
48
  if (!cached) tokens.set(repo.installationId, token)
49
+ return token
45
50
  }
46
- return repositoryReader(repo, await token, getIdentity, fetchImpl)
51
+ return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl)
47
52
  },
48
53
  }
49
54
  }
50
55
 
51
- async function repositoryReader(repo: SelectedRepo, token: string | null, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
56
+ async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
52
57
  const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
58
+ // Public reads can use the signed-in user's quota without repository grants.
59
+ // Nonpublic reads still require the repository App and the effective-user gate.
60
+ let token = userToken ?? await installationToken()
53
61
  const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
54
62
  const metadata = async () => {
55
63
  try { return await read('') }
56
64
  catch (err) {
57
- if (!token || !(err instanceof GithubApiError)) throw err
58
- // A valid installation token may have lost this particular repository.
59
- // After falling back, all reads and rechecks stay anonymous.
65
+ if (!token || !credentialFailure(err)) throw err
66
+ const fallback = await installationToken()
67
+ if (fallback && fallback !== token) {
68
+ token = fallback
69
+ try { return await read('') } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
70
+ }
71
+ // Anonymous fallback is only for unavailable credentials, never rate
72
+ // limits, network errors, or GitHub outages.
60
73
  token = null
61
74
  return read('')
62
75
  }
@@ -70,9 +83,12 @@ async function repositoryReader(repo: SelectedRepo, token: string | null, getIde
70
83
  }
71
84
  if (data.private === false && data.visibility === 'public') return data
72
85
  if (!token) throw new GithubApiError(404, 'no-repository')
73
- if (!(await githubRepoReadPermission(repo.fullName, token, await getIdentity(freshIdentity), fetchImpl))) {
86
+ const appToken = await installationToken()
87
+ if (!appToken) throw new GithubApiError(404, 'no-repository')
88
+ if (!(await githubRepoReadPermission(repo.fullName, appToken, await getIdentity(freshIdentity), fetchImpl))) {
74
89
  throw new GithubApiError(404, 'no-repository')
75
90
  }
91
+ token = appToken
76
92
  return data
77
93
  }
78
94
  const current = await checkAccess()
@@ -71,7 +71,7 @@ function shareQueries(db: ManagedSql) {
71
71
  const repositories = db.prepare(`SELECT tr.repo_id AS repoId, sr.full_name AS github, tr.path
72
72
  FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
73
73
  WHERE tr.team_id = ? ORDER BY tr.repo_id, tr.path`)
74
- const reports = db.prepare(`SELECT DISTINCT r.id, r.slug, r.filename, r.byte_size AS byteSize,
74
+ const reports = db.prepare(`SELECT DISTINCT r.id, r.slug, r.filename, r.analyzer, r.byte_size AS byteSize,
75
75
  r.sha256, r.repo_directory AS directory, sr.full_name AS github
76
76
  FROM managed_team_repo tr JOIN managed_report r ON r.repo_id = tr.repo_id
77
77
  JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
@@ -128,7 +128,7 @@ export function workspaceShareMethods(db: ManagedSql): WorkspaceShareStore {
128
128
  if (!grant) return null
129
129
  const { dependencies, security, ...team } = grant
130
130
  const permissions = { dependencies: dependencies === 1, security: security === 1 }
131
- const rows = await q.reports.all(team.id) as { id: string; slug: string; filename: string; byteSize: number; sha256: string; directory: string; github: string }[]
131
+ const rows = await q.reports.all(team.id) as { id: string; slug: string; filename: string; analyzer: string | null; byteSize: number; sha256: string; directory: string; github: string }[]
132
132
  const bundleRows = await q.bundles.all(team.id) as (ManagedBundle & { repoFullName: string })[]
133
133
  return {
134
134
  user: { id: `share:${tokenHash}`, login: 'public', name: 'Public workspace', avatarUrl: null, role: 'view' },
@@ -138,7 +138,7 @@ export function workspaceShareMethods(db: ManagedSql): WorkspaceShareStore {
138
138
  reports: rows.map(row => ({ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
139
139
  repo: { github: row.github, directory: row.directory }, permissions })),
140
140
  team: { ...team,
141
- reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, repoFullName: row.github, repoDirectory: row.directory,
141
+ reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, analyzer: row.analyzer, repoFullName: row.github, repoDirectory: row.directory,
142
142
  cacheKey: JSON.stringify([row.sha256, row.github, row.directory, row.filename, permissions]) })),
143
143
  bundles: bundleRows.map(row => ({ id: row.id, slug: row.slug, integrity: row.integrity,
144
144
  filename: row.filename, kind: row.kind, byteSize: row.byteSize, repoId: row.repoId!, repoDirectory: row.repoDirectory, repoFullName: row.repoFullName })),