@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.39

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