@preventive/triage 1.0.0-alpha.47 → 1.0.0-alpha.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/dependency-paths.d.ts +2 -1
- package/client/dependency-paths.js +16 -4
- package/common/managed/agent-tasks.ts +12 -14
- package/common/managed/github-agents.ts +7 -6
- package/out/activity.js +4 -4
- package/out/{chunk-NOZJULWS.js → chunk-6LNQTKAV.js} +1 -1
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/{chunk-5AHIQFFG.js → chunk-C2AVODGZ.js} +1 -1
- package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
- package/out/chunk-JEAUAJVF.js +232 -0
- package/out/chunk-O6M574JI.js +1 -0
- package/out/{chunk-VL43BTSO.js → chunk-PBSOL2SL.js} +1 -1
- package/out/{chunk-ZCS4UYHI.js → chunk-PBZPYIG7.js} +1 -1
- package/out/{chunk-6WF3KNTL.js → chunk-SZD5CW5R.js} +2 -2
- package/out/{chunk-SPEFPML6.js → chunk-TQJDYHVE.js} +1 -1
- package/out/{chunk-UENXT2HK.js → chunk-ULIF7A7N.js} +1 -1
- package/out/{chunk-FH24VHSJ.js → chunk-WYLQWYLA.js} +1 -1
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +26 -26
- package/out/compare.js +13 -13
- package/out/dialogs.js +17 -17
- package/out/graph.js +1 -1
- package/out/index.html +7 -7
- package/out/scan.js +1 -1
- package/out/terminal.js +214 -214
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +8 -5
- package/risk/npm/score.js +1 -1
- package/server-managed/agent-merges.ts +38 -22
- package/server-managed/app-agents.ts +6 -8
- package/server-managed/cache-storage.ts +3 -2
- package/server-managed/commit-coauthors.ts +7 -2
- package/server-managed/commit-lockfiles.ts +22 -21
- package/server-managed/db-methods.ts +52 -38
- package/server-managed/db-neon.ts +21 -5
- package/server-managed/db.ts +2 -0
- package/server-managed/dependabot-access.ts +1 -1
- package/server-managed/dependency-review-comments.ts +7 -4
- package/server-managed/dependency-reviews.ts +71 -33
- package/server-managed/github-accounts.ts +28 -1
- package/server-managed/github-app.ts +27 -23
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +8 -1
- package/server-managed/github-metadata.ts +2 -2
- package/server-managed/http-response.ts +28 -10
- package/server-managed/http.ts +751 -273
- package/server-managed/index.ts +2 -2
- package/server-managed/managed-issues.ts +3 -0
- package/server-managed/npm-delta.ts +16 -0
- package/server-managed/npm-kept.ts +32 -5
- package/server-managed/npm-loads.ts +21 -13
- package/server-managed/npm-manifest.ts +1 -1
- package/server-managed/npm-packages.ts +13 -4
- package/server-managed/object-storage.ts +4 -3
- package/server-managed/public-workspace.ts +3 -3
- package/server-managed/pull-comment-reviews.ts +1 -1
- package/server-managed/pull-dependencies.ts +84 -36
- package/server-managed/pull-dependency-reviews.ts +28 -18
- package/server-managed/rename-diff-worker.js +1 -1
- package/server-managed/repository-agents.ts +9 -6
- package/server-managed/repository-browser.ts +13 -3
- package/server-managed/repository-code.ts +153 -7
- package/server-managed/repository-dependency-reviews.ts +12 -4
- package/server-managed/repository-history.ts +85 -152
- package/server-managed/repository-issues.ts +27 -13
- package/server-managed/repository-pulse.ts +31 -11
- package/server-managed/repository-squashes.ts +145 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +3 -2
- package/server-managed/session.ts +34 -12
- package/server-managed/sql.ts +16 -8
- package/server-managed/storage-encryption.ts +16 -8
- package/server-managed/storage-maintenance.ts +5 -2
- package/server-managed/storage-payload.ts +18 -0
- package/server-managed/storage-stores.ts +3 -2
- package/server-managed/team-feed.ts +16 -11
- package/server-managed/team-npm-scopes.ts +29 -0
- package/server-managed/team-reports.ts +68 -16
- package/out/chunk-6ANSHRFQ.js +0 -6
- package/out/chunk-S6U2J62U.js +0 -232
- package/out/chunk-ZGNMDGSB.js +0 -1
package/server-managed/http.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
|
|
24
24
|
// GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
|
|
25
25
|
// GET /api/avatar/<id>[?own] → cached avatar bytes by user id, of the account chosen on their profile (`own`: the
|
|
26
|
-
// sign-in account's), or by linked account id; `app-<id>` or `user-<id>`, an
|
|
26
|
+
// sign-in account's), or by linked account id; `app-<id>` or `user-<id>`, an agent's GitHub App's or account's (GITHUB_AGENTS) | 401/404
|
|
27
27
|
// GET /api/admin/users → admin-only user list | 401/403
|
|
28
28
|
// GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
|
|
29
29
|
// POST /api/admin/set-role → admin sets another user's role | 401/403/404
|
|
@@ -135,9 +135,10 @@ import { mergeLinkGroups } from './link-reports.ts'
|
|
|
135
135
|
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
136
136
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
137
137
|
import { pipeline } from 'node:stream/promises'
|
|
138
|
-
import {
|
|
139
|
-
repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
140
|
-
import {
|
|
138
|
+
import { type KeptStore, REPOSITORY_READ_KEPT_MS, codeHeadStore, codeTreeStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, lockfileReadStore,
|
|
139
|
+
npmDeltaStore, npmPublishTimeStore, npmVersionStore, ownKeptPrunes, pullDependencyStore, repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
140
|
+
import { plainObject } from './npm-manifest.ts'
|
|
141
|
+
import { type NpmDelta, keptNpmDeltas, npmDelta } from './npm-delta.ts'
|
|
141
142
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
142
143
|
import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
|
|
143
144
|
import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
|
|
@@ -183,11 +184,13 @@ import { githubActionsSource } from './github-actions.ts'
|
|
|
183
184
|
import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
|
|
184
185
|
import { type Lru, lru } from './lru.ts'
|
|
185
186
|
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
186
|
-
import { clearCookie, endSession, readSession, runRequestPresence } from './session.ts'
|
|
187
|
+
import { clearCookie, endSession, handOverSession, readSession, runRequestPresence, sessionIds } from './session.ts'
|
|
188
|
+
import { runTimed } from './request-timing.ts'
|
|
189
|
+
import { isExactVersion } from '@preventive/upstream/semver.js'
|
|
187
190
|
import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
|
|
188
191
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
189
192
|
import { acceptsReportMetadata } from './report-response.ts'
|
|
190
|
-
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
193
|
+
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, loadTeamTreesResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
191
194
|
import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
192
195
|
import { lookupFixes, storedFixUrls } from './github-pulls.ts'
|
|
193
196
|
import { lookupIssueLinks } from './github-issue-links.ts'
|
|
@@ -198,7 +201,7 @@ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallb
|
|
|
198
201
|
import type { ProfileChoice } from './github-links.ts'
|
|
199
202
|
import { withAppUsers } from './github-users.ts'
|
|
200
203
|
import { isBlobId } from './object-storage.ts'
|
|
201
|
-
import { attachmentDisposition, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
|
|
204
|
+
import { attachmentDisposition, openLines, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
|
|
202
205
|
import { triageWireEntry } from './triage-response.ts'
|
|
203
206
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
204
207
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
@@ -207,19 +210,20 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
|
207
210
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
208
211
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
209
212
|
import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
|
|
210
|
-
import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions,
|
|
211
|
-
readTogether } from './npm-packages.ts'
|
|
213
|
+
import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, VERSION_KEPT_MS, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions, readTogether } from './npm-packages.ts'
|
|
212
214
|
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
213
|
-
import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
|
|
215
|
+
import { type RepositoryCodeKept, loadRepositoryCodeBody, repositoryCodeFits, repositoryCodeKept } from './repository-code.ts'
|
|
214
216
|
import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
|
|
215
217
|
import { COAUTHORS_READ } from './commit-coauthors.ts'
|
|
216
|
-
import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE,
|
|
217
|
-
repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips
|
|
218
|
-
import {
|
|
218
|
+
import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, historyFrom, historyStarts, openPullHeads,
|
|
219
|
+
repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips } from './repository-history.ts'
|
|
220
|
+
import { type SquashedPull, type Squashes, squashedPulls } from './repository-squashes.ts'
|
|
221
|
+
import { AGENTS_READ_VERSION, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
|
|
219
222
|
import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
|
|
220
|
-
import {
|
|
221
|
-
|
|
222
|
-
import {
|
|
223
|
+
import { type GithubAsked, type ListedLockfile, type LockfileRead, type LockfileStore, MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullCommits, type PullDependencies,
|
|
224
|
+
type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
|
|
225
|
+
import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
|
|
226
|
+
import { MAX_REQUIRED_REVIEWS, type RepoDependencyReview, SINCE_COMMIT } from './repository-dependency-reviews.ts'
|
|
223
227
|
import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
|
|
224
228
|
import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
|
|
225
229
|
import { formattedPull, withFormatted } from './dependency-review-comments.ts'
|
|
@@ -230,7 +234,7 @@ import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitS
|
|
|
230
234
|
import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
|
|
231
235
|
import { releaseCommits } from './release-commits.ts'
|
|
232
236
|
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
233
|
-
import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
|
|
237
|
+
import type { ReviewChange, ReviewContext, ReviewInput, ReviewedTarballs } from './dependency-reviews.ts'
|
|
234
238
|
import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
|
|
235
239
|
import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
|
|
236
240
|
import { npmProvenance } from './npm-provenance.ts'
|
|
@@ -310,6 +314,7 @@ const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
|
|
|
310
314
|
const NPM_COMPARISON_PATH = '/api/npm/comparison'
|
|
311
315
|
const NPM_RISK_PATH = '/api/npm/risk'
|
|
312
316
|
const NPM_DELTA_PATH = '/api/npm/delta'
|
|
317
|
+
const NPM_DELTAS_PATH = '/api/npm/deltas'
|
|
313
318
|
// What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
|
|
314
319
|
// having no such package or version, or a tarball unpacking past risk's
|
|
315
320
|
// bounds; npm failing otherwise a 502.
|
|
@@ -334,7 +339,7 @@ const VIEW_PREFIXES = [...MANAGED_DATA_PREFIXES, '/api/auth', '/api/oauth']
|
|
|
334
339
|
const underPrefix = (path: string, prefixes: readonly string[]) => prefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))
|
|
335
340
|
|
|
336
341
|
async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
|
|
337
|
-
const s = await readWorkspaceSession(res, deps, cookie)
|
|
342
|
+
const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
|
|
338
343
|
if (!s) return
|
|
339
344
|
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
|
|
340
345
|
const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
|
|
@@ -364,7 +369,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
364
369
|
async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
|
|
365
370
|
cookie: string | undefined, teamId: string, query: URLSearchParams): Promise<void> {
|
|
366
371
|
if (req.method !== 'GET' && req.method !== 'POST') { send405(res, 'GET, POST'); return }
|
|
367
|
-
const s = req.method === 'GET' ? await readWorkspaceSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
|
|
372
|
+
const s = req.method === 'GET' ? await readWorkspaceSession(res, deps, cookie, { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
|
|
368
373
|
if (!s) return
|
|
369
374
|
if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
370
375
|
let body: unknown = Object.fromEntries(query)
|
|
@@ -396,7 +401,7 @@ function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined
|
|
|
396
401
|
// Managers get content activity for their currently accessible team content;
|
|
397
402
|
// even the displayed report context must come from those accessible reports.
|
|
398
403
|
async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
399
|
-
const s = await readManageSession(res, deps, cookie)
|
|
404
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
400
405
|
if (s == null) return
|
|
401
406
|
const page = Number(query.get('page') ?? 1)
|
|
402
407
|
const limit = Number(query.get('limit') ?? 100)
|
|
@@ -440,6 +445,9 @@ export interface ManagedHttpDeps {
|
|
|
440
445
|
prettyCache?: PrettyCache
|
|
441
446
|
// The public npm cache, where risk keeps public packages' assessments (npm-risk.ts).
|
|
442
447
|
npmCacheStorage?: CacheStorage
|
|
448
|
+
// Where the code viewer keeps the default branch's head's directories
|
|
449
|
+
// (repository-code.ts repositoryCodeKept).
|
|
450
|
+
repositoryCodeStorage?: CacheStorage
|
|
443
451
|
uploadStore?: BlobStore
|
|
444
452
|
originGate: OriginGate
|
|
445
453
|
isShuttingDown: () => boolean
|
|
@@ -468,46 +476,50 @@ function firstHeader(v: string | string[] | undefined): string | null {
|
|
|
468
476
|
return null
|
|
469
477
|
}
|
|
470
478
|
|
|
479
|
+
// A user's avatar, drawn on every tile, comment and review by them: kept by
|
|
480
|
+
// the browser five minutes, then shown as kept for a day while the browser
|
|
481
|
+
// asks again behind it, its image named by its ETag, so that an unchanged one
|
|
482
|
+
// answers with none (304). One changed on another's profile shows within
|
|
483
|
+
// minutes; a user's own change, at once, as their page asks for it again
|
|
484
|
+
// (ui/view/github-users.js refreshAvatar).
|
|
485
|
+
const USER_AVATAR_CACHE = 'private, max-age=300, stale-while-revalidate=86400'
|
|
486
|
+
|
|
471
487
|
// `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
sendAvatar(res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
|
|
488
|
+
async function serveAvatar(req: IncomingMessage, res: ServerResponse, avatarStore: AvatarStore, id: string, key = id, cacheControl = USER_AVATAR_CACHE): Promise<void> {
|
|
489
|
+
sendAvatar(req, res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
|
|
475
490
|
}
|
|
476
491
|
|
|
477
|
-
function sendAvatar(res: ServerResponse, avatar: CachedAvatar | null, cacheControl
|
|
492
|
+
function sendAvatar(req: IncomingMessage, res: ServerResponse, avatar: CachedAvatar | null, cacheControl: string): void {
|
|
478
493
|
if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
})
|
|
494
|
+
const etag = `"${createHash('sha256').update(`${avatar.contentType}\0`).update(avatar.bytes).digest('base64url').slice(0, 22)}"`
|
|
495
|
+
const headers = { 'etag': etag, 'cache-control': cacheControl, 'x-content-type-options': 'nosniff' }
|
|
496
|
+
const asked = req.headers['if-none-match']
|
|
497
|
+
if (typeof asked === 'string' && asked.split(',').some(tag => ['*', etag, `W/${etag}`].includes(tag.trim()))) { res.writeHead(304, headers); res.end(); return }
|
|
498
|
+
res.writeHead(200, { ...headers, 'content-type': avatar.contentType, 'content-length': String(avatar.bytes.length) })
|
|
485
499
|
res.end(avatar.bytes)
|
|
486
500
|
}
|
|
487
501
|
|
|
488
502
|
// The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
|
|
489
|
-
// alone: the agents
|
|
490
|
-
//
|
|
491
|
-
// by (GITHUB_AGENTS). Each is fetched from GitHub once and
|
|
503
|
+
// alone: those the app draws agents by (GITHUB_AGENTS), on the Agents tab and
|
|
504
|
+
// wherever their work shows. Each is fetched from GitHub once and
|
|
492
505
|
// kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
|
|
493
506
|
// an account's, `…-0001-<id>`.
|
|
494
|
-
const AGENT_AVATARS: ReadonlySet<string> = new Set(
|
|
495
|
-
.flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
|
|
507
|
+
const AGENT_AVATARS: ReadonlySet<string> = new Set(Object.values(GITHUB_AGENTS).flatMap(agent => 'avatar' in agent ? [agent.avatar] : []))
|
|
496
508
|
const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
|
|
497
509
|
// An agent's mark is a public GitHub image that never changes with anyone's
|
|
498
510
|
// profile, drawn on every tile and comment by it: kept by the browser a day,
|
|
499
511
|
// for it alone.
|
|
500
512
|
const AGENT_AVATAR_CACHE = 'private, max-age=86400'
|
|
501
513
|
|
|
502
|
-
async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
|
|
514
|
+
async function serveAgentAvatar(req: IncomingMessage, res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
|
|
503
515
|
const [, kind, id] = AGENT_AVATAR.exec(avatar)!
|
|
504
516
|
const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
|
|
505
517
|
// Read once where it is kept; read again only past fetching it.
|
|
506
518
|
const kept = await avatarStore.get(key)
|
|
507
|
-
if (kept != null) { sendAvatar(res, kept, AGENT_AVATAR_CACHE); return }
|
|
519
|
+
if (kept != null) { sendAvatar(req, res, kept, AGENT_AVATAR_CACHE); return }
|
|
508
520
|
await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
|
|
509
521
|
.catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
|
|
510
|
-
await serveAvatar(res, avatarStore, key, key, AGENT_AVATAR_CACHE)
|
|
522
|
+
await serveAvatar(req, res, avatarStore, key, key, AGENT_AVATAR_CACHE)
|
|
511
523
|
}
|
|
512
524
|
|
|
513
525
|
const MAX_JSON_BODY_BYTES = 4096
|
|
@@ -547,7 +559,7 @@ async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, sessi
|
|
|
547
559
|
// first use. Only this session's server-side private key opens what is sealed to it.
|
|
548
560
|
async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
549
561
|
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
550
|
-
const s = await readManageSession(res, deps, cookie)
|
|
562
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
551
563
|
if (!s) return
|
|
552
564
|
const key = s.session.uploadKey ?? await deps.db.ensureSessionUploadKey(s.session.id, newUploadKey())
|
|
553
565
|
if (!key) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
@@ -557,7 +569,7 @@ async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
557
569
|
async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
|
|
558
570
|
const cancel = req.method === 'DELETE'
|
|
559
571
|
if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
|
|
560
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
572
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
561
573
|
if (!s || !requireManageRole(res, s.user)) return
|
|
562
574
|
if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
563
575
|
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
|
|
@@ -598,8 +610,8 @@ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES
|
|
|
598
610
|
// Returns the session, or null after having already sent the 401/403. An
|
|
599
611
|
// admin's view as another user writes nothing; `view` admits only the
|
|
600
612
|
// endpoints that end it.
|
|
601
|
-
async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
602
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
613
|
+
async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false, handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
614
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
603
615
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
604
616
|
const csrf = firstHeader(req.headers['x-csrf-token'])
|
|
605
617
|
if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
|
|
@@ -727,7 +739,7 @@ function parseProfileChoice(body: unknown): ProfileChoice | null {
|
|
|
727
739
|
// at least one admin).
|
|
728
740
|
async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
729
741
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
730
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
742
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
731
743
|
if (s == null) return
|
|
732
744
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
733
745
|
let body: unknown
|
|
@@ -757,22 +769,22 @@ function requireManageRole(res: ServerResponse, user: StoredUser): boolean {
|
|
|
757
769
|
// A read endpoint open to admin|manage: resolve the session (401 if absent),
|
|
758
770
|
// then gate on the role (403). Returns the session, or null after the response
|
|
759
771
|
// was already sent. No CSRF — reads aren't mutations (cf. /api/admin/users).
|
|
760
|
-
async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
761
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
772
|
+
async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
773
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
762
774
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
763
775
|
if (!requireManageRole(res, s.user)) return null
|
|
764
776
|
return s
|
|
765
777
|
}
|
|
766
778
|
|
|
767
|
-
async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
768
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
779
|
+
async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
780
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
769
781
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
770
782
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
771
783
|
return s
|
|
772
784
|
}
|
|
773
785
|
|
|
774
|
-
async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
775
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
786
|
+
async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
787
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
776
788
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
777
789
|
if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
778
790
|
return s
|
|
@@ -802,7 +814,7 @@ async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: stri
|
|
|
802
814
|
// GET /api/admin/scan/models — the server's canonical scan model ids and effort
|
|
803
815
|
// levels. Names are intentionally absent; the client derives them from ids.
|
|
804
816
|
async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
805
|
-
const s = await readManageSession(res, deps, cookie)
|
|
817
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
806
818
|
if (s == null) return
|
|
807
819
|
sendJson(res, 200, { models: MANAGED_SCAN_MODELS, defaultModel: DEFAULT_MANAGED_SCAN_MODEL })
|
|
808
820
|
}
|
|
@@ -813,7 +825,7 @@ async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cook
|
|
|
813
825
|
// filtering. Admin-only. No CSRF.
|
|
814
826
|
async function handleListRepositories(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, discovery: RepositoryDiscovery): Promise<void> {
|
|
815
827
|
if ((req.method ?? 'GET') !== 'GET') { send405(res, 'GET'); return }
|
|
816
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
828
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
817
829
|
if (s == null) return
|
|
818
830
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
819
831
|
// Keep the original unparameterized response shape for older clients. The
|
|
@@ -931,7 +943,7 @@ async function connectRepository(res: ServerResponse, deps: ManagedHttpDeps, use
|
|
|
931
943
|
// Authentication, server admin permission, origin and CSRF remain mandatory.
|
|
932
944
|
async function handleAddPublicRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
933
945
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
934
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
946
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
935
947
|
if (s == null) return
|
|
936
948
|
if (!canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id))) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
937
949
|
let body: unknown
|
|
@@ -955,7 +967,7 @@ async function handleAddPublicRepository(req: IncomingMessage, res: ServerRespon
|
|
|
955
967
|
// from the per-user checks required for browsing source.
|
|
956
968
|
async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
957
969
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
958
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
970
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
959
971
|
if (s == null) return
|
|
960
972
|
let body: unknown
|
|
961
973
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -990,10 +1002,12 @@ async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerRespo
|
|
|
990
1002
|
// POST /api/admin/repositories/select — an admin toggles whether a repo is
|
|
991
1003
|
// active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
|
|
992
1004
|
// selected }. selected:true verifies + records the read context; selected:false
|
|
993
|
-
// deactivates the row while retaining its reports, bundles, and metadata
|
|
1005
|
+
// deactivates the row while retaining its reports, bundles, and metadata, but
|
|
1006
|
+
// for the code the code viewer kept of it, which no one reads meanwhile
|
|
1007
|
+
// (removeKeptCode).
|
|
994
1008
|
async function handleSelectRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
995
1009
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
996
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
1010
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
997
1011
|
if (s == null) return
|
|
998
1012
|
let body: unknown
|
|
999
1013
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1006,6 +1020,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
1006
1020
|
const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
|
|
1007
1021
|
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1008
1022
|
await deps.db.deactivateRepo(repoId)
|
|
1023
|
+
await removeKeptCode(deps, repoId)
|
|
1009
1024
|
if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
|
|
1010
1025
|
sendJson(res, 200, { ok: true, selected: false }); return
|
|
1011
1026
|
}
|
|
@@ -1021,7 +1036,7 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
1021
1036
|
// stored kept as it is). Answers the setting then. Admin, CSRF.
|
|
1022
1037
|
async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1023
1038
|
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
1024
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
1039
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
1025
1040
|
if (s == null) return
|
|
1026
1041
|
let body: Record<string, unknown> | null
|
|
1027
1042
|
try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1093,7 +1108,7 @@ async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id
|
|
|
1093
1108
|
// removal would destroy. This is intentionally separate from the repository
|
|
1094
1109
|
// list so the normal page stays cheap even with many reports.
|
|
1095
1110
|
async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1096
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
1111
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
1097
1112
|
if (s == null) return
|
|
1098
1113
|
const repoId = Number(new URL(req.url ?? '/', 'http://localhost').searchParams.get('repoId'))
|
|
1099
1114
|
if (!Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
@@ -1106,7 +1121,7 @@ async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse,
|
|
|
1106
1121
|
// attached reports/bundles. The exact name + explicit acknowledgement are
|
|
1107
1122
|
// checked server-side too; the UI's dialog is a usability guard, not the policy.
|
|
1108
1123
|
async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1109
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
1124
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
1110
1125
|
if (s == null) return
|
|
1111
1126
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
1112
1127
|
let body: unknown
|
|
@@ -1139,9 +1154,17 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
1139
1154
|
await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
1140
1155
|
await deps.bundleStore.delete(bundle.id).catch(() => {})
|
|
1141
1156
|
}
|
|
1157
|
+
await removeKeptCode(deps, repo.repoId)
|
|
1142
1158
|
sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
|
|
1143
1159
|
}
|
|
1144
1160
|
|
|
1161
|
+
// The code the code viewer kept of a repository no longer read, its
|
|
1162
|
+
// directories' files (repositoryCodeKeptOf), removed once its row is: as a
|
|
1163
|
+
// bundle's derivatives are, at best, a failure warned of.
|
|
1164
|
+
async function removeKeptCode(deps: ManagedHttpDeps, repoId: number): Promise<void> {
|
|
1165
|
+
await repositoryCodeKeptOf(deps)?.remove(repoId).catch((err: unknown) => { console.warn('managed: kept repository code delete failed:', err) })
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1145
1168
|
// Strip a client-supplied upload filename to a safe display string. The bytes
|
|
1146
1169
|
// are keyed by a server uuid. This name is used for display, Content-Disposition,
|
|
1147
1170
|
// and format detection: decode URLs, remove controls/paths, and cap the basename
|
|
@@ -1193,7 +1216,7 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
|
|
|
1193
1216
|
// { reportId, repoId, directory }, where repoId is null (detach) or a
|
|
1194
1217
|
// currently-selected repo id. Directory is relative to the repository root.
|
|
1195
1218
|
async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1196
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1219
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1197
1220
|
if (s == null) return
|
|
1198
1221
|
let body: unknown
|
|
1199
1222
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1214,7 +1237,7 @@ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, de
|
|
|
1214
1237
|
// POST /api/admin/reports/set-visible — publish or hide a stored report. New
|
|
1215
1238
|
// reports start hidden so an admin can inspect the parsed metadata first.
|
|
1216
1239
|
async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1217
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1240
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1218
1241
|
if (s == null) return
|
|
1219
1242
|
let body: unknown
|
|
1220
1243
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1227,7 +1250,7 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
|
|
|
1227
1250
|
}
|
|
1228
1251
|
|
|
1229
1252
|
async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1230
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1253
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1231
1254
|
if (s == null) return
|
|
1232
1255
|
let body: unknown
|
|
1233
1256
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1242,7 +1265,7 @@ async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse,
|
|
|
1242
1265
|
// POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
|
|
1243
1266
|
// and directory (same shape as reports). Body { bundleId, repoId, directory }.
|
|
1244
1267
|
async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1245
|
-
const s = await manageMutation(req, res, deps, cookie)
|
|
1268
|
+
const s = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
1246
1269
|
if (s == null) return
|
|
1247
1270
|
let body: unknown
|
|
1248
1271
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -1306,7 +1329,7 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1306
1329
|
// insert failure or concurrent duplicate drops the orphan blob; an uncertain
|
|
1307
1330
|
// commit retains it. 413 over the cap, 400 on empty.
|
|
1308
1331
|
async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1309
|
-
let s = await checkMutation(req, res, deps, cookie)
|
|
1332
|
+
let s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
1310
1333
|
if (s == null) return
|
|
1311
1334
|
if (!requireManageRole(res, s.user)) return
|
|
1312
1335
|
let bytes: Buffer
|
|
@@ -1414,7 +1437,7 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1414
1437
|
// or rendered inline against the app origin. 404 when there's no such report;
|
|
1415
1438
|
// 503 when the row exists but the bytes don't (store desync).
|
|
1416
1439
|
async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1417
|
-
const s = await readManageSession(res, deps, cookie)
|
|
1440
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
1418
1441
|
if (s == null) return
|
|
1419
1442
|
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1420
1443
|
const rec = await deps.db.getReport(id)
|
|
@@ -1437,7 +1460,7 @@ async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cooki
|
|
|
1437
1460
|
// same-origin + CSRF. Drops the row, then best-effort the bytes; 404 when there
|
|
1438
1461
|
// was no such report.
|
|
1439
1462
|
async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1440
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
1463
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
1441
1464
|
if (s == null) return
|
|
1442
1465
|
if (!requireManageRole(res, s.user)) return
|
|
1443
1466
|
const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
|
|
@@ -1490,7 +1513,7 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
|
|
|
1490
1513
|
// Any team member (view or above) lists those their teams reach, for the code
|
|
1491
1514
|
// viewer as for Create a bundle, whose builds stay admin|manage.
|
|
1492
1515
|
async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1493
|
-
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1516
|
+
const s = await readWorkspaceSession(res, deps, cookie, { handed: true })
|
|
1494
1517
|
if (!s) return
|
|
1495
1518
|
sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
|
|
1496
1519
|
}
|
|
@@ -1547,15 +1570,28 @@ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
|
|
|
1547
1570
|
// answered, where it is none of theirs. Every team member (view or above)
|
|
1548
1571
|
// reads the repositories their teams reach; the reader then checks their own
|
|
1549
1572
|
// GitHub read permission (repository-browser.ts), on each read.
|
|
1550
|
-
async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
|
|
1551
|
-
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1573
|
+
async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string, { handed = false } = {}) {
|
|
1574
|
+
const s = await readWorkspaceSession(res, deps, cookie, { handed })
|
|
1552
1575
|
if (!s) return null
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1576
|
+
const access = repositoryAccessIn(await repositoryGrants(deps, s.user), repoId, path)
|
|
1577
|
+
if (!access) { sendJson(res, 404, { error: 'no-repository' }); return null }
|
|
1578
|
+
return { ...access, user: s.user }
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// What a reader's grants let them read of the repositories (bundleRepos's
|
|
1582
|
+
// reads, the reader's scopes read once for both), for repositoryAccessIn to
|
|
1583
|
+
// tell of each: read once for as many repositories as a request asks of.
|
|
1584
|
+
async function repositoryGrants(deps: ManagedHttpDeps, user: StoredUser) {
|
|
1585
|
+
const access = await contentAccess(deps.db, user)
|
|
1586
|
+
return { access, repos: (await deps.db.listSelectedRepos()).filter(access.bundle) }
|
|
1587
|
+
}
|
|
1588
|
+
// Repository `repoId` as `grants` let it be read at `path`, as
|
|
1589
|
+
// authorizeRepository answers it; null where it is none of the reader's.
|
|
1590
|
+
function repositoryAccessIn({ access, repos }: Awaited<ReturnType<typeof repositoryGrants>>, repoId: number, path: string) {
|
|
1591
|
+
const repo = repos.find(item => item.repoId === repoId)
|
|
1592
|
+
if (!repo) return null
|
|
1557
1593
|
const scopes = access.scopes == null ? [null] : access.scopes.filter(scope => scope.repoId === repoId).map(scope => scope.path)
|
|
1558
|
-
return { repo, virtualEntries: scopedDirectory(path, scopes)
|
|
1594
|
+
return { repo, virtualEntries: scopedDirectory(path, scopes) }
|
|
1559
1595
|
}
|
|
1560
1596
|
|
|
1561
1597
|
// The repository as it was authorized, but for the shared default branch hint,
|
|
@@ -1575,12 +1611,12 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1575
1611
|
const { repoId, ref } = asked
|
|
1576
1612
|
const withDefault = refs && query.get('withDefault') === 'true'
|
|
1577
1613
|
const path = refs ? '' : asked.path
|
|
1578
|
-
const authorize = async () => {
|
|
1579
|
-
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1614
|
+
const authorize = async (handed = false) => {
|
|
1615
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
|
|
1580
1616
|
if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1581
1617
|
return access
|
|
1582
1618
|
}
|
|
1583
|
-
const access = await authorize()
|
|
1619
|
+
const access = await authorize(true)
|
|
1584
1620
|
if (!access) return
|
|
1585
1621
|
try {
|
|
1586
1622
|
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
|
|
@@ -1590,8 +1626,9 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1590
1626
|
const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
|
|
1591
1627
|
const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
|
|
1592
1628
|
const { tagCommits, tagsComplete, ...listed } = revisions ?? { tagCommits: [], tagsComplete: false }
|
|
1593
|
-
// Create a bundle
|
|
1594
|
-
//
|
|
1629
|
+
// Every refs read (Create a bundle's, and the code viewer's) refreshes the
|
|
1630
|
+
// tags bundle catalogs show, and a tag read keeps the one its revision
|
|
1631
|
+
// input resolves even when the listed page leaves it out.
|
|
1595
1632
|
// Like other GitHub caches, written before the access fence below.
|
|
1596
1633
|
const tags = refs ? tagCommits : ref.length > 5 && ref.startsWith('tags/') && contents ? [{ name: ref.slice(5), sha: contents.commit }] : []
|
|
1597
1634
|
if (refs || tags.length > 0) {
|
|
@@ -1619,45 +1656,78 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1619
1656
|
// tarball is asked for; the reader's access is checked again once it is
|
|
1620
1657
|
// read, before any of it is sent. The load stays held until this response is
|
|
1621
1658
|
// written or abandoned, and a reader who stops reading it gives it up after
|
|
1622
|
-
// a while.
|
|
1659
|
+
// a while. A directory of the default branch's head is kept in byte storage
|
|
1660
|
+
// as it was sent, for the next to open it (repositoryCodeKeptOf).
|
|
1623
1661
|
// What GitHub answered of a repository's trees for the code viewer, which a
|
|
1624
1662
|
// commit or tree it names never changes: the tree at a directory of a commit
|
|
1625
1663
|
// (getRepoTreeId reads the commit, then a listing a level down to it), and
|
|
1626
1664
|
// how many files a tree holds under it and their bytes (repositoryTreeFiles),
|
|
1627
1665
|
// by the repository's id, for the next to open it. Asked of them only once
|
|
1628
1666
|
// the reader's own access has named the commit, as GitHub would have
|
|
1629
|
-
// answered them; by
|
|
1667
|
+
// answered them; held by each instance a while, by the database it has, and
|
|
1668
|
+
// kept in that database for every instance (npm-kept.ts codeTreeStore).
|
|
1630
1669
|
const REPOSITORY_TREES_KEPT = 4096
|
|
1631
1670
|
type TreeFiles = Awaited<ReturnType<typeof repositoryTreeFiles>>
|
|
1632
1671
|
const repositoryTrees = new WeakMap<ManagedDb, { ids: Lru<string>; files: Lru<TreeFiles> }>()
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1672
|
+
const treeId = (value: unknown): value is string => typeof value === 'string' && /^[a-f\d]{40}$/u.test(value)
|
|
1673
|
+
const treeFiles = (value: unknown): value is TreeFiles => value === null || (plainObject(value)
|
|
1674
|
+
&& Number.isSafeInteger(value['files']) && (value['files'] as number) >= 0 && Number.isSafeInteger(value['bytes']) && (value['bytes'] as number) >= 0)
|
|
1675
|
+
function repositoryTreesOf(deps: ManagedHttpDeps) {
|
|
1676
|
+
let held = repositoryTrees.get(deps.db)
|
|
1677
|
+
if (!held) repositoryTrees.set(deps.db, held = { ids: lru(REPOSITORY_TREES_KEPT), files: lru(REPOSITORY_TREES_KEPT) })
|
|
1678
|
+
const { ids, files } = held
|
|
1679
|
+
return {
|
|
1680
|
+
id: (key: string, read: () => Promise<string>) => keptTree(ids, codeTreeStore<string>(deps.db, 'trees', deps.config.debug), key, treeId, read),
|
|
1681
|
+
files: (key: string, read: () => Promise<TreeFiles>) => keptTree(files, codeTreeStore<TreeFiles>(deps.db, 'tree-files', deps.config.debug), key, treeFiles, read),
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
// What `memory` holds by `key`, else what `store` keeps of it as `kept`
|
|
1685
|
+
// takes it, else what `read` answers, then held and kept.
|
|
1686
|
+
async function keptTree<T>(memory: Lru<T>, store: KeptStore<T>, key: string, kept: (value: unknown) => value is T, read: () => Promise<T>): Promise<T> {
|
|
1687
|
+
const held = memory.get(key)
|
|
1688
|
+
if (held !== undefined) return held
|
|
1689
|
+
const stored = await store.read(key)
|
|
1690
|
+
let value: T
|
|
1691
|
+
if (stored && kept(stored.value)) value = stored.value
|
|
1692
|
+
else {
|
|
1693
|
+
value = await read()
|
|
1694
|
+
await store.write(key, value, Date.now())
|
|
1695
|
+
}
|
|
1696
|
+
memory.set(key, value)
|
|
1697
|
+
return value
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// The default branch's head's directories as kept in byte storage
|
|
1701
|
+
// (repository-code.ts repositoryCodeKept), the commit they are kept at in
|
|
1702
|
+
// the database, for a repository active as it is selected; none where this
|
|
1703
|
+
// server has no byte storage for them. Work behind an answer is tracked
|
|
1704
|
+
// (serverless, its invocation kept alive till it settles), as a kept read
|
|
1705
|
+
// read again behind one is.
|
|
1706
|
+
function repositoryCodeKeptOf(deps: ManagedHttpDeps): RepositoryCodeKept | null {
|
|
1707
|
+
if (!deps.repositoryCodeStorage) return null
|
|
1708
|
+
const active = async (repoId: number) => (await deps.db.listSelectedRepos()).some(row => row.repoId === repoId)
|
|
1709
|
+
return repositoryCodeKept(deps.repositoryCodeStorage, codeHeadStore(deps.db, deps.config.debug), active, work => {
|
|
1710
|
+
deps.track(work)
|
|
1711
|
+
deps.waitUntil?.(work)
|
|
1712
|
+
}, deps.config.debug)
|
|
1637
1713
|
}
|
|
1638
1714
|
|
|
1639
1715
|
async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1640
1716
|
const asked = repositoryQuery(res, query)
|
|
1641
1717
|
if (!asked) return
|
|
1642
1718
|
const { repoId, ref, path } = asked
|
|
1643
|
-
const authorize = async () => {
|
|
1644
|
-
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1719
|
+
const authorize = async (handed = false) => {
|
|
1720
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
|
|
1645
1721
|
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1646
1722
|
return access
|
|
1647
1723
|
}
|
|
1648
|
-
const access = await authorize()
|
|
1724
|
+
const access = await authorize(true)
|
|
1649
1725
|
if (!access) return
|
|
1650
1726
|
try {
|
|
1651
1727
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1652
1728
|
const commit = await reader.commit(ref)
|
|
1653
1729
|
const repo = access.repo.fullName
|
|
1654
1730
|
const client = createClient({ token: reader.readToken() })
|
|
1655
|
-
const treeKey = `${access.repo.repoId}\0${commit}\0${path}`, trees = repositoryTreesOf(deps.db)
|
|
1656
|
-
// A directory the commit has none at is none to read, as browsing it says.
|
|
1657
|
-
const tree = trees.ids.get(treeKey) ?? await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1658
|
-
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
|
|
1659
|
-
}))
|
|
1660
|
-
trees.ids.set(treeKey, tree)
|
|
1661
1731
|
// Access still held, and the same repository, before anything read is sent.
|
|
1662
1732
|
const fenced = async () => {
|
|
1663
1733
|
await reader.recheckAccess()
|
|
@@ -1666,25 +1736,54 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1666
1736
|
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
|
|
1667
1737
|
return true
|
|
1668
1738
|
}
|
|
1739
|
+
const send = (bytes: Buffer) => {
|
|
1740
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
1741
|
+
res.writeHead(200, {
|
|
1742
|
+
'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
|
|
1743
|
+
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
1744
|
+
})
|
|
1745
|
+
writeResponse(res, bytes)
|
|
1746
|
+
}
|
|
1747
|
+
// The default branch's head's directory as kept, once the reader's own
|
|
1748
|
+
// access has named its commit, sent as one read is, access checked again;
|
|
1749
|
+
// its place among the active loads held till it is written or abandoned,
|
|
1750
|
+
// else given up before a read from GitHub asks for its own.
|
|
1751
|
+
const at = { repoId, repo, commit, path }, kept = repositoryCodeKeptOf(deps)
|
|
1752
|
+
const atHead = kept !== null && await kept.at(at, ref, reader.defaultBranch)
|
|
1753
|
+
if (atHead) {
|
|
1754
|
+
const keptLoad = kept.read(at)
|
|
1755
|
+
res.once('close', keptLoad.release)
|
|
1756
|
+
const held = await keptLoad.result
|
|
1757
|
+
if (held) {
|
|
1758
|
+
if (await fenced() && !res.destroyed) send(held)
|
|
1759
|
+
return
|
|
1760
|
+
}
|
|
1761
|
+
keptLoad.release()
|
|
1762
|
+
}
|
|
1763
|
+
const trees = repositoryTreesOf(deps)
|
|
1764
|
+
// A directory the commit has none at is none to read, as browsing it says.
|
|
1765
|
+
const tree = await trees.id(`${repoId}\0${commit}\0${path}`, () => upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1766
|
+
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
|
|
1767
|
+
})))
|
|
1669
1768
|
// Too large names the commit it was read at, for History to read.
|
|
1670
|
-
const
|
|
1671
|
-
let listed = trees.files.get(filesKey)
|
|
1672
|
-
if (listed === undefined) trees.files.set(filesKey, listed = await repositoryTreeFiles(repo, reader.readToken(), tree))
|
|
1769
|
+
const listed = await trees.files(`${repoId}\0${tree}`, () => repositoryTreeFiles(repo, reader.readToken(), tree))
|
|
1673
1770
|
if (!repositoryCodeFits(listed)) {
|
|
1674
1771
|
if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
|
|
1675
1772
|
return
|
|
1676
1773
|
}
|
|
1677
1774
|
if (res.destroyed) return
|
|
1678
|
-
|
|
1775
|
+
// A tarball upstream finds no tree in, as @preventive/archive refuses
|
|
1776
|
+
// one with a symlink past the repository's root, is one the viewer
|
|
1777
|
+
// cannot read; one holding another tree is GitHub's answer gone wrong.
|
|
1778
|
+
const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree, listed }, symlinksAbove => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree, symlinksAbove }).catch((err: unknown) => {
|
|
1779
|
+
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(', got no tree: ') ? new GithubApiError(422, 'directory-unreadable') : err
|
|
1780
|
+
})))
|
|
1679
1781
|
res.once('close', load.release)
|
|
1680
1782
|
const bytes = await load.result
|
|
1783
|
+
// Like other GitHub caches, kept before the access fence below.
|
|
1784
|
+
if (atHead) kept.keep(at, bytes)
|
|
1681
1785
|
if (!(await fenced()) || res.destroyed) return
|
|
1682
|
-
|
|
1683
|
-
res.writeHead(200, {
|
|
1684
|
-
'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
|
|
1685
|
-
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
1686
|
-
})
|
|
1687
|
-
writeResponse(res, bytes)
|
|
1786
|
+
send(bytes)
|
|
1688
1787
|
} catch (err) {
|
|
1689
1788
|
if (res.headersSent || res.destroyed) return
|
|
1690
1789
|
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
@@ -1721,12 +1820,12 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1721
1820
|
const starts = from === null ? null : historyStarts(from)
|
|
1722
1821
|
if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
|
|
1723
1822
|
|| (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1724
|
-
const authorize = async () => {
|
|
1725
|
-
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1823
|
+
const authorize = async (handed = false) => {
|
|
1824
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
|
|
1726
1825
|
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1727
1826
|
return access
|
|
1728
1827
|
}
|
|
1729
|
-
const access = await authorize()
|
|
1828
|
+
const access = await authorize(true)
|
|
1730
1829
|
if (!access) return
|
|
1731
1830
|
let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
|
|
1732
1831
|
try {
|
|
@@ -1776,12 +1875,24 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1776
1875
|
// The pull requests squashed into a whole repository's commits, drawn as
|
|
1777
1876
|
// the merges they were, and on its first page the open ones' heads,
|
|
1778
1877
|
// naming commits: with a token, as far as GitHub answers them, the
|
|
1779
|
-
// history sent all the same where it doesn't.
|
|
1878
|
+
// history sent all the same where it doesn't. Those GitHub is still
|
|
1879
|
+
// looking up SQUASH_WAIT_MS on are left to the lookup, kept alive behind
|
|
1880
|
+
// the answer, as a kept answer's read is (`pullsPending`, for the client
|
|
1881
|
+
// to ask again).
|
|
1780
1882
|
const listed = (body as { commits: HistoryCommit[] }).commits
|
|
1781
|
-
const
|
|
1782
|
-
|
|
1883
|
+
const none = { pulls: [] as SquashedPull[], pending: false }
|
|
1884
|
+
const [squashed, openPulls] = token && path === '' ? await Promise.all([
|
|
1885
|
+
squashedPulls(repo, token, listed, historySquashes(deps, repoId), SQUASH_WAIT_MS).then(read => {
|
|
1886
|
+
if (read.behind) {
|
|
1887
|
+
const behind = read.behind.catch(quiet(null))
|
|
1888
|
+
deps.track(behind)
|
|
1889
|
+
deps.waitUntil?.(behind)
|
|
1890
|
+
}
|
|
1891
|
+
return read
|
|
1892
|
+
}, quiet(none)),
|
|
1783
1893
|
readingPulls,
|
|
1784
|
-
]) : [
|
|
1894
|
+
]) : [none, null]
|
|
1895
|
+
const { pulls } = squashed
|
|
1785
1896
|
await reader.recheckAccess()
|
|
1786
1897
|
const current = await authorize()
|
|
1787
1898
|
if (!current) return
|
|
@@ -1791,7 +1902,8 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1791
1902
|
const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
|
|
1792
1903
|
({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
|
|
1793
1904
|
sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
|
|
1794
|
-
...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...
|
|
1905
|
+
...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...squashed.pending ? { pullsPending: true } : {},
|
|
1906
|
+
...openPulls?.length ? { openPulls } : {} }))
|
|
1795
1907
|
} catch (err) {
|
|
1796
1908
|
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1797
1909
|
throw err
|
|
@@ -1845,12 +1957,12 @@ async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie:
|
|
|
1845
1957
|
const asked = repositoryQuery(res, query)
|
|
1846
1958
|
if (!asked) return
|
|
1847
1959
|
const { repoId, path } = asked
|
|
1848
|
-
const authorize = async () => {
|
|
1849
|
-
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1960
|
+
const authorize = async (handed = false) => {
|
|
1961
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
|
|
1850
1962
|
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1851
1963
|
return access
|
|
1852
1964
|
}
|
|
1853
|
-
const access = await authorize()
|
|
1965
|
+
const access = await authorize(true)
|
|
1854
1966
|
if (!access) return
|
|
1855
1967
|
try {
|
|
1856
1968
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
@@ -1887,6 +1999,9 @@ function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
|
|
|
1887
1999
|
// and how long one read is answered as kept even where it is asked for afresh.
|
|
1888
2000
|
const REPOSITORY_READ_FRESH_MS = 2 * 60_000
|
|
1889
2001
|
const REPOSITORY_READ_FLOOR_MS = 10_000
|
|
2002
|
+
// How long a history's answer waits on GitHub looking up the pull requests
|
|
2003
|
+
// squashed into its commits (handleRepositoryHistory).
|
|
2004
|
+
const SQUASH_WAIT_MS = 1500
|
|
1890
2005
|
// The repositories' reads being read again, behind a kept answer or as asked
|
|
1891
2006
|
// for afresh, by key: one at a time, those asking meanwhile answered by it.
|
|
1892
2007
|
const repositoryRereads = new Map<string, Promise<unknown>>()
|
|
@@ -1980,7 +2095,7 @@ function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): H
|
|
|
1980
2095
|
|
|
1981
2096
|
// The pull requests squashed into repository `repoId`'s commits, kept by
|
|
1982
2097
|
// each commit and the rules their commits' co-authors were read by
|
|
1983
|
-
// (COAUTHORS_READ), null for one none was (repository-
|
|
2098
|
+
// (COAUTHORS_READ), null for one none was (repository-squashes.ts
|
|
1984
2099
|
// squashedPulls); one kept before its commits' co-authors were read, read
|
|
1985
2100
|
// again.
|
|
1986
2101
|
function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
|
|
@@ -2023,28 +2138,55 @@ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, c
|
|
|
2023
2138
|
async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
|
|
2024
2139
|
answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
|
|
2025
2140
|
unread: () => Promise<object | null> = () => Promise.resolve(null)): Promise<void> {
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
2029
|
-
return access
|
|
2030
|
-
}
|
|
2031
|
-
const access = await authorize()
|
|
2032
|
-
if (!access) return
|
|
2141
|
+
const access = await wholeRepositoryAccess(deps, cookie, repoId, { handed: true })
|
|
2142
|
+
if ('error' in access) { sendRefusal(res, access); return }
|
|
2033
2143
|
const told = await unread()
|
|
2034
2144
|
if (told) { sendJson(res, 200, await withAppUsers(deps.db, told)); return }
|
|
2145
|
+
const read = await readWholeRepositoryOf(deps, access, answer, () => wholeRepositoryAccess(deps, cookie, repoId))
|
|
2146
|
+
if ('error' in read) { sendRefusal(res, read); return }
|
|
2147
|
+
sendJson(res, 200, await withAppUsers(deps.db, read.value))
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// What a request is refused, as its answer tells it.
|
|
2151
|
+
type Refusal = { status: number; error: string; retryAfter?: number }
|
|
2152
|
+
const refusal = (status: number, error: string, retryAfter?: number | null): Refusal => ({ status, error, ...retryAfter == null ? {} : { retryAfter } })
|
|
2153
|
+
function sendRefusal(res: ServerResponse, { status, error, retryAfter }: Refusal) {
|
|
2154
|
+
sendJson(res, status, { error }, retryAfter == null ? {} : { 'retry-after': String(retryAfter) })
|
|
2155
|
+
}
|
|
2156
|
+
// The whole of repository `repoId`, where the session `cookie` names may
|
|
2157
|
+
// read all of it, and who reads it; else why not: none of it
|
|
2158
|
+
// (`no-repository`), or only some (`no-directory`).
|
|
2159
|
+
type WholeAccess = { repo: SelectedRepo; user: StoredUser }
|
|
2160
|
+
async function wholeRepositoryAccess(deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, { handed = false } = {}): Promise<WholeAccess | Refusal> {
|
|
2161
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
2162
|
+
if (s == null) return refusal(401, 'unauthenticated')
|
|
2163
|
+
if (!roleAtLeast(s.user.role, 'view')) return refusal(403, 'forbidden')
|
|
2164
|
+
return wholeAccessIn(await repositoryGrants(deps, s.user), s.user, repoId)
|
|
2165
|
+
}
|
|
2166
|
+
function wholeAccessIn(grants: Awaited<ReturnType<typeof repositoryGrants>>, user: StoredUser, repoId: number): WholeAccess | Refusal {
|
|
2167
|
+
const access = repositoryAccessIn(grants, repoId, '')
|
|
2168
|
+
if (!access) return refusal(404, 'no-repository')
|
|
2169
|
+
if (access.virtualEntries !== null) return refusal(404, 'no-directory')
|
|
2170
|
+
return { repo: access.repo, user }
|
|
2171
|
+
}
|
|
2172
|
+
// What `answer` reads of the whole of the repository `access` lets its
|
|
2173
|
+
// reader read, with their own GitHub access: as kept, else with that access
|
|
2174
|
+
// and `reauthorize`'s checked again after; else why it was refused.
|
|
2175
|
+
async function readWholeRepositoryOf<T>(deps: ManagedHttpDeps, access: WholeAccess, answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: T; kept: boolean }>,
|
|
2176
|
+
reauthorize: () => Promise<WholeAccess | Refusal>): Promise<{ value: T; kept: boolean } | Refusal> {
|
|
2035
2177
|
try {
|
|
2036
2178
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
2037
2179
|
const token = reader.readToken()
|
|
2038
|
-
if (!token)
|
|
2180
|
+
if (!token) return refusal(403, 'github-token-required')
|
|
2039
2181
|
const answered = await answer(access.repo.fullName, token, access.user)
|
|
2040
|
-
if (answered.kept)
|
|
2182
|
+
if (answered.kept) return answered
|
|
2041
2183
|
await reader.recheckAccess()
|
|
2042
|
-
const current = await
|
|
2043
|
-
if (
|
|
2044
|
-
if (!sameRepository(current.repo, access.repo))
|
|
2045
|
-
|
|
2184
|
+
const current = await reauthorize()
|
|
2185
|
+
if ('error' in current) return current
|
|
2186
|
+
if (!sameRepository(current.repo, access.repo)) return refusal(409, 'repository-changed')
|
|
2187
|
+
return answered
|
|
2046
2188
|
} catch (err) {
|
|
2047
|
-
if (err instanceof GithubApiError)
|
|
2189
|
+
if (err instanceof GithubApiError) return refusal(err.status === 401 ? 502 : err.status, err.message, err.retryAfter)
|
|
2048
2190
|
throw err
|
|
2049
2191
|
}
|
|
2050
2192
|
}
|
|
@@ -2052,7 +2194,9 @@ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
2052
2194
|
// GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
|
|
2053
2195
|
// requests tabs show (repository-issues.ts): `kind`'s open or closed
|
|
2054
2196
|
// (`state`), a page at a time, on from the cursor `after` names. They are
|
|
2055
|
-
// the whole repository's
|
|
2197
|
+
// the whole repository's, read as readWholeRepository reads them, kept with
|
|
2198
|
+
// open pull requests' commits, which they never tell (teamOpenPulls reads
|
|
2199
|
+
// them).
|
|
2056
2200
|
async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2057
2201
|
const repoId = Number(query.get('repoId'))
|
|
2058
2202
|
const kind = query.get('kind')
|
|
@@ -2060,8 +2204,11 @@ async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
2060
2204
|
const after = query.get('after')
|
|
2061
2205
|
if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
|
|
2062
2206
|
|| (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2063
|
-
await
|
|
2064
|
-
|
|
2207
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2208
|
+
const { value: { commits: _commits, ...page }, kept, at } = await keptAnswer(deps, `${repoId}\0issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`,
|
|
2209
|
+
async () => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }))
|
|
2210
|
+
return { value: { ...page, readAt: at }, kept }
|
|
2211
|
+
})
|
|
2065
2212
|
}
|
|
2066
2213
|
|
|
2067
2214
|
// GET /api/admin/repositories/agents: what the code viewer's Agents tab
|
|
@@ -2132,19 +2279,23 @@ async function handleRepositoryPullDependencies(res: ServerResponse, deps: Manag
|
|
|
2132
2279
|
}
|
|
2133
2280
|
|
|
2134
2281
|
// Pull request `number`'s answer, as the endpoint gives it, and whether all
|
|
2135
|
-
// of it was kept, GitHub asked for none of it
|
|
2136
|
-
|
|
2282
|
+
// of it was kept, GitHub asked for none of it: its base's and head's commits
|
|
2283
|
+
// as a page of its repository's open pull requests read them (`commits`,
|
|
2284
|
+
// kept and read again as that page is), else as kept by its number.
|
|
2285
|
+
async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, number: number, review: boolean,
|
|
2286
|
+
commits?: PullCommits) {
|
|
2137
2287
|
const kept = pullDependencyKept(deps, repoId)
|
|
2138
2288
|
const reading: { told?: PullDependencies } = {}
|
|
2139
|
-
const answered =
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2289
|
+
const answered = commits ? { value: { commits: `${commits.base}...${commits.head}` }, kept: true }
|
|
2290
|
+
: await keptAnswer(deps, `${repoId}\0pull-commits\0${PULL_DEPENDENCIES_READ}\0${number}`, async () => {
|
|
2291
|
+
const read = await pullDependenciesAt(fullName, token, number, kept)
|
|
2292
|
+
reading.told = read.told
|
|
2293
|
+
return { commits: read.commits }
|
|
2294
|
+
})
|
|
2144
2295
|
// What its commits tell, as read, else as kept by them, else, no longer
|
|
2145
|
-
// kept, read again.
|
|
2296
|
+
// kept, read again: by those given, without asking GitHub for them.
|
|
2146
2297
|
const held = reading.told ?? (typeof answered.value.commits === 'string' ? await kept.read(answered.value.commits) : null)
|
|
2147
|
-
const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept)).told }
|
|
2298
|
+
const pulled = { number, ...held ?? (await pullDependenciesAt(fullName, token, number, kept, undefined, commits)).told }
|
|
2148
2299
|
// Where its review is on, those of lockfiles it leaves out told to none,
|
|
2149
2300
|
// whoever asks.
|
|
2150
2301
|
const setting = await deps.db.repoDependencyReview(repoId)
|
|
@@ -2155,7 +2306,8 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
|
|
|
2155
2306
|
const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
|
|
2156
2307
|
const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
|
|
2157
2308
|
const reviewed = deps.reviewedDependencies ?? null
|
|
2158
|
-
const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)
|
|
2309
|
+
const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps), lockfileKept(deps, repoId))
|
|
2310
|
+
: { baseline: null, unread: [], error: null }
|
|
2159
2311
|
// Its comments read for reviews, where the host reads them, while it is
|
|
2160
2312
|
// open (pull-comment-reviews.ts reads whether it is with them).
|
|
2161
2313
|
const parse = reviewed?.parseComments
|
|
@@ -2176,32 +2328,40 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
|
|
|
2176
2328
|
// a pull request's others are (pull-dependency-reviews.ts), each left unread
|
|
2177
2329
|
// a directory of its own failing the review, as a pull request's are. Kept,
|
|
2178
2330
|
// its access not asked again, where its head was kept and its lockfiles and
|
|
2179
|
-
// baseline held, GitHub asked for none of them.
|
|
2331
|
+
// baseline held or kept (lockfileKept), GitHub asked for none of them.
|
|
2180
2332
|
async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2181
2333
|
const repoId = Number(query.get('repoId'))
|
|
2182
2334
|
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2183
|
-
await answerWholeRepository(res, deps, cookie, repoId,
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2335
|
+
await answerWholeRepository(res, deps, cookie, repoId, (fullName, token, user) => branchDependencyAnswer(deps, fullName, token, user, repoId),
|
|
2336
|
+
async () => (await deps.db.repoDependencyReview(repoId))?.enabled ? null : { review: null })
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
// The default branch's answer, as GET branch-dependencies gives it, and
|
|
2340
|
+
// whether all of it was kept, GitHub asked for none of it; its review's
|
|
2341
|
+
// setting as read already (`known`), where it was.
|
|
2342
|
+
async function branchDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number,
|
|
2343
|
+
known?: RepoDependencyReview | null): Promise<{ value: object; kept: boolean }> {
|
|
2344
|
+
const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
|
|
2345
|
+
const head = headRead.value.sha
|
|
2346
|
+
if (!head) return { value: { head: null, review: null }, kept: false }
|
|
2347
|
+
const asked: GithubAsked = { github: !headRead.kept }
|
|
2348
|
+
// Those its review holds to alone read, as many as are read of them.
|
|
2349
|
+
const setting = known === undefined ? await deps.db.repoDependencyReview(repoId) : known
|
|
2350
|
+
if (!setting?.enabled) return { value: { head, review: null }, kept: false }
|
|
2351
|
+
const reads = lockfileKept(deps, repoId)
|
|
2352
|
+
const read = await lockedAt(fullName, token, head, reads, path => heldTo(setting.lockfiles, path), asked)
|
|
2353
|
+
const { packages, paths } = lockedIn(read)
|
|
2354
|
+
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) },
|
|
2355
|
+
...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
|
|
2356
|
+
const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
|
|
2357
|
+
if (!now) return { value: { head, review: null }, kept: !asked.github }
|
|
2358
|
+
// Told by the lockfiles it held to as they were read, though they have
|
|
2359
|
+
// changed since.
|
|
2360
|
+
const state = { ...now, lockfiles: setting.lockfiles }
|
|
2361
|
+
const reviewed = deps.reviewedDependencies ?? null
|
|
2362
|
+
const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
|
|
2363
|
+
: await baselineFor(fullName, token, repoId, state, baselineKept(deps), reads, asked)
|
|
2364
|
+
return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: !asked.github }
|
|
2205
2365
|
}
|
|
2206
2366
|
|
|
2207
2367
|
// GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
|
|
@@ -2213,7 +2373,195 @@ async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpD
|
|
|
2213
2373
|
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2214
2374
|
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2215
2375
|
const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
|
|
2216
|
-
return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head)).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
|
|
2376
|
+
return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head, lockfileKept(deps, repoId))).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
|
|
2377
|
+
})
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// GET /api/teams/:id/dependencies: what the team's Dependencies tab shows
|
|
2381
|
+
// (team-dependencies.js), in one request rather than one a repository, a
|
|
2382
|
+
// page of its pull requests and a batch of their dependencies: of each of
|
|
2383
|
+
// the team's repositories (those `repoIds` names alone, where it names
|
|
2384
|
+
// some), as lines of JSON as each is read, as the endpoints of each read
|
|
2385
|
+
// them: its default branch's dependencies and its open pull requests
|
|
2386
|
+
// (`{ repoId, branch, pulls: { items, more } }`) where its review is in
|
|
2387
|
+
// effect for the reader, else `{ repoId, branch: null }`, as for one they
|
|
2388
|
+
// may read only some of, or `{ repoId, error, status }`; then its pull
|
|
2389
|
+
// requests' dependencies, of those changing a lockfile or that may,
|
|
2390
|
+
// TEAM_PULLS_TOGETHER at a time (`{ repoId, dependencies }`); each line of
|
|
2391
|
+
// dependencies with the lines their versions wanting reviews change, where
|
|
2392
|
+
// they are counted already (`deltas`, npm-delta.ts keptNpmDeltas), told
|
|
2393
|
+
// before the rows asking for them are drawn. Last, `{ done: true
|
|
2394
|
+
// }`, or, its time up, `{ done: false, unread }`, the repositories left for
|
|
2395
|
+
// the reader to ask for again. The team's repositories, the reader's grants
|
|
2396
|
+
// and the repositories' reviews read once, those reviewing none answered at
|
|
2397
|
+
// once, GitHub asked nothing of them; each read with GitHub has its access
|
|
2398
|
+
// checked again after (readWholeRepositoryOf), and the reader's session
|
|
2399
|
+
// before each line, their team and grants before each telling a
|
|
2400
|
+
// repository's.
|
|
2401
|
+
const TEAM_REPOSITORIES_AT_ONCE = 4
|
|
2402
|
+
const TEAM_DEPENDENCIES_MS = 240_000
|
|
2403
|
+
const TEAM_PULL_PAGES = 20
|
|
2404
|
+
const TEAM_PULLS_TOGETHER = 20
|
|
2405
|
+
const PARTIAL_ACCESS = new Set(['no-directory', 'no-repository'])
|
|
2406
|
+
type TeamDependencyLine = Record<string, unknown> & { repoId?: number }
|
|
2407
|
+
type TeamPull = { number: number; touchesLockfile?: boolean | null }
|
|
2408
|
+
async function handleTeamDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, s: { session: ManagedSession; user: StoredUser },
|
|
2409
|
+
teamId: string, query: URLSearchParams): Promise<void> {
|
|
2410
|
+
const deadline = Date.now() + TEAM_DEPENDENCIES_MS
|
|
2411
|
+
const named = query.get('repoIds')
|
|
2412
|
+
const only = named === null ? null : new Set(named.split(',').map(Number))
|
|
2413
|
+
if (only && (only.size === 0 || only.size > 1000 || [...only].some(id => !Number.isSafeInteger(id) || id <= 0))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2414
|
+
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
|
|
2415
|
+
const repoIds = [...new Set(snapshot.repositories.map(repo => repo.repoId))].filter(id => !only || only.has(id))
|
|
2416
|
+
const grants = await repositoryGrants(deps, s.user)
|
|
2417
|
+
const settings = await deps.db.getRepoDependencyReviews(repoIds)
|
|
2418
|
+
const out = openLines(res)
|
|
2419
|
+
let stopped = false
|
|
2420
|
+
// Lines written once their reader still reads them as at the start, the
|
|
2421
|
+
// stream running minutes and a kept answer asking nothing again: their
|
|
2422
|
+
// session theirs, and, where a line tells what a repository holds, the
|
|
2423
|
+
// team still theirs and holding it, their grants still reaching the whole
|
|
2424
|
+
// of it. None, and the answer cut short, where not, or the reader is gone.
|
|
2425
|
+
const send = async (...lines: TeamDependencyLine[]) => {
|
|
2426
|
+
if (stopped) return false
|
|
2427
|
+
const now = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2428
|
+
if (now?.user.id !== s.user.id || !roleAtLeast(now.user.role, 'view')) stopped = true
|
|
2429
|
+
const telling = lines.filter(line => line.repoId !== undefined && (line['branch'] || line['dependencies'])).map(line => line.repoId!)
|
|
2430
|
+
if (!stopped && telling.length > 0 && !await stillReads(now!.user, telling)) stopped = true
|
|
2431
|
+
for (const line of lines) if (stopped || !await out.write(line)) stopped = true
|
|
2432
|
+
if (stopped) out.cut()
|
|
2433
|
+
return !stopped
|
|
2434
|
+
}
|
|
2435
|
+
const stillReads = async (user: StoredUser, ids: number[]) => {
|
|
2436
|
+
const team = await teamSnapshot(deps.db, s.session.id, teamId).catch((err: unknown) => {
|
|
2437
|
+
if (err instanceof TeamReportsError) return null
|
|
2438
|
+
throw err
|
|
2439
|
+
})
|
|
2440
|
+
if (!team) return false
|
|
2441
|
+
const current = await repositoryGrants(deps, user)
|
|
2442
|
+
return ids.every(id => team.repositories.some(repo => repo.repoId === id) && !('error' in wholeAccessIn(current, user, id)))
|
|
2443
|
+
}
|
|
2444
|
+
const reading: { access: WholeAccess; setting: RepoDependencyReview }[] = []
|
|
2445
|
+
const answered: TeamDependencyLine[] = []
|
|
2446
|
+
for (const repoId of repoIds) {
|
|
2447
|
+
const access = wholeAccessIn(grants, s.user, repoId), setting = settings.get(repoId)
|
|
2448
|
+
if ('error' in access || !setting?.enabled) answered.push({ repoId, branch: null })
|
|
2449
|
+
else reading.push({ access, setting })
|
|
2450
|
+
}
|
|
2451
|
+
if (answered.length > 0 && !await send(...answered)) return
|
|
2452
|
+
const done = await eachOf(reading, TEAM_REPOSITORIES_AT_ONCE, ({ access, setting }) => stopped || Date.now() > deadline ? Promise.resolve(false)
|
|
2453
|
+
: teamRepositoryDependencies(deps, access, setting, deadline, send, () => wholeRepositoryAccess(deps, cookie, access.repo.repoId)))
|
|
2454
|
+
if (stopped) return
|
|
2455
|
+
const unread = reading.filter((_, at) => !done[at]).map(({ access }) => access.repo.repoId)
|
|
2456
|
+
if (!await send(unread.length === 0 ? { done: true } : { done: false, unread })) return
|
|
2457
|
+
await out.end()
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// A repository's lines of its team's Dependencies, as handleTeamDependencies
|
|
2461
|
+
// tells them; whether all were written, none left for its time.
|
|
2462
|
+
async function teamRepositoryDependencies(deps: ManagedHttpDeps, access: WholeAccess, setting: RepoDependencyReview, deadline: number,
|
|
2463
|
+
send: (...lines: TeamDependencyLine[]) => Promise<boolean>, reauthorize: () => Promise<WholeAccess | Refusal>): Promise<boolean> {
|
|
2464
|
+
const repoId = access.repo.repoId
|
|
2465
|
+
type Read = { branch: object | null; pulls?: { items: TeamPull[]; more: boolean }; commits?: Map<number, PullCommits> }
|
|
2466
|
+
const read = await readWholeRepositoryOf<Read>(deps, access, async (fullName, token, user) => {
|
|
2467
|
+
const branch = await branchDependencyAnswer(deps, fullName, token, user, repoId, setting)
|
|
2468
|
+
if (!(branch.value as { review?: unknown }).review) return { value: { branch: null }, kept: branch.kept }
|
|
2469
|
+
const pulls = await teamOpenPulls(deps, repoId, fullName, token)
|
|
2470
|
+
return { value: { branch: branch.value, pulls: { items: pulls.items, more: pulls.more }, commits: pulls.commits }, kept: branch.kept && pulls.kept }
|
|
2471
|
+
}, reauthorize)
|
|
2472
|
+
if ('error' in read) return send({ repoId, ...PARTIAL_ACCESS.has(read.error) ? { branch: null } : { error: read.error, status: read.status } })
|
|
2473
|
+
const { branch, pulls, commits } = read.value
|
|
2474
|
+
if (!branch) return send({ repoId, branch: null })
|
|
2475
|
+
if (!await send({ repoId, branch, pulls, ...await withDeltas(deps, [branch]) })) return false
|
|
2476
|
+
// Each by the commits its page names, where it names them, GitHub not asked
|
|
2477
|
+
// for them (pullDependencyAnswer); never told.
|
|
2478
|
+
const numbers = pulls!.items.filter(item => item.touchesLockfile === true || item.touchesLockfile === null).map(item => item.number)
|
|
2479
|
+
for (let at = 0; at < numbers.length; at += TEAM_PULLS_TOGETHER) {
|
|
2480
|
+
if (Date.now() > deadline) return false
|
|
2481
|
+
const batch = numbers.slice(at, at + TEAM_PULLS_TOGETHER)
|
|
2482
|
+
const pulled = await readWholeRepositoryOf(deps, access, async (fullName, token, user) => {
|
|
2483
|
+
const answers = await eachOf(batch, PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, true, commits?.get(number)).catch((err: unknown) => {
|
|
2484
|
+
// GitHub asked, though it failed: its access asked again after.
|
|
2485
|
+
if (err instanceof GithubApiError) return { value: { number, error: err.message }, kept: false }
|
|
2486
|
+
throw err
|
|
2487
|
+
}))
|
|
2488
|
+
return { value: answers.map(({ value }) => value), kept: answers.every(({ kept }) => kept) }
|
|
2489
|
+
}, reauthorize)
|
|
2490
|
+
const dependencies = 'error' in pulled ? batch.map(number => ({ number, error: pulled.error })) : pulled.value
|
|
2491
|
+
if (!await send({ repoId, dependencies, ...await withDeltas(deps, dependencies) })) return false
|
|
2492
|
+
}
|
|
2493
|
+
return true
|
|
2494
|
+
}
|
|
2495
|
+
const withDeltas = async (deps: ManagedHttpDeps, answers: object[]) => {
|
|
2496
|
+
const deltas = await teamDeltas(deps, answers)
|
|
2497
|
+
return deltas.length > 0 ? { deltas } : {}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
// A commit's or a blob's id, as GitHub names one.
|
|
2501
|
+
const OBJECT_ID = /^[a-f\d]{40}$/u
|
|
2502
|
+
|
|
2503
|
+
// A repository's open pull requests, as many pages of them as
|
|
2504
|
+
// TEAM_PULL_PAGES, each read and kept as GET issues reads it; each's base's
|
|
2505
|
+
// and head's commits as its page has them (`commits`, by its number), where
|
|
2506
|
+
// it has them; whether it has more, and whether all were kept.
|
|
2507
|
+
async function teamOpenPulls(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
|
|
2508
|
+
const items: TeamPull[] = []
|
|
2509
|
+
const commits = new Map<number, PullCommits>()
|
|
2510
|
+
let after: string | null = null, kept = true
|
|
2511
|
+
for (let page = 0; page < TEAM_PULL_PAGES; page++) {
|
|
2512
|
+
const cursor = after
|
|
2513
|
+
const answer = await keptAnswer(deps, `${repoId}\0issues\0${ISSUES_READ_VERSION}\0pulls\0open\0${cursor ?? ''}`,
|
|
2514
|
+
async () => ({ kind: 'pulls', state: 'open', ...await repositoryIssues(fullName, token, 'pulls', 'open', cursor) }))
|
|
2515
|
+
kept &&= answer.kept
|
|
2516
|
+
const value = answer.value as { items?: unknown; more?: unknown; after?: unknown; commits?: unknown }
|
|
2517
|
+
items.push(...(Array.isArray(value.items) ? value.items : []).filter(item => Number.isSafeInteger(item?.number) && item.number > 0))
|
|
2518
|
+
for (const pull of Array.isArray(value.commits) ? value.commits as { number?: unknown; base?: unknown; head?: unknown }[] : []) {
|
|
2519
|
+
const { number, base, head } = pull ?? {}
|
|
2520
|
+
if (Number.isSafeInteger(number) && typeof base === 'string' && OBJECT_ID.test(base) && typeof head === 'string' && OBJECT_ID.test(head)) {
|
|
2521
|
+
commits.set(number as number, { base, head })
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
if (value.more !== true || typeof value.after !== 'string') return { items, commits, more: false, kept }
|
|
2525
|
+
after = value.after
|
|
2526
|
+
}
|
|
2527
|
+
return { items, commits, more: true, kept }
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// The lines the versions wanting reviews of `answers` change, as their rows
|
|
2531
|
+
// show them (npm-delta.js deltaBase), where they are counted already: of
|
|
2532
|
+
// public versions alone, those npm answered for in a day (npmVersionStore),
|
|
2533
|
+
// their integrities those npmDelta keys them by. `[name, version, base,
|
|
2534
|
+
// delta]` each, none counted here.
|
|
2535
|
+
async function teamDeltas(deps: ManagedHttpDeps, answers: object[]) {
|
|
2536
|
+
const asked = new Map<string, [string, string, string]>()
|
|
2537
|
+
for (const answer of answers as { lockfiles?: { added?: unknown; unreviewed?: unknown }[]; review?: { required?: number } }[]) {
|
|
2538
|
+
const required = answer.review?.required
|
|
2539
|
+
if (!Number.isSafeInteger(required)) continue
|
|
2540
|
+
for (const pkg of (answer.lockfiles ?? []).flatMap(dir => [...Array.isArray(dir.added) ? dir.added : [], ...Array.isArray(dir.unreviewed) ? dir.unreviewed : []]) as
|
|
2541
|
+
{ name?: unknown; version?: unknown; source?: unknown; was?: unknown; reviews?: unknown; rejected?: unknown; baseline?: unknown; reviewed?: unknown; unsupported?: unknown }[]) {
|
|
2542
|
+
if (pkg.source !== 'registry' || typeof pkg.name !== 'string' || typeof pkg.version !== 'string' || !isExactVersion(pkg.version)) continue
|
|
2543
|
+
if (pkg.unsupported || pkg.baseline || pkg.reviewed || typeof pkg.reviews !== 'number' || (pkg.reviews >= required! && !pkg.rejected)) continue
|
|
2544
|
+
const was = Array.isArray(pkg.was) ? pkg.was.find(prior => typeof prior === 'string' && prior !== pkg.version) as string | undefined : undefined
|
|
2545
|
+
asked.set(`${pkg.name}\0${pkg.version}\0${was ?? '(empty)'}`, [pkg.name, pkg.version, was ?? '(empty)'])
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
if (asked.size === 0) return []
|
|
2549
|
+
const versions = [...asked.values()].flatMap(([name, version, base]) => [{ name, version }, ...base === '(empty)' || !isExactVersion(base) ? [] : [{ name, version: base }]])
|
|
2550
|
+
const kept = await npmVersionStore(deps.db, deps.config.debug).readMany!(versions)
|
|
2551
|
+
const integrity = (name: string, version: string) => {
|
|
2552
|
+
const hit = kept.get(`${name}@${version}`)
|
|
2553
|
+
const dist = hit && Date.now() - hit.at < VERSION_KEPT_MS ? hit.json['dist'] : null
|
|
2554
|
+
return typeof dist === 'object' && dist !== null && typeof (dist as { integrity?: unknown }).integrity === 'string' ? (dist as { integrity: string }).integrity : null
|
|
2555
|
+
}
|
|
2556
|
+
const sides = [...asked.values()].flatMap(([name, version, base]) => {
|
|
2557
|
+
const against = base === '(empty)' ? null : integrity(name, base), other = integrity(name, version)
|
|
2558
|
+
return other && (base === '(empty)' || against) ? [{ name, version, base, key: [against, other] as [string | null, string] }] : []
|
|
2559
|
+
})
|
|
2560
|
+
if (sides.length === 0) return []
|
|
2561
|
+
const counted = await keptNpmDeltas(sides.map(({ key }) => key), npmDeltaStore<NpmDelta>(deps.db, deps.config.debug))
|
|
2562
|
+
return sides.flatMap(({ name, version, base, key: [against, other] }) => {
|
|
2563
|
+
const delta = counted.get(`${against ?? '(empty)'}\0${other}`)
|
|
2564
|
+
return delta ? [[name, version, base, delta]] : []
|
|
2217
2565
|
})
|
|
2218
2566
|
}
|
|
2219
2567
|
|
|
@@ -2254,6 +2602,34 @@ function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependen
|
|
|
2254
2602
|
}
|
|
2255
2603
|
}
|
|
2256
2604
|
|
|
2605
|
+
// What reading repository `repoId`'s lockfiles for its dependency review
|
|
2606
|
+
// keeps, by the rules they were read by (PULL_DEPENDENCIES_READ,
|
|
2607
|
+
// lockfileReadStore): each commit's listing, and each lockfile's read, of
|
|
2608
|
+
// those GitHub gave (pull-dependencies.ts LockfileStore). One kept otherwise
|
|
2609
|
+
// is none.
|
|
2610
|
+
function lockfileKept(deps: ManagedHttpDeps, repoId: number): LockfileStore {
|
|
2611
|
+
const listings = lockfileReadStore<ListedLockfile[]>(deps.db, 'listings', deps.config.debug)
|
|
2612
|
+
const lockfiles = lockfileReadStore<LockfileRead>(deps.db, 'lockfiles', deps.config.debug)
|
|
2613
|
+
const keyOf = (key: string) => `${repoId}\0${PULL_DEPENDENCIES_READ}\0${key}`
|
|
2614
|
+
return {
|
|
2615
|
+
listings: {
|
|
2616
|
+
async read(commit) {
|
|
2617
|
+
const value: unknown = (await listings.read(keyOf(commit)))?.value
|
|
2618
|
+
return Array.isArray(value) && value.every(item => typeof item?.path === 'string' && typeof item.format === 'string' && lockfileFormat(item.path) === item.format
|
|
2619
|
+
&& typeof item.sha === 'string' && OBJECT_ID.test(item.sha)) ? value : null
|
|
2620
|
+
},
|
|
2621
|
+
write: (commit, listed) => listings.write(keyOf(commit), listed, Date.now()),
|
|
2622
|
+
},
|
|
2623
|
+
lockfiles: {
|
|
2624
|
+
async read(key) {
|
|
2625
|
+
const value = (await lockfiles.read(keyOf(key)))?.value as { packages?: unknown; error?: unknown } | null | undefined
|
|
2626
|
+
return value && (Array.isArray(value.packages) || value.error === 'lockfile-unreadable') ? value as LockfileRead : null
|
|
2627
|
+
},
|
|
2628
|
+
write: (key, read) => lockfiles.write(keyOf(key), read, Date.now()),
|
|
2629
|
+
},
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2257
2633
|
// GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
|
|
2258
2634
|
// (repository-pulse.ts): the default branch's year, counted from its commits
|
|
2259
2635
|
// (pulseCommits), and the pull requests and issues of the last `weeks`
|
|
@@ -2269,7 +2645,7 @@ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
2269
2645
|
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2270
2646
|
const [period, year] = await Promise.all([
|
|
2271
2647
|
keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => repositoryActivity(fullName, token, weeks)),
|
|
2272
|
-
pulseCommits(deps, repoId, fullName, token),
|
|
2648
|
+
pulseCommits(deps, repoId, fullName, token, weeks),
|
|
2273
2649
|
])
|
|
2274
2650
|
return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
|
|
2275
2651
|
pending: year.pending }, kept: period.kept && year.kept }
|
|
@@ -2284,14 +2660,21 @@ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
2284
2660
|
// read on by the next answer as the year's commits are, and read again as
|
|
2285
2661
|
// an answer kept is, a label changing whose a pull request is without
|
|
2286
2662
|
// moving the head, the year counted again where what they bring changed.
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
2663
|
+
// Read no further back than the period asked (`weeks`) needs, its weeks and
|
|
2664
|
+
// as many before them, the rest read only as a longer one asks, its count
|
|
2665
|
+
// so said (`from`). A year not read as far as it needs yet is how many of
|
|
2666
|
+
// its commits are (`reading`), or of the pull requests merged (`pulls`),
|
|
2667
|
+
// beside the year last counted from an earlier head, where one is kept over
|
|
2668
|
+
// the same weeks or a few weeks earlier, so that a head moved on, or the
|
|
2669
|
+
// rules its commits are read by, shows the year as it was till it is
|
|
2670
|
+
// counted again; one past
|
|
2291
2671
|
// YEAR_COMMITS is GitHub's statistics' (repositoryStats, repositoryLines),
|
|
2292
2672
|
// kept together as an issue's page is and through GitHub computing them
|
|
2293
2673
|
// again, `pending` while it first computes them, the year last counted
|
|
2294
2674
|
// beside it so too.
|
|
2675
|
+
// How many weeks behind the year shown a year last counted may be shown
|
|
2676
|
+
// while it is counted again: the year as it was, its latest weeks missing.
|
|
2677
|
+
const PULSE_LAST_MS = 28 * 24 * 60 * 60_000
|
|
2295
2678
|
// A day as a pulse's items are narrowed by (YYYY-MM-DD, UTC), when it starts;
|
|
2296
2679
|
// a GitHub login, as many of them as PULSE_AUTHORS.
|
|
2297
2680
|
const PULSE_DAY = /^\d{4}-\d{2}-\d{2}$/u
|
|
@@ -2323,8 +2706,13 @@ async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttp
|
|
|
2323
2706
|
})
|
|
2324
2707
|
}
|
|
2325
2708
|
|
|
2326
|
-
async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
|
|
2327
|
-
const
|
|
2709
|
+
async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, weeks = 52) {
|
|
2710
|
+
const now = Date.now()
|
|
2711
|
+
const first = pulseSince(52, now)
|
|
2712
|
+
// As far back as a period of `weeks` weeks needs the year read whole: its
|
|
2713
|
+
// weeks and as many before them, which it is shown beside; the year's,
|
|
2714
|
+
// all of it.
|
|
2715
|
+
const need = Math.max(first, pulseSince(2 * weeks, now))
|
|
2328
2716
|
const head = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
|
|
2329
2717
|
if (!head.value.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
|
|
2330
2718
|
const store = repositoryReadStore(deps.db, deps.config.debug)
|
|
@@ -2333,7 +2721,7 @@ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: str
|
|
|
2333
2721
|
const mergesKey = `${repoId}\0pulse-merges\0${PULSE_YEAR_VERSION}`
|
|
2334
2722
|
const last = async () => {
|
|
2335
2723
|
const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
|
|
2336
|
-
return kept
|
|
2724
|
+
return kept && first - kept.first <= PULSE_LAST_MS ? kept.stats : null
|
|
2337
2725
|
}
|
|
2338
2726
|
const sha = head.value.sha
|
|
2339
2727
|
// What tells whose the year's commits are, kept as read: the agents' pull
|
|
@@ -2341,47 +2729,60 @@ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: str
|
|
|
2341
2729
|
// repository (`releases`, release-commits.ts releaseCommits).
|
|
2342
2730
|
type Attribution = AgentMerges & { releases?: string[] }
|
|
2343
2731
|
const keptAttribution = async () => ((await store.read(mergesKey))?.value as Attribution | undefined) ?? null
|
|
2344
|
-
const attribution = async (kept: Attribution | null): Promise<Attribution> => {
|
|
2345
|
-
const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept
|
|
2732
|
+
const attribution = async (kept: Attribution | null, whole: number): Promise<Attribution> => {
|
|
2733
|
+
const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept, globalThis.fetch, whole),
|
|
2734
|
+
releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
|
|
2346
2735
|
return { ...merges, releases }
|
|
2347
2736
|
}
|
|
2737
|
+
// Since when every pull request merged is read (agentMerges `reach`, or
|
|
2738
|
+
// a read not finished's `to`): all of them, -∞; none yet, ∞.
|
|
2739
|
+
const readTo = (read: Attribution) => read.next ? read.next.to ?? Number.POSITIVE_INFINITY : read.reach ?? Number.NEGATIVE_INFINITY
|
|
2348
2740
|
// The year counted from its `commits` as `read` tells whose they are and
|
|
2349
2741
|
// which were pushed directly, the branch followed through those it was
|
|
2350
|
-
// read on past (`passed`, yearCommits) too
|
|
2351
|
-
//
|
|
2352
|
-
|
|
2742
|
+
// read on past (`passed`, yearCommits) too, whole from when both the
|
|
2743
|
+
// commits and the pull requests merged are read (`from`, none where all
|
|
2744
|
+
// are); kept by its head and as the year last counted.
|
|
2745
|
+
const count = async ({ commits, passed, from }: { commits: HistoryCommit[]; passed: HistoryCommit[]; from: number | null }, read: Attribution) => {
|
|
2353
2746
|
const walked = [...commits, ...passed]
|
|
2354
|
-
const
|
|
2747
|
+
const whole = Math.max(from ?? Number.NEGATIVE_INFINITY, readTo(read))
|
|
2748
|
+
const stats = { ...commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha)),
|
|
2749
|
+
...Number.isFinite(whole) ? { from: whole } : {} }
|
|
2355
2750
|
await Promise.all([store.write(key, stats, Date.now()), store.write(lastKey, { first, stats }, Date.now())])
|
|
2356
2751
|
return stats
|
|
2357
2752
|
}
|
|
2358
2753
|
// A year kept by its head, what tells whose its commits are read again as
|
|
2359
2754
|
// an answer kept is (keptAnswer), a label or a tag changing it without
|
|
2360
|
-
// moving the head: counted again
|
|
2361
|
-
// pull requests bring, or the releases, changed,
|
|
2362
|
-
// requests
|
|
2363
|
-
// meanwhile, and where reading them fails.
|
|
2755
|
+
// moving the head: counted again, as far back as it was, from its pages
|
|
2756
|
+
// kept where what agents' pull requests bring, or the releases, changed,
|
|
2757
|
+
// or the pull requests merged are read further back; the year as kept
|
|
2758
|
+
// answered meanwhile, and where reading them fails.
|
|
2364
2759
|
const relabelled = async (stats: PulseStats) => {
|
|
2365
2760
|
const recounted: { stats?: PulseStats } = {}
|
|
2761
|
+
const whole = stats.from ?? first
|
|
2366
2762
|
await keptAnswer(deps, mergesKey, async () => {
|
|
2367
2763
|
const kept = await keptAttribution()
|
|
2368
|
-
const read = await attribution(kept)
|
|
2369
|
-
const same = kept &&
|
|
2370
|
-
if (read
|
|
2371
|
-
const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
|
|
2764
|
+
const read = await attribution(kept, whole)
|
|
2765
|
+
const same = kept && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join() && readTo(kept) <= readTo(read)
|
|
2766
|
+
if (readTo(read) >= whole || same) return read
|
|
2767
|
+
const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true), globalThis.fetch, whole)
|
|
2372
2768
|
if (year.complete) recounted.stats = await count(year, read)
|
|
2373
2769
|
return read
|
|
2374
2770
|
}, () => true).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
|
|
2375
2771
|
return recounted.stats ?? stats
|
|
2376
2772
|
}
|
|
2773
|
+
// A year kept, read whole as far back as the period needs, as it was;
|
|
2774
|
+
// else its commits and the pull requests merged read on together as far
|
|
2775
|
+
// as it needs, and counted once both are.
|
|
2377
2776
|
const held = (await store.read(key))?.value as PulseStats | { tooMany: true } | undefined
|
|
2378
|
-
if (held && !('tooMany' in held)
|
|
2379
|
-
|
|
2380
|
-
|
|
2777
|
+
if (held && !('tooMany' in held) && (held.from === undefined || held.from < need)) {
|
|
2778
|
+
return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
|
|
2779
|
+
}
|
|
2780
|
+
if (!held || !('tooMany' in held)) {
|
|
2781
|
+
const [year, read] = await Promise.all([yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true), globalThis.fetch, need),
|
|
2782
|
+
keptAttribution().then(kept => attribution(kept, need))])
|
|
2783
|
+
await store.write(mergesKey, read, Date.now())
|
|
2381
2784
|
if (year.complete) {
|
|
2382
|
-
|
|
2383
|
-
await store.write(mergesKey, read, Date.now())
|
|
2384
|
-
if (read.next) return { stats: await last(), reading: null, pulls: read.next.read, pending: false, kept: false }
|
|
2785
|
+
if (readTo(read) >= need) return { stats: await last(), reading: null, pulls: read.next?.read ?? 0, pending: false, kept: false }
|
|
2385
2786
|
return { stats: await count(year, read), reading: null, pulls: null, pending: false, kept: false }
|
|
2386
2787
|
}
|
|
2387
2788
|
if (!year.tooMany) return { stats: await last(), reading: year.read, pulls: null, pending: false, kept: false }
|
|
@@ -2480,7 +2881,7 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
|
|
|
2480
2881
|
// reader leaving, losing their admin role or
|
|
2481
2882
|
// the server shutting down stops it.
|
|
2482
2883
|
async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2483
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
2884
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
2484
2885
|
if (!s) return
|
|
2485
2886
|
const repos = await riskRepositories(deps.db, query)
|
|
2486
2887
|
if (!repos) {
|
|
@@ -2763,7 +3164,7 @@ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2763
3164
|
}
|
|
2764
3165
|
|
|
2765
3166
|
async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2766
|
-
const initial = await manageMutation(req, res, deps, cookie)
|
|
3167
|
+
const initial = await manageMutation(req, res, deps, cookie, { handed: true })
|
|
2767
3168
|
if (!initial) return
|
|
2768
3169
|
const controller = new AbortController()
|
|
2769
3170
|
const onClose = () => controller.abort()
|
|
@@ -2826,7 +3227,7 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2826
3227
|
// After storing, any reports that declared this integrity but weren't linked yet
|
|
2827
3228
|
// get attached (auto-link). 413 over the cap, 400 on empty.
|
|
2828
3229
|
async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2829
|
-
let s = await checkMutation(req, res, deps, cookie)
|
|
3230
|
+
let s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
2830
3231
|
if (s == null) return
|
|
2831
3232
|
if (!requireManageRole(res, s.user)) return
|
|
2832
3233
|
let bytes: Buffer
|
|
@@ -2956,7 +3357,7 @@ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2956
3357
|
// the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
|
|
2957
3358
|
// best-effort. 404 when there was no such bundle.
|
|
2958
3359
|
async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
2959
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
3360
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
2960
3361
|
if (s == null) return
|
|
2961
3362
|
if (!requireManageRole(res, s.user)) return
|
|
2962
3363
|
const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
|
|
@@ -2969,8 +3370,8 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2969
3370
|
|
|
2970
3371
|
// admin|manage mutation guard: same-origin + CSRF + role, in one step. Returns
|
|
2971
3372
|
// the session, or null after the 401/403 was sent.
|
|
2972
|
-
async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
2973
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
3373
|
+
async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
3374
|
+
const s = await checkMutation(req, res, deps, cookie, { handed })
|
|
2974
3375
|
if (s == null) return null
|
|
2975
3376
|
if (!requireManageRole(res, s.user)) return null
|
|
2976
3377
|
return s
|
|
@@ -2978,8 +3379,8 @@ async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: M
|
|
|
2978
3379
|
|
|
2979
3380
|
// Workspace access and repository connections are administered separately
|
|
2980
3381
|
// from content management. Managers cannot change their own access via teams.
|
|
2981
|
-
async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
2982
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
3382
|
+
async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { handed = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
3383
|
+
const s = await checkMutation(req, res, deps, cookie, { handed })
|
|
2983
3384
|
if (s == null) return null
|
|
2984
3385
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
2985
3386
|
return s
|
|
@@ -3035,7 +3436,7 @@ async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId:
|
|
|
3035
3436
|
// visible findings (location AND evidence) are returned. Missing/unavailable
|
|
3036
3437
|
// bundles are a quiet no-op; cached gzip bodies stream directly from disk.
|
|
3037
3438
|
async function handleReportSources(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null) {
|
|
3038
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
3439
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
|
|
3039
3440
|
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3040
3441
|
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3041
3442
|
const sourcePaths = roleAtLeast(s.user.role, 'manage') ? undefined : await teamSourcePaths(deps.db, deps.reportStore, s.session.id, teamId ?? '', id)
|
|
@@ -3079,7 +3480,7 @@ async function reportIds(data: unknown): Promise<Set<string>> {
|
|
|
3079
3480
|
async function handleDeduplication(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
3080
3481
|
const method = req.method ?? 'GET'
|
|
3081
3482
|
if (!(id ? ['GET', 'PATCH'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'GET, PATCH' : 'GET, POST'); return }
|
|
3082
|
-
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
3483
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
|
|
3083
3484
|
if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3084
3485
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
3085
3486
|
if (method === 'GET') { sendJson(res, 200, id ? await deps.db.getLinkReport(s.session.id, id) : { reports: await deps.db.listLinkReports() }); return }
|
|
@@ -3102,7 +3503,7 @@ async function handleDeduplication(req: IncomingMessage, res: ServerResponse, de
|
|
|
3102
3503
|
async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
3103
3504
|
const method = req.method ?? 'GET'
|
|
3104
3505
|
if (!(id ? ['PATCH', 'DELETE'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'PATCH, DELETE' : 'GET, POST'); return }
|
|
3105
|
-
const s = method === 'GET' ? await readAdminSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
|
|
3506
|
+
const s = method === 'GET' ? await readAdminSession(res, deps, cookie, { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
|
|
3106
3507
|
if (!s) return
|
|
3107
3508
|
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
3108
3509
|
if (method === 'GET') {
|
|
@@ -3122,7 +3523,7 @@ async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse
|
|
|
3122
3523
|
// Managers can resolve only destinations their current repository grants cover.
|
|
3123
3524
|
async function handleRepositorySuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3124
3525
|
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
3125
|
-
if (!await readManageSession(res, deps, cookie)) return
|
|
3526
|
+
if (!await readManageSession(res, deps, cookie, { handed: true })) return
|
|
3126
3527
|
const params = new URL(req.url!, 'http://localhost').searchParams
|
|
3127
3528
|
const github = reportRepoGithub({ repo: { github: params.get('repo') } })
|
|
3128
3529
|
const directory = normalizeTeamPath(params.get('directory'))
|
|
@@ -3160,7 +3561,7 @@ async function suggestReportLocation(deps: ManagedHttpDeps, user: StoredUser, da
|
|
|
3160
3561
|
// location editor. It never changes the stored assignment.
|
|
3161
3562
|
async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
3162
3563
|
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
3163
|
-
const s = await readManageSession(res, deps, cookie)
|
|
3564
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
3164
3565
|
if (!s) return
|
|
3165
3566
|
const report = await deps.db.getReport(id)
|
|
3166
3567
|
if (!report || !(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
@@ -3182,7 +3583,7 @@ async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerR
|
|
|
3182
3583
|
// "not authorized" (so neither existence nor membership is probeable); 503 = row
|
|
3183
3584
|
// without bytes (store desync).
|
|
3184
3585
|
async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
3185
|
-
const s = await readManageSession(res, deps, cookie)
|
|
3586
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
3186
3587
|
if (s == null) return
|
|
3187
3588
|
const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
|
|
3188
3589
|
const access = snapshot?.reports[0]
|
|
@@ -3219,7 +3620,7 @@ async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3219
3620
|
const REPORT_QUERY_CONCURRENCY = 8
|
|
3220
3621
|
const REPORT_QUERY_IN_FLIGHT_BYTES = 64 * 1024 * 1024
|
|
3221
3622
|
async function handleQueryReports(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3222
|
-
const s = await readManageSession(res, deps, cookie)
|
|
3623
|
+
const s = await readManageSession(res, deps, cookie, { handed: true })
|
|
3223
3624
|
if (!s) return
|
|
3224
3625
|
let body
|
|
3225
3626
|
try { body = await readJsonBody(req, 1024 * 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3313,9 +3714,11 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
|
|
|
3313
3714
|
|
|
3314
3715
|
// The server selects the complete published workspace for classification.
|
|
3315
3716
|
// Privileged hidden-report previews are independently scoped and bounded.
|
|
3316
|
-
|
|
3717
|
+
// Its reports' file trees are asked for apart, by its graph or Files alone.
|
|
3718
|
+
async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string, reportId: string | null,
|
|
3719
|
+
part: 'reports' | 'trees' = 'reports'): Promise<void> {
|
|
3317
3720
|
const snapshot = await teamSnapshot(deps.db, session.id, teamId, reportId)
|
|
3318
|
-
const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
|
|
3721
|
+
const body = await (part === 'trees' ? loadTeamTreesResponse : loadTeamReportsResponse)(deps.db, deps.reportStore, snapshot)
|
|
3319
3722
|
await recheckTeam(deps.db, session.id, snapshot)
|
|
3320
3723
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
3321
3724
|
writeResponse(res, body)
|
|
@@ -3372,7 +3775,7 @@ async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256
|
|
|
3372
3775
|
// stripped finding must not leak that the finding exists. A cleared entry is
|
|
3373
3776
|
// sent as null (the server's tombstone), distinct from one never set.
|
|
3374
3777
|
async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
|
|
3375
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
3778
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
|
|
3376
3779
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3377
3780
|
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3378
3781
|
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
@@ -3391,7 +3794,7 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
3391
3794
|
// exists). Each event is the entry as written then (null = a clear), who
|
|
3392
3795
|
// wrote it and when; "what changed" is the diff against the next-older event.
|
|
3393
3796
|
async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
|
|
3394
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
3797
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
|
|
3395
3798
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3396
3799
|
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3397
3800
|
const finding = query.get('finding') ?? ''
|
|
@@ -3422,7 +3825,7 @@ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHt
|
|
|
3422
3825
|
// scope for rows that are themselves per finding id — so a stripped (or
|
|
3423
3826
|
// foreign) finding id 404s without revealing whether it exists.
|
|
3424
3827
|
async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
|
|
3425
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
3828
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
3426
3829
|
if (s == null) return
|
|
3427
3830
|
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
3428
3831
|
let body: unknown
|
|
@@ -3456,7 +3859,7 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
|
|
|
3456
3859
|
// GET only: clients match local annotations against these IDs without sending
|
|
3457
3860
|
// their local finding IDs or annotation bodies to discover which reports exist.
|
|
3458
3861
|
async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
|
|
3459
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
3862
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
3460
3863
|
if (!s) return
|
|
3461
3864
|
const after = search.get('after') ?? ''
|
|
3462
3865
|
if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
|
|
@@ -3478,7 +3881,7 @@ async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
3478
3881
|
// Admin imports compare the exact snapshot shown by the conflict dialog before
|
|
3479
3882
|
// writing. The store commits shared finding triage and imported comments together.
|
|
3480
3883
|
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
3481
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
3884
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3482
3885
|
if (!s) return
|
|
3483
3886
|
let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
|
|
3484
3887
|
try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof body }
|
|
@@ -3517,7 +3920,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
|
|
|
3517
3920
|
if (commentId == null ? method !== 'GET' && method !== 'POST' : method !== 'PATCH' && method !== 'DELETE') {
|
|
3518
3921
|
send405(res, commentId == null ? 'GET, POST' : 'PATCH, DELETE'); return
|
|
3519
3922
|
}
|
|
3520
|
-
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
3923
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
|
|
3521
3924
|
if (s == null) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3522
3925
|
const allowed = method === 'GET' ? await canViewReport(deps, s.user, reportId) : await canTriageReport(deps, s.user, reportId)
|
|
3523
3926
|
if (!allowed) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
@@ -3565,7 +3968,7 @@ async function handleReportComments(req: IncomingMessage, res: ServerResponse, d
|
|
|
3565
3968
|
// the page needs: all users (member dropdown), selected repos (repo dropdown),
|
|
3566
3969
|
// and the visibility-permission keys. admin, read-only (no CSRF).
|
|
3567
3970
|
async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3568
|
-
const s = await readAdminSession(res, deps, cookie)
|
|
3971
|
+
const s = await readAdminSession(res, deps, cookie, { handed: true })
|
|
3569
3972
|
if (s == null) return
|
|
3570
3973
|
const scopes = await deps.db.listTeamNpmScopes()
|
|
3571
3974
|
const reviewTeams = new Set(await deps.db.listDependencyReviewEnabledTeams())
|
|
@@ -3579,7 +3982,7 @@ async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cooki
|
|
|
3579
3982
|
|
|
3580
3983
|
// POST /api/admin/teams — create a team. Body { name }. 409 if the name's taken.
|
|
3581
3984
|
async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3582
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
3985
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3583
3986
|
if (s == null) return
|
|
3584
3987
|
let body: unknown
|
|
3585
3988
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3596,7 +3999,7 @@ async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3596
3999
|
// POST /api/admin/teams/rename — rename a team. Body { teamId, name }. 404 if no
|
|
3597
4000
|
// such team; 409 if the new name is already taken by another team.
|
|
3598
4001
|
async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3599
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4002
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3600
4003
|
if (s == null) return
|
|
3601
4004
|
let body: unknown
|
|
3602
4005
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3615,7 +4018,7 @@ async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3615
4018
|
|
|
3616
4019
|
// Hidden teams retain their configuration but grant no access or sidebar presence.
|
|
3617
4020
|
async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3618
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4021
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3619
4022
|
if (s == null) return
|
|
3620
4023
|
let body: unknown
|
|
3621
4024
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3631,7 +4034,7 @@ async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, de
|
|
|
3631
4034
|
|
|
3632
4035
|
// POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
|
|
3633
4036
|
async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3634
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4037
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3635
4038
|
if (s == null) return
|
|
3636
4039
|
let body: unknown
|
|
3637
4040
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3648,7 +4051,7 @@ async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3648
4051
|
// path links with the whole repository. Body { teamId, repoId, path? }.
|
|
3649
4052
|
// Repo must be in the selected set.
|
|
3650
4053
|
async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3651
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4054
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3652
4055
|
if (s == null) return
|
|
3653
4056
|
let body: unknown
|
|
3654
4057
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3673,7 +4076,7 @@ async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps
|
|
|
3673
4076
|
// POST /api/admin/teams/remove-repo — unlink one path. Omitting path retains
|
|
3674
4077
|
// the legacy remove-all behavior. Body { teamId, repoId, path? }.
|
|
3675
4078
|
async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3676
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4079
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3677
4080
|
if (s == null) return
|
|
3678
4081
|
let body: unknown
|
|
3679
4082
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3693,7 +4096,7 @@ async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, d
|
|
|
3693
4096
|
// POST /api/admin/teams/set-member — add/update a member + their visibility
|
|
3694
4097
|
// permissions. Body { teamId, userId, dependencies?, security? }.
|
|
3695
4098
|
async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3696
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4099
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3697
4100
|
if (s == null) return
|
|
3698
4101
|
let body: unknown
|
|
3699
4102
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3716,7 +4119,7 @@ async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, de
|
|
|
3716
4119
|
|
|
3717
4120
|
// POST /api/admin/teams/remove-member — remove a membership. Body { teamId, userId }.
|
|
3718
4121
|
async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3719
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4122
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3720
4123
|
if (s == null) return
|
|
3721
4124
|
let body: unknown
|
|
3722
4125
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3732,7 +4135,7 @@ async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse,
|
|
|
3732
4135
|
}
|
|
3733
4136
|
|
|
3734
4137
|
async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3735
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4138
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3736
4139
|
if (!s) return
|
|
3737
4140
|
let body
|
|
3738
4141
|
try { body = await readJsonBody(req) as { teamId?: unknown; enabled?: unknown } } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3744,7 +4147,7 @@ async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerRe
|
|
|
3744
4147
|
// POST /api/admin/teams/set-npm-scopes — replace the npm scopes whose private
|
|
3745
4148
|
// packages the team's members may read. Body { teamId, scopes: ['@scope'] }.
|
|
3746
4149
|
async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
3747
|
-
const s = await adminMutation(req, res, deps, cookie)
|
|
4150
|
+
const s = await adminMutation(req, res, deps, cookie, { handed: true })
|
|
3748
4151
|
if (s == null) return
|
|
3749
4152
|
let body: unknown
|
|
3750
4153
|
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
@@ -3763,10 +4166,18 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
|
|
|
3763
4166
|
// npm scopes of its visible teams, else none: none are read for an unscoped
|
|
3764
4167
|
// package, an admin or a manager, or an answer checked again that is public.
|
|
3765
4168
|
// Null for none.
|
|
3766
|
-
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
|
|
3767
|
-
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
4169
|
+
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[], { handed = false } = {}): Promise<NpmReader | null> {
|
|
4170
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now(), { handed })
|
|
3768
4171
|
return s == null ? null : npmReaderOf(deps, s.user, names)
|
|
3769
4172
|
}
|
|
4173
|
+
// The reader of the session `cookie` names now, as npmReader reads them,
|
|
4174
|
+
// their npm scopes whatever the packages, and whether they read team
|
|
4175
|
+
// `teamId`'s reviews: one statement.
|
|
4176
|
+
async function npmReviewReader(deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<(NpmReader & { readsTeam: boolean }) | null> {
|
|
4177
|
+
const ids = sessionIds(deps.config, cookie)
|
|
4178
|
+
const reader = ids && await deps.db.getNpmReviewReader(ids.id, ids.viewer, Date.now(), teamId)
|
|
4179
|
+
return reader && roleAtLeast(reader.role, 'view') ? { role: reader.role, scopes: new Set(reader.scopes), userId: reader.id, readsTeam: reader.readsTeam } : null
|
|
4180
|
+
}
|
|
3770
4181
|
// The same of `user`, as a session the request read just now tells them.
|
|
3771
4182
|
async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
|
|
3772
4183
|
if (!roleAtLeast(user.role, 'view')) return null
|
|
@@ -3787,9 +4198,11 @@ const REVIEW_READS_AT_ONCE = 8
|
|
|
3787
4198
|
|
|
3788
4199
|
// Lists must enforce the same target and base access as the review itself,
|
|
3789
4200
|
// including permission changes while the registry requests are in flight;
|
|
3790
|
-
// first as `session`, the one the request read just now, where given.
|
|
4201
|
+
// first as `session`, the one the request read just now, where given. A
|
|
4202
|
+
// team's (`teamId`) only to one who still reads its reviews there, asked
|
|
4203
|
+
// again with the rest (npmReviewReader).
|
|
3791
4204
|
async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[],
|
|
3792
|
-
session?: { id: string; role: NpmReader['role'] }): Promise<T[] | null> {
|
|
4205
|
+
session?: { id: string; role: NpmReader['role'] }, teamId?: string): Promise<T[] | null> {
|
|
3793
4206
|
const controller = new AbortController()
|
|
3794
4207
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
3795
4208
|
res.on('close', onClose)
|
|
@@ -3808,8 +4221,10 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
|
|
|
3808
4221
|
const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
|
|
3809
4222
|
const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
|
|
3810
4223
|
const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
|
|
3811
|
-
const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
|
|
4224
|
+
const current = teamId === undefined ? await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
|
|
4225
|
+
: await npmReviewReader(deps, cookie, teamId)
|
|
3812
4226
|
if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
4227
|
+
if ('readsTeam' in current && !current.readsTeam) { sendJson(res, 404, { error: 'no-team' }); return null }
|
|
3813
4228
|
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
3814
4229
|
} catch (error) {
|
|
3815
4230
|
if (controller.signal.aborted) return null
|
|
@@ -3847,13 +4262,16 @@ async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReade
|
|
|
3847
4262
|
return npmReviewVerdictLimit(doc, list, socket, risk)
|
|
3848
4263
|
}
|
|
3849
4264
|
|
|
3850
|
-
// Reviews are scoped to the bytes actually displayed, including the base
|
|
3851
|
-
//
|
|
4265
|
+
// Reviews are scoped to the bytes actually displayed, including the base:
|
|
4266
|
+
// each change but a revocation loads both versions' files, of tarballs held
|
|
4267
|
+
// to their integrity and to their shasum where npm tells one (npm-loads.ts),
|
|
4268
|
+
// and the review keeps those shasums. Access to both versions is rechecked
|
|
4269
|
+
// before returning or saving annotations.
|
|
3852
4270
|
async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
|
|
3853
4271
|
const method = req.method ?? 'GET'
|
|
3854
4272
|
if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
|
|
3855
4273
|
const mutation = method !== 'GET'
|
|
3856
|
-
const session = mutation ? await checkMutation(req, res, deps, cookie) : await readSession(deps.config, deps.db, cookie, Date.now())
|
|
4274
|
+
const session = mutation ? await checkMutation(req, res, deps, cookie, { handed: true }) : await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true })
|
|
3857
4275
|
if (!session) { if (!mutation) sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3858
4276
|
if (!roleAtLeast(session.user.role, mutation ? 'triage' : 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
3859
4277
|
let body: Record<string, unknown> = {}
|
|
@@ -3928,10 +4346,14 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3928
4346
|
|| (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
|
|
3929
4347
|
sendJson(res, 409, { error: 'review-content-changed' }); return
|
|
3930
4348
|
}
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
4349
|
+
// Each change but a revocation loads both versions' files; a save or a
|
|
4350
|
+
// publication is held to them.
|
|
4351
|
+
const loads = change && change.action !== 'revoke' ? [doc, ...base ? [base] : []].map(loadNpmFiles) : []
|
|
4352
|
+
let tarballs: ReviewedTarballs | undefined
|
|
4353
|
+
try {
|
|
4354
|
+
const files = await Promise.all(loads.map(load => load.result))
|
|
4355
|
+
if (loads.length > 0) tarballs = { shasum: doc.dist.shasum, baseShasum: base?.dist.shasum ?? null }
|
|
4356
|
+
if (change?.action === 'publish' || change?.action === 'save') {
|
|
3935
4357
|
const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
|
|
3936
4358
|
const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
|
|
3937
4359
|
const diff = computeBundleDiff(before, after, () => '.')
|
|
@@ -3945,8 +4367,8 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3945
4367
|
change.files = changed.map(file => file.path)
|
|
3946
4368
|
change.nonTextFiles = changed.filter(file => !reviewable(file)).map(file => file.path)
|
|
3947
4369
|
}
|
|
3948
|
-
}
|
|
3949
|
-
}
|
|
4370
|
+
}
|
|
4371
|
+
} finally { loads.forEach(load => load.release()) }
|
|
3950
4372
|
const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
|
|
3951
4373
|
? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
|
|
3952
4374
|
const current = await npmReader(deps, cookie, [doc, base].flatMap(item => item?.private ? [item.name] : []))
|
|
@@ -3965,7 +4387,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3965
4387
|
if (change.action === 'publish' && change.final && !allowedReviewVerdicts([], limit).includes(change.final.status)) {
|
|
3966
4388
|
sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
|
|
3967
4389
|
}
|
|
3968
|
-
const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
|
|
4390
|
+
const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now(), tarballs)
|
|
3969
4391
|
sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
|
|
3970
4392
|
} catch (err) {
|
|
3971
4393
|
if (controller.signal.aborted) return
|
|
@@ -3975,6 +4397,62 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3975
4397
|
} finally { res.off('close', onClose) }
|
|
3976
4398
|
}
|
|
3977
4399
|
|
|
4400
|
+
// GET /api/npm/deltas?versions=[[name, version, otherVersion], …]: the lines
|
|
4401
|
+
// each of up to MAX_ASKED_DELTAS versions changes against another, or
|
|
4402
|
+
// `(empty)`, as GET delta counts them, as lines of JSON as each is counted
|
|
4403
|
+
// (`{ name, version, otherVersion, …delta }`, or `{ name, version,
|
|
4404
|
+
// otherVersion, error, status }`), as a list's rows near the viewport ask
|
|
4405
|
+
// for them together (npm-delta.js), DELTAS_AT_ONCE at a time. Each read as
|
|
4406
|
+
// GET delta reads it, with the reader's access to its package, which is
|
|
4407
|
+
// checked again once it is counted: a private one's told only to a reader
|
|
4408
|
+
// who may still read it, none told to one whose session is gone.
|
|
4409
|
+
const MAX_ASKED_DELTAS = 50
|
|
4410
|
+
const DELTAS_AT_ONCE = 2
|
|
4411
|
+
async function handleNpmDeltas(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
|
|
4412
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
4413
|
+
let asked: unknown = null
|
|
4414
|
+
try { asked = JSON.parse(params.get('versions') ?? '') } catch {}
|
|
4415
|
+
if (!Array.isArray(asked) || asked.length === 0 || asked.length > MAX_ASKED_DELTAS || !asked.every(item => Array.isArray(item) && item.length === 3
|
|
4416
|
+
&& isNpmPackageName(item[0]) && isNpmPackageSpec(item[1]) && (item[2] === '(empty)' || isNpmPackageSpec(item[2])))) { sendJson(res, 400, { error: 'bad-package' }); return }
|
|
4417
|
+
const versions = asked as [string, string, string][]
|
|
4418
|
+
const reader = await npmReader(deps, cookie, [...new Set(versions.map(([name]) => name))], { handed: true })
|
|
4419
|
+
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4420
|
+
const controller = new AbortController()
|
|
4421
|
+
res.once('close', () => { if (!res.writableEnded) controller.abort() })
|
|
4422
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
4423
|
+
const kept = npmDeltaStore<NpmDelta>(deps.db, deps.config.debug), keptVersions = npmVersionStore(deps.db, deps.config.debug)
|
|
4424
|
+
const out = openLines(res)
|
|
4425
|
+
let stopped = false
|
|
4426
|
+
await eachOf(versions, DELTAS_AT_ONCE, async ([name, version, otherVersion]) => {
|
|
4427
|
+
if (stopped || controller.signal.aborted) return
|
|
4428
|
+
const asOne = { name, version, otherVersion }
|
|
4429
|
+
let isPrivate = false, line: Record<string, unknown>
|
|
4430
|
+
try {
|
|
4431
|
+
const privileged = canReadPrivateNpm(reader, name)
|
|
4432
|
+
const together = otherVersion === '(empty)' ? keptVersions : readTogether(keptVersions, [{ name, version }, { name, version: otherVersion }])
|
|
4433
|
+
const [doc, old] = await Promise.all([readNpmVersion(name, version, privileged, controller.signal, together).then(found),
|
|
4434
|
+
otherVersion === '(empty)' ? null : readNpmVersion(name, otherVersion, privileged, controller.signal, together).then(found)])
|
|
4435
|
+
isPrivate = doc.private || old?.private === true
|
|
4436
|
+
const side = (of: NpmVersionDocument) => ({ integrity: of.dist.integrity, files: async () => {
|
|
4437
|
+
const load = loadNpmFiles(of)
|
|
4438
|
+
try { return await load.result } finally { load.release() }
|
|
4439
|
+
} })
|
|
4440
|
+
line = { ...asOne, ...await npmDelta(old ? side(old) : null, side(doc), kept) }
|
|
4441
|
+
} catch (err) {
|
|
4442
|
+
if (controller.signal.aborted) return
|
|
4443
|
+
if (!(err instanceof NpmPackageError)) console.warn('managed: a package delta failed:', err)
|
|
4444
|
+
line = err instanceof NpmPackageError ? { ...asOne, error: err.message, status: err.status } : { ...asOne, error: 'delta-failed', status: 500 }
|
|
4445
|
+
}
|
|
4446
|
+
// A reader who lost their session, or their access to a private one,
|
|
4447
|
+
// while it was counted, is told nothing of it.
|
|
4448
|
+
const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
|
|
4449
|
+
if (current == null) { stopped = true; out.cut(); return }
|
|
4450
|
+
if (isPrivate && !canReadPrivateNpm(current, name)) line = { ...asOne, error: 'package-not-found', status: 404 }
|
|
4451
|
+
if (!stopped && !await out.write(line)) stopped = true
|
|
4452
|
+
})
|
|
4453
|
+
if (!stopped) await out.end()
|
|
4454
|
+
}
|
|
4455
|
+
|
|
3978
4456
|
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
|
|
3979
4457
|
// (canReadPrivateNpm) can be answered from the server's npm token; the rest get
|
|
3980
4458
|
// what the registry answers anonymously, every time (see npm-packages.ts).
|
|
@@ -3995,7 +4473,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3995
4473
|
}
|
|
3996
4474
|
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
3997
4475
|
if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
3998
|
-
const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
|
|
4476
|
+
const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName], { handed: true })
|
|
3999
4477
|
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4000
4478
|
const controller = new AbortController()
|
|
4001
4479
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
@@ -4240,7 +4718,7 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
|
|
|
4240
4718
|
const method = req.method ?? 'GET'
|
|
4241
4719
|
const allowed = id ? ['PATCH', 'DELETE'] : ['GET', 'POST', 'DELETE']
|
|
4242
4720
|
if (!allowed.includes(method)) { send405(res, allowed.join(', ')); return }
|
|
4243
|
-
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
4721
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now(), { handed: true }) : await checkMutation(req, res, deps, cookie, { handed: true })
|
|
4244
4722
|
if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4245
4723
|
if (!requireManageRole(res, s.user)) return
|
|
4246
4724
|
const { db } = deps
|
|
@@ -4371,6 +4849,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4371
4849
|
const managedDataPath = underPrefix(path, MANAGED_DATA_PREFIXES)
|
|
4372
4850
|
const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
|
|
4373
4851
|
if (managedDataPath && !workspaceSession) return
|
|
4852
|
+
// What it read, for the handler's own first read to take.
|
|
4853
|
+
if (workspaceSession) handOverSession(cookie, workspaceSession)
|
|
4374
4854
|
const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
|
|
4375
4855
|
if (issueRoute) {
|
|
4376
4856
|
await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
|
|
@@ -4383,8 +4863,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4383
4863
|
if (path === '/api/admin/links') {
|
|
4384
4864
|
if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
4385
4865
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4386
|
-
const s =
|
|
4387
|
-
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4866
|
+
const s = workspaceSession!
|
|
4388
4867
|
if (!requireManageRole(res, s.user)) return
|
|
4389
4868
|
const shares = await db.listManagedWorkspaceShares(s.session.id, Date.now())
|
|
4390
4869
|
sendJson(res, shares ? 200 : 403, shares ? { shares } : { error: 'forbidden' }); return
|
|
@@ -4402,11 +4881,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4402
4881
|
// sign-in account's. A linked account's is served by its own id.
|
|
4403
4882
|
if (path.startsWith(AVATAR_PREFIX)) {
|
|
4404
4883
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4405
|
-
const s = await readSession(config, db, cookie, Date.now())
|
|
4406
|
-
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4407
4884
|
const id = path.slice(AVATAR_PREFIX.length)
|
|
4408
|
-
if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
|
|
4409
|
-
await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
|
|
4885
|
+
if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(req, res, avatarStore, id); return }
|
|
4886
|
+
await serveAvatar(req, res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
|
|
4410
4887
|
return
|
|
4411
4888
|
}
|
|
4412
4889
|
// Admin: list users (admin-only).
|
|
@@ -4416,9 +4893,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4416
4893
|
}
|
|
4417
4894
|
if (path === ADMIN_USERS_PATH) {
|
|
4418
4895
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4419
|
-
|
|
4420
|
-
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4421
|
-
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
4896
|
+
if (workspaceSession!.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
4422
4897
|
sendJson(res, 200, { users: await db.listUsers() })
|
|
4423
4898
|
return
|
|
4424
4899
|
}
|
|
@@ -4590,13 +5065,17 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4590
5065
|
const teamFeed = /^\/api\/teams\/([^/]+)\/feed$/u.exec(path)
|
|
4591
5066
|
if (teamFeed || path === '/api/teams/feed') {
|
|
4592
5067
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4593
|
-
const s =
|
|
4594
|
-
if (!s) return
|
|
5068
|
+
const s = workspaceSession!
|
|
4595
5069
|
const teamId = teamFeed?.[1] ?? null
|
|
4596
5070
|
const reportId = url.searchParams.get('reportId')
|
|
4597
5071
|
if (teamId) await teamSnapshot(db, s.session.id, teamId, reportId)
|
|
4598
5072
|
await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId, { reportId }); return
|
|
4599
5073
|
}
|
|
5074
|
+
const teamDependencies = /^\/api\/teams\/([^/]+)\/dependencies$/u.exec(path)
|
|
5075
|
+
if (teamDependencies) {
|
|
5076
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
5077
|
+
await handleTeamDependencies(res, deps, cookie, workspaceSession!, teamDependencies[1]!, url.searchParams); return
|
|
5078
|
+
}
|
|
4600
5079
|
const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
|
|
4601
5080
|
if (teamFixes) {
|
|
4602
5081
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
@@ -4610,10 +5089,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4610
5089
|
await recheckTeam(db, workspaceSession!.session.id, snapshot)
|
|
4611
5090
|
sendJson(res, 200, annotations); return
|
|
4612
5091
|
}
|
|
4613
|
-
const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
|
|
5092
|
+
const teamReports = /^\/api\/teams\/([^/]+)\/(reports|trees)$/u.exec(path)
|
|
4614
5093
|
if (teamReports) {
|
|
4615
5094
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4616
|
-
await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId')); return
|
|
5095
|
+
await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId'), teamReports[2] as 'reports' | 'trees'); return
|
|
4617
5096
|
}
|
|
4618
5097
|
if (path === REPORT_QUERY_PATH) {
|
|
4619
5098
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
@@ -4681,8 +5160,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4681
5160
|
}
|
|
4682
5161
|
if (path === '/api/npm/reviews/latest') {
|
|
4683
5162
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4684
|
-
const s =
|
|
4685
|
-
if (!s) return
|
|
5163
|
+
const s = workspaceSession!
|
|
4686
5164
|
const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
|
|
4687
5165
|
if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
|
|
4688
5166
|
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
@@ -4694,7 +5172,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4694
5172
|
}
|
|
4695
5173
|
if (path === '/api/npm/reviews/discard') {
|
|
4696
5174
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
4697
|
-
const s = await checkMutation(req, res, deps, cookie)
|
|
5175
|
+
const s = await checkMutation(req, res, deps, cookie, { handed: true })
|
|
4698
5176
|
if (!s) return
|
|
4699
5177
|
let body: Record<string, unknown>
|
|
4700
5178
|
try {
|
|
@@ -4711,8 +5189,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4711
5189
|
}
|
|
4712
5190
|
if (path === '/api/npm/reviews/mine') {
|
|
4713
5191
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4714
|
-
const s =
|
|
4715
|
-
if (!s) return
|
|
5192
|
+
const s = workspaceSession!
|
|
4716
5193
|
const id = url.searchParams.get('beforeId'), time = url.searchParams.get('beforeTime')
|
|
4717
5194
|
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
4718
5195
|
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
@@ -4726,8 +5203,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4726
5203
|
// Dependencies tab lists them, fifty a page.
|
|
4727
5204
|
if (path === '/api/npm/reviews/team') {
|
|
4728
5205
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4729
|
-
const s =
|
|
4730
|
-
if (!s) return
|
|
5206
|
+
const s = workspaceSession!
|
|
4731
5207
|
const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
|
|
4732
5208
|
if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
|
|
4733
5209
|
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
@@ -4735,16 +5211,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4735
5211
|
}
|
|
4736
5212
|
const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
4737
5213
|
if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
4738
|
-
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
|
|
4739
|
-
if (!reviews) return
|
|
4740
5214
|
// Its access asked again, as it may have been taken while npm was read.
|
|
4741
|
-
|
|
4742
|
-
sendJson(res, 200, { ...page, reviews })
|
|
5215
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user, teamId)
|
|
5216
|
+
if (reviews) sendJson(res, 200, { ...page, reviews })
|
|
4743
5217
|
return
|
|
4744
5218
|
}
|
|
4745
5219
|
if (path === '/api/npm/reviews') {
|
|
4746
5220
|
await handleNpmReviews(req, res, deps, cookie, url.searchParams); return
|
|
4747
5221
|
}
|
|
5222
|
+
if (path === NPM_DELTAS_PATH) { await handleNpmDeltas(req, res, deps, cookie, url.searchParams); return }
|
|
4748
5223
|
if (NPM_PATHS.has(path)) {
|
|
4749
5224
|
await handleNpm(req, res, deps, cookie, path, url.searchParams); return
|
|
4750
5225
|
}
|
|
@@ -4754,12 +5229,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4754
5229
|
sendJson(res, 404, { error: 'not-found' }, { connection: 'close' })
|
|
4755
5230
|
}
|
|
4756
5231
|
|
|
4757
|
-
|
|
5232
|
+
// Each request's time told in its Server-Timing header (request-timing.ts).
|
|
5233
|
+
return (req, res) => runTimed(res, () => {
|
|
4758
5234
|
if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
|
|
4759
5235
|
if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return }
|
|
4760
5236
|
// Feeds lease a connection per poll, never across streaming sleeps. This
|
|
4761
|
-
// also covers capability-authenticated feeds handled by public-workspace
|
|
4762
|
-
|
|
5237
|
+
// also covers capability-authenticated feeds handled by public-workspace;
|
|
5238
|
+
// and a team's dependencies and npm's deltas, streamed as GitHub and npm
|
|
5239
|
+
// are waited on, lease one a statement.
|
|
5240
|
+
const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '') || /^\/api\/(?:teams\/[^/?]+\/dependencies|npm\/deltas)(?:\?|$)/u.test(req.url ?? '')
|
|
4763
5241
|
const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
|
|
4764
5242
|
const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
|
|
4765
5243
|
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
@@ -4769,5 +5247,5 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4769
5247
|
})
|
|
4770
5248
|
track(work)
|
|
4771
5249
|
return work
|
|
4772
|
-
}
|
|
5250
|
+
})
|
|
4773
5251
|
}
|