@preventive/triage 1.0.0-alpha.46 → 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 -12
- package/common/managed/github-agents.ts +29 -10
- package/out/activity.js +4 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C2AVODGZ.js +36 -0
- package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
- package/out/chunk-MOACFRJ4.js +3 -0
- package/out/chunk-O6M574JI.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PENGGKX7.js +233 -0
- package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
- package/out/chunk-SZD5CW5R.js +3 -0
- package/out/{chunk-G24A42V6.js → chunk-TQJDYHVE.js} +1 -1
- package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
- package/out/{chunk-7XRR4OFU.js → chunk-WYLQWYLA.js} +1 -1
- package/out/chunk-X6PJAK3T.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +26 -26
- package/out/client-sync.js +2 -2
- package/out/compare.js +14 -14
- package/out/dialogs.js +40 -40
- package/out/graph.js +11 -11
- package/out/index.html +12 -12
- package/out/scan.js +2 -2
- package/out/terminal.js +215 -215
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +9 -5
- package/risk/index.d.ts +4 -2
- package/risk/npm/assess.js +15 -4
- package/risk/npm/registry.js +6 -1
- package/risk/npm/score.js +1 -1
- package/server-managed/activity.ts +24 -11
- package/server-managed/agent-merges.ts +10 -8
- package/server-managed/app-agents.ts +8 -21
- package/server-managed/bundle-build-worker.js +7 -3
- package/server-managed/bundle-build.ts +5 -3
- package/server-managed/bundle-cache.ts +9 -3
- package/server-managed/bundle.ts +3 -4
- package/server-managed/cache-storage.ts +3 -2
- package/server-managed/commit-coauthors.ts +11 -10
- package/server-managed/commit-lockfiles.ts +22 -21
- package/server-managed/db-methods.ts +338 -187
- package/server-managed/db-neon.ts +31 -8
- 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 +736 -247
- package/server-managed/index.ts +2 -2
- package/server-managed/link-reports.ts +5 -1
- package/server-managed/managed-issues.ts +3 -0
- package/server-managed/npm-delta.ts +16 -0
- package/server-managed/npm-kept.ts +51 -10
- package/server-managed/npm-loads.ts +48 -28
- package/server-managed/npm-manifest.ts +1 -1
- package/server-managed/npm-packages.ts +31 -15
- package/server-managed/npm-risk.ts +64 -17
- package/server-managed/object-storage.ts +4 -3
- package/server-managed/pretty-print.ts +45 -12
- 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/report-store.ts +16 -11
- package/server-managed/repository-agents.ts +9 -6
- package/server-managed/repository-browser.ts +17 -5
- package/server-managed/repository-code.ts +180 -17
- 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/solidity-entry-points.ts +5 -3
- package/server-managed/sql-postgres.ts +3 -0
- package/server-managed/sql.ts +17 -8
- package/server-managed/storage-db.ts +11 -3
- package/server-managed/storage-encryption.ts +23 -12
- 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/storage.ts +4 -2
- package/server-managed/team-feed.ts +19 -12
- package/server-managed/team-npm-scopes.ts +29 -0
- package/server-managed/team-reports.ts +74 -17
- package/server-managed/uploads.ts +29 -9
- package/out/chunk-4UFAMJAX.js +0 -1
- package/out/chunk-BOIXMUU5.js +0 -3
- package/out/chunk-CC4OM6TZ.js +0 -3
- package/out/chunk-JKGQ3O7O.js +0 -6
- package/out/chunk-LE5ZWLG4.js +0 -235
- package/out/chunk-MSION3G4.js +0 -36
- package/out/chunk-S6GIXWQW.js +0 -52
- package/out/chunk-V3KWNNMY.js +0 -1
- package/out/chunk-WQ74ZBZF.js +0 -7
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'
|
|
@@ -151,7 +152,7 @@ import { contentAccess } from './content-access.ts'
|
|
|
151
152
|
import type { BundleStore } from './bundle-store.ts'
|
|
152
153
|
import { Buffer } from 'node:buffer'
|
|
153
154
|
import { createHash, randomUUID } from 'node:crypto'
|
|
154
|
-
import type { AvatarStore } from './avatar-store.ts'
|
|
155
|
+
import type { AvatarStore, CachedAvatar } from './avatar-store.ts'
|
|
155
156
|
import type { BlobStore, OpenedBlob } from './blob-store.ts'
|
|
156
157
|
import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
|
|
157
158
|
import { resolveRepositoryImportLocation } from './repository-aliases.ts'
|
|
@@ -181,12 +182,15 @@ import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines
|
|
|
181
182
|
import { WORKFLOWS_DIR } from './repository-actions.ts'
|
|
182
183
|
import { githubActionsSource } from './github-actions.ts'
|
|
183
184
|
import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
|
|
185
|
+
import { type Lru, lru } from './lru.ts'
|
|
184
186
|
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
185
|
-
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'
|
|
186
190
|
import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
|
|
187
191
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
188
192
|
import { acceptsReportMetadata } from './report-response.ts'
|
|
189
|
-
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'
|
|
190
194
|
import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
191
195
|
import { lookupFixes, storedFixUrls } from './github-pulls.ts'
|
|
192
196
|
import { lookupIssueLinks } from './github-issue-links.ts'
|
|
@@ -197,7 +201,7 @@ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallb
|
|
|
197
201
|
import type { ProfileChoice } from './github-links.ts'
|
|
198
202
|
import { withAppUsers } from './github-users.ts'
|
|
199
203
|
import { isBlobId } from './object-storage.ts'
|
|
200
|
-
import { attachmentDisposition, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
|
|
204
|
+
import { attachmentDisposition, openLines, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
|
|
201
205
|
import { triageWireEntry } from './triage-response.ts'
|
|
202
206
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
203
207
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
@@ -206,19 +210,20 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
|
206
210
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
207
211
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
208
212
|
import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
|
|
209
|
-
import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions,
|
|
210
|
-
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'
|
|
211
214
|
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
212
|
-
import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
|
|
215
|
+
import { type RepositoryCodeKept, loadRepositoryCodeBody, repositoryCodeFits, repositoryCodeKept } from './repository-code.ts'
|
|
213
216
|
import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
|
|
214
217
|
import { COAUTHORS_READ } from './commit-coauthors.ts'
|
|
215
|
-
import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE,
|
|
216
|
-
repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips
|
|
217
|
-
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'
|
|
218
222
|
import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
|
|
219
|
-
import {
|
|
220
|
-
|
|
221
|
-
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'
|
|
222
227
|
import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
|
|
223
228
|
import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
|
|
224
229
|
import { formattedPull, withFormatted } from './dependency-review-comments.ts'
|
|
@@ -229,7 +234,7 @@ import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitS
|
|
|
229
234
|
import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
|
|
230
235
|
import { releaseCommits } from './release-commits.ts'
|
|
231
236
|
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
232
|
-
import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
|
|
237
|
+
import type { ReviewChange, ReviewContext, ReviewInput, ReviewedTarballs } from './dependency-reviews.ts'
|
|
233
238
|
import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
|
|
234
239
|
import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
|
|
235
240
|
import { npmProvenance } from './npm-provenance.ts'
|
|
@@ -309,6 +314,7 @@ const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
|
|
|
309
314
|
const NPM_COMPARISON_PATH = '/api/npm/comparison'
|
|
310
315
|
const NPM_RISK_PATH = '/api/npm/risk'
|
|
311
316
|
const NPM_DELTA_PATH = '/api/npm/delta'
|
|
317
|
+
const NPM_DELTAS_PATH = '/api/npm/deltas'
|
|
312
318
|
// What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
|
|
313
319
|
// having no such package or version, or a tarball unpacking past risk's
|
|
314
320
|
// bounds; npm failing otherwise a 502.
|
|
@@ -333,7 +339,7 @@ const VIEW_PREFIXES = [...MANAGED_DATA_PREFIXES, '/api/auth', '/api/oauth']
|
|
|
333
339
|
const underPrefix = (path: string, prefixes: readonly string[]) => prefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))
|
|
334
340
|
|
|
335
341
|
async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
|
|
336
|
-
const s = await readWorkspaceSession(res, deps, cookie)
|
|
342
|
+
const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
|
|
337
343
|
if (!s) return
|
|
338
344
|
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
|
|
339
345
|
const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
|
|
@@ -363,7 +369,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
363
369
|
async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
|
|
364
370
|
cookie: string | undefined, teamId: string, query: URLSearchParams): Promise<void> {
|
|
365
371
|
if (req.method !== 'GET' && req.method !== 'POST') { send405(res, 'GET, POST'); return }
|
|
366
|
-
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 })
|
|
367
373
|
if (!s) return
|
|
368
374
|
if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
369
375
|
let body: unknown = Object.fromEntries(query)
|
|
@@ -395,7 +401,7 @@ function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined
|
|
|
395
401
|
// Managers get content activity for their currently accessible team content;
|
|
396
402
|
// even the displayed report context must come from those accessible reports.
|
|
397
403
|
async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
398
|
-
const s = await readManageSession(res, deps, cookie)
|
|
404
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
399
405
|
if (s == null) return
|
|
400
406
|
const page = Number(query.get('page') ?? 1)
|
|
401
407
|
const limit = Number(query.get('limit') ?? 100)
|
|
@@ -439,6 +445,9 @@ export interface ManagedHttpDeps {
|
|
|
439
445
|
prettyCache?: PrettyCache
|
|
440
446
|
// The public npm cache, where risk keeps public packages' assessments (npm-risk.ts).
|
|
441
447
|
npmCacheStorage?: CacheStorage
|
|
448
|
+
// Where the code viewer keeps the default branch's head's directories
|
|
449
|
+
// (repository-code.ts repositoryCodeKept).
|
|
450
|
+
repositoryCodeStorage?: CacheStorage
|
|
442
451
|
uploadStore?: BlobStore
|
|
443
452
|
originGate: OriginGate
|
|
444
453
|
isShuttingDown: () => boolean
|
|
@@ -468,36 +477,43 @@ function firstHeader(v: string | string[] | undefined): string | null {
|
|
|
468
477
|
}
|
|
469
478
|
|
|
470
479
|
// `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
|
|
471
|
-
|
|
472
|
-
|
|
480
|
+
// A user's is never kept by the browser, as it changes with their profile.
|
|
481
|
+
async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id, cacheControl = 'no-store'): Promise<void> {
|
|
482
|
+
sendAvatar(res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function sendAvatar(res: ServerResponse, avatar: CachedAvatar | null, cacheControl = 'no-store'): void {
|
|
473
486
|
if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
|
|
474
487
|
res.writeHead(200, {
|
|
475
488
|
'content-type': avatar.contentType,
|
|
476
489
|
'content-length': String(avatar.bytes.length),
|
|
477
490
|
'x-content-type-options': 'nosniff',
|
|
478
|
-
'cache-control':
|
|
491
|
+
'cache-control': cacheControl,
|
|
479
492
|
})
|
|
480
493
|
res.end(avatar.bytes)
|
|
481
494
|
}
|
|
482
495
|
|
|
483
496
|
// The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
|
|
484
|
-
// alone: the agents
|
|
485
|
-
//
|
|
486
|
-
// 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
|
|
487
499
|
// kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
|
|
488
500
|
// an account's, `…-0001-<id>`.
|
|
489
|
-
const AGENT_AVATARS: ReadonlySet<string> = new Set(
|
|
490
|
-
.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] : []))
|
|
491
502
|
const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
|
|
503
|
+
// An agent's mark is a public GitHub image that never changes with anyone's
|
|
504
|
+
// profile, drawn on every tile and comment by it: kept by the browser a day,
|
|
505
|
+
// for it alone.
|
|
506
|
+
const AGENT_AVATAR_CACHE = 'private, max-age=86400'
|
|
492
507
|
|
|
493
508
|
async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
|
|
494
509
|
const [, kind, id] = AGENT_AVATAR.exec(avatar)!
|
|
495
510
|
const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
|
|
511
|
+
// Read once where it is kept; read again only past fetching it.
|
|
512
|
+
const kept = await avatarStore.get(key)
|
|
513
|
+
if (kept != null) { sendAvatar(res, kept, AGENT_AVATAR_CACHE); return }
|
|
514
|
+
await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
|
|
515
|
+
.catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
|
|
516
|
+
await serveAvatar(res, avatarStore, key, key, AGENT_AVATAR_CACHE)
|
|
501
517
|
}
|
|
502
518
|
|
|
503
519
|
const MAX_JSON_BODY_BYTES = 4096
|
|
@@ -518,14 +534,17 @@ async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Bu
|
|
|
518
534
|
}
|
|
519
535
|
|
|
520
536
|
// A file upload's content, opening a body the browser sealed to this session
|
|
521
|
-
// (common/managed/upload-seal.ts). Link reports are never sent in parts
|
|
537
|
+
// (common/managed/upload-seal.ts). Link reports are never sent in parts; the
|
|
538
|
+
// parts of one sent in them are deleted behind its answer.
|
|
522
539
|
async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: ManagedSession, kind: UploadKind | 'links'): Promise<Buffer> {
|
|
523
540
|
const maxBytes = kind === 'bundles' ? deps.config.maxBundleBytes : deps.config.maxReportBytes
|
|
524
541
|
const sealed = req.headers[UPLOAD_SEAL_HEADER] !== undefined
|
|
525
542
|
const limit = sealed ? maxSealedBytes(maxBytes) : maxBytes
|
|
526
543
|
let bytes
|
|
527
544
|
if (kind === 'links' || req.headers['x-upload-id'] == null) bytes = await readBodyBytes(req, limit)
|
|
528
|
-
else if (deps.uploadStore)
|
|
545
|
+
else if (deps.uploadStore) {
|
|
546
|
+
bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit, cleanup => { deps.track(cleanup); deps.waitUntil?.(cleanup) })
|
|
547
|
+
}
|
|
529
548
|
else throw new Error('bad-upload')
|
|
530
549
|
return sealed ? await openSessionUpload(session.uploadKey, bytes, maxBytes) : bytes
|
|
531
550
|
}
|
|
@@ -534,7 +553,7 @@ async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, sessi
|
|
|
534
553
|
// first use. Only this session's server-side private key opens what is sealed to it.
|
|
535
554
|
async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
536
555
|
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
537
|
-
const s = await readManageSession(res, deps, cookie)
|
|
556
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
538
557
|
if (!s) return
|
|
539
558
|
const key = s.session.uploadKey ?? await deps.db.ensureSessionUploadKey(s.session.id, newUploadKey())
|
|
540
559
|
if (!key) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
@@ -544,7 +563,7 @@ async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
544
563
|
async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
|
|
545
564
|
const cancel = req.method === 'DELETE'
|
|
546
565
|
if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
|
|
547
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
566
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
548
567
|
if (!s || !requireManageRole(res, s.user)) return
|
|
549
568
|
if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
550
569
|
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
|
|
@@ -585,8 +604,8 @@ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES
|
|
|
585
604
|
// Returns the session, or null after having already sent the 401/403. An
|
|
586
605
|
// admin's view as another user writes nothing; `view` admits only the
|
|
587
606
|
// endpoints that end it.
|
|
588
|
-
async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
589
|
-
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 })
|
|
590
609
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
591
610
|
const csrf = firstHeader(req.headers['x-csrf-token'])
|
|
592
611
|
if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
|
|
@@ -640,7 +659,7 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
|
|
|
640
659
|
const userId = (body as { userId?: unknown } | null)?.userId
|
|
641
660
|
if (typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
642
661
|
if (userId === s.user.id) { sendJson(res, 400, { error: 'cannot-view-as-self' }); return }
|
|
643
|
-
const target =
|
|
662
|
+
const target = await deps.db.getUser(userId)
|
|
644
663
|
if (!target) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
645
664
|
// The insert itself rechecks the admin session and role.
|
|
646
665
|
const setCookie = await startViewSession(deps.config, deps.db, s.session, userId, Date.now())
|
|
@@ -714,7 +733,7 @@ function parseProfileChoice(body: unknown): ProfileChoice | null {
|
|
|
714
733
|
// at least one admin).
|
|
715
734
|
async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
716
735
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
717
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
736
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
718
737
|
if (s == null) return
|
|
719
738
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
720
739
|
let body: unknown
|
|
@@ -723,7 +742,7 @@ async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: Ma
|
|
|
723
742
|
const role = (body as { role?: unknown } | null)?.role
|
|
724
743
|
if (typeof userId !== 'string' || !isRole(role)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
725
744
|
if (userId === s.user.id) { sendJson(res, 403, { error: 'cannot-change-own-role' }); return }
|
|
726
|
-
const target =
|
|
745
|
+
const target = await deps.db.getUser(userId)
|
|
727
746
|
// A caller can hold the body open while their admin access is revoked.
|
|
728
747
|
if (await readAdminSession(res, deps, cookie) == null) return
|
|
729
748
|
const ok = await deps.db.setUserRole(userId, role)
|
|
@@ -744,22 +763,22 @@ function requireManageRole(res: ServerResponse, user: StoredUser): boolean {
|
|
|
744
763
|
// A read endpoint open to admin|manage: resolve the session (401 if absent),
|
|
745
764
|
// then gate on the role (403). Returns the session, or null after the response
|
|
746
765
|
// was already sent. No CSRF — reads aren't mutations (cf. /api/admin/users).
|
|
747
|
-
async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
748
|
-
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 })
|
|
749
768
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
750
769
|
if (!requireManageRole(res, s.user)) return null
|
|
751
770
|
return s
|
|
752
771
|
}
|
|
753
772
|
|
|
754
|
-
async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
755
|
-
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 })
|
|
756
775
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
757
776
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
758
777
|
return s
|
|
759
778
|
}
|
|
760
779
|
|
|
761
|
-
async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
762
|
-
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 })
|
|
763
782
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
764
783
|
if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
765
784
|
return s
|
|
@@ -789,7 +808,7 @@ async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: stri
|
|
|
789
808
|
// GET /api/admin/scan/models — the server's canonical scan model ids and effort
|
|
790
809
|
// levels. Names are intentionally absent; the client derives them from ids.
|
|
791
810
|
async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
792
|
-
const s = await readManageSession(res, deps, cookie)
|
|
811
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
793
812
|
if (s == null) return
|
|
794
813
|
sendJson(res, 200, { models: MANAGED_SCAN_MODELS, defaultModel: DEFAULT_MANAGED_SCAN_MODEL })
|
|
795
814
|
}
|
|
@@ -800,7 +819,7 @@ async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cook
|
|
|
800
819
|
// filtering. Admin-only. No CSRF.
|
|
801
820
|
async function handleListRepositories(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, discovery: RepositoryDiscovery): Promise<void> {
|
|
802
821
|
if ((req.method ?? 'GET') !== 'GET') { send405(res, 'GET'); return }
|
|
803
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
822
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
804
823
|
if (s == null) return
|
|
805
824
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
806
825
|
// Keep the original unparameterized response shape for older clients. The
|
|
@@ -918,7 +937,7 @@ async function connectRepository(res: ServerResponse, deps: ManagedHttpDeps, use
|
|
|
918
937
|
// Authentication, server admin permission, origin and CSRF remain mandatory.
|
|
919
938
|
async function handleAddPublicRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
920
939
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
921
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
940
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
922
941
|
if (s == null) return
|
|
923
942
|
if (!canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id))) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
924
943
|
let body: unknown
|
|
@@ -942,7 +961,7 @@ async function handleAddPublicRepository(req: IncomingMessage, res: ServerRespon
|
|
|
942
961
|
// from the per-user checks required for browsing source.
|
|
943
962
|
async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
944
963
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
945
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
964
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
946
965
|
if (s == null) return
|
|
947
966
|
let body: unknown
|
|
948
967
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -977,10 +996,12 @@ async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerRespo
|
|
|
977
996
|
// POST /api/admin/repositories/select — an admin toggles whether a repo is
|
|
978
997
|
// active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
|
|
979
998
|
// selected }. selected:true verifies + records the read context; selected:false
|
|
980
|
-
// 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).
|
|
981
1002
|
async function handleSelectRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
982
1003
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
983
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
1004
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
984
1005
|
if (s == null) return
|
|
985
1006
|
let body: unknown
|
|
986
1007
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -993,6 +1014,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
993
1014
|
const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
|
|
994
1015
|
if (await readAdminSession(res, deps, cookie) == null) return
|
|
995
1016
|
await deps.db.deactivateRepo(repoId)
|
|
1017
|
+
await removeKeptCode(deps, repoId)
|
|
996
1018
|
if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
|
|
997
1019
|
sendJson(res, 200, { ok: true, selected: false }); return
|
|
998
1020
|
}
|
|
@@ -1008,7 +1030,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
1008
1030
|
// stored kept as it is). Answers the setting then. Admin, CSRF.
|
|
1009
1031
|
async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1010
1032
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
1011
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
1033
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
1012
1034
|
if (s == null) return
|
|
1013
1035
|
let body: Record<string, unknown> | null
|
|
1014
1036
|
try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1080,7 +1102,7 @@ async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id
|
|
|
1080
1102
|
// removal would destroy. This is intentionally separate from the repository
|
|
1081
1103
|
// list so the normal page stays cheap even with many reports.
|
|
1082
1104
|
async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1083
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
1105
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
1084
1106
|
if (s == null) return
|
|
1085
1107
|
const repoId = Number(new URL(req.url ?? '/', 'http://localhost').searchParams.get('repoId'))
|
|
1086
1108
|
if (!Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
@@ -1093,7 +1115,7 @@ async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse,
|
|
|
1093
1115
|
// attached reports/bundles. The exact name + explicit acknowledgement are
|
|
1094
1116
|
// checked server-side too; the UI's dialog is a usability guard, not the policy.
|
|
1095
1117
|
async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1096
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
1118
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
1097
1119
|
if (s == null) return
|
|
1098
1120
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
1099
1121
|
let body: unknown
|
|
@@ -1126,9 +1148,17 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
1126
1148
|
await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
1127
1149
|
await deps.bundleStore.delete(bundle.id).catch(() => {})
|
|
1128
1150
|
}
|
|
1151
|
+
await removeKeptCode(deps, repo.repoId)
|
|
1129
1152
|
sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
|
|
1130
1153
|
}
|
|
1131
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
|
+
|
|
1132
1162
|
// Strip a client-supplied upload filename to a safe display string. The bytes
|
|
1133
1163
|
// are keyed by a server uuid. This name is used for display, Content-Disposition,
|
|
1134
1164
|
// and format detection: decode URLs, remove controls/paths, and cap the basename
|
|
@@ -1180,7 +1210,7 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
|
|
|
1180
1210
|
// { reportId, repoId, directory }, where repoId is null (detach) or a
|
|
1181
1211
|
// currently-selected repo id. Directory is relative to the repository root.
|
|
1182
1212
|
async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1183
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1213
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1184
1214
|
if (s == null) return
|
|
1185
1215
|
let body: unknown
|
|
1186
1216
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1201,7 +1231,7 @@ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, de
|
|
|
1201
1231
|
// POST /api/admin/reports/set-visible — publish or hide a stored report. New
|
|
1202
1232
|
// reports start hidden so an admin can inspect the parsed metadata first.
|
|
1203
1233
|
async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1204
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1234
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1205
1235
|
if (s == null) return
|
|
1206
1236
|
let body: unknown
|
|
1207
1237
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1214,7 +1244,7 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
|
|
|
1214
1244
|
}
|
|
1215
1245
|
|
|
1216
1246
|
async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1217
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1247
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1218
1248
|
if (s == null) return
|
|
1219
1249
|
let body: unknown
|
|
1220
1250
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1229,7 +1259,7 @@ async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse,
|
|
|
1229
1259
|
// POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
|
|
1230
1260
|
// and directory (same shape as reports). Body { bundleId, repoId, directory }.
|
|
1231
1261
|
async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1232
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1262
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1233
1263
|
if (s == null) return
|
|
1234
1264
|
let body: unknown
|
|
1235
1265
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1260,8 +1290,8 @@ async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, ses
|
|
|
1260
1290
|
// declared integrity that has a stored bundle wins (bundleId set). When none is
|
|
1261
1291
|
// stored yet, keep the first declared integrity so a later bundle upload of it
|
|
1262
1292
|
// re-links (see linkReportsToBundle). Returns the (bundleId, integrity) to store.
|
|
1263
|
-
async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser,
|
|
1264
|
-
const hashes = reportBundleHashes(
|
|
1293
|
+
async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, json: unknown): Promise<{ bundleId: string | null; integrity: string | null }> {
|
|
1294
|
+
const hashes = reportBundleHashes(json)
|
|
1265
1295
|
for (const h of hashes) {
|
|
1266
1296
|
const b = await deps.db.getBundleByIntegrity(h)
|
|
1267
1297
|
if (b != null && await canAccessBundle(deps, user, b.id)) return { bundleId: b.id, integrity: h }
|
|
@@ -1293,7 +1323,7 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1293
1323
|
// insert failure or concurrent duplicate drops the orphan blob; an uncertain
|
|
1294
1324
|
// commit retains it. 413 over the cap, 400 on empty.
|
|
1295
1325
|
async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1296
|
-
let s = await checkMutation(req, res, deps, cookie)
|
|
1326
|
+
let s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
1297
1327
|
if (s == null) return
|
|
1298
1328
|
if (!requireManageRole(res, s.user)) return
|
|
1299
1329
|
let bytes: Buffer
|
|
@@ -1371,7 +1401,8 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1371
1401
|
if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
|
|
1372
1402
|
const id = randomUUID()
|
|
1373
1403
|
const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
|
|
1374
|
-
|
|
1404
|
+
// A JSON report's parse already in hand, not parsed again for its hashes.
|
|
1405
|
+
const { bundleId, integrity } = await resolveReportBundle(deps, s.user, parsed.format === 'json' ? parsed.data : null)
|
|
1375
1406
|
const dataKey = await deps.reportStore.put(id, bytes)
|
|
1376
1407
|
let report: ReportRecord
|
|
1377
1408
|
try {
|
|
@@ -1400,7 +1431,7 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1400
1431
|
// or rendered inline against the app origin. 404 when there's no such report;
|
|
1401
1432
|
// 503 when the row exists but the bytes don't (store desync).
|
|
1402
1433
|
async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1403
|
-
const s = await readManageSession(res, deps, cookie)
|
|
1434
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
1404
1435
|
if (s == null) return
|
|
1405
1436
|
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1406
1437
|
const rec = await deps.db.getReport(id)
|
|
@@ -1423,7 +1454,7 @@ async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cooki
|
|
|
1423
1454
|
// same-origin + CSRF. Drops the row, then best-effort the bytes; 404 when there
|
|
1424
1455
|
// was no such report.
|
|
1425
1456
|
async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1426
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
1457
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
1427
1458
|
if (s == null) return
|
|
1428
1459
|
if (!requireManageRole(res, s.user)) return
|
|
1429
1460
|
const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
|
|
@@ -1461,8 +1492,10 @@ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
|
|
|
1461
1492
|
// user's repository access, for the next catalog to send.
|
|
1462
1493
|
async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, missing: readonly CatalogCommit[]): Promise<void> {
|
|
1463
1494
|
let browser: ReturnType<typeof createRepositoryBrowser> | undefined
|
|
1495
|
+
let repos: ReturnType<ManagedDb['listAllRepos']> | undefined
|
|
1464
1496
|
await backfillBundleCommits(deps.db, userId, missing, async repoId => {
|
|
1465
|
-
|
|
1497
|
+
// Read once for the repositories the backfill reaches, not once each.
|
|
1498
|
+
const repo = (await (repos ??= deps.db.listAllRepos())).find(item => item.repoId === repoId)
|
|
1466
1499
|
if (!repo) return null
|
|
1467
1500
|
browser ??= createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, userId))
|
|
1468
1501
|
return browser.reader(repo)
|
|
@@ -1474,7 +1507,7 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
|
|
|
1474
1507
|
// Any team member (view or above) lists those their teams reach, for the code
|
|
1475
1508
|
// viewer as for Create a bundle, whose builds stay admin|manage.
|
|
1476
1509
|
async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1477
|
-
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1510
|
+
const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
|
|
1478
1511
|
if (!s) return
|
|
1479
1512
|
sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
|
|
1480
1513
|
}
|
|
@@ -1531,13 +1564,28 @@ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
|
|
|
1531
1564
|
// answered, where it is none of theirs. Every team member (view or above)
|
|
1532
1565
|
// reads the repositories their teams reach; the reader then checks their own
|
|
1533
1566
|
// GitHub read permission (repository-browser.ts), on each read.
|
|
1534
|
-
async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
|
|
1535
|
-
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 })
|
|
1536
1569
|
if (!s) return null
|
|
1537
|
-
const
|
|
1538
|
-
if (!
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
|
1587
|
+
const scopes = access.scopes == null ? [null] : access.scopes.filter(scope => scope.repoId === repoId).map(scope => scope.path)
|
|
1588
|
+
return { repo, virtualEntries: scopedDirectory(path, scopes) }
|
|
1541
1589
|
}
|
|
1542
1590
|
|
|
1543
1591
|
// The repository as it was authorized, but for the shared default branch hint,
|
|
@@ -1557,12 +1605,12 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1557
1605
|
const { repoId, ref } = asked
|
|
1558
1606
|
const withDefault = refs && query.get('withDefault') === 'true'
|
|
1559
1607
|
const path = refs ? '' : asked.path
|
|
1560
|
-
const authorize = async () => {
|
|
1561
|
-
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 })
|
|
1562
1610
|
if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1563
1611
|
return access
|
|
1564
1612
|
}
|
|
1565
|
-
const access = await authorize()
|
|
1613
|
+
const access = await authorize(true)
|
|
1566
1614
|
if (!access) return
|
|
1567
1615
|
try {
|
|
1568
1616
|
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
|
|
@@ -1572,8 +1620,9 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1572
1620
|
const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
|
|
1573
1621
|
const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
|
|
1574
1622
|
const { tagCommits, tagsComplete, ...listed } = revisions ?? { tagCommits: [], tagsComplete: false }
|
|
1575
|
-
// Create a bundle
|
|
1576
|
-
//
|
|
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.
|
|
1577
1626
|
// Like other GitHub caches, written before the access fence below.
|
|
1578
1627
|
const tags = refs ? tagCommits : ref.length > 5 && ref.startsWith('tags/') && contents ? [{ name: ref.slice(5), sha: contents.commit }] : []
|
|
1579
1628
|
if (refs || tags.length > 0) {
|
|
@@ -1601,27 +1650,78 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1601
1650
|
// tarball is asked for; the reader's access is checked again once it is
|
|
1602
1651
|
// read, before any of it is sent. The load stays held until this response is
|
|
1603
1652
|
// written or abandoned, and a reader who stops reading it gives it up after
|
|
1604
|
-
// 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).
|
|
1655
|
+
// What GitHub answered of a repository's trees for the code viewer, which a
|
|
1656
|
+
// commit or tree it names never changes: the tree at a directory of a commit
|
|
1657
|
+
// (getRepoTreeId reads the commit, then a listing a level down to it), and
|
|
1658
|
+
// how many files a tree holds under it and their bytes (repositoryTreeFiles),
|
|
1659
|
+
// by the repository's id, for the next to open it. Asked of them only once
|
|
1660
|
+
// the reader's own access has named the commit, as GitHub would have
|
|
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).
|
|
1663
|
+
const REPOSITORY_TREES_KEPT = 4096
|
|
1664
|
+
type TreeFiles = Awaited<ReturnType<typeof repositoryTreeFiles>>
|
|
1665
|
+
const repositoryTrees = new WeakMap<ManagedDb, { ids: Lru<string>; files: Lru<TreeFiles> }>()
|
|
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)
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1605
1709
|
async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1606
1710
|
const asked = repositoryQuery(res, query)
|
|
1607
1711
|
if (!asked) return
|
|
1608
1712
|
const { repoId, ref, path } = asked
|
|
1609
|
-
const authorize = async () => {
|
|
1610
|
-
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 })
|
|
1611
1715
|
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1612
1716
|
return access
|
|
1613
1717
|
}
|
|
1614
|
-
const access = await authorize()
|
|
1718
|
+
const access = await authorize(true)
|
|
1615
1719
|
if (!access) return
|
|
1616
1720
|
try {
|
|
1617
1721
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1618
1722
|
const commit = await reader.commit(ref)
|
|
1619
1723
|
const repo = access.repo.fullName
|
|
1620
1724
|
const client = createClient({ token: reader.readToken() })
|
|
1621
|
-
// A directory the commit has none at is none to read, as browsing it says.
|
|
1622
|
-
const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1623
|
-
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
|
|
1624
|
-
}))
|
|
1625
1725
|
// Access still held, and the same repository, before anything read is sent.
|
|
1626
1726
|
const fenced = async () => {
|
|
1627
1727
|
await reader.recheckAccess()
|
|
@@ -1630,22 +1730,54 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1630
1730
|
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
|
|
1631
1731
|
return true
|
|
1632
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
|
+
})))
|
|
1633
1762
|
// Too large names the commit it was read at, for History to read.
|
|
1634
|
-
|
|
1763
|
+
const listed = await trees.files(`${repoId}\0${tree}`, () => repositoryTreeFiles(repo, reader.readToken(), tree))
|
|
1764
|
+
if (!repositoryCodeFits(listed)) {
|
|
1635
1765
|
if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
|
|
1636
1766
|
return
|
|
1637
1767
|
}
|
|
1638
1768
|
if (res.destroyed) return
|
|
1639
|
-
|
|
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
|
+
})))
|
|
1640
1775
|
res.once('close', load.release)
|
|
1641
1776
|
const bytes = await load.result
|
|
1777
|
+
// Like other GitHub caches, kept before the access fence below.
|
|
1778
|
+
if (atHead) kept.keep(at, bytes)
|
|
1642
1779
|
if (!(await fenced()) || res.destroyed) return
|
|
1643
|
-
|
|
1644
|
-
res.writeHead(200, {
|
|
1645
|
-
'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
|
|
1646
|
-
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
1647
|
-
})
|
|
1648
|
-
writeResponse(res, bytes)
|
|
1780
|
+
send(bytes)
|
|
1649
1781
|
} catch (err) {
|
|
1650
1782
|
if (res.headersSent || res.destroyed) return
|
|
1651
1783
|
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
@@ -1682,12 +1814,12 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1682
1814
|
const starts = from === null ? null : historyStarts(from)
|
|
1683
1815
|
if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
|
|
1684
1816
|
|| (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1685
|
-
const authorize = async () => {
|
|
1686
|
-
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 })
|
|
1687
1819
|
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1688
1820
|
return access
|
|
1689
1821
|
}
|
|
1690
|
-
const access = await authorize()
|
|
1822
|
+
const access = await authorize(true)
|
|
1691
1823
|
if (!access) return
|
|
1692
1824
|
let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
|
|
1693
1825
|
try {
|
|
@@ -1737,12 +1869,24 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1737
1869
|
// The pull requests squashed into a whole repository's commits, drawn as
|
|
1738
1870
|
// the merges they were, and on its first page the open ones' heads,
|
|
1739
1871
|
// naming commits: with a token, as far as GitHub answers them, the
|
|
1740
|
-
// 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).
|
|
1741
1876
|
const listed = (body as { commits: HistoryCommit[] }).commits
|
|
1742
|
-
const
|
|
1743
|
-
|
|
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)),
|
|
1744
1887
|
readingPulls,
|
|
1745
|
-
]) : [
|
|
1888
|
+
]) : [none, null]
|
|
1889
|
+
const { pulls } = squashed
|
|
1746
1890
|
await reader.recheckAccess()
|
|
1747
1891
|
const current = await authorize()
|
|
1748
1892
|
if (!current) return
|
|
@@ -1752,7 +1896,8 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1752
1896
|
const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
|
|
1753
1897
|
({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
|
|
1754
1898
|
sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
|
|
1755
|
-
...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 } : {} }))
|
|
1756
1901
|
} catch (err) {
|
|
1757
1902
|
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1758
1903
|
throw err
|
|
@@ -1806,12 +1951,12 @@ async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie:
|
|
|
1806
1951
|
const asked = repositoryQuery(res, query)
|
|
1807
1952
|
if (!asked) return
|
|
1808
1953
|
const { repoId, path } = asked
|
|
1809
|
-
const authorize = async () => {
|
|
1810
|
-
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 })
|
|
1811
1956
|
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1812
1957
|
return access
|
|
1813
1958
|
}
|
|
1814
|
-
const access = await authorize()
|
|
1959
|
+
const access = await authorize(true)
|
|
1815
1960
|
if (!access) return
|
|
1816
1961
|
try {
|
|
1817
1962
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
@@ -1848,6 +1993,9 @@ function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
|
|
|
1848
1993
|
// and how long one read is answered as kept even where it is asked for afresh.
|
|
1849
1994
|
const REPOSITORY_READ_FRESH_MS = 2 * 60_000
|
|
1850
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
|
|
1851
1999
|
// The repositories' reads being read again, behind a kept answer or as asked
|
|
1852
2000
|
// for afresh, by key: one at a time, those asking meanwhile answered by it.
|
|
1853
2001
|
const repositoryRereads = new Map<string, Promise<unknown>>()
|
|
@@ -1941,7 +2089,7 @@ function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): H
|
|
|
1941
2089
|
|
|
1942
2090
|
// The pull requests squashed into repository `repoId`'s commits, kept by
|
|
1943
2091
|
// each commit and the rules their commits' co-authors were read by
|
|
1944
|
-
// (COAUTHORS_READ), null for one none was (repository-
|
|
2092
|
+
// (COAUTHORS_READ), null for one none was (repository-squashes.ts
|
|
1945
2093
|
// squashedPulls); one kept before its commits' co-authors were read, read
|
|
1946
2094
|
// again.
|
|
1947
2095
|
function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
|
|
@@ -1984,28 +2132,55 @@ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, c
|
|
|
1984
2132
|
async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
|
|
1985
2133
|
answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
|
|
1986
2134
|
unread: () => Promise<object | null> = () => Promise.resolve(null)): Promise<void> {
|
|
1987
|
-
const
|
|
1988
|
-
|
|
1989
|
-
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1990
|
-
return access
|
|
1991
|
-
}
|
|
1992
|
-
const access = await authorize()
|
|
1993
|
-
if (!access) return
|
|
2135
|
+
const access = await wholeRepositoryAccess(deps, cookie, repoId, { handed: true })
|
|
2136
|
+
if ('error' in access) { sendRefusal(res, access); return }
|
|
1994
2137
|
const told = await unread()
|
|
1995
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> {
|
|
1996
2171
|
try {
|
|
1997
2172
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1998
2173
|
const token = reader.readToken()
|
|
1999
|
-
if (!token)
|
|
2174
|
+
if (!token) return refusal(403, 'github-token-required')
|
|
2000
2175
|
const answered = await answer(access.repo.fullName, token, access.user)
|
|
2001
|
-
if (answered.kept)
|
|
2176
|
+
if (answered.kept) return answered
|
|
2002
2177
|
await reader.recheckAccess()
|
|
2003
|
-
const current = await
|
|
2004
|
-
if (
|
|
2005
|
-
if (!sameRepository(current.repo, access.repo))
|
|
2006
|
-
|
|
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
|
|
2007
2182
|
} catch (err) {
|
|
2008
|
-
if (err instanceof GithubApiError)
|
|
2183
|
+
if (err instanceof GithubApiError) return refusal(err.status === 401 ? 502 : err.status, err.message, err.retryAfter)
|
|
2009
2184
|
throw err
|
|
2010
2185
|
}
|
|
2011
2186
|
}
|
|
@@ -2013,7 +2188,9 @@ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
2013
2188
|
// GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
|
|
2014
2189
|
// requests tabs show (repository-issues.ts): `kind`'s open or closed
|
|
2015
2190
|
// (`state`), a page at a time, on from the cursor `after` names. They are
|
|
2016
|
-
// 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).
|
|
2017
2194
|
async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2018
2195
|
const repoId = Number(query.get('repoId'))
|
|
2019
2196
|
const kind = query.get('kind')
|
|
@@ -2021,8 +2198,11 @@ async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
2021
2198
|
const after = query.get('after')
|
|
2022
2199
|
if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
|
|
2023
2200
|
|| (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2024
|
-
await
|
|
2025
|
-
|
|
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
|
+
})
|
|
2026
2206
|
}
|
|
2027
2207
|
|
|
2028
2208
|
// GET /api/admin/repositories/agents: what the code viewer's Agents tab
|
|
@@ -2093,19 +2273,23 @@ async function handleRepositoryPullDependencies(res: ServerResponse, deps: Manag
|
|
|
2093
2273
|
}
|
|
2094
2274
|
|
|
2095
2275
|
// Pull request `number`'s answer, as the endpoint gives it, and whether all
|
|
2096
|
-
// of it was kept, GitHub asked for none of it
|
|
2097
|
-
|
|
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) {
|
|
2098
2281
|
const kept = pullDependencyKept(deps, repoId)
|
|
2099
2282
|
const reading: { told?: PullDependencies } = {}
|
|
2100
|
-
const answered =
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
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
|
+
})
|
|
2105
2289
|
// What its commits tell, as read, else as kept by them, else, no longer
|
|
2106
|
-
// kept, read again.
|
|
2290
|
+
// kept, read again: by those given, without asking GitHub for them.
|
|
2107
2291
|
const held = reading.told ?? (typeof answered.value.commits === 'string' ? await kept.read(answered.value.commits) : null)
|
|
2108
|
-
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 }
|
|
2109
2293
|
// Where its review is on, those of lockfiles it leaves out told to none,
|
|
2110
2294
|
// whoever asks.
|
|
2111
2295
|
const setting = await deps.db.repoDependencyReview(repoId)
|
|
@@ -2116,7 +2300,8 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
|
|
|
2116
2300
|
const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
|
|
2117
2301
|
const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
|
|
2118
2302
|
const reviewed = deps.reviewedDependencies ?? null
|
|
2119
|
-
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 }
|
|
2120
2305
|
// Its comments read for reviews, where the host reads them, while it is
|
|
2121
2306
|
// open (pull-comment-reviews.ts reads whether it is with them).
|
|
2122
2307
|
const parse = reviewed?.parseComments
|
|
@@ -2137,32 +2322,40 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
|
|
|
2137
2322
|
// a pull request's others are (pull-dependency-reviews.ts), each left unread
|
|
2138
2323
|
// a directory of its own failing the review, as a pull request's are. Kept,
|
|
2139
2324
|
// its access not asked again, where its head was kept and its lockfiles and
|
|
2140
|
-
// baseline held, GitHub asked for none of them.
|
|
2325
|
+
// baseline held or kept (lockfileKept), GitHub asked for none of them.
|
|
2141
2326
|
async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2142
2327
|
const repoId = Number(query.get('repoId'))
|
|
2143
2328
|
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2144
|
-
await answerWholeRepository(res, deps, cookie, repoId,
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
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 }
|
|
2166
2359
|
}
|
|
2167
2360
|
|
|
2168
2361
|
// GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
|
|
@@ -2174,7 +2367,195 @@ async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpD
|
|
|
2174
2367
|
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2175
2368
|
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2176
2369
|
const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
|
|
2177
|
-
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]] : []
|
|
2178
2559
|
})
|
|
2179
2560
|
}
|
|
2180
2561
|
|
|
@@ -2215,6 +2596,34 @@ function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependen
|
|
|
2215
2596
|
}
|
|
2216
2597
|
}
|
|
2217
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
|
+
|
|
2218
2627
|
// GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
|
|
2219
2628
|
// (repository-pulse.ts): the default branch's year, counted from its commits
|
|
2220
2629
|
// (pulseCommits), and the pull requests and issues of the last `weeks`
|
|
@@ -2441,7 +2850,7 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
|
|
|
2441
2850
|
// reader leaving, losing their admin role or
|
|
2442
2851
|
// the server shutting down stops it.
|
|
2443
2852
|
async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2444
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
2853
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
2445
2854
|
if (!s) return
|
|
2446
2855
|
const repos = await riskRepositories(deps.db, query)
|
|
2447
2856
|
if (!repos) {
|
|
@@ -2724,7 +3133,7 @@ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2724
3133
|
}
|
|
2725
3134
|
|
|
2726
3135
|
async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2727
|
-
const initial = await manageMutation(req, res, deps, cookie)
|
|
3136
|
+
const initial = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
2728
3137
|
if (!initial) return
|
|
2729
3138
|
const controller = new AbortController()
|
|
2730
3139
|
const onClose = () => controller.abort()
|
|
@@ -2763,9 +3172,8 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2763
3172
|
throw new BundleBuildError(409, 'repository-changed')
|
|
2764
3173
|
}
|
|
2765
3174
|
signal.throwIfAborted()
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
bundleIntegrity(bytes), input.conditions)
|
|
3175
|
+
await storeUploadedBundle(req, res, deps, cookie, current, built.bytes, input.repoId, built.directory, built.filename, 'build',
|
|
3176
|
+
bundleIntegrity(built.bytes), input.conditions)
|
|
2769
3177
|
})
|
|
2770
3178
|
} catch (error) {
|
|
2771
3179
|
if (res.destroyed || controller.signal.aborted) return
|
|
@@ -2788,7 +3196,7 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2788
3196
|
// After storing, any reports that declared this integrity but weren't linked yet
|
|
2789
3197
|
// get attached (auto-link). 413 over the cap, 400 on empty.
|
|
2790
3198
|
async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2791
|
-
let s = await checkMutation(req, res, deps, cookie)
|
|
3199
|
+
let s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
2792
3200
|
if (s == null) return
|
|
2793
3201
|
if (!requireManageRole(res, s.user)) return
|
|
2794
3202
|
let bytes: Buffer
|
|
@@ -2846,15 +3254,17 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2846
3254
|
repo.repoId = null
|
|
2847
3255
|
directory = ''
|
|
2848
3256
|
}
|
|
2849
|
-
await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity)
|
|
3257
|
+
await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity, null, existing === null)
|
|
2850
3258
|
}
|
|
2851
3259
|
|
|
3260
|
+
// `absent` where the caller has just found no bundle of `integrity`: not read
|
|
3261
|
+
// again, one inserted since meeting the insert's UNIQUE below as a race does.
|
|
2852
3262
|
async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
|
|
2853
3263
|
s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
|
|
2854
|
-
provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null): Promise<void> {
|
|
3264
|
+
provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null, absent = false): Promise<void> {
|
|
2855
3265
|
const kind = bundleKind(filename)
|
|
2856
3266
|
const reuse = { provenance, filename, kind, buildConditions }
|
|
2857
|
-
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
3267
|
+
const existing = absent ? null : await deps.db.getBundleByIntegrity(integrity)
|
|
2858
3268
|
if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, reuse); return }
|
|
2859
3269
|
const id = randomUUID()
|
|
2860
3270
|
const dataKey = await deps.bundleStore.put(id, bytes, kind)
|
|
@@ -2916,7 +3326,7 @@ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2916
3326
|
// the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
|
|
2917
3327
|
// best-effort. 404 when there was no such bundle.
|
|
2918
3328
|
async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
2919
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
3329
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
2920
3330
|
if (s == null) return
|
|
2921
3331
|
if (!requireManageRole(res, s.user)) return
|
|
2922
3332
|
const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
|
|
@@ -2929,8 +3339,8 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2929
3339
|
|
|
2930
3340
|
// admin|manage mutation guard: same-origin + CSRF + role, in one step. Returns
|
|
2931
3341
|
// the session, or null after the 401/403 was sent.
|
|
2932
|
-
async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
2933
|
-
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 })
|
|
2934
3344
|
if (s == null) return null
|
|
2935
3345
|
if (!requireManageRole(res, s.user)) return null
|
|
2936
3346
|
return s
|
|
@@ -2938,8 +3348,8 @@ async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: M
|
|
|
2938
3348
|
|
|
2939
3349
|
// Workspace access and repository connections are administered separately
|
|
2940
3350
|
// from content management. Managers cannot change their own access via teams.
|
|
2941
|
-
async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
2942
|
-
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 })
|
|
2943
3353
|
if (s == null) return null
|
|
2944
3354
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
2945
3355
|
return s
|
|
@@ -2984,19 +3394,18 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
|
|
|
2984
3394
|
// need a published report in their teams; ownership never overrides role none.
|
|
2985
3395
|
async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
2986
3396
|
if (!roleAtLeast(user.role, 'view')) return false
|
|
2987
|
-
const report = await deps.db.
|
|
3397
|
+
const report = await deps.db.getReportView(user.id, reportId)
|
|
2988
3398
|
if (report == null) return false
|
|
2989
3399
|
if (user.role === 'admin') return true
|
|
2990
|
-
if (user.role === 'manage') return report.uploadedBy === user.id ||
|
|
2991
|
-
|
|
2992
|
-
return deps.db.userCanReadReport(user.id, reportId)
|
|
3400
|
+
if (user.role === 'manage') return report.uploadedBy === user.id || report.readable
|
|
3401
|
+
return report.visible && report.readable
|
|
2993
3402
|
}
|
|
2994
3403
|
|
|
2995
3404
|
// GET/HEAD /api/reports/:id/sources. Only the linked bundle's files cited by
|
|
2996
3405
|
// visible findings (location AND evidence) are returned. Missing/unavailable
|
|
2997
3406
|
// bundles are a quiet no-op; cached gzip bodies stream directly from disk.
|
|
2998
3407
|
async function handleReportSources(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null) {
|
|
2999
|
-
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 })
|
|
3000
3409
|
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3001
3410
|
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3002
3411
|
const sourcePaths = roleAtLeast(s.user.role, 'manage') ? undefined : await teamSourcePaths(deps.db, deps.reportStore, s.session.id, teamId ?? '', id)
|
|
@@ -3040,7 +3449,7 @@ async function reportIds(data: unknown): Promise<Set<string>> {
|
|
|
3040
3449
|
async function handleDeduplication(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
3041
3450
|
const method = req.method ?? 'GET'
|
|
3042
3451
|
if (!(id ? ['GET', 'PATCH'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'GET, PATCH' : 'GET, POST'); return }
|
|
3043
|
-
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 })
|
|
3044
3453
|
if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3045
3454
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
3046
3455
|
if (method === 'GET') { sendJson(res, 200, id ? await deps.db.getLinkReport(s.session.id, id) : { reports: await deps.db.listLinkReports() }); return }
|
|
@@ -3063,7 +3472,7 @@ async function handleDeduplication(req: IncomingMessage, res: ServerResponse, de
|
|
|
3063
3472
|
async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
3064
3473
|
const method = req.method ?? 'GET'
|
|
3065
3474
|
if (!(id ? ['PATCH', 'DELETE'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'PATCH, DELETE' : 'GET, POST'); return }
|
|
3066
|
-
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 })
|
|
3067
3476
|
if (!s) return
|
|
3068
3477
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
3069
3478
|
if (method === 'GET') {
|
|
@@ -3083,7 +3492,7 @@ async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse
|
|
|
3083
3492
|
// Managers can resolve only destinations their current repository grants cover.
|
|
3084
3493
|
async function handleRepositorySuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3085
3494
|
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
3086
|
-
if (!await readManageSession(res, deps, cookie)) return
|
|
3495
|
+
if (!await readManageSession(res, deps, cookie, { handed: true })) return
|
|
3087
3496
|
const params = new URL(req.url!, 'http://localhost').searchParams
|
|
3088
3497
|
const github = reportRepoGithub({ repo: { github: params.get('repo') } })
|
|
3089
3498
|
const directory = normalizeTeamPath(params.get('directory'))
|
|
@@ -3121,7 +3530,7 @@ async function suggestReportLocation(deps: ManagedHttpDeps, user: StoredUser, da
|
|
|
3121
3530
|
// location editor. It never changes the stored assignment.
|
|
3122
3531
|
async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
3123
3532
|
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
3124
|
-
const s = await readManageSession(res, deps, cookie)
|
|
3533
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
3125
3534
|
if (!s) return
|
|
3126
3535
|
const report = await deps.db.getReport(id)
|
|
3127
3536
|
if (!report || !(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
@@ -3143,7 +3552,7 @@ async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerR
|
|
|
3143
3552
|
// "not authorized" (so neither existence nor membership is probeable); 503 = row
|
|
3144
3553
|
// without bytes (store desync).
|
|
3145
3554
|
async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
3146
|
-
const s = await readManageSession(res, deps, cookie)
|
|
3555
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
3147
3556
|
if (s == null) return
|
|
3148
3557
|
const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
|
|
3149
3558
|
const access = snapshot?.reports[0]
|
|
@@ -3180,7 +3589,7 @@ async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3180
3589
|
const REPORT_QUERY_CONCURRENCY = 8
|
|
3181
3590
|
const REPORT_QUERY_IN_FLIGHT_BYTES = 64 * 1024 * 1024
|
|
3182
3591
|
async function handleQueryReports(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3183
|
-
const s = await readManageSession(res, deps, cookie)
|
|
3592
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
3184
3593
|
if (!s) return
|
|
3185
3594
|
let body
|
|
3186
3595
|
try { body = await readJsonBody(req, 1024 * 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3274,9 +3683,11 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
|
|
|
3274
3683
|
|
|
3275
3684
|
// The server selects the complete published workspace for classification.
|
|
3276
3685
|
// Privileged hidden-report previews are independently scoped and bounded.
|
|
3277
|
-
|
|
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> {
|
|
3278
3689
|
const snapshot = await teamSnapshot(deps.db, session.id, teamId, reportId)
|
|
3279
|
-
const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
|
|
3690
|
+
const body = await (part === 'trees' ? loadTeamTreesResponse : loadTeamReportsResponse)(deps.db, deps.reportStore, snapshot)
|
|
3280
3691
|
await recheckTeam(deps.db, session.id, snapshot)
|
|
3281
3692
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
3282
3693
|
writeResponse(res, body)
|
|
@@ -3333,7 +3744,7 @@ async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256
|
|
|
3333
3744
|
// stripped finding must not leak that the finding exists. A cleared entry is
|
|
3334
3745
|
// sent as null (the server's tombstone), distinct from one never set.
|
|
3335
3746
|
async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
|
|
3336
|
-
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 })
|
|
3337
3748
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3338
3749
|
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3339
3750
|
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
@@ -3352,7 +3763,7 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
3352
3763
|
// exists). Each event is the entry as written then (null = a clear), who
|
|
3353
3764
|
// wrote it and when; "what changed" is the diff against the next-older event.
|
|
3354
3765
|
async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
|
|
3355
|
-
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 })
|
|
3356
3767
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3357
3768
|
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3358
3769
|
const finding = query.get('finding') ?? ''
|
|
@@ -3383,7 +3794,7 @@ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHt
|
|
|
3383
3794
|
// scope for rows that are themselves per finding id — so a stripped (or
|
|
3384
3795
|
// foreign) finding id 404s without revealing whether it exists.
|
|
3385
3796
|
async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
|
|
3386
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
3797
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
3387
3798
|
if (s == null) return
|
|
3388
3799
|
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3389
3800
|
let body: unknown
|
|
@@ -3417,7 +3828,7 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
|
|
|
3417
3828
|
// GET only: clients match local annotations against these IDs without sending
|
|
3418
3829
|
// their local finding IDs or annotation bodies to discover which reports exist.
|
|
3419
3830
|
async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
|
|
3420
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
3831
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
3421
3832
|
if (!s) return
|
|
3422
3833
|
const after = search.get('after') ?? ''
|
|
3423
3834
|
if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
|
|
@@ -3439,7 +3850,7 @@ async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
3439
3850
|
// Admin imports compare the exact snapshot shown by the conflict dialog before
|
|
3440
3851
|
// writing. The store commits shared finding triage and imported comments together.
|
|
3441
3852
|
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
3442
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
3853
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3443
3854
|
if (!s) return
|
|
3444
3855
|
let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
|
|
3445
3856
|
try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof body }
|
|
@@ -3478,7 +3889,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
|
|
|
3478
3889
|
if (commentId == null ? method !== 'GET' && method !== 'POST' : method !== 'PATCH' && method !== 'DELETE') {
|
|
3479
3890
|
send405(res, commentId == null ? 'GET, POST' : 'PATCH, DELETE'); return
|
|
3480
3891
|
}
|
|
3481
|
-
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 })
|
|
3482
3893
|
if (s == null) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3483
3894
|
const allowed = method === 'GET' ? await canViewReport(deps, s.user, reportId) : await canTriageReport(deps, s.user, reportId)
|
|
3484
3895
|
if (!allowed) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
@@ -3526,7 +3937,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
|
|
|
3526
3937
|
// the page needs: all users (member dropdown), selected repos (repo dropdown),
|
|
3527
3938
|
// and the visibility-permission keys. admin, read-only (no CSRF).
|
|
3528
3939
|
async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3529
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
3940
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
3530
3941
|
if (s == null) return
|
|
3531
3942
|
const scopes = await deps.db.listTeamNpmScopes()
|
|
3532
3943
|
const reviewTeams = new Set(await deps.db.listDependencyReviewEnabledTeams())
|
|
@@ -3540,7 +3951,7 @@ async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cooki
|
|
|
3540
3951
|
|
|
3541
3952
|
// POST /api/admin/teams — create a team. Body { name }. 409 if the name's taken.
|
|
3542
3953
|
async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3543
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
3954
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3544
3955
|
if (s == null) return
|
|
3545
3956
|
let body: unknown
|
|
3546
3957
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3557,7 +3968,7 @@ async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3557
3968
|
// POST /api/admin/teams/rename — rename a team. Body { teamId, name }. 404 if no
|
|
3558
3969
|
// such team; 409 if the new name is already taken by another team.
|
|
3559
3970
|
async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3560
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
3971
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3561
3972
|
if (s == null) return
|
|
3562
3973
|
let body: unknown
|
|
3563
3974
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3576,7 +3987,7 @@ async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3576
3987
|
|
|
3577
3988
|
// Hidden teams retain their configuration but grant no access or sidebar presence.
|
|
3578
3989
|
async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3579
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
3990
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3580
3991
|
if (s == null) return
|
|
3581
3992
|
let body: unknown
|
|
3582
3993
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3592,7 +4003,7 @@ async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, de
|
|
|
3592
4003
|
|
|
3593
4004
|
// POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
|
|
3594
4005
|
async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3595
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4006
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3596
4007
|
if (s == null) return
|
|
3597
4008
|
let body: unknown
|
|
3598
4009
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3609,7 +4020,7 @@ async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3609
4020
|
// path links with the whole repository. Body { teamId, repoId, path? }.
|
|
3610
4021
|
// Repo must be in the selected set.
|
|
3611
4022
|
async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3612
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4023
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3613
4024
|
if (s == null) return
|
|
3614
4025
|
let body: unknown
|
|
3615
4026
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3634,7 +4045,7 @@ async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps
|
|
|
3634
4045
|
// POST /api/admin/teams/remove-repo — unlink one path. Omitting path retains
|
|
3635
4046
|
// the legacy remove-all behavior. Body { teamId, repoId, path? }.
|
|
3636
4047
|
async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3637
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4048
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3638
4049
|
if (s == null) return
|
|
3639
4050
|
let body: unknown
|
|
3640
4051
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3654,7 +4065,7 @@ async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, d
|
|
|
3654
4065
|
// POST /api/admin/teams/set-member — add/update a member + their visibility
|
|
3655
4066
|
// permissions. Body { teamId, userId, dependencies?, security? }.
|
|
3656
4067
|
async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3657
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4068
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3658
4069
|
if (s == null) return
|
|
3659
4070
|
let body: unknown
|
|
3660
4071
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3677,7 +4088,7 @@ async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, de
|
|
|
3677
4088
|
|
|
3678
4089
|
// POST /api/admin/teams/remove-member — remove a membership. Body { teamId, userId }.
|
|
3679
4090
|
async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3680
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4091
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3681
4092
|
if (s == null) return
|
|
3682
4093
|
let body: unknown
|
|
3683
4094
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3693,7 +4104,7 @@ async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse,
|
|
|
3693
4104
|
}
|
|
3694
4105
|
|
|
3695
4106
|
async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3696
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4107
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3697
4108
|
if (!s) return
|
|
3698
4109
|
let body
|
|
3699
4110
|
try { body = await readJsonBody(req) as { teamId?: unknown; enabled?: unknown } } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3705,7 +4116,7 @@ async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerRe
|
|
|
3705
4116
|
// POST /api/admin/teams/set-npm-scopes — replace the npm scopes whose private
|
|
3706
4117
|
// packages the team's members may read. Body { teamId, scopes: ['@scope'] }.
|
|
3707
4118
|
async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3708
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4119
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3709
4120
|
if (s == null) return
|
|
3710
4121
|
let body: unknown
|
|
3711
4122
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3724,10 +4135,18 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
|
|
|
3724
4135
|
// npm scopes of its visible teams, else none: none are read for an unscoped
|
|
3725
4136
|
// package, an admin or a manager, or an answer checked again that is public.
|
|
3726
4137
|
// Null for none.
|
|
3727
|
-
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
|
|
3728
|
-
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 })
|
|
3729
4140
|
return s == null ? null : npmReaderOf(deps, s.user, names)
|
|
3730
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
|
+
}
|
|
3731
4150
|
// The same of `user`, as a session the request read just now tells them.
|
|
3732
4151
|
async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
|
|
3733
4152
|
if (!roleAtLeast(user.role, 'view')) return null
|
|
@@ -3748,9 +4167,11 @@ const REVIEW_READS_AT_ONCE = 8
|
|
|
3748
4167
|
|
|
3749
4168
|
// Lists must enforce the same target and base access as the review itself,
|
|
3750
4169
|
// including permission changes while the registry requests are in flight;
|
|
3751
|
-
// 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).
|
|
3752
4173
|
async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[],
|
|
3753
|
-
session?: { id: string; role: NpmReader['role'] }): Promise<T[] | null> {
|
|
4174
|
+
session?: { id: string; role: NpmReader['role'] }, teamId?: string): Promise<T[] | null> {
|
|
3754
4175
|
const controller = new AbortController()
|
|
3755
4176
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
3756
4177
|
res.on('close', onClose)
|
|
@@ -3769,8 +4190,10 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
|
|
|
3769
4190
|
const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
|
|
3770
4191
|
const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
|
|
3771
4192
|
const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
|
|
3772
|
-
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)
|
|
3773
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 }
|
|
3774
4197
|
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
3775
4198
|
} catch (error) {
|
|
3776
4199
|
if (controller.signal.aborted) return null
|
|
@@ -3808,13 +4231,16 @@ async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReade
|
|
|
3808
4231
|
return npmReviewVerdictLimit(doc, list, socket, risk)
|
|
3809
4232
|
}
|
|
3810
4233
|
|
|
3811
|
-
// Reviews are scoped to the bytes actually displayed, including the base
|
|
3812
|
-
//
|
|
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.
|
|
3813
4239
|
async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
|
|
3814
4240
|
const method = req.method ?? 'GET'
|
|
3815
4241
|
if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
|
|
3816
4242
|
const mutation = method !== 'GET'
|
|
3817
|
-
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 })
|
|
3818
4244
|
if (!session) { if (!mutation) sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3819
4245
|
if (!roleAtLeast(session.user.role, mutation ? 'triage' : 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
3820
4246
|
let body: Record<string, unknown> = {}
|
|
@@ -3889,10 +4315,14 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3889
4315
|
|| (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
|
|
3890
4316
|
sendJson(res, 409, { error: 'review-content-changed' }); return
|
|
3891
4317
|
}
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
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') {
|
|
3896
4326
|
const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
|
|
3897
4327
|
const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
|
|
3898
4328
|
const diff = computeBundleDiff(before, after, () => '.')
|
|
@@ -3906,8 +4336,8 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3906
4336
|
change.files = changed.map(file => file.path)
|
|
3907
4337
|
change.nonTextFiles = changed.filter(file => !reviewable(file)).map(file => file.path)
|
|
3908
4338
|
}
|
|
3909
|
-
}
|
|
3910
|
-
}
|
|
4339
|
+
}
|
|
4340
|
+
} finally { loads.forEach(load => load.release()) }
|
|
3911
4341
|
const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
|
|
3912
4342
|
? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
|
|
3913
4343
|
const current = await npmReader(deps, cookie, [doc, base].flatMap(item => item?.private ? [item.name] : []))
|
|
@@ -3926,7 +4356,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3926
4356
|
if (change.action === 'publish' && change.final && !allowedReviewVerdicts([], limit).includes(change.final.status)) {
|
|
3927
4357
|
sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
|
|
3928
4358
|
}
|
|
3929
|
-
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)
|
|
3930
4360
|
sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
|
|
3931
4361
|
} catch (err) {
|
|
3932
4362
|
if (controller.signal.aborted) return
|
|
@@ -3936,6 +4366,62 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3936
4366
|
} finally { res.off('close', onClose) }
|
|
3937
4367
|
}
|
|
3938
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
|
+
|
|
3939
4425
|
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
|
|
3940
4426
|
// (canReadPrivateNpm) can be answered from the server's npm token; the rest get
|
|
3941
4427
|
// what the registry answers anonymously, every time (see npm-packages.ts).
|
|
@@ -3956,7 +4442,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3956
4442
|
}
|
|
3957
4443
|
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
3958
4444
|
if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
3959
|
-
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 })
|
|
3960
4446
|
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3961
4447
|
const controller = new AbortController()
|
|
3962
4448
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
@@ -4201,7 +4687,7 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
|
|
|
4201
4687
|
const method = req.method ?? 'GET'
|
|
4202
4688
|
const allowed = id ? ['PATCH', 'DELETE'] : ['GET', 'POST', 'DELETE']
|
|
4203
4689
|
if (!allowed.includes(method)) { send405(res, allowed.join(', ')); return }
|
|
4204
|
-
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 })
|
|
4205
4691
|
if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4206
4692
|
if (!requireManageRole(res, s.user)) return
|
|
4207
4693
|
const { db } = deps
|
|
@@ -4230,6 +4716,9 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
|
|
|
4230
4716
|
export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
4231
4717
|
const { config, db, avatarStore, isShuttingDown, track } = deps
|
|
4232
4718
|
const repositoryDiscovery = new RepositoryDiscovery(config)
|
|
4719
|
+
// Kept reads' prunes run behind their writes: tracked, so the database
|
|
4720
|
+
// outlives them, and on serverless kept alive past the answer.
|
|
4721
|
+
ownKeptPrunes(db, prune => { track(prune); deps.waitUntil?.(prune) })
|
|
4233
4722
|
|
|
4234
4723
|
async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
4235
4724
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
@@ -4329,6 +4818,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4329
4818
|
const managedDataPath = underPrefix(path, MANAGED_DATA_PREFIXES)
|
|
4330
4819
|
const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
|
|
4331
4820
|
if (managedDataPath && !workspaceSession) return
|
|
4821
|
+
// What it read, for the handler's own first read to take.
|
|
4822
|
+
if (workspaceSession) handOverSession(cookie, workspaceSession)
|
|
4332
4823
|
const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
|
|
4333
4824
|
if (issueRoute) {
|
|
4334
4825
|
await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
|
|
@@ -4341,8 +4832,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4341
4832
|
if (path === '/api/admin/links') {
|
|
4342
4833
|
if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
4343
4834
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4344
|
-
const s =
|
|
4345
|
-
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4835
|
+
const s = workspaceSession!
|
|
4346
4836
|
if (!requireManageRole(res, s.user)) return
|
|
4347
4837
|
const shares = await db.listManagedWorkspaceShares(s.session.id, Date.now())
|
|
4348
4838
|
sendJson(res, shares ? 200 : 403, shares ? { shares } : { error: 'forbidden' }); return
|
|
@@ -4360,8 +4850,6 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4360
4850
|
// sign-in account's. A linked account's is served by its own id.
|
|
4361
4851
|
if (path.startsWith(AVATAR_PREFIX)) {
|
|
4362
4852
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4363
|
-
const s = await readSession(config, db, cookie, Date.now())
|
|
4364
|
-
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4365
4853
|
const id = path.slice(AVATAR_PREFIX.length)
|
|
4366
4854
|
if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
|
|
4367
4855
|
await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
|
|
@@ -4374,9 +4862,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4374
4862
|
}
|
|
4375
4863
|
if (path === ADMIN_USERS_PATH) {
|
|
4376
4864
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4377
|
-
|
|
4378
|
-
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4379
|
-
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
4865
|
+
if (workspaceSession!.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
4380
4866
|
sendJson(res, 200, { users: await db.listUsers() })
|
|
4381
4867
|
return
|
|
4382
4868
|
}
|
|
@@ -4548,13 +5034,17 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4548
5034
|
const teamFeed = /^\/api\/teams\/([^/]+)\/feed$/u.exec(path)
|
|
4549
5035
|
if (teamFeed || path === '/api/teams/feed') {
|
|
4550
5036
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4551
|
-
const s =
|
|
4552
|
-
if (!s) return
|
|
5037
|
+
const s = workspaceSession!
|
|
4553
5038
|
const teamId = teamFeed?.[1] ?? null
|
|
4554
5039
|
const reportId = url.searchParams.get('reportId')
|
|
4555
5040
|
if (teamId) await teamSnapshot(db, s.session.id, teamId, reportId)
|
|
4556
5041
|
await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId, { reportId }); return
|
|
4557
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
|
+
}
|
|
4558
5048
|
const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
|
|
4559
5049
|
if (teamFixes) {
|
|
4560
5050
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
@@ -4568,10 +5058,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4568
5058
|
await recheckTeam(db, workspaceSession!.session.id, snapshot)
|
|
4569
5059
|
sendJson(res, 200, annotations); return
|
|
4570
5060
|
}
|
|
4571
|
-
const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
|
|
5061
|
+
const teamReports = /^\/api\/teams\/([^/]+)\/(reports|trees)$/u.exec(path)
|
|
4572
5062
|
if (teamReports) {
|
|
4573
5063
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4574
|
-
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
|
|
4575
5065
|
}
|
|
4576
5066
|
if (path === REPORT_QUERY_PATH) {
|
|
4577
5067
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
@@ -4639,8 +5129,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4639
5129
|
}
|
|
4640
5130
|
if (path === '/api/npm/reviews/latest') {
|
|
4641
5131
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4642
|
-
const s =
|
|
4643
|
-
if (!s) return
|
|
5132
|
+
const s = workspaceSession!
|
|
4644
5133
|
const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
|
|
4645
5134
|
if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
|
|
4646
5135
|
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
@@ -4652,7 +5141,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4652
5141
|
}
|
|
4653
5142
|
if (path === '/api/npm/reviews/discard') {
|
|
4654
5143
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
4655
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
5144
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
4656
5145
|
if (!s) return
|
|
4657
5146
|
let body: Record<string, unknown>
|
|
4658
5147
|
try {
|
|
@@ -4669,8 +5158,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4669
5158
|
}
|
|
4670
5159
|
if (path === '/api/npm/reviews/mine') {
|
|
4671
5160
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4672
|
-
const s =
|
|
4673
|
-
if (!s) return
|
|
5161
|
+
const s = workspaceSession!
|
|
4674
5162
|
const id = url.searchParams.get('beforeId'), time = url.searchParams.get('beforeTime')
|
|
4675
5163
|
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
4676
5164
|
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
@@ -4684,8 +5172,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4684
5172
|
// Dependencies tab lists them, fifty a page.
|
|
4685
5173
|
if (path === '/api/npm/reviews/team') {
|
|
4686
5174
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4687
|
-
const s =
|
|
4688
|
-
if (!s) return
|
|
5175
|
+
const s = workspaceSession!
|
|
4689
5176
|
const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
|
|
4690
5177
|
if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
|
|
4691
5178
|
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
@@ -4693,16 +5180,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4693
5180
|
}
|
|
4694
5181
|
const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
4695
5182
|
if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
4696
|
-
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
|
|
4697
|
-
if (!reviews) return
|
|
4698
5183
|
// Its access asked again, as it may have been taken while npm was read.
|
|
4699
|
-
|
|
4700
|
-
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 })
|
|
4701
5186
|
return
|
|
4702
5187
|
}
|
|
4703
5188
|
if (path === '/api/npm/reviews') {
|
|
4704
5189
|
await handleNpmReviews(req, res, deps, cookie, url.searchParams); return
|
|
4705
5190
|
}
|
|
5191
|
+
if (path === NPM_DELTAS_PATH) { await handleNpmDeltas(req, res, deps, cookie, url.searchParams); return }
|
|
4706
5192
|
if (NPM_PATHS.has(path)) {
|
|
4707
5193
|
await handleNpm(req, res, deps, cookie, path, url.searchParams); return
|
|
4708
5194
|
}
|
|
@@ -4712,12 +5198,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4712
5198
|
sendJson(res, 404, { error: 'not-found' }, { connection: 'close' })
|
|
4713
5199
|
}
|
|
4714
5200
|
|
|
4715
|
-
|
|
5201
|
+
// Each request's time told in its Server-Timing header (request-timing.ts).
|
|
5202
|
+
return (req, res) => runTimed(res, () => {
|
|
4716
5203
|
if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
|
|
4717
5204
|
if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return }
|
|
4718
5205
|
// Feeds lease a connection per poll, never across streaming sleeps. This
|
|
4719
|
-
// also covers capability-authenticated feeds handled by public-workspace
|
|
4720
|
-
|
|
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 ?? '')
|
|
4721
5210
|
const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
|
|
4722
5211
|
const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
|
|
4723
5212
|
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
@@ -4727,5 +5216,5 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4727
5216
|
})
|
|
4728
5217
|
track(work)
|
|
4729
5218
|
return work
|
|
4730
|
-
}
|
|
5219
|
+
})
|
|
4731
5220
|
}
|