@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50
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 +3 -0
- package/client/dependency-paths.js +36 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +115 -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 +357 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-metadata.d.ts +15 -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 +333 -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 +18 -0
- package/common/dependency-reviews.js +64 -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 +57 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/finding-detail.d.ts +2 -0
- package/common/managed/finding-detail.js +32 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -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 +109 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +35 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +347 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +5 -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/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +46 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +39 -0
- package/common/workspace-groups.js +176 -0
- package/out/activity.js +4 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2WDZPXVL.js +3 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4XMP7YVH.js +36 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-6S2U2UYG.js +232 -0
- package/out/chunk-75YLHBCM.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FC65TPUB.js +1 -0
- package/out/chunk-FKBXNUW4.js +1 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HTBYHSPG.js +1 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-PWFUOLZK.js +17 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TQJDYHVE.js +2 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-UK7RGJT6.js +3 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WCPJDAYA.js +10 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WYLQWYLA.js +1 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +33 -0
- package/out/client-sync.js +11 -17
- package/out/compare.js +35 -0
- package/out/dialogs.js +55 -0
- 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 +14 -10
- package/out/scan.js +2 -0
- package/out/stasis.svg +20 -0
- package/out/terminal.js +366 -39
- package/out/view.css +1 -1
- package/out/view.js +19 -144
- package/package.json +362 -59
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +177 -0
- package/risk/files/runs-code.js +615 -0
- package/risk/files/script.js +118 -0
- package/risk/files/stamps.js +47 -0
- package/risk/index.d.ts +254 -0
- package/risk/index.js +11 -0
- package/risk/npm/assess.js +285 -0
- package/risk/npm/combined.js +68 -0
- package/risk/npm/compare.js +257 -0
- package/risk/npm/dependencies.js +172 -0
- package/risk/npm/diff.js +79 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +445 -0
- package/risk/npm/held.js +63 -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 +60 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +388 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +96 -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 +487 -0
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +50 -42
- 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 +44 -13
- 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 +19 -27
- 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 +3 -3
- 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 +60 -33
- package/{server → server-e2e}/static.ts +31 -21
- package/{server → server-e2e}/sync-handlers.ts +64 -30
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +258 -0
- package/server-managed/agent-merges.ts +109 -0
- package/server-managed/app-agents.ts +21 -0
- package/server-managed/avatar-store.ts +13 -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 +115 -0
- package/server-managed/bundle-build.ts +200 -0
- package/server-managed/bundle-cache.ts +211 -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 +119 -0
- package/server-managed/cache-storage.ts +20 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +47 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/commit-coauthors.ts +106 -0
- package/server-managed/commit-lockfiles.ts +73 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/config.ts +164 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1923 -0
- package/server-managed/db-neon.ts +458 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +142 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +100 -0
- package/server-managed/dependency-reviews.ts +346 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +415 -0
- package/server-managed/github-errors.ts +70 -0
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +140 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +238 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5329 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +192 -0
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +162 -0
- package/server-managed/locked-packages.ts +129 -0
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +78 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +194 -0
- package/server-managed/npm-insights.ts +449 -0
- package/server-managed/npm-kept.ts +215 -0
- package/server-managed/npm-loads.ts +264 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +454 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +285 -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 +75 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +434 -0
- package/server-managed/public-workspace.ts +238 -0
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +348 -0
- package/server-managed/pull-dependency-reviews.ts +328 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +39 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +279 -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 +85 -0
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +355 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +294 -0
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +215 -0
- package/server-managed/repository-dependency-reviews.ts +165 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-history.ts +432 -0
- package/server-managed/repository-issues.ts +195 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-pulls.ts +156 -0
- package/server-managed/repository-pulse.ts +460 -0
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +79 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +491 -0
- package/server-managed/repository-squashes.ts +168 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +150 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/session.ts +132 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +71 -0
- package/server-managed/sql-postgres.ts +34 -0
- package/server-managed/sql.ts +88 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +183 -0
- package/server-managed/storage-encryption.ts +290 -0
- package/server-managed/storage-maintenance.ts +259 -0
- package/server-managed/storage-payload.ts +121 -0
- package/server-managed/storage-stores.ts +91 -0
- package/server-managed/storage.ts +82 -0
- package/server-managed/team-app.ts +134 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +180 -0
- package/server-managed/team-npm-scopes.ts +106 -0
- package/server-managed/team-reports.ts +307 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +105 -0
- package/server-managed/uploads.ts +106 -0
- package/server-managed/upstream-cache.ts +243 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +169 -0
- package/server.ts +70 -0
- package/server/index.ts +0 -480
- package/server/lifecycle.ts +0 -208
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/auth.ts +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}/objstore/sign.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/sse-session.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { type GithubFixResult, githubIssueClosedReason, isGithubRepoName, parseGithubFixUrl } from '../common/github-pr.ts'
|
|
2
|
+
import type { ManagedConfig } from './config.ts'
|
|
3
|
+
import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
|
|
4
|
+
import type { ManagedIssue } from './managed-issues.ts'
|
|
5
|
+
import { ensureUserAccessToken } from './github-oauth.ts'
|
|
6
|
+
import { lookupFixes } from './github-pulls.ts'
|
|
7
|
+
|
|
8
|
+
export type IssueFixUpdate = { findingId: string; issueUrl: string; previous: string | null; next: string | null; checkedAt: number }
|
|
9
|
+
type Pull = { number: number; title: string; body: string | null; state: string; isDraft: boolean; createdAt: string;
|
|
10
|
+
repository: { databaseId: number; nameWithOwner: string } }
|
|
11
|
+
type Connection = { nodes: Pull[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } }
|
|
12
|
+
type Issue = { number: number; title: string; body: string | null; state: string; stateReason: string | null; closedByPullRequestsReferences?: Connection }
|
|
13
|
+
type Repository = TeamReportAccessSnapshot['repositories'][number]
|
|
14
|
+
type Job = { repo: Repository; kind: 'pull' | 'issue'; number: number; aliases: string[];
|
|
15
|
+
linked: boolean; cursor: string | null; pulls: Pull[]; complete: boolean }
|
|
16
|
+
type Metadata = Exclude<GithubFixResult, { error: string }>
|
|
17
|
+
const pullFields = 'number title body state isDraft createdAt repository { databaseId nameWithOwner }'
|
|
18
|
+
|
|
19
|
+
function metadataQuery(jobs: Job[]) {
|
|
20
|
+
return `{ ${jobs.map((job, index) => {
|
|
21
|
+
const [owner, name] = job.repo.github.split('/')
|
|
22
|
+
const fields = job.kind === 'pull' ? pullFields : `number title body state stateReason ${job.linked
|
|
23
|
+
? `closedByPullRequestsReferences(first: 100, includeClosedPrs: true, after: ${JSON.stringify(job.cursor)}) { nodes { ${pullFields} } pageInfo { hasNextPage endCursor } }` : ''}`
|
|
24
|
+
return `r${index}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { databaseId nameWithOwner item: ${job.kind === 'pull' ? 'pullRequest' : 'issue'}(number: ${job.number}) { ${fields} } }`
|
|
25
|
+
}).join('\n')} }`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function itemMetadata(item: Issue | Pull, kind: Job['kind']): Omit<Metadata, 'url'> | null {
|
|
29
|
+
if (!item || typeof item.title !== 'string' || !item.title.trim() || item.title.length > 1024
|
|
30
|
+
|| item.body != null && typeof item.body !== 'string' || !['OPEN', 'CLOSED', ...(kind === 'pull' ? ['MERGED'] : [])].includes(item.state)) return null
|
|
31
|
+
const status = item.state === 'MERGED' ? 'merged' : item.state === 'CLOSED' ? 'closed' : 'isDraft' in item && item.isDraft === true ? 'draft' : 'open'
|
|
32
|
+
const stateReason = kind === 'issue' && status === 'closed' ? githubIssueClosedReason(String((item as Issue).stateReason).toLowerCase()) : null
|
|
33
|
+
return { title: item.title, description: item.body ?? null, status, stateReason }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readPage(job: Job, item: Issue | Pull, metadata: Map<string, Metadata>): boolean {
|
|
37
|
+
const details = itemMetadata(item, job.kind)
|
|
38
|
+
if (!details || item.number !== job.number) return false
|
|
39
|
+
for (const url of job.aliases) metadata.set(url, { url, ...details })
|
|
40
|
+
if (!job.linked) return false
|
|
41
|
+
const connection = (item as Issue).closedByPullRequestsReferences
|
|
42
|
+
if (!connection || !Array.isArray(connection.nodes) || typeof connection.pageInfo?.hasNextPage !== 'boolean'
|
|
43
|
+
|| connection.nodes.some(pr => !pr || !Number.isSafeInteger(pr.number) || pr.number <= 0 || typeof pr.createdAt !== 'string'
|
|
44
|
+
|| !Number.isFinite(Date.parse(pr.createdAt)) || !itemMetadata(pr, 'pull') || typeof pr.isDraft !== 'boolean'
|
|
45
|
+
|| !Number.isSafeInteger(pr.repository?.databaseId) || typeof pr.repository?.nameWithOwner !== 'string' || !isGithubRepoName(pr.repository.nameWithOwner))) return false
|
|
46
|
+
job.pulls.push(...connection.nodes)
|
|
47
|
+
if (!connection.pageInfo.hasNextPage) { job.complete = true; return false }
|
|
48
|
+
if (typeof connection.pageInfo.endCursor !== 'string' || !connection.pageInfo.endCursor || connection.pageInfo.endCursor === job.cursor) return false
|
|
49
|
+
job.cursor = connection.pageInfo.endCursor
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string, Repository>, checkedAt: number, metadata: Map<string, Metadata>) {
|
|
54
|
+
const updates: IssueFixUpdate[] = []
|
|
55
|
+
// The issues each job's issue is, by repository and number, read once.
|
|
56
|
+
const byIssue = new Map<string, ManagedIssue[]>()
|
|
57
|
+
for (const issue of issues) {
|
|
58
|
+
const ref = parseGithubFixUrl(issue.issueUrl)
|
|
59
|
+
if (ref?.kind !== 'issue') continue
|
|
60
|
+
const key = `${issue.repoId}:${ref.number}`, same = byIssue.get(key)
|
|
61
|
+
if (same) same.push(issue)
|
|
62
|
+
else byIssue.set(key, [issue])
|
|
63
|
+
}
|
|
64
|
+
for (const job of jobs) {
|
|
65
|
+
if (!job.linked || !job.complete) continue
|
|
66
|
+
// "Last" means the newest PR by creation time, never its latest comment.
|
|
67
|
+
const latest = job.pulls.filter(pr => pr.state !== 'CLOSED').toSorted((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || b.number - a.number)[0]
|
|
68
|
+
const repo = latest && allowed.get(latest.repository.nameWithOwner.toLowerCase())
|
|
69
|
+
// The viewer's token can have broader access than this workspace. Never
|
|
70
|
+
// persist or disclose a PR from outside the workspace repository grants.
|
|
71
|
+
if (latest && (!repo || repo.repoId !== latest.repository.databaseId)) continue
|
|
72
|
+
const next = latest ? `https://github.com/${repo!.github}/pull/${latest.number}` : null
|
|
73
|
+
if (latest && next) metadata.set(next, { url: next, ...itemMetadata(latest, 'pull')! })
|
|
74
|
+
// Only a changed derived Fix is written: an unchanged one's check time
|
|
75
|
+
// alone would bump the annotations revision that every open team feed
|
|
76
|
+
// polls, for nothing they show. Changes still stamp it, so an older lookup
|
|
77
|
+
// never replaces a newer change (managed-issues.ts). An older lookup that
|
|
78
|
+
// differs can now land after a newer unchanged one, as it could already
|
|
79
|
+
// land before it (whose compare-and-set then fails); either way the next
|
|
80
|
+
// lookup finds the stored Fix differs, and writes the current one.
|
|
81
|
+
for (const issue of byIssue.get(`${job.repo.repoId}:${job.number}`) ?? []) {
|
|
82
|
+
if (issue.autoFixUrl !== next) updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return updates
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fetch issue details, linked PR states and manual Fix metadata in one batch.
|
|
89
|
+
// Pagination and workspaces with over 200 distinct links need further batches.
|
|
90
|
+
// Live GraphQL results prove this viewer's access; never publish them into the
|
|
91
|
+
// REST shared cache under an older public-visibility grant.
|
|
92
|
+
export async function lookupIssueLinks(config: ManagedConfig, db: ManagedDb, snapshot: TeamReportAccessSnapshot,
|
|
93
|
+
urls: string[], issues: ManagedIssue[], fetchImpl: typeof fetch = fetch): Promise<{ fixes: GithubFixResult[]; updates: IssueFixUpdate[] }> {
|
|
94
|
+
const checkedAt = Date.now(), signal = AbortSignal.timeout(10_000)
|
|
95
|
+
const allowed = new Map(snapshot.repositories.filter(repo => isGithubRepoName(repo.github)).map(repo => [repo.github.toLowerCase(), repo]))
|
|
96
|
+
const jobs = new Map<string, Job>()
|
|
97
|
+
const linked = new Set(issues.filter(issue => issue.issueUrl).map(issue => {
|
|
98
|
+
const ref = parseGithubFixUrl(issue.issueUrl)
|
|
99
|
+
return ref?.kind === 'issue' ? `${issue.repoId}:issue:${ref.number}` : ''
|
|
100
|
+
}))
|
|
101
|
+
for (const url of urls) {
|
|
102
|
+
const ref = parseGithubFixUrl(url), repo = ref && allowed.get(ref.repo.toLowerCase())
|
|
103
|
+
if (!ref || !repo) continue
|
|
104
|
+
const key = `${repo.repoId}:${ref.kind}:${ref.number}`, previous = jobs.get(key)
|
|
105
|
+
if (previous) { previous.aliases.push(url); continue }
|
|
106
|
+
jobs.set(key, { repo, kind: ref.kind, number: ref.number, aliases: [url], linked: linked.has(key), cursor: null, pulls: [], complete: false })
|
|
107
|
+
}
|
|
108
|
+
if (jobs.size === 0) return { fixes: [], updates: [] }
|
|
109
|
+
const withinDeadline: typeof fetch = (input, init) => { signal.throwIfAborted(); return fetchImpl(input, { ...init, signal }) }
|
|
110
|
+
const token = await ensureUserAccessToken(config, db, snapshot.user.id, checkedAt, withinDeadline)
|
|
111
|
+
if (!token) return { fixes: await lookupFixes(config, db, snapshot, urls, withinDeadline), updates: [] }
|
|
112
|
+
const metadata = new Map<string, Metadata>(), selected = [...jobs.values()]
|
|
113
|
+
let pending = selected
|
|
114
|
+
for (let page = 0; pending.length > 0 && page < 100 && !signal.aborted; page++) {
|
|
115
|
+
const batch = pending.slice(0, 200), query = metadataQuery(batch)
|
|
116
|
+
let data: Record<string, { databaseId: number; nameWithOwner: string; item: Issue | Pull } | null>
|
|
117
|
+
try {
|
|
118
|
+
const response = await withinDeadline('https://api.github.com/graphql', {
|
|
119
|
+
method: 'POST', redirect: 'error',
|
|
120
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'user-agent': 'deepview-triage' }, body: JSON.stringify({ query }),
|
|
121
|
+
})
|
|
122
|
+
if (!response.ok) break
|
|
123
|
+
const body = await response.json() as { data?: typeof data; errors?: { path?: unknown[] }[] }
|
|
124
|
+
// Errors attached to an alias invalidate that whole job, including its
|
|
125
|
+
// connection. Other aliases can succeed even if one issue was deleted.
|
|
126
|
+
if (!body?.data || body.errors != null && (!Array.isArray(body.errors)
|
|
127
|
+
|| body.errors.some(error => typeof error?.path?.[0] !== 'string' || !/^r\d+$/u.test(error.path[0])))) break
|
|
128
|
+
data = body.data
|
|
129
|
+
for (const error of body.errors ?? []) data[error.path![0] as string] = null
|
|
130
|
+
} catch { break }
|
|
131
|
+
pending = pending.slice(batch.length)
|
|
132
|
+
for (const [index, job] of batch.entries()) {
|
|
133
|
+
const repository = data[`r${index}`]
|
|
134
|
+
if (repository?.databaseId !== job.repo.repoId || typeof repository.nameWithOwner !== 'string' || repository.nameWithOwner.toLowerCase() !== job.repo.github.toLowerCase()) continue
|
|
135
|
+
if (readPage(job, repository.item, metadata)) pending.push(job)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const updates = issueUpdates(selected, issues, allowed, checkedAt, metadata)
|
|
139
|
+
return { fixes: [...metadata.values()], updates }
|
|
140
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ManagedConfig } from './config.ts'
|
|
2
|
+
import type { ManagedDb, ManagedSession } from './db.ts'
|
|
3
|
+
import { randomToken, safeEqual } from './crypto.ts'
|
|
4
|
+
import { ISSUE_STATE_COOKIE, buildCookie, clearCookie, cookieName, parseCookies } from './session.ts'
|
|
5
|
+
import { OAuthError, ensureUserAccessToken, exchangeCode, fetchIdentity } from './github-oauth.ts'
|
|
6
|
+
|
|
7
|
+
export const ISSUE_LOGIN_PATH = '/api/oauth/github/issues/login'
|
|
8
|
+
|
|
9
|
+
export function isIssueOAuthCallback(config: ManagedConfig, query: URLSearchParams, cookie: string | undefined): boolean {
|
|
10
|
+
const expected = parseCookies(cookie).get(cookieName(config, ISSUE_STATE_COOKIE))?.split('.')[0]
|
|
11
|
+
return !!expected && safeEqual(query.get('state') ?? '', expected)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function issueLoginRedirect(config: ManagedConfig, session: ManagedSession, now = Date.now()) {
|
|
15
|
+
const state = randomToken()
|
|
16
|
+
const url = new URL('https://github.com/login/oauth/authorize')
|
|
17
|
+
url.searchParams.set('client_id', config.githubClientId)
|
|
18
|
+
url.searchParams.set('redirect_uri', config.oauthCallbackUrl)
|
|
19
|
+
url.searchParams.set('state', state)
|
|
20
|
+
url.searchParams.set('allow_signup', 'false')
|
|
21
|
+
// Reauthorize the same app for the current account, without replacing the
|
|
22
|
+
// DeepView session or permitting an OAuth account switch during creation.
|
|
23
|
+
const value = `${state}.${session.id}.${now}`
|
|
24
|
+
return { location: url.href, setCookie: buildCookie(cookieName(config, ISSUE_STATE_COOKIE), value,
|
|
25
|
+
{ secure: config.cookieSecure, maxAgeS: 600, sameSite: 'Lax' }) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function issueOAuthCallback(config: ManagedConfig, db: ManagedDb, session: ManagedSession,
|
|
29
|
+
query: URLSearchParams, cookie: string | undefined, fetchImpl: typeof fetch = fetch, now = Date.now()) {
|
|
30
|
+
const [expected, sessionId, timestamp] = (parseCookies(cookie).get(cookieName(config, ISSUE_STATE_COOKIE)) ?? '').split('.')
|
|
31
|
+
const code = query.get('code'), state = query.get('state')
|
|
32
|
+
if (!code || !state || !expected || !safeEqual(state, expected) || sessionId !== session.id
|
|
33
|
+
|| !timestamp || !Number.isFinite(Number(timestamp)) || now < Number(timestamp) || now - Number(timestamp) > 600_000) {
|
|
34
|
+
throw new OAuthError(400, 'invalid-oauth-state')
|
|
35
|
+
}
|
|
36
|
+
const tokens = await exchangeCode(config, code, now, fetchImpl)
|
|
37
|
+
const identity = await fetchIdentity(tokens.accessToken, fetchImpl)
|
|
38
|
+
if (identity.githubUserId !== await db.getUserGithubId(session.userId)) throw new OAuthError(403, 'github-account-mismatch')
|
|
39
|
+
const current = await db.sessionWithUser(session.id, Date.now())
|
|
40
|
+
if (!current || current.user.role === 'none' || current.session.userId !== session.userId) throw new OAuthError(401, 'unauthenticated')
|
|
41
|
+
await db.setUserTokens(session.userId, tokens)
|
|
42
|
+
return { location: '/github-issue-authorized.html', setCookie: clearCookie(cookieName(config, ISSUE_STATE_COOKIE), config.cookieSecure) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function issueUserToken(config: ManagedConfig, db: ManagedDb, userId: string, fetchImpl: typeof fetch = fetch) {
|
|
46
|
+
return ensureUserAccessToken(config, db, userId, Date.now(), fetchImpl)
|
|
47
|
+
}
|
|
@@ -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,69 @@
|
|
|
1
|
+
// Linking another GitHub account to the signed-in user (github-links.ts): the
|
|
2
|
+
// App's user authorization again, completed by the account to link. GitHub is
|
|
3
|
+
// asked for its account picker, as the browser is usually signed in to the
|
|
4
|
+
// account the user signs in here with. The token GitHub grants reads the
|
|
5
|
+
// account's identity and avatar; it is never stored.
|
|
6
|
+
import type { AvatarStore } from './avatar-store.ts'
|
|
7
|
+
import type { ManagedConfig } from './config.ts'
|
|
8
|
+
import type { ManagedDb, ManagedSession } from './db.ts'
|
|
9
|
+
import { randomToken, safeEqual } from './crypto.ts'
|
|
10
|
+
import { LINK_STATE_COOKIE, buildCookie, clearCookie, cookieName, parseCookies } from './session.ts'
|
|
11
|
+
import { OAuthError, cacheAvatar, exchangeCode, fetchIdentity } from './github-oauth.ts'
|
|
12
|
+
import { ManagedMutationError } from './management.ts'
|
|
13
|
+
|
|
14
|
+
export const LINK_LOGIN_PATH = '/api/oauth/github/link'
|
|
15
|
+
// The profile page, where linking starts and its callback lands.
|
|
16
|
+
const PROFILE_PATH = '/my/profile'
|
|
17
|
+
const STATE_TTL_MS = 600_000
|
|
18
|
+
|
|
19
|
+
export function isLinkOAuthCallback(config: ManagedConfig, query: URLSearchParams, cookie: string | undefined): boolean {
|
|
20
|
+
const expected = parseCookies(cookie).get(cookieName(config, LINK_STATE_COOKIE))?.split('.')[0]
|
|
21
|
+
return !!expected && safeEqual(query.get('state') ?? '', expected)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The state is bound to the session that started linking, so the account
|
|
25
|
+
// links to the user who asked, never to whoever is signed in on return.
|
|
26
|
+
export function linkLoginRedirect(config: ManagedConfig, session: ManagedSession, now = Date.now()): { location: string; setCookie: string } {
|
|
27
|
+
const state = randomToken()
|
|
28
|
+
const url = new URL('https://github.com/login/oauth/authorize')
|
|
29
|
+
url.searchParams.set('client_id', config.githubClientId)
|
|
30
|
+
url.searchParams.set('redirect_uri', config.oauthCallbackUrl)
|
|
31
|
+
url.searchParams.set('state', state)
|
|
32
|
+
url.searchParams.set('allow_signup', 'false')
|
|
33
|
+
url.searchParams.set('prompt', 'select_account')
|
|
34
|
+
return { location: url.href, setCookie: buildCookie(cookieName(config, LINK_STATE_COOKIE), `${state}.${session.id}.${now}`,
|
|
35
|
+
{ secure: config.cookieSecure, maxAgeS: STATE_TTL_MS / 1000, sameSite: 'Lax' }) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Back from GitHub: link the account that authorized, then land on the
|
|
39
|
+
// profile, naming the refusal (`link-error`) when it was refused.
|
|
40
|
+
// `linked` is the login of an account linked anew.
|
|
41
|
+
export async function linkOAuthCallback(config: ManagedConfig, db: ManagedDb, session: ManagedSession,
|
|
42
|
+
query: URLSearchParams, cookie: string | undefined,
|
|
43
|
+
{ avatarStore, fetchImpl = fetch, now = Date.now() }: { avatarStore?: AvatarStore; fetchImpl?: typeof fetch; now?: number } = {},
|
|
44
|
+
): Promise<{ location: string; setCookie: string; linked: string | null }> {
|
|
45
|
+
const setCookie = clearCookie(cookieName(config, LINK_STATE_COOKIE), config.cookieSecure)
|
|
46
|
+
try {
|
|
47
|
+
const [expected, sessionId, timestamp] = (parseCookies(cookie).get(cookieName(config, LINK_STATE_COOKIE)) ?? '').split('.')
|
|
48
|
+
const code = query.get('code'), started = Number(timestamp), state = query.get('state')
|
|
49
|
+
if (!state || !expected || !safeEqual(state, expected) || sessionId !== session.id
|
|
50
|
+
|| !timestamp || !Number.isFinite(started) || now < started || now - started > STATE_TTL_MS) {
|
|
51
|
+
throw new OAuthError(400, 'invalid-oauth-state')
|
|
52
|
+
}
|
|
53
|
+
// Declined on GitHub's side, which sends an error in place of a code.
|
|
54
|
+
if (!code) throw new OAuthError(400, query.get('error') ? 'github-authorization-declined' : 'invalid-oauth-state')
|
|
55
|
+
const tokens = await exchangeCode(config, code, now, fetchImpl)
|
|
56
|
+
const account = await fetchIdentity(tokens.accessToken, fetchImpl)
|
|
57
|
+
const link = await db.linkGithubAccount(session.id, account, now)
|
|
58
|
+
// Best-effort, as at sign-in: the avatar falls back to an initial.
|
|
59
|
+
if (avatarStore != null && account.avatarUrl != null) {
|
|
60
|
+
await cacheAvatar(avatarStore, link.id, account.avatarUrl, fetchImpl).catch((err) => {
|
|
61
|
+
console.warn('managed: linked avatar cache failed:', err)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
return { location: PROFILE_PATH, setCookie, linked: link.created ? account.login : null }
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (!(err instanceof OAuthError || err instanceof ManagedMutationError)) throw err
|
|
67
|
+
return { location: `${PROFILE_PATH}?${new URLSearchParams({ 'link-error': err.message })}`, setCookie, linked: null }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// GitHub accounts a user links to their own on the profile page. A linked
|
|
2
|
+
// account's identity is reserved for that user: it can neither sign in nor
|
|
3
|
+
// register (upsertUser refuses it in the transaction that would create the
|
|
4
|
+
// user), and an account that has registered cannot be linked. The user's
|
|
5
|
+
// login and GitHub id stay the sign-in account's; the displayed name and
|
|
6
|
+
// avatar may come from any of their accounts.
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
import type { ManagedUser } from './db-methods.ts'
|
|
9
|
+
import { ManagedMutationError } from './management.ts'
|
|
10
|
+
import type { ManagedSql } from './sql.ts'
|
|
11
|
+
|
|
12
|
+
// login/name are snapshots of the account as it was last linked; linking it
|
|
13
|
+
// again refreshes them. Its avatar is cached under the link id (avatar-store.ts).
|
|
14
|
+
export const GITHUB_LINK_SCHEMA = `
|
|
15
|
+
CREATE TABLE IF NOT EXISTS managed_github_link (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
user_id TEXT NOT NULL REFERENCES managed_user(id) ON DELETE CASCADE,
|
|
18
|
+
github_user_id INTEGER NOT NULL UNIQUE,
|
|
19
|
+
login TEXT NOT NULL,
|
|
20
|
+
name TEXT,
|
|
21
|
+
linked_at INTEGER NOT NULL
|
|
22
|
+
) STRICT;
|
|
23
|
+
|
|
24
|
+
CREATE INDEX IF NOT EXISTS managed_github_link_user_idx ON managed_github_link(user_id);
|
|
25
|
+
`
|
|
26
|
+
|
|
27
|
+
// managed_user columns added with the table. managed_user.name is the
|
|
28
|
+
// displayed name; github_name keeps the sign-in account's own. name_link and
|
|
29
|
+
// avatar_link name the linked account each comes from, NULL the sign-in one.
|
|
30
|
+
export const GITHUB_LINK_USER_COLUMNS: readonly (readonly [string, string])[] = [
|
|
31
|
+
['github_name', 'TEXT'],
|
|
32
|
+
['name_link', 'TEXT REFERENCES managed_github_link(id) ON DELETE SET NULL'],
|
|
33
|
+
['avatar_link', 'TEXT REFERENCES managed_github_link(id) ON DELETE SET NULL'],
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
export interface LinkedGithubAccount {
|
|
37
|
+
id: string
|
|
38
|
+
// Its GitHub account's numeric id, which GitHub never gives another.
|
|
39
|
+
githubId: number
|
|
40
|
+
login: string
|
|
41
|
+
name: string | null
|
|
42
|
+
linkedAt: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ManagedProfile {
|
|
46
|
+
// Always the sign-in account's.
|
|
47
|
+
login: string
|
|
48
|
+
// The displayed name, and the sign-in account's own GitHub name.
|
|
49
|
+
name: string | null
|
|
50
|
+
githubName: string | null
|
|
51
|
+
// The linked account the displayed name or avatar comes from, null the sign-in one.
|
|
52
|
+
nameAccount: string | null
|
|
53
|
+
avatarAccount: string | null
|
|
54
|
+
accounts: LinkedGithubAccount[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A user as the app shows them where a GitHub login of theirs is named: by
|
|
58
|
+
// their id, whose avatar /api/avatar/<id> serves (the one they chose), their
|
|
59
|
+
// sign-in login and the name they chose.
|
|
60
|
+
export interface AppUser {
|
|
61
|
+
id: string
|
|
62
|
+
login: string
|
|
63
|
+
name: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Which account the displayed name and avatar each come from: a linked
|
|
67
|
+
// account's id, or null for the sign-in account. An absent key is unchanged.
|
|
68
|
+
export interface ProfileChoice {
|
|
69
|
+
name?: string | null
|
|
70
|
+
avatar?: string | null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface GithubLinkStore {
|
|
74
|
+
getProfile(userId: string): Promise<ManagedProfile | null>
|
|
75
|
+
// Link `account` to the session's own user, or refresh the snapshot of one
|
|
76
|
+
// they linked before. Refused for the sign-in account itself, an account
|
|
77
|
+
// another user linked, and any account that has registered.
|
|
78
|
+
linkGithubAccount(sessionId: string, account: ManagedUser, now: number): Promise<{ id: string; created: boolean }>
|
|
79
|
+
// Unlink one of the session user's accounts, which may then sign in or
|
|
80
|
+
// register again; a name or avatar it gave falls back to the sign-in
|
|
81
|
+
// account's. Null when the user has no such link.
|
|
82
|
+
unlinkGithubAccount(sessionId: string, id: string, now: number): Promise<LinkedGithubAccount | null>
|
|
83
|
+
setProfileChoice(sessionId: string, choice: ProfileChoice, now: number): Promise<void>
|
|
84
|
+
// The avatar to serve for `id`: a user's chosen linked account's, else `id`'s own.
|
|
85
|
+
getAvatarKey(id: string): Promise<string>
|
|
86
|
+
// The approved users signing in with, or having linked, each of
|
|
87
|
+
// `accounts`, by the login as asked: by the account's numeric id where it
|
|
88
|
+
// is known, which GitHub never gives another; else by its login (matched
|
|
89
|
+
// regardless of case, as GitHub matches them), one more than one user
|
|
90
|
+
// claims, renamed on GitHub since another signed in with it or linked it,
|
|
91
|
+
// left out. No access users are left out.
|
|
92
|
+
listUsersByAccount(accounts: readonly GithubAccount[]): Promise<Record<string, AppUser>>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A GitHub account a repository's activity names: by its login, and its
|
|
96
|
+
// numeric id where GitHub told it.
|
|
97
|
+
export interface GithubAccount { login: string; githubId: number | null }
|
|
98
|
+
|
|
99
|
+
type LinkRow = { id: string; githubId: number; login: string; name: string | null; linkedAt: number }
|
|
100
|
+
const accountOf = (row: LinkRow): LinkedGithubAccount => ({ id: row.id, githubId: row.githubId, login: row.login, name: row.name, linkedAt: row.linkedAt })
|
|
101
|
+
|
|
102
|
+
// GithubLinkStore.listUsersByAccount.
|
|
103
|
+
async function usersByAccount(db: ManagedSql, accounts: readonly GithubAccount[]): Promise<Record<string, AppUser>> {
|
|
104
|
+
if (accounts.length === 0) return {}
|
|
105
|
+
const ids = accounts.flatMap(account => account.githubId ?? [])
|
|
106
|
+
const logins = accounts.flatMap(account => account.githubId === null ? [account.login] : [])
|
|
107
|
+
const rows = await db.prepare(`SELECT a.login, a.githubId, u.id, u.login AS appLogin, u.name, u.role FROM (
|
|
108
|
+
SELECT login, github_user_id AS githubId, id AS claimant FROM managed_user
|
|
109
|
+
UNION ALL SELECT login, github_user_id AS githubId, user_id AS claimant FROM managed_github_link
|
|
110
|
+
) a JOIN managed_user u ON u.id = a.claimant
|
|
111
|
+
WHERE a.githubId IN (SELECT CAST(value AS BIGINT) FROM json_each(?)) OR lower(a.login) IN (SELECT lower(value) FROM json_each(?))`)
|
|
112
|
+
.all(JSON.stringify(ids), JSON.stringify(logins)) as { login: string; githubId: number; id: string; appLogin: string; name: string | null; role: string }[]
|
|
113
|
+
const byId = new Map(rows.map(row => [row.githubId, row]))
|
|
114
|
+
const claims = new Map<string, typeof rows>()
|
|
115
|
+
for (const row of rows) claims.set(row.login.toLowerCase(), [...claims.get(row.login.toLowerCase()) ?? [], row])
|
|
116
|
+
const users: Record<string, AppUser> = {}
|
|
117
|
+
for (const { login, githubId } of accounts) {
|
|
118
|
+
const [user, ...others] = githubId === null ? claims.get(login.toLowerCase()) ?? [] : byId.has(githubId) ? [byId.get(githubId)!] : []
|
|
119
|
+
if (!user || user.role === 'none' || others.some(other => other.id !== user.id)) continue
|
|
120
|
+
users[login] = { id: user.id, login: user.appLogin, name: user.name }
|
|
121
|
+
}
|
|
122
|
+
return users
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function githubLinkMethods(db: ManagedSql): GithubLinkStore {
|
|
126
|
+
// Linking is the session user's own act: never an admin's view as them,
|
|
127
|
+
// nor an account that has not been approved for the workspace.
|
|
128
|
+
async function sessionUser(sessionId: string, now: number): Promise<{ id: string; githubId: number }> {
|
|
129
|
+
const row = await db.prepare(`SELECT u.id, u.github_user_id AS githubId, u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
|
|
130
|
+
WHERE s.id = ? AND s.expires_at > ? AND s.viewer_session IS NULL`).get(sessionId, now) as { id: string; githubId: number; role: string } | undefined
|
|
131
|
+
if (!row) throw new ManagedMutationError(401, 'unauthenticated')
|
|
132
|
+
if (row.role === 'none') throw new ManagedMutationError(403, 'forbidden')
|
|
133
|
+
return row
|
|
134
|
+
}
|
|
135
|
+
const linkOf = (userId: string, id: string) => db.prepare(`SELECT id, github_user_id AS githubId, login, name, linked_at AS linkedAt FROM managed_github_link
|
|
136
|
+
WHERE id = ? AND user_id = ?`).get(id, userId) as Promise<LinkRow | undefined>
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
async getProfile(userId) {
|
|
140
|
+
const user = await db.prepare(`SELECT login, name, github_name AS githubName, name_link AS nameAccount, avatar_link AS avatarAccount
|
|
141
|
+
FROM managed_user WHERE id = ?`).get(userId) as Omit<ManagedProfile, 'accounts'> | undefined
|
|
142
|
+
if (!user) return null
|
|
143
|
+
const accounts = await db.prepare(`SELECT id, github_user_id AS githubId, login, name, linked_at AS linkedAt FROM managed_github_link
|
|
144
|
+
WHERE user_id = ? ORDER BY linked_at, id`).all(userId) as LinkRow[]
|
|
145
|
+
return { ...user, accounts: accounts.map(accountOf) }
|
|
146
|
+
},
|
|
147
|
+
async linkGithubAccount(sessionId, account, now) {
|
|
148
|
+
const user = await sessionUser(sessionId, now)
|
|
149
|
+
if (account.githubUserId === user.githubId) throw new ManagedMutationError(409, 'github-account-own')
|
|
150
|
+
if (await db.prepare('SELECT 1 AS found FROM managed_user WHERE github_user_id = ?').get(account.githubUserId)) {
|
|
151
|
+
throw new ManagedMutationError(409, 'github-account-registered')
|
|
152
|
+
}
|
|
153
|
+
const existing = await db.prepare('SELECT id, user_id AS userId FROM managed_github_link WHERE github_user_id = ?')
|
|
154
|
+
.get(account.githubUserId) as { id: string; userId: string } | undefined
|
|
155
|
+
if (existing && existing.userId !== user.id) throw new ManagedMutationError(409, 'github-account-linked')
|
|
156
|
+
if (existing) {
|
|
157
|
+
await db.prepare('UPDATE managed_github_link SET login = ?, name = ? WHERE id = ?').run(account.login, account.name, existing.id)
|
|
158
|
+
await db.prepare('UPDATE managed_user SET name = ?, updated_at = ? WHERE id = ? AND name_link = ?').run(account.name, now, user.id, existing.id)
|
|
159
|
+
return { id: existing.id, created: false }
|
|
160
|
+
}
|
|
161
|
+
const id = randomUUID()
|
|
162
|
+
await db.prepare(`INSERT INTO managed_github_link (id, user_id, github_user_id, login, name, linked_at)
|
|
163
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(id, user.id, account.githubUserId, account.login, account.name, now)
|
|
164
|
+
return { id, created: true }
|
|
165
|
+
},
|
|
166
|
+
async unlinkGithubAccount(sessionId, id, now) {
|
|
167
|
+
const user = await sessionUser(sessionId, now)
|
|
168
|
+
const link = await linkOf(user.id, id)
|
|
169
|
+
if (!link) return null
|
|
170
|
+
await db.prepare(`UPDATE managed_user SET
|
|
171
|
+
name = CASE WHEN name_link = :link THEN github_name ELSE name END,
|
|
172
|
+
name_link = CASE WHEN name_link = :link THEN NULL ELSE name_link END,
|
|
173
|
+
avatar_link = CASE WHEN avatar_link = :link THEN NULL ELSE avatar_link END,
|
|
174
|
+
updated_at = :now
|
|
175
|
+
WHERE id = :user`).run({ link: id, now, user: user.id })
|
|
176
|
+
await db.prepare('DELETE FROM managed_github_link WHERE id = ?').run(id)
|
|
177
|
+
return accountOf(link)
|
|
178
|
+
},
|
|
179
|
+
async setProfileChoice(sessionId, choice, now) {
|
|
180
|
+
const user = await sessionUser(sessionId, now)
|
|
181
|
+
const links = new Map<string, LinkRow>()
|
|
182
|
+
for (const id of [choice.name, choice.avatar]) {
|
|
183
|
+
if (id == null || links.has(id)) continue
|
|
184
|
+
const link = await linkOf(user.id, id)
|
|
185
|
+
if (!link) throw new ManagedMutationError(404, 'no-account')
|
|
186
|
+
links.set(id, link)
|
|
187
|
+
}
|
|
188
|
+
if (choice.name !== undefined) {
|
|
189
|
+
// A linked account without a name leaves the user without one too.
|
|
190
|
+
await db.prepare(`UPDATE managed_user SET name_link = :link,
|
|
191
|
+
name = CASE WHEN :link IS NULL THEN github_name ELSE :name END, updated_at = :now
|
|
192
|
+
WHERE id = :user`).run({ link: choice.name, name: choice.name === null ? null : links.get(choice.name)!.name, now, user: user.id })
|
|
193
|
+
}
|
|
194
|
+
if (choice.avatar !== undefined) {
|
|
195
|
+
await db.prepare('UPDATE managed_user SET avatar_link = ?, updated_at = ? WHERE id = ?').run(choice.avatar, now, user.id)
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
async getAvatarKey(id) {
|
|
199
|
+
const row = await db.prepare('SELECT avatar_link AS link FROM managed_user WHERE id = ?').get(id) as { link: string | null } | undefined
|
|
200
|
+
return row?.link ?? id
|
|
201
|
+
},
|
|
202
|
+
listUsersByAccount: accounts => usersByAccount(db, accounts),
|
|
203
|
+
}
|
|
204
|
+
}
|