@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/out/client-managed.js +2 -2
- package/out/view.js +142 -142
- package/package.json +2 -1
- package/server-managed/db-methods.ts +4 -3
- package/server-managed/github-app.ts +5 -17
- package/server-managed/github-errors.ts +28 -0
- package/server-managed/http.ts +4 -1
- package/server-managed/repository-browser.ts +28 -12
- package/server-managed/workspace-shares.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
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
|
-
|
|
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
|
+
}
|
package/server-managed/http.ts
CHANGED
|
@@ -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) {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
41
|
-
// Stale installations cannot prevent verified
|
|
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,
|
|
51
|
+
return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl)
|
|
47
52
|
},
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
55
|
|
|
51
|
-
async function repositoryReader(repo: SelectedRepo,
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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 })),
|