@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.51
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 +3 -0
- package/common/managed/finding-detail.js +66 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +7 -0
- package/common/managed/npm-packages.js +51 -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 +359 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +6 -0
- package/common/managed/team-app.js +47 -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-2K6UN3PC.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-6LNQTKAV.js +52 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7JBSSRD7.js +3 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FMXWXDR4.js +36 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HT2SSA3V.js +1 -0
- package/out/chunk-IPAH4LKF.js +232 -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-KOGNQDSJ.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-VDRXHIHN.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-WRAJVD35.js +1 -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 +364 -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 +23 -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 +76 -0
- package/server-managed/comparison.ts +52 -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 +468 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +143 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +98 -0
- package/server-managed/dependency-reviews.ts +364 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +376 -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 +136 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5503 -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/listed-activity.ts +39 -0
- package/server-managed/locked-packages.ts +152 -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 +225 -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-patch.ts +325 -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 +351 -0
- package/server-managed/pull-dependency-reviews.ts +334 -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 +448 -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 +166 -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 +212 -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 +149 -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 +319 -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,227 @@
|
|
|
1
|
+
import { type GithubFixRef, type GithubFixResult, type GithubFixStatus, 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 { GithubMetadata, GithubRepositoryVisibility } from './github-metadata.ts'
|
|
5
|
+
import { ensureUserAccessToken } from './github-oauth.ts'
|
|
6
|
+
|
|
7
|
+
type Metadata = Pick<GithubMetadata, 'title' | 'description' | 'status' | 'stateReason'>
|
|
8
|
+
const MAX_GITHUB_LOOKUPS = 200
|
|
9
|
+
const GITHUB_LOOKUP_TIMEOUT_MS = 10_000
|
|
10
|
+
const GITHUB_METADATA_TTL_MS = 60_000
|
|
11
|
+
const GITHUB_PUBLIC_VISIBILITY_TTL_MS = 60_000
|
|
12
|
+
const GITHUB_PRIVATE_REPOSITORIES_URL = 'https://api.github.com/user/repos?visibility=private&per_page=100&page=1'
|
|
13
|
+
|
|
14
|
+
type Repository = TeamReportAccessSnapshot['repositories'][number]
|
|
15
|
+
type RepositoryPayload = { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; permissions?: { pull?: unknown } }
|
|
16
|
+
type RepositoryChecks = { remaining: number; verified: Set<string> }
|
|
17
|
+
type RepositoryAccessContext = {
|
|
18
|
+
db: ManagedDb
|
|
19
|
+
visibility: GithubRepositoryVisibility[]
|
|
20
|
+
publicRepos: Set<string>
|
|
21
|
+
token: string | null
|
|
22
|
+
signal: AbortSignal
|
|
23
|
+
fetchImpl: typeof fetch
|
|
24
|
+
checks: RepositoryChecks
|
|
25
|
+
includePrivateList?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function githubRequest(token: string | null): RequestInit {
|
|
29
|
+
return {
|
|
30
|
+
headers: {
|
|
31
|
+
...(token ? { authorization: `Bearer ${token}` } : {}), accept: 'application/vnd.github+json',
|
|
32
|
+
'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28',
|
|
33
|
+
},
|
|
34
|
+
redirect: 'error',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function repositoryMatches(repo: Repository, body: RepositoryPayload | null): boolean {
|
|
39
|
+
return body?.id === repo.repoId && typeof body.full_name === 'string' && body.full_name.toLowerCase() === repo.github.toLowerCase()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function listPrivateRepositories(token: string, signal: AbortSignal, fetchImpl: typeof fetch): Promise<(RepositoryPayload | null)[]> {
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetchImpl(GITHUB_PRIVATE_REPOSITORIES_URL, githubRequest(token))
|
|
45
|
+
if (!response.ok) return []
|
|
46
|
+
const body = await response.json() as (RepositoryPayload | null)[] | null
|
|
47
|
+
return Array.isArray(body) && body.length <= 100 && !signal.aborted ? body : []
|
|
48
|
+
} catch { return [] } // A failed list is unknown access; direct checks can still verify it.
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Public visibility can be reused briefly across users. Private access stays
|
|
52
|
+
// request-local: one page of the viewer's own repo list grants matching repos,
|
|
53
|
+
// then direct checks cover omissions, pagination and failed list requests.
|
|
54
|
+
async function readableRepositories(repositories: Repository[], { db, visibility, publicRepos, token, signal, fetchImpl, checks, includePrivateList = true }: RepositoryAccessContext): Promise<Set<string>> {
|
|
55
|
+
const readable = new Set(publicRepos)
|
|
56
|
+
const byId = new Map(repositories.map(repo => [repo.repoId, repo]))
|
|
57
|
+
const unverified = repositories.filter(repo => !readable.has(repo.github))
|
|
58
|
+
const updates = new Map<number, GithubRepositoryVisibility>()
|
|
59
|
+
const grant = (repo: Repository, body: RepositoryPayload, checkedAt: number) => {
|
|
60
|
+
const isPublic = body.private === false && body.visibility === 'public'
|
|
61
|
+
readable.add(repo.github)
|
|
62
|
+
checks.verified.add(repo.github)
|
|
63
|
+
if (isPublic) publicRepos.add(repo.github)
|
|
64
|
+
updates.set(repo.repoId, { repoId: repo.repoId, github: repo.github, public: isPublic, checkedAt })
|
|
65
|
+
}
|
|
66
|
+
if (includePrivateList && token && unverified.length > 0 && !signal.aborted) {
|
|
67
|
+
const checkedAt = Date.now()
|
|
68
|
+
for (const entry of await listPrivateRepositories(token, signal, fetchImpl)) {
|
|
69
|
+
const repo = typeof entry?.id === 'number' ? byId.get(entry.id) : undefined
|
|
70
|
+
if (repo && !readable.has(repo.github) && repositoryMatches(repo, entry) && entry?.permissions?.pull === true) grant(repo, entry, checkedAt)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const formerlyPublic = new Set(visibility.filter(entry => entry.public
|
|
74
|
+
&& byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => entry.repoId))
|
|
75
|
+
const pending = unverified.filter(repo => !readable.has(repo.github) && (token || formerlyPublic.has(repo.repoId))).slice(0, checks.remaining)
|
|
76
|
+
checks.remaining -= pending.length
|
|
77
|
+
for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
|
|
78
|
+
await Promise.all(pending.slice(i, i + 4).map(async repo => {
|
|
79
|
+
if (signal.aborted) return
|
|
80
|
+
const checkedAt = Date.now()
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetchImpl(`https://api.github.com/repos/${repo.github}`, githubRequest(token))
|
|
83
|
+
if (!response.ok) return
|
|
84
|
+
const body = await response.json() as RepositoryPayload | null
|
|
85
|
+
if (!signal.aborted && body && repositoryMatches(repo, body)
|
|
86
|
+
&& (token || (body.private === false && body.visibility === 'public'))) grant(repo, body, checkedAt)
|
|
87
|
+
} catch { /* Unverified access must never expose cached metadata. */ }
|
|
88
|
+
}))
|
|
89
|
+
}
|
|
90
|
+
if (updates.size > 0) await db.setGithubRepositoryVisibility([...updates.values()])
|
|
91
|
+
return readable
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function verifyPublicRefreshes(config: ManagedConfig, userId: string, repositories: Repository[], context: RepositoryAccessContext): Promise<string | null> {
|
|
95
|
+
// Cached public metadata needs no credentials. New content uses a viewer
|
|
96
|
+
// token only after a live check, so privatization cannot publish private data
|
|
97
|
+
// under an old public grant. Anonymous reads remain available without a token.
|
|
98
|
+
const { db, publicRepos } = context
|
|
99
|
+
const token = context.token ?? await ensureUserAccessToken(config, db, userId, Date.now(), context.fetchImpl)
|
|
100
|
+
if (token) {
|
|
101
|
+
const currentPublic = new Set<string>()
|
|
102
|
+
const readable = await readableRepositories(repositories, { ...context, publicRepos: currentPublic, token, includePrivateList: false })
|
|
103
|
+
for (const repo of repositories) if (readable.has(repo.github) && !currentPublic.has(repo.github)) publicRepos.delete(repo.github)
|
|
104
|
+
}
|
|
105
|
+
return token
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ref.repo is always the canonical name from managed_selected_repo, never an
|
|
109
|
+
// owner/repo/path taken from a Fix URL. Redirects cannot move the user token.
|
|
110
|
+
async function fetchMetadata(ref: GithubFixRef, token: string | null, fetchImpl: typeof fetch): Promise<Metadata | null> {
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetchImpl(`https://api.github.com/repos/${ref.repo}/${ref.kind === 'pull' ? 'pulls' : 'issues'}/${ref.number}`, githubRequest(token))
|
|
113
|
+
if (!response.ok) return null
|
|
114
|
+
const body = await response.json() as { number?: unknown; title?: unknown; body?: unknown; state?: unknown; merged?: unknown;
|
|
115
|
+
draft?: unknown; base?: { repo?: { full_name?: unknown } }; repository_url?: unknown; pull_request?: unknown; state_reason?: unknown } | null
|
|
116
|
+
if (body?.number !== ref.number || typeof body.title !== 'string' || !body.title.trim() || body.title.length > 1024
|
|
117
|
+
|| !['open', 'closed'].includes(String(body.state)) || (body.body != null && typeof body.body !== 'string')) return null
|
|
118
|
+
let status: GithubFixStatus
|
|
119
|
+
if (ref.kind === 'pull') {
|
|
120
|
+
if (typeof body.merged !== 'boolean' || typeof body.base?.repo?.full_name !== 'string'
|
|
121
|
+
|| body.base.repo.full_name.toLowerCase() !== ref.repo.toLowerCase()) return null
|
|
122
|
+
status = body.merged ? 'merged' : body.state === 'closed' ? 'closed' : body.draft === true ? 'draft' : 'open'
|
|
123
|
+
} else {
|
|
124
|
+
// The issues API also accepts PR numbers. Do not cache a PR as a closed
|
|
125
|
+
// ordinary issue: only the pulls API can establish its merged state.
|
|
126
|
+
if (body.pull_request != null || typeof body.repository_url !== 'string'
|
|
127
|
+
|| body.repository_url.toLowerCase() !== `https://api.github.com/repos/${ref.repo}`.toLowerCase()) return null
|
|
128
|
+
status = body.state === 'closed' ? 'closed' : 'open'
|
|
129
|
+
}
|
|
130
|
+
const stateReason = ref.kind === 'issue' && status === 'closed' ? githubIssueClosedReason(body.state_reason) : null
|
|
131
|
+
return { title: body.title, description: body.body ?? null, status, stateReason }
|
|
132
|
+
} catch { return null }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The workspace handler checks user -> team membership and finding visibility.
|
|
136
|
+
// Shared private metadata also requires this viewer's live GitHub access,
|
|
137
|
+
// including for admins. Only recently verified public repos skip that check.
|
|
138
|
+
export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot: TeamReportAccessSnapshot, urls: string[], fetchImpl: typeof fetch = globalThis.fetch): Promise<GithubFixResult[]> {
|
|
139
|
+
const allowed = new Map(snapshot.repositories.filter(repo => isGithubRepoName(repo.github)).map(repo => [repo.github.toLowerCase(), repo]))
|
|
140
|
+
const parsed = urls.map(parseGithubFixUrl)
|
|
141
|
+
const jobs = new Map<string, GithubFixRef>()
|
|
142
|
+
const keys = parsed.map(ref => {
|
|
143
|
+
const repo = ref && allowed.get(ref.repo.toLowerCase())
|
|
144
|
+
if (!ref || !repo) return null
|
|
145
|
+
const key = `${repo.repoId}:${ref.kind}:${ref.number}`
|
|
146
|
+
jobs.set(key, { ...ref, repo: repo.github })
|
|
147
|
+
return key
|
|
148
|
+
})
|
|
149
|
+
if (jobs.size === 0) return []
|
|
150
|
+
const now = Date.now()
|
|
151
|
+
const signal = AbortSignal.timeout(GITHUB_LOOKUP_TIMEOUT_MS)
|
|
152
|
+
const fetchWithinDeadline: typeof fetch = (input, init) => {
|
|
153
|
+
signal.throwIfAborted()
|
|
154
|
+
return fetchImpl(input, { ...init, signal })
|
|
155
|
+
}
|
|
156
|
+
const repositories = [...new Map([...jobs.values()].map(ref => {
|
|
157
|
+
const repo = allowed.get(ref.repo.toLowerCase())!
|
|
158
|
+
return [repo.repoId, repo] as const
|
|
159
|
+
})).values()]
|
|
160
|
+
const visibility = await db.listGithubRepositoryVisibility(repositories.map(repo => repo.repoId))
|
|
161
|
+
const byId = new Map(repositories.map(repo => [repo.repoId, repo]))
|
|
162
|
+
const publicRepos = new Set(visibility.filter(entry => entry.public && entry.checkedAt <= now
|
|
163
|
+
&& entry.checkedAt + GITHUB_PUBLIC_VISIBILITY_TTL_MS > now
|
|
164
|
+
&& byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => byId.get(entry.repoId)!.github))
|
|
165
|
+
let token = repositories.some(repo => !publicRepos.has(repo.github))
|
|
166
|
+
? await ensureUserAccessToken(config, db, snapshot.user.id, now, fetchWithinDeadline) : null
|
|
167
|
+
const checks: RepositoryChecks = { remaining: MAX_GITHUB_LOOKUPS, verified: new Set() }
|
|
168
|
+
const context = { db, visibility, publicRepos, token, signal, fetchImpl: fetchWithinDeadline, checks }
|
|
169
|
+
const readable = await readableRepositories(repositories, context)
|
|
170
|
+
const authorized = [...jobs.entries()].filter(([, ref]) => readable.has(ref.repo))
|
|
171
|
+
const metadata = new Map((await db.listGithubMetadata(authorized.map(([key]) => key))).map(entry => [entry.key, entry]))
|
|
172
|
+
const lastCheckedAt = (key: string) => {
|
|
173
|
+
const cached = metadata.get(key)
|
|
174
|
+
return Math.max(cached?.fetchedAt ?? 0, cached?.attemptedAt ?? 0)
|
|
175
|
+
}
|
|
176
|
+
const pending = authorized.filter(([key, ref]) => {
|
|
177
|
+
const cached = metadata.get(key)
|
|
178
|
+
// Backfill closed issues cached before closure reasons existed, within the
|
|
179
|
+
// same authorized/capped queue. Even an unknown reason completes backfill.
|
|
180
|
+
return !cached || (ref.kind === 'issue' && cached.status === 'closed' && cached.stateReason == null)
|
|
181
|
+
|| (['open', 'draft'].includes(cached.status) && cached.fetchedAt + GITHUB_METADATA_TTL_MS <= now)
|
|
182
|
+
}).toSorted(([a], [b]) => lastCheckedAt(a) - lastCheckedAt(b)).slice(0, MAX_GITHUB_LOOKUPS)
|
|
183
|
+
// Only stale/missing, distinct, authorized entries consume the request budget.
|
|
184
|
+
// Prioritize missing/oldest entries so a large workspace makes progress.
|
|
185
|
+
if (pending.length > 0) {
|
|
186
|
+
const publicRefreshes = new Set(pending.filter(([, ref]) => publicRepos.has(ref.repo) && !checks.verified.has(ref.repo)).map(([, ref]) => ref.repo))
|
|
187
|
+
if (publicRefreshes.size > 0) token = await verifyPublicRefreshes(config, snapshot.user.id, repositories.filter(repo => publicRefreshes.has(repo.github)), context)
|
|
188
|
+
const fresh: Omit<GithubMetadata, 'attemptedAt'>[] = []
|
|
189
|
+
const attempted: string[] = []
|
|
190
|
+
// All waves and access checks share one deadline and four-call limit.
|
|
191
|
+
for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
|
|
192
|
+
await Promise.all(pending.slice(i, i + 4).map(async ([key, ref]) => {
|
|
193
|
+
if (signal.aborted) return
|
|
194
|
+
if (metadata.has(key)) attempted.push(key)
|
|
195
|
+
const result = await fetchMetadata(ref, publicRepos.has(ref.repo) && !checks.verified.has(ref.repo) ? null : token, fetchWithinDeadline)
|
|
196
|
+
if (result) fresh.push({ key, ...result, fetchedAt: Date.now() })
|
|
197
|
+
}))
|
|
198
|
+
}
|
|
199
|
+
// Only started cached reads rotate. Missing entries retain priority, and
|
|
200
|
+
// jobs skipped by the cap, deadline or absent token keep their place.
|
|
201
|
+
if (attempted.length > 0) await db.recordGithubMetadataAttempts(attempted, Date.now())
|
|
202
|
+
if (fresh.length > 0) {
|
|
203
|
+
await db.setGithubMetadata(fresh)
|
|
204
|
+
// A concurrent merged result must win over a slower open/closed read.
|
|
205
|
+
for (const entry of await db.listGithubMetadata(fresh.map(row => row.key))) metadata.set(entry.key, entry)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (publicRepos.size > 0) {
|
|
209
|
+
// Another request may already have discovered a public -> private change
|
|
210
|
+
// and written new private metadata. Honor that newer visibility observation.
|
|
211
|
+
const current = await db.listGithubRepositoryVisibility(repositories.filter(repo => publicRepos.has(repo.github)).map(repo => repo.repoId))
|
|
212
|
+
const stillPublic = new Set(current.filter(entry => entry.public
|
|
213
|
+
&& byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => byId.get(entry.repoId)!.github))
|
|
214
|
+
for (const [key, ref] of authorized) if (publicRepos.has(ref.repo) && !stillPublic.has(ref.repo)) metadata.delete(key)
|
|
215
|
+
}
|
|
216
|
+
// The handler rechecks workspace access and saved Fix links before returning.
|
|
217
|
+
return urls.flatMap((url, index): GithubFixResult[] => {
|
|
218
|
+
const key = keys[index]
|
|
219
|
+
if (!key) return []
|
|
220
|
+
const found = metadata.get(key)
|
|
221
|
+
return [found ? { url, title: found.title, description: found.description, status: found.status, stateReason: found.stateReason } : { url, error: 'unavailable' }]
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function storedFixUrls(fixes: string[]): string[] {
|
|
226
|
+
return [...new Set(fixes.filter(fix => parseGithubFixUrl(fix)))].toSorted()
|
|
227
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { GithubApiError, githubUnreachable, throwGithubResponseError } from './github-errors.ts'
|
|
3
|
+
import { timed } from './request-timing.ts'
|
|
4
|
+
|
|
5
|
+
const API_VERSION = '2022-11-28'
|
|
6
|
+
export const USER_AGENT = 'deepview-triage'
|
|
7
|
+
|
|
8
|
+
// Only outstanding reads: a completed answer (or failure) is never reused.
|
|
9
|
+
// Keep transports and credentials separate, including anonymous reads. When
|
|
10
|
+
// full, new reads proceed normally rather than waiting for an unrelated one.
|
|
11
|
+
const MAX_PENDING_READS = 1000
|
|
12
|
+
type PendingRead = { promise: Promise<unknown>; shared: boolean }
|
|
13
|
+
const pendingReads = new WeakMap<typeof fetch, Map<string, PendingRead>>()
|
|
14
|
+
|
|
15
|
+
// Invalidate installation credentials and access verdicts when GitHub rejects
|
|
16
|
+
// a token, including when several callers share the rejected request.
|
|
17
|
+
const rejectedTokenListeners = new Set<(token: string, fetchImpl: typeof fetch) => void>()
|
|
18
|
+
export function onTokenRejected(listener: (token: string, fetchImpl: typeof fetch) => void): void {
|
|
19
|
+
rejectedTokenListeners.add(listener)
|
|
20
|
+
}
|
|
21
|
+
export function tokenRejected(token: string, fetchImpl: typeof fetch): void {
|
|
22
|
+
for (const listener of rejectedTokenListeners) listener(token, fetchImpl)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function shareGithubRead<T>(fetchImpl: typeof fetch, request: string, read: () => Promise<T>): Promise<T> {
|
|
26
|
+
let pending = pendingReads.get(fetchImpl)
|
|
27
|
+
if (!pending) { pending = new Map(); pendingReads.set(fetchImpl, pending) }
|
|
28
|
+
// The request includes credentials and query variables. Retain only its hash.
|
|
29
|
+
const key = createHash('sha256').update(request).digest('hex')
|
|
30
|
+
const held = pending.get(key)
|
|
31
|
+
if (held) {
|
|
32
|
+
held.shared = true
|
|
33
|
+
return structuredClone(await held.promise) as T
|
|
34
|
+
}
|
|
35
|
+
if (pending.size >= MAX_PENDING_READS) return read()
|
|
36
|
+
const entry: PendingRead = { promise: read(), shared: false }
|
|
37
|
+
pending.set(key, entry)
|
|
38
|
+
try {
|
|
39
|
+
const value = await entry.promise
|
|
40
|
+
// Independent objects for coalesced callers, without copying large trees
|
|
41
|
+
// or histories when nobody joined the read.
|
|
42
|
+
return (entry.shared ? structuredClone(value) : value) as T
|
|
43
|
+
} finally { pending.delete(key) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// GitHub's answers to GETs that came with an ETag, by the request (its URL
|
|
47
|
+
// and credential, hashed as outstanding reads are): the next asked again
|
|
48
|
+
// If-None-Match, GitHub checking the credential as ever, and a 304, which
|
|
49
|
+
// it doesn't count against the rate limit, answered by the body kept. Each
|
|
50
|
+
// transport's own, as many as MAX_TAGGED_READS and MAX_TAGGED_BYTES hold,
|
|
51
|
+
// the least recently used first to go; none larger than MAX_TAGGED_READ_BYTES.
|
|
52
|
+
const MAX_TAGGED_READS = 1000
|
|
53
|
+
const MAX_TAGGED_BYTES = 32 * 1024 * 1024
|
|
54
|
+
const MAX_TAGGED_READ_BYTES = 1024 * 1024
|
|
55
|
+
type TaggedRead = { etag: string; text: string }
|
|
56
|
+
const taggedReads = new WeakMap<typeof fetch, { reads: Map<string, TaggedRead>; bytes: number }>()
|
|
57
|
+
const taggedOf = (fetchImpl: typeof fetch) => {
|
|
58
|
+
let tagged = taggedReads.get(fetchImpl)
|
|
59
|
+
if (!tagged) taggedReads.set(fetchImpl, tagged = { reads: new Map(), bytes: 0 })
|
|
60
|
+
return tagged
|
|
61
|
+
}
|
|
62
|
+
const requestKey = (request: string) => createHash('sha256').update(request).digest('hex')
|
|
63
|
+
|
|
64
|
+
// The answer kept to `request`, its ETag and body as GitHub wrote it.
|
|
65
|
+
export function taggedRead(fetchImpl: typeof fetch, request: string): TaggedRead | undefined {
|
|
66
|
+
const { reads } = taggedOf(fetchImpl)
|
|
67
|
+
const key = requestKey(request)
|
|
68
|
+
const read = reads.get(key)
|
|
69
|
+
if (read) { reads.delete(key); reads.set(key, read) }
|
|
70
|
+
return read
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// `text` kept as GitHub's answer to `request`, by its ETag.
|
|
74
|
+
export function keepTaggedRead(fetchImpl: typeof fetch, request: string, etag: string, text: string): void {
|
|
75
|
+
const tagged = taggedOf(fetchImpl)
|
|
76
|
+
const key = requestKey(request)
|
|
77
|
+
const before = tagged.reads.get(key)
|
|
78
|
+
if (before) { tagged.reads.delete(key); tagged.bytes -= before.text.length }
|
|
79
|
+
if (text.length > MAX_TAGGED_READ_BYTES) return
|
|
80
|
+
tagged.reads.set(key, { etag, text })
|
|
81
|
+
tagged.bytes += text.length
|
|
82
|
+
for (const [oldest, read] of tagged.reads) {
|
|
83
|
+
if (tagged.reads.size <= MAX_TAGGED_READS && tagged.bytes <= MAX_TAGGED_BYTES) break
|
|
84
|
+
tagged.reads.delete(oldest)
|
|
85
|
+
tagged.bytes -= read.text.length
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One GitHub API call returning parsed JSON, optionally Bearer-authed by a
|
|
90
|
+
// user token, App JWT, or installation token, `body` sent as JSON (a GraphQL
|
|
91
|
+
// query's, given longer to answer). Network / non-2xx / malformed fold into a
|
|
92
|
+
// GithubApiError (401 passes through for the caller to handle). Identical GETs
|
|
93
|
+
// share an outstanding read through its body; one GitHub answered with an
|
|
94
|
+
// ETag is asked again If-None-Match, its body kept answering a 304
|
|
95
|
+
// (taggedRead). POSTs only opt in for read-only GraphQL
|
|
96
|
+
// queries (repository-history.ts), never for mutations or token mints.
|
|
97
|
+
export function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
|
|
98
|
+
readOnly = method === 'GET'): Promise<unknown> {
|
|
99
|
+
if (readOnly) return shareGithubRead(fetchImpl, JSON.stringify([url, token, method, body]), () => githubJson(url, token, fetchImpl, method, body, false))
|
|
100
|
+
const request = method === 'GET' && body === undefined ? JSON.stringify([url, token]) : null
|
|
101
|
+
const tagged = request === null ? undefined : taggedRead(fetchImpl, request)
|
|
102
|
+
// Each call told in its request's Server-Timing (request-timing.ts).
|
|
103
|
+
return timed('gh', async () => {
|
|
104
|
+
let res: Response
|
|
105
|
+
const started = Date.now()
|
|
106
|
+
try {
|
|
107
|
+
res = await fetchImpl(url, {
|
|
108
|
+
method,
|
|
109
|
+
signal: AbortSignal.timeout(body === undefined ? 10_000 : 20_000),
|
|
110
|
+
redirect: 'error',
|
|
111
|
+
headers: {
|
|
112
|
+
...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
|
|
113
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }), 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
|
|
114
|
+
...(tagged ? { 'if-none-match': tagged.etag } : {}),
|
|
115
|
+
},
|
|
116
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
117
|
+
})
|
|
118
|
+
} catch (error) { throw githubUnreachable(error, started) }
|
|
119
|
+
if (res.status === 401 && token) tokenRejected(token, fetchImpl)
|
|
120
|
+
if (res.status === 304 && tagged) return JSON.parse(tagged.text) as unknown
|
|
121
|
+
if (!res.ok) await throwGithubResponseError(res)
|
|
122
|
+
// The answer's body is read within the same timeout: one not read whole is
|
|
123
|
+
// GitHub not reached, as one not answered is; only one that isn't JSON is
|
|
124
|
+
// malformed.
|
|
125
|
+
const etag = request === null ? null : res.headers?.get('etag') ?? null
|
|
126
|
+
try {
|
|
127
|
+
if (etag === null) return await res.json()
|
|
128
|
+
const text = await res.text()
|
|
129
|
+
const value = JSON.parse(text) as unknown
|
|
130
|
+
keepTaggedRead(fetchImpl, request!, etag, text)
|
|
131
|
+
return value
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw error instanceof SyntaxError ? new GithubApiError(502, 'github-malformed') : githubUnreachable(error, started)
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The app's users and agents where a repository's activity names their
|
|
2
|
+
// GitHub accounts (GET /api/admin/repositories/history, issues, item, agents
|
|
3
|
+
// and pulse): of the accounts an answer names (common/managed/github-logins.ts),
|
|
4
|
+
// the agents' (common/managed/github-agents.ts), and those an approved user
|
|
5
|
+
// signs in with or linked to theirs (github-links.ts), each as that user
|
|
6
|
+
// shows in the app, linked accounts as their user; each by its numeric id
|
|
7
|
+
// where the answer names it, else by its login. Resolved as each answer is
|
|
8
|
+
// sent, never kept with it, and only for the accounts it names: a reader is
|
|
9
|
+
// never sent a list of users.
|
|
10
|
+
import { type GithubAgentId, githubAgent } from '../common/managed/github-agents.ts'
|
|
11
|
+
import { namedAccounts } from '../common/managed/github-logins.ts'
|
|
12
|
+
import type { ManagedDb } from './db.ts'
|
|
13
|
+
import type { AppUser } from './github-links.ts'
|
|
14
|
+
|
|
15
|
+
// An agent, as the app draws it (GITHUB_AGENTS).
|
|
16
|
+
export interface AppAgentUser { agent: GithubAgentId }
|
|
17
|
+
|
|
18
|
+
// `answer` with the app's users and agents its accounts are (`users`, by
|
|
19
|
+
// login), as a new object: what was kept of it, every reader's, is left as
|
|
20
|
+
// it was. A login it names by more than one account is no one's.
|
|
21
|
+
export async function withAppUsers<T extends object>(db: ManagedDb, answer: T): Promise<T & { users: Record<string, AppUser | AppAgentUser> }> {
|
|
22
|
+
const agents: Record<string, AppAgentUser> = {}
|
|
23
|
+
const accounts = [...namedAccounts(answer)].flatMap(([login, githubId]) => {
|
|
24
|
+
if (githubId === 'ambiguous') return []
|
|
25
|
+
const agent = githubAgent(login, githubId)
|
|
26
|
+
if (agent) agents[login] = { agent }
|
|
27
|
+
return agent ? [] : [{ login, githubId }]
|
|
28
|
+
})
|
|
29
|
+
return { ...answer, users: { ...await db.listUsersByAccount(accounts), ...agents } }
|
|
30
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { pipeline } from 'node:stream/promises'
|
|
4
|
+
import { createBrotliCompress, constants as zlibConstants } from 'node:zlib'
|
|
5
|
+
import { UPLOAD_CHUNK_BYTES } from './uploads.ts'
|
|
6
|
+
|
|
7
|
+
// Keep the header ASCII while filename* carries the original UTF-8 name.
|
|
8
|
+
export function attachmentDisposition(filename: string): string {
|
|
9
|
+
const fallback = filename.replaceAll(/[^\u0020-\u007E]|["\\%]/gu, '_')
|
|
10
|
+
const encoded = encodeURIComponent(filename.toWellFormed()).replaceAll(/['()*]/gu, ch => `%${ch.codePointAt(0)!.toString(16).toUpperCase()}`)
|
|
11
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Writing body chunks enables the function's streamed-response path. Data is
|
|
15
|
+
// already materialized by the authorization/filtering layer before we get here.
|
|
16
|
+
export function writeResponse(res: ServerResponse, body: string | Buffer): void {
|
|
17
|
+
const bytes = typeof body === 'string' ? Buffer.from(body) : body
|
|
18
|
+
writeResponseParts(res, [bytes], bytes.length)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A body of `length` bytes held in parts, written as writeResponse writes
|
|
22
|
+
// one without first copying them into one buffer.
|
|
23
|
+
export function writeResponseParts(res: ServerResponse, parts: Buffer[], length: number): void {
|
|
24
|
+
if (length <= UPLOAD_CHUNK_BYTES) { res.end(parts.length === 1 ? parts[0] : Buffer.concat(parts, length)); return }
|
|
25
|
+
for (const part of parts) {
|
|
26
|
+
for (let offset = 0; offset < part.length; offset += 64 * 1024) res.write(part.subarray(offset, offset + 64 * 1024))
|
|
27
|
+
}
|
|
28
|
+
res.end()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sendJson(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
|
|
32
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers })
|
|
33
|
+
writeResponse(res, JSON.stringify(body))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Lines of JSON, Brotli-encoded and sent as each is written, so the reader
|
|
37
|
+
// has each while the next is made. A reader gone, or no longer `allowed`
|
|
38
|
+
// before a line, has the response cut short, as is one whose lines fail: a
|
|
39
|
+
// response that ends is one with every line.
|
|
40
|
+
export async function sendLines(res: ServerResponse, lines: AsyncIterable<unknown>, allowed: () => Promise<boolean>) {
|
|
41
|
+
const out = openLines(res)
|
|
42
|
+
try {
|
|
43
|
+
for await (const line of lines) {
|
|
44
|
+
if (res.destroyed || !await allowed()) { out.cut(); return }
|
|
45
|
+
await out.write(line)
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.warn('managed: a streamed response failed:', err)
|
|
49
|
+
out.cut()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
await out.end()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A response of lines of JSON as sendLines sends them, written as its caller
|
|
56
|
+
// has each: `write` sends one, flushed, false where the reader is gone; `end`
|
|
57
|
+
// ends it with every line; `cut` ends it short.
|
|
58
|
+
export function openLines(res: ServerResponse) {
|
|
59
|
+
res.writeHead(200, { 'content-type': 'application/x-ndjson', 'content-encoding': 'br', 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' })
|
|
60
|
+
const encoder = createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } })
|
|
61
|
+
const sent = pipeline(encoder, res).catch(() => {})
|
|
62
|
+
const closed = new Promise(resolve => { res.once('close', resolve) })
|
|
63
|
+
return {
|
|
64
|
+
async write(line: unknown): Promise<boolean> {
|
|
65
|
+
if (res.destroyed) return false
|
|
66
|
+
encoder.write(`${JSON.stringify(line)}\n`)
|
|
67
|
+
await Promise.race([new Promise<void>(resolve => { encoder.flush(() => { resolve() }) }), closed])
|
|
68
|
+
return !res.destroyed
|
|
69
|
+
},
|
|
70
|
+
async end() {
|
|
71
|
+
encoder.end()
|
|
72
|
+
await sent
|
|
73
|
+
},
|
|
74
|
+
cut() { res.destroy() },
|
|
75
|
+
}
|
|
76
|
+
}
|