@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.40
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/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +2 -0
- package/client/dependency-paths.js +24 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +112 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +14 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +310 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +254 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +35 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-4F6JSF57.js +3 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-AQ75QITE.js +1 -0
- package/out/chunk-B2RQY6OP.js +2 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-IISEF2BZ.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-MYDDTA7W.js +225 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-RSHMVYNS.js +1 -0
- package/out/chunk-TGANXVTA.js +1 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UN5ZSYLE.js +1 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UXNUAIIC.js +33 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-VQJCXGIY.js +7 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-WZDTZE64.js +1 -0
- package/out/chunk-XYDGU6BM.js +51 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZBBOBQ43.js +1 -0
- package/out/chunk-ZMQQCGF6.js +2 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +37 -0
- package/out/client-sync.js +10 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +6 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +365 -39
- package/out/view.css +1 -1
- package/out/view.js +83 -117
- package/package.json +297 -59
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +171 -0
- package/risk/files/runs-code.js +358 -0
- package/risk/files/script.js +114 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +142 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +219 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +228 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -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-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +116 -18
- package/{server → server-e2e}/hub.ts +40 -9
- 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 +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- 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 +27 -26
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +245 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +111 -0
- package/server-managed/bundle-build.ts +198 -0
- package/server-managed/bundle-cache.ts +199 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +120 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +158 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1725 -0
- package/server-managed/db-neon.ts +367 -0
- package/server-managed/db-schema.ts +227 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +132 -0
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +391 -0
- package/server-managed/github-errors.ts +68 -0
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +235 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +50 -0
- package/server-managed/http.ts +3472 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +187 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +75 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +74 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +220 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +153 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-risk.ts +418 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +92 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +76 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +279 -0
- package/server-managed/storage-maintenance.ts +256 -0
- package/server-managed/storage-payload.ts +103 -0
- package/server-managed/storage-stores.ts +85 -0
- package/server-managed/storage.ts +78 -0
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/team-reports.ts +228 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +86 -0
- package/server-managed/upstream-cache.ts +215 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +166 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { ManagedConfig } from './config.ts'
|
|
2
|
+
import type { ManagedDb, ManagedSession } from './db.ts'
|
|
3
|
+
import type { BlobStore } from './blob-store.ts'
|
|
4
|
+
import type { ManagedIssue } from './managed-issues.ts'
|
|
5
|
+
import { randomToken } from './crypto.ts'
|
|
6
|
+
import { githubIssueClosedReason } from '../common/github-pr.ts'
|
|
7
|
+
import { reportEntries, reportRepoGithub } from '@preventive/report'
|
|
8
|
+
import { newIssueLabels } from '../common/github-issue-labels.js'
|
|
9
|
+
import { appJwt, githubAppConfigured, installUrl, publicRepositoryName } from './github-app.ts'
|
|
10
|
+
import { ISSUE_LOGIN_PATH, issueUserToken } from './github-issue-oauth.ts'
|
|
11
|
+
import { TeamReportsError, loadTeamReports, recheckTeam, teamSnapshot } from './team-reports.ts'
|
|
12
|
+
|
|
13
|
+
export const MAX_ISSUE_BODY_BYTES = 512_000
|
|
14
|
+
export class IssueError extends Error {
|
|
15
|
+
status: number
|
|
16
|
+
constructor(status: number, message: string) { super(message); this.status = status }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Context = { reportId: string; findingId: string; repository: string }
|
|
20
|
+
export function parseIssueContext(raw: unknown): Context {
|
|
21
|
+
const value = raw as Partial<Context> | null
|
|
22
|
+
const repository = publicRepositoryName(value?.repository)
|
|
23
|
+
if (!repository || typeof value?.reportId !== 'string' || !value.reportId || value.reportId.length > 200
|
|
24
|
+
|| typeof value.findingId !== 'string' || !value.findingId || value.findingId.length > 4096) throw new IssueError(400, 'bad-request')
|
|
25
|
+
return { repository, reportId: value.reportId, findingId: value.findingId }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function github(path: string, token: string, fetchImpl: typeof fetch, payload?: unknown) {
|
|
29
|
+
let response: Response
|
|
30
|
+
try {
|
|
31
|
+
response = await fetchImpl(`https://api.github.com${path}`, {
|
|
32
|
+
method: payload === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
33
|
+
headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json',
|
|
34
|
+
'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28', 'content-type': 'application/json' },
|
|
35
|
+
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
|
|
36
|
+
})
|
|
37
|
+
} catch { throw new IssueError(502, payload === undefined ? 'github-unavailable' : 'github-create-uncertain') }
|
|
38
|
+
return response
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function referenceResult(stored: ManagedIssue, repoIds: number[]) {
|
|
42
|
+
// A shared finding ID does not grant access to another team's repository.
|
|
43
|
+
if (!repoIds.includes(stored.repoId)) return { mode: 'unavailable' as const }
|
|
44
|
+
return stored.issueUrl ? { mode: 'existing' as const, url: stored.issueUrl }
|
|
45
|
+
: { mode: 'pending' as const, repositoryUrl: `https://github.com/${stored.repository}/issues` }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function issueDetails(raw: unknown, url: string, number: number, fallback: { title: string; body: string; labels: string[] }) {
|
|
49
|
+
const issue = raw as { title?: unknown; body?: unknown; state?: unknown; state_reason?: unknown;
|
|
50
|
+
user?: { login?: unknown }; labels?: ({ name?: unknown } | string)[] } | null
|
|
51
|
+
const status = issue?.state === 'closed' ? 'closed' : 'open'
|
|
52
|
+
return { url, number, title: typeof issue?.title === 'string' ? issue.title : fallback.title,
|
|
53
|
+
description: typeof issue?.body === 'string' ? issue.body : fallback.body, status,
|
|
54
|
+
stateReason: status === 'closed' ? githubIssueClosedReason(issue?.state_reason) : null,
|
|
55
|
+
author: typeof issue?.user?.login === 'string' ? issue.user.login : null,
|
|
56
|
+
labels: Array.isArray(issue?.labels) ? issue.labels.map(label => typeof label === 'string' ? label : label?.name).filter((label): label is string => typeof label === 'string') : fallback.labels }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Authorize before any upstream lookup, including installation/label discovery.
|
|
60
|
+
// Report filtering also supplies the full row/link isSecurity classification.
|
|
61
|
+
export async function prepareGithubIssue(config: ManagedConfig, db: ManagedDb, store: BlobStore,
|
|
62
|
+
session: ManagedSession, teamId: string, context: Context, fetchImpl: typeof fetch = fetch) {
|
|
63
|
+
const snapshot = await teamSnapshot(db, session.id, teamId, context.reportId)
|
|
64
|
+
if (!snapshot.reports.some(report => report.id === context.reportId)) throw new TeamReportsError(404, 'no-report')
|
|
65
|
+
const reports = await loadTeamReports(db, store, snapshot)
|
|
66
|
+
const report = reports.find(item => item.id === context.reportId)!
|
|
67
|
+
const findings = (reportEntries(report.data) ?? []).flat() as Record<string, unknown>[]
|
|
68
|
+
const finding = findings.find(item => item['id'] === context.findingId)
|
|
69
|
+
if (!finding) throw new TeamReportsError(404, 'no-finding')
|
|
70
|
+
const recheck = () => recheckTeam(db, session.id, snapshot)
|
|
71
|
+
await recheck()
|
|
72
|
+
const labels = newIssueLabels(finding['isSecurity'] === true, config.githubNewIssueLabels ?? '')
|
|
73
|
+
const repoIds = snapshot.repositories.map(repo => repo.repoId)
|
|
74
|
+
const stored = await db.getManagedIssue(context.findingId)
|
|
75
|
+
if (stored) return { ...referenceResult(stored, repoIds), labels, recheck }
|
|
76
|
+
// The target comes from this finding's own source, or the report's managed
|
|
77
|
+
// assignment. Team membership alone must not let a caller claim an unrelated
|
|
78
|
+
// repository's issue as the permanent reference for this finding.
|
|
79
|
+
const findingRepo = finding['repo'] as { github?: unknown } | null | undefined
|
|
80
|
+
const targetRepository = publicRepositoryName(reportRepoGithub({ repo: { github: findingRepo?.github || report.repo.github } }))
|
|
81
|
+
if (!targetRepository || targetRepository.toLowerCase() !== context.repository.toLowerCase()) throw new IssueError(400, 'bad-issue-repository')
|
|
82
|
+
const repository = snapshot.repositories.find(repo => repo.github.toLowerCase() === targetRepository.toLowerCase())
|
|
83
|
+
if (!repository || !githubAppConfigured(config)) return { mode: 'form' as const, labels, recheck }
|
|
84
|
+
const path = `/repos/${targetRepository.split('/').map(encodeURIComponent).join('/')}`
|
|
85
|
+
const installation = await github(`${path}/installation`, appJwt(config.githubAppId!, config.githubAppPrivateKey!), fetchImpl)
|
|
86
|
+
if (installation.status === 404) return { mode: 'form' as const, labels, recheck }
|
|
87
|
+
if (!installation.ok) throw new IssueError(502, 'github-unavailable')
|
|
88
|
+
let installed: { permissions?: { issues?: string } }
|
|
89
|
+
try { installed = await installation.json() } catch { throw new IssueError(502, 'github-unavailable') }
|
|
90
|
+
if (installed?.permissions?.issues !== 'write') return { mode: 'permissions' as const, authorizationPath: installUrl(config), labels, recheck }
|
|
91
|
+
const token = await issueUserToken(config, db, session.userId, fetchImpl)
|
|
92
|
+
if (!token) return { mode: 'authorize' as const, authorizationPath: ISSUE_LOGIN_PATH, labels, recheck }
|
|
93
|
+
// All requested labels are optional, including security and operator labels.
|
|
94
|
+
// Omit missing names rather than rejecting issue creation or creating labels.
|
|
95
|
+
const existingLabels: string[] = []
|
|
96
|
+
for (const name of labels) {
|
|
97
|
+
const label = await github(`${path}/labels/${encodeURIComponent(name)}`, token, fetchImpl)
|
|
98
|
+
if (label.status === 401) return { mode: 'authorize' as const, authorizationPath: ISSUE_LOGIN_PATH, labels, recheck }
|
|
99
|
+
if (label.status === 404) continue
|
|
100
|
+
if (!label.ok) throw new IssueError(502, 'github-unavailable')
|
|
101
|
+
existingLabels.push(name)
|
|
102
|
+
}
|
|
103
|
+
return { mode: 'api' as const, token, path, labels: existingLabels, recheck,
|
|
104
|
+
db, findingId: context.findingId, repoId: repository.repoId, repository: targetRepository, repoIds, userId: session.userId }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function createGithubIssue(prepared: Awaited<ReturnType<typeof prepareGithubIssue>>, input: unknown, fetchImpl: typeof fetch = fetch) {
|
|
108
|
+
if (prepared.mode !== 'api') throw new IssueError(409, 'github-authorization-required')
|
|
109
|
+
const { title, body } = input as { title?: unknown; body?: unknown }
|
|
110
|
+
if (typeof title !== 'string' || !title.trim() || title.length > 256 || typeof body !== 'string' || body.length > 65_536) throw new IssueError(400, 'bad-issue')
|
|
111
|
+
const { db, findingId } = prepared
|
|
112
|
+
const requestId = randomToken()
|
|
113
|
+
if (!await db.claimManagedIssue({ findingId, repoId: prepared.repoId, repository: prepared.repository,
|
|
114
|
+
requestId, createdBy: prepared.userId, createdAt: Date.now() })) {
|
|
115
|
+
const stored = await db.getManagedIssue(findingId)
|
|
116
|
+
await prepared.recheck()
|
|
117
|
+
// The competing request may have released its reservation after a definite
|
|
118
|
+
// failure. Still return a listing, never a fresh creation form, for this race.
|
|
119
|
+
return stored ? referenceResult(stored, prepared.repoIds)
|
|
120
|
+
: { mode: 'pending' as const, repositoryUrl: `https://github.com/${prepared.repository}/issues` }
|
|
121
|
+
}
|
|
122
|
+
// The last awaited operation before the external write checks current access.
|
|
123
|
+
// Release only when we know no issue was created; unknown outcomes stay claimed.
|
|
124
|
+
try { await prepared.recheck() }
|
|
125
|
+
catch (error) { await db.releaseManagedIssue(findingId, requestId); throw error }
|
|
126
|
+
const response = await github(`${prepared.path}/issues`, prepared.token, fetchImpl, { title: title.trim(), body, labels: prepared.labels })
|
|
127
|
+
if (response.status >= 400 && response.status < 500) await db.releaseManagedIssue(findingId, requestId)
|
|
128
|
+
if (response.status === 401) throw new IssueError(409, 'github-authorization-required')
|
|
129
|
+
if (response.status === 403) throw new IssueError(403, 'github-issue-forbidden')
|
|
130
|
+
if (!response.ok) throw new IssueError(response.status === 422 ? 422 : 502, response.status >= 500 ? 'github-create-uncertain' : 'github-create-failed')
|
|
131
|
+
let issue: { number?: unknown }
|
|
132
|
+
try { issue = await response.json() } catch { throw new IssueError(502, 'github-create-uncertain') }
|
|
133
|
+
if (!Number.isSafeInteger(issue?.number) || Number(issue.number) <= 0) throw new IssueError(502, 'github-create-uncertain')
|
|
134
|
+
// Construct the URL from validated inputs, never an upstream redirect target.
|
|
135
|
+
const number = Number(issue.number), url = `https://github.com${prepared.path.slice('/repos'.length)}/issues/${issue.number}`
|
|
136
|
+
if (!await db.finishManagedIssue(findingId, requestId, url)) throw new IssueError(502, 'github-create-uncertain')
|
|
137
|
+
let details = issueDetails(issue, url, number, { title, body, labels: prepared.labels }), detailsUnavailable = true
|
|
138
|
+
try {
|
|
139
|
+
const fetched = await github(`${prepared.path}/issues/${number}`, prepared.token, fetchImpl)
|
|
140
|
+
if (fetched.ok) {
|
|
141
|
+
details = issueDetails(await fetched.json(), url, number, { title, body, labels: prepared.labels })
|
|
142
|
+
detailsUnavailable = false
|
|
143
|
+
}
|
|
144
|
+
} catch { /* Creation and its permanent reference succeeded; detail reads are best effort. */ }
|
|
145
|
+
try { await prepared.recheck() }
|
|
146
|
+
catch {
|
|
147
|
+
// The external write and permanent reference succeeded. Withhold details
|
|
148
|
+
// if current access cannot be confirmed, but never advertise a safe retry.
|
|
149
|
+
return { mode: 'created-unavailable' as const }
|
|
150
|
+
}
|
|
151
|
+
return { url, issue: details, detailsUnavailable }
|
|
152
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
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 GithubRepositoryVisibility {
|
|
16
|
+
repoId: number
|
|
17
|
+
github: string
|
|
18
|
+
public: boolean
|
|
19
|
+
checkedAt: number
|
|
20
|
+
}
|
|
21
|
+
// A commit's details, keyed by githubCommitKey. Commits never change, so an
|
|
22
|
+
// entry is never refreshed or evicted.
|
|
23
|
+
export interface GithubCommit {
|
|
24
|
+
key: string
|
|
25
|
+
message: string
|
|
26
|
+
authorName: string | null
|
|
27
|
+
authorLogin: string | null
|
|
28
|
+
authoredAt: number | null
|
|
29
|
+
committedAt: number | null
|
|
30
|
+
fetchedAt: number
|
|
31
|
+
}
|
|
32
|
+
export interface GithubTag { name: string; sha: string }
|
|
33
|
+
export interface GithubMetadataStore {
|
|
34
|
+
listGithubMetadata(keys: readonly string[]): Promise<GithubMetadata[]>
|
|
35
|
+
setGithubMetadata(entries: readonly Omit<GithubMetadata, 'attemptedAt'>[]): Promise<void>
|
|
36
|
+
recordGithubMetadataAttempts(keys: readonly string[], attemptedAt: number): Promise<void>
|
|
37
|
+
listGithubRepositoryVisibility(repoIds: readonly number[]): Promise<GithubRepositoryVisibility[]>
|
|
38
|
+
setGithubRepositoryVisibility(entries: readonly GithubRepositoryVisibility[]): Promise<void>
|
|
39
|
+
}
|
|
40
|
+
export interface GithubCommitStore {
|
|
41
|
+
listGithubCommits(keys: readonly string[]): Promise<GithubCommit[]>
|
|
42
|
+
setGithubCommits(entries: readonly GithubCommit[]): Promise<void>
|
|
43
|
+
// The cached tags that point to each commit, by githubCommitKey.
|
|
44
|
+
listGithubCommitTags(keys: readonly string[]): Promise<{ key: string; name: string }[]>
|
|
45
|
+
// `complete` is a listing of every tag the repository has: tags it leaves
|
|
46
|
+
// out were deleted. Otherwise only the listed tags are updated. `observedUs`
|
|
47
|
+
// orders overlapping refreshes (see githubCommitMethods).
|
|
48
|
+
refreshGithubTags(repoId: number, tags: readonly GithubTag[], complete: boolean, observedUs: number): Promise<void>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A stable repository ID survives renames, as in the PR metadata keys.
|
|
52
|
+
export function githubCommitKey(repoId: number, sha: string): string {
|
|
53
|
+
return `${repoId}:${sha}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Successful GitHub reads are shared and retained without eviction. The stable
|
|
57
|
+
// repository ID in the key survives renames, but is never an access grant.
|
|
58
|
+
// Callers must verify current user -> team -> repo grants plus either recently
|
|
59
|
+
// verified public visibility or the viewer's live GitHub access with their token.
|
|
60
|
+
export const GITHUB_STATE_REASON_COLUMN = "TEXT CHECK (state_reason IN ('completed', 'not_planned', 'duplicate', 'unknown'))"
|
|
61
|
+
export const GITHUB_METADATA_SCHEMA = `
|
|
62
|
+
CREATE TABLE IF NOT EXISTS managed_github_metadata (
|
|
63
|
+
cache_key TEXT PRIMARY KEY,
|
|
64
|
+
title TEXT NOT NULL,
|
|
65
|
+
description TEXT,
|
|
66
|
+
status TEXT NOT NULL CHECK (status IN ('open', 'draft', 'closed', 'merged')),
|
|
67
|
+
state_reason ${GITHUB_STATE_REASON_COLUMN},
|
|
68
|
+
fetched_at INTEGER NOT NULL,
|
|
69
|
+
attempted_at INTEGER
|
|
70
|
+
) STRICT;
|
|
71
|
+
CREATE TABLE IF NOT EXISTS managed_github_repository_visibility (
|
|
72
|
+
repo_id INTEGER PRIMARY KEY,
|
|
73
|
+
github TEXT NOT NULL,
|
|
74
|
+
is_public INTEGER NOT NULL CHECK (is_public IN (0, 1)),
|
|
75
|
+
checked_at INTEGER NOT NULL
|
|
76
|
+
) STRICT;
|
|
77
|
+
`
|
|
78
|
+
// Bundles show their commit's details and tags from these tables. Readers rely
|
|
79
|
+
// on bundle access alone; only a GitHub read made with access fills them.
|
|
80
|
+
// Commits are retained without eviction. Create a bundle refreshes a
|
|
81
|
+
// repository's tags, which go with the repository.
|
|
82
|
+
export const GITHUB_COMMIT_SCHEMA = `
|
|
83
|
+
CREATE TABLE IF NOT EXISTS managed_github_commit (
|
|
84
|
+
cache_key TEXT PRIMARY KEY,
|
|
85
|
+
message TEXT NOT NULL,
|
|
86
|
+
author_name TEXT,
|
|
87
|
+
author_login TEXT,
|
|
88
|
+
authored_at INTEGER,
|
|
89
|
+
committed_at INTEGER,
|
|
90
|
+
fetched_at INTEGER NOT NULL
|
|
91
|
+
) STRICT;
|
|
92
|
+
CREATE TABLE IF NOT EXISTS managed_github_tag (
|
|
93
|
+
repo_id INTEGER NOT NULL REFERENCES managed_selected_repo(repo_id) ON DELETE CASCADE,
|
|
94
|
+
name TEXT NOT NULL,
|
|
95
|
+
commit_key TEXT NOT NULL,
|
|
96
|
+
observed_us INTEGER NOT NULL,
|
|
97
|
+
PRIMARY KEY (repo_id, name)
|
|
98
|
+
) STRICT;
|
|
99
|
+
CREATE INDEX IF NOT EXISTS managed_github_tag_commit_idx ON managed_github_tag(commit_key);
|
|
100
|
+
CREATE TABLE IF NOT EXISTS managed_github_tag_listing (
|
|
101
|
+
repo_id INTEGER PRIMARY KEY REFERENCES managed_selected_repo(repo_id) ON DELETE CASCADE,
|
|
102
|
+
observed_us INTEGER NOT NULL
|
|
103
|
+
) STRICT;
|
|
104
|
+
`
|
|
105
|
+
|
|
106
|
+
export function githubMetadataMethods(db: ManagedSql): GithubMetadataStore {
|
|
107
|
+
const list = db.prepare(`SELECT cache_key AS key, title, description, status, state_reason AS stateReason, fetched_at AS fetchedAt, attempted_at AS attemptedAt
|
|
108
|
+
FROM managed_github_metadata WHERE cache_key IN (SELECT value FROM json_each(?))`)
|
|
109
|
+
const set = db.prepare(`INSERT INTO managed_github_metadata (cache_key, title, description, status, state_reason, fetched_at)
|
|
110
|
+
SELECT json_extract(e.value, '$.key'), json_extract(e.value, '$.title'),
|
|
111
|
+
json_extract(e.value, '$.description'), json_extract(e.value, '$.status'), json_extract(e.value, '$.stateReason'),
|
|
112
|
+
CAST(json_extract(e.value, '$.fetchedAt') AS BIGINT)
|
|
113
|
+
FROM json_each(:contexts) e WHERE 1 = 1
|
|
114
|
+
ON CONFLICT (cache_key) DO UPDATE SET title = excluded.title, description = excluded.description,
|
|
115
|
+
status = excluded.status, state_reason = excluded.state_reason, fetched_at = excluded.fetched_at
|
|
116
|
+
WHERE managed_github_metadata.status != 'merged' AND managed_github_metadata.fetched_at <= excluded.fetched_at`)
|
|
117
|
+
// Scheduling attempts never overwrite metadata or make stale data fresh.
|
|
118
|
+
// The monotonic guard preserves a newer attempt recorded by another reader.
|
|
119
|
+
const attempt = db.prepare(`UPDATE managed_github_metadata SET attempted_at = ?
|
|
120
|
+
WHERE cache_key IN (SELECT value FROM json_each(?)) AND (attempted_at IS NULL OR attempted_at < ?)`)
|
|
121
|
+
const visibility = db.prepare(`SELECT repo_id AS repoId, github, is_public AS public, checked_at AS checkedAt
|
|
122
|
+
FROM managed_github_repository_visibility WHERE repo_id IN (SELECT CAST(value AS BIGINT) FROM json_each(?))`)
|
|
123
|
+
const setVisibility = db.prepare(`INSERT INTO managed_github_repository_visibility (repo_id, github, is_public, checked_at)
|
|
124
|
+
SELECT CAST(json_extract(e.value, '$.repoId') AS BIGINT), json_extract(e.value, '$.github'),
|
|
125
|
+
CAST(json_extract(e.value, '$.public') AS BIGINT), CAST(json_extract(e.value, '$.checkedAt') AS BIGINT)
|
|
126
|
+
FROM json_each(:contexts) e WHERE 1 = 1
|
|
127
|
+
ON CONFLICT (repo_id) DO UPDATE SET github = excluded.github, is_public = excluded.is_public, checked_at = excluded.checked_at
|
|
128
|
+
WHERE managed_github_repository_visibility.checked_at < excluded.checked_at
|
|
129
|
+
OR (managed_github_repository_visibility.checked_at = excluded.checked_at
|
|
130
|
+
AND (managed_github_repository_visibility.is_public = 1 OR excluded.is_public = 0))`)
|
|
131
|
+
return {
|
|
132
|
+
async listGithubMetadata(keys) {
|
|
133
|
+
if (keys.length === 0) return []
|
|
134
|
+
const rows = await list.all(JSON.stringify(keys)) as GithubMetadata[]
|
|
135
|
+
return rows.map(row => ({ ...row }))
|
|
136
|
+
},
|
|
137
|
+
async setGithubMetadata(entries) {
|
|
138
|
+
if (entries.length > 0) await set.run({ contexts: JSON.stringify(entries) })
|
|
139
|
+
},
|
|
140
|
+
async recordGithubMetadataAttempts(keys, attemptedAt) {
|
|
141
|
+
if (keys.length > 0) await attempt.run(attemptedAt, JSON.stringify(keys), attemptedAt)
|
|
142
|
+
},
|
|
143
|
+
async listGithubRepositoryVisibility(repoIds) {
|
|
144
|
+
if (repoIds.length === 0) return []
|
|
145
|
+
const rows = await visibility.all(JSON.stringify(repoIds)) as (Omit<GithubRepositoryVisibility, 'public'> & { public: number })[]
|
|
146
|
+
return rows.map(row => ({ ...row, public: row.public === 1 }))
|
|
147
|
+
},
|
|
148
|
+
async setGithubRepositoryVisibility(entries) {
|
|
149
|
+
if (entries.length > 0) await setVisibility.run({ contexts: JSON.stringify(entries.map(entry => ({ ...entry, public: entry.public ? 1 : 0 }))) })
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function githubCommitMethods(db: ManagedSql): GithubCommitStore {
|
|
155
|
+
const commits = db.prepare(`SELECT cache_key AS key, message, author_name AS authorName, author_login AS authorLogin,
|
|
156
|
+
authored_at AS authoredAt, committed_at AS committedAt, fetched_at AS fetchedAt
|
|
157
|
+
FROM managed_github_commit WHERE cache_key IN (SELECT value FROM json_each(?))`)
|
|
158
|
+
// Concurrent reads of one commit store the same details; the first stays.
|
|
159
|
+
const setCommits = db.prepare(`INSERT INTO managed_github_commit (cache_key, message, author_name, author_login, authored_at, committed_at, fetched_at)
|
|
160
|
+
SELECT json_extract(e.value, '$.key'), json_extract(e.value, '$.message'), json_extract(e.value, '$.authorName'),
|
|
161
|
+
json_extract(e.value, '$.authorLogin'), CAST(json_extract(e.value, '$.authoredAt') AS BIGINT),
|
|
162
|
+
CAST(json_extract(e.value, '$.committedAt') AS BIGINT), CAST(json_extract(e.value, '$.fetchedAt') AS BIGINT)
|
|
163
|
+
FROM json_each(:contexts) e WHERE 1 = 1
|
|
164
|
+
ON CONFLICT (cache_key) DO NOTHING`)
|
|
165
|
+
const commitTags = db.prepare(`SELECT commit_key AS key, name FROM managed_github_tag
|
|
166
|
+
WHERE commit_key IN (SELECT value FROM json_each(?)) ORDER BY commit_key, name`)
|
|
167
|
+
// Overlapping browses can finish out of order, so every refresh carries
|
|
168
|
+
// when it was observed (in microseconds). Each tag keeps the time it was
|
|
169
|
+
// last observed and only a later observation moves it; a complete listing
|
|
170
|
+
// deletes only the tags observed before it. The listing table holds the
|
|
171
|
+
// last complete listing applied: one observed no later knew whether every
|
|
172
|
+
// tag existed, so a refresh it supersedes is skipped (a tie keeps the first).
|
|
173
|
+
const lastListing = db.prepare('SELECT observed_us AS observedUs FROM managed_github_tag_listing WHERE repo_id = ?')
|
|
174
|
+
const setListing = db.prepare(`INSERT INTO managed_github_tag_listing (repo_id, observed_us) VALUES (?, ?)
|
|
175
|
+
ON CONFLICT (repo_id) DO UPDATE SET observed_us = excluded.observed_us`)
|
|
176
|
+
const deleteTags = db.prepare(`DELETE FROM managed_github_tag WHERE repo_id = ? AND observed_us < ?
|
|
177
|
+
AND name NOT IN (SELECT value FROM json_each(?))`)
|
|
178
|
+
const setTags = db.prepare(`INSERT INTO managed_github_tag (repo_id, name, commit_key, observed_us)
|
|
179
|
+
SELECT CAST(json_extract(e.value, '$.repoId') AS BIGINT), json_extract(e.value, '$.name'), json_extract(e.value, '$.key'),
|
|
180
|
+
CAST(json_extract(e.value, '$.observedUs') AS BIGINT)
|
|
181
|
+
FROM json_each(:contexts) e WHERE 1 = 1
|
|
182
|
+
ON CONFLICT (repo_id, name) DO UPDATE SET commit_key = excluded.commit_key, observed_us = excluded.observed_us
|
|
183
|
+
WHERE managed_github_tag.observed_us < excluded.observed_us`)
|
|
184
|
+
return {
|
|
185
|
+
async listGithubCommits(keys) {
|
|
186
|
+
if (keys.length === 0) return []
|
|
187
|
+
const rows = await commits.all(JSON.stringify(keys)) as GithubCommit[]
|
|
188
|
+
return rows.map(row => ({ ...row }))
|
|
189
|
+
},
|
|
190
|
+
async setGithubCommits(entries) {
|
|
191
|
+
if (entries.length > 0) await setCommits.run({ contexts: JSON.stringify(entries) })
|
|
192
|
+
},
|
|
193
|
+
async listGithubCommitTags(keys) {
|
|
194
|
+
if (keys.length === 0) return []
|
|
195
|
+
const rows = await commitTags.all(JSON.stringify(keys)) as { key: string; name: string }[]
|
|
196
|
+
return rows.map(row => ({ ...row }))
|
|
197
|
+
},
|
|
198
|
+
async refreshGithubTags(repoId, tags, complete, observedUs) {
|
|
199
|
+
// Writes hold the store's writer lock: the listing read stays current.
|
|
200
|
+
const last = (await lastListing.get(repoId) as { observedUs: number } | undefined)?.observedUs
|
|
201
|
+
if (last !== undefined && last >= observedUs) return
|
|
202
|
+
// Postgres refuses an upsert that names one row twice.
|
|
203
|
+
const unique = [...new Map(tags.map(tag => [tag.name, tag])).values()]
|
|
204
|
+
if (complete) {
|
|
205
|
+
await setListing.run(repoId, observedUs)
|
|
206
|
+
await deleteTags.run(repoId, observedUs, JSON.stringify(unique.map(tag => tag.name)))
|
|
207
|
+
}
|
|
208
|
+
if (unique.length > 0) {
|
|
209
|
+
await setTags.run({ contexts: JSON.stringify(unique.map(tag => ({ repoId, name: tag.name, key: githubCommitKey(repoId, tag.sha), observedUs }))) })
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// GitHub App user authorization for managed sign-in. Configure no additional
|
|
2
|
+
// account permissions on the App. Repository permissions are granted separately
|
|
3
|
+
// at installation; login does not install the App or request OAuth scopes.
|
|
4
|
+
import { Buffer } from 'node:buffer'
|
|
5
|
+
import type { AvatarStore } from './avatar-store.ts'
|
|
6
|
+
import type { ManagedConfig } from './config.ts'
|
|
7
|
+
import type { ManagedDb, ManagedUser, UserTokens } from './db.ts'
|
|
8
|
+
import { randomToken, safeEqual } from './crypto.ts'
|
|
9
|
+
import { STATE_COOKIE, buildCookie, clearCookie, cookieName, createSession, parseCookies } from './session.ts'
|
|
10
|
+
import { isViewing } from './view-as.ts'
|
|
11
|
+
|
|
12
|
+
const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'
|
|
13
|
+
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'
|
|
14
|
+
const GITHUB_USER_URL = 'https://api.github.com/user'
|
|
15
|
+
const STATE_TTL_S = 600
|
|
16
|
+
|
|
17
|
+
export const LOGIN_PATH = '/api/oauth/github/login'
|
|
18
|
+
export const CALLBACK_PATH = '/api/oauth/github/callback'
|
|
19
|
+
|
|
20
|
+
// A failure with the HTTP status the router should surface.
|
|
21
|
+
export class OAuthError extends Error {
|
|
22
|
+
status: number
|
|
23
|
+
constructor(status: number, message: string) {
|
|
24
|
+
super(message)
|
|
25
|
+
this.name = 'OAuthError'
|
|
26
|
+
this.status = status
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CallbackResult { location: string; setCookies: string[] }
|
|
31
|
+
export interface CallbackDeps { config: ManagedConfig; db: ManagedDb; avatarStore?: AvatarStore; now?: number; fetchImpl?: typeof fetch }
|
|
32
|
+
|
|
33
|
+
// GET /api/oauth/github/login — redirect to GitHub, stashing the CSRF state. No
|
|
34
|
+
// `scope` is sent: as the GitHub App user-authorization flow, the App's own
|
|
35
|
+
// permission set governs access. Keep account permissions minimal; repository
|
|
36
|
+
// grants are approved separately when installing the App.
|
|
37
|
+
export function buildLoginRedirect(config: ManagedConfig): { location: string; setCookie: string } {
|
|
38
|
+
const state = randomToken()
|
|
39
|
+
const u = new URL(GITHUB_AUTHORIZE_URL)
|
|
40
|
+
u.searchParams.set('client_id', config.githubClientId)
|
|
41
|
+
u.searchParams.set('redirect_uri', config.oauthCallbackUrl)
|
|
42
|
+
u.searchParams.set('state', state)
|
|
43
|
+
u.searchParams.set('allow_signup', 'false')
|
|
44
|
+
const setCookie = buildCookie(cookieName(config, STATE_COOKIE), state, {
|
|
45
|
+
maxAgeS: STATE_TTL_S, secure: config.cookieSecure, sameSite: 'Lax',
|
|
46
|
+
})
|
|
47
|
+
return { location: u.toString(), setCookie }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// GET /api/oauth/github/callback?code&state — the GitHub OAuth hook.
|
|
51
|
+
export async function handleCallback(
|
|
52
|
+
query: URLSearchParams, cookieHeader: string | undefined, deps: CallbackDeps,
|
|
53
|
+
): Promise<CallbackResult> {
|
|
54
|
+
const { config, db } = deps
|
|
55
|
+
const now = deps.now ?? Date.now()
|
|
56
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch
|
|
57
|
+
const code = query.get('code')
|
|
58
|
+
const state = query.get('state')
|
|
59
|
+
const stateCookie = parseCookies(cookieHeader).get(cookieName(config, STATE_COOKIE))
|
|
60
|
+
// CSRF: the `state` GitHub echoes back must match the one we set at /login.
|
|
61
|
+
if (!code || !state || !stateCookie || !safeEqual(state, stateCookie)) {
|
|
62
|
+
throw new OAuthError(400, 'invalid-oauth-state')
|
|
63
|
+
}
|
|
64
|
+
const tokens = await exchangeCode(config, code, now, fetchImpl)
|
|
65
|
+
const user = await fetchIdentity(tokens.accessToken, fetchImpl)
|
|
66
|
+
const { setCookie, userId } = await createSession(config, db, user, now)
|
|
67
|
+
// Persist the user token so the repositories page can list repos on demand.
|
|
68
|
+
await db.setUserTokens(userId, tokens)
|
|
69
|
+
// Cache the avatar for same-origin serving (the page's CSP forbids loading
|
|
70
|
+
// github's CDN directly). Best-effort: a fetch/store failure must not break
|
|
71
|
+
// login, and the avatar endpoint just 404s until a later login fills it.
|
|
72
|
+
if (deps.avatarStore != null && user.avatarUrl != null) {
|
|
73
|
+
await cacheAvatar(deps.avatarStore, userId, user.avatarUrl, fetchImpl).catch((err) => {
|
|
74
|
+
console.warn('managed: avatar cache failed:', err)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
// Clear the spent state cookie, set the session cookie, land on the app.
|
|
78
|
+
return {
|
|
79
|
+
location: '/',
|
|
80
|
+
setCookies: [clearCookie(cookieName(config, STATE_COOKIE), config.cookieSecure), setCookie],
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Exchange the authorization code for a user-to-server token set.
|
|
85
|
+
export function exchangeCode(config: ManagedConfig, code: string, now: number, fetchImpl: typeof fetch): Promise<UserTokens> {
|
|
86
|
+
return postToken({
|
|
87
|
+
client_id: config.githubClientId, client_secret: config.githubClientSecret,
|
|
88
|
+
code, redirect_uri: config.oauthCallbackUrl,
|
|
89
|
+
}, now, fetchImpl)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Refresh an expiring user-to-server token (GitHub Apps with expiring tokens
|
|
93
|
+
// enabled). Returns the fresh token set; OAuthError(502) on any failure.
|
|
94
|
+
export function refreshUserToken(config: ManagedConfig, refreshToken: string, now: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<UserTokens> {
|
|
95
|
+
return postToken({
|
|
96
|
+
client_id: config.githubClientId, client_secret: config.githubClientSecret,
|
|
97
|
+
grant_type: 'refresh_token', refresh_token: refreshToken,
|
|
98
|
+
}, now, fetchImpl)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// POST the GitHub token endpoint and parse the {access_token, refresh_token?,
|
|
102
|
+
// expires_in?} body into a UserTokens (expiry resolved to an absolute ms time).
|
|
103
|
+
async function postToken(payload: Record<string, string>, now: number, fetchImpl: typeof fetch): Promise<UserTokens> {
|
|
104
|
+
let res: Response
|
|
105
|
+
try {
|
|
106
|
+
res = await fetchImpl(GITHUB_TOKEN_URL, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'accept': 'application/json', 'content-type': 'application/json' },
|
|
109
|
+
body: JSON.stringify(payload),
|
|
110
|
+
})
|
|
111
|
+
} catch { throw new OAuthError(502, 'github-token-unreachable') }
|
|
112
|
+
if (!res.ok) throw new OAuthError(502, `github-token-status-${res.status}`)
|
|
113
|
+
let body: unknown
|
|
114
|
+
try { body = await res.json() } catch { throw new OAuthError(502, 'github-token-malformed') }
|
|
115
|
+
const tok = (body as { access_token?: unknown }).access_token
|
|
116
|
+
if (typeof tok !== 'string' || tok === '') throw new OAuthError(502, 'github-token-denied')
|
|
117
|
+
const refresh = (body as { refresh_token?: unknown }).refresh_token
|
|
118
|
+
const expiresIn = (body as { expires_in?: unknown }).expires_in
|
|
119
|
+
return {
|
|
120
|
+
accessToken: tok,
|
|
121
|
+
refreshToken: typeof refresh === 'string' && refresh !== '' ? refresh : null,
|
|
122
|
+
expiresAt: typeof expiresIn === 'number' && expiresIn > 0 ? now + expiresIn * 1000 : null,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The refreshes in flight in this process, by user. GitHub rotates refresh
|
|
127
|
+
// tokens and refuses a spent one (bad_refresh_token), so requests that find the
|
|
128
|
+
// token expired together share one refresh rather than race on the same token.
|
|
129
|
+
const refreshing = new Map<string, Promise<string | null>>()
|
|
130
|
+
// After GitHub refuses a refresh token, how long to wait before each further
|
|
131
|
+
// read for the pair another instance got with it: a second in all.
|
|
132
|
+
const STORED_PAIR_WAITS_MS = [100, 200, 300, 400]
|
|
133
|
+
|
|
134
|
+
// Resolve a usable access token for a user: the stored one if still valid, else
|
|
135
|
+
// refreshed (when a refresh token is on file) and re-persisted. Null when there
|
|
136
|
+
// is no token or it's expired and unrefreshable — the caller prompts re-login.
|
|
137
|
+
// A 60s skew margin avoids handing back a token about to expire mid-request.
|
|
138
|
+
// An admin viewing as another user never borrows that user's GitHub access.
|
|
139
|
+
export async function ensureUserAccessToken(
|
|
140
|
+
config: ManagedConfig, db: ManagedDb, userId: string, now: number, fetchImpl: typeof fetch = globalThis.fetch,
|
|
141
|
+
): Promise<string | null> {
|
|
142
|
+
if (isViewing()) return null
|
|
143
|
+
const tokens = await db.getUserTokens(userId)
|
|
144
|
+
if (tokens == null) return null
|
|
145
|
+
if (usableAt(tokens, now)) return tokens.accessToken
|
|
146
|
+
if (tokens.refreshToken == null) return null
|
|
147
|
+
let pending = refreshing.get(userId)
|
|
148
|
+
if (pending == null) {
|
|
149
|
+
pending = refreshStoredToken(config, db, userId, tokens.accessToken, tokens.refreshToken, now, fetchImpl)
|
|
150
|
+
.finally(() => refreshing.delete(userId))
|
|
151
|
+
refreshing.set(userId, pending)
|
|
152
|
+
}
|
|
153
|
+
return pending
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function usableAt(tokens: UserTokens, now: number): boolean {
|
|
157
|
+
return tokens.expiresAt == null || tokens.expiresAt > now + 60_000
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function refreshStoredToken(
|
|
161
|
+
config: ManagedConfig, db: ManagedDb, userId: string, staleToken: string, refreshToken: string, now: number, fetchImpl: typeof fetch,
|
|
162
|
+
): Promise<string | null> {
|
|
163
|
+
try {
|
|
164
|
+
const fresh = await refreshUserToken(config, refreshToken, now, fetchImpl)
|
|
165
|
+
await db.setUserTokens(userId, fresh)
|
|
166
|
+
return fresh.accessToken
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// Another request or instance may have spent the refresh token first: use
|
|
169
|
+
// the pair it stored rather than leave this request to ask anonymously.
|
|
170
|
+
// Where GitHub refused the token, that pair may not be stored yet.
|
|
171
|
+
const waits = err instanceof OAuthError && err.message === 'github-token-denied' ? STORED_PAIR_WAITS_MS : []
|
|
172
|
+
for (const wait of [0, ...waits]) {
|
|
173
|
+
if (wait > 0) await new Promise(resolve => { setTimeout(resolve, wait) })
|
|
174
|
+
const stored = await db.getUserTokens(userId).catch(() => null)
|
|
175
|
+
if (stored != null && stored.accessToken !== staleToken && usableAt(stored, now)) return stored.accessToken
|
|
176
|
+
}
|
|
177
|
+
console.warn('managed: token refresh failed:', err)
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Read the authenticated user's identity (`GET /user`).
|
|
183
|
+
export async function fetchIdentity(token: string, fetchImpl: typeof fetch): Promise<ManagedUser> {
|
|
184
|
+
let res: Response
|
|
185
|
+
try {
|
|
186
|
+
res = await fetchImpl(GITHUB_USER_URL, {
|
|
187
|
+
headers: {
|
|
188
|
+
'authorization': `Bearer ${token}`, 'accept': 'application/vnd.github+json',
|
|
189
|
+
'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28',
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
} catch { throw new OAuthError(502, 'github-user-unreachable') }
|
|
193
|
+
if (!res.ok) throw new OAuthError(502, `github-user-status-${res.status}`)
|
|
194
|
+
let body: unknown
|
|
195
|
+
try { body = await res.json() } catch { throw new OAuthError(502, 'github-user-malformed') }
|
|
196
|
+
const user = parseGithubUser(body)
|
|
197
|
+
if (user == null) throw new OAuthError(502, 'github-user-invalid')
|
|
198
|
+
return user
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Validate GitHub's `GET /user` payload into a ManagedUser (or null).
|
|
202
|
+
function parseGithubUser(body: unknown): ManagedUser | null {
|
|
203
|
+
if (body == null || typeof body !== 'object') return null
|
|
204
|
+
const id = (body as { id?: unknown }).id
|
|
205
|
+
const login = (body as { login?: unknown }).login
|
|
206
|
+
if (typeof id !== 'number' || !Number.isSafeInteger(id) || typeof login !== 'string' || login === '') return null
|
|
207
|
+
const name = (body as { name?: unknown }).name
|
|
208
|
+
const avatar = (body as { avatar_url?: unknown }).avatar_url
|
|
209
|
+
return {
|
|
210
|
+
githubUserId: id, login,
|
|
211
|
+
name: typeof name === 'string' ? name : null,
|
|
212
|
+
avatarUrl: typeof avatar === 'string' ? avatar : null,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Fetch the user's GitHub avatar and cache it for same-origin serving. The host
|
|
217
|
+
// is validated (githubusercontent.com over https) as SSRF defence-in-depth,
|
|
218
|
+
// even though the URL comes from GitHub's own /user response.
|
|
219
|
+
async function cacheAvatar(store: AvatarStore, id: string, avatarUrl: string, fetchImpl: typeof fetch): Promise<void> {
|
|
220
|
+
if (!isGithubAvatarUrl(avatarUrl)) return
|
|
221
|
+
// `redirect: 'manual'` so a 3xx off the (validated) avatars host can't bounce
|
|
222
|
+
// the fetch to an internal address — an opaque-redirect / 3xx fails the
|
|
223
|
+
// `res.ok` + image check below and is skipped (SSRF defence-in-depth).
|
|
224
|
+
const res = await fetchImpl(avatarUrl, { redirect: 'manual' })
|
|
225
|
+
const contentType = res.headers.get('content-type') ?? ''
|
|
226
|
+
if (!res.ok || !contentType.startsWith('image/')) return
|
|
227
|
+
await store.put(id, contentType, Buffer.from(await res.arrayBuffer()))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isGithubAvatarUrl(raw: string): boolean {
|
|
231
|
+
let url: URL
|
|
232
|
+
try { url = new URL(raw) } catch { return false }
|
|
233
|
+
return url.protocol === 'https:'
|
|
234
|
+
&& (url.hostname === 'avatars.githubusercontent.com' || url.hostname.endsWith('.githubusercontent.com'))
|
|
235
|
+
}
|