@preventive/triage 1.0.0-alpha.47 → 1.0.0-alpha.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/dependency-paths.d.ts +2 -1
- package/client/dependency-paths.js +16 -4
- package/common/managed/agent-tasks.ts +12 -14
- package/common/managed/github-agents.ts +7 -6
- package/out/activity.js +4 -4
- package/out/{chunk-NOZJULWS.js → chunk-6LNQTKAV.js} +1 -1
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/{chunk-5AHIQFFG.js → chunk-C2AVODGZ.js} +1 -1
- package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
- package/out/{chunk-UENXT2HK.js → chunk-MOACFRJ4.js} +1 -1
- package/out/chunk-O6M574JI.js +1 -0
- package/out/{chunk-ZCS4UYHI.js → chunk-PBZPYIG7.js} +1 -1
- package/out/{chunk-S6U2J62U.js → chunk-PENGGKX7.js} +42 -41
- package/out/{chunk-6WF3KNTL.js → chunk-SZD5CW5R.js} +2 -2
- package/out/{chunk-SPEFPML6.js → chunk-TQJDYHVE.js} +1 -1
- package/out/{chunk-FH24VHSJ.js → chunk-WYLQWYLA.js} +1 -1
- package/out/{chunk-VL43BTSO.js → chunk-X6PJAK3T.js} +1 -1
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +26 -26
- package/out/compare.js +13 -13
- package/out/dialogs.js +17 -17
- package/out/graph.js +1 -1
- package/out/index.html +7 -7
- package/out/scan.js +1 -1
- package/out/terminal.js +214 -214
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +8 -5
- package/risk/npm/score.js +1 -1
- package/server-managed/agent-merges.ts +10 -8
- package/server-managed/app-agents.ts +6 -8
- package/server-managed/cache-storage.ts +3 -2
- package/server-managed/commit-coauthors.ts +7 -2
- package/server-managed/commit-lockfiles.ts +22 -21
- package/server-managed/db-methods.ts +52 -38
- package/server-managed/db-neon.ts +21 -5
- package/server-managed/db.ts +2 -0
- package/server-managed/dependabot-access.ts +1 -1
- package/server-managed/dependency-review-comments.ts +7 -4
- package/server-managed/dependency-reviews.ts +71 -33
- package/server-managed/github-accounts.ts +28 -1
- package/server-managed/github-app.ts +27 -23
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +8 -1
- package/server-managed/github-metadata.ts +2 -2
- package/server-managed/http-response.ts +28 -10
- package/server-managed/http.ts +677 -230
- package/server-managed/index.ts +2 -2
- package/server-managed/managed-issues.ts +3 -0
- package/server-managed/npm-delta.ts +16 -0
- package/server-managed/npm-kept.ts +32 -5
- package/server-managed/npm-loads.ts +21 -13
- package/server-managed/npm-manifest.ts +1 -1
- package/server-managed/npm-packages.ts +13 -4
- package/server-managed/object-storage.ts +4 -3
- package/server-managed/public-workspace.ts +3 -3
- package/server-managed/pull-dependencies.ts +84 -36
- package/server-managed/pull-dependency-reviews.ts +13 -10
- package/server-managed/rename-diff-worker.js +1 -1
- package/server-managed/repository-agents.ts +9 -6
- package/server-managed/repository-browser.ts +13 -3
- package/server-managed/repository-code.ts +153 -7
- package/server-managed/repository-dependency-reviews.ts +7 -0
- package/server-managed/repository-history.ts +85 -152
- package/server-managed/repository-issues.ts +27 -13
- package/server-managed/repository-pulse.ts +9 -3
- package/server-managed/repository-squashes.ts +145 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +3 -2
- package/server-managed/session.ts +34 -12
- package/server-managed/sql.ts +16 -8
- package/server-managed/storage-encryption.ts +16 -8
- package/server-managed/storage-maintenance.ts +5 -2
- package/server-managed/storage-payload.ts +18 -0
- package/server-managed/storage-stores.ts +3 -2
- package/server-managed/team-feed.ts +16 -11
- package/server-managed/team-npm-scopes.ts +29 -0
- package/server-managed/team-reports.ts +68 -16
- package/out/chunk-6ANSHRFQ.js +0 -6
- package/out/chunk-ZGNMDGSB.js +0 -1
package/server-managed/http.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
|
|
24
24
|
// GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
|
|
25
25
|
// GET /api/avatar/<id>[?own] → cached avatar bytes by user id, of the account chosen on their profile (`own`: the
|
|
26
|
-
// sign-in account's), or by linked account id; `app-<id>` or `user-<id>`, an
|
|
26
|
+
// sign-in account's), or by linked account id; `app-<id>` or `user-<id>`, an agent's GitHub App's or account's (GITHUB_AGENTS) | 401/404
|
|
27
27
|
// GET /api/admin/users → admin-only user list | 401/403
|
|
28
28
|
// GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
|
|
29
29
|
// POST /api/admin/set-role → admin sets another user's role | 401/403/404
|
|
@@ -135,9 +135,10 @@ import { mergeLinkGroups } from './link-reports.ts'
|
|
|
135
135
|
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
136
136
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
137
137
|
import { pipeline } from 'node:stream/promises'
|
|
138
|
-
import {
|
|
139
|
-
repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
140
|
-
import {
|
|
138
|
+
import { type KeptStore, REPOSITORY_READ_KEPT_MS, codeHeadStore, codeTreeStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, lockfileReadStore,
|
|
139
|
+
npmDeltaStore, npmPublishTimeStore, npmVersionStore, ownKeptPrunes, pullDependencyStore, repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
140
|
+
import { plainObject } from './npm-manifest.ts'
|
|
141
|
+
import { type NpmDelta, keptNpmDeltas, npmDelta } from './npm-delta.ts'
|
|
141
142
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
142
143
|
import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
|
|
143
144
|
import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
|
|
@@ -183,11 +184,13 @@ import { githubActionsSource } from './github-actions.ts'
|
|
|
183
184
|
import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
|
|
184
185
|
import { type Lru, lru } from './lru.ts'
|
|
185
186
|
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
186
|
-
import { clearCookie, endSession, readSession, runRequestPresence } from './session.ts'
|
|
187
|
+
import { clearCookie, endSession, handOverSession, readSession, runRequestPresence, sessionIds } from './session.ts'
|
|
188
|
+
import { runTimed } from './request-timing.ts'
|
|
189
|
+
import { isExactVersion } from '@preventive/upstream/semver.js'
|
|
187
190
|
import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
|
|
188
191
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
189
192
|
import { acceptsReportMetadata } from './report-response.ts'
|
|
190
|
-
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
193
|
+
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, loadTeamTreesResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
191
194
|
import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
192
195
|
import { lookupFixes, storedFixUrls } from './github-pulls.ts'
|
|
193
196
|
import { lookupIssueLinks } from './github-issue-links.ts'
|
|
@@ -198,7 +201,7 @@ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallb
|
|
|
198
201
|
import type { ProfileChoice } from './github-links.ts'
|
|
199
202
|
import { withAppUsers } from './github-users.ts'
|
|
200
203
|
import { isBlobId } from './object-storage.ts'
|
|
201
|
-
import { attachmentDisposition, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
|
|
204
|
+
import { attachmentDisposition, openLines, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
|
|
202
205
|
import { triageWireEntry } from './triage-response.ts'
|
|
203
206
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
204
207
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
@@ -207,19 +210,20 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
|
207
210
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
208
211
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
209
212
|
import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
|
|
210
|
-
import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions,
|
|
211
|
-
readTogether } from './npm-packages.ts'
|
|
213
|
+
import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, VERSION_KEPT_MS, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions, readTogether } from './npm-packages.ts'
|
|
212
214
|
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
213
|
-
import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
|
|
215
|
+
import { type RepositoryCodeKept, loadRepositoryCodeBody, repositoryCodeFits, repositoryCodeKept } from './repository-code.ts'
|
|
214
216
|
import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
|
|
215
217
|
import { COAUTHORS_READ } from './commit-coauthors.ts'
|
|
216
|
-
import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE,
|
|
217
|
-
repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips
|
|
218
|
-
import {
|
|
218
|
+
import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, historyFrom, historyStarts, openPullHeads,
|
|
219
|
+
repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips } from './repository-history.ts'
|
|
220
|
+
import { type SquashedPull, type Squashes, squashedPulls } from './repository-squashes.ts'
|
|
221
|
+
import { AGENTS_READ_VERSION, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
|
|
219
222
|
import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
|
|
220
|
-
import {
|
|
221
|
-
|
|
222
|
-
import {
|
|
223
|
+
import { type GithubAsked, type ListedLockfile, type LockfileRead, type LockfileStore, MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullCommits, type PullDependencies,
|
|
224
|
+
type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
|
|
225
|
+
import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
|
|
226
|
+
import { MAX_REQUIRED_REVIEWS, type RepoDependencyReview, SINCE_COMMIT } from './repository-dependency-reviews.ts'
|
|
223
227
|
import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
|
|
224
228
|
import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
|
|
225
229
|
import { formattedPull, withFormatted } from './dependency-review-comments.ts'
|
|
@@ -230,7 +234,7 @@ import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitS
|
|
|
230
234
|
import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
|
|
231
235
|
import { releaseCommits } from './release-commits.ts'
|
|
232
236
|
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
233
|
-
import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
|
|
237
|
+
import type { ReviewChange, ReviewContext, ReviewInput, ReviewedTarballs } from './dependency-reviews.ts'
|
|
234
238
|
import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
|
|
235
239
|
import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
|
|
236
240
|
import { npmProvenance } from './npm-provenance.ts'
|
|
@@ -310,6 +314,7 @@ const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
|
|
|
310
314
|
const NPM_COMPARISON_PATH = '/api/npm/comparison'
|
|
311
315
|
const NPM_RISK_PATH = '/api/npm/risk'
|
|
312
316
|
const NPM_DELTA_PATH = '/api/npm/delta'
|
|
317
|
+
const NPM_DELTAS_PATH = '/api/npm/deltas'
|
|
313
318
|
// What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
|
|
314
319
|
// having no such package or version, or a tarball unpacking past risk's
|
|
315
320
|
// bounds; npm failing otherwise a 502.
|
|
@@ -334,7 +339,7 @@ const VIEW_PREFIXES = [...MANAGED_DATA_PREFIXES, '/api/auth', '/api/oauth']
|
|
|
334
339
|
const underPrefix = (path: string, prefixes: readonly string[]) => prefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))
|
|
335
340
|
|
|
336
341
|
async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
|
|
337
|
-
const s = await readWorkspaceSession(res, deps, cookie)
|
|
342
|
+
const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
|
|
338
343
|
if (!s) return
|
|
339
344
|
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
|
|
340
345
|
const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
|
|
@@ -364,7 +369,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
364
369
|
async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
|
|
365
370
|
cookie: string | undefined, teamId: string, query: URLSearchParams): Promise<void> {
|
|
366
371
|
if (req.method !== 'GET' && req.method !== 'POST') { send405(res, 'GET, POST'); return }
|
|
367
|
-
const s = req.method === 'GET' ? await readWorkspaceSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
|
|
372
|
+
const s = req.method === 'GET' ? await readWorkspaceSession(res, deps, cookie, { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
|
|
368
373
|
if (!s) return
|
|
369
374
|
if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
370
375
|
let body: unknown = Object.fromEntries(query)
|
|
@@ -396,7 +401,7 @@ function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined
|
|
|
396
401
|
// Managers get content activity for their currently accessible team content;
|
|
397
402
|
// even the displayed report context must come from those accessible reports.
|
|
398
403
|
async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
399
|
-
const s = await readManageSession(res, deps, cookie)
|
|
404
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
400
405
|
if (s == null) return
|
|
401
406
|
const page = Number(query.get('page') ?? 1)
|
|
402
407
|
const limit = Number(query.get('limit') ?? 100)
|
|
@@ -440,6 +445,9 @@ export interface ManagedHttpDeps {
|
|
|
440
445
|
prettyCache?: PrettyCache
|
|
441
446
|
// The public npm cache, where risk keeps public packages' assessments (npm-risk.ts).
|
|
442
447
|
npmCacheStorage?: CacheStorage
|
|
448
|
+
// Where the code viewer keeps the default branch's head's directories
|
|
449
|
+
// (repository-code.ts repositoryCodeKept).
|
|
450
|
+
repositoryCodeStorage?: CacheStorage
|
|
443
451
|
uploadStore?: BlobStore
|
|
444
452
|
originGate: OriginGate
|
|
445
453
|
isShuttingDown: () => boolean
|
|
@@ -486,13 +494,11 @@ function sendAvatar(res: ServerResponse, avatar: CachedAvatar | null, cacheContr
|
|
|
486
494
|
}
|
|
487
495
|
|
|
488
496
|
// The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
|
|
489
|
-
// alone: the agents
|
|
490
|
-
//
|
|
491
|
-
// by (GITHUB_AGENTS). Each is fetched from GitHub once and
|
|
497
|
+
// alone: those the app draws agents by (GITHUB_AGENTS), on the Agents tab and
|
|
498
|
+
// wherever their work shows. Each is fetched from GitHub once and
|
|
492
499
|
// kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
|
|
493
500
|
// an account's, `…-0001-<id>`.
|
|
494
|
-
const AGENT_AVATARS: ReadonlySet<string> = new Set(
|
|
495
|
-
.flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
|
|
501
|
+
const AGENT_AVATARS: ReadonlySet<string> = new Set(Object.values(GITHUB_AGENTS).flatMap(agent => 'avatar' in agent ? [agent.avatar] : []))
|
|
496
502
|
const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
|
|
497
503
|
// An agent's mark is a public GitHub image that never changes with anyone's
|
|
498
504
|
// profile, drawn on every tile and comment by it: kept by the browser a day,
|
|
@@ -547,7 +553,7 @@ async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, sessi
|
|
|
547
553
|
// first use. Only this session's server-side private key opens what is sealed to it.
|
|
548
554
|
async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
549
555
|
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
550
|
-
const s = await readManageSession(res, deps, cookie)
|
|
556
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
551
557
|
if (!s) return
|
|
552
558
|
const key = s.session.uploadKey ?? await deps.db.ensureSessionUploadKey(s.session.id, newUploadKey())
|
|
553
559
|
if (!key) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
@@ -557,7 +563,7 @@ async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
557
563
|
async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
|
|
558
564
|
const cancel = req.method === 'DELETE'
|
|
559
565
|
if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
|
|
560
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
566
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
561
567
|
if (!s || !requireManageRole(res, s.user)) return
|
|
562
568
|
if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
563
569
|
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
|
|
@@ -598,8 +604,8 @@ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES
|
|
|
598
604
|
// Returns the session, or null after having already sent the 401/403. An
|
|
599
605
|
// admin's view as another user writes nothing; `view` admits only the
|
|
600
606
|
// endpoints that end it.
|
|
601
|
-
async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
602
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
607
|
+
async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false, handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
608
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
603
609
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
604
610
|
const csrf = firstHeader(req.headers['x-csrf-token'])
|
|
605
611
|
if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
|
|
@@ -727,7 +733,7 @@ function parseProfileChoice(body: unknown): ProfileChoice | null {
|
|
|
727
733
|
// at least one admin).
|
|
728
734
|
async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
729
735
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
730
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
736
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
731
737
|
if (s == null) return
|
|
732
738
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
733
739
|
let body: unknown
|
|
@@ -757,22 +763,22 @@ function requireManageRole(res: ServerResponse, user: StoredUser): boolean {
|
|
|
757
763
|
// A read endpoint open to admin|manage: resolve the session (401 if absent),
|
|
758
764
|
// then gate on the role (403). Returns the session, or null after the response
|
|
759
765
|
// was already sent. No CSRF — reads aren't mutations (cf. /api/admin/users).
|
|
760
|
-
async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
761
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
766
|
+
async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
767
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
762
768
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
763
769
|
if (!requireManageRole(res, s.user)) return null
|
|
764
770
|
return s
|
|
765
771
|
}
|
|
766
772
|
|
|
767
|
-
async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
768
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
773
|
+
async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
774
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
769
775
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
770
776
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
771
777
|
return s
|
|
772
778
|
}
|
|
773
779
|
|
|
774
|
-
async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
775
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
780
|
+
async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
781
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
776
782
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
777
783
|
if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
778
784
|
return s
|
|
@@ -802,7 +808,7 @@ async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: stri
|
|
|
802
808
|
// GET /api/admin/scan/models — the server's canonical scan model ids and effort
|
|
803
809
|
// levels. Names are intentionally absent; the client derives them from ids.
|
|
804
810
|
async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
805
|
-
const s = await readManageSession(res, deps, cookie)
|
|
811
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
806
812
|
if (s == null) return
|
|
807
813
|
sendJson(res, 200, { models: MANAGED_SCAN_MODELS, defaultModel: DEFAULT_MANAGED_SCAN_MODEL })
|
|
808
814
|
}
|
|
@@ -813,7 +819,7 @@ async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cook
|
|
|
813
819
|
// filtering. Admin-only. No CSRF.
|
|
814
820
|
async function handleListRepositories(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, discovery: RepositoryDiscovery): Promise<void> {
|
|
815
821
|
if ((req.method ?? 'GET') !== 'GET') { send405(res, 'GET'); return }
|
|
816
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
822
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
817
823
|
if (s == null) return
|
|
818
824
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
819
825
|
// Keep the original unparameterized response shape for older clients. The
|
|
@@ -931,7 +937,7 @@ async function connectRepository(res: ServerResponse, deps: ManagedHttpDeps, use
|
|
|
931
937
|
// Authentication, server admin permission, origin and CSRF remain mandatory.
|
|
932
938
|
async function handleAddPublicRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
933
939
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
934
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
940
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
935
941
|
if (s == null) return
|
|
936
942
|
if (!canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id))) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
937
943
|
let body: unknown
|
|
@@ -955,7 +961,7 @@ async function handleAddPublicRepository(req: IncomingMessage, res: ServerRespon
|
|
|
955
961
|
// from the per-user checks required for browsing source.
|
|
956
962
|
async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
957
963
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
958
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
964
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
959
965
|
if (s == null) return
|
|
960
966
|
let body: unknown
|
|
961
967
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -990,10 +996,12 @@ async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerRespo
|
|
|
990
996
|
// POST /api/admin/repositories/select — an admin toggles whether a repo is
|
|
991
997
|
// active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
|
|
992
998
|
// selected }. selected:true verifies + records the read context; selected:false
|
|
993
|
-
// deactivates the row while retaining its reports, bundles, and metadata
|
|
999
|
+
// deactivates the row while retaining its reports, bundles, and metadata, but
|
|
1000
|
+
// for the code the code viewer kept of it, which no one reads meanwhile
|
|
1001
|
+
// (removeKeptCode).
|
|
994
1002
|
async function handleSelectRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
995
1003
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
996
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
1004
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
997
1005
|
if (s == null) return
|
|
998
1006
|
let body: unknown
|
|
999
1007
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1006,6 +1014,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
1006
1014
|
const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
|
|
1007
1015
|
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1008
1016
|
await deps.db.deactivateRepo(repoId)
|
|
1017
|
+
await removeKeptCode(deps, repoId)
|
|
1009
1018
|
if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
|
|
1010
1019
|
sendJson(res, 200, { ok: true, selected: false }); return
|
|
1011
1020
|
}
|
|
@@ -1021,7 +1030,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
1021
1030
|
// stored kept as it is). Answers the setting then. Admin, CSRF.
|
|
1022
1031
|
async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1023
1032
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
1024
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
1033
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
1025
1034
|
if (s == null) return
|
|
1026
1035
|
let body: Record<string, unknown> | null
|
|
1027
1036
|
try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1093,7 +1102,7 @@ async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id
|
|
|
1093
1102
|
// removal would destroy. This is intentionally separate from the repository
|
|
1094
1103
|
// list so the normal page stays cheap even with many reports.
|
|
1095
1104
|
async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1096
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
1105
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
1097
1106
|
if (s == null) return
|
|
1098
1107
|
const repoId = Number(new URL(req.url ?? '/', 'http://localhost').searchParams.get('repoId'))
|
|
1099
1108
|
if (!Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
@@ -1106,7 +1115,7 @@ async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse,
|
|
|
1106
1115
|
// attached reports/bundles. The exact name + explicit acknowledgement are
|
|
1107
1116
|
// checked server-side too; the UI's dialog is a usability guard, not the policy.
|
|
1108
1117
|
async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1109
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
1118
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
1110
1119
|
if (s == null) return
|
|
1111
1120
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
1112
1121
|
let body: unknown
|
|
@@ -1139,9 +1148,17 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
1139
1148
|
await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
1140
1149
|
await deps.bundleStore.delete(bundle.id).catch(() => {})
|
|
1141
1150
|
}
|
|
1151
|
+
await removeKeptCode(deps, repo.repoId)
|
|
1142
1152
|
sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
|
|
1143
1153
|
}
|
|
1144
1154
|
|
|
1155
|
+
// The code the code viewer kept of a repository no longer read, its
|
|
1156
|
+
// directories' files (repositoryCodeKeptOf), removed once its row is: as a
|
|
1157
|
+
// bundle's derivatives are, at best, a failure warned of.
|
|
1158
|
+
async function removeKeptCode(deps: ManagedHttpDeps, repoId: number): Promise<void> {
|
|
1159
|
+
await repositoryCodeKeptOf(deps)?.remove(repoId).catch((err: unknown) => { console.warn('managed: kept repository code delete failed:', err) })
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1145
1162
|
// Strip a client-supplied upload filename to a safe display string. The bytes
|
|
1146
1163
|
// are keyed by a server uuid. This name is used for display, Content-Disposition,
|
|
1147
1164
|
// and format detection: decode URLs, remove controls/paths, and cap the basename
|
|
@@ -1193,7 +1210,7 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
|
|
|
1193
1210
|
// { reportId, repoId, directory }, where repoId is null (detach) or a
|
|
1194
1211
|
// currently-selected repo id. Directory is relative to the repository root.
|
|
1195
1212
|
async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1196
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1213
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1197
1214
|
if (s == null) return
|
|
1198
1215
|
let body: unknown
|
|
1199
1216
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1214,7 +1231,7 @@ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, de
|
|
|
1214
1231
|
// POST /api/admin/reports/set-visible — publish or hide a stored report. New
|
|
1215
1232
|
// reports start hidden so an admin can inspect the parsed metadata first.
|
|
1216
1233
|
async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1217
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1234
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1218
1235
|
if (s == null) return
|
|
1219
1236
|
let body: unknown
|
|
1220
1237
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1227,7 +1244,7 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
|
|
|
1227
1244
|
}
|
|
1228
1245
|
|
|
1229
1246
|
async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1230
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1247
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1231
1248
|
if (s == null) return
|
|
1232
1249
|
let body: unknown
|
|
1233
1250
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1242,7 +1259,7 @@ async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse,
|
|
|
1242
1259
|
// POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
|
|
1243
1260
|
// and directory (same shape as reports). Body { bundleId, repoId, directory }.
|
|
1244
1261
|
async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1245
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1262
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1246
1263
|
if (s == null) return
|
|
1247
1264
|
let body: unknown
|
|
1248
1265
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1306,7 +1323,7 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1306
1323
|
// insert failure or concurrent duplicate drops the orphan blob; an uncertain
|
|
1307
1324
|
// commit retains it. 413 over the cap, 400 on empty.
|
|
1308
1325
|
async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1309
|
-
let s = await checkMutation(req, res, deps, cookie)
|
|
1326
|
+
let s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
1310
1327
|
if (s == null) return
|
|
1311
1328
|
if (!requireManageRole(res, s.user)) return
|
|
1312
1329
|
let bytes: Buffer
|
|
@@ -1414,7 +1431,7 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1414
1431
|
// or rendered inline against the app origin. 404 when there's no such report;
|
|
1415
1432
|
// 503 when the row exists but the bytes don't (store desync).
|
|
1416
1433
|
async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1417
|
-
const s = await readManageSession(res, deps, cookie)
|
|
1434
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
1418
1435
|
if (s == null) return
|
|
1419
1436
|
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1420
1437
|
const rec = await deps.db.getReport(id)
|
|
@@ -1437,7 +1454,7 @@ async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cooki
|
|
|
1437
1454
|
// same-origin + CSRF. Drops the row, then best-effort the bytes; 404 when there
|
|
1438
1455
|
// was no such report.
|
|
1439
1456
|
async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1440
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
1457
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
1441
1458
|
if (s == null) return
|
|
1442
1459
|
if (!requireManageRole(res, s.user)) return
|
|
1443
1460
|
const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
|
|
@@ -1490,7 +1507,7 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
|
|
|
1490
1507
|
// Any team member (view or above) lists those their teams reach, for the code
|
|
1491
1508
|
// viewer as for Create a bundle, whose builds stay admin|manage.
|
|
1492
1509
|
async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1493
|
-
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1510
|
+
const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
|
|
1494
1511
|
if (!s) return
|
|
1495
1512
|
sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
|
|
1496
1513
|
}
|
|
@@ -1547,15 +1564,28 @@ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
|
|
|
1547
1564
|
// answered, where it is none of theirs. Every team member (view or above)
|
|
1548
1565
|
// reads the repositories their teams reach; the reader then checks their own
|
|
1549
1566
|
// GitHub read permission (repository-browser.ts), on each read.
|
|
1550
|
-
async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
|
|
1551
|
-
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1567
|
+
async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string, { handed = false } = {}) {
|
|
1568
|
+
const s = await readWorkspaceSession(res, deps, cookie, { handed })
|
|
1552
1569
|
if (!s) return null
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1570
|
+
const access = repositoryAccessIn(await repositoryGrants(deps, s.user), repoId, path)
|
|
1571
|
+
if (!access) { sendJson(res, 404, { error: 'no-repository' }); return null }
|
|
1572
|
+
return { ...access, user: s.user }
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// What a reader's grants let them read of the repositories (bundleRepos's
|
|
1576
|
+
// reads, the reader's scopes read once for both), for repositoryAccessIn to
|
|
1577
|
+
// tell of each: read once for as many repositories as a request asks of.
|
|
1578
|
+
async function repositoryGrants(deps: ManagedHttpDeps, user: StoredUser) {
|
|
1579
|
+
const access = await contentAccess(deps.db, user)
|
|
1580
|
+
return { access, repos: (await deps.db.listSelectedRepos()).filter(access.bundle) }
|
|
1581
|
+
}
|
|
1582
|
+
// Repository `repoId` as `grants` let it be read at `path`, as
|
|
1583
|
+
// authorizeRepository answers it; null where it is none of the reader's.
|
|
1584
|
+
function repositoryAccessIn({ access, repos }: Awaited<ReturnType<typeof repositoryGrants>>, repoId: number, path: string) {
|
|
1585
|
+
const repo = repos.find(item => item.repoId === repoId)
|
|
1586
|
+
if (!repo) return null
|
|
1557
1587
|
const scopes = access.scopes == null ? [null] : access.scopes.filter(scope => scope.repoId === repoId).map(scope => scope.path)
|
|
1558
|
-
return { repo, virtualEntries: scopedDirectory(path, scopes)
|
|
1588
|
+
return { repo, virtualEntries: scopedDirectory(path, scopes) }
|
|
1559
1589
|
}
|
|
1560
1590
|
|
|
1561
1591
|
// The repository as it was authorized, but for the shared default branch hint,
|
|
@@ -1575,12 +1605,12 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1575
1605
|
const { repoId, ref } = asked
|
|
1576
1606
|
const withDefault = refs && query.get('withDefault') === 'true'
|
|
1577
1607
|
const path = refs ? '' : asked.path
|
|
1578
|
-
const authorize = async () => {
|
|
1579
|
-
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1608
|
+
const authorize = async (handed = false) => {
|
|
1609
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
|
|
1580
1610
|
if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1581
1611
|
return access
|
|
1582
1612
|
}
|
|
1583
|
-
const access = await authorize()
|
|
1613
|
+
const access = await authorize(true)
|
|
1584
1614
|
if (!access) return
|
|
1585
1615
|
try {
|
|
1586
1616
|
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
|
|
@@ -1590,8 +1620,9 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1590
1620
|
const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
|
|
1591
1621
|
const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
|
|
1592
1622
|
const { tagCommits, tagsComplete, ...listed } = revisions ?? { tagCommits: [], tagsComplete: false }
|
|
1593
|
-
// Create a bundle
|
|
1594
|
-
//
|
|
1623
|
+
// Every refs read (Create a bundle's, and the code viewer's) refreshes the
|
|
1624
|
+
// tags bundle catalogs show, and a tag read keeps the one its revision
|
|
1625
|
+
// input resolves even when the listed page leaves it out.
|
|
1595
1626
|
// Like other GitHub caches, written before the access fence below.
|
|
1596
1627
|
const tags = refs ? tagCommits : ref.length > 5 && ref.startsWith('tags/') && contents ? [{ name: ref.slice(5), sha: contents.commit }] : []
|
|
1597
1628
|
if (refs || tags.length > 0) {
|
|
@@ -1619,45 +1650,78 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1619
1650
|
// tarball is asked for; the reader's access is checked again once it is
|
|
1620
1651
|
// read, before any of it is sent. The load stays held until this response is
|
|
1621
1652
|
// written or abandoned, and a reader who stops reading it gives it up after
|
|
1622
|
-
// a while.
|
|
1653
|
+
// a while. A directory of the default branch's head is kept in byte storage
|
|
1654
|
+
// as it was sent, for the next to open it (repositoryCodeKeptOf).
|
|
1623
1655
|
// What GitHub answered of a repository's trees for the code viewer, which a
|
|
1624
1656
|
// commit or tree it names never changes: the tree at a directory of a commit
|
|
1625
1657
|
// (getRepoTreeId reads the commit, then a listing a level down to it), and
|
|
1626
1658
|
// how many files a tree holds under it and their bytes (repositoryTreeFiles),
|
|
1627
1659
|
// by the repository's id, for the next to open it. Asked of them only once
|
|
1628
1660
|
// the reader's own access has named the commit, as GitHub would have
|
|
1629
|
-
// answered them; by
|
|
1661
|
+
// answered them; held by each instance a while, by the database it has, and
|
|
1662
|
+
// kept in that database for every instance (npm-kept.ts codeTreeStore).
|
|
1630
1663
|
const REPOSITORY_TREES_KEPT = 4096
|
|
1631
1664
|
type TreeFiles = Awaited<ReturnType<typeof repositoryTreeFiles>>
|
|
1632
1665
|
const repositoryTrees = new WeakMap<ManagedDb, { ids: Lru<string>; files: Lru<TreeFiles> }>()
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1666
|
+
const treeId = (value: unknown): value is string => typeof value === 'string' && /^[a-f\d]{40}$/u.test(value)
|
|
1667
|
+
const treeFiles = (value: unknown): value is TreeFiles => value === null || (plainObject(value)
|
|
1668
|
+
&& Number.isSafeInteger(value['files']) && (value['files'] as number) >= 0 && Number.isSafeInteger(value['bytes']) && (value['bytes'] as number) >= 0)
|
|
1669
|
+
function repositoryTreesOf(deps: ManagedHttpDeps) {
|
|
1670
|
+
let held = repositoryTrees.get(deps.db)
|
|
1671
|
+
if (!held) repositoryTrees.set(deps.db, held = { ids: lru(REPOSITORY_TREES_KEPT), files: lru(REPOSITORY_TREES_KEPT) })
|
|
1672
|
+
const { ids, files } = held
|
|
1673
|
+
return {
|
|
1674
|
+
id: (key: string, read: () => Promise<string>) => keptTree(ids, codeTreeStore<string>(deps.db, 'trees', deps.config.debug), key, treeId, read),
|
|
1675
|
+
files: (key: string, read: () => Promise<TreeFiles>) => keptTree(files, codeTreeStore<TreeFiles>(deps.db, 'tree-files', deps.config.debug), key, treeFiles, read),
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
// What `memory` holds by `key`, else what `store` keeps of it as `kept`
|
|
1679
|
+
// takes it, else what `read` answers, then held and kept.
|
|
1680
|
+
async function keptTree<T>(memory: Lru<T>, store: KeptStore<T>, key: string, kept: (value: unknown) => value is T, read: () => Promise<T>): Promise<T> {
|
|
1681
|
+
const held = memory.get(key)
|
|
1682
|
+
if (held !== undefined) return held
|
|
1683
|
+
const stored = await store.read(key)
|
|
1684
|
+
let value: T
|
|
1685
|
+
if (stored && kept(stored.value)) value = stored.value
|
|
1686
|
+
else {
|
|
1687
|
+
value = await read()
|
|
1688
|
+
await store.write(key, value, Date.now())
|
|
1689
|
+
}
|
|
1690
|
+
memory.set(key, value)
|
|
1691
|
+
return value
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// The default branch's head's directories as kept in byte storage
|
|
1695
|
+
// (repository-code.ts repositoryCodeKept), the commit they are kept at in
|
|
1696
|
+
// the database, for a repository active as it is selected; none where this
|
|
1697
|
+
// server has no byte storage for them. Work behind an answer is tracked
|
|
1698
|
+
// (serverless, its invocation kept alive till it settles), as a kept read
|
|
1699
|
+
// read again behind one is.
|
|
1700
|
+
function repositoryCodeKeptOf(deps: ManagedHttpDeps): RepositoryCodeKept | null {
|
|
1701
|
+
if (!deps.repositoryCodeStorage) return null
|
|
1702
|
+
const active = async (repoId: number) => (await deps.db.listSelectedRepos()).some(row => row.repoId === repoId)
|
|
1703
|
+
return repositoryCodeKept(deps.repositoryCodeStorage, codeHeadStore(deps.db, deps.config.debug), active, work => {
|
|
1704
|
+
deps.track(work)
|
|
1705
|
+
deps.waitUntil?.(work)
|
|
1706
|
+
}, deps.config.debug)
|
|
1637
1707
|
}
|
|
1638
1708
|
|
|
1639
1709
|
async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1640
1710
|
const asked = repositoryQuery(res, query)
|
|
1641
1711
|
if (!asked) return
|
|
1642
1712
|
const { repoId, ref, path } = asked
|
|
1643
|
-
const authorize = async () => {
|
|
1644
|
-
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1713
|
+
const authorize = async (handed = false) => {
|
|
1714
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
|
|
1645
1715
|
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1646
1716
|
return access
|
|
1647
1717
|
}
|
|
1648
|
-
const access = await authorize()
|
|
1718
|
+
const access = await authorize(true)
|
|
1649
1719
|
if (!access) return
|
|
1650
1720
|
try {
|
|
1651
1721
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1652
1722
|
const commit = await reader.commit(ref)
|
|
1653
1723
|
const repo = access.repo.fullName
|
|
1654
1724
|
const client = createClient({ token: reader.readToken() })
|
|
1655
|
-
const treeKey = `${access.repo.repoId}\0${commit}\0${path}`, trees = repositoryTreesOf(deps.db)
|
|
1656
|
-
// A directory the commit has none at is none to read, as browsing it says.
|
|
1657
|
-
const tree = trees.ids.get(treeKey) ?? await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1658
|
-
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
|
|
1659
|
-
}))
|
|
1660
|
-
trees.ids.set(treeKey, tree)
|
|
1661
1725
|
// Access still held, and the same repository, before anything read is sent.
|
|
1662
1726
|
const fenced = async () => {
|
|
1663
1727
|
await reader.recheckAccess()
|
|
@@ -1666,25 +1730,54 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1666
1730
|
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
|
|
1667
1731
|
return true
|
|
1668
1732
|
}
|
|
1733
|
+
const send = (bytes: Buffer) => {
|
|
1734
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
1735
|
+
res.writeHead(200, {
|
|
1736
|
+
'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
|
|
1737
|
+
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
1738
|
+
})
|
|
1739
|
+
writeResponse(res, bytes)
|
|
1740
|
+
}
|
|
1741
|
+
// The default branch's head's directory as kept, once the reader's own
|
|
1742
|
+
// access has named its commit, sent as one read is, access checked again;
|
|
1743
|
+
// its place among the active loads held till it is written or abandoned,
|
|
1744
|
+
// else given up before a read from GitHub asks for its own.
|
|
1745
|
+
const at = { repoId, repo, commit, path }, kept = repositoryCodeKeptOf(deps)
|
|
1746
|
+
const atHead = kept !== null && await kept.at(at, ref, reader.defaultBranch)
|
|
1747
|
+
if (atHead) {
|
|
1748
|
+
const keptLoad = kept.read(at)
|
|
1749
|
+
res.once('close', keptLoad.release)
|
|
1750
|
+
const held = await keptLoad.result
|
|
1751
|
+
if (held) {
|
|
1752
|
+
if (await fenced() && !res.destroyed) send(held)
|
|
1753
|
+
return
|
|
1754
|
+
}
|
|
1755
|
+
keptLoad.release()
|
|
1756
|
+
}
|
|
1757
|
+
const trees = repositoryTreesOf(deps)
|
|
1758
|
+
// A directory the commit has none at is none to read, as browsing it says.
|
|
1759
|
+
const tree = await trees.id(`${repoId}\0${commit}\0${path}`, () => upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1760
|
+
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
|
|
1761
|
+
})))
|
|
1669
1762
|
// Too large names the commit it was read at, for History to read.
|
|
1670
|
-
const
|
|
1671
|
-
let listed = trees.files.get(filesKey)
|
|
1672
|
-
if (listed === undefined) trees.files.set(filesKey, listed = await repositoryTreeFiles(repo, reader.readToken(), tree))
|
|
1763
|
+
const listed = await trees.files(`${repoId}\0${tree}`, () => repositoryTreeFiles(repo, reader.readToken(), tree))
|
|
1673
1764
|
if (!repositoryCodeFits(listed)) {
|
|
1674
1765
|
if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
|
|
1675
1766
|
return
|
|
1676
1767
|
}
|
|
1677
1768
|
if (res.destroyed) return
|
|
1678
|
-
|
|
1769
|
+
// A tarball upstream finds no tree in, as @preventive/archive refuses
|
|
1770
|
+
// one with a symlink past the repository's root, is one the viewer
|
|
1771
|
+
// cannot read; one holding another tree is GitHub's answer gone wrong.
|
|
1772
|
+
const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree, listed }, symlinksAbove => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree, symlinksAbove }).catch((err: unknown) => {
|
|
1773
|
+
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(', got no tree: ') ? new GithubApiError(422, 'directory-unreadable') : err
|
|
1774
|
+
})))
|
|
1679
1775
|
res.once('close', load.release)
|
|
1680
1776
|
const bytes = await load.result
|
|
1777
|
+
// Like other GitHub caches, kept before the access fence below.
|
|
1778
|
+
if (atHead) kept.keep(at, bytes)
|
|
1681
1779
|
if (!(await fenced()) || res.destroyed) return
|
|
1682
|
-
|
|
1683
|
-
res.writeHead(200, {
|
|
1684
|
-
'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
|
|
1685
|
-
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
1686
|
-
})
|
|
1687
|
-
writeResponse(res, bytes)
|
|
1780
|
+
send(bytes)
|
|
1688
1781
|
} catch (err) {
|
|
1689
1782
|
if (res.headersSent || res.destroyed) return
|
|
1690
1783
|
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
@@ -1721,12 +1814,12 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1721
1814
|
const starts = from === null ? null : historyStarts(from)
|
|
1722
1815
|
if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
|
|
1723
1816
|
|| (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1724
|
-
const authorize = async () => {
|
|
1725
|
-
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1817
|
+
const authorize = async (handed = false) => {
|
|
1818
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
|
|
1726
1819
|
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1727
1820
|
return access
|
|
1728
1821
|
}
|
|
1729
|
-
const access = await authorize()
|
|
1822
|
+
const access = await authorize(true)
|
|
1730
1823
|
if (!access) return
|
|
1731
1824
|
let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
|
|
1732
1825
|
try {
|
|
@@ -1776,12 +1869,24 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1776
1869
|
// The pull requests squashed into a whole repository's commits, drawn as
|
|
1777
1870
|
// the merges they were, and on its first page the open ones' heads,
|
|
1778
1871
|
// naming commits: with a token, as far as GitHub answers them, the
|
|
1779
|
-
// history sent all the same where it doesn't.
|
|
1872
|
+
// history sent all the same where it doesn't. Those GitHub is still
|
|
1873
|
+
// looking up SQUASH_WAIT_MS on are left to the lookup, kept alive behind
|
|
1874
|
+
// the answer, as a kept answer's read is (`pullsPending`, for the client
|
|
1875
|
+
// to ask again).
|
|
1780
1876
|
const listed = (body as { commits: HistoryCommit[] }).commits
|
|
1781
|
-
const
|
|
1782
|
-
|
|
1877
|
+
const none = { pulls: [] as SquashedPull[], pending: false }
|
|
1878
|
+
const [squashed, openPulls] = token && path === '' ? await Promise.all([
|
|
1879
|
+
squashedPulls(repo, token, listed, historySquashes(deps, repoId), SQUASH_WAIT_MS).then(read => {
|
|
1880
|
+
if (read.behind) {
|
|
1881
|
+
const behind = read.behind.catch(quiet(null))
|
|
1882
|
+
deps.track(behind)
|
|
1883
|
+
deps.waitUntil?.(behind)
|
|
1884
|
+
}
|
|
1885
|
+
return read
|
|
1886
|
+
}, quiet(none)),
|
|
1783
1887
|
readingPulls,
|
|
1784
|
-
]) : [
|
|
1888
|
+
]) : [none, null]
|
|
1889
|
+
const { pulls } = squashed
|
|
1785
1890
|
await reader.recheckAccess()
|
|
1786
1891
|
const current = await authorize()
|
|
1787
1892
|
if (!current) return
|
|
@@ -1791,7 +1896,8 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1791
1896
|
const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
|
|
1792
1897
|
({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
|
|
1793
1898
|
sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
|
|
1794
|
-
...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...
|
|
1899
|
+
...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...squashed.pending ? { pullsPending: true } : {},
|
|
1900
|
+
...openPulls?.length ? { openPulls } : {} }))
|
|
1795
1901
|
} catch (err) {
|
|
1796
1902
|
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1797
1903
|
throw err
|
|
@@ -1845,12 +1951,12 @@ async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie:
|
|
|
1845
1951
|
const asked = repositoryQuery(res, query)
|
|
1846
1952
|
if (!asked) return
|
|
1847
1953
|
const { repoId, path } = asked
|
|
1848
|
-
const authorize = async () => {
|
|
1849
|
-
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1954
|
+
const authorize = async (handed = false) => {
|
|
1955
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
|
|
1850
1956
|
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1851
1957
|
return access
|
|
1852
1958
|
}
|
|
1853
|
-
const access = await authorize()
|
|
1959
|
+
const access = await authorize(true)
|
|
1854
1960
|
if (!access) return
|
|
1855
1961
|
try {
|
|
1856
1962
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
@@ -1887,6 +1993,9 @@ function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
|
|
|
1887
1993
|
// and how long one read is answered as kept even where it is asked for afresh.
|
|
1888
1994
|
const REPOSITORY_READ_FRESH_MS = 2 * 60_000
|
|
1889
1995
|
const REPOSITORY_READ_FLOOR_MS = 10_000
|
|
1996
|
+
// How long a history's answer waits on GitHub looking up the pull requests
|
|
1997
|
+
// squashed into its commits (handleRepositoryHistory).
|
|
1998
|
+
const SQUASH_WAIT_MS = 1500
|
|
1890
1999
|
// The repositories' reads being read again, behind a kept answer or as asked
|
|
1891
2000
|
// for afresh, by key: one at a time, those asking meanwhile answered by it.
|
|
1892
2001
|
const repositoryRereads = new Map<string, Promise<unknown>>()
|
|
@@ -1980,7 +2089,7 @@ function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): H
|
|
|
1980
2089
|
|
|
1981
2090
|
// The pull requests squashed into repository `repoId`'s commits, kept by
|
|
1982
2091
|
// each commit and the rules their commits' co-authors were read by
|
|
1983
|
-
// (COAUTHORS_READ), null for one none was (repository-
|
|
2092
|
+
// (COAUTHORS_READ), null for one none was (repository-squashes.ts
|
|
1984
2093
|
// squashedPulls); one kept before its commits' co-authors were read, read
|
|
1985
2094
|
// again.
|
|
1986
2095
|
function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
|
|
@@ -2023,28 +2132,55 @@ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, c
|
|
|
2023
2132
|
async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
|
|
2024
2133
|
answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
|
|
2025
2134
|
unread: () => Promise<object | null> = () => Promise.resolve(null)): Promise<void> {
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
2029
|
-
return access
|
|
2030
|
-
}
|
|
2031
|
-
const access = await authorize()
|
|
2032
|
-
if (!access) return
|
|
2135
|
+
const access = await wholeRepositoryAccess(deps, cookie, repoId, { handed: true })
|
|
2136
|
+
if ('error' in access) { sendRefusal(res, access); return }
|
|
2033
2137
|
const told = await unread()
|
|
2034
2138
|
if (told) { sendJson(res, 200, await withAppUsers(deps.db, told)); return }
|
|
2139
|
+
const read = await readWholeRepositoryOf(deps, access, answer, () => wholeRepositoryAccess(deps, cookie, repoId))
|
|
2140
|
+
if ('error' in read) { sendRefusal(res, read); return }
|
|
2141
|
+
sendJson(res, 200, await withAppUsers(deps.db, read.value))
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// What a request is refused, as its answer tells it.
|
|
2145
|
+
type Refusal = { status: number; error: string; retryAfter?: number }
|
|
2146
|
+
const refusal = (status: number, error: string, retryAfter?: number | null): Refusal => ({ status, error, ...retryAfter == null ? {} : { retryAfter } })
|
|
2147
|
+
function sendRefusal(res: ServerResponse, { status, error, retryAfter }: Refusal) {
|
|
2148
|
+
sendJson(res, status, { error }, retryAfter == null ? {} : { 'retry-after': String(retryAfter) })
|
|
2149
|
+
}
|
|
2150
|
+
// The whole of repository `repoId`, where the session `cookie` names may
|
|
2151
|
+
// read all of it, and who reads it; else why not: none of it
|
|
2152
|
+
// (`no-repository`), or only some (`no-directory`).
|
|
2153
|
+
type WholeAccess = { repo: SelectedRepo; user: StoredUser }
|
|
2154
|
+
async function wholeRepositoryAccess(deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, { handed = false } = {}): Promise<WholeAccess | Refusal> {
|
|
2155
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
2156
|
+
if (s == null) return refusal(401, 'unauthenticated')
|
|
2157
|
+
if (!roleAtLeast(s.user.role, 'view')) return refusal(403, 'forbidden')
|
|
2158
|
+
return wholeAccessIn(await repositoryGrants(deps, s.user), s.user, repoId)
|
|
2159
|
+
}
|
|
2160
|
+
function wholeAccessIn(grants: Awaited<ReturnType<typeof repositoryGrants>>, user: StoredUser, repoId: number): WholeAccess | Refusal {
|
|
2161
|
+
const access = repositoryAccessIn(grants, repoId, '')
|
|
2162
|
+
if (!access) return refusal(404, 'no-repository')
|
|
2163
|
+
if (access.virtualEntries !== null) return refusal(404, 'no-directory')
|
|
2164
|
+
return { repo: access.repo, user }
|
|
2165
|
+
}
|
|
2166
|
+
// What `answer` reads of the whole of the repository `access` lets its
|
|
2167
|
+
// reader read, with their own GitHub access: as kept, else with that access
|
|
2168
|
+
// and `reauthorize`'s checked again after; else why it was refused.
|
|
2169
|
+
async function readWholeRepositoryOf<T>(deps: ManagedHttpDeps, access: WholeAccess, answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: T; kept: boolean }>,
|
|
2170
|
+
reauthorize: () => Promise<WholeAccess | Refusal>): Promise<{ value: T; kept: boolean } | Refusal> {
|
|
2035
2171
|
try {
|
|
2036
2172
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
2037
2173
|
const token = reader.readToken()
|
|
2038
|
-
if (!token)
|
|
2174
|
+
if (!token) return refusal(403, 'github-token-required')
|
|
2039
2175
|
const answered = await answer(access.repo.fullName, token, access.user)
|
|
2040
|
-
if (answered.kept)
|
|
2176
|
+
if (answered.kept) return answered
|
|
2041
2177
|
await reader.recheckAccess()
|
|
2042
|
-
const current = await
|
|
2043
|
-
if (
|
|
2044
|
-
if (!sameRepository(current.repo, access.repo))
|
|
2045
|
-
|
|
2178
|
+
const current = await reauthorize()
|
|
2179
|
+
if ('error' in current) return current
|
|
2180
|
+
if (!sameRepository(current.repo, access.repo)) return refusal(409, 'repository-changed')
|
|
2181
|
+
return answered
|
|
2046
2182
|
} catch (err) {
|
|
2047
|
-
if (err instanceof GithubApiError)
|
|
2183
|
+
if (err instanceof GithubApiError) return refusal(err.status === 401 ? 502 : err.status, err.message, err.retryAfter)
|
|
2048
2184
|
throw err
|
|
2049
2185
|
}
|
|
2050
2186
|
}
|
|
@@ -2052,7 +2188,9 @@ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
2052
2188
|
// GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
|
|
2053
2189
|
// requests tabs show (repository-issues.ts): `kind`'s open or closed
|
|
2054
2190
|
// (`state`), a page at a time, on from the cursor `after` names. They are
|
|
2055
|
-
// the whole repository's
|
|
2191
|
+
// the whole repository's, read as readWholeRepository reads them, kept with
|
|
2192
|
+
// open pull requests' commits, which they never tell (teamOpenPulls reads
|
|
2193
|
+
// them).
|
|
2056
2194
|
async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2057
2195
|
const repoId = Number(query.get('repoId'))
|
|
2058
2196
|
const kind = query.get('kind')
|
|
@@ -2060,8 +2198,11 @@ async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
2060
2198
|
const after = query.get('after')
|
|
2061
2199
|
if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
|
|
2062
2200
|
|| (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2063
|
-
await
|
|
2064
|
-
|
|
2201
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2202
|
+
const { value: { commits: _commits, ...page }, kept, at } = await keptAnswer(deps, `${repoId}\0issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`,
|
|
2203
|
+
async () => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }))
|
|
2204
|
+
return { value: { ...page, readAt: at }, kept }
|
|
2205
|
+
})
|
|
2065
2206
|
}
|
|
2066
2207
|
|
|
2067
2208
|
// GET /api/admin/repositories/agents: what the code viewer's Agents tab
|
|
@@ -2132,19 +2273,23 @@ async function handleRepositoryPullDependencies(res: ServerResponse, deps: Manag
|
|
|
2132
2273
|
}
|
|
2133
2274
|
|
|
2134
2275
|
// Pull request `number`'s answer, as the endpoint gives it, and whether all
|
|
2135
|
-
// of it was kept, GitHub asked for none of it
|
|
2136
|
-
|
|
2276
|
+
// of it was kept, GitHub asked for none of it: its base's and head's commits
|
|
2277
|
+
// as a page of its repository's open pull requests read them (`commits`,
|
|
2278
|
+
// kept and read again as that page is), else as kept by its number.
|
|
2279
|
+
async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, number: number, review: boolean,
|
|
2280
|
+
commits?: PullCommits) {
|
|
2137
2281
|
const kept = pullDependencyKept(deps, repoId)
|
|
2138
2282
|
const reading: { told?: PullDependencies } = {}
|
|
2139
|
-
const answered =
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2283
|
+
const answered = commits ? { value: { commits: `${commits.base}...${commits.head}` }, kept: true }
|
|
2284
|
+
: await keptAnswer(deps, `${repoId}\0pull-commits\0${PULL_DEPENDENCIES_READ}\0${number}`, async () => {
|
|
2285
|
+
const read = await pullDependenciesAt(fullName, token, number, kept)
|
|
2286
|
+
reading.told = read.told
|
|
2287
|
+
return { commits: read.commits }
|
|
2288
|
+
})
|
|
2144
2289
|
// What its commits tell, as read, else as kept by them, else, no longer
|
|
2145
|
-
// kept, read again.
|
|
2290
|
+
// kept, read again: by those given, without asking GitHub for them.
|
|
2146
2291
|
const held = reading.told ?? (typeof answered.value.commits === 'string' ? await kept.read(answered.value.commits) : null)
|
|
2147
|
-
const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept)).told }
|
|
2292
|
+
const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept, undefined, commits)).told }
|
|
2148
2293
|
// Where its review is on, those of lockfiles it leaves out told to none,
|
|
2149
2294
|
// whoever asks.
|
|
2150
2295
|
const setting = await deps.db.repoDependencyReview(repoId)
|
|
@@ -2155,7 +2300,8 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
|
|
|
2155
2300
|
const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
|
|
2156
2301
|
const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
|
|
2157
2302
|
const reviewed = deps.reviewedDependencies ?? null
|
|
2158
|
-
const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)
|
|
2303
|
+
const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps), lockfileKept(deps, repoId))
|
|
2304
|
+
: { baseline: null, unread: [], error: null }
|
|
2159
2305
|
// Its comments read for reviews, where the host reads them, while it is
|
|
2160
2306
|
// open (pull-comment-reviews.ts reads whether it is with them).
|
|
2161
2307
|
const parse = reviewed?.parseComments
|
|
@@ -2176,32 +2322,40 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
|
|
|
2176
2322
|
// a pull request's others are (pull-dependency-reviews.ts), each left unread
|
|
2177
2323
|
// a directory of its own failing the review, as a pull request's are. Kept,
|
|
2178
2324
|
// its access not asked again, where its head was kept and its lockfiles and
|
|
2179
|
-
// baseline held, GitHub asked for none of them.
|
|
2325
|
+
// baseline held or kept (lockfileKept), GitHub asked for none of them.
|
|
2180
2326
|
async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2181
2327
|
const repoId = Number(query.get('repoId'))
|
|
2182
2328
|
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2183
|
-
await answerWholeRepository(res, deps, cookie, repoId,
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2329
|
+
await answerWholeRepository(res, deps, cookie, repoId, (fullName, token, user) => branchDependencyAnswer(deps, fullName, token, user, repoId),
|
|
2330
|
+
async () => (await deps.db.repoDependencyReview(repoId))?.enabled ? null : { review: null })
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// The default branch's answer, as GET branch-dependencies gives it, and
|
|
2334
|
+
// whether all of it was kept, GitHub asked for none of it; its review's
|
|
2335
|
+
// setting as read already (`known`), where it was.
|
|
2336
|
+
async function branchDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number,
|
|
2337
|
+
known?: RepoDependencyReview | null): Promise<{ value: object; kept: boolean }> {
|
|
2338
|
+
const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
|
|
2339
|
+
const head = headRead.value.sha
|
|
2340
|
+
if (!head) return { value: { head: null, review: null }, kept: false }
|
|
2341
|
+
const asked: GithubAsked = { github: !headRead.kept }
|
|
2342
|
+
// Those its review holds to alone read, as many as are read of them.
|
|
2343
|
+
const setting = known === undefined ? await deps.db.repoDependencyReview(repoId) : known
|
|
2344
|
+
if (!setting?.enabled) return { value: { head, review: null }, kept: false }
|
|
2345
|
+
const reads = lockfileKept(deps, repoId)
|
|
2346
|
+
const read = await lockedAt(fullName, token, head, reads, path => heldTo(setting.lockfiles, path), asked)
|
|
2347
|
+
const { packages, paths } = lockedIn(read)
|
|
2348
|
+
const told = { base: head, head, cut: false, lockfiles: [{ paths, added: [], more: 0, locked: packages.slice(0, MAX_LOCKED), moreLocked: Math.max(0, packages.length - MAX_LOCKED) },
|
|
2349
|
+
...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
|
|
2350
|
+
const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
|
|
2351
|
+
if (!now) return { value: { head, review: null }, kept: !asked.github }
|
|
2352
|
+
// Told by the lockfiles it held to as they were read, though they have
|
|
2353
|
+
// changed since.
|
|
2354
|
+
const state = { ...now, lockfiles: setting.lockfiles }
|
|
2355
|
+
const reviewed = deps.reviewedDependencies ?? null
|
|
2356
|
+
const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
|
|
2357
|
+
: await baselineFor(fullName, token, repoId, state, baselineKept(deps), reads, asked)
|
|
2358
|
+
return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: !asked.github }
|
|
2205
2359
|
}
|
|
2206
2360
|
|
|
2207
2361
|
// GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
|
|
@@ -2213,7 +2367,195 @@ async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpD
|
|
|
2213
2367
|
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2214
2368
|
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2215
2369
|
const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
|
|
2216
|
-
return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head)).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
|
|
2370
|
+
return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head, lockfileKept(deps, repoId))).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
|
|
2371
|
+
})
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// GET /api/teams/:id/dependencies: what the team's Dependencies tab shows
|
|
2375
|
+
// (team-dependencies.js), in one request rather than one a repository, a
|
|
2376
|
+
// page of its pull requests and a batch of their dependencies: of each of
|
|
2377
|
+
// the team's repositories (those `repoIds` names alone, where it names
|
|
2378
|
+
// some), as lines of JSON as each is read, as the endpoints of each read
|
|
2379
|
+
// them: its default branch's dependencies and its open pull requests
|
|
2380
|
+
// (`{ repoId, branch, pulls: { items, more } }`) where its review is in
|
|
2381
|
+
// effect for the reader, else `{ repoId, branch: null }`, as for one they
|
|
2382
|
+
// may read only some of, or `{ repoId, error, status }`; then its pull
|
|
2383
|
+
// requests' dependencies, of those changing a lockfile or that may,
|
|
2384
|
+
// TEAM_PULLS_TOGETHER at a time (`{ repoId, dependencies }`); each line of
|
|
2385
|
+
// dependencies with the lines their versions wanting reviews change, where
|
|
2386
|
+
// they are counted already (`deltas`, npm-delta.ts keptNpmDeltas), told
|
|
2387
|
+
// before the rows asking for them are drawn. Last, `{ done: true
|
|
2388
|
+
// }`, or, its time up, `{ done: false, unread }`, the repositories left for
|
|
2389
|
+
// the reader to ask for again. The team's repositories, the reader's grants
|
|
2390
|
+
// and the repositories' reviews read once, those reviewing none answered at
|
|
2391
|
+
// once, GitHub asked nothing of them; each read with GitHub has its access
|
|
2392
|
+
// checked again after (readWholeRepositoryOf), and the reader's session
|
|
2393
|
+
// before each line, their team and grants before each telling a
|
|
2394
|
+
// repository's.
|
|
2395
|
+
const TEAM_REPOSITORIES_AT_ONCE = 4
|
|
2396
|
+
const TEAM_DEPENDENCIES_MS = 240_000
|
|
2397
|
+
const TEAM_PULL_PAGES = 20
|
|
2398
|
+
const TEAM_PULLS_TOGETHER = 20
|
|
2399
|
+
const PARTIAL_ACCESS = new Set(['no-directory', 'no-repository'])
|
|
2400
|
+
type TeamDependencyLine = Record<string, unknown> & { repoId?: number }
|
|
2401
|
+
type TeamPull = { number: number; touchesLockfile?: boolean | null }
|
|
2402
|
+
async function handleTeamDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, s: { session: ManagedSession; user: StoredUser },
|
|
2403
|
+
teamId: string, query: URLSearchParams): Promise<void> {
|
|
2404
|
+
const deadline = Date.now() + TEAM_DEPENDENCIES_MS
|
|
2405
|
+
const named = query.get('repoIds')
|
|
2406
|
+
const only = named === null ? null : new Set(named.split(',').map(Number))
|
|
2407
|
+
if (only && (only.size === 0 || only.size > 1000 || [...only].some(id => !Number.isSafeInteger(id) || id <= 0))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2408
|
+
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
|
|
2409
|
+
const repoIds = [...new Set(snapshot.repositories.map(repo => repo.repoId))].filter(id => !only || only.has(id))
|
|
2410
|
+
const grants = await repositoryGrants(deps, s.user)
|
|
2411
|
+
const settings = await deps.db.getRepoDependencyReviews(repoIds)
|
|
2412
|
+
const out = openLines(res)
|
|
2413
|
+
let stopped = false
|
|
2414
|
+
// Lines written once their reader still reads them as at the start, the
|
|
2415
|
+
// stream running minutes and a kept answer asking nothing again: their
|
|
2416
|
+
// session theirs, and, where a line tells what a repository holds, the
|
|
2417
|
+
// team still theirs and holding it, their grants still reaching the whole
|
|
2418
|
+
// of it. None, and the answer cut short, where not, or the reader is gone.
|
|
2419
|
+
const send = async (...lines: TeamDependencyLine[]) => {
|
|
2420
|
+
if (stopped) return false
|
|
2421
|
+
const now = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2422
|
+
if (now?.user.id !== s.user.id || !roleAtLeast(now.user.role, 'view')) stopped = true
|
|
2423
|
+
const telling = lines.filter(line => line.repoId !== undefined && (line['branch'] || line['dependencies'])).map(line => line.repoId!)
|
|
2424
|
+
if (!stopped && telling.length > 0 && !await stillReads(now!.user, telling)) stopped = true
|
|
2425
|
+
for (const line of lines) if (stopped || !await out.write(line)) stopped = true
|
|
2426
|
+
if (stopped) out.cut()
|
|
2427
|
+
return !stopped
|
|
2428
|
+
}
|
|
2429
|
+
const stillReads = async (user: StoredUser, ids: number[]) => {
|
|
2430
|
+
const team = await teamSnapshot(deps.db, s.session.id, teamId).catch((err: unknown) => {
|
|
2431
|
+
if (err instanceof TeamReportsError) return null
|
|
2432
|
+
throw err
|
|
2433
|
+
})
|
|
2434
|
+
if (!team) return false
|
|
2435
|
+
const current = await repositoryGrants(deps, user)
|
|
2436
|
+
return ids.every(id => team.repositories.some(repo => repo.repoId === id) && !('error' in wholeAccessIn(current, user, id)))
|
|
2437
|
+
}
|
|
2438
|
+
const reading: { access: WholeAccess; setting: RepoDependencyReview }[] = []
|
|
2439
|
+
const answered: TeamDependencyLine[] = []
|
|
2440
|
+
for (const repoId of repoIds) {
|
|
2441
|
+
const access = wholeAccessIn(grants, s.user, repoId), setting = settings.get(repoId)
|
|
2442
|
+
if ('error' in access || !setting?.enabled) answered.push({ repoId, branch: null })
|
|
2443
|
+
else reading.push({ access, setting })
|
|
2444
|
+
}
|
|
2445
|
+
if (answered.length > 0 && !await send(...answered)) return
|
|
2446
|
+
const done = await eachOf(reading, TEAM_REPOSITORIES_AT_ONCE, ({ access, setting }) => stopped || Date.now() > deadline ? Promise.resolve(false)
|
|
2447
|
+
: teamRepositoryDependencies(deps, access, setting, deadline, send, () => wholeRepositoryAccess(deps, cookie, access.repo.repoId)))
|
|
2448
|
+
if (stopped) return
|
|
2449
|
+
const unread = reading.filter((_, at) => !done[at]).map(({ access }) => access.repo.repoId)
|
|
2450
|
+
if (!await send(unread.length === 0 ? { done: true } : { done: false, unread })) return
|
|
2451
|
+
await out.end()
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// A repository's lines of its team's Dependencies, as handleTeamDependencies
|
|
2455
|
+
// tells them; whether all were written, none left for its time.
|
|
2456
|
+
async function teamRepositoryDependencies(deps: ManagedHttpDeps, access: WholeAccess, setting: RepoDependencyReview, deadline: number,
|
|
2457
|
+
send: (...lines: TeamDependencyLine[]) => Promise<boolean>, reauthorize: () => Promise<WholeAccess | Refusal>): Promise<boolean> {
|
|
2458
|
+
const repoId = access.repo.repoId
|
|
2459
|
+
type Read = { branch: object | null; pulls?: { items: TeamPull[]; more: boolean }; commits?: Map<number, PullCommits> }
|
|
2460
|
+
const read = await readWholeRepositoryOf<Read>(deps, access, async (fullName, token, user) => {
|
|
2461
|
+
const branch = await branchDependencyAnswer(deps, fullName, token, user, repoId, setting)
|
|
2462
|
+
if (!(branch.value as { review?: unknown }).review) return { value: { branch: null }, kept: branch.kept }
|
|
2463
|
+
const pulls = await teamOpenPulls(deps, repoId, fullName, token)
|
|
2464
|
+
return { value: { branch: branch.value, pulls: { items: pulls.items, more: pulls.more }, commits: pulls.commits }, kept: branch.kept && pulls.kept }
|
|
2465
|
+
}, reauthorize)
|
|
2466
|
+
if ('error' in read) return send({ repoId, ...PARTIAL_ACCESS.has(read.error) ? { branch: null } : { error: read.error, status: read.status } })
|
|
2467
|
+
const { branch, pulls, commits } = read.value
|
|
2468
|
+
if (!branch) return send({ repoId, branch: null })
|
|
2469
|
+
if (!await send({ repoId, branch, pulls, ...await withDeltas(deps, [branch]) })) return false
|
|
2470
|
+
// Each by the commits its page names, where it names them, GitHub not asked
|
|
2471
|
+
// for them (pullDependencyAnswer); never told.
|
|
2472
|
+
const numbers = pulls!.items.filter(item => item.touchesLockfile === true || item.touchesLockfile === null).map(item => item.number)
|
|
2473
|
+
for (let at = 0; at < numbers.length; at += TEAM_PULLS_TOGETHER) {
|
|
2474
|
+
if (Date.now() > deadline) return false
|
|
2475
|
+
const batch = numbers.slice(at, at + TEAM_PULLS_TOGETHER)
|
|
2476
|
+
const pulled = await readWholeRepositoryOf(deps, access, async (fullName, token, user) => {
|
|
2477
|
+
const answers = await eachOf(batch, PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, true, commits?.get(number)).catch((err: unknown) => {
|
|
2478
|
+
// GitHub asked, though it failed: its access asked again after.
|
|
2479
|
+
if (err instanceof GithubApiError) return { value: { number, error: err.message }, kept: false }
|
|
2480
|
+
throw err
|
|
2481
|
+
}))
|
|
2482
|
+
return { value: answers.map(({ value }) => value), kept: answers.every(({ kept }) => kept) }
|
|
2483
|
+
}, reauthorize)
|
|
2484
|
+
const dependencies = 'error' in pulled ? batch.map(number => ({ number, error: pulled.error })) : pulled.value
|
|
2485
|
+
if (!await send({ repoId, dependencies, ...await withDeltas(deps, dependencies) })) return false
|
|
2486
|
+
}
|
|
2487
|
+
return true
|
|
2488
|
+
}
|
|
2489
|
+
const withDeltas = async (deps: ManagedHttpDeps, answers: object[]) => {
|
|
2490
|
+
const deltas = await teamDeltas(deps, answers)
|
|
2491
|
+
return deltas.length > 0 ? { deltas } : {}
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// A commit's or a blob's id, as GitHub names one.
|
|
2495
|
+
const OBJECT_ID = /^[a-f\d]{40}$/u
|
|
2496
|
+
|
|
2497
|
+
// A repository's open pull requests, as many pages of them as
|
|
2498
|
+
// TEAM_PULL_PAGES, each read and kept as GET issues reads it; each's base's
|
|
2499
|
+
// and head's commits as its page has them (`commits`, by its number), where
|
|
2500
|
+
// it has them; whether it has more, and whether all were kept.
|
|
2501
|
+
async function teamOpenPulls(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
|
|
2502
|
+
const items: TeamPull[] = []
|
|
2503
|
+
const commits = new Map<number, PullCommits>()
|
|
2504
|
+
let after: string | null = null, kept = true
|
|
2505
|
+
for (let page = 0; page < TEAM_PULL_PAGES; page++) {
|
|
2506
|
+
const cursor = after
|
|
2507
|
+
const answer = await keptAnswer(deps, `${repoId}\0issues\0${ISSUES_READ_VERSION}\0pulls\0open\0${cursor ?? ''}`,
|
|
2508
|
+
async () => ({ kind: 'pulls', state: 'open', ...await repositoryIssues(fullName, token, 'pulls', 'open', cursor) }))
|
|
2509
|
+
kept &&= answer.kept
|
|
2510
|
+
const value = answer.value as { items?: unknown; more?: unknown; after?: unknown; commits?: unknown }
|
|
2511
|
+
items.push(...(Array.isArray(value.items) ? value.items : []).filter(item => Number.isSafeInteger(item?.number) && item.number > 0))
|
|
2512
|
+
for (const pull of Array.isArray(value.commits) ? value.commits as { number?: unknown; base?: unknown; head?: unknown }[] : []) {
|
|
2513
|
+
const { number, base, head } = pull ?? {}
|
|
2514
|
+
if (Number.isSafeInteger(number) && typeof base === 'string' && OBJECT_ID.test(base) && typeof head === 'string' && OBJECT_ID.test(head)) {
|
|
2515
|
+
commits.set(number as number, { base, head })
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
if (value.more !== true || typeof value.after !== 'string') return { items, commits, more: false, kept }
|
|
2519
|
+
after = value.after
|
|
2520
|
+
}
|
|
2521
|
+
return { items, commits, more: true, kept }
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
// The lines the versions wanting reviews of `answers` change, as their rows
|
|
2525
|
+
// show them (npm-delta.js deltaBase), where they are counted already: of
|
|
2526
|
+
// public versions alone, those npm answered for in a day (npmVersionStore),
|
|
2527
|
+
// their integrities those npmDelta keys them by. `[name, version, base,
|
|
2528
|
+
// delta]` each, none counted here.
|
|
2529
|
+
async function teamDeltas(deps: ManagedHttpDeps, answers: object[]) {
|
|
2530
|
+
const asked = new Map<string, [string, string, string]>()
|
|
2531
|
+
for (const answer of answers as { lockfiles?: { added?: unknown; unreviewed?: unknown }[]; review?: { required?: number } }[]) {
|
|
2532
|
+
const required = answer.review?.required
|
|
2533
|
+
if (!Number.isSafeInteger(required)) continue
|
|
2534
|
+
for (const pkg of (answer.lockfiles ?? []).flatMap(dir => [...Array.isArray(dir.added) ? dir.added : [], ...Array.isArray(dir.unreviewed) ? dir.unreviewed : []]) as
|
|
2535
|
+
{ name?: unknown; version?: unknown; source?: unknown; was?: unknown; reviews?: unknown; rejected?: unknown; baseline?: unknown; reviewed?: unknown; unsupported?: unknown }[]) {
|
|
2536
|
+
if (pkg.source !== 'registry' || typeof pkg.name !== 'string' || typeof pkg.version !== 'string' || !isExactVersion(pkg.version)) continue
|
|
2537
|
+
if (pkg.unsupported || pkg.baseline || pkg.reviewed || typeof pkg.reviews !== 'number' || (pkg.reviews >= required! && !pkg.rejected)) continue
|
|
2538
|
+
const was = Array.isArray(pkg.was) ? pkg.was.find(prior => typeof prior === 'string' && prior !== pkg.version) as string | undefined : undefined
|
|
2539
|
+
asked.set(`${pkg.name}\0${pkg.version}\0${was ?? '(empty)'}`, [pkg.name, pkg.version, was ?? '(empty)'])
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
if (asked.size === 0) return []
|
|
2543
|
+
const versions = [...asked.values()].flatMap(([name, version, base]) => [{ name, version }, ...base === '(empty)' || !isExactVersion(base) ? [] : [{ name, version: base }]])
|
|
2544
|
+
const kept = await npmVersionStore(deps.db, deps.config.debug).readMany!(versions)
|
|
2545
|
+
const integrity = (name: string, version: string) => {
|
|
2546
|
+
const hit = kept.get(`${name}@${version}`)
|
|
2547
|
+
const dist = hit && Date.now() - hit.at < VERSION_KEPT_MS ? hit.json['dist'] : null
|
|
2548
|
+
return typeof dist === 'object' && dist !== null && typeof (dist as { integrity?: unknown }).integrity === 'string' ? (dist as { integrity: string }).integrity : null
|
|
2549
|
+
}
|
|
2550
|
+
const sides = [...asked.values()].flatMap(([name, version, base]) => {
|
|
2551
|
+
const against = base === '(empty)' ? null : integrity(name, base), other = integrity(name, version)
|
|
2552
|
+
return other && (base === '(empty)' || against) ? [{ name, version, base, key: [against, other] as [string | null, string] }] : []
|
|
2553
|
+
})
|
|
2554
|
+
if (sides.length === 0) return []
|
|
2555
|
+
const counted = await keptNpmDeltas(sides.map(({ key }) => key), npmDeltaStore<NpmDelta>(deps.db, deps.config.debug))
|
|
2556
|
+
return sides.flatMap(({ name, version, base, key: [against, other] }) => {
|
|
2557
|
+
const delta = counted.get(`${against ?? '(empty)'}\0${other}`)
|
|
2558
|
+
return delta ? [[name, version, base, delta]] : []
|
|
2217
2559
|
})
|
|
2218
2560
|
}
|
|
2219
2561
|
|
|
@@ -2254,6 +2596,34 @@ function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependen
|
|
|
2254
2596
|
}
|
|
2255
2597
|
}
|
|
2256
2598
|
|
|
2599
|
+
// What reading repository `repoId`'s lockfiles for its dependency review
|
|
2600
|
+
// keeps, by the rules they were read by (PULL_DEPENDENCIES_READ,
|
|
2601
|
+
// lockfileReadStore): each commit's listing, and each lockfile's read, of
|
|
2602
|
+
// those GitHub gave (pull-dependencies.ts LockfileStore). One kept otherwise
|
|
2603
|
+
// is none.
|
|
2604
|
+
function lockfileKept(deps: ManagedHttpDeps, repoId: number): LockfileStore {
|
|
2605
|
+
const listings = lockfileReadStore<ListedLockfile[]>(deps.db, 'listings', deps.config.debug)
|
|
2606
|
+
const lockfiles = lockfileReadStore<LockfileRead>(deps.db, 'lockfiles', deps.config.debug)
|
|
2607
|
+
const keyOf = (key: string) => `${repoId}\0${PULL_DEPENDENCIES_READ}\0${key}`
|
|
2608
|
+
return {
|
|
2609
|
+
listings: {
|
|
2610
|
+
async read(commit) {
|
|
2611
|
+
const value: unknown = (await listings.read(keyOf(commit)))?.value
|
|
2612
|
+
return Array.isArray(value) && value.every(item => typeof item?.path === 'string' && typeof item.format === 'string' && lockfileFormat(item.path) === item.format
|
|
2613
|
+
&& typeof item.sha === 'string' && OBJECT_ID.test(item.sha)) ? value : null
|
|
2614
|
+
},
|
|
2615
|
+
write: (commit, listed) => listings.write(keyOf(commit), listed, Date.now()),
|
|
2616
|
+
},
|
|
2617
|
+
lockfiles: {
|
|
2618
|
+
async read(key) {
|
|
2619
|
+
const value = (await lockfiles.read(keyOf(key)))?.value as { packages?: unknown; error?: unknown } | null | undefined
|
|
2620
|
+
return value && (Array.isArray(value.packages) || value.error === 'lockfile-unreadable') ? value as LockfileRead : null
|
|
2621
|
+
},
|
|
2622
|
+
write: (key, read) => lockfiles.write(keyOf(key), read, Date.now()),
|
|
2623
|
+
},
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2257
2627
|
// GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
|
|
2258
2628
|
// (repository-pulse.ts): the default branch's year, counted from its commits
|
|
2259
2629
|
// (pulseCommits), and the pull requests and issues of the last `weeks`
|
|
@@ -2480,7 +2850,7 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
|
|
|
2480
2850
|
// reader leaving, losing their admin role or
|
|
2481
2851
|
// the server shutting down stops it.
|
|
2482
2852
|
async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2483
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
2853
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
2484
2854
|
if (!s) return
|
|
2485
2855
|
const repos = await riskRepositories(deps.db, query)
|
|
2486
2856
|
if (!repos) {
|
|
@@ -2763,7 +3133,7 @@ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2763
3133
|
}
|
|
2764
3134
|
|
|
2765
3135
|
async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2766
|
-
const initial = await manageMutation(req, res, deps, cookie)
|
|
3136
|
+
const initial = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
2767
3137
|
if (!initial) return
|
|
2768
3138
|
const controller = new AbortController()
|
|
2769
3139
|
const onClose = () => controller.abort()
|
|
@@ -2826,7 +3196,7 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2826
3196
|
// After storing, any reports that declared this integrity but weren't linked yet
|
|
2827
3197
|
// get attached (auto-link). 413 over the cap, 400 on empty.
|
|
2828
3198
|
async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2829
|
-
let s = await checkMutation(req, res, deps, cookie)
|
|
3199
|
+
let s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
2830
3200
|
if (s == null) return
|
|
2831
3201
|
if (!requireManageRole(res, s.user)) return
|
|
2832
3202
|
let bytes: Buffer
|
|
@@ -2956,7 +3326,7 @@ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2956
3326
|
// the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
|
|
2957
3327
|
// best-effort. 404 when there was no such bundle.
|
|
2958
3328
|
async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
2959
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
3329
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
2960
3330
|
if (s == null) return
|
|
2961
3331
|
if (!requireManageRole(res, s.user)) return
|
|
2962
3332
|
const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
|
|
@@ -2969,8 +3339,8 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2969
3339
|
|
|
2970
3340
|
// admin|manage mutation guard: same-origin + CSRF + role, in one step. Returns
|
|
2971
3341
|
// the session, or null after the 401/403 was sent.
|
|
2972
|
-
async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
2973
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
3342
|
+
async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
3343
|
+
const s = await checkMutation(req, res, deps, cookie, { handed })
|
|
2974
3344
|
if (s == null) return null
|
|
2975
3345
|
if (!requireManageRole(res, s.user)) return null
|
|
2976
3346
|
return s
|
|
@@ -2978,8 +3348,8 @@ async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: M
|
|
|
2978
3348
|
|
|
2979
3349
|
// Workspace access and repository connections are administered separately
|
|
2980
3350
|
// from content management. Managers cannot change their own access via teams.
|
|
2981
|
-
async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
2982
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
3351
|
+
async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
3352
|
+
const s = await checkMutation(req, res, deps, cookie, { handed })
|
|
2983
3353
|
if (s == null) return null
|
|
2984
3354
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
2985
3355
|
return s
|
|
@@ -3035,7 +3405,7 @@ async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId:
|
|
|
3035
3405
|
// visible findings (location AND evidence) are returned. Missing/unavailable
|
|
3036
3406
|
// bundles are a quiet no-op; cached gzip bodies stream directly from disk.
|
|
3037
3407
|
async function handleReportSources(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null) {
|
|
3038
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
3408
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
|
|
3039
3409
|
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3040
3410
|
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3041
3411
|
const sourcePaths = roleAtLeast(s.user.role, 'manage') ? undefined : await teamSourcePaths(deps.db, deps.reportStore, s.session.id, teamId ?? '', id)
|
|
@@ -3079,7 +3449,7 @@ async function reportIds(data: unknown): Promise<Set<string>> {
|
|
|
3079
3449
|
async function handleDeduplication(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
3080
3450
|
const method = req.method ?? 'GET'
|
|
3081
3451
|
if (!(id ? ['GET', 'PATCH'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'GET, PATCH' : 'GET, POST'); return }
|
|
3082
|
-
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
3452
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
|
|
3083
3453
|
if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3084
3454
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
3085
3455
|
if (method === 'GET') { sendJson(res, 200, id ? await deps.db.getLinkReport(s.session.id, id) : { reports: await deps.db.listLinkReports() }); return }
|
|
@@ -3102,7 +3472,7 @@ async function handleDeduplication(req: IncomingMessage, res: ServerResponse, de
|
|
|
3102
3472
|
async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
3103
3473
|
const method = req.method ?? 'GET'
|
|
3104
3474
|
if (!(id ? ['PATCH', 'DELETE'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'PATCH, DELETE' : 'GET, POST'); return }
|
|
3105
|
-
const s = method === 'GET' ? await readAdminSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
|
|
3475
|
+
const s = method === 'GET' ? await readAdminSession(res, deps, cookie, { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
|
|
3106
3476
|
if (!s) return
|
|
3107
3477
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
3108
3478
|
if (method === 'GET') {
|
|
@@ -3122,7 +3492,7 @@ async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse
|
|
|
3122
3492
|
// Managers can resolve only destinations their current repository grants cover.
|
|
3123
3493
|
async function handleRepositorySuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3124
3494
|
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
3125
|
-
if (!await readManageSession(res, deps, cookie)) return
|
|
3495
|
+
if (!await readManageSession(res, deps, cookie, { handed: true })) return
|
|
3126
3496
|
const params = new URL(req.url!, 'http://localhost').searchParams
|
|
3127
3497
|
const github = reportRepoGithub({ repo: { github: params.get('repo') } })
|
|
3128
3498
|
const directory = normalizeTeamPath(params.get('directory'))
|
|
@@ -3160,7 +3530,7 @@ async function suggestReportLocation(deps: ManagedHttpDeps, user: StoredUser, da
|
|
|
3160
3530
|
// location editor. It never changes the stored assignment.
|
|
3161
3531
|
async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
3162
3532
|
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
3163
|
-
const s = await readManageSession(res, deps, cookie)
|
|
3533
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
3164
3534
|
if (!s) return
|
|
3165
3535
|
const report = await deps.db.getReport(id)
|
|
3166
3536
|
if (!report || !(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
@@ -3182,7 +3552,7 @@ async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerR
|
|
|
3182
3552
|
// "not authorized" (so neither existence nor membership is probeable); 503 = row
|
|
3183
3553
|
// without bytes (store desync).
|
|
3184
3554
|
async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
3185
|
-
const s = await readManageSession(res, deps, cookie)
|
|
3555
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
3186
3556
|
if (s == null) return
|
|
3187
3557
|
const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
|
|
3188
3558
|
const access = snapshot?.reports[0]
|
|
@@ -3219,7 +3589,7 @@ async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3219
3589
|
const REPORT_QUERY_CONCURRENCY = 8
|
|
3220
3590
|
const REPORT_QUERY_IN_FLIGHT_BYTES = 64 * 1024 * 1024
|
|
3221
3591
|
async function handleQueryReports(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3222
|
-
const s = await readManageSession(res, deps, cookie)
|
|
3592
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
3223
3593
|
if (!s) return
|
|
3224
3594
|
let body
|
|
3225
3595
|
try { body = await readJsonBody(req, 1024 * 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3313,9 +3683,11 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
|
|
|
3313
3683
|
|
|
3314
3684
|
// The server selects the complete published workspace for classification.
|
|
3315
3685
|
// Privileged hidden-report previews are independently scoped and bounded.
|
|
3316
|
-
|
|
3686
|
+
// Its reports' file trees are asked for apart, by its graph or Files alone.
|
|
3687
|
+
async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string, reportId: string | null,
|
|
3688
|
+
part: 'reports' | 'trees' = 'reports'): Promise<void> {
|
|
3317
3689
|
const snapshot = await teamSnapshot(deps.db, session.id, teamId, reportId)
|
|
3318
|
-
const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
|
|
3690
|
+
const body = await (part === 'trees' ? loadTeamTreesResponse : loadTeamReportsResponse)(deps.db, deps.reportStore, snapshot)
|
|
3319
3691
|
await recheckTeam(deps.db, session.id, snapshot)
|
|
3320
3692
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
3321
3693
|
writeResponse(res, body)
|
|
@@ -3372,7 +3744,7 @@ async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256
|
|
|
3372
3744
|
// stripped finding must not leak that the finding exists. A cleared entry is
|
|
3373
3745
|
// sent as null (the server's tombstone), distinct from one never set.
|
|
3374
3746
|
async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
|
|
3375
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
3747
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
|
|
3376
3748
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3377
3749
|
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3378
3750
|
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
@@ -3391,7 +3763,7 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
3391
3763
|
// exists). Each event is the entry as written then (null = a clear), who
|
|
3392
3764
|
// wrote it and when; "what changed" is the diff against the next-older event.
|
|
3393
3765
|
async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
|
|
3394
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
3766
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
|
|
3395
3767
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3396
3768
|
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3397
3769
|
const finding = query.get('finding') ?? ''
|
|
@@ -3422,7 +3794,7 @@ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHt
|
|
|
3422
3794
|
// scope for rows that are themselves per finding id — so a stripped (or
|
|
3423
3795
|
// foreign) finding id 404s without revealing whether it exists.
|
|
3424
3796
|
async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
|
|
3425
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
3797
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
3426
3798
|
if (s == null) return
|
|
3427
3799
|
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3428
3800
|
let body: unknown
|
|
@@ -3456,7 +3828,7 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
|
|
|
3456
3828
|
// GET only: clients match local annotations against these IDs without sending
|
|
3457
3829
|
// their local finding IDs or annotation bodies to discover which reports exist.
|
|
3458
3830
|
async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
|
|
3459
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
3831
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
3460
3832
|
if (!s) return
|
|
3461
3833
|
const after = search.get('after') ?? ''
|
|
3462
3834
|
if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
|
|
@@ -3478,7 +3850,7 @@ async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
3478
3850
|
// Admin imports compare the exact snapshot shown by the conflict dialog before
|
|
3479
3851
|
// writing. The store commits shared finding triage and imported comments together.
|
|
3480
3852
|
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
3481
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
3853
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3482
3854
|
if (!s) return
|
|
3483
3855
|
let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
|
|
3484
3856
|
try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof body }
|
|
@@ -3517,7 +3889,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
|
|
|
3517
3889
|
if (commentId == null ? method !== 'GET' && method !== 'POST' : method !== 'PATCH' && method !== 'DELETE') {
|
|
3518
3890
|
send405(res, commentId == null ? 'GET, POST' : 'PATCH, DELETE'); return
|
|
3519
3891
|
}
|
|
3520
|
-
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
3892
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
|
|
3521
3893
|
if (s == null) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3522
3894
|
const allowed = method === 'GET' ? await canViewReport(deps, s.user, reportId) : await canTriageReport(deps, s.user, reportId)
|
|
3523
3895
|
if (!allowed) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
@@ -3565,7 +3937,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
|
|
|
3565
3937
|
// the page needs: all users (member dropdown), selected repos (repo dropdown),
|
|
3566
3938
|
// and the visibility-permission keys. admin, read-only (no CSRF).
|
|
3567
3939
|
async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3568
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
3940
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
3569
3941
|
if (s == null) return
|
|
3570
3942
|
const scopes = await deps.db.listTeamNpmScopes()
|
|
3571
3943
|
const reviewTeams = new Set(await deps.db.listDependencyReviewEnabledTeams())
|
|
@@ -3579,7 +3951,7 @@ async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cooki
|
|
|
3579
3951
|
|
|
3580
3952
|
// POST /api/admin/teams — create a team. Body { name }. 409 if the name's taken.
|
|
3581
3953
|
async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3582
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
3954
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3583
3955
|
if (s == null) return
|
|
3584
3956
|
let body: unknown
|
|
3585
3957
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3596,7 +3968,7 @@ async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3596
3968
|
// POST /api/admin/teams/rename — rename a team. Body { teamId, name }. 404 if no
|
|
3597
3969
|
// such team; 409 if the new name is already taken by another team.
|
|
3598
3970
|
async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3599
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
3971
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3600
3972
|
if (s == null) return
|
|
3601
3973
|
let body: unknown
|
|
3602
3974
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3615,7 +3987,7 @@ async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3615
3987
|
|
|
3616
3988
|
// Hidden teams retain their configuration but grant no access or sidebar presence.
|
|
3617
3989
|
async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3618
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
3990
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3619
3991
|
if (s == null) return
|
|
3620
3992
|
let body: unknown
|
|
3621
3993
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3631,7 +4003,7 @@ async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, de
|
|
|
3631
4003
|
|
|
3632
4004
|
// POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
|
|
3633
4005
|
async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3634
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4006
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3635
4007
|
if (s == null) return
|
|
3636
4008
|
let body: unknown
|
|
3637
4009
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3648,7 +4020,7 @@ async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3648
4020
|
// path links with the whole repository. Body { teamId, repoId, path? }.
|
|
3649
4021
|
// Repo must be in the selected set.
|
|
3650
4022
|
async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3651
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4023
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3652
4024
|
if (s == null) return
|
|
3653
4025
|
let body: unknown
|
|
3654
4026
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3673,7 +4045,7 @@ async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps
|
|
|
3673
4045
|
// POST /api/admin/teams/remove-repo — unlink one path. Omitting path retains
|
|
3674
4046
|
// the legacy remove-all behavior. Body { teamId, repoId, path? }.
|
|
3675
4047
|
async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3676
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4048
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3677
4049
|
if (s == null) return
|
|
3678
4050
|
let body: unknown
|
|
3679
4051
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3693,7 +4065,7 @@ async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, d
|
|
|
3693
4065
|
// POST /api/admin/teams/set-member — add/update a member + their visibility
|
|
3694
4066
|
// permissions. Body { teamId, userId, dependencies?, security? }.
|
|
3695
4067
|
async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3696
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4068
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3697
4069
|
if (s == null) return
|
|
3698
4070
|
let body: unknown
|
|
3699
4071
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3716,7 +4088,7 @@ async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, de
|
|
|
3716
4088
|
|
|
3717
4089
|
// POST /api/admin/teams/remove-member — remove a membership. Body { teamId, userId }.
|
|
3718
4090
|
async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3719
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4091
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3720
4092
|
if (s == null) return
|
|
3721
4093
|
let body: unknown
|
|
3722
4094
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3732,7 +4104,7 @@ async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse,
|
|
|
3732
4104
|
}
|
|
3733
4105
|
|
|
3734
4106
|
async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3735
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4107
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3736
4108
|
if (!s) return
|
|
3737
4109
|
let body
|
|
3738
4110
|
try { body = await readJsonBody(req) as { teamId?: unknown; enabled?: unknown } } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3744,7 +4116,7 @@ async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerRe
|
|
|
3744
4116
|
// POST /api/admin/teams/set-npm-scopes — replace the npm scopes whose private
|
|
3745
4117
|
// packages the team's members may read. Body { teamId, scopes: ['@scope'] }.
|
|
3746
4118
|
async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3747
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4119
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3748
4120
|
if (s == null) return
|
|
3749
4121
|
let body: unknown
|
|
3750
4122
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3763,10 +4135,18 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
|
|
|
3763
4135
|
// npm scopes of its visible teams, else none: none are read for an unscoped
|
|
3764
4136
|
// package, an admin or a manager, or an answer checked again that is public.
|
|
3765
4137
|
// Null for none.
|
|
3766
|
-
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
|
|
3767
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
4138
|
+
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[], { handed = false } = {}): Promise<NpmReader | null> {
|
|
4139
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
3768
4140
|
return s == null ? null : npmReaderOf(deps, s.user, names)
|
|
3769
4141
|
}
|
|
4142
|
+
// The reader of the session `cookie` names now, as npmReader reads them,
|
|
4143
|
+
// their npm scopes whatever the packages, and whether they read team
|
|
4144
|
+
// `teamId`'s reviews: one statement.
|
|
4145
|
+
async function npmReviewReader(deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<(NpmReader & { readsTeam: boolean }) | null> {
|
|
4146
|
+
const ids = sessionIds(deps.config, cookie)
|
|
4147
|
+
const reader = ids && await deps.db.getNpmReviewReader(ids.id, ids.viewer, Date.now(), teamId)
|
|
4148
|
+
return reader && roleAtLeast(reader.role, 'view') ? { role: reader.role, scopes: new Set(reader.scopes), userId: reader.id, readsTeam: reader.readsTeam } : null
|
|
4149
|
+
}
|
|
3770
4150
|
// The same of `user`, as a session the request read just now tells them.
|
|
3771
4151
|
async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
|
|
3772
4152
|
if (!roleAtLeast(user.role, 'view')) return null
|
|
@@ -3787,9 +4167,11 @@ const REVIEW_READS_AT_ONCE = 8
|
|
|
3787
4167
|
|
|
3788
4168
|
// Lists must enforce the same target and base access as the review itself,
|
|
3789
4169
|
// including permission changes while the registry requests are in flight;
|
|
3790
|
-
// first as `session`, the one the request read just now, where given.
|
|
4170
|
+
// first as `session`, the one the request read just now, where given. A
|
|
4171
|
+
// team's (`teamId`) only to one who still reads its reviews there, asked
|
|
4172
|
+
// again with the rest (npmReviewReader).
|
|
3791
4173
|
async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[],
|
|
3792
|
-
session?: { id: string; role: NpmReader['role'] }): Promise<T[] | null> {
|
|
4174
|
+
session?: { id: string; role: NpmReader['role'] }, teamId?: string): Promise<T[] | null> {
|
|
3793
4175
|
const controller = new AbortController()
|
|
3794
4176
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
3795
4177
|
res.on('close', onClose)
|
|
@@ -3808,8 +4190,10 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
|
|
|
3808
4190
|
const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
|
|
3809
4191
|
const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
|
|
3810
4192
|
const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
|
|
3811
|
-
const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
|
|
4193
|
+
const current = teamId === undefined ? await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
|
|
4194
|
+
: await npmReviewReader(deps, cookie, teamId)
|
|
3812
4195
|
if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
4196
|
+
if ('readsTeam' in current && !current.readsTeam) { sendJson(res, 404, { error: 'no-team' }); return null }
|
|
3813
4197
|
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
3814
4198
|
} catch (error) {
|
|
3815
4199
|
if (controller.signal.aborted) return null
|
|
@@ -3847,13 +4231,16 @@ async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReade
|
|
|
3847
4231
|
return npmReviewVerdictLimit(doc, list, socket, risk)
|
|
3848
4232
|
}
|
|
3849
4233
|
|
|
3850
|
-
// Reviews are scoped to the bytes actually displayed, including the base
|
|
3851
|
-
//
|
|
4234
|
+
// Reviews are scoped to the bytes actually displayed, including the base:
|
|
4235
|
+
// each change but a revocation loads both versions' files, of tarballs held
|
|
4236
|
+
// to their integrity and to their shasum where npm tells one (npm-loads.ts),
|
|
4237
|
+
// and the review keeps those shasums. Access to both versions is rechecked
|
|
4238
|
+
// before returning or saving annotations.
|
|
3852
4239
|
async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
|
|
3853
4240
|
const method = req.method ?? 'GET'
|
|
3854
4241
|
if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
|
|
3855
4242
|
const mutation = method !== 'GET'
|
|
3856
|
-
const session = mutation ? await checkMutation(req, res, deps, cookie) : await readSession(deps.config, deps.db, cookie, Date.now())
|
|
4243
|
+
const session = mutation ? await checkMutation(req, res, deps, cookie, { handed: true }) : await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
|
|
3857
4244
|
if (!session) { if (!mutation) sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3858
4245
|
if (!roleAtLeast(session.user.role, mutation ? 'triage' : 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
3859
4246
|
let body: Record<string, unknown> = {}
|
|
@@ -3928,10 +4315,14 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3928
4315
|
|| (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
|
|
3929
4316
|
sendJson(res, 409, { error: 'review-content-changed' }); return
|
|
3930
4317
|
}
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
4318
|
+
// Each change but a revocation loads both versions' files; a save or a
|
|
4319
|
+
// publication is held to them.
|
|
4320
|
+
const loads = change && change.action !== 'revoke' ? [doc, ...base ? [base] : []].map(loadNpmFiles) : []
|
|
4321
|
+
let tarballs: ReviewedTarballs | undefined
|
|
4322
|
+
try {
|
|
4323
|
+
const files = await Promise.all(loads.map(load => load.result))
|
|
4324
|
+
if (loads.length > 0) tarballs = { shasum: doc.dist.shasum, baseShasum: base?.dist.shasum ?? null }
|
|
4325
|
+
if (change?.action === 'publish' || change?.action === 'save') {
|
|
3935
4326
|
const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
|
|
3936
4327
|
const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
|
|
3937
4328
|
const diff = computeBundleDiff(before, after, () => '.')
|
|
@@ -3945,8 +4336,8 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3945
4336
|
change.files = changed.map(file => file.path)
|
|
3946
4337
|
change.nonTextFiles = changed.filter(file => !reviewable(file)).map(file => file.path)
|
|
3947
4338
|
}
|
|
3948
|
-
}
|
|
3949
|
-
}
|
|
4339
|
+
}
|
|
4340
|
+
} finally { loads.forEach(load => load.release()) }
|
|
3950
4341
|
const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
|
|
3951
4342
|
? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
|
|
3952
4343
|
const current = await npmReader(deps, cookie, [doc, base].flatMap(item => item?.private ? [item.name] : []))
|
|
@@ -3965,7 +4356,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3965
4356
|
if (change.action === 'publish' && change.final && !allowedReviewVerdicts([], limit).includes(change.final.status)) {
|
|
3966
4357
|
sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
|
|
3967
4358
|
}
|
|
3968
|
-
const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
|
|
4359
|
+
const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now(), tarballs)
|
|
3969
4360
|
sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
|
|
3970
4361
|
} catch (err) {
|
|
3971
4362
|
if (controller.signal.aborted) return
|
|
@@ -3975,6 +4366,62 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3975
4366
|
} finally { res.off('close', onClose) }
|
|
3976
4367
|
}
|
|
3977
4368
|
|
|
4369
|
+
// GET /api/npm/deltas?versions=[[name, version, otherVersion], …]: the lines
|
|
4370
|
+
// each of up to MAX_ASKED_DELTAS versions changes against another, or
|
|
4371
|
+
// `(empty)`, as GET delta counts them, as lines of JSON as each is counted
|
|
4372
|
+
// (`{ name, version, otherVersion, …delta }`, or `{ name, version,
|
|
4373
|
+
// otherVersion, error, status }`), as a list's rows near the viewport ask
|
|
4374
|
+
// for them together (npm-delta.js), DELTAS_AT_ONCE at a time. Each read as
|
|
4375
|
+
// GET delta reads it, with the reader's access to its package, which is
|
|
4376
|
+
// checked again once it is counted: a private one's told only to a reader
|
|
4377
|
+
// who may still read it, none told to one whose session is gone.
|
|
4378
|
+
const MAX_ASKED_DELTAS = 50
|
|
4379
|
+
const DELTAS_AT_ONCE = 2
|
|
4380
|
+
async function handleNpmDeltas(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
|
|
4381
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
4382
|
+
let asked: unknown = null
|
|
4383
|
+
try { asked = JSON.parse(params.get('versions') ?? '') } catch {}
|
|
4384
|
+
if (!Array.isArray(asked) || asked.length === 0 || asked.length > MAX_ASKED_DELTAS || !asked.every(item => Array.isArray(item) && item.length === 3
|
|
4385
|
+
&& isNpmPackageName(item[0]) && isNpmPackageSpec(item[1]) && (item[2] === '(empty)' || isNpmPackageSpec(item[2])))) { sendJson(res, 400, { error: 'bad-package' }); return }
|
|
4386
|
+
const versions = asked as [string, string, string][]
|
|
4387
|
+
const reader = await npmReader(deps, cookie, [...new Set(versions.map(([name]) => name))], { handed: true })
|
|
4388
|
+
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4389
|
+
const controller = new AbortController()
|
|
4390
|
+
res.once('close', () => { if (!res.writableEnded) controller.abort() })
|
|
4391
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
4392
|
+
const kept = npmDeltaStore<NpmDelta>(deps.db, deps.config.debug), keptVersions = npmVersionStore(deps.db, deps.config.debug)
|
|
4393
|
+
const out = openLines(res)
|
|
4394
|
+
let stopped = false
|
|
4395
|
+
await eachOf(versions, DELTAS_AT_ONCE, async ([name, version, otherVersion]) => {
|
|
4396
|
+
if (stopped || controller.signal.aborted) return
|
|
4397
|
+
const asOne = { name, version, otherVersion }
|
|
4398
|
+
let isPrivate = false, line: Record<string, unknown>
|
|
4399
|
+
try {
|
|
4400
|
+
const privileged = canReadPrivateNpm(reader, name)
|
|
4401
|
+
const together = otherVersion === '(empty)' ? keptVersions : readTogether(keptVersions, [{ name, version }, { name, version: otherVersion }])
|
|
4402
|
+
const [doc, old] = await Promise.all([readNpmVersion(name, version, privileged, controller.signal, together).then(found),
|
|
4403
|
+
otherVersion === '(empty)' ? null : readNpmVersion(name, otherVersion, privileged, controller.signal, together).then(found)])
|
|
4404
|
+
isPrivate = doc.private || old?.private === true
|
|
4405
|
+
const side = (of: NpmVersionDocument) => ({ integrity: of.dist.integrity, files: async () => {
|
|
4406
|
+
const load = loadNpmFiles(of)
|
|
4407
|
+
try { return await load.result } finally { load.release() }
|
|
4408
|
+
} })
|
|
4409
|
+
line = { ...asOne, ...await npmDelta(old ? side(old) : null, side(doc), kept) }
|
|
4410
|
+
} catch (err) {
|
|
4411
|
+
if (controller.signal.aborted) return
|
|
4412
|
+
if (!(err instanceof NpmPackageError)) console.warn('managed: a package delta failed:', err)
|
|
4413
|
+
line = err instanceof NpmPackageError ? { ...asOne, error: err.message, status: err.status } : { ...asOne, error: 'delta-failed', status: 500 }
|
|
4414
|
+
}
|
|
4415
|
+
// A reader who lost their session, or their access to a private one,
|
|
4416
|
+
// while it was counted, is told nothing of it.
|
|
4417
|
+
const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
|
|
4418
|
+
if (current == null) { stopped = true; out.cut(); return }
|
|
4419
|
+
if (isPrivate && !canReadPrivateNpm(current, name)) line = { ...asOne, error: 'package-not-found', status: 404 }
|
|
4420
|
+
if (!stopped && !await out.write(line)) stopped = true
|
|
4421
|
+
})
|
|
4422
|
+
if (!stopped) await out.end()
|
|
4423
|
+
}
|
|
4424
|
+
|
|
3978
4425
|
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
|
|
3979
4426
|
// (canReadPrivateNpm) can be answered from the server's npm token; the rest get
|
|
3980
4427
|
// what the registry answers anonymously, every time (see npm-packages.ts).
|
|
@@ -3995,7 +4442,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3995
4442
|
}
|
|
3996
4443
|
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
3997
4444
|
if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
3998
|
-
const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
|
|
4445
|
+
const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName], { handed: true })
|
|
3999
4446
|
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4000
4447
|
const controller = new AbortController()
|
|
4001
4448
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
@@ -4240,7 +4687,7 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
|
|
|
4240
4687
|
const method = req.method ?? 'GET'
|
|
4241
4688
|
const allowed = id ? ['PATCH', 'DELETE'] : ['GET', 'POST', 'DELETE']
|
|
4242
4689
|
if (!allowed.includes(method)) { send405(res, allowed.join(', ')); return }
|
|
4243
|
-
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
4690
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
|
|
4244
4691
|
if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4245
4692
|
if (!requireManageRole(res, s.user)) return
|
|
4246
4693
|
const { db } = deps
|
|
@@ -4371,6 +4818,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4371
4818
|
const managedDataPath = underPrefix(path, MANAGED_DATA_PREFIXES)
|
|
4372
4819
|
const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
|
|
4373
4820
|
if (managedDataPath && !workspaceSession) return
|
|
4821
|
+
// What it read, for the handler's own first read to take.
|
|
4822
|
+
if (workspaceSession) handOverSession(cookie, workspaceSession)
|
|
4374
4823
|
const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
|
|
4375
4824
|
if (issueRoute) {
|
|
4376
4825
|
await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
|
|
@@ -4383,8 +4832,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4383
4832
|
if (path === '/api/admin/links') {
|
|
4384
4833
|
if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
4385
4834
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4386
|
-
const s =
|
|
4387
|
-
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4835
|
+
const s = workspaceSession!
|
|
4388
4836
|
if (!requireManageRole(res, s.user)) return
|
|
4389
4837
|
const shares = await db.listManagedWorkspaceShares(s.session.id, Date.now())
|
|
4390
4838
|
sendJson(res, shares ? 200 : 403, shares ? { shares } : { error: 'forbidden' }); return
|
|
@@ -4402,8 +4850,6 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4402
4850
|
// sign-in account's. A linked account's is served by its own id.
|
|
4403
4851
|
if (path.startsWith(AVATAR_PREFIX)) {
|
|
4404
4852
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4405
|
-
const s = await readSession(config, db, cookie, Date.now())
|
|
4406
|
-
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4407
4853
|
const id = path.slice(AVATAR_PREFIX.length)
|
|
4408
4854
|
if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
|
|
4409
4855
|
await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
|
|
@@ -4416,9 +4862,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4416
4862
|
}
|
|
4417
4863
|
if (path === ADMIN_USERS_PATH) {
|
|
4418
4864
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4419
|
-
|
|
4420
|
-
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4421
|
-
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
4865
|
+
if (workspaceSession!.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
4422
4866
|
sendJson(res, 200, { users: await db.listUsers() })
|
|
4423
4867
|
return
|
|
4424
4868
|
}
|
|
@@ -4590,13 +5034,17 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4590
5034
|
const teamFeed = /^\/api\/teams\/([^/]+)\/feed$/u.exec(path)
|
|
4591
5035
|
if (teamFeed || path === '/api/teams/feed') {
|
|
4592
5036
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4593
|
-
const s =
|
|
4594
|
-
if (!s) return
|
|
5037
|
+
const s = workspaceSession!
|
|
4595
5038
|
const teamId = teamFeed?.[1] ?? null
|
|
4596
5039
|
const reportId = url.searchParams.get('reportId')
|
|
4597
5040
|
if (teamId) await teamSnapshot(db, s.session.id, teamId, reportId)
|
|
4598
5041
|
await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId, { reportId }); return
|
|
4599
5042
|
}
|
|
5043
|
+
const teamDependencies = /^\/api\/teams\/([^/]+)\/dependencies$/u.exec(path)
|
|
5044
|
+
if (teamDependencies) {
|
|
5045
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
5046
|
+
await handleTeamDependencies(res, deps, cookie, workspaceSession!, teamDependencies[1]!, url.searchParams); return
|
|
5047
|
+
}
|
|
4600
5048
|
const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
|
|
4601
5049
|
if (teamFixes) {
|
|
4602
5050
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
@@ -4610,10 +5058,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4610
5058
|
await recheckTeam(db, workspaceSession!.session.id, snapshot)
|
|
4611
5059
|
sendJson(res, 200, annotations); return
|
|
4612
5060
|
}
|
|
4613
|
-
const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
|
|
5061
|
+
const teamReports = /^\/api\/teams\/([^/]+)\/(reports|trees)$/u.exec(path)
|
|
4614
5062
|
if (teamReports) {
|
|
4615
5063
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4616
|
-
await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId')); return
|
|
5064
|
+
await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId'), teamReports[2] as 'reports' | 'trees'); return
|
|
4617
5065
|
}
|
|
4618
5066
|
if (path === REPORT_QUERY_PATH) {
|
|
4619
5067
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
@@ -4681,8 +5129,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4681
5129
|
}
|
|
4682
5130
|
if (path === '/api/npm/reviews/latest') {
|
|
4683
5131
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4684
|
-
const s =
|
|
4685
|
-
if (!s) return
|
|
5132
|
+
const s = workspaceSession!
|
|
4686
5133
|
const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
|
|
4687
5134
|
if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
|
|
4688
5135
|
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
@@ -4694,7 +5141,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4694
5141
|
}
|
|
4695
5142
|
if (path === '/api/npm/reviews/discard') {
|
|
4696
5143
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
4697
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
5144
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
4698
5145
|
if (!s) return
|
|
4699
5146
|
let body: Record<string, unknown>
|
|
4700
5147
|
try {
|
|
@@ -4711,8 +5158,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4711
5158
|
}
|
|
4712
5159
|
if (path === '/api/npm/reviews/mine') {
|
|
4713
5160
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4714
|
-
const s =
|
|
4715
|
-
if (!s) return
|
|
5161
|
+
const s = workspaceSession!
|
|
4716
5162
|
const id = url.searchParams.get('beforeId'), time = url.searchParams.get('beforeTime')
|
|
4717
5163
|
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
4718
5164
|
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
@@ -4726,8 +5172,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4726
5172
|
// Dependencies tab lists them, fifty a page.
|
|
4727
5173
|
if (path === '/api/npm/reviews/team') {
|
|
4728
5174
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4729
|
-
const s =
|
|
4730
|
-
if (!s) return
|
|
5175
|
+
const s = workspaceSession!
|
|
4731
5176
|
const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
|
|
4732
5177
|
if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
|
|
4733
5178
|
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
@@ -4735,16 +5180,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4735
5180
|
}
|
|
4736
5181
|
const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
4737
5182
|
if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
4738
|
-
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
|
|
4739
|
-
if (!reviews) return
|
|
4740
5183
|
// Its access asked again, as it may have been taken while npm was read.
|
|
4741
|
-
|
|
4742
|
-
sendJson(res, 200, { ...page, reviews })
|
|
5184
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user, teamId)
|
|
5185
|
+
if (reviews) sendJson(res, 200, { ...page, reviews })
|
|
4743
5186
|
return
|
|
4744
5187
|
}
|
|
4745
5188
|
if (path === '/api/npm/reviews') {
|
|
4746
5189
|
await handleNpmReviews(req, res, deps, cookie, url.searchParams); return
|
|
4747
5190
|
}
|
|
5191
|
+
if (path === NPM_DELTAS_PATH) { await handleNpmDeltas(req, res, deps, cookie, url.searchParams); return }
|
|
4748
5192
|
if (NPM_PATHS.has(path)) {
|
|
4749
5193
|
await handleNpm(req, res, deps, cookie, path, url.searchParams); return
|
|
4750
5194
|
}
|
|
@@ -4754,12 +5198,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4754
5198
|
sendJson(res, 404, { error: 'not-found' }, { connection: 'close' })
|
|
4755
5199
|
}
|
|
4756
5200
|
|
|
4757
|
-
|
|
5201
|
+
// Each request's time told in its Server-Timing header (request-timing.ts).
|
|
5202
|
+
return (req, res) => runTimed(res, () => {
|
|
4758
5203
|
if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
|
|
4759
5204
|
if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return }
|
|
4760
5205
|
// Feeds lease a connection per poll, never across streaming sleeps. This
|
|
4761
|
-
// also covers capability-authenticated feeds handled by public-workspace
|
|
4762
|
-
|
|
5206
|
+
// also covers capability-authenticated feeds handled by public-workspace;
|
|
5207
|
+
// and a team's dependencies and npm's deltas, streamed as GitHub and npm
|
|
5208
|
+
// are waited on, lease one a statement.
|
|
5209
|
+
const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '') || /^\/api\/(?:teams\/[^/?]+\/dependencies|npm\/deltas)(?:\?|$)/u.test(req.url ?? '')
|
|
4763
5210
|
const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
|
|
4764
5211
|
const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
|
|
4765
5212
|
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
@@ -4769,5 +5216,5 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4769
5216
|
})
|
|
4770
5217
|
track(work)
|
|
4771
5218
|
return work
|
|
4772
|
-
}
|
|
5219
|
+
})
|
|
4773
5220
|
}
|