@preventive/triage 1.0.0-alpha.45 → 1.0.0-alpha.47
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/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +5 -1
- package/common/line-diff.js +5 -4
- package/common/managed/agent-tasks.ts +5 -3
- package/common/managed/github-agents.ts +28 -10
- package/out/activity.js +4 -0
- package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
- package/out/chunk-5AHIQFFG.js +36 -0
- package/out/chunk-6ANSHRFQ.js +6 -0
- package/out/chunk-6WF3KNTL.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/{chunk-HMAJDVXN.js → chunk-FH24VHSJ.js} +1 -1
- package/out/chunk-NOZJULWS.js +52 -0
- package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
- package/out/chunk-S6U2J62U.js +232 -0
- package/out/chunk-SPEFPML6.js +2 -0
- package/out/chunk-UENXT2HK.js +3 -0
- package/out/chunk-VL43BTSO.js +1 -0
- package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
- package/out/chunk-ZCS4UYHI.js +1 -0
- package/out/chunk-ZGNMDGSB.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +15 -15
- package/out/client-sync.js +2 -2
- package/out/compare.js +12 -12
- package/out/dialogs.js +28 -28
- package/out/graph.js +11 -11
- package/out/index.html +11 -11
- package/out/scan.js +2 -2
- package/out/terminal.js +6 -6
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +4 -1
- package/risk/actions/findings.js +14 -9
- package/risk/actions/scripts/shell.js +5 -1
- package/risk/index.d.ts +9 -4
- package/risk/npm/assess.js +15 -4
- package/risk/npm/registry.js +6 -1
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/activity.ts +24 -11
- package/server-managed/app-agents.ts +10 -21
- package/server-managed/bundle-build-worker.js +7 -3
- package/server-managed/bundle-build.ts +5 -3
- package/server-managed/bundle-cache.ts +16 -4
- package/server-managed/bundle.ts +3 -4
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-coauthors.ts +5 -9
- package/server-managed/commit-lockfiles.ts +21 -11
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +321 -156
- package/server-managed/db-neon.ts +71 -24
- package/server-managed/db-schema.ts +4 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/dependency-reviews.ts +13 -3
- package/server-managed/github-app.ts +12 -6
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +269 -143
- package/server-managed/index.ts +8 -7
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/link-reports.ts +5 -1
- package/server-managed/npm-delta.ts +11 -6
- package/server-managed/npm-kept.ts +51 -7
- package/server-managed/npm-loads.ts +117 -35
- package/server-managed/npm-packages.ts +107 -14
- package/server-managed/npm-risk.ts +68 -18
- package/server-managed/pretty-print.ts +112 -35
- package/server-managed/pull-dependencies.ts +29 -9
- package/server-managed/pull-dependency-reviews.ts +8 -8
- package/server-managed/queue.ts +16 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +50 -12
- package/server-managed/repository-actions.ts +15 -6
- package/server-managed/repository-agents.ts +1 -1
- package/server-managed/repository-browser.ts +123 -33
- package/server-managed/repository-code.ts +28 -11
- package/server-managed/repository-dependency-reviews.ts +41 -19
- package/server-managed/repository-history.ts +24 -8
- package/server-managed/repository-pulse.ts +9 -2
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +11 -6
- package/server-managed/repository-risk.ts +27 -20
- package/server-managed/session.ts +18 -4
- package/server-managed/solidity-entry-points.ts +5 -3
- package/server-managed/sql-postgres.ts +3 -0
- package/server-managed/sql.ts +6 -2
- package/server-managed/storage-db.ts +11 -3
- package/server-managed/storage-encryption.ts +7 -4
- package/server-managed/storage.ts +8 -4
- package/server-managed/team-feed.ts +3 -1
- package/server-managed/team-reports.ts +30 -13
- package/server-managed/uploads.ts +29 -9
- package/server-managed/upstream-cache.ts +12 -0
- package/server.ts +14 -5
- package/out/chunk-5DHL5NG3.js +0 -2
- package/out/chunk-6HNXNIQY.js +0 -52
- package/out/chunk-AFLZH7WG.js +0 -1
- package/out/chunk-NV7ES4EW.js +0 -1
- package/out/chunk-PTGDYLNS.js +0 -36
- package/out/chunk-Q6MVYGYV.js +0 -3
- package/out/chunk-QRD6GIG5.js +0 -229
- package/out/chunk-WI7Z5A2T.js +0 -6
- package/out/chunk-WQ74ZBZF.js +0 -7
- package/out/chunk-XVBEOGOS.js +0 -3
package/server-managed/http.ts
CHANGED
|
@@ -51,9 +51,10 @@
|
|
|
51
51
|
// description's Markdown | 400/401/403/404/409/429/502
|
|
52
52
|
// GET /api/admin/repositories/pull-dependencies?repoId=&number= → the package versions a pull request's npm, pnpm and
|
|
53
53
|
// Yarn 1 lockfiles lock that they didn't at its merge base, an update's with those it was, by directory; with review=1, each's
|
|
54
|
-
// review, where its repository reviews dependencies
|
|
54
|
+
// review, where its repository reviews dependencies; with numbers=1,2,3 (twenty at most) in place of number, those of each,
|
|
55
|
+
// { pulls }, one GitHub fails told { number, error } | 400/401/403/404/409/429/502
|
|
55
56
|
// GET /api/admin/repositories/branch-dependencies?repoId= → the versions the default branch's lockfiles lock that want review, where
|
|
56
|
-
// its review is in effect for the reader | 400/401/403/404/409/429/502
|
|
57
|
+
// its review is in effect for the reader, GitHub not asked where it reviews none | 400/401/403/404/409/429/502
|
|
57
58
|
// GET /api/admin/repositories/lockfiles?repoId= → the lockfiles of the default branch its dependency review reads, by their
|
|
58
59
|
// paths, for its settings to name | 400/401/403/404/409/429/502
|
|
59
60
|
// GET /api/admin/repositories/pulse?repoId=&weeks=1|4|13|52 → the default branch's commits and lines a week and a day over
|
|
@@ -134,7 +135,7 @@ import { mergeLinkGroups } from './link-reports.ts'
|
|
|
134
135
|
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
135
136
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
136
137
|
import { pipeline } from 'node:stream/promises'
|
|
137
|
-
import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmVersionStore, pullDependencyStore,
|
|
138
|
+
import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmPublishTimeStore, npmVersionStore, ownKeptPrunes, pullDependencyStore,
|
|
138
139
|
repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
139
140
|
import { type NpmDelta, npmDelta } from './npm-delta.ts'
|
|
140
141
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
@@ -150,7 +151,7 @@ import { contentAccess } from './content-access.ts'
|
|
|
150
151
|
import type { BundleStore } from './bundle-store.ts'
|
|
151
152
|
import { Buffer } from 'node:buffer'
|
|
152
153
|
import { createHash, randomUUID } from 'node:crypto'
|
|
153
|
-
import type { AvatarStore } from './avatar-store.ts'
|
|
154
|
+
import type { AvatarStore, CachedAvatar } from './avatar-store.ts'
|
|
154
155
|
import type { BlobStore, OpenedBlob } from './blob-store.ts'
|
|
155
156
|
import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
|
|
156
157
|
import { resolveRepositoryImportLocation } from './repository-aliases.ts'
|
|
@@ -180,8 +181,9 @@ import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines
|
|
|
180
181
|
import { WORKFLOWS_DIR } from './repository-actions.ts'
|
|
181
182
|
import { githubActionsSource } from './github-actions.ts'
|
|
182
183
|
import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
|
|
184
|
+
import { type Lru, lru } from './lru.ts'
|
|
183
185
|
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
184
|
-
import { clearCookie, endSession, readSession } from './session.ts'
|
|
186
|
+
import { clearCookie, endSession, readSession, runRequestPresence } from './session.ts'
|
|
185
187
|
import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
|
|
186
188
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
187
189
|
import { acceptsReportMetadata } from './report-response.ts'
|
|
@@ -196,7 +198,7 @@ import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallb
|
|
|
196
198
|
import type { ProfileChoice } from './github-links.ts'
|
|
197
199
|
import { withAppUsers } from './github-users.ts'
|
|
198
200
|
import { isBlobId } from './object-storage.ts'
|
|
199
|
-
import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
|
|
201
|
+
import { attachmentDisposition, sendJson, sendLines, writeResponse, writeResponseParts } from './http-response.ts'
|
|
200
202
|
import { triageWireEntry } from './triage-response.ts'
|
|
201
203
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
202
204
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
@@ -205,7 +207,8 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
|
205
207
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
206
208
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
207
209
|
import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
|
|
208
|
-
import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions
|
|
210
|
+
import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions,
|
|
211
|
+
readTogether } from './npm-packages.ts'
|
|
209
212
|
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
210
213
|
import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
|
|
211
214
|
import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
|
|
@@ -214,8 +217,8 @@ import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE,
|
|
|
214
217
|
repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
|
|
215
218
|
import { AGENTS_READ_VERSION, APP_AGENTS, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
|
|
216
219
|
import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
|
|
217
|
-
import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat,
|
|
218
|
-
import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
|
|
220
|
+
import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
|
|
221
|
+
import { type GithubAsked, lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
|
|
219
222
|
import { MAX_REQUIRED_REVIEWS, SINCE_COMMIT } from './repository-dependency-reviews.ts'
|
|
220
223
|
import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
|
|
221
224
|
import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
|
|
@@ -335,7 +338,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
335
338
|
if (!s) return
|
|
336
339
|
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
|
|
337
340
|
const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
|
|
338
|
-
const manualUrls = storedFixUrls(await deps.db.
|
|
341
|
+
const manualUrls = storedFixUrls(await deps.db.listTriageFixes(ids))
|
|
339
342
|
const storedIssues = await deps.db.listManagedIssues(ids)
|
|
340
343
|
const issues = visibleManagedIssues(storedIssues, snapshot)
|
|
341
344
|
const visibleIds = new Set(issues.map(issue => issue.findingId))
|
|
@@ -346,7 +349,7 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
346
349
|
: { fixes: await lookupFixes(deps.config, deps.db, snapshot, urls), updates: [] }
|
|
347
350
|
// Cold report reads, token refresh and upstream batches can outlive changes
|
|
348
351
|
// to security/links, team grants, publication, sessions or the Fix links.
|
|
349
|
-
const currentUrls = storedFixUrls(await deps.db.
|
|
352
|
+
const currentUrls = storedFixUrls(await deps.db.listTriageFixes(ids))
|
|
350
353
|
if (await readWorkspaceSession(res, deps, cookie) == null) return
|
|
351
354
|
await recheckTeam(deps.db, s.session.id, snapshot)
|
|
352
355
|
if (JSON.stringify(currentUrls) !== JSON.stringify(manualUrls)) {
|
|
@@ -385,9 +388,8 @@ function activity(deps: ManagedHttpDeps, user: StoredUser, kind: ActivityInput['
|
|
|
385
388
|
return deps.db.recordActivity({ kind, actor: user.login, actorId: user.id, action, ...context }, Date.now())
|
|
386
389
|
}
|
|
387
390
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
return (await deps.db.listAllRepos()).find(repo => repo.repoId === repoId)?.fullName ?? null
|
|
391
|
+
function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
|
|
392
|
+
return repoId == null ? Promise.resolve(null) : deps.db.getRepoFullName(repoId)
|
|
391
393
|
}
|
|
392
394
|
|
|
393
395
|
// Workspace history is authorized BEFORE filtering, counting, or paging.
|
|
@@ -411,8 +413,14 @@ async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie:
|
|
|
411
413
|
if (s.user.role !== 'admin') {
|
|
412
414
|
const findings = new Map<string, ActivityContext>()
|
|
413
415
|
const reports = kind === 'all' || kind === 'triage' ? await deps.db.listActivityReports(s.user.id) : []
|
|
416
|
+
// Every report's record in one read, as visibleFindingIds reads each one's.
|
|
417
|
+
const snapshot = reports.length > 0 ? await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), reports.map(report => report.reportId)) : { reports: [] }
|
|
418
|
+
if (snapshot == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
419
|
+
const records = new Map(snapshot.reports.map(record => [record.id, record]))
|
|
414
420
|
for (const report of reports) {
|
|
415
|
-
|
|
421
|
+
const record = records.get(report.reportId)
|
|
422
|
+
if (record == null) continue
|
|
423
|
+
for (const finding of await reportFindingIds(deps, record, record.repo.github)) {
|
|
416
424
|
if (!findings.has(finding)) findings.set(finding, { finding, ...report })
|
|
417
425
|
}
|
|
418
426
|
}
|
|
@@ -436,6 +444,9 @@ export interface ManagedHttpDeps {
|
|
|
436
444
|
originGate: OriginGate
|
|
437
445
|
isShuttingDown: () => boolean
|
|
438
446
|
track: (p: Promise<unknown>) => void
|
|
447
|
+
// Serverless (Vercel's): keeps the invocation alive for work that goes on
|
|
448
|
+
// past its answer, as a kept read read again behind it does.
|
|
449
|
+
waitUntil?: (p: Promise<unknown>) => void
|
|
439
450
|
// Combined boot delegates unmatched paths to e2e and overrides discovery.
|
|
440
451
|
serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
|
|
441
452
|
next?: Handler
|
|
@@ -458,14 +469,18 @@ function firstHeader(v: string | string[] | undefined): string | null {
|
|
|
458
469
|
}
|
|
459
470
|
|
|
460
471
|
// `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
|
|
461
|
-
|
|
462
|
-
|
|
472
|
+
// A user's is never kept by the browser, as it changes with their profile.
|
|
473
|
+
async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id, cacheControl = 'no-store'): Promise<void> {
|
|
474
|
+
sendAvatar(res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function sendAvatar(res: ServerResponse, avatar: CachedAvatar | null, cacheControl = 'no-store'): void {
|
|
463
478
|
if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
|
|
464
479
|
res.writeHead(200, {
|
|
465
480
|
'content-type': avatar.contentType,
|
|
466
481
|
'content-length': String(avatar.bytes.length),
|
|
467
482
|
'x-content-type-options': 'nosniff',
|
|
468
|
-
'cache-control':
|
|
483
|
+
'cache-control': cacheControl,
|
|
469
484
|
})
|
|
470
485
|
res.end(avatar.bytes)
|
|
471
486
|
}
|
|
@@ -479,15 +494,20 @@ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: st
|
|
|
479
494
|
const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
|
|
480
495
|
.flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
|
|
481
496
|
const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
|
|
497
|
+
// An agent's mark is a public GitHub image that never changes with anyone's
|
|
498
|
+
// profile, drawn on every tile and comment by it: kept by the browser a day,
|
|
499
|
+
// for it alone.
|
|
500
|
+
const AGENT_AVATAR_CACHE = 'private, max-age=86400'
|
|
482
501
|
|
|
483
502
|
async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
|
|
484
503
|
const [, kind, id] = AGENT_AVATAR.exec(avatar)!
|
|
485
504
|
const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
|
|
505
|
+
// Read once where it is kept; read again only past fetching it.
|
|
506
|
+
const kept = await avatarStore.get(key)
|
|
507
|
+
if (kept != null) { sendAvatar(res, kept, AGENT_AVATAR_CACHE); return }
|
|
508
|
+
await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
|
|
509
|
+
.catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
|
|
510
|
+
await serveAvatar(res, avatarStore, key, key, AGENT_AVATAR_CACHE)
|
|
491
511
|
}
|
|
492
512
|
|
|
493
513
|
const MAX_JSON_BODY_BYTES = 4096
|
|
@@ -508,14 +528,17 @@ async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Bu
|
|
|
508
528
|
}
|
|
509
529
|
|
|
510
530
|
// A file upload's content, opening a body the browser sealed to this session
|
|
511
|
-
// (common/managed/upload-seal.ts). Link reports are never sent in parts
|
|
531
|
+
// (common/managed/upload-seal.ts). Link reports are never sent in parts; the
|
|
532
|
+
// parts of one sent in them are deleted behind its answer.
|
|
512
533
|
async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: ManagedSession, kind: UploadKind | 'links'): Promise<Buffer> {
|
|
513
534
|
const maxBytes = kind === 'bundles' ? deps.config.maxBundleBytes : deps.config.maxReportBytes
|
|
514
535
|
const sealed = req.headers[UPLOAD_SEAL_HEADER] !== undefined
|
|
515
536
|
const limit = sealed ? maxSealedBytes(maxBytes) : maxBytes
|
|
516
537
|
let bytes
|
|
517
538
|
if (kind === 'links' || req.headers['x-upload-id'] == null) bytes = await readBodyBytes(req, limit)
|
|
518
|
-
else if (deps.uploadStore)
|
|
539
|
+
else if (deps.uploadStore) {
|
|
540
|
+
bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit, cleanup => { deps.track(cleanup); deps.waitUntil?.(cleanup) })
|
|
541
|
+
}
|
|
519
542
|
else throw new Error('bad-upload')
|
|
520
543
|
return sealed ? await openSessionUpload(session.uploadKey, bytes, maxBytes) : bytes
|
|
521
544
|
}
|
|
@@ -630,7 +653,7 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
|
|
|
630
653
|
const userId = (body as { userId?: unknown } | null)?.userId
|
|
631
654
|
if (typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
632
655
|
if (userId === s.user.id) { sendJson(res, 400, { error: 'cannot-view-as-self' }); return }
|
|
633
|
-
const target =
|
|
656
|
+
const target = await deps.db.getUser(userId)
|
|
634
657
|
if (!target) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
635
658
|
// The insert itself rechecks the admin session and role.
|
|
636
659
|
const setCookie = await startViewSession(deps.config, deps.db, s.session, userId, Date.now())
|
|
@@ -713,7 +736,7 @@ async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: Ma
|
|
|
713
736
|
const role = (body as { role?: unknown } | null)?.role
|
|
714
737
|
if (typeof userId !== 'string' || !isRole(role)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
715
738
|
if (userId === s.user.id) { sendJson(res, 403, { error: 'cannot-change-own-role' }); return }
|
|
716
|
-
const target =
|
|
739
|
+
const target = await deps.db.getUser(userId)
|
|
717
740
|
// A caller can hold the body open while their admin access is revoked.
|
|
718
741
|
if (await readAdminSession(res, deps, cookie) == null) return
|
|
719
742
|
const ok = await deps.db.setUserRole(userId, role)
|
|
@@ -1250,8 +1273,8 @@ async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, ses
|
|
|
1250
1273
|
// declared integrity that has a stored bundle wins (bundleId set). When none is
|
|
1251
1274
|
// stored yet, keep the first declared integrity so a later bundle upload of it
|
|
1252
1275
|
// re-links (see linkReportsToBundle). Returns the (bundleId, integrity) to store.
|
|
1253
|
-
async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser,
|
|
1254
|
-
const hashes = reportBundleHashes(
|
|
1276
|
+
async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, json: unknown): Promise<{ bundleId: string | null; integrity: string | null }> {
|
|
1277
|
+
const hashes = reportBundleHashes(json)
|
|
1255
1278
|
for (const h of hashes) {
|
|
1256
1279
|
const b = await deps.db.getBundleByIntegrity(h)
|
|
1257
1280
|
if (b != null && await canAccessBundle(deps, user, b.id)) return { bundleId: b.id, integrity: h }
|
|
@@ -1361,7 +1384,8 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1361
1384
|
if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
|
|
1362
1385
|
const id = randomUUID()
|
|
1363
1386
|
const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
|
|
1364
|
-
|
|
1387
|
+
// A JSON report's parse already in hand, not parsed again for its hashes.
|
|
1388
|
+
const { bundleId, integrity } = await resolveReportBundle(deps, s.user, parsed.format === 'json' ? parsed.data : null)
|
|
1365
1389
|
const dataKey = await deps.reportStore.put(id, bytes)
|
|
1366
1390
|
let report: ReportRecord
|
|
1367
1391
|
try {
|
|
@@ -1451,8 +1475,10 @@ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
|
|
|
1451
1475
|
// user's repository access, for the next catalog to send.
|
|
1452
1476
|
async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, missing: readonly CatalogCommit[]): Promise<void> {
|
|
1453
1477
|
let browser: ReturnType<typeof createRepositoryBrowser> | undefined
|
|
1478
|
+
let repos: ReturnType<ManagedDb['listAllRepos']> | undefined
|
|
1454
1479
|
await backfillBundleCommits(deps.db, userId, missing, async repoId => {
|
|
1455
|
-
|
|
1480
|
+
// Read once for the repositories the backfill reaches, not once each.
|
|
1481
|
+
const repo = (await (repos ??= deps.db.listAllRepos())).find(item => item.repoId === repoId)
|
|
1456
1482
|
if (!repo) return null
|
|
1457
1483
|
browser ??= createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, userId))
|
|
1458
1484
|
return browser.reader(repo)
|
|
@@ -1524,9 +1550,11 @@ function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
|
|
|
1524
1550
|
async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
|
|
1525
1551
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1526
1552
|
if (!s) return null
|
|
1527
|
-
|
|
1553
|
+
// bundleRepos's reads, the reader's scopes read once for both.
|
|
1554
|
+
const access = await contentAccess(deps.db, s.user)
|
|
1555
|
+
const repo = (await deps.db.listSelectedRepos()).filter(access.bundle).find(item => item.repoId === repoId)
|
|
1528
1556
|
if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
|
|
1529
|
-
const scopes =
|
|
1557
|
+
const scopes = access.scopes == null ? [null] : access.scopes.filter(scope => scope.repoId === repoId).map(scope => scope.path)
|
|
1530
1558
|
return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
|
|
1531
1559
|
}
|
|
1532
1560
|
|
|
@@ -1592,6 +1620,22 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1592
1620
|
// read, before any of it is sent. The load stays held until this response is
|
|
1593
1621
|
// written or abandoned, and a reader who stops reading it gives it up after
|
|
1594
1622
|
// a while.
|
|
1623
|
+
// What GitHub answered of a repository's trees for the code viewer, which a
|
|
1624
|
+
// commit or tree it names never changes: the tree at a directory of a commit
|
|
1625
|
+
// (getRepoTreeId reads the commit, then a listing a level down to it), and
|
|
1626
|
+
// how many files a tree holds under it and their bytes (repositoryTreeFiles),
|
|
1627
|
+
// by the repository's id, for the next to open it. Asked of them only once
|
|
1628
|
+
// the reader's own access has named the commit, as GitHub would have
|
|
1629
|
+
// answered them; by the database, as each instance has one.
|
|
1630
|
+
const REPOSITORY_TREES_KEPT = 4096
|
|
1631
|
+
type TreeFiles = Awaited<ReturnType<typeof repositoryTreeFiles>>
|
|
1632
|
+
const repositoryTrees = new WeakMap<ManagedDb, { ids: Lru<string>; files: Lru<TreeFiles> }>()
|
|
1633
|
+
function repositoryTreesOf(db: ManagedDb) {
|
|
1634
|
+
let kept = repositoryTrees.get(db)
|
|
1635
|
+
if (!kept) repositoryTrees.set(db, kept = { ids: lru(REPOSITORY_TREES_KEPT), files: lru(REPOSITORY_TREES_KEPT) })
|
|
1636
|
+
return kept
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1595
1639
|
async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1596
1640
|
const asked = repositoryQuery(res, query)
|
|
1597
1641
|
if (!asked) return
|
|
@@ -1608,10 +1652,12 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1608
1652
|
const commit = await reader.commit(ref)
|
|
1609
1653
|
const repo = access.repo.fullName
|
|
1610
1654
|
const client = createClient({ token: reader.readToken() })
|
|
1655
|
+
const treeKey = `${access.repo.repoId}\0${commit}\0${path}`, trees = repositoryTreesOf(deps.db)
|
|
1611
1656
|
// A directory the commit has none at is none to read, as browsing it says.
|
|
1612
|
-
const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1657
|
+
const tree = trees.ids.get(treeKey) ?? await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1613
1658
|
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
|
|
1614
1659
|
}))
|
|
1660
|
+
trees.ids.set(treeKey, tree)
|
|
1615
1661
|
// Access still held, and the same repository, before anything read is sent.
|
|
1616
1662
|
const fenced = async () => {
|
|
1617
1663
|
await reader.recheckAccess()
|
|
@@ -1621,12 +1667,15 @@ async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1621
1667
|
return true
|
|
1622
1668
|
}
|
|
1623
1669
|
// Too large names the commit it was read at, for History to read.
|
|
1624
|
-
|
|
1670
|
+
const filesKey = `${access.repo.repoId}\0${tree}`
|
|
1671
|
+
let listed = trees.files.get(filesKey)
|
|
1672
|
+
if (listed === undefined) trees.files.set(filesKey, listed = await repositoryTreeFiles(repo, reader.readToken(), tree))
|
|
1673
|
+
if (!repositoryCodeFits(listed)) {
|
|
1625
1674
|
if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
|
|
1626
1675
|
return
|
|
1627
1676
|
}
|
|
1628
1677
|
if (res.destroyed) return
|
|
1629
|
-
const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
|
|
1678
|
+
const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree, listed }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
|
|
1630
1679
|
res.once('close', load.release)
|
|
1631
1680
|
const bytes = await load.result
|
|
1632
1681
|
if (!(await fenced()) || res.destroyed) return
|
|
@@ -1679,12 +1728,22 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1679
1728
|
}
|
|
1680
1729
|
const access = await authorize()
|
|
1681
1730
|
if (!access) return
|
|
1731
|
+
let readingPulls: Promise<{ number: number; sha: string }[] | null> = Promise.resolve(null)
|
|
1682
1732
|
try {
|
|
1683
1733
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1684
1734
|
const repo = access.repo.fullName
|
|
1685
1735
|
const token = reader.readToken()
|
|
1686
1736
|
// With a token, as GraphQL reads them.
|
|
1687
1737
|
const pages = historyPages(deps, repoId, token !== null)
|
|
1738
|
+
const quiet = <T>(fallback: T) => (err: unknown) => {
|
|
1739
|
+
if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
|
|
1740
|
+
return fallback
|
|
1741
|
+
}
|
|
1742
|
+
// Open pull heads do not depend on the commit history. Start them with
|
|
1743
|
+
// it, handling failures immediately even if the history fails first.
|
|
1744
|
+
if (token && path === '' && page === 1) {
|
|
1745
|
+
readingPulls = keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null))
|
|
1746
|
+
}
|
|
1688
1747
|
let body: object
|
|
1689
1748
|
if (all) {
|
|
1690
1749
|
if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
|
|
@@ -1719,13 +1778,9 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1719
1778
|
// naming commits: with a token, as far as GitHub answers them, the
|
|
1720
1779
|
// history sent all the same where it doesn't.
|
|
1721
1780
|
const listed = (body as { commits: HistoryCommit[] }).commits
|
|
1722
|
-
const quiet = <T>(fallback: T) => (err: unknown) => {
|
|
1723
|
-
if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
|
|
1724
|
-
return fallback
|
|
1725
|
-
}
|
|
1726
1781
|
const [pulls, openPulls] = token && path === '' ? await Promise.all([
|
|
1727
1782
|
squashedPulls(repo, token, listed, historySquashes(deps, repoId)).catch(quiet<SquashedPull[]>([])),
|
|
1728
|
-
|
|
1783
|
+
readingPulls,
|
|
1729
1784
|
]) : [[], null]
|
|
1730
1785
|
await reader.recheckAccess()
|
|
1731
1786
|
const current = await authorize()
|
|
@@ -1740,6 +1795,10 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1740
1795
|
} catch (err) {
|
|
1741
1796
|
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1742
1797
|
throw err
|
|
1798
|
+
} finally {
|
|
1799
|
+
// A failed history must not release the request's database lease while
|
|
1800
|
+
// its independent pull read is still writing to the cache.
|
|
1801
|
+
await readingPulls
|
|
1743
1802
|
}
|
|
1744
1803
|
}
|
|
1745
1804
|
|
|
@@ -1840,9 +1899,9 @@ const refusedRead = (err: unknown) => err instanceof GithubApiError
|
|
|
1840
1899
|
// What `read` answers of a repository, kept by `key` (its id first) for every
|
|
1841
1900
|
// reader (repositoryReadStore), each one's access checked as they ask before
|
|
1842
1901
|
// this: answered as kept for REPOSITORY_READ_FRESH_MS, then, while it is
|
|
1843
|
-
// kept, answered at once and read again behind it for the next to ask,
|
|
1844
|
-
//
|
|
1845
|
-
//
|
|
1902
|
+
// kept, answered at once and read again behind it for the next to ask, the
|
|
1903
|
+
// read tracked (serverless, its invocation kept alive till it settles,
|
|
1904
|
+
// deps.waitUntil); `kept` where it was, or where reading it again
|
|
1846
1905
|
// failed as `keepThrough` lets it. One asked for afresh (`fresh`) is read
|
|
1847
1906
|
// now, unless it was read REPOSITORY_READ_FLOOR_MS since, what was kept
|
|
1848
1907
|
// answered where that read fails. One GitHub refuses to read again is no
|
|
@@ -1877,10 +1936,12 @@ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string,
|
|
|
1877
1936
|
}
|
|
1878
1937
|
const age = held ? Date.now() - held.at : Infinity
|
|
1879
1938
|
const now = fresh && age >= REPOSITORY_READ_FLOOR_MS
|
|
1880
|
-
if (held && !now
|
|
1939
|
+
if (held && !now) {
|
|
1881
1940
|
if (age >= REPOSITORY_READ_FRESH_MS && !repositoryRereads.has(key)) {
|
|
1882
1941
|
// Its failure leaves the kept answer sent until it is a day old, so it is said.
|
|
1883
|
-
|
|
1942
|
+
const behind = reread().catch((err: unknown) => { console.warn('managed: repository read failed:', err) })
|
|
1943
|
+
deps.track(behind)
|
|
1944
|
+
deps.waitUntil?.(behind)
|
|
1884
1945
|
}
|
|
1885
1946
|
return { value: held.value as T, kept: true, at: held.at }
|
|
1886
1947
|
}
|
|
@@ -1902,14 +1963,16 @@ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string,
|
|
|
1902
1963
|
function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): HistoryPages {
|
|
1903
1964
|
const store = historyPageStore<HistoryCommit[]>(deps.db, deps.config.debug)
|
|
1904
1965
|
const keyOf = (top: string) => `${repoId}\0${COAUTHORS_READ}\0${top}`
|
|
1966
|
+
const told = (tops: string[], kept: Map<string, { value: HistoryCommit[] }>) => new Map(tops.flatMap(top => {
|
|
1967
|
+
const page = kept.get(keyOf(top))?.value
|
|
1968
|
+
return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
|
|
1969
|
+
? [[top, page] as const] : []
|
|
1970
|
+
}))
|
|
1905
1971
|
return {
|
|
1906
|
-
async
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
|
|
1911
|
-
? [[top, page] as const] : []
|
|
1912
|
-
}))
|
|
1972
|
+
read: async tops => told(tops, await store.readMany(tops.map(keyOf))),
|
|
1973
|
+
async readFirst(first, then) {
|
|
1974
|
+
const kept = await store.readFirst(first.map(keyOf), then.map(keyOf))
|
|
1975
|
+
return { first: kept.first, pages: told(kept.first ? first : then, kept.found) }
|
|
1913
1976
|
},
|
|
1914
1977
|
write: (top, commits) => store.write(keyOf(top), commits, Date.now()),
|
|
1915
1978
|
}
|
|
@@ -1955,9 +2018,11 @@ async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, c
|
|
|
1955
2018
|
// What `answer` gives of the whole of repository `repoId` (with its name and
|
|
1956
2019
|
// a token), as readWholeRepository reads it: sent at once where it was kept,
|
|
1957
2020
|
// else once access is checked again, with the app's users it names
|
|
1958
|
-
// (github-users.ts), never kept with it
|
|
2021
|
+
// (github-users.ts), never kept with it; or what `unread` tells of it, the
|
|
2022
|
+
// app's alone, once the app's access is checked, GitHub then never asked.
|
|
1959
2023
|
async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
|
|
1960
|
-
answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }
|
|
2024
|
+
answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>,
|
|
2025
|
+
unread: () => Promise<object | null> = () => Promise.resolve(null)): Promise<void> {
|
|
1961
2026
|
const authorize = async () => {
|
|
1962
2027
|
const access = await authorizeRepository(res, deps, cookie, repoId, '')
|
|
1963
2028
|
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
@@ -1965,6 +2030,8 @@ async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1965
2030
|
}
|
|
1966
2031
|
const access = await authorize()
|
|
1967
2032
|
if (!access) return
|
|
2033
|
+
const told = await unread()
|
|
2034
|
+
if (told) { sendJson(res, 200, await withAppUsers(deps.db, told)); return }
|
|
1968
2035
|
try {
|
|
1969
2036
|
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1970
2037
|
const token = reader.readToken()
|
|
@@ -2033,72 +2100,108 @@ function itemNumber(query: URLSearchParams): number | null {
|
|
|
2033
2100
|
// GET /api/admin/repositories/pull-dependencies: the package versions pull
|
|
2034
2101
|
// request `number`'s lockfiles add (pull-dependencies.ts), what its base's and head's
|
|
2035
2102
|
// commits tell kept by them (pullDependencyKept), its `number` with them.
|
|
2036
|
-
// They are the whole repository's (answerWholeRepository),
|
|
2037
|
-
// as its popup's item is, its head moving with each
|
|
2103
|
+
// They are the whole repository's (answerWholeRepository), the commits they
|
|
2104
|
+
// are of kept by its number as its popup's item is, its head moving with each
|
|
2105
|
+
// push, what they tell kept by them alone, not again. With `review=1`,
|
|
2038
2106
|
// as an open one's are asked, where the repository reviews dependencies,
|
|
2039
2107
|
// each version's review (pull-dependency-reviews.ts), read afresh as each
|
|
2040
2108
|
// asks, reviews being published meanwhile; its baseline kept, where the host
|
|
2041
2109
|
// registered no reviewed dependencies, which are asked in its place.
|
|
2110
|
+
// Up to MAX_ASKED_PULLS pull requests asked together (`numbers=1,2,3`) are
|
|
2111
|
+
// answered together, as many read at once as PULLS_AT_ONCE, `{ pulls }`,
|
|
2112
|
+
// each's answer, else `{ number, error }` where GitHub fails it, with one
|
|
2113
|
+
// check of its access before and one after, but where each was kept.
|
|
2114
|
+
const MAX_ASKED_PULLS = 20
|
|
2115
|
+
const PULLS_AT_ONCE = 3
|
|
2042
2116
|
async function handleRepositoryPullDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2043
2117
|
const repoId = Number(query.get('repoId'))
|
|
2044
|
-
const number = itemNumber(query)
|
|
2045
2118
|
const review = query.get('review') === '1'
|
|
2046
|
-
|
|
2119
|
+
const listed = query.get('numbers')
|
|
2120
|
+
const numbers = listed === null ? [itemNumber(query)] : listed.split(',').map(text => itemNumber(new URLSearchParams({ number: text })))
|
|
2121
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || numbers.length > MAX_ASKED_PULLS || numbers.some(number => number === null) || new Set(numbers).size < numbers.length
|
|
2122
|
+
|| !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2047
2123
|
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
// Its review by the lockfiles they were told by, though they have changed
|
|
2056
|
-
// since; turned off meanwhile, all of them, as where it is off.
|
|
2057
|
-
const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
|
|
2058
|
-
const told = review && !now && setting?.enabled && !(await deps.db.listRepoDependencyReviews()).get(repoId)?.enabled ? answered.value : held
|
|
2059
|
-
const reviewed = deps.reviewedDependencies ?? null
|
|
2060
|
-
const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
|
|
2061
|
-
// Its comments read for reviews, where the host reads them, while it is
|
|
2062
|
-
// open (pull-comment-reviews.ts reads whether it is with them).
|
|
2063
|
-
const parse = reviewed?.parseComments
|
|
2064
|
-
const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
|
|
2065
|
-
// The comment the host formats of the reader's reviews of its versions, for its copy button.
|
|
2066
|
-
const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
|
|
2067
|
-
return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
|
|
2068
|
-
...formatted ? { formatted } : {} }, kept: answered.kept }
|
|
2124
|
+
if (listed === null) return pullDependencyAnswer(deps, fullName, token, user, repoId, numbers[0]!, review)
|
|
2125
|
+
const answers = await eachOf(numbers as number[], PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, review).catch((err: unknown) => {
|
|
2126
|
+
// GitHub asked, though it failed: its access asked again after.
|
|
2127
|
+
if (err instanceof GithubApiError) return { value: { number, error: err.message }, kept: false }
|
|
2128
|
+
throw err
|
|
2129
|
+
}))
|
|
2130
|
+
return { value: { pulls: answers.map(({ value }) => value) }, kept: answers.every(({ kept }) => kept) }
|
|
2069
2131
|
})
|
|
2070
2132
|
}
|
|
2071
2133
|
|
|
2134
|
+
// 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
|
+
async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, number: number, review: boolean) {
|
|
2137
|
+
const kept = pullDependencyKept(deps, repoId)
|
|
2138
|
+
const reading: { told?: PullDependencies } = {}
|
|
2139
|
+
const answered = await keptAnswer(deps, `${repoId}\0pull-commits\0${PULL_DEPENDENCIES_READ}\0${number}`, async () => {
|
|
2140
|
+
const { commits, told } = await pullDependenciesAt(fullName, token, number, kept)
|
|
2141
|
+
reading.told = told
|
|
2142
|
+
return { commits }
|
|
2143
|
+
})
|
|
2144
|
+
// What its commits tell, as read, else as kept by them, else, no longer
|
|
2145
|
+
// kept, read again.
|
|
2146
|
+
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 }
|
|
2148
|
+
// Where its review is on, those of lockfiles it leaves out told to none,
|
|
2149
|
+
// whoever asks.
|
|
2150
|
+
const setting = await deps.db.repoDependencyReview(repoId)
|
|
2151
|
+
const scoped = setting?.enabled ? heldDependencies(pulled, setting.lockfiles) : pulled
|
|
2152
|
+
const now = review ? await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(scoped)) : null
|
|
2153
|
+
// Its review by the lockfiles they were told by, though they have changed
|
|
2154
|
+
// since; turned off meanwhile, all of them, as where it is off.
|
|
2155
|
+
const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
|
|
2156
|
+
const told = review && !now && setting?.enabled && !(await deps.db.repoDependencyReview(repoId))?.enabled ? pulled : scoped
|
|
2157
|
+
const reviewed = deps.reviewedDependencies ?? null
|
|
2158
|
+
const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
|
|
2159
|
+
// Its comments read for reviews, where the host reads them, while it is
|
|
2160
|
+
// open (pull-comment-reviews.ts reads whether it is with them).
|
|
2161
|
+
const parse = reviewed?.parseComments
|
|
2162
|
+
const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
|
|
2163
|
+
// The comment the host formats of the reader's reviews of its versions, for its copy button.
|
|
2164
|
+
const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
|
|
2165
|
+
return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
|
|
2166
|
+
...formatted ? { formatted } : {} }, kept: answered.kept && held !== null }
|
|
2167
|
+
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2072
2170
|
// GET /api/admin/repositories/branch-dependencies: what the lockfiles of the
|
|
2073
2171
|
// repository's default branch lock that wants review, where its review is in
|
|
2074
|
-
// effect for the reader (`review`, else none
|
|
2075
|
-
//
|
|
2076
|
-
// there its review holds to
|
|
2077
|
-
//
|
|
2078
|
-
// (pull-dependency-reviews.ts), each left unread
|
|
2079
|
-
// failing the review, as a pull request's are.
|
|
2172
|
+
// effect for the reader (`review`, else none, told without asking GitHub
|
|
2173
|
+
// where it reviews none), as a team's Dependencies tab shows it: its head, as
|
|
2174
|
+
// its pulse reads it, kept a while; every lockfile there its review holds to
|
|
2175
|
+
// (lockfile-scope.ts, commit-lockfiles.ts) as one directory, its versions as
|
|
2176
|
+
// a pull request's others are (pull-dependency-reviews.ts), each left unread
|
|
2177
|
+
// a directory of its own failing the review, as a pull request's are. Kept,
|
|
2178
|
+
// its access not asked again, where its head was kept and its lockfiles and
|
|
2179
|
+
// baseline held, GitHub asked for none of them.
|
|
2080
2180
|
async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2081
2181
|
const repoId = Number(query.get('repoId'))
|
|
2082
2182
|
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2083
2183
|
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
|
|
2084
|
-
const
|
|
2184
|
+
const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
|
|
2185
|
+
const head = headRead.value.sha
|
|
2085
2186
|
if (!head) return { value: { head: null, review: null }, kept: false }
|
|
2187
|
+
const asked: GithubAsked = { github: !headRead.kept }
|
|
2086
2188
|
// Those its review holds to alone read, as many as are read of them.
|
|
2087
|
-
const setting =
|
|
2189
|
+
const setting = await deps.db.repoDependencyReview(repoId)
|
|
2088
2190
|
if (!setting?.enabled) return { value: { head, review: null }, kept: false }
|
|
2089
|
-
const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path))
|
|
2191
|
+
const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path), asked)
|
|
2090
2192
|
const { packages, paths } = lockedIn(read)
|
|
2091
2193
|
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) },
|
|
2092
2194
|
...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
|
|
2093
2195
|
const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
|
|
2094
|
-
if (!now) return { value: { head, review: null }, kept:
|
|
2196
|
+
if (!now) return { value: { head, review: null }, kept: !asked.github }
|
|
2095
2197
|
// Told by the lockfiles it held to as they were read, though they have
|
|
2096
2198
|
// changed since.
|
|
2097
2199
|
const state = { ...now, lockfiles: setting.lockfiles }
|
|
2098
2200
|
const reviewed = deps.reviewedDependencies ?? null
|
|
2099
|
-
const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
|
|
2100
|
-
|
|
2101
|
-
|
|
2201
|
+
const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
|
|
2202
|
+
: await baselineFor(fullName, token, repoId, state, baselineKept(deps), asked)
|
|
2203
|
+
return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: !asked.github }
|
|
2204
|
+
}, async () => (await deps.db.repoDependencyReview(repoId))?.enabled ? null : { review: null })
|
|
2102
2205
|
}
|
|
2103
2206
|
|
|
2104
2207
|
// GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
|
|
@@ -2699,9 +2802,8 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2699
2802
|
throw new BundleBuildError(409, 'repository-changed')
|
|
2700
2803
|
}
|
|
2701
2804
|
signal.throwIfAborted()
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
bundleIntegrity(bytes), input.conditions)
|
|
2805
|
+
await storeUploadedBundle(req, res, deps, cookie, current, built.bytes, input.repoId, built.directory, built.filename, 'build',
|
|
2806
|
+
bundleIntegrity(built.bytes), input.conditions)
|
|
2705
2807
|
})
|
|
2706
2808
|
} catch (error) {
|
|
2707
2809
|
if (res.destroyed || controller.signal.aborted) return
|
|
@@ -2782,15 +2884,17 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
2782
2884
|
repo.repoId = null
|
|
2783
2885
|
directory = ''
|
|
2784
2886
|
}
|
|
2785
|
-
await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity)
|
|
2887
|
+
await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity, null, existing === null)
|
|
2786
2888
|
}
|
|
2787
2889
|
|
|
2890
|
+
// `absent` where the caller has just found no bundle of `integrity`: not read
|
|
2891
|
+
// again, one inserted since meeting the insert's UNIQUE below as a race does.
|
|
2788
2892
|
async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
|
|
2789
2893
|
s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
|
|
2790
|
-
provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null): Promise<void> {
|
|
2894
|
+
provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null, absent = false): Promise<void> {
|
|
2791
2895
|
const kind = bundleKind(filename)
|
|
2792
2896
|
const reuse = { provenance, filename, kind, buildConditions }
|
|
2793
|
-
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
2897
|
+
const existing = absent ? null : await deps.db.getBundleByIntegrity(integrity)
|
|
2794
2898
|
if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, reuse); return }
|
|
2795
2899
|
const id = randomUUID()
|
|
2796
2900
|
const dataKey = await deps.bundleStore.put(id, bytes, kind)
|
|
@@ -2920,12 +3024,11 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
|
|
|
2920
3024
|
// need a published report in their teams; ownership never overrides role none.
|
|
2921
3025
|
async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
2922
3026
|
if (!roleAtLeast(user.role, 'view')) return false
|
|
2923
|
-
const report = await deps.db.
|
|
3027
|
+
const report = await deps.db.getReportView(user.id, reportId)
|
|
2924
3028
|
if (report == null) return false
|
|
2925
3029
|
if (user.role === 'admin') return true
|
|
2926
|
-
if (user.role === 'manage') return report.uploadedBy === user.id ||
|
|
2927
|
-
|
|
2928
|
-
return deps.db.userCanReadReport(user.id, reportId)
|
|
3030
|
+
if (user.role === 'manage') return report.uploadedBy === user.id || report.readable
|
|
3031
|
+
return report.visible && report.readable
|
|
2929
3032
|
}
|
|
2930
3033
|
|
|
2931
3034
|
// GET/HEAD /api/reports/:id/sources. Only the linked bundle's files cited by
|
|
@@ -3205,7 +3308,7 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
|
|
|
3205
3308
|
if (outputBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
|
|
3206
3309
|
parts.push(suffix)
|
|
3207
3310
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
3208
|
-
|
|
3311
|
+
writeResponseParts(res, parts, outputBytes)
|
|
3209
3312
|
}
|
|
3210
3313
|
|
|
3211
3314
|
// The server selects the complete published workspace for classification.
|
|
@@ -3235,24 +3338,23 @@ async function visibleFindingIds(deps: ManagedHttpDeps, user: StoredUser, report
|
|
|
3235
3338
|
}
|
|
3236
3339
|
const rec = await deps.db.getReport(reportId)
|
|
3237
3340
|
if (rec == null) return new Set()
|
|
3238
|
-
|
|
3239
|
-
|
|
3341
|
+
return reportFindingIds(deps, rec, await repositoryName(deps, rec.repoId))
|
|
3342
|
+
}
|
|
3343
|
+
// A whole report's finding ids, kept by its content, name and repository,
|
|
3344
|
+
// the one read least recently dropped first.
|
|
3345
|
+
async function reportFindingIds(deps: ManagedHttpDeps, rec: { id: string; sha256: string; filename: string }, github: string | null): Promise<Set<string>> {
|
|
3346
|
+
const key = JSON.stringify([rec.id, rec.sha256, rec.filename, { github }])
|
|
3240
3347
|
let cache = visibleIdsCaches.get(deps)
|
|
3241
3348
|
if (cache == null) { cache = new Map(); visibleIdsCaches.set(deps, cache) }
|
|
3242
3349
|
const hit = cache.get(key)
|
|
3243
|
-
if (hit != null) return hit
|
|
3244
|
-
const
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
if (report == null) return ids
|
|
3252
|
-
for (const f of report.findings) {
|
|
3253
|
-
const id = (f as { id?: unknown }).id
|
|
3254
|
-
if (typeof id === 'string' && id !== '') ids.add(id)
|
|
3255
|
-
}
|
|
3350
|
+
if (hit != null) { cache.delete(key); cache.set(key, hit); return hit }
|
|
3351
|
+
const bytes = await deps.reportStore.get(rec.id)
|
|
3352
|
+
if (bytes == null) return new Set()
|
|
3353
|
+
const { data } = readManagedReport(bytes.toString('utf8'), rec.filename)
|
|
3354
|
+
if (data == null) return new Set()
|
|
3355
|
+
// The ids the team workspace derives (teamFindingIds), from the one parse.
|
|
3356
|
+
const ids = await reportIds(data)
|
|
3357
|
+
ids.delete('')
|
|
3256
3358
|
while (cache.size >= VISIBLE_IDS_CACHE_MAX) {
|
|
3257
3359
|
const oldest = cache.keys().next().value
|
|
3258
3360
|
if (oldest === undefined) break
|
|
@@ -3663,8 +3765,12 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
|
|
|
3663
3765
|
// Null for none.
|
|
3664
3766
|
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
|
|
3665
3767
|
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
3666
|
-
|
|
3667
|
-
|
|
3768
|
+
return s == null ? null : npmReaderOf(deps, s.user, names)
|
|
3769
|
+
}
|
|
3770
|
+
// The same of `user`, as a session the request read just now tells them.
|
|
3771
|
+
async function npmReaderOf(deps: ManagedHttpDeps, user: { id: string; role: NpmReader['role'] }, names: readonly string[]): Promise<NpmReader | null> {
|
|
3772
|
+
if (!roleAtLeast(user.role, 'view')) return null
|
|
3773
|
+
return { role: user.role, scopes: new Set(npmScopesCount(user.role, names) ? await deps.db.listUserNpmScopes(user.id) : []), userId: user.id }
|
|
3668
3774
|
}
|
|
3669
3775
|
|
|
3670
3776
|
// What a registry read found, or a 404 for the package (handleNpm's catch
|
|
@@ -3680,20 +3786,25 @@ function found<T>(value: T | null | undefined): T {
|
|
|
3680
3786
|
const REVIEW_READS_AT_ONCE = 8
|
|
3681
3787
|
|
|
3682
3788
|
// Lists must enforce the same target and base access as the review itself,
|
|
3683
|
-
// including permission changes while the registry requests are in flight
|
|
3684
|
-
|
|
3789
|
+
// including permission changes while the registry requests are in flight;
|
|
3790
|
+
// first as `session`, the one the request read just now, where given.
|
|
3791
|
+
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> {
|
|
3685
3793
|
const controller = new AbortController()
|
|
3686
3794
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
3687
3795
|
res.on('close', onClose)
|
|
3688
3796
|
try {
|
|
3689
|
-
const
|
|
3797
|
+
const names = reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []])
|
|
3798
|
+
const reader = session ? await npmReaderOf(deps, session, names) : await npmReader(deps, cookie, names)
|
|
3690
3799
|
if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
3691
3800
|
// Public versions' documents as the viewer keeps them (npm-kept.ts), a
|
|
3692
|
-
// day: a list asks npm for none it read
|
|
3693
|
-
// many at once as REVIEW_READS_AT_ONCE.
|
|
3694
|
-
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
3801
|
+
// day, all read of the database at once: a list asks npm for none it read
|
|
3802
|
+
// within it. Each version once, as many at once as REVIEW_READS_AT_ONCE.
|
|
3695
3803
|
const versionsOf = (review: T) => [[review.package, review.version], ...review.basePackage ? [[review.basePackage, review.baseVersion!]] : []] as [string, string][]
|
|
3696
3804
|
const asked = new Map(reviews.flatMap(versionsOf).map(([name, version]) => [`${name}\0${version}`, [name, version] as const]))
|
|
3805
|
+
const store = npmVersionStore(deps.db, deps.config.debug)
|
|
3806
|
+
const held = await store.readMany?.([...asked.values()].map(([name, version]) => ({ name, version }))) ?? new Map()
|
|
3807
|
+
const kept: NpmVersionStore = { read: (name, version) => Promise.resolve(held.get(`${name}@${version}`) ?? null), write: store.write }
|
|
3697
3808
|
const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
|
|
3698
3809
|
const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
|
|
3699
3810
|
const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
|
|
@@ -3809,7 +3920,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3809
3920
|
try {
|
|
3810
3921
|
const reader = await npmReader(deps, cookie, [context.package, ...context.basePackage ? [context.basePackage] : []])
|
|
3811
3922
|
if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3812
|
-
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
3923
|
+
const kept = readTogether(npmVersionStore(deps.db, deps.config.debug), [{ name, version }, ...context.basePackage ? [{ name: context.basePackage, version: context.baseVersion! }] : []])
|
|
3813
3924
|
const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
|
|
3814
3925
|
const [doc, base] = await Promise.all([read(name, version), context.basePackage ? read(context.basePackage, context.baseVersion!) : null])
|
|
3815
3926
|
if (!doc || (context.basePackage && !base)) { sendJson(res, 404, { error: 'package-not-found' }); return }
|
|
@@ -3844,7 +3955,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
3844
3955
|
if (!change) {
|
|
3845
3956
|
const allBases = params.get('allBases') === 'true'
|
|
3846
3957
|
const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
|
|
3847
|
-
const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
|
|
3958
|
+
const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows, { id: current.userId, role: current.role }) : rows
|
|
3848
3959
|
if (reviews) sendJson(res, 200, { reviews: await withFormatted(deps.reviewedDependencies ?? null, current.userId, reviews), teams: await deps.db.listDependencyReviewTeams(current.userId) })
|
|
3849
3960
|
return
|
|
3850
3961
|
}
|
|
@@ -3949,7 +4060,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3949
4060
|
await reply(doc.private, { name, downloads, github })
|
|
3950
4061
|
return
|
|
3951
4062
|
}
|
|
3952
|
-
|
|
4063
|
+
// The version it is compared with, where its route names one, read
|
|
4064
|
+
// beside it, the two read of those kept at once.
|
|
4065
|
+
const against: [string, string, boolean] | null = (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)') || (path === NPM_DELTA_PATH && otherSpec !== '(empty)')
|
|
4066
|
+
|| ((path === NPM_TYPE_ONLY_PATH || path === NPM_RENAME_DIFF_PATH || (path === NPM_PRETTY_PATH && !params.has('path'))) && otherSpec !== '')
|
|
4067
|
+
? [name, otherSpec, privileged]
|
|
4068
|
+
: path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' ? [otherName, otherSpec, canReadPrivateNpm(reader, otherName)] : null
|
|
4069
|
+
const keptTogether = against ? readTogether(keptVersions, [{ name, version: spec }, { name: against[0], version: against[1] }]) : keptVersions
|
|
4070
|
+
const otherAsked = against && readNpmVersion(against[0], against[1], against[2], controller.signal, keptTogether)
|
|
4071
|
+
otherAsked?.catch(() => {})
|
|
4072
|
+
const otherDoc = async () => found(await otherAsked!)
|
|
4073
|
+
const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptTogether))
|
|
3953
4074
|
if (spec === 'latest') latest = Promise.resolve(doc)
|
|
3954
4075
|
const repo = githubRepoOf(doc), sha = bundleCommitHash(doc.manifest['gitHead'])
|
|
3955
4076
|
// What the viewer asks about a version beside its files, by its route,
|
|
@@ -3972,7 +4093,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3972
4093
|
// new and however few download it where a team lists its scope, its own
|
|
3973
4094
|
// packages, as a new dependency on a package of such a scope is.
|
|
3974
4095
|
if (path === NPM_RISK_PATH) {
|
|
3975
|
-
const old = otherSpec === '' || otherSpec === '(empty)' ? null :
|
|
4096
|
+
const old = otherSpec === '' || otherSpec === '(empty)' ? null : await otherDoc()
|
|
3976
4097
|
const scope = npmPackageScope(name)
|
|
3977
4098
|
const teamScoped = scope !== null && await deps.db.hasTeamNpmScope(scope)
|
|
3978
4099
|
const assessment = await npmRisk(doc, otherSpec === '' ? 'pick' : old, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped, fromEmpty: otherSpec === '(empty)',
|
|
@@ -3987,7 +4108,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
3987
4108
|
// package's, and those but for declaration files changed in types alone
|
|
3988
4109
|
// (npm-delta.ts), as a list of versions wanting reviews tells them.
|
|
3989
4110
|
if (path === NPM_DELTA_PATH) {
|
|
3990
|
-
const old = otherSpec === '(empty)' ? null :
|
|
4111
|
+
const old = otherSpec === '(empty)' ? null : await otherDoc()
|
|
3991
4112
|
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
3992
4113
|
const side = (version: NpmVersionDocument) => ({ integrity: version.dist.integrity, files: async () => {
|
|
3993
4114
|
const load = loadNpmFiles(version)
|
|
@@ -4003,7 +4124,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
4003
4124
|
// the one compared with, or `(empty)`, none, as Review reads a first
|
|
4004
4125
|
// version. Read of both, each package's access checked again once worked out.
|
|
4005
4126
|
if (path === NPM_COMPARISON_PATH) {
|
|
4006
|
-
const old = otherSpec === '(empty)' ? null :
|
|
4127
|
+
const old = otherSpec === '(empty)' ? null : await otherDoc()
|
|
4007
4128
|
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
4008
4129
|
const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
|
|
4009
4130
|
let body
|
|
@@ -4041,7 +4162,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
4041
4162
|
// and those that changed in types alone (type-only.ts).
|
|
4042
4163
|
if (path === NPM_PRETTY_PATH || path === NPM_TYPE_ONLY_PATH || (path === NPM_RENAME_DIFF_PATH && !diffed)) {
|
|
4043
4164
|
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
4044
|
-
const other = otherSpec === '' ? null :
|
|
4165
|
+
const other = otherSpec === '' ? null : await otherDoc()
|
|
4045
4166
|
const sides = other && await deps.prettyCache.npmSides(doc, other)
|
|
4046
4167
|
const lines = path === NPM_RENAME_DIFF_PATH ? renamedPairs(sides!, !doc.private && !other!.private)
|
|
4047
4168
|
: path === NPM_TYPE_ONLY_PATH ? typeOnlyFiles(sides!) : sides ? compared(sides) : await deps.prettyCache.npmAll(doc)
|
|
@@ -4059,7 +4180,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
4059
4180
|
if (diffed) {
|
|
4060
4181
|
const { prettyCache } = deps
|
|
4061
4182
|
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
4062
|
-
const other =
|
|
4183
|
+
const other = await otherDoc()
|
|
4063
4184
|
const diff = await renameDiffOf(diffed, async () => [await prettyText(await prettyCache.npm(doc, diffed.base)),
|
|
4064
4185
|
await prettyText(await prettyCache.npm(other, diffed.other))], !doc.private && !other.private)
|
|
4065
4186
|
if (!(await recheck(doc.private || other.private))) { diff.stream.destroy(); return }
|
|
@@ -4079,18 +4200,20 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
4079
4200
|
// viewer's own request to meet. Its whole document, read for its
|
|
4080
4201
|
// advisories' version list and the time each version was published
|
|
4081
4202
|
// (npmPublishTime), is read only where registry reads leave room for
|
|
4082
|
-
// readers'
|
|
4083
|
-
|
|
4203
|
+
// readers'; a public version's time is then kept in the database
|
|
4204
|
+
// (npm-kept.ts), to come with the version on any instance.
|
|
4205
|
+
const published = download ? undefined : npmPublishTime(doc, npmPublishTimeStore(deps.db, deps.config.debug))
|
|
4206
|
+
if (published) {
|
|
4084
4207
|
const quiet = (asked: Promise<unknown>) => { asked.catch(() => {}) }
|
|
4085
4208
|
quiet(npmDownloads(name))
|
|
4086
4209
|
quiet(latestDoc().then(newest => newest && githubFigures(newest)))
|
|
4087
4210
|
quiet(readNpmVersions(name, privileged, controller.signal, { times: true, spare: true })
|
|
4088
|
-
.then(versions => versions && npmAdvisories(name, versions.versions, advisoryOptions)))
|
|
4211
|
+
.then(versions => versions && Promise.all([published.keep(), npmAdvisories(name, versions.versions, advisoryOptions)])))
|
|
4089
4212
|
quiet(tagsOf())
|
|
4090
4213
|
quiet(socketOf())
|
|
4091
4214
|
quiet(provenanceOf())
|
|
4092
4215
|
}
|
|
4093
|
-
const load =
|
|
4216
|
+
const load = published ? loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha), published.read) : loadNpmTarball(doc)
|
|
4094
4217
|
res.once('close', load.release)
|
|
4095
4218
|
const bytes = await load.result
|
|
4096
4219
|
if (!(await recheck(doc.private))) return
|
|
@@ -4146,6 +4269,9 @@ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, d
|
|
|
4146
4269
|
export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
4147
4270
|
const { config, db, avatarStore, isShuttingDown, track } = deps
|
|
4148
4271
|
const repositoryDiscovery = new RepositoryDiscovery(config)
|
|
4272
|
+
// Kept reads' prunes run behind their writes: tracked, so the database
|
|
4273
|
+
// outlives them, and on serverless kept alive past the answer.
|
|
4274
|
+
ownKeptPrunes(db, prune => { track(prune); deps.waitUntil?.(prune) })
|
|
4149
4275
|
|
|
4150
4276
|
async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
4151
4277
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
@@ -4562,7 +4688,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4562
4688
|
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
4563
4689
|
}
|
|
4564
4690
|
const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
|
|
4565
|
-
const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
|
|
4691
|
+
const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [], s.user)
|
|
4566
4692
|
if (visible) sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, s.user.id, visible))[0] ?? null })
|
|
4567
4693
|
return
|
|
4568
4694
|
}
|
|
@@ -4592,7 +4718,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4592
4718
|
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
4593
4719
|
}
|
|
4594
4720
|
const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
4595
|
-
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
|
|
4721
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
|
|
4596
4722
|
if (reviews) sendJson(res, 200, { ...page, reviews: await withFormatted(deps.reviewedDependencies ?? null, s.user.id, reviews) })
|
|
4597
4723
|
return
|
|
4598
4724
|
}
|
|
@@ -4609,7 +4735,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4609
4735
|
}
|
|
4610
4736
|
const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
4611
4737
|
if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
4612
|
-
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
|
|
4738
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews, s.user)
|
|
4613
4739
|
if (!reviews) return
|
|
4614
4740
|
// Its access asked again, as it may have been taken while npm was read.
|
|
4615
4741
|
if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
@@ -4634,7 +4760,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
4634
4760
|
// Feeds lease a connection per poll, never across streaming sleeps. This
|
|
4635
4761
|
// also covers capability-authenticated feeds handled by public-workspace.
|
|
4636
4762
|
const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
|
|
4637
|
-
const serve = () => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)
|
|
4763
|
+
const serve = () => runRequestPresence(() => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res))
|
|
4638
4764
|
const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
|
|
4639
4765
|
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
4640
4766
|
console.warn('managed: request handler error:', err)
|