@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.39
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/README.md +16 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +6 -2
- package/common/bundle-metadata.js +63 -20
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-source-links.js +7 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +3 -1
- package/common/bundle-sources.js +47 -19
- package/common/code-language.js +16 -2
- package/common/default-scan-models.ts +2 -1
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/github-pr.ts +1 -1
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +1 -5
- package/common/managed/routes.js +123 -12
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +1 -1
- 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/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-45WIN3SW.js +1 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-6C76GAXK.js +1 -0
- package/out/chunk-7ZKDKGAH.js +1 -0
- package/out/chunk-ABW5FVSN.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-F4OMDNEY.js +1 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GMFXFEUA.js +2 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-HIV7SVI7.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-JVBB4LZX.js +1 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-QSLWGJBQ.js +223 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-TINZZKP5.js +33 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-XNGEQO7Y.js +2 -0
- package/out/chunk-XRWAV3DX.js +7 -0
- package/out/chunk-XYDGU6BM.js +51 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZPD5R5RP.js +3 -0
- package/out/client-managed-import.js +3 -87
- package/out/client-managed.js +26 -338
- package/out/client-sync.js +8 -14
- package/out/graph.js +17 -30
- package/out/index.html +34 -0
- package/out/prism.js +6 -2
- package/out/terminal.js +241 -257
- package/out/view.css +1 -1
- package/out/view.js +76 -520
- package/package.json +89 -7
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +171 -0
- package/risk/files/runs-code.js +358 -0
- package/risk/files/script.js +114 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +142 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +219 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +228 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-e2e/http.ts +7 -7
- package/server-e2e/npm-proxy.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
- package/server-e2e/objstore/handlers.ts +1 -1
- package/server-e2e/objstore/tokens.ts +1 -1
- package/server-e2e/sign.ts +1 -1
- package/server-managed/bundle-advisories.ts +74 -12
- package/server-managed/bundle-cache.ts +37 -4
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-summary-cache.ts +24 -10
- package/server-managed/config.ts +4 -0
- package/server-managed/db-methods.ts +98 -11
- package/server-managed/db-neon.ts +39 -4
- package/server-managed/db-schema.ts +14 -2
- package/server-managed/db-table-names.ts +1 -1
- package/server-managed/db.ts +7 -3
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +9 -3
- package/server-managed/github-errors.ts +21 -2
- package/server-managed/github-issue-oauth.ts +5 -6
- package/server-managed/github-metadata.ts +115 -0
- package/server-managed/github-oauth.ts +38 -2
- package/server-managed/http-response.ts +26 -0
- package/server-managed/http.ts +952 -53
- package/server-managed/index.ts +11 -5
- package/server-managed/kept.ts +28 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +78 -7
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/report-sources.ts +13 -2
- package/server-managed/repository-browser.ts +19 -5
- package/server-managed/repository-risk.ts +418 -0
- package/server-managed/session.ts +16 -2
- package/server-managed/sql.ts +5 -2
- package/server-managed/storage-encryption.ts +5 -3
- package/server-managed/storage-payload.ts +4 -0
- package/server-managed/storage-stores.ts +2 -0
- package/server-managed/storage.ts +6 -3
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-feed.ts +1 -1
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +25 -4
- package/server-managed/upstream-cache.ts +147 -5
- package/server-managed/view-as.ts +58 -0
package/server-managed/http.ts
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
// GET /api/admin/repositories/impact → admin attached data summary
|
|
26
26
|
// GET /api/admin/repositories/{browsable,refs,contents} → admin|manage + GitHub read access
|
|
27
27
|
// POST /api/admin/repositories/remove → admin permanently removes a repo
|
|
28
|
+
// GET /api/admin/repositories/risk[?team=|?repo=] → admin: each connected repo's npm lockfiles, or a team's or one repo's,
|
|
29
|
+
// their packages and what risk tells of each, Brotli-encoded ndjson as each is found; the reader leaving stops it | 400/401/403/404
|
|
28
30
|
// GET /api/admin/reports → admin|manage list of uploaded reports | 401/403
|
|
29
31
|
// POST /api/admin/reports → admin|manage uploads a report (raw body) | 401/403/413
|
|
30
32
|
// GET /api/admin/reports/<id> → admin|manage downloads a stored report | 401/403/404
|
|
@@ -34,11 +36,21 @@
|
|
|
34
36
|
// GET /api/bundles/<id>/{metadata,contents} → authorized encoded bytes | 401/404/422
|
|
35
37
|
// GET /api/bundles/<id>/advisories → published dependency advisories (security access) | 401/403/404
|
|
36
38
|
// GET /api/bundles/<id>/download → authorized original upload | 401/404
|
|
39
|
+
// GET /api/bundles/<id>/pretty?path=&hash= → one of its files pretty-printed, Brotli-encoded | 400/401/404/409/413/422/429
|
|
40
|
+
// GET /api/bundles/<id>/pretty?other= → the files that changed between it and another bundle, both sides' pretty-printed,
|
|
41
|
+
// Brotli-encoded ndjson | 400/401/404/422/503
|
|
42
|
+
// GET /api/bundles/<id>/rename-diff?path=&hash=&other=&otherPath=&otherHash= → a JavaScript file diffed with another
|
|
43
|
+
// bundle's, both pretty-printed, renamed names hidden, whitespace changes shown and hidden | 400/401/404/409/413/422/429/504
|
|
44
|
+
// GET /api/bundles/<id>/rename-diff?other=&scope= → every pair of its JavaScript files and another bundle's the viewer
|
|
45
|
+
// diffs so, within a reason scope where given, Brotli-encoded ndjson | 400/401/404/422/503
|
|
46
|
+
// GET /api/bundles/<id>/type-only?other=&scope= → the files that changed between it and another bundle in types alone, as
|
|
47
|
+
// @preventive/compare proves them, within a reason scope where given, Brotli-encoded ndjson | 400/401/404/422/503
|
|
37
48
|
// GET /api/admin/bundles → admin all bundles; managers own/team bundles | 401/403
|
|
38
49
|
// POST /api/admin/bundles → admin|manage uploads a bundle (raw body) | 401/403/413
|
|
39
50
|
// GET /api/admin/bundles/<id> → admin|manage downloads a stored bundle | 401/403/404
|
|
40
51
|
// DELETE /api/admin/bundles/<id> → admin|manage deletes a bundle | 401/403/404
|
|
41
52
|
// POST /api/admin/bundles/set-repo → admin|manage attaches/detaches a bundle's repo | 401/403/404
|
|
53
|
+
// GET /api/admin/uploads/key → admin|manage public key for sealed upload bodies | 401/403
|
|
42
54
|
// GET /api/admin/teams → admin teams (+ members/repos) + pickers | 401/403
|
|
43
55
|
// POST /api/admin/teams → admin creates a team | 401/403/409
|
|
44
56
|
// POST /api/admin/teams/rename → admin renames a team | 401/403/404/409
|
|
@@ -46,23 +58,53 @@
|
|
|
46
58
|
// POST /api/admin/teams/delete → admin deletes a team | 401/403/404
|
|
47
59
|
// POST /api/admin/teams/{set,remove}-repo → admin links/unlinks a repo (+path) | 401/403/404
|
|
48
60
|
// POST /api/admin/teams/{set,remove}-member → admin links/unlinks a user (+perms) | 401/403/404
|
|
49
|
-
// POST /api/
|
|
61
|
+
// POST /api/admin/teams/set-dependency-review → admin toggles dependency review publication for a team | 400/401/403/404
|
|
62
|
+
// POST /api/admin/teams/set-npm-scopes → admin replaces a team's npm scopes | 400/401/403/404
|
|
63
|
+
// GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set | 400/401/403/404/409
|
|
64
|
+
// POST /api/npm/reviews/discard → author discards an unpublished review and its file rows | 400/401/403/409
|
|
65
|
+
// GET /api/npm/reviews/latest → latest own review for exact target bytes, across all bases | 400/401/403
|
|
66
|
+
// GET /api/npm/package?name=&version= → a published version's manifest and files | 400/401/404/413/429/502
|
|
67
|
+
// GET /api/npm/versions?name= → its dist-tags, versions, when each was published and its first was, and whether a team lists its scope | 400/401/404/429/502
|
|
68
|
+
// GET /api/npm/download?name=&version= → its tarball | 400/401/404/413/429/502
|
|
69
|
+
// GET /api/npm/stats?name= → its downloads over the last year and its GitHub repository's figures | 400/401/404/429/502
|
|
70
|
+
// GET /api/npm/advisories?name= → its advisories across every published version, npm's and its repository's | 400/401/404/429/502
|
|
71
|
+
// GET /api/npm/tags?name=&version= → the tags pointing to its publish commit | 400/401/404/429/502
|
|
72
|
+
// GET /api/npm/socket?name=&version= → Socket's scores and alerts for a public version | 400/401/404/429/502
|
|
73
|
+
// GET /api/npm/provenance?name=&version= → npm's provenance for a public version | 400/401/404/429/502
|
|
74
|
+
// GET /api/npm/pretty?name=&version= → every one of its files that needs it pretty-printed, Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
75
|
+
// GET /api/npm/pretty?name=&version=&otherVersion= → the files that changed between it and another version, both sides'
|
|
76
|
+
// pretty-printed, Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
77
|
+
// GET /api/npm/pretty?name=&version=&path=&hash= → one of its files pretty-printed, Brotli-encoded | 400/401/404/409/413/422/429/502/503
|
|
78
|
+
// GET /api/npm/rename-diff?name=&version=&path=&hash=&otherVersion=&otherPath=&otherHash= → a JavaScript file diffed with
|
|
79
|
+
// another version's, both pretty-printed, renamed names hidden, whitespace changes shown and hidden | 400/401/404/409/413/422/429/502/503/504
|
|
80
|
+
// GET /api/npm/rename-diff?name=&version=&otherVersion= → every pair of their JavaScript files the viewer diffs so,
|
|
81
|
+
// Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
82
|
+
// GET /api/npm/type-only?name=&version=&otherVersion= → the files that changed between two versions in types alone,
|
|
83
|
+
// Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
84
|
+
// GET /api/npm/risk?name=&version=&otherVersion= → what calls for a look at a version, as risk tells it, against another where given | 400/401/404/413/429/502
|
|
85
|
+
// POST /api/auth/logout → same-origin + CSRF, drops the session (and any view)
|
|
86
|
+
// POST /api/auth/view-as → admin opens a read-only view as another user | 400/401/403/404
|
|
87
|
+
// DELETE /api/auth/view-as → ends the view (the view's CSRF token)
|
|
50
88
|
import { mergeLinkGroups } from './link-reports.ts'
|
|
51
89
|
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
52
90
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
53
91
|
import { pipeline } from 'node:stream/promises'
|
|
92
|
+
import { npmVersionStore, riskLineStore } from './npm-kept.ts'
|
|
54
93
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
55
|
-
import { auditCache } from './upstream-cache.ts'
|
|
94
|
+
import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
|
|
56
95
|
import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
|
|
57
|
-
import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, putUploadPart, readUpload, validUpload, validUploadPart } from './uploads.ts'
|
|
96
|
+
import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, newUploadKey, openSessionUpload, putUploadPart, readUpload, uploadPublicKey, validUpload, validUploadPart } from './uploads.ts'
|
|
97
|
+
import { UPLOAD_SEAL_HEADER, maxSealedBytes } from '../common/managed/upload-seal.ts'
|
|
58
98
|
import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
99
|
+
import type { CacheStorage } from './cache-storage.ts'
|
|
59
100
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
101
|
+
import { type CatalogCommit, backfillBundleCommits, bundleCommits, cacheCommitDetails } from './bundle-commits.ts'
|
|
60
102
|
import { contentAccess } from './content-access.ts'
|
|
61
103
|
import type { BundleStore } from './bundle-store.ts'
|
|
62
104
|
import { Buffer } from 'node:buffer'
|
|
63
105
|
import { createHash, randomUUID } from 'node:crypto'
|
|
64
106
|
import type { AvatarStore } from './avatar-store.ts'
|
|
65
|
-
import type { BlobStore } from './blob-store.ts'
|
|
107
|
+
import type { BlobStore, OpenedBlob } from './blob-store.ts'
|
|
66
108
|
import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
|
|
67
109
|
import { resolveRepositoryImportLocation } from './repository-aliases.ts'
|
|
68
110
|
import type { ManagedConfig } from './config.ts'
|
|
@@ -79,13 +121,18 @@ import type { ReportSourcesCache } from './report-sources.ts'
|
|
|
79
121
|
import { normalizeTeamPath } from './repo-path.ts'
|
|
80
122
|
import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
|
|
81
123
|
import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
|
|
124
|
+
import { bundleCommitHash } from '../common/bundle-commit.js'
|
|
82
125
|
import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
|
|
126
|
+
import { upstreamGithub } from './github-errors.ts'
|
|
127
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
83
128
|
import type { ConnectedRepo } from './github-app.ts'
|
|
84
129
|
import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
|
|
85
130
|
import { RepositoryDiscovery } from './repository-discovery.ts'
|
|
131
|
+
import { RISK_KEPT_MS, repositoryRiskLines } from './repository-risk.ts'
|
|
86
132
|
import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
|
|
87
133
|
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
88
134
|
import { clearCookie, endSession, readSession } from './session.ts'
|
|
135
|
+
import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
|
|
89
136
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
90
137
|
import { acceptsReportMetadata } from './report-response.ts'
|
|
91
138
|
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
@@ -95,14 +142,28 @@ import { lookupIssueLinks } from './github-issue-links.ts'
|
|
|
95
142
|
import { visibleManagedIssues } from './managed-issues.ts'
|
|
96
143
|
import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
|
|
97
144
|
import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
|
|
98
|
-
import { attachmentDisposition, sendJson, writeResponse } from './http-response.ts'
|
|
145
|
+
import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
|
|
99
146
|
import { triageWireEntry } from './triage-response.ts'
|
|
100
147
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
101
148
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
149
|
+
import { teamAppKey, teamAppStates } from './team-app.ts'
|
|
102
150
|
import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
103
151
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
104
152
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
105
153
|
import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
|
|
154
|
+
import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
|
|
155
|
+
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
156
|
+
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
157
|
+
import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
|
|
158
|
+
import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
|
|
159
|
+
import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
|
|
160
|
+
import { npmProvenance } from './npm-provenance.ts'
|
|
161
|
+
import { npmRisk, npmRiskRemoved } from './npm-risk.ts'
|
|
162
|
+
import { NpmRiskError } from '../risk/index.js'
|
|
163
|
+
import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
|
|
164
|
+
import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
|
|
165
|
+
import { typeOnlyFiles } from './type-only.ts'
|
|
166
|
+
import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
106
167
|
|
|
107
168
|
const SESSION_PATH = '/api/auth/session'
|
|
108
169
|
const AVATAR_PREFIX = '/api/avatar/'
|
|
@@ -120,6 +181,7 @@ const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
|
|
|
120
181
|
const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
|
|
121
182
|
const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
|
|
122
183
|
const REMOVE_REPO_PATH = '/api/admin/repositories/remove'
|
|
184
|
+
const REPO_RISK_PATH = '/api/admin/repositories/risk'
|
|
123
185
|
const ADMIN_REPORTS_PATH = '/api/admin/reports'
|
|
124
186
|
const REPORT_SET_REPO_PATH = '/api/admin/reports/set-repo'
|
|
125
187
|
const REPORT_SET_VISIBLE_PATH = '/api/admin/reports/set-visible'
|
|
@@ -131,6 +193,7 @@ const BUNDLE_SET_VISIBLE_PATH = '/api/admin/bundles/set-visible'
|
|
|
131
193
|
const BUNDLE_PREFIX = '/api/admin/bundles/'
|
|
132
194
|
const MY_TEAMS_PATH = '/api/teams'
|
|
133
195
|
const MY_REPORT_PREFIX = '/api/reports/'
|
|
196
|
+
const REPORT_QUERY_PATH = '/api/reports/query'
|
|
134
197
|
const MY_REPORT_TRIAGE_SUFFIX = '/triage'
|
|
135
198
|
const MY_REPORT_TRIAGE_HISTORY_SUFFIX = '/triage/history'
|
|
136
199
|
const ADMIN_TEAMS_PATH = '/api/admin/teams'
|
|
@@ -141,7 +204,41 @@ const TEAM_SET_REPO_PATH = '/api/admin/teams/set-repo'
|
|
|
141
204
|
const TEAM_REMOVE_REPO_PATH = '/api/admin/teams/remove-repo'
|
|
142
205
|
const TEAM_SET_MEMBER_PATH = '/api/admin/teams/set-member'
|
|
143
206
|
const TEAM_REMOVE_MEMBER_PATH = '/api/admin/teams/remove-member'
|
|
207
|
+
const TEAM_SET_NPM_SCOPES_PATH = '/api/admin/teams/set-npm-scopes'
|
|
208
|
+
const NPM_PACKAGE_PATH = '/api/npm/package'
|
|
209
|
+
const NPM_VERSIONS_PATH = '/api/npm/versions'
|
|
210
|
+
const NPM_DOWNLOAD_PATH = '/api/npm/download'
|
|
211
|
+
const NPM_STATS_PATH = '/api/npm/stats'
|
|
212
|
+
const NPM_ADVISORIES_PATH = '/api/npm/advisories'
|
|
213
|
+
const NPM_TAGS_PATH = '/api/npm/tags'
|
|
214
|
+
const NPM_SOCKET_PATH = '/api/npm/socket'
|
|
215
|
+
const NPM_PROVENANCE_PATH = '/api/npm/provenance'
|
|
216
|
+
const NPM_PRETTY_PATH = '/api/npm/pretty'
|
|
217
|
+
const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
|
|
218
|
+
const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
|
|
219
|
+
const NPM_RISK_PATH = '/api/npm/risk'
|
|
220
|
+
// What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
|
|
221
|
+
// having no such package or version, or a tarball unpacking past risk's
|
|
222
|
+
// bounds; npm failing otherwise a 502.
|
|
223
|
+
const NPM_RISK_STATUS: Partial<Record<string, number>> = { 'package-not-found': 404, 'version-not-found': 404, 'package-too-large': 413 }
|
|
224
|
+
// A risk failure answered with its code alone, as risk's status for it (NPM_RISK_STATUS, else 502), its cause
|
|
225
|
+
// logged where it is the server's or npm's, not the reader's (a 5xx), for the logs to say why.
|
|
226
|
+
function sendRiskError(res: ServerResponse, err: NpmRiskError) {
|
|
227
|
+
const status = NPM_RISK_STATUS[err.code] ?? 502
|
|
228
|
+
if (status >= 500) console.error(`managed: npm risk failed (${err.code}):`, err.message)
|
|
229
|
+
sendJson(res, status, { error: err.code })
|
|
230
|
+
}
|
|
231
|
+
const NPM_PATHS = new Set([NPM_PACKAGE_PATH, NPM_VERSIONS_PATH, NPM_DOWNLOAD_PATH, NPM_STATS_PATH, NPM_ADVISORIES_PATH, NPM_TAGS_PATH, NPM_SOCKET_PATH, NPM_PROVENANCE_PATH, NPM_PRETTY_PATH,
|
|
232
|
+
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH])
|
|
233
|
+
// How long an npm response may go unread before its connection is dropped.
|
|
234
|
+
const NPM_RESPONSE_IDLE_MS = 60_000
|
|
144
235
|
const MAX_TEAM_NAME = 100
|
|
236
|
+
// Managed data routes, and with them the authentication routes, which an
|
|
237
|
+
// admin's view as another user covers. Other paths fall through to a combined
|
|
238
|
+
// e2e server, whose own authentication a view never touches.
|
|
239
|
+
const MANAGED_DATA_PREFIXES = ['/api/admin', '/api/reports', '/api/bundles', '/api/teams', '/api/avatar', '/api/github', '/api/npm']
|
|
240
|
+
const VIEW_PREFIXES = [...MANAGED_DATA_PREFIXES, '/api/auth', '/api/oauth']
|
|
241
|
+
const underPrefix = (path: string, prefixes: readonly string[]) => prefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))
|
|
145
242
|
|
|
146
243
|
async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
|
|
147
244
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
@@ -242,6 +339,9 @@ export interface ManagedHttpDeps {
|
|
|
242
339
|
bundleStore: BundleStore
|
|
243
340
|
bundleCache?: BundleCache
|
|
244
341
|
reportSourcesCache?: ReportSourcesCache
|
|
342
|
+
prettyCache?: PrettyCache
|
|
343
|
+
// The public npm cache, where risk keeps public packages' assessments (npm-risk.ts).
|
|
344
|
+
npmCacheStorage?: CacheStorage
|
|
245
345
|
uploadStore?: BlobStore
|
|
246
346
|
originGate: OriginGate
|
|
247
347
|
isShuttingDown: () => boolean
|
|
@@ -293,11 +393,28 @@ async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Bu
|
|
|
293
393
|
return Buffer.concat(chunks)
|
|
294
394
|
}
|
|
295
395
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
396
|
+
// A file upload's content, opening a body the browser sealed to this session
|
|
397
|
+
// (common/managed/upload-seal.ts). Link reports are never sent in parts.
|
|
398
|
+
async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: ManagedSession, kind: UploadKind | 'links'): Promise<Buffer> {
|
|
399
|
+
const maxBytes = kind === 'bundles' ? deps.config.maxBundleBytes : deps.config.maxReportBytes
|
|
400
|
+
const sealed = req.headers[UPLOAD_SEAL_HEADER] !== undefined
|
|
401
|
+
const limit = sealed ? maxSealedBytes(maxBytes) : maxBytes
|
|
402
|
+
let bytes
|
|
403
|
+
if (kind === 'links' || req.headers['x-upload-id'] == null) bytes = await readBodyBytes(req, limit)
|
|
404
|
+
else if (deps.uploadStore) bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit)
|
|
405
|
+
else throw new Error('bad-upload')
|
|
406
|
+
return sealed ? await openSessionUpload(session.uploadKey, bytes, maxBytes) : bytes
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// GET /api/admin/uploads/key — the session's upload public key, created on
|
|
410
|
+
// first use. Only this session's server-side private key opens what is sealed to it.
|
|
411
|
+
async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
412
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
413
|
+
const s = await readManageSession(res, deps, cookie)
|
|
414
|
+
if (!s) return
|
|
415
|
+
const key = s.session.uploadKey ?? await deps.db.ensureSessionUploadKey(s.session.id, newUploadKey())
|
|
416
|
+
if (!key) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
417
|
+
sendJson(res, 200, { key: uploadPublicKey(key) })
|
|
301
418
|
}
|
|
302
419
|
|
|
303
420
|
async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
|
|
@@ -324,7 +441,7 @@ async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
324
441
|
}
|
|
325
442
|
if (!validUploadPart(id, index, maxBytes)) { await discard(); sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
326
443
|
let bytes
|
|
327
|
-
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
|
|
444
|
+
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxSealedBytes(maxBytes))) }
|
|
328
445
|
catch { await discard(); sendJson(res, 413, { error: 'too-large' }); return }
|
|
329
446
|
if (bytes.length === 0) { await discard(); sendJson(res, 400, { error: 'empty' }); return }
|
|
330
447
|
if (await manageMutation(req, res, deps, cookie) == null) return
|
|
@@ -341,23 +458,70 @@ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES
|
|
|
341
458
|
|
|
342
459
|
// The router enforces same-origin for every request. A mutation also needs an
|
|
343
460
|
// authenticated session whose CSRF token matches the X-CSRF-Token header.
|
|
344
|
-
// Returns the session, or null after having already sent the 401/403.
|
|
345
|
-
|
|
461
|
+
// Returns the session, or null after having already sent the 401/403. An
|
|
462
|
+
// admin's view as another user writes nothing; `view` admits only the
|
|
463
|
+
// endpoints that end it.
|
|
464
|
+
async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
346
465
|
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
347
466
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
348
467
|
const csrf = firstHeader(req.headers['x-csrf-token'])
|
|
349
468
|
if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
|
|
350
469
|
if (!safeEqual(csrf, s.session.csrfToken)) { sendJson(res, 403, { error: 'csrf-mismatch' }); return null }
|
|
470
|
+
if (s.session.viewer && !view) { sendViewOnly(res); return null }
|
|
351
471
|
return s
|
|
352
472
|
}
|
|
353
473
|
|
|
354
|
-
|
|
474
|
+
function sendViewOnly(res: ServerResponse): void {
|
|
475
|
+
sendJson(res, 403, { error: VIEW_ONLY_ERROR })
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Reads stay available while an admin views as another user. Everything else
|
|
479
|
+
// is refused before dispatch, except ending the view or signing out, and the
|
|
480
|
+
// batch report preview, a POST without effects. Issue authorization would
|
|
481
|
+
// grant GitHub access on the viewed user's behalf.
|
|
482
|
+
function viewAllows(method: string, path: string): boolean {
|
|
483
|
+
if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH
|
|
484
|
+
return (method === 'DELETE' && path === VIEW_AS_PATH) || (method === 'POST' && (path === LOGOUT_PATH || path === REPORT_QUERY_PATH))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// POST /api/auth/logout — drop the session (same-origin + CSRF), which also
|
|
488
|
+
// ends a view opened from it.
|
|
355
489
|
async function handleLogout(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
356
490
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
357
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
491
|
+
const s = await checkMutation(req, res, deps, cookie, { view: true })
|
|
358
492
|
if (s == null) return
|
|
359
493
|
await endSession(deps.config, deps.db, cookie)
|
|
360
|
-
|
|
494
|
+
const cleared = clearCookie(deps.config.sessionCookieName, deps.config.cookieSecure)
|
|
495
|
+
res.writeHead(204, { 'set-cookie': s.session.viewer ? [cleared, clearViewCookie(deps.config)] : cleared, 'cache-control': 'no-store' })
|
|
496
|
+
res.end()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// POST /api/auth/view-as — an admin opens a read-only view as another user
|
|
500
|
+
// (body { userId }); DELETE ends it. Viewing as oneself is refused.
|
|
501
|
+
async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
502
|
+
const method = req.method ?? 'GET'
|
|
503
|
+
if (method === 'DELETE') {
|
|
504
|
+
if (await checkMutation(req, res, deps, cookie, { view: true }) == null) return
|
|
505
|
+
await endViewSession(deps.config, deps.db, cookie)
|
|
506
|
+
res.writeHead(204, { 'set-cookie': clearViewCookie(deps.config), 'cache-control': 'no-store' })
|
|
507
|
+
res.end(); return
|
|
508
|
+
}
|
|
509
|
+
if (method !== 'POST') { send405(res, 'POST, DELETE'); return }
|
|
510
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
511
|
+
if (s == null) return
|
|
512
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
513
|
+
let body: unknown
|
|
514
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
515
|
+
const userId = (body as { userId?: unknown } | null)?.userId
|
|
516
|
+
if (typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
517
|
+
if (userId === s.user.id) { sendJson(res, 400, { error: 'cannot-view-as-self' }); return }
|
|
518
|
+
const target = (await deps.db.listUsers()).find(user => user.id === userId)
|
|
519
|
+
if (!target) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
520
|
+
// The insert itself rechecks the admin session and role.
|
|
521
|
+
const setCookie = await startViewSession(deps.config, deps.db, s.session, userId, Date.now())
|
|
522
|
+
if (setCookie == null) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
523
|
+
await activity(deps, s.user, 'access', `viewed as ${target.login}`)
|
|
524
|
+
res.writeHead(204, { 'set-cookie': setCookie, 'cache-control': 'no-store' })
|
|
361
525
|
res.end()
|
|
362
526
|
}
|
|
363
527
|
|
|
@@ -891,7 +1055,7 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
891
1055
|
if (!requireManageRole(res, s.user)) return
|
|
892
1056
|
let bytes: Buffer
|
|
893
1057
|
try {
|
|
894
|
-
bytes = await readUploadBody(req, deps, s.session
|
|
1058
|
+
bytes = await readUploadBody(req, deps, s.session, 'reports')
|
|
895
1059
|
} catch (err) {
|
|
896
1060
|
const tooLarge = err instanceof Error && err.message === 'too-large'
|
|
897
1061
|
sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
|
|
@@ -1050,6 +1214,18 @@ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
|
|
|
1050
1214
|
return { githubUserId, token }
|
|
1051
1215
|
}
|
|
1052
1216
|
|
|
1217
|
+
// After a catalog response: read the commit details it lacked with this
|
|
1218
|
+
// user's repository access, for the next catalog to send.
|
|
1219
|
+
async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, missing: readonly CatalogCommit[]): Promise<void> {
|
|
1220
|
+
let browser: ReturnType<typeof createRepositoryBrowser> | undefined
|
|
1221
|
+
await backfillBundleCommits(deps.db, userId, missing, async repoId => {
|
|
1222
|
+
const repo = (await deps.db.listAllRepos()).find(item => item.repoId === repoId)
|
|
1223
|
+
if (!repo) return null
|
|
1224
|
+
browser ??= createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, userId))
|
|
1225
|
+
return browser.reader(repo)
|
|
1226
|
+
}).catch(err => { console.warn('managed: commit backfill failed:', err) })
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1053
1229
|
// Listing connected repository names uses managed access only. Verify GitHub
|
|
1054
1230
|
// access after selection, before returning any refs or source directory data.
|
|
1055
1231
|
async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
@@ -1117,8 +1293,18 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1117
1293
|
try {
|
|
1118
1294
|
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
|
|
1119
1295
|
const reader = await browser.reader(access.repo)
|
|
1296
|
+
// Microseconds, so overlapping browses practically never share a listing time.
|
|
1297
|
+
const observedUs = Math.round((performance.timeOrigin + performance.now()) * 1000)
|
|
1120
1298
|
const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
|
|
1121
1299
|
const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
|
|
1300
|
+
const { tagCommits, tagsComplete, ...listed } = revisions ?? { tagCommits: [], tagsComplete: false }
|
|
1301
|
+
// Create a bundle refreshes the tags bundle catalogs show, and keeps a tag
|
|
1302
|
+
// its revision input resolves even when the listed page leaves it out.
|
|
1303
|
+
// Like other GitHub caches, written before the access fence below.
|
|
1304
|
+
const tags = refs ? tagCommits : ref.length > 5 && ref.startsWith('tags/') && contents ? [{ name: ref.slice(5), sha: contents.commit }] : []
|
|
1305
|
+
if (refs || tags.length > 0) {
|
|
1306
|
+
await deps.db.refreshGithubTags(access.repo.repoId, tags, refs && tagsComplete, observedUs).catch(err => { console.warn('managed: tag cache refresh failed:', err) })
|
|
1307
|
+
}
|
|
1122
1308
|
await reader.recheckAccess()
|
|
1123
1309
|
const current = await authorize()
|
|
1124
1310
|
if (!current) return
|
|
@@ -1127,7 +1313,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1127
1313
|
if (JSON.stringify({ ...current.repo, cachedDefaultBranch: access.repo.cachedDefaultBranch }) !== JSON.stringify(access.repo)) {
|
|
1128
1314
|
sendJson(res, 409, { error: 'repository-changed' }); return
|
|
1129
1315
|
}
|
|
1130
|
-
sendJson(res, 200, refs ? { ...
|
|
1316
|
+
sendJson(res, 200, refs ? { ...listed, ...(withDefault ? {
|
|
1131
1317
|
defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
|
|
1132
1318
|
} : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
|
|
1133
1319
|
} catch (err) {
|
|
@@ -1147,17 +1333,144 @@ function prebuildBundle(deps: ManagedHttpDeps, id: string) {
|
|
|
1147
1333
|
})().catch(err => console.warn('managed: bundle cache build failed:', err)))
|
|
1148
1334
|
}
|
|
1149
1335
|
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
async function
|
|
1336
|
+
// Brotli-encoded bytes as they are: the browser's HTTP stack decompresses
|
|
1337
|
+
// them without a Brotli JS dependency.
|
|
1338
|
+
async function sendEncoded(req: IncomingMessage, res: ServerResponse, blob: OpenedBlob, contentType: string, headers: Record<string, string> = {}) {
|
|
1339
|
+
res.writeHead(200, {
|
|
1340
|
+
...headers, 'content-type': contentType, 'content-encoding': 'br',
|
|
1341
|
+
...(blob.size == null ? {} : { 'content-length': String(blob.size) }), 'cache-control': 'private, no-store',
|
|
1342
|
+
'x-content-type-options': 'nosniff',
|
|
1343
|
+
})
|
|
1344
|
+
if (req.method === 'HEAD') { blob.stream.destroy(); res.end(); return }
|
|
1345
|
+
try { await pipeline(blob.stream, res) } catch { res.destroy() }
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
// How often a scan of repositories' risk checks again that its reader is
|
|
1350
|
+
// still an admin, between the lines it sends; and how long its connection may
|
|
1351
|
+
// go idle, unread or with no line to send, before the scan is stopped.
|
|
1352
|
+
const REPO_RISK_RECHECK_MS = 10_000
|
|
1353
|
+
const REPO_RISK_IDLE_MS = 5 * 60_000
|
|
1354
|
+
|
|
1355
|
+
// The repositories a scan of risk reads, by its query: every connected one;
|
|
1356
|
+
// a team's (`team`, its id), each in the team's directories of it; or one
|
|
1357
|
+
// (`repo`, its id). Null for a team or repository there is none of, or a
|
|
1358
|
+
// query naming both.
|
|
1359
|
+
async function riskRepositories(db: ManagedDb, query: URLSearchParams): Promise<(SelectedRepo & { paths?: string[] })[] | null> {
|
|
1360
|
+
const connected = await db.listSelectedRepos()
|
|
1361
|
+
const repoId = query.get('repo'), teamId = query.get('team')
|
|
1362
|
+
if (teamId !== null && repoId !== null) return null
|
|
1363
|
+
if (repoId !== null) {
|
|
1364
|
+
const repo = connected.find(item => String(item.repoId) === repoId)
|
|
1365
|
+
return repo ? [repo] : null
|
|
1366
|
+
}
|
|
1367
|
+
if (teamId === null) return connected
|
|
1368
|
+
const team = (await db.listTeams()).find(item => item.id === teamId)
|
|
1369
|
+
if (!team) return null
|
|
1370
|
+
// By repository, its directories; null for the whole of it.
|
|
1371
|
+
const paths = new Map<number, string[] | null>()
|
|
1372
|
+
for (const { repoId: id, path } of team.repos) {
|
|
1373
|
+
const kept = paths.get(id)
|
|
1374
|
+
paths.set(id, path === null || kept === null ? null : [...kept ?? [], path])
|
|
1375
|
+
}
|
|
1376
|
+
return connected.flatMap(repo => {
|
|
1377
|
+
const of = paths.get(repo.repoId)
|
|
1378
|
+
return of === undefined ? [] : [of === null ? repo : { ...repo, paths: of }]
|
|
1379
|
+
})
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// GET /api/admin/repositories/risk: every connected repository's npm
|
|
1383
|
+
// packages, or a team's or one repository's (riskRepositories), as its
|
|
1384
|
+
// lockfiles lock them at its default branch's head, each assessed by risk
|
|
1385
|
+
// (repository-risk.ts), a line as each is found. A
|
|
1386
|
+
// repository is read with the admin's own GitHub access, as browsing it is
|
|
1387
|
+
// (repository-browser.ts), and that access checked again once it is read,
|
|
1388
|
+
// before any of it is told; packages as the npm viewer's admins read them, in
|
|
1389
|
+
// spare assessments (npm-risk.ts), trusted however new where a team lists
|
|
1390
|
+
// their scope, and each lockfile's asked of npm's advisories at once. The
|
|
1391
|
+
// reader leaving, losing their admin role or the server shutting down stops
|
|
1392
|
+
// it.
|
|
1393
|
+
async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1394
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
1395
|
+
if (!s) return
|
|
1396
|
+
const repos = await riskRepositories(deps.db, query)
|
|
1397
|
+
if (!repos) {
|
|
1398
|
+
const both = query.has('team') && query.has('repo')
|
|
1399
|
+
sendJson(res, both ? 400 : 404, { error: both ? 'bad-scope' : 'no-repository' })
|
|
1400
|
+
return
|
|
1401
|
+
}
|
|
1402
|
+
const controller = new AbortController()
|
|
1403
|
+
res.on('close', () => { controller.abort() })
|
|
1404
|
+
res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
|
|
1405
|
+
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
|
|
1406
|
+
const scopes = new Map<string, Promise<boolean>>()
|
|
1407
|
+
const versions = npmVersionStore(deps.db, deps.config.debug)
|
|
1408
|
+
const lines = repositoryRiskLines({
|
|
1409
|
+
repos: () => Promise.resolve(repos),
|
|
1410
|
+
async open(repo) {
|
|
1411
|
+
const reader = await browser.reader(repo)
|
|
1412
|
+
const commit = await reader.commit('')
|
|
1413
|
+
const client = createClient({ token: reader.readToken() })
|
|
1414
|
+
return {
|
|
1415
|
+
commit,
|
|
1416
|
+
lockfiles: async () => (await upstreamGithub(() => client.findRepoLockfiles({ repo: repo.fullName, sha: commit }))).lockfiles,
|
|
1417
|
+
file: path => upstreamGithub(() => client.getRepoFile({ repo: repo.fullName, path, ref: commit })),
|
|
1418
|
+
recheck: async () => { await reader.recheckAccess() },
|
|
1419
|
+
}
|
|
1420
|
+
},
|
|
1421
|
+
teamScoped(name) {
|
|
1422
|
+
const scope = npmPackageScope(name)
|
|
1423
|
+
if (scope === null) return Promise.resolve(false)
|
|
1424
|
+
if (!scopes.has(scope)) scopes.set(scope, deps.db.hasTeamNpmScope(scope))
|
|
1425
|
+
return scopes.get(scope)!
|
|
1426
|
+
},
|
|
1427
|
+
// Public versions' documents kept in the database (npm-kept.ts), as the
|
|
1428
|
+
// viewer keeps them: whether npm still has one, its whole document tells
|
|
1429
|
+
// risk.
|
|
1430
|
+
version: async (name, version, signal) => found(await readNpmVersion(name, version, true, signal, versions)),
|
|
1431
|
+
assess: (doc, teamScoped, signal) => npmRisk(doc, 'pick', { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
|
|
1432
|
+
teamScoped, privileged: true, signal, spare: true }),
|
|
1433
|
+
removed: (name, version, teamScoped, signal) => npmRiskRemoved(name, version, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
|
|
1434
|
+
teamScoped, privileged: true, signal }),
|
|
1435
|
+
risks: riskLineStore(deps.db, RISK_KEPT_MS, deps.config.debug),
|
|
1436
|
+
// As a bundle's advisories are asked (bundle-advisories.ts), npm's registry
|
|
1437
|
+
// and, for packages anyone may read, OSV: no repository's. Each lockfile's
|
|
1438
|
+
// audit is bounded on its own, for one npm doesn't answer to fail alone and
|
|
1439
|
+
// the scan to carry on.
|
|
1440
|
+
async advisories(packages, scanning) {
|
|
1441
|
+
const signal = AbortSignal.any([scanning, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)])
|
|
1442
|
+
const audited = await fetchBundleAdvisories(packages.map(pkg => ({ ecosystem: 'npm' as const, ...pkg })), signal,
|
|
1443
|
+
{ debug: deps.config.debug, cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug) })
|
|
1444
|
+
if (audited.status !== 200) throw new NpmPackageError(502, audited.body.error)
|
|
1445
|
+
return audited.body
|
|
1446
|
+
},
|
|
1447
|
+
}, controller.signal)
|
|
1448
|
+
let checked = Date.now()
|
|
1449
|
+
await sendLines(res, lines, async () => {
|
|
1450
|
+
if (deps.isShuttingDown()) return false
|
|
1451
|
+
if (Date.now() - checked < REPO_RISK_RECHECK_MS) return true
|
|
1452
|
+
if ((await readSession(deps.config, deps.db, cookie, Date.now()))?.user.role !== 'admin') return false
|
|
1453
|
+
checked = Date.now()
|
|
1454
|
+
return true
|
|
1455
|
+
})
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// A pretty-printed copy names the file it was formatted from, by the hash it
|
|
1459
|
+
// was asked for and its source was found to have, for the viewer to check.
|
|
1460
|
+
const prettySource = ({ hash }: PrettyRequest): Record<string, string> => ({ 'x-source-hash': hash })
|
|
1461
|
+
|
|
1462
|
+
// GET /api/bundles/:id/{metadata,contents,pretty}: what `open` reads of the
|
|
1463
|
+
// bundle, already encoded; it is absent where its cache is.
|
|
1464
|
+
async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string,
|
|
1465
|
+
open: ((rec: ManagedBundle) => Promise<OpenedBlob>) | undefined, contentType = 'application/json', headers: Record<string, string> = {}) {
|
|
1153
1466
|
const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
|
|
1154
1467
|
if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1155
1468
|
const rec = access.bundle
|
|
1156
1469
|
if (!rec) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1157
|
-
if (!
|
|
1470
|
+
if (!open) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1158
1471
|
let cached
|
|
1159
|
-
try { cached = await
|
|
1160
|
-
catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
|
|
1472
|
+
try { cached = await open(rec) }
|
|
1473
|
+
catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
|
|
1161
1474
|
// A cold build can outlast a session or membership change.
|
|
1162
1475
|
const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
|
|
1163
1476
|
if (!current?.bundle) {
|
|
@@ -1165,13 +1478,55 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
|
|
|
1165
1478
|
sendJson(res, current ? 404 : 401, { error: current ? 'no-bundle' : 'unauthenticated' })
|
|
1166
1479
|
return
|
|
1167
1480
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1481
|
+
await sendEncoded(req, res, cached, contentType, headers)
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// GET /api/bundles/:id/rename-diff: one of its JavaScript files diffed with
|
|
1485
|
+
// one of the `other` bundle's, both pretty-printed, renamed names hidden
|
|
1486
|
+
// (rename-diff.ts), for a reader of both, checked again once it is worked out.
|
|
1487
|
+
async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
|
|
1488
|
+
const otherId = params.get('other') ?? '', request = renameDiffRequest(params)
|
|
1489
|
+
if (!request || !/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
1490
|
+
const readable = async () => {
|
|
1491
|
+
const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1492
|
+
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
1493
|
+
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
|
|
1494
|
+
return [base.bundle, other.bundle] as const
|
|
1495
|
+
}
|
|
1496
|
+
const records = await readable()
|
|
1497
|
+
if (!records) return
|
|
1498
|
+
const { prettyCache } = deps
|
|
1499
|
+
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1500
|
+
let diff
|
|
1501
|
+
try {
|
|
1502
|
+
diff = await renameDiffOf(request, async () => [await prettyText(await prettyCache.bundle(records[0], request.base)),
|
|
1503
|
+
await prettyText(await prettyCache.bundle(records[1], request.other))])
|
|
1504
|
+
} catch (err) {
|
|
1505
|
+
sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return
|
|
1506
|
+
}
|
|
1507
|
+
if (await readable()) await sendEncoded(req, res, diff, 'application/json')
|
|
1508
|
+
else diff.stream.destroy()
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// GET /api/bundles/:id/pretty?other=: the files that changed between it and
|
|
1512
|
+
// the `other` bundle, both sides' pretty-printed (pretty-print.ts compared), a
|
|
1513
|
+
// line each as it is found kept or formatted; GET
|
|
1514
|
+
// /api/bundles/:id/rename-diff?other=, the pairs of them its viewer diffs
|
|
1515
|
+
// with renamed names hidden (rename-diff.ts renamedPairs), a line each as it
|
|
1516
|
+
// is worked out; GET /api/bundles/:id/type-only?other=, those that changed in
|
|
1517
|
+
// types alone (type-only.ts). For a reader of both, checked again before each.
|
|
1518
|
+
async function handleBundleChanges(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, otherId: string,
|
|
1519
|
+
linesOf: (cache: PrettyCache, base: ManagedBundle, other: ManagedBundle) => Promise<AsyncIterable<unknown>>) {
|
|
1520
|
+
if (!/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-bundle' }); return }
|
|
1521
|
+
const access = () => Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1522
|
+
const [base, other] = await access()
|
|
1523
|
+
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1524
|
+
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1525
|
+
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1526
|
+
let lines
|
|
1527
|
+
try { lines = await linesOf(deps.prettyCache, base.bundle, other.bundle) }
|
|
1528
|
+
catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
|
|
1529
|
+
await sendLines(res, lines, async () => (await access()).every(snapshot => snapshot?.bundle))
|
|
1175
1530
|
}
|
|
1176
1531
|
|
|
1177
1532
|
// Published dependency advisories require security access, independently of access to
|
|
@@ -1200,7 +1555,8 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
1200
1555
|
const controller = new AbortController()
|
|
1201
1556
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
1202
1557
|
res.on('close', onClose)
|
|
1203
|
-
|
|
1558
|
+
// Its deadline aborts as AbortSignal.timeout does, for a failure it leaves to be told from the reader's leaving.
|
|
1559
|
+
const timer = setTimeout(() => controller.abort(new DOMException('advisories took too long', 'TimeoutError')), ADVISORIES_TIMEOUT_MS)
|
|
1204
1560
|
try {
|
|
1205
1561
|
if (res.destroyed) return
|
|
1206
1562
|
const needsGithub = inventory.packages.some(pkg => repoAdvisories || pkg.ecosystem === 'github' || pkg.ecosystem === 'soldeer')
|
|
@@ -1209,7 +1565,8 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
1209
1565
|
})) : null
|
|
1210
1566
|
if (res.destroyed || (needsGithub && !(await authorize()))) return
|
|
1211
1567
|
const result = await fetchBundleAdvisories(inventory.packages, controller.signal, {
|
|
1212
|
-
debug: deps.config.debug, repoAdvisories, details, githubToken,
|
|
1568
|
+
debug: deps.config.debug, repoAdvisories, details, githubToken,
|
|
1569
|
+
cache: auditCache(deps.config.upstreamCacheDir, deps.db, controller.signal, deps.config.debug, auditedRepos(inventory.packages, repoAdvisories)),
|
|
1213
1570
|
})
|
|
1214
1571
|
if (res.destroyed || !(await authorize())) return
|
|
1215
1572
|
// Return just inventory and public advisories, never source or report data.
|
|
@@ -1226,16 +1583,18 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
1226
1583
|
async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
|
|
1227
1584
|
const before = await deps.db.getBundleCatalog(session.id, Date.now())
|
|
1228
1585
|
const summaries = await bundleSummaries(before.bundles, deps.bundleCache)
|
|
1229
|
-
|
|
1586
|
+
const commits = await bundleCommits(deps.db, before.bundles, summaries)
|
|
1587
|
+
// Even cached storage and commit reads may outlast changes to access or locations.
|
|
1230
1588
|
const catalog = await deps.db.getBundleCatalog(session.id, Date.now())
|
|
1231
1589
|
sendJson(res, 200, {
|
|
1232
1590
|
...catalog,
|
|
1233
1591
|
bundles: catalog.bundles.map(bundle => ({
|
|
1234
|
-
...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
|
|
1592
|
+
...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }), commitInfo: commits.commitInfo(bundle),
|
|
1235
1593
|
})),
|
|
1236
1594
|
maxBytes: deps.config.maxBundleBytes,
|
|
1237
1595
|
})
|
|
1238
1596
|
await backfillBundleSummaries(catalog.bundles, deps.bundleCache)
|
|
1597
|
+
await backfillCatalogCommits(deps, session.userId, commits.missing)
|
|
1239
1598
|
}
|
|
1240
1599
|
|
|
1241
1600
|
type UploadedBundle = Pick<ManagedBundle, 'id' | 'slug' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
|
|
@@ -1283,10 +1642,13 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1283
1642
|
await withBundleBuildLease(deps.db, access.user.id, controller.signal, async signal => {
|
|
1284
1643
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1285
1644
|
signal.throwIfAborted()
|
|
1645
|
+
// Catalogs then show the built bundle's commit details from the cache.
|
|
1646
|
+
const commitCached = cacheCommitDetails(deps.db, reader, input.repoId, input.commit)
|
|
1286
1647
|
const built = await buildRepositoryBundle(access.user.id, {
|
|
1287
1648
|
input, github: access.repo.fullName, token: reader.readToken(), maxBytes: deps.config.maxBundleBytes, scopes: access.scopes,
|
|
1288
1649
|
cacheDir: deps.config.upstreamCacheDir ?? null,
|
|
1289
1650
|
}, signal)
|
|
1651
|
+
await commitCached
|
|
1290
1652
|
signal.throwIfAborted()
|
|
1291
1653
|
await reader.recheckAccess()
|
|
1292
1654
|
const current = await authorize(built.directory)
|
|
@@ -1325,7 +1687,7 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1325
1687
|
if (!requireManageRole(res, s.user)) return
|
|
1326
1688
|
let bytes: Buffer
|
|
1327
1689
|
try {
|
|
1328
|
-
bytes = await readUploadBody(req, deps, s.session
|
|
1690
|
+
bytes = await readUploadBody(req, deps, s.session, 'bundles')
|
|
1329
1691
|
} catch (err) {
|
|
1330
1692
|
const tooLarge = err instanceof Error && err.message === 'too-large'
|
|
1331
1693
|
sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
|
|
@@ -1480,10 +1842,16 @@ async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: Ma
|
|
|
1480
1842
|
// GET /api/teams — the CURRENT user's teams, each with the reports and bundles
|
|
1481
1843
|
// attached to the team's repos, for the sidebar's per-user Teams section. Any approved
|
|
1482
1844
|
// user (not just admin|manage); a user only ever sees their own teams.
|
|
1845
|
+
// Each team carries `app`, its App classification (team-app.ts), so the
|
|
1846
|
+
// sidebar's first paint already shows App teams collapsed.
|
|
1483
1847
|
async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
|
|
1484
1848
|
let snapshot = await deps.db.getUserTeamFeedSnapshot(session.id, Date.now())
|
|
1485
1849
|
if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1486
|
-
const summaries = await
|
|
1850
|
+
const [summaries, classified] = await Promise.all([
|
|
1851
|
+
bundleSummaries(snapshot.teams.flatMap(team => team.bundles), deps.bundleCache),
|
|
1852
|
+
teamAppStates(deps.db, deps.reportStore, session.id, snapshot.teams),
|
|
1853
|
+
])
|
|
1854
|
+
const commits = await bundleCommits(deps.db, snapshot.teams.flatMap(team => team.bundles), summaries)
|
|
1487
1855
|
const current = await deps.db.getFeedState(session.id, Date.now())
|
|
1488
1856
|
if (!current) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1489
1857
|
// Keep the post-storage authorization fence, but reload the catalog only
|
|
@@ -1493,11 +1861,16 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
|
|
|
1493
1861
|
if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1494
1862
|
}
|
|
1495
1863
|
const { teams, revision } = snapshot
|
|
1864
|
+
// The revision sent is the reloaded catalog's, so classify what changed now.
|
|
1865
|
+
const apps = await teamAppStates(deps.db, deps.reportStore, session.id, teams, classified)
|
|
1496
1866
|
sendJson(res, 200, {
|
|
1497
|
-
teams: teams.map(team => ({ ...team,
|
|
1867
|
+
teams: teams.map(team => ({ ...team, app: apps.get(teamAppKey(team)) ?? null,
|
|
1868
|
+
bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
|
|
1869
|
+
commitInfo: commits.commitInfo(bundle) })) })),
|
|
1498
1870
|
revision,
|
|
1499
1871
|
})
|
|
1500
1872
|
await backfillBundleSummaries(teams.flatMap(team => team.bundles), deps.bundleCache)
|
|
1873
|
+
await backfillCatalogCommits(deps, session.userId, commits.missing)
|
|
1501
1874
|
}
|
|
1502
1875
|
|
|
1503
1876
|
// Admins read all reports. Managers read their uploads or reports inside their
|
|
@@ -1574,7 +1947,7 @@ async function handleDeduplication(req: IncomingMessage, res: ServerResponse, de
|
|
|
1574
1947
|
sendJson(res, 200, { ok: true }); return
|
|
1575
1948
|
}
|
|
1576
1949
|
let bytes: Buffer
|
|
1577
|
-
try { bytes = await
|
|
1950
|
+
try { bytes = await readUploadBody(req, deps, s.session, 'links') }
|
|
1578
1951
|
catch (error) { const large = error instanceof Error && error.message === 'too-large'; sendJson(res, large ? 413 : 400, { error: large ? 'too-large' : 'bad-body' }); return }
|
|
1579
1952
|
const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.link.json')
|
|
1580
1953
|
const result = await deps.db.importLinkReport(s.session.id, filename, bytes.toString('utf8'))
|
|
@@ -2050,8 +2423,10 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
|
|
|
2050
2423
|
async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2051
2424
|
const s = await readAdminSession(res, deps, cookie)
|
|
2052
2425
|
if (s == null) return
|
|
2426
|
+
const scopes = await deps.db.listTeamNpmScopes()
|
|
2427
|
+
const reviewTeams = new Set(await deps.db.listDependencyReviewEnabledTeams())
|
|
2053
2428
|
sendJson(res, 200, {
|
|
2054
|
-
teams: await deps.db.listTeams(),
|
|
2429
|
+
teams: (await deps.db.listTeams()).map(team => ({ ...team, npmScopes: scopes[team.id] ?? [], dependencyReview: reviewTeams.has(team.id) })),
|
|
2055
2430
|
users: await deps.db.listUserOptions(),
|
|
2056
2431
|
repos: selectableRepos(await deps.db.listSelectedRepos()),
|
|
2057
2432
|
permissions: VISIBILITY_PERMISSIONS,
|
|
@@ -2212,6 +2587,445 @@ async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse,
|
|
|
2212
2587
|
sendJson(res, 200, { ok: true })
|
|
2213
2588
|
}
|
|
2214
2589
|
|
|
2590
|
+
async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2591
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2592
|
+
if (!s) return
|
|
2593
|
+
let body
|
|
2594
|
+
try { body = await readJsonBody(req) as { teamId?: unknown; enabled?: unknown } } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2595
|
+
if (typeof body?.teamId !== 'string' || typeof body?.enabled !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2596
|
+
if (!await deps.db.setTeamDependencyReview(s.session.id, body.teamId, body.enabled)) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2597
|
+
sendJson(res, 200, { ok: true })
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
// POST /api/admin/teams/set-npm-scopes — replace the npm scopes whose private
|
|
2601
|
+
// packages the team's members may read. Body { teamId, scopes: ['@scope'] }.
|
|
2602
|
+
async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2603
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2604
|
+
if (s == null) return
|
|
2605
|
+
let body: unknown
|
|
2606
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2607
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2608
|
+
if (typeof teamId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2609
|
+
const team = await deps.db.getTeam(teamId)
|
|
2610
|
+
const changed = await deps.db.setTeamNpmScopes(s.session.id, teamId, (body as { scopes?: unknown }).scopes)
|
|
2611
|
+
if (changed == null) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2612
|
+
const parts = [changed.added.length > 0 ? `added ${changed.added.join(', ')}` : '', changed.removed.length > 0 ? `removed ${changed.removed.join(', ')}` : ''].filter(Boolean)
|
|
2613
|
+
if (parts.length > 0) await activity(deps, s.user, 'access', `changed team ${team?.name ?? teamId}'s npm scopes: ${parts.join('; ')}`)
|
|
2614
|
+
sendJson(res, 200, { ok: true, scopes: (await deps.db.listTeamNpmScopes())[teamId] ?? [] })
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// Who reads npm packages now: a session with workspace access, its role and
|
|
2618
|
+
// the npm scopes of its visible teams. Null for none.
|
|
2619
|
+
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined): Promise<NpmReader | null> {
|
|
2620
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2621
|
+
if (s == null || !roleAtLeast(s.user.role, 'view')) return null
|
|
2622
|
+
return { role: s.user.role, scopes: new Set(await deps.db.listUserNpmScopes(s.user.id)), userId: s.user.id }
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// What a registry read found, or a 404 for the package (handleNpm's catch
|
|
2626
|
+
// answers it) where it found none.
|
|
2627
|
+
function found<T>(value: T | null | undefined): T {
|
|
2628
|
+
if (value == null) throw new NpmPackageError(404, 'package-not-found')
|
|
2629
|
+
return value
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// Lists must enforce the same target and base access as the review itself,
|
|
2633
|
+
// including permission changes while the registry requests are in flight.
|
|
2634
|
+
async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[]): Promise<T[] | null> {
|
|
2635
|
+
const controller = new AbortController()
|
|
2636
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2637
|
+
res.on('close', onClose)
|
|
2638
|
+
try {
|
|
2639
|
+
const reader = await npmReader(deps, cookie)
|
|
2640
|
+
if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2641
|
+
const documents = new Map<string, Promise<NpmVersionDocument | null>>()
|
|
2642
|
+
// Public versions' documents as the viewer keeps them (npm-kept.ts), a
|
|
2643
|
+
// day: a list asks npm for none it read within it.
|
|
2644
|
+
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
2645
|
+
const read = (name: string, version: string) => {
|
|
2646
|
+
const key = `${name}@${version}`
|
|
2647
|
+
if (!documents.has(key)) documents.set(key, readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
|
|
2648
|
+
return documents.get(key)!
|
|
2649
|
+
}
|
|
2650
|
+
const packages = await Promise.all(reviews.map(review => Promise.all([
|
|
2651
|
+
read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
|
|
2652
|
+
])))
|
|
2653
|
+
const current = await npmReader(deps, cookie)
|
|
2654
|
+
if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2655
|
+
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
2656
|
+
} catch (error) {
|
|
2657
|
+
if (controller.signal.aborted) return null
|
|
2658
|
+
if (error instanceof NpmPackageError) { sendJson(res, error.status, { error: error.message }); return null }
|
|
2659
|
+
throw error
|
|
2660
|
+
} finally { res.off('close', onClose) }
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
const githubRepoOf = (doc: NpmVersionDocument) => (doc.manifest['github'] as { github?: string } | undefined)?.github ?? null
|
|
2664
|
+
|
|
2665
|
+
function npmAdvisoryOptions(deps: ManagedHttpDeps, githubToken: NpmAdvisoryOptions['githubToken'], latestDoc: () => Promise<NpmVersionDocument | null>): NpmAdvisoryOptions {
|
|
2666
|
+
return {
|
|
2667
|
+
githubToken,
|
|
2668
|
+
repo: async () => { const doc = await latestDoc(); return doc && githubRepoOf(doc) },
|
|
2669
|
+
cache: signal => auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug),
|
|
2670
|
+
lastListing: repo => lastListing(deps.config.upstreamCacheDir, deps.db, repo, deps.config.debug),
|
|
2671
|
+
debug: deps.config.debug,
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
// Read the same target-version evidence as the viewer. Missing upstream
|
|
2676
|
+
// reports impose no extra verdict restriction; detected findings do.
|
|
2677
|
+
async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReader, doc: NpmVersionDocument, signal: AbortSignal) {
|
|
2678
|
+
if (npmTakenDown({ npm: doc })) return 'reject' as const
|
|
2679
|
+
const privileged = canReadPrivateNpm(reader, doc.name)
|
|
2680
|
+
const advisories = async () => {
|
|
2681
|
+
const versions = await readNpmVersions(doc.name, privileged, signal)
|
|
2682
|
+
if (!versions) return null
|
|
2683
|
+
return npmAdvisories(doc.name, versions.versions, npmAdvisoryOptions(deps,
|
|
2684
|
+
() => ensureUserAccessToken(deps.config, deps.db, reader.userId, Date.now()).catch(() => null),
|
|
2685
|
+
() => readNpmVersion(doc.name, 'latest', privileged, signal)))
|
|
2686
|
+
}
|
|
2687
|
+
const [list, socket, risk] = await Promise.all([advisories().catch(() => null), doc.private ? null : npmSocketReport(doc.name, doc.version).catch(() => null),
|
|
2688
|
+
npmRisk(doc, null, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped: true, privileged, signal })])
|
|
2689
|
+
return npmReviewVerdictLimit(doc, list, socket, risk)
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
// Reviews are scoped to the bytes actually displayed, including the base.
|
|
2693
|
+
// Access to both versions is rechecked before returning or saving annotations.
|
|
2694
|
+
async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
|
|
2695
|
+
const method = req.method ?? 'GET'
|
|
2696
|
+
if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
|
|
2697
|
+
const mutation = method !== 'GET'
|
|
2698
|
+
const session = mutation ? await checkMutation(req, res, deps, cookie) : await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2699
|
+
if (!session) { if (!mutation) sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2700
|
+
if (!roleAtLeast(session.user.role, mutation ? 'triage' : 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
2701
|
+
let body: Record<string, unknown> = {}
|
|
2702
|
+
if (mutation) {
|
|
2703
|
+
try {
|
|
2704
|
+
const raw = await readJsonBody(req, 128 * 1024)
|
|
2705
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('bad-body')
|
|
2706
|
+
body = raw as Record<string, unknown>
|
|
2707
|
+
} catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2708
|
+
}
|
|
2709
|
+
const field = (key: string) => mutation ? body[key] : params.get(key)
|
|
2710
|
+
const name = field('package')
|
|
2711
|
+
const version = field('version')
|
|
2712
|
+
const integrity = field('integrity')
|
|
2713
|
+
const basePackage = field('basePackage') ?? null
|
|
2714
|
+
const baseVersion = field('baseVersion') ?? null
|
|
2715
|
+
const baseIntegrity = field('baseIntegrity') ?? null
|
|
2716
|
+
const validIntegrity = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 1024
|
|
2717
|
+
if (field('ecosystem') !== 'npm' || !isNpmPackageName(name) || !isNpmPackageSpec(version) || !validIntegrity(integrity)
|
|
2718
|
+
|| (basePackage !== null || baseVersion !== null || baseIntegrity !== null)
|
|
2719
|
+
&& (!isNpmPackageName(basePackage) || !isNpmPackageSpec(baseVersion) || !validIntegrity(baseIntegrity))) {
|
|
2720
|
+
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
2721
|
+
}
|
|
2722
|
+
const context: ReviewContext = { ecosystem: 'npm', package: name, version, integrity,
|
|
2723
|
+
basePackage: basePackage as string | null, baseVersion: baseVersion as string | null, baseIntegrity: baseIntegrity as string | null }
|
|
2724
|
+
let change: ReviewChange | null = null
|
|
2725
|
+
if (mutation) {
|
|
2726
|
+
const action = body['action'], reviewId = body['reviewId'] ?? null, revision = body['revision'] ?? null
|
|
2727
|
+
if (typeof action !== 'string' || !['start', 'save', 'publish', 'continue', 'revoke'].includes(action)
|
|
2728
|
+
|| (reviewId !== null && (typeof reviewId !== 'string' || reviewId.length === 0 || reviewId.length > 100 || !Number.isSafeInteger(revision) || Number(revision) < 1))
|
|
2729
|
+
|| (!['save', 'start'].includes(action) && reviewId === null) || (action === 'start' && reviewId !== null)) { sendJson(res, 400, { error: 'bad-review' }); return }
|
|
2730
|
+
if (action === 'start') change = { action, reviewId: null, revision: null }
|
|
2731
|
+
else if (action === 'publish') {
|
|
2732
|
+
const teamIds = body['teamIds']
|
|
2733
|
+
if (!Array.isArray(teamIds) || teamIds.length === 0 || teamIds.length > 100 || teamIds.some(id => typeof id !== 'string' || id.length > 100)) {
|
|
2734
|
+
sendJson(res, 400, { error: 'bad-review-teams' }); return
|
|
2735
|
+
}
|
|
2736
|
+
const finalComment = body['finalComment'] ?? '', finalStatus = body['finalStatus'] ?? null
|
|
2737
|
+
if (typeof finalComment !== 'string' || finalComment.length > 20000 || finalStatus !== null
|
|
2738
|
+
&& (typeof finalStatus !== 'string' || !['accept', 'accept-with-nits', 'request-changes', 'reject'].includes(finalStatus))) {
|
|
2739
|
+
sendJson(res, 400, { error: 'bad-final-verdict' }); return
|
|
2740
|
+
}
|
|
2741
|
+
change = { action, reviewId: reviewId as string, revision: revision as number, teamIds, files: [], nonTextFiles: [],
|
|
2742
|
+
final: finalStatus === null ? null : { status: finalStatus as ReviewInput['status'] & string, comment: finalComment } }
|
|
2743
|
+
} else if (action === 'continue' || action === 'revoke') change = { action, reviewId: reviewId as string, revision: revision as number }
|
|
2744
|
+
else {
|
|
2745
|
+
const file = body['file']
|
|
2746
|
+
const comment = body['comment']
|
|
2747
|
+
const status = body['status'] ?? null
|
|
2748
|
+
const entryId = body['entryId'] ?? null
|
|
2749
|
+
if (typeof file !== 'string' || !file || file.length > 4096 || file.includes('\0')
|
|
2750
|
+
|| (entryId !== null && (typeof entryId !== 'string' || entryId.length === 0 || entryId.length > 100))
|
|
2751
|
+
|| typeof comment !== 'string' || comment.length > 20000
|
|
2752
|
+
|| (status !== null && (typeof status !== 'string' || !['accept', 'accept-with-nits', 'request-changes', 'reject'].includes(status))) || (status === null && entryId === null)) {
|
|
2753
|
+
sendJson(res, 400, { error: 'bad-review' }); return
|
|
2754
|
+
}
|
|
2755
|
+
change = { action: 'save', reviewId: reviewId as string | null, revision: revision as number | null, entryId: entryId as string | null,
|
|
2756
|
+
entry: { file, comment, status: status as ReviewInput['status'] } }
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
const controller = new AbortController()
|
|
2760
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2761
|
+
res.on('close', onClose)
|
|
2762
|
+
try {
|
|
2763
|
+
const reader = await npmReader(deps, cookie)
|
|
2764
|
+
if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2765
|
+
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
2766
|
+
const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
|
|
2767
|
+
const [doc, base] = await Promise.all([read(name, version), context.basePackage ? read(context.basePackage, context.baseVersion!) : null])
|
|
2768
|
+
if (!doc || (context.basePackage && !base)) { sendJson(res, 404, { error: 'package-not-found' }); return }
|
|
2769
|
+
if (doc.version !== version || doc.dist.integrity !== integrity
|
|
2770
|
+
|| (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
|
|
2771
|
+
sendJson(res, 409, { error: 'review-content-changed' }); return
|
|
2772
|
+
}
|
|
2773
|
+
if (change?.action === 'publish' || change?.action === 'save') {
|
|
2774
|
+
const loads = [doc, ...base ? [base] : []].map(loadNpmFiles)
|
|
2775
|
+
try {
|
|
2776
|
+
const files = await Promise.all(loads.map(load => load.result))
|
|
2777
|
+
const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
|
|
2778
|
+
const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
|
|
2779
|
+
const diff = computeBundleDiff(before, after, () => '.')
|
|
2780
|
+
const changed: { path: string; basePath?: string }[] = [...diff.files.onlyBase, ...diff.files.onlyOther, ...diff.files.changed]
|
|
2781
|
+
const reviewable = (file: typeof changed[number]) => isReviewableFile(before.get(file.basePath ?? file.path), after.get(file.path))
|
|
2782
|
+
const reviewedPath = change.action === 'save' ? change.entry.file : null
|
|
2783
|
+
const reviewedFile = changed.find(file => file.path === reviewedPath)
|
|
2784
|
+
if (change.action === 'save' && !reviewedFile) { sendJson(res, 400, { error: 'unknown-review-file' }); return }
|
|
2785
|
+
if (change.action === 'save' && change.entry.status !== null && reviewedFile && !reviewable(reviewedFile)) { sendJson(res, 400, { error: 'non-text-review-file' }); return }
|
|
2786
|
+
if (change.action === 'publish') {
|
|
2787
|
+
change.files = changed.map(file => file.path)
|
|
2788
|
+
change.nonTextFiles = changed.filter(file => !reviewable(file)).map(file => file.path)
|
|
2789
|
+
}
|
|
2790
|
+
} finally { loads.forEach(load => load.release()) }
|
|
2791
|
+
}
|
|
2792
|
+
const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
|
|
2793
|
+
? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
|
|
2794
|
+
const current = await npmReader(deps, cookie)
|
|
2795
|
+
if (!current || current.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2796
|
+
if ([doc, base].some(item => item?.private && !canReadPrivateNpm(current, item.name))) { sendJson(res, 404, { error: 'package-not-found' }); return }
|
|
2797
|
+
if (!change) {
|
|
2798
|
+
const allBases = params.get('allBases') === 'true'
|
|
2799
|
+
const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
|
|
2800
|
+
const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
|
|
2801
|
+
if (reviews) sendJson(res, 200, { reviews, teams: await deps.db.listDependencyReviewTeams(current.userId) })
|
|
2802
|
+
return
|
|
2803
|
+
}
|
|
2804
|
+
const checked = await checkMutation(req, res, deps, cookie)
|
|
2805
|
+
if (!checked) return
|
|
2806
|
+
if (checked.user.id !== current.userId || !roleAtLeast(checked.user.role, 'triage')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
2807
|
+
if (change.action === 'publish' && change.final && !allowedReviewVerdicts([], limit).includes(change.final.status)) {
|
|
2808
|
+
sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
|
|
2809
|
+
}
|
|
2810
|
+
const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
|
|
2811
|
+
sendJson(res, 200, { review })
|
|
2812
|
+
} catch (err) {
|
|
2813
|
+
if (controller.signal.aborted) return
|
|
2814
|
+
if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
|
|
2815
|
+
if (err instanceof NpmRiskError) { sendRiskError(res, err); return }
|
|
2816
|
+
throw err
|
|
2817
|
+
} finally { res.off('close', onClose) }
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only}. Only readers with private access
|
|
2821
|
+
// (canReadPrivateNpm) can be answered from the server's npm token; the rest get
|
|
2822
|
+
// what the registry answers anonymously, every time (see npm-packages.ts).
|
|
2823
|
+
// Access is checked again after the registry's answer.
|
|
2824
|
+
async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string, params: URLSearchParams): Promise<void> {
|
|
2825
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
2826
|
+
const name = params.get('name'), spec = params.get('version') ?? 'latest'
|
|
2827
|
+
// All but the package's own (its versions, figures and advisories) are a version's.
|
|
2828
|
+
const versioned = path !== NPM_VERSIONS_PATH && path !== NPM_STATS_PATH && path !== NPM_ADVISORIES_PATH
|
|
2829
|
+
const otherSpec = params.get('otherVersion') ?? ''
|
|
2830
|
+
if (!isNpmPackageName(name) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
|
|
2831
|
+
|| (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
|
|
2832
|
+
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
|
|
2833
|
+
sendJson(res, 400, { error: 'bad-package' }); return
|
|
2834
|
+
}
|
|
2835
|
+
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
2836
|
+
if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
2837
|
+
const reader = await npmReader(deps, cookie)
|
|
2838
|
+
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2839
|
+
const controller = new AbortController()
|
|
2840
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2841
|
+
res.on('close', onClose)
|
|
2842
|
+
// A reader who lost their session, or their private access to an answer
|
|
2843
|
+
// that needed it, while the registry was asked gets nothing.
|
|
2844
|
+
const recheck = async (isPrivate: boolean) => {
|
|
2845
|
+
const current = await npmReader(deps, cookie)
|
|
2846
|
+
if (current == null) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
|
|
2847
|
+
if (isPrivate && !canReadPrivateNpm(current, name)) { sendJson(res, 404, { error: 'package-not-found' }); return false }
|
|
2848
|
+
return !res.destroyed
|
|
2849
|
+
}
|
|
2850
|
+
const reply = async (isPrivate: boolean, body: Record<string, unknown>) => { if (await recheck(isPrivate)) sendJson(res, 200, body) }
|
|
2851
|
+
// The reader's GitHub token, for GitHub's rate limits and never for what it
|
|
2852
|
+
// shows: asked for once, and only where an answer isn't kept (npm-insights.ts).
|
|
2853
|
+
let token: Promise<string | null> | undefined
|
|
2854
|
+
const githubToken = () => token ??= ensureUserAccessToken(deps.config, deps.db, reader.userId, Date.now()).catch(() => null)
|
|
2855
|
+
const privileged = canReadPrivateNpm(reader, name)
|
|
2856
|
+
// Public versions' documents, kept in the database (npm-kept.ts), as
|
|
2857
|
+
// repository risk scans keep them: an exact version's, read on every view,
|
|
2858
|
+
// asked of npm once a day.
|
|
2859
|
+
const keptVersions = npmVersionStore(deps.db, deps.config.debug)
|
|
2860
|
+
// The latest version, read once a request: the package's figures and
|
|
2861
|
+
// advisories go by the repository it names.
|
|
2862
|
+
let latest: Promise<NpmVersionDocument | null> | undefined
|
|
2863
|
+
const latestDoc = () => latest ??= readNpmVersion(name, 'latest', privileged, controller.signal, keptVersions)
|
|
2864
|
+
const advisoryOptions = npmAdvisoryOptions(deps, githubToken, latestDoc)
|
|
2865
|
+
// The figures of the repository the latest version names, noted as its
|
|
2866
|
+
// repository for npmKnown; null where it names none, or they can't be had.
|
|
2867
|
+
const githubFigures = (doc: NpmVersionDocument) => {
|
|
2868
|
+
const repo = githubRepoOf(doc)
|
|
2869
|
+
npmLatestRepo(name, repo)
|
|
2870
|
+
return repo === null ? null : npmGithubStats(repo, githubToken).catch(() => null)
|
|
2871
|
+
}
|
|
2872
|
+
try {
|
|
2873
|
+
// With whether a visible team lists its scope (`teamScoped`): its own
|
|
2874
|
+
// releases, which the viewer doesn't flag as just published.
|
|
2875
|
+
if (path === NPM_VERSIONS_PATH) {
|
|
2876
|
+
const scope = npmPackageScope(name)
|
|
2877
|
+
const [versions, teamScoped] = await Promise.all([readNpmVersions(name, privileged, controller.signal, { times: true }),
|
|
2878
|
+
scope !== null && deps.db.hasTeamNpmScope(scope)])
|
|
2879
|
+
await reply(found(versions).private, { ...versions, teamScoped })
|
|
2880
|
+
return
|
|
2881
|
+
}
|
|
2882
|
+
// Across every published version, as the version list has them, which
|
|
2883
|
+
// each advisory's `affected` indexes; those of the repository its latest
|
|
2884
|
+
// version names, where GitHub says it is public, asked with the reader's
|
|
2885
|
+
// GitHub token, its listing kept as bundle advisories keep it.
|
|
2886
|
+
if (path === NPM_ADVISORIES_PATH) {
|
|
2887
|
+
const versions = found(await readNpmVersions(name, privileged, controller.signal))
|
|
2888
|
+
await reply(versions.private, { name, ...await npmAdvisories(name, versions.versions, advisoryOptions) })
|
|
2889
|
+
return
|
|
2890
|
+
}
|
|
2891
|
+
// The package's, with the repository its latest version names; either
|
|
2892
|
+
// is null where it can't be had.
|
|
2893
|
+
if (path === NPM_STATS_PATH) {
|
|
2894
|
+
// npm's downloads, which need nothing of the version, are asked at once;
|
|
2895
|
+
// none is answered before the package's access is.
|
|
2896
|
+
const downloadsAsked = npmDownloads(name).catch(() => null)
|
|
2897
|
+
const doc = found(await latestDoc())
|
|
2898
|
+
const [downloads, github] = await Promise.all([downloadsAsked, githubFigures(doc)])
|
|
2899
|
+
await reply(doc.private, { name, downloads, github })
|
|
2900
|
+
return
|
|
2901
|
+
}
|
|
2902
|
+
const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptVersions))
|
|
2903
|
+
if (spec === 'latest') latest = Promise.resolve(doc)
|
|
2904
|
+
const repo = githubRepoOf(doc), sha = bundleCommitHash(doc.manifest['gitHead'])
|
|
2905
|
+
// What the viewer asks about a version beside its files, by its route,
|
|
2906
|
+
// and asks for meanwhile below. Its publish commit's tags, where its
|
|
2907
|
+
// repository is public on GitHub and the reader has a token to ask with.
|
|
2908
|
+
// Socket's report, asked only where npm answers for the version without a
|
|
2909
|
+
// token: a private package's name never leaves for Socket. npm's
|
|
2910
|
+
// provenance, asked only where its version says npm keeps one, and npm
|
|
2911
|
+
// answers for the version without a token.
|
|
2912
|
+
const tagsOf = () => repo === null || sha === null ? Promise.resolve([]) : npmCommitTags(repo, sha, doc.version, githubToken)
|
|
2913
|
+
const socketOf = () => doc.private ? Promise.resolve(null) : npmSocketReport(name, doc.version)
|
|
2914
|
+
const provenanceOf = () => doc.private || doc.manifest['provenance'] !== true ? Promise.resolve(null) : npmProvenance(name, doc.version, doc.dist.integrity)
|
|
2915
|
+
// None where tags or Socket's report can't be had; a failure to have
|
|
2916
|
+
// provenance is answered as one, for the viewer to ask again.
|
|
2917
|
+
if (path === NPM_TAGS_PATH) { await reply(doc.private, { name, version: doc.version, tags: await tagsOf().catch(() => []) }); return }
|
|
2918
|
+
if (path === NPM_SOCKET_PATH) { await reply(doc.private, { name, version: doc.version, socket: await socketOf().catch(() => null) }); return }
|
|
2919
|
+
// What calls for a look at the version, as risk tells it (npm-risk.ts),
|
|
2920
|
+
// against `otherVersion` where given, as Compare asks, else against the
|
|
2921
|
+
// one risk picks of its versions, as the Overview asks; trusted however
|
|
2922
|
+
// new and however few download it where a team lists its scope, its own
|
|
2923
|
+
// packages, as a new dependency on a package of such a scope is.
|
|
2924
|
+
if (path === NPM_RISK_PATH) {
|
|
2925
|
+
const old = otherSpec === '' || otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
2926
|
+
const scope = npmPackageScope(name)
|
|
2927
|
+
const teamScoped = scope !== null && await deps.db.hasTeamNpmScope(scope)
|
|
2928
|
+
const assessment = await npmRisk(doc, otherSpec === '' ? 'pick' : old, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped, fromEmpty: otherSpec === '(empty)',
|
|
2929
|
+
ownScope: listed => deps.db.hasTeamNpmScope(listed), privileged, signal: controller.signal })
|
|
2930
|
+
// What it tells is read of both versions: private where either is.
|
|
2931
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2932
|
+
await reply(doc.private || old?.private === true, { ...assessment })
|
|
2933
|
+
return
|
|
2934
|
+
}
|
|
2935
|
+
if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
|
|
2936
|
+
// Every one of its files that needs it pretty-printed (pretty-print.ts
|
|
2937
|
+
// npmAll), or, against another version, the files that changed between
|
|
2938
|
+
// them, both sides' (compared), a line each as it is formatted, or found
|
|
2939
|
+
// kept for a public version; its files read before anything is answered.
|
|
2940
|
+
// A reader who lost their session, or their access, meanwhile has the
|
|
2941
|
+
// rest cut short.
|
|
2942
|
+
if (path === NPM_PRETTY_PATH && params.has('path')) {
|
|
2943
|
+
// One of its files pretty-printed, as one of a bundle's is.
|
|
2944
|
+
const file = prettyRequest(params)
|
|
2945
|
+
if (!file) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
2946
|
+
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2947
|
+
const copy = await deps.prettyCache.npm(doc, file)
|
|
2948
|
+
if (!(await recheck(doc.private))) { copy.stream.destroy(); return }
|
|
2949
|
+
await sendEncoded(req, res, copy, 'text/plain; charset=utf-8', prettySource(file))
|
|
2950
|
+
return
|
|
2951
|
+
}
|
|
2952
|
+
// Against another version, the pairs of its files that changed that the
|
|
2953
|
+
// viewer diffs with renamed names hidden (rename-diff.ts renamedPairs), a
|
|
2954
|
+
// line each as it is worked out, as Compare's pretty-printed files are;
|
|
2955
|
+
// and those that changed in types alone (type-only.ts).
|
|
2956
|
+
if (path === NPM_PRETTY_PATH || path === NPM_TYPE_ONLY_PATH || (path === NPM_RENAME_DIFF_PATH && !diffed)) {
|
|
2957
|
+
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2958
|
+
const other = otherSpec === '' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
2959
|
+
const sides = other && await deps.prettyCache.npmSides(doc, other)
|
|
2960
|
+
const lines = path === NPM_RENAME_DIFF_PATH ? renamedPairs(sides!, !doc.private && !other!.private)
|
|
2961
|
+
: path === NPM_TYPE_ONLY_PATH ? typeOnlyFiles(sides!) : sides ? compared(sides) : await deps.prettyCache.npmAll(doc)
|
|
2962
|
+
const isPrivate = doc.private || other?.private === true
|
|
2963
|
+
if (!(await recheck(isPrivate))) return
|
|
2964
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2965
|
+
await sendLines(res, lines, async () => {
|
|
2966
|
+
const current = await npmReader(deps, cookie)
|
|
2967
|
+
return current != null && (!isPrivate || canReadPrivateNpm(current, name))
|
|
2968
|
+
})
|
|
2969
|
+
return
|
|
2970
|
+
}
|
|
2971
|
+
// One of its JavaScript files diffed with another version's, both
|
|
2972
|
+
// pretty-printed, renamed names hidden (rename-diff.ts).
|
|
2973
|
+
if (diffed) {
|
|
2974
|
+
const { prettyCache } = deps
|
|
2975
|
+
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2976
|
+
const other = found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
2977
|
+
const diff = await renameDiffOf(diffed, async () => [await prettyText(await prettyCache.npm(doc, diffed.base)),
|
|
2978
|
+
await prettyText(await prettyCache.npm(other, diffed.other))], !doc.private && !other.private)
|
|
2979
|
+
if (!(await recheck(doc.private || other.private))) { diff.stream.destroy(); return }
|
|
2980
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2981
|
+
await sendEncoded(req, res, diff, 'application/json')
|
|
2982
|
+
return
|
|
2983
|
+
}
|
|
2984
|
+
// The load stays held until this response is written or abandoned, and
|
|
2985
|
+
// a reader who stops reading it gives it up after a while. One already
|
|
2986
|
+
// gone takes none, as its close has passed.
|
|
2987
|
+
if (res.destroyed) return
|
|
2988
|
+
const download = path === NPM_DOWNLOAD_PATH
|
|
2989
|
+
// A version's files take longest to load: what the viewer asks for beside
|
|
2990
|
+
// them is asked for meanwhile, so that what is answered by the time they
|
|
2991
|
+
// are encoded comes with them (npmKnown), and the rest is on its way when
|
|
2992
|
+
// the viewer asks. Nothing waits on it, and a failure is left for the
|
|
2993
|
+
// viewer's own request to meet. Its whole document, read for its
|
|
2994
|
+
// advisories' version list and the time each version was published
|
|
2995
|
+
// (npmPublishTime), is read only where registry reads leave room for
|
|
2996
|
+
// readers'.
|
|
2997
|
+
if (!download) {
|
|
2998
|
+
const quiet = (asked: Promise<unknown>) => { asked.catch(() => {}) }
|
|
2999
|
+
quiet(npmDownloads(name))
|
|
3000
|
+
quiet(latestDoc().then(newest => newest && githubFigures(newest)))
|
|
3001
|
+
quiet(readNpmVersions(name, privileged, controller.signal, { times: true, spare: true })
|
|
3002
|
+
.then(versions => versions && npmAdvisories(name, versions.versions, advisoryOptions)))
|
|
3003
|
+
quiet(tagsOf())
|
|
3004
|
+
quiet(socketOf())
|
|
3005
|
+
quiet(provenanceOf())
|
|
3006
|
+
}
|
|
3007
|
+
const load = download ? loadNpmTarball(doc) : loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha))
|
|
3008
|
+
res.once('close', load.release)
|
|
3009
|
+
const bytes = await load.result
|
|
3010
|
+
if (!(await recheck(doc.private))) return
|
|
3011
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
3012
|
+
res.writeHead(200, {
|
|
3013
|
+
...download
|
|
3014
|
+
? { 'content-type': 'application/gzip', 'content-disposition': attachmentDisposition(npmTarballFilename(doc.name, doc.version)) }
|
|
3015
|
+
: { 'content-type': 'application/json', 'content-encoding': 'br' },
|
|
3016
|
+
'content-length': String(bytes.byteLength), 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
3017
|
+
})
|
|
3018
|
+
writeResponse(res, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength))
|
|
3019
|
+
} catch (err) {
|
|
3020
|
+
if (err instanceof NpmPackageError || err instanceof PrettyError) { if (!res.headersSent && !res.destroyed) sendJson(res, err.status, { error: err.message }); return }
|
|
3021
|
+
if (err instanceof NpmRiskError) { if (!res.headersSent && !res.destroyed) sendRiskError(res, err); return }
|
|
3022
|
+
if (controller.signal.aborted) return
|
|
3023
|
+
throw err
|
|
3024
|
+
} finally {
|
|
3025
|
+
res.off('close', onClose)
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
|
|
2215
3029
|
async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, id?: string) {
|
|
2216
3030
|
if (!deps.config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
2217
3031
|
const method = req.method ?? 'GET'
|
|
@@ -2252,6 +3066,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2252
3066
|
const path = url.pathname
|
|
2253
3067
|
const method = req.method ?? 'GET'
|
|
2254
3068
|
const cookie = req.headers.cookie
|
|
3069
|
+
const viewing = viewToken(config, cookie) != null
|
|
2255
3070
|
|
|
2256
3071
|
if (req.headers['x-deepview-share'] !== undefined || path.startsWith('/api/shares/')) {
|
|
2257
3072
|
await handlePublicWorkspace(req, res, deps, url); return
|
|
@@ -2267,6 +3082,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2267
3082
|
} : {}) } })
|
|
2268
3083
|
return
|
|
2269
3084
|
}
|
|
3085
|
+
if (viewing && underPrefix(path, VIEW_PREFIXES) && !viewAllows(method, path)) { sendViewOnly(res); return }
|
|
2270
3086
|
// OAuth: start → redirect to GitHub with the CSRF state cookie.
|
|
2271
3087
|
if (path === LOGIN_PATH) {
|
|
2272
3088
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
@@ -2280,6 +3096,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2280
3096
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2281
3097
|
try {
|
|
2282
3098
|
if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
|
|
3099
|
+
if (viewing) { sendViewOnly(res); return }
|
|
2283
3100
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
2284
3101
|
if (!s) return
|
|
2285
3102
|
const result = await issueOAuthCallback(config, db, s.session, url.searchParams, cookie)
|
|
@@ -2287,7 +3104,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2287
3104
|
res.end(); return
|
|
2288
3105
|
}
|
|
2289
3106
|
const result = await handleCallback(url.searchParams, cookie, { config, db, avatarStore })
|
|
2290
|
-
|
|
3107
|
+
// A new sign-in never resumes an earlier view.
|
|
3108
|
+
const setCookies = viewing ? [...result.setCookies, clearViewCookie(config)] : result.setCookies
|
|
3109
|
+
res.writeHead(302, { location: result.location, 'set-cookie': setCookies, 'cache-control': 'no-store' })
|
|
2291
3110
|
res.end()
|
|
2292
3111
|
} catch (err) {
|
|
2293
3112
|
if (err instanceof OAuthError) sendJson(res, err.status, { error: err.message })
|
|
@@ -2303,22 +3122,29 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2303
3122
|
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
2304
3123
|
res.end(); return
|
|
2305
3124
|
}
|
|
2306
|
-
// Who am I?
|
|
3125
|
+
// Who am I? While an admin views as another user, that user, plus the
|
|
3126
|
+
// admin as `viewer`. A view that has ended (with the admin's session or
|
|
3127
|
+
// role, or the viewed account) is cleared, returning to the own session.
|
|
2307
3128
|
if (path === SESSION_PATH) {
|
|
2308
3129
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2309
|
-
const
|
|
2310
|
-
|
|
3130
|
+
const now = Date.now()
|
|
3131
|
+
let s = await readSession(config, db, cookie, now)
|
|
3132
|
+
const ended = s == null && viewing
|
|
3133
|
+
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
3134
|
+
const headers: Record<string, string> = ended ? { 'set-cookie': clearViewCookie(config) } : {}
|
|
3135
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
|
|
2311
3136
|
sendJson(res, 200, {
|
|
2312
3137
|
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
2313
3138
|
csrfToken: s.session.csrfToken,
|
|
2314
|
-
|
|
3139
|
+
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
3140
|
+
}, headers)
|
|
2315
3141
|
return
|
|
2316
3142
|
}
|
|
3143
|
+
if (path === VIEW_AS_PATH) { await handleViewAs(req, res, deps, cookie); return }
|
|
2317
3144
|
// Authentication is not workspace access. Keep bootstrap/session/logout
|
|
2318
3145
|
// available so blocked accounts can see their role and sign out, but deny
|
|
2319
3146
|
// every managed data route before reading bodies or looking up resources.
|
|
2320
|
-
const managedDataPath =
|
|
2321
|
-
.some(prefix => path === prefix || path.startsWith(prefix + '/'))
|
|
3147
|
+
const managedDataPath = underPrefix(path, MANAGED_DATA_PREFIXES)
|
|
2322
3148
|
const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
|
|
2323
3149
|
if (managedDataPath && !workspaceSession) return
|
|
2324
3150
|
const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
|
|
@@ -2343,6 +3169,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2343
3169
|
if (shareRoute) {
|
|
2344
3170
|
await handleWorkspaceShare(req, res, deps, cookie, shareRoute[1]!, shareRoute[2]); return
|
|
2345
3171
|
}
|
|
3172
|
+
if (path === '/api/admin/uploads/key') { await handleUploadKey(req, res, deps, cookie); return }
|
|
2346
3173
|
if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
|
|
2347
3174
|
// Cached avatar by user id, served same-origin (the page CSP forbids the
|
|
2348
3175
|
// github CDN). The id in the path keys the browser cache per user, so a user
|
|
@@ -2380,6 +3207,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2380
3207
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2381
3208
|
await handleRemoveRepository(req, res, deps, cookie); return
|
|
2382
3209
|
}
|
|
3210
|
+
if (path === REPO_RISK_PATH) {
|
|
3211
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3212
|
+
await handleRepositoryRisk(res, deps, cookie, url.searchParams); return
|
|
3213
|
+
}
|
|
2383
3214
|
if (path === BROWSABLE_REPOS_PATH) {
|
|
2384
3215
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2385
3216
|
await handleBrowsableRepositories(res, deps, cookie); return
|
|
@@ -2434,12 +3265,22 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2434
3265
|
await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
|
|
2435
3266
|
return
|
|
2436
3267
|
}
|
|
2437
|
-
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download)$/iu.exec(path)
|
|
3268
|
+
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only)$/iu.exec(path)
|
|
2438
3269
|
if (bundleRead) {
|
|
3270
|
+
const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
|
|
3271
|
+
if (part === 'rename-diff' || part === 'type-only' || (part === 'pretty' && url.searchParams.has('other'))) {
|
|
3272
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3273
|
+
if (part === 'rename-diff' && url.searchParams.has('path')) { await handleBundleRenameDiff(req, res, deps, session, id, url.searchParams); return }
|
|
3274
|
+
// The pairs a scoped Compare diffs, of the files it compares within its scope.
|
|
3275
|
+
const [linesOf, scope] = part === 'pretty' ? [compared, ''] : [part === 'type-only' ? typeOnlyFiles : renamedPairs, url.searchParams.get('scope') ?? '']
|
|
3276
|
+
await handleBundleChanges(res, deps, session, id, url.searchParams.get('other') ?? '', async (cache, base, other) => linesOf(await cache.bundleSides(base, other, scope))); return
|
|
3277
|
+
}
|
|
2439
3278
|
if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
|
|
2440
|
-
|
|
2441
|
-
if (
|
|
2442
|
-
|
|
3279
|
+
if (part === 'download') { await handleGetBundle(req, res, deps, session, id); return }
|
|
3280
|
+
if (part !== 'pretty') { await handleBundleCache(req, res, deps, session, id, bundleCache && (rec => bundleCache.open(rec, part as BundleCachePart))); return }
|
|
3281
|
+
const file = prettyRequest(url.searchParams)
|
|
3282
|
+
if (!file) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
3283
|
+
await handleBundleCache(req, res, deps, session, id, prettyCache && (rec => prettyCache.bundle(rec, file)), 'text/plain; charset=utf-8', prettySource(file))
|
|
2443
3284
|
return
|
|
2444
3285
|
}
|
|
2445
3286
|
if (path === BUNDLE_CREATE_PATH) {
|
|
@@ -2493,7 +3334,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2493
3334
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2494
3335
|
await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId')); return
|
|
2495
3336
|
}
|
|
2496
|
-
if (path ===
|
|
3337
|
+
if (path === REPORT_QUERY_PATH) {
|
|
2497
3338
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2498
3339
|
await handleQueryReports(req, res, deps, cookie); return
|
|
2499
3340
|
}
|
|
@@ -2549,6 +3390,63 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2549
3390
|
if (path === TEAM_SET_MEMBER_PATH) { await handleSetTeamMember(req, res, deps, cookie); return }
|
|
2550
3391
|
await handleRemoveTeamMember(req, res, deps, cookie); return
|
|
2551
3392
|
}
|
|
3393
|
+
if (path === '/api/admin/teams/set-dependency-review') {
|
|
3394
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3395
|
+
await handleSetTeamDependencyReview(req, res, deps, cookie); return
|
|
3396
|
+
}
|
|
3397
|
+
if (path === TEAM_SET_NPM_SCOPES_PATH) {
|
|
3398
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3399
|
+
await handleSetTeamNpmScopes(req, res, deps, cookie); return
|
|
3400
|
+
}
|
|
3401
|
+
if (path === '/api/npm/reviews/latest') {
|
|
3402
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3403
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
3404
|
+
if (!s) return
|
|
3405
|
+
const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
|
|
3406
|
+
if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
|
|
3407
|
+
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
3408
|
+
}
|
|
3409
|
+
const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
|
|
3410
|
+
const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
|
|
3411
|
+
if (visible) sendJson(res, 200, { review: visible[0] ?? null })
|
|
3412
|
+
return
|
|
3413
|
+
}
|
|
3414
|
+
if (path === '/api/npm/reviews/discard') {
|
|
3415
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3416
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
3417
|
+
if (!s) return
|
|
3418
|
+
let body: Record<string, unknown>
|
|
3419
|
+
try {
|
|
3420
|
+
const raw = await readJsonBody(req, 4096)
|
|
3421
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('bad-body')
|
|
3422
|
+
body = raw as Record<string, unknown>
|
|
3423
|
+
} catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
3424
|
+
const id = body['reviewId'], revision = body['revision']
|
|
3425
|
+
if (typeof id !== 'string' || !id || id.length > 100 || !Number.isSafeInteger(revision) || Number(revision) < 1) {
|
|
3426
|
+
sendJson(res, 400, { error: 'bad-review' }); return
|
|
3427
|
+
}
|
|
3428
|
+
await deps.db.discardDependencyReview(s.session.id, id, revision as number)
|
|
3429
|
+
sendJson(res, 200, { ok: true }); return
|
|
3430
|
+
}
|
|
3431
|
+
if (path === '/api/npm/reviews/mine') {
|
|
3432
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3433
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
3434
|
+
if (!s) return
|
|
3435
|
+
const id = url.searchParams.get('beforeId'), time = url.searchParams.get('beforeTime')
|
|
3436
|
+
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
3437
|
+
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
3438
|
+
}
|
|
3439
|
+
const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
3440
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
|
|
3441
|
+
if (reviews) sendJson(res, 200, { ...page, reviews })
|
|
3442
|
+
return
|
|
3443
|
+
}
|
|
3444
|
+
if (path === '/api/npm/reviews') {
|
|
3445
|
+
await handleNpmReviews(req, res, deps, cookie, url.searchParams); return
|
|
3446
|
+
}
|
|
3447
|
+
if (NPM_PATHS.has(path)) {
|
|
3448
|
+
await handleNpm(req, res, deps, cookie, path, url.searchParams); return
|
|
3449
|
+
}
|
|
2552
3450
|
if (path === LOGOUT_PATH) { await handleLogout(req, res, deps, cookie); return }
|
|
2553
3451
|
if (deps.serveStatic?.(req, res)) return
|
|
2554
3452
|
if (deps.next) { deps.next(req, res); return }
|
|
@@ -2561,7 +3459,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2561
3459
|
// Feeds lease a connection per poll, never across streaming sleeps. This
|
|
2562
3460
|
// also covers capability-authenticated feeds handled by public-workspace.
|
|
2563
3461
|
const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
|
|
2564
|
-
const
|
|
3462
|
+
const serve = () => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)
|
|
3463
|
+
const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
|
|
2565
3464
|
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2566
3465
|
console.warn('managed: request handler error:', err)
|
|
2567
3466
|
if (res.headersSent) { try { res.destroy() } catch {} }
|