@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20
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/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1330 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +25 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
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.
|
|
13
|
+
import { Buffer } from 'node:buffer'
|
|
14
|
+
import { createSign } from 'node:crypto'
|
|
15
|
+
import type { ManagedConfig } from './config.ts'
|
|
16
|
+
|
|
17
|
+
const GITHUB_API = 'https://api.github.com'
|
|
18
|
+
const API_VERSION = '2022-11-28'
|
|
19
|
+
const USER_AGENT = 'deepview-triage'
|
|
20
|
+
const PER_PAGE = 100
|
|
21
|
+
|
|
22
|
+
// One listed repository. Carries the context to read its contents later:
|
|
23
|
+
// `installationId` mints an App installation token (Contents: Read) for a repo
|
|
24
|
+
// reached through an installation — null for a public repo listed off the user
|
|
25
|
+
// token, readable without the App — and `defaultBranch` is the ref to read.
|
|
26
|
+
export interface ConnectedRepo {
|
|
27
|
+
id: number
|
|
28
|
+
fullName: string
|
|
29
|
+
private: boolean
|
|
30
|
+
visibility: 'public' | 'private' | 'internal' | null
|
|
31
|
+
htmlUrl: string
|
|
32
|
+
defaultBranch: string
|
|
33
|
+
installationId: number | null
|
|
34
|
+
}
|
|
35
|
+
|
|
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) →
|
|
48
|
+
// private repos can be listed via its installation tokens.
|
|
49
|
+
export function githubAppConfigured(config: ManagedConfig): boolean {
|
|
50
|
+
return config.githubAppId != null && config.githubAppPrivateKey != null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The install URL for the repositories App ("Connect a repository"), or null
|
|
54
|
+
// when no slug is set.
|
|
55
|
+
export function installUrl(config: ManagedConfig): string | null {
|
|
56
|
+
if (config.githubAppSlug == null) return null
|
|
57
|
+
return `https://github.com/apps/${encodeURIComponent(config.githubAppSlug)}/installations/new`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Merge repo lists from every source, deduped by full name and sorted. Later
|
|
61
|
+
// sources win a collision (the handler lists private after public).
|
|
62
|
+
export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
|
|
63
|
+
const byName = new Map<string, ConnectedRepo>()
|
|
64
|
+
for (const list of lists) {
|
|
65
|
+
for (const repo of list) byName.set(repo.fullName, repo)
|
|
66
|
+
}
|
|
67
|
+
return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One GitHub API call returning parsed JSON, optionally Bearer-authed by a
|
|
71
|
+
// user token, App JWT, or installation token. Network / non-2xx / malformed
|
|
72
|
+
// 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> {
|
|
74
|
+
let res: Response
|
|
75
|
+
try {
|
|
76
|
+
res = await fetchImpl(url, {
|
|
77
|
+
method,
|
|
78
|
+
signal: AbortSignal.timeout(10_000),
|
|
79
|
+
redirect: 'error',
|
|
80
|
+
headers: {
|
|
81
|
+
...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
|
|
82
|
+
'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
} 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}`)
|
|
89
|
+
try { return await res.json() } catch { throw new GithubApiError(502, 'github-malformed') }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Validate one repo object into a ConnectedRepo (or null to skip), tagging it
|
|
93
|
+
// with the installation it was reached through (null for the user-token path). A
|
|
94
|
+
// missing numeric id or full name → skip (can't key/locate it). Archived repos
|
|
95
|
+
// are skipped — read-only history, not triage targets.
|
|
96
|
+
function parseRepo(raw: unknown, installationId: number | null): ConnectedRepo | null {
|
|
97
|
+
if (raw == null || typeof raw !== 'object') return null
|
|
98
|
+
if ((raw as { archived?: unknown }).archived === true) return null
|
|
99
|
+
const id = (raw as { id?: unknown }).id
|
|
100
|
+
const fullName = (raw as { full_name?: unknown }).full_name
|
|
101
|
+
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
|
|
102
|
+
if (typeof fullName !== 'string' || fullName === '') return null
|
|
103
|
+
const htmlUrl = (raw as { html_url?: unknown }).html_url
|
|
104
|
+
const branch = (raw as { default_branch?: unknown }).default_branch
|
|
105
|
+
const visibility = (raw as { visibility?: unknown }).visibility
|
|
106
|
+
return {
|
|
107
|
+
id,
|
|
108
|
+
fullName,
|
|
109
|
+
private: (raw as { private?: unknown }).private === true,
|
|
110
|
+
visibility: visibility === 'public' || visibility === 'private' || visibility === 'internal' ? visibility : null,
|
|
111
|
+
htmlUrl: typeof htmlUrl === 'string' ? htmlUrl : '',
|
|
112
|
+
defaultBranch: typeof branch === 'string' ? branch : '',
|
|
113
|
+
installationId,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Accept an owner/repo or exact GitHub repository URL, never an arbitrary API
|
|
118
|
+
// path. Reject traversal and encoded separators before constructing a fixed URL.
|
|
119
|
+
export function publicRepositoryName(raw: unknown): string | null {
|
|
120
|
+
if (typeof raw !== 'string') return null
|
|
121
|
+
const name = raw.trim().replace(/^https:\/\/github\.com\//iu, '').replace(/\/$/u, '')
|
|
122
|
+
if (!/^[a-z\d](?:[a-z\d-]{0,38})\/[a-z\d_.-]{1,100}$/iu.test(name)) return null
|
|
123
|
+
if (['.', '..'].includes(name.split('/')[1]!)) return null
|
|
124
|
+
return name
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Public additions must be readable by the server without the acting user's
|
|
128
|
+
// credentials. Require explicit public visibility and use GitHub's canonical
|
|
129
|
+
// metadata, never client-supplied ids, installation ids, or branches.
|
|
130
|
+
export async function fetchPublicRepository(fullName: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo> {
|
|
131
|
+
if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(400, 'bad-repository')
|
|
132
|
+
const path = fullName.split('/').map(encodeURIComponent).join('/')
|
|
133
|
+
const repo = parseRepo(await githubJson(`${GITHUB_API}/repos/${path}`, null, fetchImpl), null)
|
|
134
|
+
if (repo == null || repo.private || repo.visibility !== 'public') throw new GithubApiError(409, 'repo-not-public')
|
|
135
|
+
if (publicRepositoryName(repo.fullName) !== repo.fullName || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(502, 'github-malformed')
|
|
136
|
+
return { ...repo, htmlUrl: `https://github.com/${repo.fullName}` }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── PUBLIC: the user's own repos via their login token ──
|
|
140
|
+
|
|
141
|
+
// List the authenticated user's repositories (GET /user/repos), paginated until
|
|
142
|
+
// a short page, deduped + sorted. With an identity-only token this returns the
|
|
143
|
+
// user's PUBLIC repos. READ-ONLY.
|
|
144
|
+
export async function listUserRepos(accessToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
|
|
145
|
+
const byName = new Map<string, ConnectedRepo>()
|
|
146
|
+
for (let page = 1; ; page++) {
|
|
147
|
+
const url = `${GITHUB_API}/user/repos?per_page=${PER_PAGE}&page=${page}&sort=full_name`
|
|
148
|
+
const body = await githubJson(url, accessToken, fetchImpl)
|
|
149
|
+
if (!Array.isArray(body)) break
|
|
150
|
+
for (const r of body) {
|
|
151
|
+
const repo = parseRepo(r, null)
|
|
152
|
+
if (repo != null) byName.set(repo.fullName, repo)
|
|
153
|
+
}
|
|
154
|
+
if (body.length < PER_PAGE) break
|
|
155
|
+
}
|
|
156
|
+
return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── PRIVATE: the separate App's installations via installation tokens ──
|
|
160
|
+
|
|
161
|
+
function base64url(input: Buffer | string): string {
|
|
162
|
+
return Buffer.from(input).toString('base64url')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Mint a short-lived App JWT (RS256) for the repositories App. iat backdated 60s
|
|
166
|
+
// for clock skew; exp 8 min out (within GitHub's 10-min cap); iss = the App id.
|
|
167
|
+
export function appJwt(appId: string, privateKeyPem: string, now: number = Date.now()): string {
|
|
168
|
+
const seconds = Math.floor(now / 1000)
|
|
169
|
+
const iat = seconds - 60
|
|
170
|
+
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
|
171
|
+
const payload = base64url(JSON.stringify({ iat, exp: seconds + 480, iss: appId }))
|
|
172
|
+
const signingInput = `${header}.${payload}`
|
|
173
|
+
return `${signingInput}.${base64url(createSign('RSA-SHA256').update(signingInput).sign(privateKeyPem))}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The repositories App's installation ids (one per org/user that installed it).
|
|
177
|
+
async function listInstallationIds(jwt: string, fetchImpl: typeof fetch): Promise<number[]> {
|
|
178
|
+
const ids: number[] = []
|
|
179
|
+
for (let page = 1; ; page++) {
|
|
180
|
+
const body = await githubJson(`${GITHUB_API}/app/installations?per_page=${PER_PAGE}&page=${page}`, jwt, fetchImpl)
|
|
181
|
+
if (!Array.isArray(body)) throw new GithubApiError(502, 'github-malformed')
|
|
182
|
+
for (const inst of body) {
|
|
183
|
+
const id = (inst as { id?: unknown }).id
|
|
184
|
+
if (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) ids.push(id)
|
|
185
|
+
}
|
|
186
|
+
if (body.length < PER_PAGE) break
|
|
187
|
+
}
|
|
188
|
+
return ids
|
|
189
|
+
}
|
|
190
|
+
|
|
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
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The `repositories` array of one /installation/repositories page → ConnectedRepo[],
|
|
201
|
+
// each tagged with the installation id that can read it.
|
|
202
|
+
function repoPage(body: unknown, installationId: number): ConnectedRepo[] {
|
|
203
|
+
const repositories = (body as { repositories?: unknown }).repositories
|
|
204
|
+
if (!Array.isArray(repositories)) return []
|
|
205
|
+
const out: ConnectedRepo[] = []
|
|
206
|
+
for (const r of repositories) {
|
|
207
|
+
const repo = parseRepo(r, installationId)
|
|
208
|
+
if (repo != null) out.push(repo)
|
|
209
|
+
}
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Every repository one installation can read, paginated by the page count from
|
|
214
|
+
// `total_count` (avoids Link-header parsing).
|
|
215
|
+
async function listInstallationRepos(token: string, installationId: number, fetchImpl: typeof fetch): Promise<ConnectedRepo[]> {
|
|
216
|
+
const pageUrl = (page: number): string => `${GITHUB_API}/installation/repositories?per_page=${PER_PAGE}&page=${page}`
|
|
217
|
+
const first = await githubJson(pageUrl(1), token, fetchImpl) as { total_count?: unknown }
|
|
218
|
+
const repos = repoPage(first, installationId)
|
|
219
|
+
const total = typeof first.total_count === 'number' ? first.total_count : repos.length
|
|
220
|
+
const pages = Math.ceil(total / PER_PAGE)
|
|
221
|
+
for (let page = 2; page <= pages; page++) {
|
|
222
|
+
repos.push(...repoPage(await githubJson(pageUrl(page), token, fetchImpl), installationId))
|
|
223
|
+
}
|
|
224
|
+
return repos
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// List every repo the repositories App is installed on, across installations
|
|
228
|
+
// (public + PRIVATE within those installs), deduped + sorted. Empty when the App
|
|
229
|
+
// isn't configured. READ-ONLY.
|
|
230
|
+
export async function listInstalledRepos(config: ManagedConfig, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
|
|
231
|
+
const { githubAppId, githubAppPrivateKey } = config
|
|
232
|
+
if (githubAppId == null || githubAppPrivateKey == null) return []
|
|
233
|
+
const jwt = appJwt(githubAppId, githubAppPrivateKey)
|
|
234
|
+
const lists = await mapGithubRequests(await listInstallationIds(jwt, fetchImpl), async (id) => {
|
|
235
|
+
const token = await mintInstallationToken(jwt, id, fetchImpl)
|
|
236
|
+
return listInstallationRepos(token, id, fetchImpl)
|
|
237
|
+
})
|
|
238
|
+
return mergeRepos(...lists)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Bound fan-out while keeping independent installations/permission checks off
|
|
242
|
+
// one long serial critical path. Preserve input order for deterministic merges.
|
|
243
|
+
async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>): Promise<U[]> {
|
|
244
|
+
const results: U[] = []
|
|
245
|
+
let next = 0
|
|
246
|
+
await Promise.all(Array.from({ length: Math.min(4, items.length) }, async () => {
|
|
247
|
+
while (next < items.length) {
|
|
248
|
+
const index = next++
|
|
249
|
+
results[index] = await work(items[index]!)
|
|
250
|
+
}
|
|
251
|
+
}))
|
|
252
|
+
return results
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The login App and repository App are separate: /user/repos with the login
|
|
256
|
+
// token is not evidence of private-repo access. Ask the repository App for the
|
|
257
|
+
// user's effective GitHub permission (including teams/org/enterprise grants).
|
|
258
|
+
// This endpoint accepts installation tokens with Metadata: read; it does NOT
|
|
259
|
+
// require Administration permission (unlike collaborator mutation endpoints).
|
|
260
|
+
// https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
|
|
261
|
+
// Resolve the current login from /user so renamed handles cannot check someone
|
|
262
|
+
// 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[]> {
|
|
264
|
+
const identity = await githubJson(`${GITHUB_API}/user`, userToken, fetchImpl) as { id?: unknown; login?: unknown }
|
|
265
|
+
if (typeof identity?.login !== 'string' || !identity.login || typeof identity.id !== 'number' || !Number.isSafeInteger(identity.id) || identity.id <= 0) throw new GithubApiError(502, 'github-malformed')
|
|
266
|
+
const tokens = new Map<number, Promise<string | null>>()
|
|
267
|
+
const allowed = await mapGithubRequests(repositories, async (repo) => {
|
|
268
|
+
// Internal repos may have private=false; missing visibility is not proof
|
|
269
|
+
// of public access either. Only explicitly public repositories skip checks.
|
|
270
|
+
if (repo.visibility === 'public') return true
|
|
271
|
+
if (repo.installationId == null) return false
|
|
272
|
+
let token = tokens.get(repo.installationId)
|
|
273
|
+
if (!token) {
|
|
274
|
+
token = repoAccessToken(config, repo.installationId, fetchImpl)
|
|
275
|
+
tokens.set(repo.installationId, token)
|
|
276
|
+
}
|
|
277
|
+
const accessToken = await token
|
|
278
|
+
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
|
+
}
|
|
287
|
+
})
|
|
288
|
+
return repositories.filter((_, index) => allowed[index])
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Mint a token to READ a selected repo's contents: an installation token (the
|
|
292
|
+
// App's Contents: Read) when the repo was reached through an installation, or
|
|
293
|
+
// null for a public repo readable unauthenticated. This is the whole point of
|
|
294
|
+
// persisting `installationId` — the stored context is enough to read.
|
|
295
|
+
export async function repoAccessToken(config: ManagedConfig, installationId: number | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
|
|
296
|
+
const { githubAppId, githubAppPrivateKey } = config
|
|
297
|
+
if (installationId == null || githubAppId == null || githubAppPrivateKey == null) return null
|
|
298
|
+
return await mintInstallationToken(appJwt(githubAppId, githubAppPrivateKey), installationId, fetchImpl)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── merged listing ──
|
|
302
|
+
|
|
303
|
+
export interface RepoListing {
|
|
304
|
+
repositories: ConnectedRepo[]
|
|
305
|
+
tokenMissing: boolean
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Every repo a user can reach, merged (deduped + sorted): PUBLIC via their login
|
|
309
|
+
// token + PRIVATE via the App's installations. Each source is best-effort so one
|
|
310
|
+
// failing never blanks the other; `tokenMissing` flags a missing/stale user
|
|
311
|
+
// token (→ the page prompts re-login). A repo seen on both sides keeps the
|
|
312
|
+
// installation tag (private listed last → wins the merge).
|
|
313
|
+
export async function collectRepos(config: ManagedConfig, userToken: string | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<RepoListing> {
|
|
314
|
+
let tokenMissing = userToken == null
|
|
315
|
+
const lists: ConnectedRepo[][] = []
|
|
316
|
+
if (userToken != null) {
|
|
317
|
+
try {
|
|
318
|
+
lists.push(await listUserRepos(userToken, fetchImpl))
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (err instanceof GithubApiError && err.status === 401) tokenMissing = true
|
|
321
|
+
else console.warn('managed: public repo list failed:', err)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (githubAppConfigured(config)) {
|
|
325
|
+
try {
|
|
326
|
+
lists.push(await listInstalledRepos(config, fetchImpl))
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.warn('managed: private repo list failed:', err)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { repositories: mergeRepos(...lists), tokenMissing }
|
|
332
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { GithubFixStatus, GithubIssueClosedReason } from '../common/github-pr.ts'
|
|
2
|
+
import type { ManagedSql } from './sql.ts'
|
|
3
|
+
|
|
4
|
+
export interface GithubMetadata {
|
|
5
|
+
key: string
|
|
6
|
+
title: string
|
|
7
|
+
description: string | null
|
|
8
|
+
status: GithubFixStatus
|
|
9
|
+
// NULL on legacy entries; 'unknown' means a closed issue was checked but
|
|
10
|
+
// GitHub supplied no recognized reason, so it needs no further backfill.
|
|
11
|
+
stateReason: GithubIssueClosedReason | null
|
|
12
|
+
fetchedAt: number
|
|
13
|
+
attemptedAt: number | null
|
|
14
|
+
}
|
|
15
|
+
export interface GithubMetadataStore {
|
|
16
|
+
listGithubMetadata(keys: readonly string[]): Promise<GithubMetadata[]>
|
|
17
|
+
setGithubMetadata(entries: readonly Omit<GithubMetadata, 'attemptedAt'>[]): Promise<void>
|
|
18
|
+
recordGithubMetadataAttempts(keys: readonly string[], attemptedAt: number): Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Successful GitHub reads are shared and retained without eviction. The stable
|
|
22
|
+
// repository ID in the key survives renames, but is never an access grant.
|
|
23
|
+
// Callers must authorize the current user -> team -> repo before every read.
|
|
24
|
+
export const GITHUB_STATE_REASON_COLUMN = "TEXT CHECK (state_reason IN ('completed', 'not_planned', 'duplicate', 'unknown'))"
|
|
25
|
+
export const GITHUB_METADATA_SCHEMA = `
|
|
26
|
+
CREATE TABLE IF NOT EXISTS managed_github_metadata (
|
|
27
|
+
cache_key TEXT PRIMARY KEY,
|
|
28
|
+
title TEXT NOT NULL,
|
|
29
|
+
description TEXT,
|
|
30
|
+
status TEXT NOT NULL CHECK (status IN ('open', 'draft', 'closed', 'merged')),
|
|
31
|
+
state_reason ${GITHUB_STATE_REASON_COLUMN},
|
|
32
|
+
fetched_at INTEGER NOT NULL,
|
|
33
|
+
attempted_at INTEGER
|
|
34
|
+
) STRICT;
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
export function githubMetadataMethods(db: ManagedSql): GithubMetadataStore {
|
|
38
|
+
const list = db.prepare(`SELECT cache_key AS key, title, description, status, state_reason AS stateReason, fetched_at AS fetchedAt, attempted_at AS attemptedAt
|
|
39
|
+
FROM managed_github_metadata WHERE cache_key IN (SELECT value FROM json_each(?))`)
|
|
40
|
+
const set = db.prepare(`INSERT INTO managed_github_metadata (cache_key, title, description, status, state_reason, fetched_at)
|
|
41
|
+
SELECT json_extract(e.value, '$.key'), json_extract(e.value, '$.title'),
|
|
42
|
+
json_extract(e.value, '$.description'), json_extract(e.value, '$.status'), json_extract(e.value, '$.stateReason'),
|
|
43
|
+
CAST(json_extract(e.value, '$.fetchedAt') AS BIGINT)
|
|
44
|
+
FROM json_each(:contexts) e WHERE 1 = 1
|
|
45
|
+
ON CONFLICT (cache_key) DO UPDATE SET title = excluded.title, description = excluded.description,
|
|
46
|
+
status = excluded.status, state_reason = excluded.state_reason, fetched_at = excluded.fetched_at
|
|
47
|
+
WHERE managed_github_metadata.status != 'merged' AND managed_github_metadata.fetched_at <= excluded.fetched_at`)
|
|
48
|
+
// Scheduling attempts never overwrite metadata or make stale data fresh.
|
|
49
|
+
// The monotonic guard preserves a newer attempt recorded by another reader.
|
|
50
|
+
const attempt = db.prepare(`UPDATE managed_github_metadata SET attempted_at = ?
|
|
51
|
+
WHERE cache_key IN (SELECT value FROM json_each(?)) AND (attempted_at IS NULL OR attempted_at < ?)`)
|
|
52
|
+
return {
|
|
53
|
+
async listGithubMetadata(keys) {
|
|
54
|
+
if (keys.length === 0) return []
|
|
55
|
+
const rows = await list.all(JSON.stringify(keys)) as GithubMetadata[]
|
|
56
|
+
return rows.map(row => ({ ...row }))
|
|
57
|
+
},
|
|
58
|
+
async setGithubMetadata(entries) {
|
|
59
|
+
if (entries.length > 0) await set.run({ contexts: JSON.stringify(entries) })
|
|
60
|
+
},
|
|
61
|
+
async recordGithubMetadataAttempts(keys, attemptedAt) {
|
|
62
|
+
if (keys.length > 0) await attempt.run(attemptedAt, JSON.stringify(keys), attemptedAt)
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// GitHub App user-authorization (identity) for the managed server. This is the
|
|
2
|
+
// GitHub *App* user-to-server flow, NOT an OAuth App: the authorize request
|
|
3
|
+
// carries NO `scope` (a GitHub App's permissions live on the App itself), so
|
|
4
|
+
// login asks for identity only — repo access is a SEPARATE installation flow
|
|
5
|
+
// (later work), keeping the broad "act on your behalf" ask off of login.
|
|
6
|
+
//
|
|
7
|
+
// GET /api/oauth/github/login → 302 to github.com/login/oauth/authorize
|
|
8
|
+
// with a CSRF `state` mirrored into a
|
|
9
|
+
// short-lived state cookie.
|
|
10
|
+
// GET /api/oauth/github/callback → verify `state` vs the cookie, exchange
|
|
11
|
+
// the code for a user token, read the
|
|
12
|
+
// GitHub identity, upsert the user, mint a
|
|
13
|
+
// session, set the cookie, 302 to the app.
|
|
14
|
+
//
|
|
15
|
+
// The user-to-server token is read for the identity AND persisted (see
|
|
16
|
+
// db.setUserTokens): the "Manage repositories" page lists the user's repos with
|
|
17
|
+
// it on demand (GET /user/repos — public repos with no App install; private
|
|
18
|
+
// ones once the App is installed). `ensureUserAccessToken` refreshes it when the
|
|
19
|
+
// App issues expiring tokens.
|
|
20
|
+
import { Buffer } from 'node:buffer'
|
|
21
|
+
import type { AvatarStore } from './avatar-store.ts'
|
|
22
|
+
import type { ManagedConfig } from './config.ts'
|
|
23
|
+
import type { ManagedDb, ManagedUser, UserTokens } from './db.ts'
|
|
24
|
+
import { randomToken, safeEqual } from './crypto.ts'
|
|
25
|
+
import { STATE_COOKIE, buildCookie, clearCookie, cookieName, createSession, parseCookies } from './session.ts'
|
|
26
|
+
|
|
27
|
+
const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'
|
|
28
|
+
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'
|
|
29
|
+
const GITHUB_USER_URL = 'https://api.github.com/user'
|
|
30
|
+
const STATE_TTL_S = 600
|
|
31
|
+
|
|
32
|
+
export const LOGIN_PATH = '/api/oauth/github/login'
|
|
33
|
+
export const CALLBACK_PATH = '/api/oauth/github/callback'
|
|
34
|
+
|
|
35
|
+
// A failure with the HTTP status the router should surface.
|
|
36
|
+
export class OAuthError extends Error {
|
|
37
|
+
status: number
|
|
38
|
+
constructor(status: number, message: string) {
|
|
39
|
+
super(message)
|
|
40
|
+
this.name = 'OAuthError'
|
|
41
|
+
this.status = status
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CallbackResult { location: string; setCookies: string[] }
|
|
46
|
+
export interface CallbackDeps { config: ManagedConfig; db: ManagedDb; avatarStore?: AvatarStore; now?: number; fetchImpl?: typeof fetch }
|
|
47
|
+
|
|
48
|
+
// GET /api/oauth/github/login — redirect to GitHub, stashing the CSRF state. No
|
|
49
|
+
// `scope` is sent: as the GitHub App user-authorization flow, the App's own
|
|
50
|
+
// permission set governs access, so login requests identity only (repo access
|
|
51
|
+
// is granted separately by installing the App).
|
|
52
|
+
export function buildLoginRedirect(config: ManagedConfig): { location: string; setCookie: string } {
|
|
53
|
+
const state = randomToken()
|
|
54
|
+
const u = new URL(GITHUB_AUTHORIZE_URL)
|
|
55
|
+
u.searchParams.set('client_id', config.githubClientId)
|
|
56
|
+
u.searchParams.set('redirect_uri', config.oauthCallbackUrl)
|
|
57
|
+
u.searchParams.set('state', state)
|
|
58
|
+
u.searchParams.set('allow_signup', 'false')
|
|
59
|
+
const setCookie = buildCookie(cookieName(config, STATE_COOKIE), state, {
|
|
60
|
+
maxAgeS: STATE_TTL_S, secure: config.cookieSecure, sameSite: 'Lax',
|
|
61
|
+
})
|
|
62
|
+
return { location: u.toString(), setCookie }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// GET /api/oauth/github/callback?code&state — the GitHub OAuth hook.
|
|
66
|
+
export async function handleCallback(
|
|
67
|
+
query: URLSearchParams, cookieHeader: string | undefined, deps: CallbackDeps,
|
|
68
|
+
): Promise<CallbackResult> {
|
|
69
|
+
const { config, db } = deps
|
|
70
|
+
const now = deps.now ?? Date.now()
|
|
71
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch
|
|
72
|
+
const code = query.get('code')
|
|
73
|
+
const state = query.get('state')
|
|
74
|
+
const stateCookie = parseCookies(cookieHeader).get(cookieName(config, STATE_COOKIE))
|
|
75
|
+
// CSRF: the `state` GitHub echoes back must match the one we set at /login.
|
|
76
|
+
if (!code || !state || !stateCookie || !safeEqual(state, stateCookie)) {
|
|
77
|
+
throw new OAuthError(400, 'invalid-oauth-state')
|
|
78
|
+
}
|
|
79
|
+
const tokens = await exchangeCode(config, code, now, fetchImpl)
|
|
80
|
+
const user = await fetchIdentity(tokens.accessToken, fetchImpl)
|
|
81
|
+
const { setCookie, userId } = await createSession(config, db, user, now)
|
|
82
|
+
// Persist the user token so the repositories page can list repos on demand.
|
|
83
|
+
await db.setUserTokens(userId, tokens)
|
|
84
|
+
// Cache the avatar for same-origin serving (the page's CSP forbids loading
|
|
85
|
+
// github's CDN directly). Best-effort: a fetch/store failure must not break
|
|
86
|
+
// login, and the avatar endpoint just 404s until a later login fills it.
|
|
87
|
+
if (deps.avatarStore != null && user.avatarUrl != null) {
|
|
88
|
+
await cacheAvatar(deps.avatarStore, userId, user.avatarUrl, fetchImpl).catch((err) => {
|
|
89
|
+
console.warn('managed: avatar cache failed:', err)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
// Clear the spent state cookie, set the session cookie, land on the app.
|
|
93
|
+
return {
|
|
94
|
+
location: '/',
|
|
95
|
+
setCookies: [clearCookie(cookieName(config, STATE_COOKIE), config.cookieSecure), setCookie],
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Exchange the authorization code for a user-to-server token set.
|
|
100
|
+
function exchangeCode(config: ManagedConfig, code: string, now: number, fetchImpl: typeof fetch): Promise<UserTokens> {
|
|
101
|
+
return postToken({
|
|
102
|
+
client_id: config.githubClientId, client_secret: config.githubClientSecret,
|
|
103
|
+
code, redirect_uri: config.oauthCallbackUrl,
|
|
104
|
+
}, now, fetchImpl)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Refresh an expiring user-to-server token (GitHub Apps with expiring tokens
|
|
108
|
+
// enabled). Returns the fresh token set; OAuthError(502) on any failure.
|
|
109
|
+
export function refreshUserToken(config: ManagedConfig, refreshToken: string, now: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<UserTokens> {
|
|
110
|
+
return postToken({
|
|
111
|
+
client_id: config.githubClientId, client_secret: config.githubClientSecret,
|
|
112
|
+
grant_type: 'refresh_token', refresh_token: refreshToken,
|
|
113
|
+
}, now, fetchImpl)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// POST the GitHub token endpoint and parse the {access_token, refresh_token?,
|
|
117
|
+
// expires_in?} body into a UserTokens (expiry resolved to an absolute ms time).
|
|
118
|
+
async function postToken(payload: Record<string, string>, now: number, fetchImpl: typeof fetch): Promise<UserTokens> {
|
|
119
|
+
let res: Response
|
|
120
|
+
try {
|
|
121
|
+
res = await fetchImpl(GITHUB_TOKEN_URL, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'accept': 'application/json', 'content-type': 'application/json' },
|
|
124
|
+
body: JSON.stringify(payload),
|
|
125
|
+
})
|
|
126
|
+
} catch { throw new OAuthError(502, 'github-token-unreachable') }
|
|
127
|
+
if (!res.ok) throw new OAuthError(502, `github-token-status-${res.status}`)
|
|
128
|
+
let body: unknown
|
|
129
|
+
try { body = await res.json() } catch { throw new OAuthError(502, 'github-token-malformed') }
|
|
130
|
+
const tok = (body as { access_token?: unknown }).access_token
|
|
131
|
+
if (typeof tok !== 'string' || tok === '') throw new OAuthError(502, 'github-token-denied')
|
|
132
|
+
const refresh = (body as { refresh_token?: unknown }).refresh_token
|
|
133
|
+
const expiresIn = (body as { expires_in?: unknown }).expires_in
|
|
134
|
+
return {
|
|
135
|
+
accessToken: tok,
|
|
136
|
+
refreshToken: typeof refresh === 'string' && refresh !== '' ? refresh : null,
|
|
137
|
+
expiresAt: typeof expiresIn === 'number' && expiresIn > 0 ? now + expiresIn * 1000 : null,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Resolve a usable access token for a user: the stored one if still valid, else
|
|
142
|
+
// refreshed (when a refresh token is on file) and re-persisted. Null when there
|
|
143
|
+
// is no token or it's expired and unrefreshable — the caller prompts re-login.
|
|
144
|
+
// A 60s skew margin avoids handing back a token about to expire mid-request.
|
|
145
|
+
export async function ensureUserAccessToken(
|
|
146
|
+
config: ManagedConfig, db: ManagedDb, userId: string, now: number, fetchImpl: typeof fetch = globalThis.fetch,
|
|
147
|
+
): Promise<string | null> {
|
|
148
|
+
const tokens = await db.getUserTokens(userId)
|
|
149
|
+
if (tokens == null) return null
|
|
150
|
+
if (tokens.expiresAt == null || tokens.expiresAt > now + 60_000) return tokens.accessToken
|
|
151
|
+
if (tokens.refreshToken == null) return null
|
|
152
|
+
try {
|
|
153
|
+
const fresh = await refreshUserToken(config, tokens.refreshToken, now, fetchImpl)
|
|
154
|
+
await db.setUserTokens(userId, fresh)
|
|
155
|
+
return fresh.accessToken
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.warn('managed: token refresh failed:', err)
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Read the authenticated user's identity (`GET /user`).
|
|
163
|
+
async function fetchIdentity(token: string, fetchImpl: typeof fetch): Promise<ManagedUser> {
|
|
164
|
+
let res: Response
|
|
165
|
+
try {
|
|
166
|
+
res = await fetchImpl(GITHUB_USER_URL, {
|
|
167
|
+
headers: {
|
|
168
|
+
'authorization': `Bearer ${token}`, 'accept': 'application/vnd.github+json',
|
|
169
|
+
'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28',
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
} catch { throw new OAuthError(502, 'github-user-unreachable') }
|
|
173
|
+
if (!res.ok) throw new OAuthError(502, `github-user-status-${res.status}`)
|
|
174
|
+
let body: unknown
|
|
175
|
+
try { body = await res.json() } catch { throw new OAuthError(502, 'github-user-malformed') }
|
|
176
|
+
const user = parseGithubUser(body)
|
|
177
|
+
if (user == null) throw new OAuthError(502, 'github-user-invalid')
|
|
178
|
+
return user
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Validate GitHub's `GET /user` payload into a ManagedUser (or null).
|
|
182
|
+
function parseGithubUser(body: unknown): ManagedUser | null {
|
|
183
|
+
if (body == null || typeof body !== 'object') return null
|
|
184
|
+
const id = (body as { id?: unknown }).id
|
|
185
|
+
const login = (body as { login?: unknown }).login
|
|
186
|
+
if (typeof id !== 'number' || !Number.isSafeInteger(id) || typeof login !== 'string' || login === '') return null
|
|
187
|
+
const name = (body as { name?: unknown }).name
|
|
188
|
+
const avatar = (body as { avatar_url?: unknown }).avatar_url
|
|
189
|
+
return {
|
|
190
|
+
githubUserId: id, login,
|
|
191
|
+
name: typeof name === 'string' ? name : null,
|
|
192
|
+
avatarUrl: typeof avatar === 'string' ? avatar : null,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Fetch the user's GitHub avatar and cache it for same-origin serving. The host
|
|
197
|
+
// is validated (githubusercontent.com over https) as SSRF defence-in-depth,
|
|
198
|
+
// even though the URL comes from GitHub's own /user response.
|
|
199
|
+
async function cacheAvatar(store: AvatarStore, id: string, avatarUrl: string, fetchImpl: typeof fetch): Promise<void> {
|
|
200
|
+
if (!isGithubAvatarUrl(avatarUrl)) return
|
|
201
|
+
// `redirect: 'manual'` so a 3xx off the (validated) avatars host can't bounce
|
|
202
|
+
// the fetch to an internal address — an opaque-redirect / 3xx fails the
|
|
203
|
+
// `res.ok` + image check below and is skipped (SSRF defence-in-depth).
|
|
204
|
+
const res = await fetchImpl(avatarUrl, { redirect: 'manual' })
|
|
205
|
+
const contentType = res.headers.get('content-type') ?? ''
|
|
206
|
+
if (!res.ok || !contentType.startsWith('image/')) return
|
|
207
|
+
await store.put(id, contentType, Buffer.from(await res.arrayBuffer()))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isGithubAvatarUrl(raw: string): boolean {
|
|
211
|
+
let url: URL
|
|
212
|
+
try { url = new URL(raw) } catch { return false }
|
|
213
|
+
return url.protocol === 'https:'
|
|
214
|
+
&& (url.hostname === 'avatars.githubusercontent.com' || url.hostname.endsWith('.githubusercontent.com'))
|
|
215
|
+
}
|