@preventive/triage 1.0.0-alpha.36 → 1.0.0-alpha.38

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