@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.38
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-4XCDFGDP.js +10 -0
- package/out/chunk-5FKRIJGF.js +1 -0
- package/out/chunk-6Z5XLSPJ.js +1 -0
- package/out/chunk-ABW5FVSN.js +1 -0
- package/out/chunk-BJKRVROW.js +1 -0
- package/out/chunk-BYUEDJOM.js +7 -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-DCQX2J3C.js +223 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-F4OMDNEY.js +1 -0
- package/out/chunk-FU4MTW7F.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-JAKWIZWL.js +2 -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-OJ5DGP5E.js +33 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-SD2WJAXN.js +48 -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-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 +88 -7
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +164 -0
- package/risk/files/runs-code.js +329 -0
- package/risk/files/script.js +112 -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 +222 -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 +33 -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 +68 -11
- 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-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 +943 -52
- 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 +413 -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,34 @@ 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
|
+
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,
|
|
225
|
+
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH])
|
|
226
|
+
// How long an npm response may go unread before its connection is dropped.
|
|
227
|
+
const NPM_RESPONSE_IDLE_MS = 60_000
|
|
144
228
|
const MAX_TEAM_NAME = 100
|
|
229
|
+
// Managed data routes, and with them the authentication routes, which an
|
|
230
|
+
// admin's view as another user covers. Other paths fall through to a combined
|
|
231
|
+
// e2e server, whose own authentication a view never touches.
|
|
232
|
+
const MANAGED_DATA_PREFIXES = ['/api/admin', '/api/reports', '/api/bundles', '/api/teams', '/api/avatar', '/api/github', '/api/npm']
|
|
233
|
+
const VIEW_PREFIXES = [...MANAGED_DATA_PREFIXES, '/api/auth', '/api/oauth']
|
|
234
|
+
const underPrefix = (path: string, prefixes: readonly string[]) => prefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))
|
|
145
235
|
|
|
146
236
|
async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
|
|
147
237
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
@@ -242,6 +332,9 @@ export interface ManagedHttpDeps {
|
|
|
242
332
|
bundleStore: BundleStore
|
|
243
333
|
bundleCache?: BundleCache
|
|
244
334
|
reportSourcesCache?: ReportSourcesCache
|
|
335
|
+
prettyCache?: PrettyCache
|
|
336
|
+
// The public npm cache, where risk keeps public packages' assessments (npm-risk.ts).
|
|
337
|
+
npmCacheStorage?: CacheStorage
|
|
245
338
|
uploadStore?: BlobStore
|
|
246
339
|
originGate: OriginGate
|
|
247
340
|
isShuttingDown: () => boolean
|
|
@@ -293,11 +386,28 @@ async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Bu
|
|
|
293
386
|
return Buffer.concat(chunks)
|
|
294
387
|
}
|
|
295
388
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
389
|
+
// A file upload's content, opening a body the browser sealed to this session
|
|
390
|
+
// (common/managed/upload-seal.ts). Link reports are never sent in parts.
|
|
391
|
+
async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: ManagedSession, kind: UploadKind | 'links'): Promise<Buffer> {
|
|
392
|
+
const maxBytes = kind === 'bundles' ? deps.config.maxBundleBytes : deps.config.maxReportBytes
|
|
393
|
+
const sealed = req.headers[UPLOAD_SEAL_HEADER] !== undefined
|
|
394
|
+
const limit = sealed ? maxSealedBytes(maxBytes) : maxBytes
|
|
395
|
+
let bytes
|
|
396
|
+
if (kind === 'links' || req.headers['x-upload-id'] == null) bytes = await readBodyBytes(req, limit)
|
|
397
|
+
else if (deps.uploadStore) bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit)
|
|
398
|
+
else throw new Error('bad-upload')
|
|
399
|
+
return sealed ? await openSessionUpload(session.uploadKey, bytes, maxBytes) : bytes
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// GET /api/admin/uploads/key — the session's upload public key, created on
|
|
403
|
+
// first use. Only this session's server-side private key opens what is sealed to it.
|
|
404
|
+
async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
405
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
406
|
+
const s = await readManageSession(res, deps, cookie)
|
|
407
|
+
if (!s) return
|
|
408
|
+
const key = s.session.uploadKey ?? await deps.db.ensureSessionUploadKey(s.session.id, newUploadKey())
|
|
409
|
+
if (!key) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
410
|
+
sendJson(res, 200, { key: uploadPublicKey(key) })
|
|
301
411
|
}
|
|
302
412
|
|
|
303
413
|
async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
|
|
@@ -324,7 +434,7 @@ async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
324
434
|
}
|
|
325
435
|
if (!validUploadPart(id, index, maxBytes)) { await discard(); sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
326
436
|
let bytes
|
|
327
|
-
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
|
|
437
|
+
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxSealedBytes(maxBytes))) }
|
|
328
438
|
catch { await discard(); sendJson(res, 413, { error: 'too-large' }); return }
|
|
329
439
|
if (bytes.length === 0) { await discard(); sendJson(res, 400, { error: 'empty' }); return }
|
|
330
440
|
if (await manageMutation(req, res, deps, cookie) == null) return
|
|
@@ -341,23 +451,70 @@ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES
|
|
|
341
451
|
|
|
342
452
|
// The router enforces same-origin for every request. A mutation also needs an
|
|
343
453
|
// 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
|
-
|
|
454
|
+
// Returns the session, or null after having already sent the 401/403. An
|
|
455
|
+
// admin's view as another user writes nothing; `view` admits only the
|
|
456
|
+
// endpoints that end it.
|
|
457
|
+
async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
346
458
|
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
347
459
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
348
460
|
const csrf = firstHeader(req.headers['x-csrf-token'])
|
|
349
461
|
if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
|
|
350
462
|
if (!safeEqual(csrf, s.session.csrfToken)) { sendJson(res, 403, { error: 'csrf-mismatch' }); return null }
|
|
463
|
+
if (s.session.viewer && !view) { sendViewOnly(res); return null }
|
|
351
464
|
return s
|
|
352
465
|
}
|
|
353
466
|
|
|
354
|
-
|
|
467
|
+
function sendViewOnly(res: ServerResponse): void {
|
|
468
|
+
sendJson(res, 403, { error: VIEW_ONLY_ERROR })
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Reads stay available while an admin views as another user. Everything else
|
|
472
|
+
// is refused before dispatch, except ending the view or signing out, and the
|
|
473
|
+
// batch report preview, a POST without effects. Issue authorization would
|
|
474
|
+
// grant GitHub access on the viewed user's behalf.
|
|
475
|
+
function viewAllows(method: string, path: string): boolean {
|
|
476
|
+
if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH
|
|
477
|
+
return (method === 'DELETE' && path === VIEW_AS_PATH) || (method === 'POST' && (path === LOGOUT_PATH || path === REPORT_QUERY_PATH))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// POST /api/auth/logout — drop the session (same-origin + CSRF), which also
|
|
481
|
+
// ends a view opened from it.
|
|
355
482
|
async function handleLogout(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
356
483
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
357
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
484
|
+
const s = await checkMutation(req, res, deps, cookie, { view: true })
|
|
358
485
|
if (s == null) return
|
|
359
486
|
await endSession(deps.config, deps.db, cookie)
|
|
360
|
-
|
|
487
|
+
const cleared = clearCookie(deps.config.sessionCookieName, deps.config.cookieSecure)
|
|
488
|
+
res.writeHead(204, { 'set-cookie': s.session.viewer ? [cleared, clearViewCookie(deps.config)] : cleared, 'cache-control': 'no-store' })
|
|
489
|
+
res.end()
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// POST /api/auth/view-as — an admin opens a read-only view as another user
|
|
493
|
+
// (body { userId }); DELETE ends it. Viewing as oneself is refused.
|
|
494
|
+
async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
495
|
+
const method = req.method ?? 'GET'
|
|
496
|
+
if (method === 'DELETE') {
|
|
497
|
+
if (await checkMutation(req, res, deps, cookie, { view: true }) == null) return
|
|
498
|
+
await endViewSession(deps.config, deps.db, cookie)
|
|
499
|
+
res.writeHead(204, { 'set-cookie': clearViewCookie(deps.config), 'cache-control': 'no-store' })
|
|
500
|
+
res.end(); return
|
|
501
|
+
}
|
|
502
|
+
if (method !== 'POST') { send405(res, 'POST, DELETE'); return }
|
|
503
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
504
|
+
if (s == null) return
|
|
505
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
506
|
+
let body: unknown
|
|
507
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
508
|
+
const userId = (body as { userId?: unknown } | null)?.userId
|
|
509
|
+
if (typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
510
|
+
if (userId === s.user.id) { sendJson(res, 400, { error: 'cannot-view-as-self' }); return }
|
|
511
|
+
const target = (await deps.db.listUsers()).find(user => user.id === userId)
|
|
512
|
+
if (!target) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
513
|
+
// The insert itself rechecks the admin session and role.
|
|
514
|
+
const setCookie = await startViewSession(deps.config, deps.db, s.session, userId, Date.now())
|
|
515
|
+
if (setCookie == null) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
516
|
+
await activity(deps, s.user, 'access', `viewed as ${target.login}`)
|
|
517
|
+
res.writeHead(204, { 'set-cookie': setCookie, 'cache-control': 'no-store' })
|
|
361
518
|
res.end()
|
|
362
519
|
}
|
|
363
520
|
|
|
@@ -891,7 +1048,7 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
891
1048
|
if (!requireManageRole(res, s.user)) return
|
|
892
1049
|
let bytes: Buffer
|
|
893
1050
|
try {
|
|
894
|
-
bytes = await readUploadBody(req, deps, s.session
|
|
1051
|
+
bytes = await readUploadBody(req, deps, s.session, 'reports')
|
|
895
1052
|
} catch (err) {
|
|
896
1053
|
const tooLarge = err instanceof Error && err.message === 'too-large'
|
|
897
1054
|
sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
|
|
@@ -1050,6 +1207,18 @@ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
|
|
|
1050
1207
|
return { githubUserId, token }
|
|
1051
1208
|
}
|
|
1052
1209
|
|
|
1210
|
+
// After a catalog response: read the commit details it lacked with this
|
|
1211
|
+
// user's repository access, for the next catalog to send.
|
|
1212
|
+
async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, missing: readonly CatalogCommit[]): Promise<void> {
|
|
1213
|
+
let browser: ReturnType<typeof createRepositoryBrowser> | undefined
|
|
1214
|
+
await backfillBundleCommits(deps.db, userId, missing, async repoId => {
|
|
1215
|
+
const repo = (await deps.db.listAllRepos()).find(item => item.repoId === repoId)
|
|
1216
|
+
if (!repo) return null
|
|
1217
|
+
browser ??= createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, userId))
|
|
1218
|
+
return browser.reader(repo)
|
|
1219
|
+
}).catch(err => { console.warn('managed: commit backfill failed:', err) })
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1053
1222
|
// Listing connected repository names uses managed access only. Verify GitHub
|
|
1054
1223
|
// access after selection, before returning any refs or source directory data.
|
|
1055
1224
|
async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
@@ -1117,8 +1286,18 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1117
1286
|
try {
|
|
1118
1287
|
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
|
|
1119
1288
|
const reader = await browser.reader(access.repo)
|
|
1289
|
+
// Microseconds, so overlapping browses practically never share a listing time.
|
|
1290
|
+
const observedUs = Math.round((performance.timeOrigin + performance.now()) * 1000)
|
|
1120
1291
|
const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
|
|
1121
1292
|
const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
|
|
1293
|
+
const { tagCommits, tagsComplete, ...listed } = revisions ?? { tagCommits: [], tagsComplete: false }
|
|
1294
|
+
// Create a bundle refreshes the tags bundle catalogs show, and keeps a tag
|
|
1295
|
+
// its revision input resolves even when the listed page leaves it out.
|
|
1296
|
+
// Like other GitHub caches, written before the access fence below.
|
|
1297
|
+
const tags = refs ? tagCommits : ref.length > 5 && ref.startsWith('tags/') && contents ? [{ name: ref.slice(5), sha: contents.commit }] : []
|
|
1298
|
+
if (refs || tags.length > 0) {
|
|
1299
|
+
await deps.db.refreshGithubTags(access.repo.repoId, tags, refs && tagsComplete, observedUs).catch(err => { console.warn('managed: tag cache refresh failed:', err) })
|
|
1300
|
+
}
|
|
1122
1301
|
await reader.recheckAccess()
|
|
1123
1302
|
const current = await authorize()
|
|
1124
1303
|
if (!current) return
|
|
@@ -1127,7 +1306,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1127
1306
|
if (JSON.stringify({ ...current.repo, cachedDefaultBranch: access.repo.cachedDefaultBranch }) !== JSON.stringify(access.repo)) {
|
|
1128
1307
|
sendJson(res, 409, { error: 'repository-changed' }); return
|
|
1129
1308
|
}
|
|
1130
|
-
sendJson(res, 200, refs ? { ...
|
|
1309
|
+
sendJson(res, 200, refs ? { ...listed, ...(withDefault ? {
|
|
1131
1310
|
defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
|
|
1132
1311
|
} : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
|
|
1133
1312
|
} catch (err) {
|
|
@@ -1147,17 +1326,144 @@ function prebuildBundle(deps: ManagedHttpDeps, id: string) {
|
|
|
1147
1326
|
})().catch(err => console.warn('managed: bundle cache build failed:', err)))
|
|
1148
1327
|
}
|
|
1149
1328
|
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
async function
|
|
1329
|
+
// Brotli-encoded bytes as they are: the browser's HTTP stack decompresses
|
|
1330
|
+
// them without a Brotli JS dependency.
|
|
1331
|
+
async function sendEncoded(req: IncomingMessage, res: ServerResponse, blob: OpenedBlob, contentType: string, headers: Record<string, string> = {}) {
|
|
1332
|
+
res.writeHead(200, {
|
|
1333
|
+
...headers, 'content-type': contentType, 'content-encoding': 'br',
|
|
1334
|
+
...(blob.size == null ? {} : { 'content-length': String(blob.size) }), 'cache-control': 'private, no-store',
|
|
1335
|
+
'x-content-type-options': 'nosniff',
|
|
1336
|
+
})
|
|
1337
|
+
if (req.method === 'HEAD') { blob.stream.destroy(); res.end(); return }
|
|
1338
|
+
try { await pipeline(blob.stream, res) } catch { res.destroy() }
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
// How often a scan of repositories' risk checks again that its reader is
|
|
1343
|
+
// still an admin, between the lines it sends; and how long its connection may
|
|
1344
|
+
// go idle, unread or with no line to send, before the scan is stopped.
|
|
1345
|
+
const REPO_RISK_RECHECK_MS = 10_000
|
|
1346
|
+
const REPO_RISK_IDLE_MS = 5 * 60_000
|
|
1347
|
+
|
|
1348
|
+
// The repositories a scan of risk reads, by its query: every connected one;
|
|
1349
|
+
// a team's (`team`, its id), each in the team's directories of it; or one
|
|
1350
|
+
// (`repo`, its id). Null for a team or repository there is none of, or a
|
|
1351
|
+
// query naming both.
|
|
1352
|
+
async function riskRepositories(db: ManagedDb, query: URLSearchParams): Promise<(SelectedRepo & { paths?: string[] })[] | null> {
|
|
1353
|
+
const connected = await db.listSelectedRepos()
|
|
1354
|
+
const repoId = query.get('repo'), teamId = query.get('team')
|
|
1355
|
+
if (teamId !== null && repoId !== null) return null
|
|
1356
|
+
if (repoId !== null) {
|
|
1357
|
+
const repo = connected.find(item => String(item.repoId) === repoId)
|
|
1358
|
+
return repo ? [repo] : null
|
|
1359
|
+
}
|
|
1360
|
+
if (teamId === null) return connected
|
|
1361
|
+
const team = (await db.listTeams()).find(item => item.id === teamId)
|
|
1362
|
+
if (!team) return null
|
|
1363
|
+
// By repository, its directories; null for the whole of it.
|
|
1364
|
+
const paths = new Map<number, string[] | null>()
|
|
1365
|
+
for (const { repoId: id, path } of team.repos) {
|
|
1366
|
+
const kept = paths.get(id)
|
|
1367
|
+
paths.set(id, path === null || kept === null ? null : [...kept ?? [], path])
|
|
1368
|
+
}
|
|
1369
|
+
return connected.flatMap(repo => {
|
|
1370
|
+
const of = paths.get(repo.repoId)
|
|
1371
|
+
return of === undefined ? [] : [of === null ? repo : { ...repo, paths: of }]
|
|
1372
|
+
})
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// GET /api/admin/repositories/risk: every connected repository's npm
|
|
1376
|
+
// packages, or a team's or one repository's (riskRepositories), as its
|
|
1377
|
+
// lockfiles lock them at its default branch's head, each assessed by risk
|
|
1378
|
+
// (repository-risk.ts), a line as each is found. A
|
|
1379
|
+
// repository is read with the admin's own GitHub access, as browsing it is
|
|
1380
|
+
// (repository-browser.ts), and that access checked again once it is read,
|
|
1381
|
+
// before any of it is told; packages as the npm viewer's admins read them, in
|
|
1382
|
+
// spare assessments (npm-risk.ts), trusted however new where a team lists
|
|
1383
|
+
// their scope, and each lockfile's asked of npm's advisories at once. The
|
|
1384
|
+
// reader leaving, losing their admin role or the server shutting down stops
|
|
1385
|
+
// it.
|
|
1386
|
+
async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1387
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
1388
|
+
if (!s) return
|
|
1389
|
+
const repos = await riskRepositories(deps.db, query)
|
|
1390
|
+
if (!repos) {
|
|
1391
|
+
const both = query.has('team') && query.has('repo')
|
|
1392
|
+
sendJson(res, both ? 400 : 404, { error: both ? 'bad-scope' : 'no-repository' })
|
|
1393
|
+
return
|
|
1394
|
+
}
|
|
1395
|
+
const controller = new AbortController()
|
|
1396
|
+
res.on('close', () => { controller.abort() })
|
|
1397
|
+
res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
|
|
1398
|
+
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
|
|
1399
|
+
const scopes = new Map<string, Promise<boolean>>()
|
|
1400
|
+
const versions = npmVersionStore(deps.db, deps.config.debug)
|
|
1401
|
+
const lines = repositoryRiskLines({
|
|
1402
|
+
repos: () => Promise.resolve(repos),
|
|
1403
|
+
async open(repo) {
|
|
1404
|
+
const reader = await browser.reader(repo)
|
|
1405
|
+
const commit = await reader.commit('')
|
|
1406
|
+
const client = createClient({ token: reader.readToken() })
|
|
1407
|
+
return {
|
|
1408
|
+
commit,
|
|
1409
|
+
lockfiles: async () => (await upstreamGithub(() => client.findRepoLockfiles({ repo: repo.fullName, sha: commit }))).lockfiles,
|
|
1410
|
+
file: path => upstreamGithub(() => client.getRepoFile({ repo: repo.fullName, path, ref: commit })),
|
|
1411
|
+
recheck: async () => { await reader.recheckAccess() },
|
|
1412
|
+
}
|
|
1413
|
+
},
|
|
1414
|
+
teamScoped(name) {
|
|
1415
|
+
const scope = npmPackageScope(name)
|
|
1416
|
+
if (scope === null) return Promise.resolve(false)
|
|
1417
|
+
if (!scopes.has(scope)) scopes.set(scope, deps.db.hasTeamNpmScope(scope))
|
|
1418
|
+
return scopes.get(scope)!
|
|
1419
|
+
},
|
|
1420
|
+
// Public versions' documents kept in the database (npm-kept.ts), as the
|
|
1421
|
+
// viewer keeps them: whether npm still has one, its whole document tells
|
|
1422
|
+
// risk.
|
|
1423
|
+
version: async (name, version, signal) => found(await readNpmVersion(name, version, true, signal, versions)),
|
|
1424
|
+
assess: (doc, teamScoped, signal) => npmRisk(doc, 'pick', { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
|
|
1425
|
+
teamScoped, privileged: true, signal, spare: true }),
|
|
1426
|
+
removed: (name, version, teamScoped, signal) => npmRiskRemoved(name, version, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
|
|
1427
|
+
teamScoped, privileged: true, signal }),
|
|
1428
|
+
risks: riskLineStore(deps.db, RISK_KEPT_MS, deps.config.debug),
|
|
1429
|
+
// As a bundle's advisories are asked (bundle-advisories.ts), npm's registry
|
|
1430
|
+
// and, for packages anyone may read, OSV: no repository's. Each lockfile's
|
|
1431
|
+
// audit is bounded on its own, for one npm doesn't answer to fail alone and
|
|
1432
|
+
// the scan to carry on.
|
|
1433
|
+
async advisories(packages, scanning) {
|
|
1434
|
+
const signal = AbortSignal.any([scanning, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)])
|
|
1435
|
+
const audited = await fetchBundleAdvisories(packages.map(pkg => ({ ecosystem: 'npm' as const, ...pkg })), signal,
|
|
1436
|
+
{ debug: deps.config.debug, cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug) })
|
|
1437
|
+
if (audited.status !== 200) throw new NpmPackageError(502, audited.body.error)
|
|
1438
|
+
return audited.body
|
|
1439
|
+
},
|
|
1440
|
+
}, controller.signal)
|
|
1441
|
+
let checked = Date.now()
|
|
1442
|
+
await sendLines(res, lines, async () => {
|
|
1443
|
+
if (deps.isShuttingDown()) return false
|
|
1444
|
+
if (Date.now() - checked < REPO_RISK_RECHECK_MS) return true
|
|
1445
|
+
if ((await readSession(deps.config, deps.db, cookie, Date.now()))?.user.role !== 'admin') return false
|
|
1446
|
+
checked = Date.now()
|
|
1447
|
+
return true
|
|
1448
|
+
})
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// A pretty-printed copy names the file it was formatted from, by the hash it
|
|
1452
|
+
// was asked for and its source was found to have, for the viewer to check.
|
|
1453
|
+
const prettySource = ({ hash }: PrettyRequest): Record<string, string> => ({ 'x-source-hash': hash })
|
|
1454
|
+
|
|
1455
|
+
// GET /api/bundles/:id/{metadata,contents,pretty}: what `open` reads of the
|
|
1456
|
+
// bundle, already encoded; it is absent where its cache is.
|
|
1457
|
+
async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string,
|
|
1458
|
+
open: ((rec: ManagedBundle) => Promise<OpenedBlob>) | undefined, contentType = 'application/json', headers: Record<string, string> = {}) {
|
|
1153
1459
|
const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
|
|
1154
1460
|
if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1155
1461
|
const rec = access.bundle
|
|
1156
1462
|
if (!rec) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1157
|
-
if (!
|
|
1463
|
+
if (!open) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1158
1464
|
let cached
|
|
1159
|
-
try { cached = await
|
|
1160
|
-
catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
|
|
1465
|
+
try { cached = await open(rec) }
|
|
1466
|
+
catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
|
|
1161
1467
|
// A cold build can outlast a session or membership change.
|
|
1162
1468
|
const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
|
|
1163
1469
|
if (!current?.bundle) {
|
|
@@ -1165,13 +1471,55 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
|
|
|
1165
1471
|
sendJson(res, current ? 404 : 401, { error: current ? 'no-bundle' : 'unauthenticated' })
|
|
1166
1472
|
return
|
|
1167
1473
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1474
|
+
await sendEncoded(req, res, cached, contentType, headers)
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// GET /api/bundles/:id/rename-diff: one of its JavaScript files diffed with
|
|
1478
|
+
// one of the `other` bundle's, both pretty-printed, renamed names hidden
|
|
1479
|
+
// (rename-diff.ts), for a reader of both, checked again once it is worked out.
|
|
1480
|
+
async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
|
|
1481
|
+
const otherId = params.get('other') ?? '', request = renameDiffRequest(params)
|
|
1482
|
+
if (!request || !/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
1483
|
+
const readable = async () => {
|
|
1484
|
+
const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1485
|
+
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
1486
|
+
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
|
|
1487
|
+
return [base.bundle, other.bundle] as const
|
|
1488
|
+
}
|
|
1489
|
+
const records = await readable()
|
|
1490
|
+
if (!records) return
|
|
1491
|
+
const { prettyCache } = deps
|
|
1492
|
+
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1493
|
+
let diff
|
|
1494
|
+
try {
|
|
1495
|
+
diff = await renameDiffOf(request, async () => [await prettyText(await prettyCache.bundle(records[0], request.base)),
|
|
1496
|
+
await prettyText(await prettyCache.bundle(records[1], request.other))])
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return
|
|
1499
|
+
}
|
|
1500
|
+
if (await readable()) await sendEncoded(req, res, diff, 'application/json')
|
|
1501
|
+
else diff.stream.destroy()
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// GET /api/bundles/:id/pretty?other=: the files that changed between it and
|
|
1505
|
+
// the `other` bundle, both sides' pretty-printed (pretty-print.ts compared), a
|
|
1506
|
+
// line each as it is found kept or formatted; GET
|
|
1507
|
+
// /api/bundles/:id/rename-diff?other=, the pairs of them its viewer diffs
|
|
1508
|
+
// with renamed names hidden (rename-diff.ts renamedPairs), a line each as it
|
|
1509
|
+
// is worked out; GET /api/bundles/:id/type-only?other=, those that changed in
|
|
1510
|
+
// types alone (type-only.ts). For a reader of both, checked again before each.
|
|
1511
|
+
async function handleBundleChanges(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, otherId: string,
|
|
1512
|
+
linesOf: (cache: PrettyCache, base: ManagedBundle, other: ManagedBundle) => Promise<AsyncIterable<unknown>>) {
|
|
1513
|
+
if (!/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-bundle' }); return }
|
|
1514
|
+
const access = () => Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1515
|
+
const [base, other] = await access()
|
|
1516
|
+
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1517
|
+
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1518
|
+
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1519
|
+
let lines
|
|
1520
|
+
try { lines = await linesOf(deps.prettyCache, base.bundle, other.bundle) }
|
|
1521
|
+
catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
|
|
1522
|
+
await sendLines(res, lines, async () => (await access()).every(snapshot => snapshot?.bundle))
|
|
1175
1523
|
}
|
|
1176
1524
|
|
|
1177
1525
|
// Published dependency advisories require security access, independently of access to
|
|
@@ -1209,7 +1557,8 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
1209
1557
|
})) : null
|
|
1210
1558
|
if (res.destroyed || (needsGithub && !(await authorize()))) return
|
|
1211
1559
|
const result = await fetchBundleAdvisories(inventory.packages, controller.signal, {
|
|
1212
|
-
debug: deps.config.debug, repoAdvisories, details, githubToken,
|
|
1560
|
+
debug: deps.config.debug, repoAdvisories, details, githubToken,
|
|
1561
|
+
cache: auditCache(deps.config.upstreamCacheDir, deps.db, controller.signal, deps.config.debug, auditedRepos(inventory.packages, repoAdvisories)),
|
|
1213
1562
|
})
|
|
1214
1563
|
if (res.destroyed || !(await authorize())) return
|
|
1215
1564
|
// Return just inventory and public advisories, never source or report data.
|
|
@@ -1226,16 +1575,18 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
1226
1575
|
async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
|
|
1227
1576
|
const before = await deps.db.getBundleCatalog(session.id, Date.now())
|
|
1228
1577
|
const summaries = await bundleSummaries(before.bundles, deps.bundleCache)
|
|
1229
|
-
|
|
1578
|
+
const commits = await bundleCommits(deps.db, before.bundles, summaries)
|
|
1579
|
+
// Even cached storage and commit reads may outlast changes to access or locations.
|
|
1230
1580
|
const catalog = await deps.db.getBundleCatalog(session.id, Date.now())
|
|
1231
1581
|
sendJson(res, 200, {
|
|
1232
1582
|
...catalog,
|
|
1233
1583
|
bundles: catalog.bundles.map(bundle => ({
|
|
1234
|
-
...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
|
|
1584
|
+
...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }), commitInfo: commits.commitInfo(bundle),
|
|
1235
1585
|
})),
|
|
1236
1586
|
maxBytes: deps.config.maxBundleBytes,
|
|
1237
1587
|
})
|
|
1238
1588
|
await backfillBundleSummaries(catalog.bundles, deps.bundleCache)
|
|
1589
|
+
await backfillCatalogCommits(deps, session.userId, commits.missing)
|
|
1239
1590
|
}
|
|
1240
1591
|
|
|
1241
1592
|
type UploadedBundle = Pick<ManagedBundle, 'id' | 'slug' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
|
|
@@ -1283,10 +1634,13 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1283
1634
|
await withBundleBuildLease(deps.db, access.user.id, controller.signal, async signal => {
|
|
1284
1635
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1285
1636
|
signal.throwIfAborted()
|
|
1637
|
+
// Catalogs then show the built bundle's commit details from the cache.
|
|
1638
|
+
const commitCached = cacheCommitDetails(deps.db, reader, input.repoId, input.commit)
|
|
1286
1639
|
const built = await buildRepositoryBundle(access.user.id, {
|
|
1287
1640
|
input, github: access.repo.fullName, token: reader.readToken(), maxBytes: deps.config.maxBundleBytes, scopes: access.scopes,
|
|
1288
1641
|
cacheDir: deps.config.upstreamCacheDir ?? null,
|
|
1289
1642
|
}, signal)
|
|
1643
|
+
await commitCached
|
|
1290
1644
|
signal.throwIfAborted()
|
|
1291
1645
|
await reader.recheckAccess()
|
|
1292
1646
|
const current = await authorize(built.directory)
|
|
@@ -1325,7 +1679,7 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1325
1679
|
if (!requireManageRole(res, s.user)) return
|
|
1326
1680
|
let bytes: Buffer
|
|
1327
1681
|
try {
|
|
1328
|
-
bytes = await readUploadBody(req, deps, s.session
|
|
1682
|
+
bytes = await readUploadBody(req, deps, s.session, 'bundles')
|
|
1329
1683
|
} catch (err) {
|
|
1330
1684
|
const tooLarge = err instanceof Error && err.message === 'too-large'
|
|
1331
1685
|
sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
|
|
@@ -1480,10 +1834,16 @@ async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: Ma
|
|
|
1480
1834
|
// GET /api/teams — the CURRENT user's teams, each with the reports and bundles
|
|
1481
1835
|
// attached to the team's repos, for the sidebar's per-user Teams section. Any approved
|
|
1482
1836
|
// user (not just admin|manage); a user only ever sees their own teams.
|
|
1837
|
+
// Each team carries `app`, its App classification (team-app.ts), so the
|
|
1838
|
+
// sidebar's first paint already shows App teams collapsed.
|
|
1483
1839
|
async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
|
|
1484
1840
|
let snapshot = await deps.db.getUserTeamFeedSnapshot(session.id, Date.now())
|
|
1485
1841
|
if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1486
|
-
const summaries = await
|
|
1842
|
+
const [summaries, classified] = await Promise.all([
|
|
1843
|
+
bundleSummaries(snapshot.teams.flatMap(team => team.bundles), deps.bundleCache),
|
|
1844
|
+
teamAppStates(deps.db, deps.reportStore, session.id, snapshot.teams),
|
|
1845
|
+
])
|
|
1846
|
+
const commits = await bundleCommits(deps.db, snapshot.teams.flatMap(team => team.bundles), summaries)
|
|
1487
1847
|
const current = await deps.db.getFeedState(session.id, Date.now())
|
|
1488
1848
|
if (!current) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1489
1849
|
// Keep the post-storage authorization fence, but reload the catalog only
|
|
@@ -1493,11 +1853,16 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
|
|
|
1493
1853
|
if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1494
1854
|
}
|
|
1495
1855
|
const { teams, revision } = snapshot
|
|
1856
|
+
// The revision sent is the reloaded catalog's, so classify what changed now.
|
|
1857
|
+
const apps = await teamAppStates(deps.db, deps.reportStore, session.id, teams, classified)
|
|
1496
1858
|
sendJson(res, 200, {
|
|
1497
|
-
teams: teams.map(team => ({ ...team,
|
|
1859
|
+
teams: teams.map(team => ({ ...team, app: apps.get(teamAppKey(team)) ?? null,
|
|
1860
|
+
bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
|
|
1861
|
+
commitInfo: commits.commitInfo(bundle) })) })),
|
|
1498
1862
|
revision,
|
|
1499
1863
|
})
|
|
1500
1864
|
await backfillBundleSummaries(teams.flatMap(team => team.bundles), deps.bundleCache)
|
|
1865
|
+
await backfillCatalogCommits(deps, session.userId, commits.missing)
|
|
1501
1866
|
}
|
|
1502
1867
|
|
|
1503
1868
|
// Admins read all reports. Managers read their uploads or reports inside their
|
|
@@ -1574,7 +1939,7 @@ async function handleDeduplication(req: IncomingMessage, res: ServerResponse, de
|
|
|
1574
1939
|
sendJson(res, 200, { ok: true }); return
|
|
1575
1940
|
}
|
|
1576
1941
|
let bytes: Buffer
|
|
1577
|
-
try { bytes = await
|
|
1942
|
+
try { bytes = await readUploadBody(req, deps, s.session, 'links') }
|
|
1578
1943
|
catch (error) { const large = error instanceof Error && error.message === 'too-large'; sendJson(res, large ? 413 : 400, { error: large ? 'too-large' : 'bad-body' }); return }
|
|
1579
1944
|
const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.link.json')
|
|
1580
1945
|
const result = await deps.db.importLinkReport(s.session.id, filename, bytes.toString('utf8'))
|
|
@@ -2050,8 +2415,10 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
|
|
|
2050
2415
|
async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2051
2416
|
const s = await readAdminSession(res, deps, cookie)
|
|
2052
2417
|
if (s == null) return
|
|
2418
|
+
const scopes = await deps.db.listTeamNpmScopes()
|
|
2419
|
+
const reviewTeams = new Set(await deps.db.listDependencyReviewEnabledTeams())
|
|
2053
2420
|
sendJson(res, 200, {
|
|
2054
|
-
teams: await deps.db.listTeams(),
|
|
2421
|
+
teams: (await deps.db.listTeams()).map(team => ({ ...team, npmScopes: scopes[team.id] ?? [], dependencyReview: reviewTeams.has(team.id) })),
|
|
2055
2422
|
users: await deps.db.listUserOptions(),
|
|
2056
2423
|
repos: selectableRepos(await deps.db.listSelectedRepos()),
|
|
2057
2424
|
permissions: VISIBILITY_PERMISSIONS,
|
|
@@ -2212,6 +2579,445 @@ async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse,
|
|
|
2212
2579
|
sendJson(res, 200, { ok: true })
|
|
2213
2580
|
}
|
|
2214
2581
|
|
|
2582
|
+
async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2583
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2584
|
+
if (!s) return
|
|
2585
|
+
let body
|
|
2586
|
+
try { body = await readJsonBody(req) as { teamId?: unknown; enabled?: unknown } } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2587
|
+
if (typeof body?.teamId !== 'string' || typeof body?.enabled !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2588
|
+
if (!await deps.db.setTeamDependencyReview(s.session.id, body.teamId, body.enabled)) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2589
|
+
sendJson(res, 200, { ok: true })
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
// POST /api/admin/teams/set-npm-scopes — replace the npm scopes whose private
|
|
2593
|
+
// packages the team's members may read. Body { teamId, scopes: ['@scope'] }.
|
|
2594
|
+
async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2595
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2596
|
+
if (s == null) return
|
|
2597
|
+
let body: unknown
|
|
2598
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2599
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2600
|
+
if (typeof teamId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2601
|
+
const team = await deps.db.getTeam(teamId)
|
|
2602
|
+
const changed = await deps.db.setTeamNpmScopes(s.session.id, teamId, (body as { scopes?: unknown }).scopes)
|
|
2603
|
+
if (changed == null) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2604
|
+
const parts = [changed.added.length > 0 ? `added ${changed.added.join(', ')}` : '', changed.removed.length > 0 ? `removed ${changed.removed.join(', ')}` : ''].filter(Boolean)
|
|
2605
|
+
if (parts.length > 0) await activity(deps, s.user, 'access', `changed team ${team?.name ?? teamId}'s npm scopes: ${parts.join('; ')}`)
|
|
2606
|
+
sendJson(res, 200, { ok: true, scopes: (await deps.db.listTeamNpmScopes())[teamId] ?? [] })
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
// Who reads npm packages now: a session with workspace access, its role and
|
|
2610
|
+
// the npm scopes of its visible teams. Null for none.
|
|
2611
|
+
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined): Promise<NpmReader | null> {
|
|
2612
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2613
|
+
if (s == null || !roleAtLeast(s.user.role, 'view')) return null
|
|
2614
|
+
return { role: s.user.role, scopes: new Set(await deps.db.listUserNpmScopes(s.user.id)), userId: s.user.id }
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// What a registry read found, or a 404 for the package (handleNpm's catch
|
|
2618
|
+
// answers it) where it found none.
|
|
2619
|
+
function found<T>(value: T | null | undefined): T {
|
|
2620
|
+
if (value == null) throw new NpmPackageError(404, 'package-not-found')
|
|
2621
|
+
return value
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// Lists must enforce the same target and base access as the review itself,
|
|
2625
|
+
// including permission changes while the registry requests are in flight.
|
|
2626
|
+
async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[]): Promise<T[] | null> {
|
|
2627
|
+
const controller = new AbortController()
|
|
2628
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2629
|
+
res.on('close', onClose)
|
|
2630
|
+
try {
|
|
2631
|
+
const reader = await npmReader(deps, cookie)
|
|
2632
|
+
if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2633
|
+
const documents = new Map<string, Promise<NpmVersionDocument | null>>()
|
|
2634
|
+
// Public versions' documents as the viewer keeps them (npm-kept.ts), a
|
|
2635
|
+
// day: a list asks npm for none it read within it.
|
|
2636
|
+
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
2637
|
+
const read = (name: string, version: string) => {
|
|
2638
|
+
const key = `${name}@${version}`
|
|
2639
|
+
if (!documents.has(key)) documents.set(key, readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
|
|
2640
|
+
return documents.get(key)!
|
|
2641
|
+
}
|
|
2642
|
+
const packages = await Promise.all(reviews.map(review => Promise.all([
|
|
2643
|
+
read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
|
|
2644
|
+
])))
|
|
2645
|
+
const current = await npmReader(deps, cookie)
|
|
2646
|
+
if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2647
|
+
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
2648
|
+
} catch (error) {
|
|
2649
|
+
if (controller.signal.aborted) return null
|
|
2650
|
+
if (error instanceof NpmPackageError) { sendJson(res, error.status, { error: error.message }); return null }
|
|
2651
|
+
throw error
|
|
2652
|
+
} finally { res.off('close', onClose) }
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
const githubRepoOf = (doc: NpmVersionDocument) => (doc.manifest['github'] as { github?: string } | undefined)?.github ?? null
|
|
2656
|
+
|
|
2657
|
+
function npmAdvisoryOptions(deps: ManagedHttpDeps, githubToken: NpmAdvisoryOptions['githubToken'], latestDoc: () => Promise<NpmVersionDocument | null>): NpmAdvisoryOptions {
|
|
2658
|
+
return {
|
|
2659
|
+
githubToken,
|
|
2660
|
+
repo: async () => { const doc = await latestDoc(); return doc && githubRepoOf(doc) },
|
|
2661
|
+
cache: signal => auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug),
|
|
2662
|
+
lastListing: repo => lastListing(deps.config.upstreamCacheDir, deps.db, repo, deps.config.debug),
|
|
2663
|
+
debug: deps.config.debug,
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// Read the same target-version evidence as the viewer. Missing upstream
|
|
2668
|
+
// reports impose no extra verdict restriction; detected findings do.
|
|
2669
|
+
async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReader, doc: NpmVersionDocument, signal: AbortSignal) {
|
|
2670
|
+
if (npmTakenDown({ npm: doc })) return 'reject' as const
|
|
2671
|
+
const privileged = canReadPrivateNpm(reader, doc.name)
|
|
2672
|
+
const advisories = async () => {
|
|
2673
|
+
const versions = await readNpmVersions(doc.name, privileged, signal)
|
|
2674
|
+
if (!versions) return null
|
|
2675
|
+
return npmAdvisories(doc.name, versions.versions, npmAdvisoryOptions(deps,
|
|
2676
|
+
() => ensureUserAccessToken(deps.config, deps.db, reader.userId, Date.now()).catch(() => null),
|
|
2677
|
+
() => readNpmVersion(doc.name, 'latest', privileged, signal)))
|
|
2678
|
+
}
|
|
2679
|
+
const [list, socket, risk] = await Promise.all([advisories().catch(() => null), doc.private ? null : npmSocketReport(doc.name, doc.version).catch(() => null),
|
|
2680
|
+
npmRisk(doc, null, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped: true, privileged, signal })])
|
|
2681
|
+
return npmReviewVerdictLimit(doc, list, socket, risk)
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
// Reviews are scoped to the bytes actually displayed, including the base.
|
|
2685
|
+
// Access to both versions is rechecked before returning or saving annotations.
|
|
2686
|
+
async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
|
|
2687
|
+
const method = req.method ?? 'GET'
|
|
2688
|
+
if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
|
|
2689
|
+
const mutation = method !== 'GET'
|
|
2690
|
+
const session = mutation ? await checkMutation(req, res, deps, cookie) : await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2691
|
+
if (!session) { if (!mutation) sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2692
|
+
if (!roleAtLeast(session.user.role, mutation ? 'triage' : 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
2693
|
+
let body: Record<string, unknown> = {}
|
|
2694
|
+
if (mutation) {
|
|
2695
|
+
try {
|
|
2696
|
+
const raw = await readJsonBody(req, 128 * 1024)
|
|
2697
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('bad-body')
|
|
2698
|
+
body = raw as Record<string, unknown>
|
|
2699
|
+
} catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2700
|
+
}
|
|
2701
|
+
const field = (key: string) => mutation ? body[key] : params.get(key)
|
|
2702
|
+
const name = field('package')
|
|
2703
|
+
const version = field('version')
|
|
2704
|
+
const integrity = field('integrity')
|
|
2705
|
+
const basePackage = field('basePackage') ?? null
|
|
2706
|
+
const baseVersion = field('baseVersion') ?? null
|
|
2707
|
+
const baseIntegrity = field('baseIntegrity') ?? null
|
|
2708
|
+
const validIntegrity = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 1024
|
|
2709
|
+
if (field('ecosystem') !== 'npm' || !isNpmPackageName(name) || !isNpmPackageSpec(version) || !validIntegrity(integrity)
|
|
2710
|
+
|| (basePackage !== null || baseVersion !== null || baseIntegrity !== null)
|
|
2711
|
+
&& (!isNpmPackageName(basePackage) || !isNpmPackageSpec(baseVersion) || !validIntegrity(baseIntegrity))) {
|
|
2712
|
+
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
2713
|
+
}
|
|
2714
|
+
const context: ReviewContext = { ecosystem: 'npm', package: name, version, integrity,
|
|
2715
|
+
basePackage: basePackage as string | null, baseVersion: baseVersion as string | null, baseIntegrity: baseIntegrity as string | null }
|
|
2716
|
+
let change: ReviewChange | null = null
|
|
2717
|
+
if (mutation) {
|
|
2718
|
+
const action = body['action'], reviewId = body['reviewId'] ?? null, revision = body['revision'] ?? null
|
|
2719
|
+
if (typeof action !== 'string' || !['start', 'save', 'publish', 'continue', 'revoke'].includes(action)
|
|
2720
|
+
|| (reviewId !== null && (typeof reviewId !== 'string' || reviewId.length === 0 || reviewId.length > 100 || !Number.isSafeInteger(revision) || Number(revision) < 1))
|
|
2721
|
+
|| (!['save', 'start'].includes(action) && reviewId === null) || (action === 'start' && reviewId !== null)) { sendJson(res, 400, { error: 'bad-review' }); return }
|
|
2722
|
+
if (action === 'start') change = { action, reviewId: null, revision: null }
|
|
2723
|
+
else if (action === 'publish') {
|
|
2724
|
+
const teamIds = body['teamIds']
|
|
2725
|
+
if (!Array.isArray(teamIds) || teamIds.length === 0 || teamIds.length > 100 || teamIds.some(id => typeof id !== 'string' || id.length > 100)) {
|
|
2726
|
+
sendJson(res, 400, { error: 'bad-review-teams' }); return
|
|
2727
|
+
}
|
|
2728
|
+
const finalComment = body['finalComment'] ?? '', finalStatus = body['finalStatus'] ?? null
|
|
2729
|
+
if (typeof finalComment !== 'string' || finalComment.length > 20000 || finalStatus !== null
|
|
2730
|
+
&& (typeof finalStatus !== 'string' || !['accept', 'accept-with-nits', 'request-changes', 'reject'].includes(finalStatus))) {
|
|
2731
|
+
sendJson(res, 400, { error: 'bad-final-verdict' }); return
|
|
2732
|
+
}
|
|
2733
|
+
change = { action, reviewId: reviewId as string, revision: revision as number, teamIds, files: [], nonTextFiles: [],
|
|
2734
|
+
final: finalStatus === null ? null : { status: finalStatus as ReviewInput['status'] & string, comment: finalComment } }
|
|
2735
|
+
} else if (action === 'continue' || action === 'revoke') change = { action, reviewId: reviewId as string, revision: revision as number }
|
|
2736
|
+
else {
|
|
2737
|
+
const file = body['file']
|
|
2738
|
+
const comment = body['comment']
|
|
2739
|
+
const status = body['status'] ?? null
|
|
2740
|
+
const entryId = body['entryId'] ?? null
|
|
2741
|
+
if (typeof file !== 'string' || !file || file.length > 4096 || file.includes('\0')
|
|
2742
|
+
|| (entryId !== null && (typeof entryId !== 'string' || entryId.length === 0 || entryId.length > 100))
|
|
2743
|
+
|| typeof comment !== 'string' || comment.length > 20000
|
|
2744
|
+
|| (status !== null && (typeof status !== 'string' || !['accept', 'accept-with-nits', 'request-changes', 'reject'].includes(status))) || (status === null && entryId === null)) {
|
|
2745
|
+
sendJson(res, 400, { error: 'bad-review' }); return
|
|
2746
|
+
}
|
|
2747
|
+
change = { action: 'save', reviewId: reviewId as string | null, revision: revision as number | null, entryId: entryId as string | null,
|
|
2748
|
+
entry: { file, comment, status: status as ReviewInput['status'] } }
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
const controller = new AbortController()
|
|
2752
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2753
|
+
res.on('close', onClose)
|
|
2754
|
+
try {
|
|
2755
|
+
const reader = await npmReader(deps, cookie)
|
|
2756
|
+
if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2757
|
+
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
2758
|
+
const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
|
|
2759
|
+
const [doc, base] = await Promise.all([read(name, version), context.basePackage ? read(context.basePackage, context.baseVersion!) : null])
|
|
2760
|
+
if (!doc || (context.basePackage && !base)) { sendJson(res, 404, { error: 'package-not-found' }); return }
|
|
2761
|
+
if (doc.version !== version || doc.dist.integrity !== integrity
|
|
2762
|
+
|| (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
|
|
2763
|
+
sendJson(res, 409, { error: 'review-content-changed' }); return
|
|
2764
|
+
}
|
|
2765
|
+
if (change?.action === 'publish' || change?.action === 'save') {
|
|
2766
|
+
const loads = [doc, ...base ? [base] : []].map(loadNpmFiles)
|
|
2767
|
+
try {
|
|
2768
|
+
const files = await Promise.all(loads.map(load => load.result))
|
|
2769
|
+
const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
|
|
2770
|
+
const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
|
|
2771
|
+
const diff = computeBundleDiff(before, after, () => '.')
|
|
2772
|
+
const changed: { path: string; basePath?: string }[] = [...diff.files.onlyBase, ...diff.files.onlyOther, ...diff.files.changed]
|
|
2773
|
+
const reviewable = (file: typeof changed[number]) => isReviewableFile(before.get(file.basePath ?? file.path), after.get(file.path))
|
|
2774
|
+
const reviewedPath = change.action === 'save' ? change.entry.file : null
|
|
2775
|
+
const reviewedFile = changed.find(file => file.path === reviewedPath)
|
|
2776
|
+
if (change.action === 'save' && !reviewedFile) { sendJson(res, 400, { error: 'unknown-review-file' }); return }
|
|
2777
|
+
if (change.action === 'save' && change.entry.status !== null && reviewedFile && !reviewable(reviewedFile)) { sendJson(res, 400, { error: 'non-text-review-file' }); return }
|
|
2778
|
+
if (change.action === 'publish') {
|
|
2779
|
+
change.files = changed.map(file => file.path)
|
|
2780
|
+
change.nonTextFiles = changed.filter(file => !reviewable(file)).map(file => file.path)
|
|
2781
|
+
}
|
|
2782
|
+
} finally { loads.forEach(load => load.release()) }
|
|
2783
|
+
}
|
|
2784
|
+
const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
|
|
2785
|
+
? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
|
|
2786
|
+
const current = await npmReader(deps, cookie)
|
|
2787
|
+
if (!current || current.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2788
|
+
if ([doc, base].some(item => item?.private && !canReadPrivateNpm(current, item.name))) { sendJson(res, 404, { error: 'package-not-found' }); return }
|
|
2789
|
+
if (!change) {
|
|
2790
|
+
const allBases = params.get('allBases') === 'true'
|
|
2791
|
+
const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
|
|
2792
|
+
const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
|
|
2793
|
+
if (reviews) sendJson(res, 200, { reviews, teams: await deps.db.listDependencyReviewTeams(current.userId) })
|
|
2794
|
+
return
|
|
2795
|
+
}
|
|
2796
|
+
const checked = await checkMutation(req, res, deps, cookie)
|
|
2797
|
+
if (!checked) return
|
|
2798
|
+
if (checked.user.id !== current.userId || !roleAtLeast(checked.user.role, 'triage')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
2799
|
+
if (change.action === 'publish' && change.final && !allowedReviewVerdicts([], limit).includes(change.final.status)) {
|
|
2800
|
+
sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
|
|
2801
|
+
}
|
|
2802
|
+
const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
|
|
2803
|
+
sendJson(res, 200, { review })
|
|
2804
|
+
} catch (err) {
|
|
2805
|
+
if (controller.signal.aborted) return
|
|
2806
|
+
if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
|
|
2807
|
+
if (err instanceof NpmRiskError) { sendJson(res, NPM_RISK_STATUS[err.code] ?? 502, { error: err.code }); return }
|
|
2808
|
+
throw err
|
|
2809
|
+
} finally { res.off('close', onClose) }
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only}. Only readers with private access
|
|
2813
|
+
// (canReadPrivateNpm) can be answered from the server's npm token; the rest get
|
|
2814
|
+
// what the registry answers anonymously, every time (see npm-packages.ts).
|
|
2815
|
+
// Access is checked again after the registry's answer.
|
|
2816
|
+
async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string, params: URLSearchParams): Promise<void> {
|
|
2817
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
2818
|
+
const name = params.get('name'), spec = params.get('version') ?? 'latest'
|
|
2819
|
+
// All but the package's own (its versions, figures and advisories) are a version's.
|
|
2820
|
+
const versioned = path !== NPM_VERSIONS_PATH && path !== NPM_STATS_PATH && path !== NPM_ADVISORIES_PATH
|
|
2821
|
+
const otherSpec = params.get('otherVersion') ?? ''
|
|
2822
|
+
if (!isNpmPackageName(name) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
|
|
2823
|
+
|| (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
|
|
2824
|
+
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
|
|
2825
|
+
sendJson(res, 400, { error: 'bad-package' }); return
|
|
2826
|
+
}
|
|
2827
|
+
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
2828
|
+
if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
2829
|
+
const reader = await npmReader(deps, cookie)
|
|
2830
|
+
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2831
|
+
const controller = new AbortController()
|
|
2832
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2833
|
+
res.on('close', onClose)
|
|
2834
|
+
// A reader who lost their session, or their private access to an answer
|
|
2835
|
+
// that needed it, while the registry was asked gets nothing.
|
|
2836
|
+
const recheck = async (isPrivate: boolean) => {
|
|
2837
|
+
const current = await npmReader(deps, cookie)
|
|
2838
|
+
if (current == null) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
|
|
2839
|
+
if (isPrivate && !canReadPrivateNpm(current, name)) { sendJson(res, 404, { error: 'package-not-found' }); return false }
|
|
2840
|
+
return !res.destroyed
|
|
2841
|
+
}
|
|
2842
|
+
const reply = async (isPrivate: boolean, body: Record<string, unknown>) => { if (await recheck(isPrivate)) sendJson(res, 200, body) }
|
|
2843
|
+
// The reader's GitHub token, for GitHub's rate limits and never for what it
|
|
2844
|
+
// shows: asked for once, and only where an answer isn't kept (npm-insights.ts).
|
|
2845
|
+
let token: Promise<string | null> | undefined
|
|
2846
|
+
const githubToken = () => token ??= ensureUserAccessToken(deps.config, deps.db, reader.userId, Date.now()).catch(() => null)
|
|
2847
|
+
const privileged = canReadPrivateNpm(reader, name)
|
|
2848
|
+
// Public versions' documents, kept in the database (npm-kept.ts), as
|
|
2849
|
+
// repository risk scans keep them: an exact version's, read on every view,
|
|
2850
|
+
// asked of npm once a day.
|
|
2851
|
+
const keptVersions = npmVersionStore(deps.db, deps.config.debug)
|
|
2852
|
+
// The latest version, read once a request: the package's figures and
|
|
2853
|
+
// advisories go by the repository it names.
|
|
2854
|
+
let latest: Promise<NpmVersionDocument | null> | undefined
|
|
2855
|
+
const latestDoc = () => latest ??= readNpmVersion(name, 'latest', privileged, controller.signal, keptVersions)
|
|
2856
|
+
const advisoryOptions = npmAdvisoryOptions(deps, githubToken, latestDoc)
|
|
2857
|
+
// The figures of the repository the latest version names, noted as its
|
|
2858
|
+
// repository for npmKnown; null where it names none, or they can't be had.
|
|
2859
|
+
const githubFigures = (doc: NpmVersionDocument) => {
|
|
2860
|
+
const repo = githubRepoOf(doc)
|
|
2861
|
+
npmLatestRepo(name, repo)
|
|
2862
|
+
return repo === null ? null : npmGithubStats(repo, githubToken).catch(() => null)
|
|
2863
|
+
}
|
|
2864
|
+
try {
|
|
2865
|
+
// With whether a visible team lists its scope (`teamScoped`): its own
|
|
2866
|
+
// releases, which the viewer doesn't flag as just published.
|
|
2867
|
+
if (path === NPM_VERSIONS_PATH) {
|
|
2868
|
+
const scope = npmPackageScope(name)
|
|
2869
|
+
const [versions, teamScoped] = await Promise.all([readNpmVersions(name, privileged, controller.signal, { times: true }),
|
|
2870
|
+
scope !== null && deps.db.hasTeamNpmScope(scope)])
|
|
2871
|
+
await reply(found(versions).private, { ...versions, teamScoped })
|
|
2872
|
+
return
|
|
2873
|
+
}
|
|
2874
|
+
// Across every published version, as the version list has them, which
|
|
2875
|
+
// each advisory's `affected` indexes; those of the repository its latest
|
|
2876
|
+
// version names, where GitHub says it is public, asked with the reader's
|
|
2877
|
+
// GitHub token, its listing kept as bundle advisories keep it.
|
|
2878
|
+
if (path === NPM_ADVISORIES_PATH) {
|
|
2879
|
+
const versions = found(await readNpmVersions(name, privileged, controller.signal))
|
|
2880
|
+
await reply(versions.private, { name, ...await npmAdvisories(name, versions.versions, advisoryOptions) })
|
|
2881
|
+
return
|
|
2882
|
+
}
|
|
2883
|
+
// The package's, with the repository its latest version names; either
|
|
2884
|
+
// is null where it can't be had.
|
|
2885
|
+
if (path === NPM_STATS_PATH) {
|
|
2886
|
+
// npm's downloads, which need nothing of the version, are asked at once;
|
|
2887
|
+
// none is answered before the package's access is.
|
|
2888
|
+
const downloadsAsked = npmDownloads(name).catch(() => null)
|
|
2889
|
+
const doc = found(await latestDoc())
|
|
2890
|
+
const [downloads, github] = await Promise.all([downloadsAsked, githubFigures(doc)])
|
|
2891
|
+
await reply(doc.private, { name, downloads, github })
|
|
2892
|
+
return
|
|
2893
|
+
}
|
|
2894
|
+
const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptVersions))
|
|
2895
|
+
if (spec === 'latest') latest = Promise.resolve(doc)
|
|
2896
|
+
const repo = githubRepoOf(doc), sha = bundleCommitHash(doc.manifest['gitHead'])
|
|
2897
|
+
// What the viewer asks about a version beside its files, by its route,
|
|
2898
|
+
// and asks for meanwhile below. Its publish commit's tags, where its
|
|
2899
|
+
// repository is public on GitHub and the reader has a token to ask with.
|
|
2900
|
+
// Socket's report, asked only where npm answers for the version without a
|
|
2901
|
+
// token: a private package's name never leaves for Socket. npm's
|
|
2902
|
+
// provenance, asked only where its version says npm keeps one, and npm
|
|
2903
|
+
// answers for the version without a token.
|
|
2904
|
+
const tagsOf = () => repo === null || sha === null ? Promise.resolve([]) : npmCommitTags(repo, sha, doc.version, githubToken)
|
|
2905
|
+
const socketOf = () => doc.private ? Promise.resolve(null) : npmSocketReport(name, doc.version)
|
|
2906
|
+
const provenanceOf = () => doc.private || doc.manifest['provenance'] !== true ? Promise.resolve(null) : npmProvenance(name, doc.version, doc.dist.integrity)
|
|
2907
|
+
// None where tags or Socket's report can't be had; a failure to have
|
|
2908
|
+
// provenance is answered as one, for the viewer to ask again.
|
|
2909
|
+
if (path === NPM_TAGS_PATH) { await reply(doc.private, { name, version: doc.version, tags: await tagsOf().catch(() => []) }); return }
|
|
2910
|
+
if (path === NPM_SOCKET_PATH) { await reply(doc.private, { name, version: doc.version, socket: await socketOf().catch(() => null) }); return }
|
|
2911
|
+
// What calls for a look at the version, as risk tells it (npm-risk.ts),
|
|
2912
|
+
// against `otherVersion` where given, as Compare asks, else against the
|
|
2913
|
+
// one risk picks of its versions, as the Overview asks; trusted however
|
|
2914
|
+
// new and however few download it where a team lists its scope, its own
|
|
2915
|
+
// packages, as a new dependency on a package of such a scope is.
|
|
2916
|
+
if (path === NPM_RISK_PATH) {
|
|
2917
|
+
const old = otherSpec === '' || otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
2918
|
+
const scope = npmPackageScope(name)
|
|
2919
|
+
const teamScoped = scope !== null && await deps.db.hasTeamNpmScope(scope)
|
|
2920
|
+
const assessment = await npmRisk(doc, otherSpec === '' ? 'pick' : old, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped, fromEmpty: otherSpec === '(empty)',
|
|
2921
|
+
ownScope: listed => deps.db.hasTeamNpmScope(listed), privileged, signal: controller.signal })
|
|
2922
|
+
// What it tells is read of both versions: private where either is.
|
|
2923
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2924
|
+
await reply(doc.private || old?.private === true, { ...assessment })
|
|
2925
|
+
return
|
|
2926
|
+
}
|
|
2927
|
+
if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
|
|
2928
|
+
// Every one of its files that needs it pretty-printed (pretty-print.ts
|
|
2929
|
+
// npmAll), or, against another version, the files that changed between
|
|
2930
|
+
// them, both sides' (compared), a line each as it is formatted, or found
|
|
2931
|
+
// kept for a public version; its files read before anything is answered.
|
|
2932
|
+
// A reader who lost their session, or their access, meanwhile has the
|
|
2933
|
+
// rest cut short.
|
|
2934
|
+
if (path === NPM_PRETTY_PATH && params.has('path')) {
|
|
2935
|
+
// One of its files pretty-printed, as one of a bundle's is.
|
|
2936
|
+
const file = prettyRequest(params)
|
|
2937
|
+
if (!file) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
2938
|
+
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2939
|
+
const copy = await deps.prettyCache.npm(doc, file)
|
|
2940
|
+
if (!(await recheck(doc.private))) { copy.stream.destroy(); return }
|
|
2941
|
+
await sendEncoded(req, res, copy, 'text/plain; charset=utf-8', prettySource(file))
|
|
2942
|
+
return
|
|
2943
|
+
}
|
|
2944
|
+
// Against another version, the pairs of its files that changed that the
|
|
2945
|
+
// viewer diffs with renamed names hidden (rename-diff.ts renamedPairs), a
|
|
2946
|
+
// line each as it is worked out, as Compare's pretty-printed files are;
|
|
2947
|
+
// and those that changed in types alone (type-only.ts).
|
|
2948
|
+
if (path === NPM_PRETTY_PATH || path === NPM_TYPE_ONLY_PATH || (path === NPM_RENAME_DIFF_PATH && !diffed)) {
|
|
2949
|
+
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2950
|
+
const other = otherSpec === '' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
2951
|
+
const sides = other && await deps.prettyCache.npmSides(doc, other)
|
|
2952
|
+
const lines = path === NPM_RENAME_DIFF_PATH ? renamedPairs(sides!, !doc.private && !other!.private)
|
|
2953
|
+
: path === NPM_TYPE_ONLY_PATH ? typeOnlyFiles(sides!) : sides ? compared(sides) : await deps.prettyCache.npmAll(doc)
|
|
2954
|
+
const isPrivate = doc.private || other?.private === true
|
|
2955
|
+
if (!(await recheck(isPrivate))) return
|
|
2956
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2957
|
+
await sendLines(res, lines, async () => {
|
|
2958
|
+
const current = await npmReader(deps, cookie)
|
|
2959
|
+
return current != null && (!isPrivate || canReadPrivateNpm(current, name))
|
|
2960
|
+
})
|
|
2961
|
+
return
|
|
2962
|
+
}
|
|
2963
|
+
// One of its JavaScript files diffed with another version's, both
|
|
2964
|
+
// pretty-printed, renamed names hidden (rename-diff.ts).
|
|
2965
|
+
if (diffed) {
|
|
2966
|
+
const { prettyCache } = deps
|
|
2967
|
+
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2968
|
+
const other = found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
2969
|
+
const diff = await renameDiffOf(diffed, async () => [await prettyText(await prettyCache.npm(doc, diffed.base)),
|
|
2970
|
+
await prettyText(await prettyCache.npm(other, diffed.other))], !doc.private && !other.private)
|
|
2971
|
+
if (!(await recheck(doc.private || other.private))) { diff.stream.destroy(); return }
|
|
2972
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2973
|
+
await sendEncoded(req, res, diff, 'application/json')
|
|
2974
|
+
return
|
|
2975
|
+
}
|
|
2976
|
+
// The load stays held until this response is written or abandoned, and
|
|
2977
|
+
// a reader who stops reading it gives it up after a while. One already
|
|
2978
|
+
// gone takes none, as its close has passed.
|
|
2979
|
+
if (res.destroyed) return
|
|
2980
|
+
const download = path === NPM_DOWNLOAD_PATH
|
|
2981
|
+
// A version's files take longest to load: what the viewer asks for beside
|
|
2982
|
+
// them is asked for meanwhile, so that what is answered by the time they
|
|
2983
|
+
// are encoded comes with them (npmKnown), and the rest is on its way when
|
|
2984
|
+
// the viewer asks. Nothing waits on it, and a failure is left for the
|
|
2985
|
+
// viewer's own request to meet. Its whole document, read for its
|
|
2986
|
+
// advisories' version list and the time each version was published
|
|
2987
|
+
// (npmPublishTime), is read only where registry reads leave room for
|
|
2988
|
+
// readers'.
|
|
2989
|
+
if (!download) {
|
|
2990
|
+
const quiet = (asked: Promise<unknown>) => { asked.catch(() => {}) }
|
|
2991
|
+
quiet(npmDownloads(name))
|
|
2992
|
+
quiet(latestDoc().then(newest => newest && githubFigures(newest)))
|
|
2993
|
+
quiet(readNpmVersions(name, privileged, controller.signal, { times: true, spare: true })
|
|
2994
|
+
.then(versions => versions && npmAdvisories(name, versions.versions, advisoryOptions)))
|
|
2995
|
+
quiet(tagsOf())
|
|
2996
|
+
quiet(socketOf())
|
|
2997
|
+
quiet(provenanceOf())
|
|
2998
|
+
}
|
|
2999
|
+
const load = download ? loadNpmTarball(doc) : loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha))
|
|
3000
|
+
res.once('close', load.release)
|
|
3001
|
+
const bytes = await load.result
|
|
3002
|
+
if (!(await recheck(doc.private))) return
|
|
3003
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
3004
|
+
res.writeHead(200, {
|
|
3005
|
+
...download
|
|
3006
|
+
? { 'content-type': 'application/gzip', 'content-disposition': attachmentDisposition(npmTarballFilename(doc.name, doc.version)) }
|
|
3007
|
+
: { 'content-type': 'application/json', 'content-encoding': 'br' },
|
|
3008
|
+
'content-length': String(bytes.byteLength), 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
3009
|
+
})
|
|
3010
|
+
writeResponse(res, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength))
|
|
3011
|
+
} catch (err) {
|
|
3012
|
+
if (err instanceof NpmPackageError || err instanceof PrettyError) { if (!res.headersSent && !res.destroyed) sendJson(res, err.status, { error: err.message }); return }
|
|
3013
|
+
if (err instanceof NpmRiskError) { if (!res.headersSent && !res.destroyed) sendJson(res, NPM_RISK_STATUS[err.code] ?? 502, { error: err.code }); return }
|
|
3014
|
+
if (controller.signal.aborted) return
|
|
3015
|
+
throw err
|
|
3016
|
+
} finally {
|
|
3017
|
+
res.off('close', onClose)
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
|
|
2215
3021
|
async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, id?: string) {
|
|
2216
3022
|
if (!deps.config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
2217
3023
|
const method = req.method ?? 'GET'
|
|
@@ -2252,6 +3058,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2252
3058
|
const path = url.pathname
|
|
2253
3059
|
const method = req.method ?? 'GET'
|
|
2254
3060
|
const cookie = req.headers.cookie
|
|
3061
|
+
const viewing = viewToken(config, cookie) != null
|
|
2255
3062
|
|
|
2256
3063
|
if (req.headers['x-deepview-share'] !== undefined || path.startsWith('/api/shares/')) {
|
|
2257
3064
|
await handlePublicWorkspace(req, res, deps, url); return
|
|
@@ -2267,6 +3074,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2267
3074
|
} : {}) } })
|
|
2268
3075
|
return
|
|
2269
3076
|
}
|
|
3077
|
+
if (viewing && underPrefix(path, VIEW_PREFIXES) && !viewAllows(method, path)) { sendViewOnly(res); return }
|
|
2270
3078
|
// OAuth: start → redirect to GitHub with the CSRF state cookie.
|
|
2271
3079
|
if (path === LOGIN_PATH) {
|
|
2272
3080
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
@@ -2280,6 +3088,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2280
3088
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2281
3089
|
try {
|
|
2282
3090
|
if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
|
|
3091
|
+
if (viewing) { sendViewOnly(res); return }
|
|
2283
3092
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
2284
3093
|
if (!s) return
|
|
2285
3094
|
const result = await issueOAuthCallback(config, db, s.session, url.searchParams, cookie)
|
|
@@ -2287,7 +3096,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2287
3096
|
res.end(); return
|
|
2288
3097
|
}
|
|
2289
3098
|
const result = await handleCallback(url.searchParams, cookie, { config, db, avatarStore })
|
|
2290
|
-
|
|
3099
|
+
// A new sign-in never resumes an earlier view.
|
|
3100
|
+
const setCookies = viewing ? [...result.setCookies, clearViewCookie(config)] : result.setCookies
|
|
3101
|
+
res.writeHead(302, { location: result.location, 'set-cookie': setCookies, 'cache-control': 'no-store' })
|
|
2291
3102
|
res.end()
|
|
2292
3103
|
} catch (err) {
|
|
2293
3104
|
if (err instanceof OAuthError) sendJson(res, err.status, { error: err.message })
|
|
@@ -2303,22 +3114,29 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2303
3114
|
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
2304
3115
|
res.end(); return
|
|
2305
3116
|
}
|
|
2306
|
-
// Who am I?
|
|
3117
|
+
// Who am I? While an admin views as another user, that user, plus the
|
|
3118
|
+
// admin as `viewer`. A view that has ended (with the admin's session or
|
|
3119
|
+
// role, or the viewed account) is cleared, returning to the own session.
|
|
2307
3120
|
if (path === SESSION_PATH) {
|
|
2308
3121
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2309
|
-
const
|
|
2310
|
-
|
|
3122
|
+
const now = Date.now()
|
|
3123
|
+
let s = await readSession(config, db, cookie, now)
|
|
3124
|
+
const ended = s == null && viewing
|
|
3125
|
+
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
3126
|
+
const headers: Record<string, string> = ended ? { 'set-cookie': clearViewCookie(config) } : {}
|
|
3127
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
|
|
2311
3128
|
sendJson(res, 200, {
|
|
2312
3129
|
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
2313
3130
|
csrfToken: s.session.csrfToken,
|
|
2314
|
-
|
|
3131
|
+
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
3132
|
+
}, headers)
|
|
2315
3133
|
return
|
|
2316
3134
|
}
|
|
3135
|
+
if (path === VIEW_AS_PATH) { await handleViewAs(req, res, deps, cookie); return }
|
|
2317
3136
|
// Authentication is not workspace access. Keep bootstrap/session/logout
|
|
2318
3137
|
// available so blocked accounts can see their role and sign out, but deny
|
|
2319
3138
|
// every managed data route before reading bodies or looking up resources.
|
|
2320
|
-
const managedDataPath =
|
|
2321
|
-
.some(prefix => path === prefix || path.startsWith(prefix + '/'))
|
|
3139
|
+
const managedDataPath = underPrefix(path, MANAGED_DATA_PREFIXES)
|
|
2322
3140
|
const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
|
|
2323
3141
|
if (managedDataPath && !workspaceSession) return
|
|
2324
3142
|
const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
|
|
@@ -2343,6 +3161,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2343
3161
|
if (shareRoute) {
|
|
2344
3162
|
await handleWorkspaceShare(req, res, deps, cookie, shareRoute[1]!, shareRoute[2]); return
|
|
2345
3163
|
}
|
|
3164
|
+
if (path === '/api/admin/uploads/key') { await handleUploadKey(req, res, deps, cookie); return }
|
|
2346
3165
|
if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
|
|
2347
3166
|
// Cached avatar by user id, served same-origin (the page CSP forbids the
|
|
2348
3167
|
// github CDN). The id in the path keys the browser cache per user, so a user
|
|
@@ -2380,6 +3199,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2380
3199
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2381
3200
|
await handleRemoveRepository(req, res, deps, cookie); return
|
|
2382
3201
|
}
|
|
3202
|
+
if (path === REPO_RISK_PATH) {
|
|
3203
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3204
|
+
await handleRepositoryRisk(res, deps, cookie, url.searchParams); return
|
|
3205
|
+
}
|
|
2383
3206
|
if (path === BROWSABLE_REPOS_PATH) {
|
|
2384
3207
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2385
3208
|
await handleBrowsableRepositories(res, deps, cookie); return
|
|
@@ -2434,12 +3257,22 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2434
3257
|
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
3258
|
return
|
|
2436
3259
|
}
|
|
2437
|
-
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download)$/iu.exec(path)
|
|
3260
|
+
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only)$/iu.exec(path)
|
|
2438
3261
|
if (bundleRead) {
|
|
3262
|
+
const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
|
|
3263
|
+
if (part === 'rename-diff' || part === 'type-only' || (part === 'pretty' && url.searchParams.has('other'))) {
|
|
3264
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3265
|
+
if (part === 'rename-diff' && url.searchParams.has('path')) { await handleBundleRenameDiff(req, res, deps, session, id, url.searchParams); return }
|
|
3266
|
+
// The pairs a scoped Compare diffs, of the files it compares within its scope.
|
|
3267
|
+
const [linesOf, scope] = part === 'pretty' ? [compared, ''] : [part === 'type-only' ? typeOnlyFiles : renamedPairs, url.searchParams.get('scope') ?? '']
|
|
3268
|
+
await handleBundleChanges(res, deps, session, id, url.searchParams.get('other') ?? '', async (cache, base, other) => linesOf(await cache.bundleSides(base, other, scope))); return
|
|
3269
|
+
}
|
|
2439
3270
|
if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
|
|
2440
|
-
|
|
2441
|
-
if (
|
|
2442
|
-
|
|
3271
|
+
if (part === 'download') { await handleGetBundle(req, res, deps, session, id); return }
|
|
3272
|
+
if (part !== 'pretty') { await handleBundleCache(req, res, deps, session, id, bundleCache && (rec => bundleCache.open(rec, part as BundleCachePart))); return }
|
|
3273
|
+
const file = prettyRequest(url.searchParams)
|
|
3274
|
+
if (!file) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
3275
|
+
await handleBundleCache(req, res, deps, session, id, prettyCache && (rec => prettyCache.bundle(rec, file)), 'text/plain; charset=utf-8', prettySource(file))
|
|
2443
3276
|
return
|
|
2444
3277
|
}
|
|
2445
3278
|
if (path === BUNDLE_CREATE_PATH) {
|
|
@@ -2493,7 +3326,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2493
3326
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2494
3327
|
await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId')); return
|
|
2495
3328
|
}
|
|
2496
|
-
if (path ===
|
|
3329
|
+
if (path === REPORT_QUERY_PATH) {
|
|
2497
3330
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2498
3331
|
await handleQueryReports(req, res, deps, cookie); return
|
|
2499
3332
|
}
|
|
@@ -2549,6 +3382,63 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2549
3382
|
if (path === TEAM_SET_MEMBER_PATH) { await handleSetTeamMember(req, res, deps, cookie); return }
|
|
2550
3383
|
await handleRemoveTeamMember(req, res, deps, cookie); return
|
|
2551
3384
|
}
|
|
3385
|
+
if (path === '/api/admin/teams/set-dependency-review') {
|
|
3386
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3387
|
+
await handleSetTeamDependencyReview(req, res, deps, cookie); return
|
|
3388
|
+
}
|
|
3389
|
+
if (path === TEAM_SET_NPM_SCOPES_PATH) {
|
|
3390
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3391
|
+
await handleSetTeamNpmScopes(req, res, deps, cookie); return
|
|
3392
|
+
}
|
|
3393
|
+
if (path === '/api/npm/reviews/latest') {
|
|
3394
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3395
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
3396
|
+
if (!s) return
|
|
3397
|
+
const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
|
|
3398
|
+
if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
|
|
3399
|
+
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
3400
|
+
}
|
|
3401
|
+
const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
|
|
3402
|
+
const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
|
|
3403
|
+
if (visible) sendJson(res, 200, { review: visible[0] ?? null })
|
|
3404
|
+
return
|
|
3405
|
+
}
|
|
3406
|
+
if (path === '/api/npm/reviews/discard') {
|
|
3407
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3408
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
3409
|
+
if (!s) return
|
|
3410
|
+
let body: Record<string, unknown>
|
|
3411
|
+
try {
|
|
3412
|
+
const raw = await readJsonBody(req, 4096)
|
|
3413
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('bad-body')
|
|
3414
|
+
body = raw as Record<string, unknown>
|
|
3415
|
+
} catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
3416
|
+
const id = body['reviewId'], revision = body['revision']
|
|
3417
|
+
if (typeof id !== 'string' || !id || id.length > 100 || !Number.isSafeInteger(revision) || Number(revision) < 1) {
|
|
3418
|
+
sendJson(res, 400, { error: 'bad-review' }); return
|
|
3419
|
+
}
|
|
3420
|
+
await deps.db.discardDependencyReview(s.session.id, id, revision as number)
|
|
3421
|
+
sendJson(res, 200, { ok: true }); return
|
|
3422
|
+
}
|
|
3423
|
+
if (path === '/api/npm/reviews/mine') {
|
|
3424
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3425
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
3426
|
+
if (!s) return
|
|
3427
|
+
const id = url.searchParams.get('beforeId'), time = url.searchParams.get('beforeTime')
|
|
3428
|
+
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
3429
|
+
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
3430
|
+
}
|
|
3431
|
+
const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
3432
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
|
|
3433
|
+
if (reviews) sendJson(res, 200, { ...page, reviews })
|
|
3434
|
+
return
|
|
3435
|
+
}
|
|
3436
|
+
if (path === '/api/npm/reviews') {
|
|
3437
|
+
await handleNpmReviews(req, res, deps, cookie, url.searchParams); return
|
|
3438
|
+
}
|
|
3439
|
+
if (NPM_PATHS.has(path)) {
|
|
3440
|
+
await handleNpm(req, res, deps, cookie, path, url.searchParams); return
|
|
3441
|
+
}
|
|
2552
3442
|
if (path === LOGOUT_PATH) { await handleLogout(req, res, deps, cookie); return }
|
|
2553
3443
|
if (deps.serveStatic?.(req, res)) return
|
|
2554
3444
|
if (deps.next) { deps.next(req, res); return }
|
|
@@ -2561,7 +3451,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2561
3451
|
// Feeds lease a connection per poll, never across streaming sleeps. This
|
|
2562
3452
|
// also covers capability-authenticated feeds handled by public-workspace.
|
|
2563
3453
|
const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
|
|
2564
|
-
const
|
|
3454
|
+
const serve = () => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)
|
|
3455
|
+
const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
|
|
2565
3456
|
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2566
3457
|
console.warn('managed: request handler error:', err)
|
|
2567
3458
|
if (res.headersSent) { try { res.destroy() } catch {} }
|