@preventive/triage 1.0.0-alpha.23 → 1.0.0-alpha.26
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/common/server-info.ts +1 -0
- package/out/client-managed-import.js +41 -41
- package/out/client-managed.js +79 -63
- package/out/graph.js +6 -6
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +187 -186
- package/package.json +4 -1
- package/report/index.js +7 -1
- package/report/src/finding-id.js +1 -1
- package/report/src/finding.js +6 -4
- package/report/src/labels.js +3 -1
- package/report/src/parse-codex.js +3 -1
- package/report/src/parse-deepsec.js +3 -1
- package/report/src/parse-deepview-md.js +2 -1
- package/report/src/parse-md.js +3 -2
- package/report/src/parse-piolium-rows.js +7 -12
- package/report/src/parse-piolium-tokens.js +12 -2
- package/report/src/parse-piolium.js +7 -8
- package/report/src/severity.js +18 -0
- package/server-common/vercel-blob.ts +7 -18
- package/server-e2e/config.ts +4 -0
- package/server-e2e/objstore/blob-vercel.ts +4 -4
- package/server-managed/blob-vercel.ts +10 -8
- package/server-managed/config.ts +2 -1
- package/server-managed/db-methods.ts +41 -14
- package/server-managed/db-neon.ts +11 -4
- package/server-managed/http.ts +112 -90
- package/server-managed/import-triage.ts +4 -1
- package/server-managed/index.ts +30 -8
- package/server-managed/management.ts +86 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/public-workspace.ts +1 -11
- package/server-managed/report-query.ts +6 -0
- package/server-managed/repository-browser.ts +6 -1
- package/server-managed/sql.ts +5 -0
- package/server-managed/uploads.ts +28 -10
package/server-managed/http.ts
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
49
49
|
import { pipeline } from 'node:stream/promises'
|
|
50
50
|
import { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
|
|
51
|
-
import { UPLOAD_CHUNK_BYTES, type UploadKind, putUploadPart, readUpload, validUploadPart } from './uploads.ts'
|
|
51
|
+
import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, putUploadPart, readUpload, validUpload, validUploadPart } from './uploads.ts'
|
|
52
52
|
import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
53
53
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
54
54
|
import type { BundleStore } from './bundle-store.ts'
|
|
@@ -82,7 +82,7 @@ import { clearCookie, endSession, readSession } from './session.ts'
|
|
|
82
82
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
83
83
|
import { acceptsReportMetadata } from './report-response.ts'
|
|
84
84
|
import { TeamReportsError, loadTeamReports, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
85
|
-
import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
85
|
+
import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
86
86
|
import { lookupFixes, storedFixUrls } from './github-pulls.ts'
|
|
87
87
|
import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
|
|
88
88
|
import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
|
|
@@ -92,6 +92,8 @@ import { handlePublicWorkspace } from './public-workspace.ts'
|
|
|
92
92
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
93
93
|
import { hashToken, randomToken } from './crypto.ts'
|
|
94
94
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
95
|
+
import { ManagedCommitError } from './sql.ts'
|
|
96
|
+
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
95
97
|
|
|
96
98
|
const SESSION_PATH = '/api/auth/session'
|
|
97
99
|
const AVATAR_PREFIX = '/api/avatar/'
|
|
@@ -278,19 +280,33 @@ function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: st
|
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
|
|
281
|
-
|
|
283
|
+
const cancel = req.method === 'DELETE'
|
|
284
|
+
if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
|
|
282
285
|
const s = await checkMutation(req, res, deps, cookie)
|
|
283
286
|
if (!s || !requireManageRole(res, s.user)) return
|
|
284
287
|
if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
285
|
-
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)
|
|
286
|
-
if (!match) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
287
|
-
const id = match[2]!,
|
|
288
|
+
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
|
|
289
|
+
if (!match || (cancel ? match[3] !== undefined : match[3] === undefined)) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
290
|
+
const id = match[2]!, kind = match[1] as UploadKind
|
|
291
|
+
if (cancel) {
|
|
292
|
+
const count = Number(req.headers['x-upload-parts'])
|
|
293
|
+
if (!validUpload(id, count)) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
294
|
+
await deleteUpload(deps.uploadStore, s.session.id, kind, id, count)
|
|
295
|
+
sendJson(res, 200, { ok: true }); return
|
|
296
|
+
}
|
|
297
|
+
const index = Number(match[3])
|
|
288
298
|
const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
|
|
289
|
-
|
|
299
|
+
const discard = async () => {
|
|
300
|
+
if (validUpload(id, index + 1)) {
|
|
301
|
+
await deleteUpload(deps.uploadStore!, s.session.id, kind, id, index + 1).catch(err => console.warn('managed: upload cleanup failed:', err))
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!validUploadPart(id, index, maxBytes)) { await discard(); sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
290
305
|
let bytes
|
|
291
306
|
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
|
|
292
|
-
catch { sendJson(res, 413, { error: 'too-large' }); return }
|
|
293
|
-
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
307
|
+
catch { await discard(); sendJson(res, 413, { error: 'too-large' }); return }
|
|
308
|
+
if (bytes.length === 0) { await discard(); sendJson(res, 400, { error: 'empty' }); return }
|
|
309
|
+
if (await manageMutation(req, res, deps, cookie) == null) return
|
|
294
310
|
await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
|
|
295
311
|
sendJson(res, 200, { ok: true })
|
|
296
312
|
}
|
|
@@ -462,7 +478,7 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
|
|
|
462
478
|
// access (never trust a client-supplied id) and capture the server-derived read
|
|
463
479
|
// context (installation id, default branch). A PRIVATE repo with no installation
|
|
464
480
|
// can't be read server-side → 409. Stores via selectRepo (upsert).
|
|
465
|
-
async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number): Promise<void> {
|
|
481
|
+
async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number, cookie: string | undefined): Promise<void> {
|
|
466
482
|
if (!canAddRepositories(user)) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
467
483
|
const userId = user.id
|
|
468
484
|
const token = await ensureUserAccessToken(deps.config, deps.db, userId, Date.now())
|
|
@@ -489,6 +505,7 @@ async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user
|
|
|
489
505
|
sendJson(res, 403, { error: 'forbidden' }); return
|
|
490
506
|
}
|
|
491
507
|
}
|
|
508
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
492
509
|
await connectRepository(res, deps, user, repo)
|
|
493
510
|
}
|
|
494
511
|
|
|
@@ -580,11 +597,12 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
580
597
|
}
|
|
581
598
|
if (!selected) {
|
|
582
599
|
const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
|
|
600
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
583
601
|
await deps.db.deactivateRepo(repoId)
|
|
584
602
|
if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
|
|
585
603
|
sendJson(res, 200, { ok: true, selected: false }); return
|
|
586
604
|
}
|
|
587
|
-
await selectRepository(res, deps, s.user, repoId)
|
|
605
|
+
await selectRepository(res, deps, s.user, repoId, cookie)
|
|
588
606
|
}
|
|
589
607
|
|
|
590
608
|
async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
|
|
@@ -614,13 +632,15 @@ async function repositoryImpact(deps: ManagedHttpDeps, repoId: number) {
|
|
|
614
632
|
}
|
|
615
633
|
}
|
|
616
634
|
|
|
617
|
-
async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[]): Promise<string[]> {
|
|
635
|
+
async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[], annotationsOnly = true): Promise<string[]> {
|
|
618
636
|
const targetIds = await repositoryFindingIds(deps, reports)
|
|
619
|
-
const triage = await deps.db.listTriage([...targetIds])
|
|
620
|
-
const commentIds = await deps.db.listCommentedFindingIds([...targetIds])
|
|
621
|
-
|
|
637
|
+
const triage = annotationsOnly ? await deps.db.listTriage([...targetIds]) : []
|
|
638
|
+
const commentIds = annotationsOnly ? await deps.db.listCommentedFindingIds([...targetIds]) : []
|
|
639
|
+
// Removal includes every exclusive finding, including annotations created
|
|
640
|
+
// during the blob scan; impact only needs the currently annotated subset.
|
|
641
|
+
const annotatedIds = annotationsOnly ? new Set([...triage.map(entry => entry.findingId), ...commentIds]) : targetIds
|
|
622
642
|
if (annotatedIds.size === 0) return []
|
|
623
|
-
//
|
|
643
|
+
// Impact can skip unannotated findings; deletion must also cover new writes.
|
|
624
644
|
// TODO(managed): Persist finding IDs per report at upload time and maintain
|
|
625
645
|
// the index on report deletion. Use it for repository impact and triage
|
|
626
646
|
// cleanup so overlap checks do not fetch and parse every other report blob.
|
|
@@ -661,13 +681,12 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
661
681
|
const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
|
|
662
682
|
if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
|
|
663
683
|
if (repo.fullName !== fullName) { sendJson(res, 400, { error: 'repo-name-mismatch' }); return }
|
|
664
|
-
const
|
|
665
|
-
const bundles = await deps.db.listBundlesForRepo(repoId)
|
|
684
|
+
const allReports = deleteTriage ? await deps.db.listReports() : []
|
|
666
685
|
const triageIds = deleteTriage
|
|
667
|
-
? await repositoryExclusiveTriageIds(deps,
|
|
686
|
+
? await repositoryExclusiveTriageIds(deps, allReports.filter(report => report.repoId === repoId), allReports.filter(report => report.repoId !== repoId), false)
|
|
668
687
|
: []
|
|
669
|
-
const deletedReports = await deps.db.
|
|
670
|
-
|
|
688
|
+
const { reports, bundles, deletedReports, deletedBundles, deletedTriage } = await deps.db.removeRepository(s.session.id, repo,
|
|
689
|
+
deleteTriage ? { reports: reportReferenceSnapshot(allReports), ids: triageIds } : null)
|
|
671
690
|
// Remove metadata first so a blob-store failure leaves an orphaned blob for
|
|
672
691
|
// later cleanup, rather than a live row pointing at missing report data.
|
|
673
692
|
for (const report of reports) {
|
|
@@ -675,15 +694,11 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
675
694
|
await deps.reportStore.delete(report.id).catch(() => {})
|
|
676
695
|
}
|
|
677
696
|
for (const bundle of bundles) {
|
|
678
|
-
//
|
|
679
|
-
// report rows needed to reconstruct exclusive finding IDs are gone.
|
|
697
|
+
// Best-effort derivative cleanup follows the committed metadata removal.
|
|
680
698
|
await deps.bundleCache?.delete(bundle.id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
|
|
681
699
|
await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
682
700
|
await deps.bundleStore.delete(bundle.id).catch(() => {})
|
|
683
701
|
}
|
|
684
|
-
const deletedTriage = await deps.db.deleteTriage(triageIds)
|
|
685
|
-
await deps.db.deleteRepo(repoId)
|
|
686
|
-
await activity(deps, s.user, 'delete', `removed a repository (${deletedReports} reports, ${deletedBundles} bundles, ${deletedTriage} triage entries)`, { repo: repo.fullName })
|
|
687
702
|
sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
|
|
688
703
|
}
|
|
689
704
|
|
|
@@ -733,15 +748,6 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
|
|
|
733
748
|
return { ok: true, repoId: n }
|
|
734
749
|
}
|
|
735
750
|
|
|
736
|
-
// True iff `repoId` may be linked to a report / bundle: null (detach) or a
|
|
737
|
-
// currently-selected repo id (so the FK can't dangle and you can only attach to
|
|
738
|
-
// a managed repo).
|
|
739
|
-
async function repoIdAllowed(deps: ManagedHttpDeps, repoId: unknown): Promise<boolean> {
|
|
740
|
-
if (repoId == null) return true
|
|
741
|
-
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) return false
|
|
742
|
-
return (await deps.db.listSelectedRepos()).some((r) => r.repoId === repoId)
|
|
743
|
-
}
|
|
744
|
-
|
|
745
751
|
async function canChangeReportRepo(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
746
752
|
if (!roleAtLeast(user.role, 'manage') || !(await canViewReport(deps, user, reportId))) return false
|
|
747
753
|
const report = await deps.db.getReport(reportId)
|
|
@@ -761,18 +767,12 @@ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, de
|
|
|
761
767
|
const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
|
|
762
768
|
const directory = (body as { directory?: unknown } | null)?.directory ?? ''
|
|
763
769
|
if (typeof reportId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
764
|
-
if (
|
|
765
|
-
const report = await deps.db.getReport(reportId)
|
|
766
|
-
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
767
|
-
if (!(await canChangeReportRepo(deps, s.user, reportId))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
768
|
-
if (report.repoEmbedded) { sendJson(res, 409, { error: 'repo-in-report' }); return }
|
|
769
|
-
if (!(await repoIdAllowed(deps, repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
770
|
+
if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
770
771
|
const normalized = normalizeTeamPath(directory)
|
|
771
772
|
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
772
|
-
|
|
773
|
-
if (!(await deps.db.setReportRepo(reportId, repoId as number | null, normalized.path ?? ''))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
773
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'repo', repoId, directory: normalized.path ?? '' })
|
|
774
774
|
if (report.repoId !== repoId || report.repoDirectory !== (repoId == null ? '' : normalized.path ?? '')) {
|
|
775
|
-
await activity(deps,
|
|
775
|
+
await activity(deps, user, 'repository', repoId == null ? 'detached a report from its repository' : `assigned a report to repository path ${normalized.path || '/'}`, { reportId, report: report.filename, repo: await repositoryName(deps, repoId ?? report.repoId) })
|
|
776
776
|
}
|
|
777
777
|
sendJson(res, 200, { ok: true, repoId, repoDirectory: normalized.path ?? '' })
|
|
778
778
|
}
|
|
@@ -787,12 +787,8 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
|
|
|
787
787
|
const reportId = (body as { reportId?: unknown } | null)?.reportId
|
|
788
788
|
const visible = (body as { visible?: unknown } | null)?.visible
|
|
789
789
|
if (typeof reportId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
790
|
-
|
|
791
|
-
if (
|
|
792
|
-
const report = await deps.db.getReport(reportId)
|
|
793
|
-
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
794
|
-
if (!(await deps.db.setReportVisible(reportId, visible))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
795
|
-
if (report.visible !== visible) await activity(deps, s.user, 'visibility', visible ? 'published a report' : 'hid a report', { reportId, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
790
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'visibility', visible })
|
|
791
|
+
if (report.visible !== visible) await activity(deps, user, 'visibility', visible ? 'published a report' : 'hid a report', { reportId, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
796
792
|
sendJson(res, 200, { ok: true, visible })
|
|
797
793
|
}
|
|
798
794
|
|
|
@@ -809,16 +805,9 @@ async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, de
|
|
|
809
805
|
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
810
806
|
const directory = repoId == null ? '' : normalized.path ?? ''
|
|
811
807
|
if (typeof bundleId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
812
|
-
if (
|
|
813
|
-
const bundle = await deps.db.
|
|
814
|
-
if (bundle
|
|
815
|
-
if (!(await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
816
|
-
if (!(await repoIdAllowed(deps, repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
817
|
-
if (repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId as number, directory))) {
|
|
818
|
-
sendJson(res, 403, { error: 'forbidden' }); return
|
|
819
|
-
}
|
|
820
|
-
if (!(await deps.db.setBundleRepo(bundleId, repoId as number | null, directory))) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
821
|
-
if (bundle.repoId !== repoId || bundle.repoDirectory !== directory) await activity(deps, s.user, 'repository', repoId == null ? 'detached a bundle from its repository' : `assigned a bundle to repository path ${directory || '/'}`, { bundleId, report: bundle.filename, repo: await repositoryName(deps, (repoId as number | null) ?? bundle.repoId) })
|
|
808
|
+
if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
809
|
+
const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'repo', repoId, directory })
|
|
810
|
+
if (bundle.repoId !== repoId || bundle.repoDirectory !== directory) await activity(deps, user, 'repository', repoId == null ? 'detached a bundle from its repository' : `assigned a bundle to repository path ${directory || '/'}`, { bundleId, report: bundle.filename, repo: await repositoryName(deps, repoId ?? bundle.repoId) })
|
|
822
811
|
sendJson(res, 200, { ok: true, repoId, repoDirectory: directory })
|
|
823
812
|
}
|
|
824
813
|
|
|
@@ -873,8 +862,9 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
873
862
|
// headers when the report has no repository metadata. The bundle link is
|
|
874
863
|
// auto-resolved from the report's bundleHashes. New reports start hidden until
|
|
875
864
|
// published. Identical content reuses its stored identity and metadata. Bytes
|
|
876
|
-
// are written first (keyed by a fresh uuid) then the metadata row
|
|
877
|
-
// insert or concurrent duplicate drops the orphan blob
|
|
865
|
+
// are written first (keyed by a fresh uuid) then the metadata row. A definite
|
|
866
|
+
// insert failure or concurrent duplicate drops the orphan blob; an uncertain
|
|
867
|
+
// commit retains it. 413 over the cap, 400 on empty.
|
|
878
868
|
async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
879
869
|
let s = await checkMutation(req, res, deps, cookie)
|
|
880
870
|
if (s == null) return
|
|
@@ -931,9 +921,10 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
931
921
|
id, filename, contentType, byteSize: bytes.length, sha256,
|
|
932
922
|
uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
|
|
933
923
|
repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
|
|
934
|
-
}, Date.now())
|
|
924
|
+
}, Date.now(), s.session.id)
|
|
935
925
|
} catch (err) {
|
|
936
|
-
|
|
926
|
+
// Losing the commit acknowledgement cannot turn primary bytes into garbage.
|
|
927
|
+
if (!(err instanceof ManagedCommitError)) await deps.reportStore.delete(id).catch(() => {})
|
|
937
928
|
throw err
|
|
938
929
|
}
|
|
939
930
|
if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
|
|
@@ -976,15 +967,10 @@ async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
976
967
|
const s = await checkMutation(req, res, deps, cookie)
|
|
977
968
|
if (s == null) return
|
|
978
969
|
if (!requireManageRole(res, s.user)) return
|
|
979
|
-
|
|
980
|
-
if (!(await canChangeReportRepo(deps, s.user, id))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
981
|
-
const report = await deps.db.getReport(id)
|
|
982
|
-
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
983
|
-
const existed = await deps.db.deleteReport(id)
|
|
970
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
|
|
984
971
|
await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
985
972
|
await deps.reportStore.delete(id).catch((err) => { console.warn('managed: report bytes delete failed:', err) })
|
|
986
|
-
|
|
987
|
-
await activity(deps, s.user, 'delete', 'deleted a report', { repoId: report.repoId, repoDirectory: report.repoDirectory, reportId: id, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
973
|
+
await activity(deps, user, 'delete', 'deleted a report', { repoId: report.repoId, repoDirectory: report.repoDirectory, reportId: id, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
988
974
|
sendJson(res, 200, { ok: true })
|
|
989
975
|
}
|
|
990
976
|
|
|
@@ -1057,7 +1043,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1057
1043
|
if (!current) return
|
|
1058
1044
|
if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1059
1045
|
sendJson(res, 200, refs ? result : {
|
|
1060
|
-
...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false } : {}), path, commit,
|
|
1046
|
+
...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [] } : {}), path, commit,
|
|
1061
1047
|
})
|
|
1062
1048
|
} catch (err) {
|
|
1063
1049
|
if (err instanceof GithubApiError) {
|
|
@@ -1206,6 +1192,8 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1206
1192
|
const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
|
|
1207
1193
|
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
1208
1194
|
if (existing != null) {
|
|
1195
|
+
s = await manageMutation(req, res, deps, cookie)
|
|
1196
|
+
if (!s) return
|
|
1209
1197
|
if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1210
1198
|
// Reports uploaded while this bundle was inaccessible retain only its
|
|
1211
1199
|
// integrity. An authorized re-upload repairs those pending links too.
|
|
@@ -1221,14 +1209,18 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1221
1209
|
await deps.db.insertBundle({
|
|
1222
1210
|
id, integrity, filename, kind,
|
|
1223
1211
|
byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
|
|
1224
|
-
}, Date.now())
|
|
1212
|
+
}, Date.now(), s.session.id)
|
|
1225
1213
|
} catch (err) {
|
|
1226
|
-
|
|
1214
|
+
// A committed row may already point here even though insertion rejected.
|
|
1215
|
+
if (!(err instanceof ManagedCommitError)) await deps.bundleStore.delete(id).catch(() => {})
|
|
1216
|
+
if (err instanceof ManagedMutationError) throw err
|
|
1227
1217
|
// A concurrent upload of identical bytes can insert this integrity (UNIQUE)
|
|
1228
1218
|
// between our dedup check and this insert — treat that as a dedup, not a 500.
|
|
1229
1219
|
// Any other failure rethrows.
|
|
1230
1220
|
const raced = await deps.db.getBundleByIntegrity(integrity)
|
|
1231
1221
|
if (raced != null) {
|
|
1222
|
+
s = await manageMutation(req, res, deps, cookie)
|
|
1223
|
+
if (!s) return
|
|
1232
1224
|
if (!(await canAccessBundle(deps, s.user, raced.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1233
1225
|
await deps.db.linkReportsToBundle(integrity, raced.id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1234
1226
|
prebuildBundle(deps, raced.id)
|
|
@@ -1236,6 +1228,8 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1236
1228
|
throw err
|
|
1237
1229
|
}
|
|
1238
1230
|
// Auto-link reports that declared this integrity before the bundle existed.
|
|
1231
|
+
s = await manageMutation(req, res, deps, cookie)
|
|
1232
|
+
if (!s) return
|
|
1239
1233
|
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1240
1234
|
prebuildBundle(deps, id)
|
|
1241
1235
|
sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
|
|
@@ -1279,16 +1273,11 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1279
1273
|
const s = await checkMutation(req, res, deps, cookie)
|
|
1280
1274
|
if (s == null) return
|
|
1281
1275
|
if (!requireManageRole(res, s.user)) return
|
|
1282
|
-
|
|
1283
|
-
const bundle = await deps.db.getBundle(id)
|
|
1284
|
-
if (bundle == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1285
|
-
if (!(await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
1286
|
-
const existed = await deps.db.deleteBundle(id)
|
|
1276
|
+
const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
|
|
1287
1277
|
await deps.bundleCache?.delete(id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
|
|
1288
1278
|
await deps.reportSourcesCache?.deleteBundle(id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
1289
1279
|
await deps.bundleStore.delete(id).catch((err) => { console.warn('managed: bundle bytes delete failed:', err) })
|
|
1290
|
-
|
|
1291
|
-
await activity(deps, s.user, 'delete', 'deleted a bundle', { repoId: bundle.repoId, repoDirectory: bundle.repoDirectory, bundleId: id, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
|
|
1280
|
+
await activity(deps, user, 'delete', 'deleted a bundle', { repoId: bundle.repoId, repoDirectory: bundle.repoDirectory, bundleId: id, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
|
|
1292
1281
|
sendJson(res, 200, { ok: true })
|
|
1293
1282
|
}
|
|
1294
1283
|
|
|
@@ -1589,25 +1578,30 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1589
1578
|
}
|
|
1590
1579
|
|
|
1591
1580
|
// GET /api/reports/<id>/triage/history?finding=<fid> — one finding's triage
|
|
1592
|
-
// trail (newest first, capped)
|
|
1593
|
-
//
|
|
1581
|
+
// trail (newest first, capped). The caller must have at least triage access
|
|
1582
|
+
// to the report, and the finding is one their visibility permissions
|
|
1594
1583
|
// keep in it (a stripped or foreign id 404s without revealing whether it
|
|
1595
1584
|
// exists). Each event is the entry as written then (null = a clear), who
|
|
1596
1585
|
// wrote it and when; "what changed" is the diff against the next-older event.
|
|
1597
1586
|
async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
|
|
1598
1587
|
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1599
1588
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1600
|
-
if (!(await
|
|
1589
|
+
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1601
1590
|
const finding = query.get('finding') ?? ''
|
|
1602
1591
|
if (finding === '' || finding.length > MAX_FINDING_ID) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1603
1592
|
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
1604
1593
|
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1605
|
-
if (!current || !(await
|
|
1594
|
+
if (!current || !(await canTriageReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1606
1595
|
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
1607
1596
|
if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1608
1597
|
const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
|
|
1609
|
-
seq: row.seq, at: row.at,
|
|
1598
|
+
seq: row.seq, at: row.at, actorId: row.actorId, actorLogin: row.actorLogin, actorName: row.actorName,
|
|
1599
|
+
batchId: row.batchId, entry: triageWireEntry(row, true),
|
|
1610
1600
|
}))
|
|
1601
|
+
// A database read can outlive a role, membership, or visibility change.
|
|
1602
|
+
const latest = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1603
|
+
if (!latest || latest.user.role !== current.user.role || !(await canTriageReport(deps, latest.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1604
|
+
if (!(await visibleFindingIds(deps, latest.user, id, latest.session.id, teamId)).has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1611
1605
|
sendJson(res, 200, { finding, events })
|
|
1612
1606
|
}
|
|
1613
1607
|
|
|
@@ -1652,9 +1646,31 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
|
|
|
1652
1646
|
sendJson(res, 200, { ok: true })
|
|
1653
1647
|
}
|
|
1654
1648
|
|
|
1649
|
+
// GET only: clients match local annotations against these IDs without sending
|
|
1650
|
+
// their local finding IDs or annotation bodies to discover which reports exist.
|
|
1651
|
+
async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
|
|
1652
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
1653
|
+
if (!s) return
|
|
1654
|
+
const after = search.get('after') ?? ''
|
|
1655
|
+
if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
|
|
1656
|
+
const stored = await deps.db.listFindingCatalogReports(after, FINDING_CATALOG_PAGE_COUNT + 1)
|
|
1657
|
+
const reports = []
|
|
1658
|
+
let bytes = 0, cursor = after, processed = 0
|
|
1659
|
+
for (const report of stored) {
|
|
1660
|
+
if (processed >= FINDING_CATALOG_PAGE_COUNT || (processed > 0 && bytes + report.byteSize > FINDING_CATALOG_PAGE_BYTES)) break
|
|
1661
|
+
bytes += report.byteSize
|
|
1662
|
+
processed++
|
|
1663
|
+
cursor = report.id
|
|
1664
|
+
const findingIds = [...await visibleFindingIds(deps, s.user, report.id, s.session.id, null)]
|
|
1665
|
+
if (findingIds.length > 0) reports.push({ id: report.id, findingIds })
|
|
1666
|
+
}
|
|
1667
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1668
|
+
sendJson(res, 200, { reports, nextCursor: processed < stored.length ? cursor : null })
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1655
1671
|
// Admin imports compare the exact snapshot shown by the conflict dialog before
|
|
1656
1672
|
// writing. The store commits shared finding triage and imported comments together.
|
|
1657
|
-
async function
|
|
1673
|
+
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1658
1674
|
const s = await adminMutation(req, res, deps, cookie)
|
|
1659
1675
|
if (!s) return
|
|
1660
1676
|
let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
|
|
@@ -1940,7 +1956,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
1940
1956
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1941
1957
|
const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
|
|
1942
1958
|
sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
|
|
1943
|
-
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
|
|
1959
|
+
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
|
|
1960
|
+
uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
|
|
1961
|
+
} : {}) } })
|
|
1944
1962
|
return
|
|
1945
1963
|
}
|
|
1946
1964
|
// OAuth: start → redirect to GitHub with the CSRF state cookie.
|
|
@@ -2079,10 +2097,14 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2079
2097
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2080
2098
|
await handleSetReportVisible(req, res, deps, cookie); return
|
|
2081
2099
|
}
|
|
2100
|
+
if (path === '/api/admin/reports/finding-ids') {
|
|
2101
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2102
|
+
await handleImportFindingIds(res, deps, cookie, url.searchParams); return
|
|
2103
|
+
}
|
|
2082
2104
|
const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
|
|
2083
2105
|
if (importTriage) {
|
|
2084
2106
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2085
|
-
await
|
|
2107
|
+
await handleImportTriage(req, res, deps, cookie, importTriage[1]!); return
|
|
2086
2108
|
}
|
|
2087
2109
|
if (path.startsWith(REPORT_PREFIX)) {
|
|
2088
2110
|
const id = path.slice(REPORT_PREFIX.length)
|
|
@@ -2204,7 +2226,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2204
2226
|
return (req, res) => {
|
|
2205
2227
|
if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
|
|
2206
2228
|
const work = route(req, res).catch((err) => {
|
|
2207
|
-
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2229
|
+
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2208
2230
|
console.warn('managed: request handler error:', err)
|
|
2209
2231
|
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
2210
2232
|
else sendJson(res, 500, { error: 'internal' })
|
|
@@ -15,7 +15,7 @@ export interface ImportTriageStore {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// Raw store methods share one transaction for comparison, triage, and comments.
|
|
18
|
-
export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
|
|
18
|
+
export function importTriageMethods(store: Pick<ManagedDb, 'getReport' | 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
|
|
19
19
|
async function snapshot(ids: readonly string[]) {
|
|
20
20
|
const rows = new Map((await store.listTriage(ids)).map(row => [row.findingId, row]))
|
|
21
21
|
const comments = await store.listComments(ids)
|
|
@@ -30,6 +30,9 @@ export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listC
|
|
|
30
30
|
return {
|
|
31
31
|
getImportTriage: snapshot,
|
|
32
32
|
async importTriage(entries, expected, actor, reportId, now) {
|
|
33
|
+
// A report deleted after the HTTP membership check must not leave newly
|
|
34
|
+
// imported annotations behind. Check existence in the write transaction.
|
|
35
|
+
if (!reportId || !(await store.getReport(reportId))) return false
|
|
33
36
|
const current = await snapshot(entries.map(([id]) => id))
|
|
34
37
|
if (entries.some(([id]) => current[id]!.version !== expected[id])) return false
|
|
35
38
|
const changes: [string, TriageEntryPatch | null][] = []
|
package/server-managed/index.ts
CHANGED
|
@@ -12,9 +12,10 @@ import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
|
|
|
12
12
|
import { loadManagedStatic } from './static.ts'
|
|
13
13
|
import { openManagedStorage } from './storage.ts'
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
const
|
|
15
|
+
// Housekeeping runs on ordinary traffic too, including serverless instances
|
|
16
|
+
// without a timer or cron. Failed sweeps retry on later traffic after a minute.
|
|
17
|
+
const REAP_INTERVAL_MS = 3_600_000
|
|
18
|
+
const REAP_RETRY_MS = 60_000
|
|
18
19
|
|
|
19
20
|
type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
|
|
20
21
|
|
|
@@ -39,25 +40,46 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
39
40
|
const serveStatic = loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
|
|
40
41
|
indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
|
|
41
42
|
})
|
|
42
|
-
const
|
|
43
|
+
const routeRequest = createManagedRequestHandler({
|
|
43
44
|
...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, originGate, serveStatic,
|
|
44
45
|
...('uploadStore' in storage ? { uploadStore: storage.uploadStore } : {}),
|
|
45
46
|
isShuttingDown, track,
|
|
46
47
|
})
|
|
47
48
|
|
|
48
49
|
let cleanup: Promise<void> | undefined
|
|
50
|
+
let nextCleanupAt = 0
|
|
49
51
|
function reap(): Promise<void> {
|
|
50
52
|
if (cleanup) return cleanup
|
|
53
|
+
const startedAt = Date.now()
|
|
54
|
+
nextCleanupAt = startedAt + REAP_INTERVAL_MS
|
|
55
|
+
let sessions = 0, uploads = 0
|
|
51
56
|
cleanup = runReapers({
|
|
52
|
-
sessions: () => db.deleteExpiredSessions(Date.now()),
|
|
53
|
-
...('reapUploads' in storage ? { uploads: () => storage.reapUploads() } : {}),
|
|
57
|
+
sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
|
|
58
|
+
...('reapUploads' in storage ? { uploads: async () => { uploads = await storage.reapUploads() } } : {}),
|
|
59
|
+
}).then(() => {
|
|
60
|
+
nextCleanupAt = Date.now() + REAP_INTERVAL_MS
|
|
61
|
+
return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
|
|
62
|
+
}, err => {
|
|
63
|
+
nextCleanupAt = Date.now() + REAP_RETRY_MS
|
|
64
|
+
throw err
|
|
54
65
|
}).finally(() => { cleanup = undefined })
|
|
55
66
|
track(cleanup)
|
|
56
67
|
return cleanup
|
|
57
68
|
}
|
|
69
|
+
function automaticReap(): Promise<void> {
|
|
70
|
+
if (isShuttingDown() || cleanup || Date.now() < nextCleanupAt) return Promise.resolve()
|
|
71
|
+
return reap().catch(err => console.warn('managed-reaper: cleanup failed:', err))
|
|
72
|
+
}
|
|
73
|
+
async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
|
|
74
|
+
const maintenance = automaticReap()
|
|
75
|
+
// Send the normal response without waiting for storage housekeeping, but
|
|
76
|
+
// keep the invocation alive until both finish. No detached serverless work.
|
|
77
|
+
try { await routeRequest(req, res) }
|
|
78
|
+
finally { await maintenance }
|
|
79
|
+
}
|
|
58
80
|
const gcTimer = config.serverless ? null : setInterval(() => {
|
|
59
|
-
void
|
|
60
|
-
},
|
|
81
|
+
void automaticReap()
|
|
82
|
+
}, REAP_INTERVAL_MS)
|
|
61
83
|
rollback.defer(stop)
|
|
62
84
|
|
|
63
85
|
function stop(): void {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { AdminReport, ManagedBundle, ManagedDb, ManagedRepo, RepoDataItem, RepoReportItem, ReportRecord, StoredUser } from './db-methods.ts'
|
|
2
|
+
import { roleAtLeast } from '../common/managed/roles.ts'
|
|
3
|
+
|
|
4
|
+
export class ManagedMutationError extends Error {
|
|
5
|
+
status: number
|
|
6
|
+
constructor(status: number, message: string) { super(message); this.status = status }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ContentMutation = { type: 'delete' } | { type: 'repo'; repoId: number | null; directory: string }
|
|
10
|
+
export type ReportMutation = ContentMutation | { type: 'visibility'; visible: boolean }
|
|
11
|
+
type RemovalRepo = Pick<ManagedRepo, 'repoId' | 'fullName' | 'addedAt'>
|
|
12
|
+
type RemovalAnnotations = { reports: string; ids: string[] }
|
|
13
|
+
export interface ManagementStore {
|
|
14
|
+
mutateReport(sessionId: string, id: string, change: ReportMutation): Promise<{ report: ReportRecord; user: StoredUser }>
|
|
15
|
+
mutateBundle(sessionId: string, id: string, change: ContentMutation): Promise<{ bundle: ManagedBundle; user: StoredUser }>
|
|
16
|
+
removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null): Promise<{
|
|
17
|
+
reports: RepoReportItem[]; bundles: RepoDataItem[]; deletedReports: number; deletedBundles: number; deletedTriage: number
|
|
18
|
+
}>
|
|
19
|
+
}
|
|
20
|
+
type ManagementDb = Pick<ManagedDb, 'sessionWithUser' | 'getReport' | 'getBundle' | 'listReports' | 'listReportsForRepo'
|
|
21
|
+
| 'listBundlesForRepo' | 'listAllRepos' | 'listSelectedRepos' | 'userCanReadReport' | 'userCanReadBundle' | 'userCanReadRepoPath'
|
|
22
|
+
| 'deleteReport' | 'setReportRepo' | 'setReportVisible' | 'deleteBundle' | 'setBundleRepo'
|
|
23
|
+
| 'deleteReportsForRepo' | 'deleteBundlesForRepo' | 'deleteRepo' | 'deleteTriage' | 'recordActivity'>
|
|
24
|
+
|
|
25
|
+
// Only immutable bytes/format and repository membership affect the overlap
|
|
26
|
+
// scan. Ignore publication, user display names, and unrelated bundle links.
|
|
27
|
+
export function reportReferenceSnapshot(reports: readonly Pick<AdminReport, 'id' | 'sha256' | 'filename' | 'repoId'>[]): string {
|
|
28
|
+
return JSON.stringify(reports.map(r => [r.id, r.sha256, r.filename, r.repoId]).toSorted((a, b) => String(a[0]).localeCompare(String(b[0]))))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// These methods are wrapped by scopeManagedMethods: authorization, comparison,
|
|
32
|
+
// metadata changes (and repository-removal audit) share the writer transaction. Blob reads
|
|
33
|
+
// and deletes stay outside it. Use the raw store here, never nested scopes.
|
|
34
|
+
export function managementMethods(db: ManagementDb): ManagementStore {
|
|
35
|
+
async function authorize(sessionId: string, role: 'admin' | 'manage') {
|
|
36
|
+
const s = await db.sessionWithUser(sessionId, Date.now())
|
|
37
|
+
if (!s) throw new ManagedMutationError(401, 'unauthenticated')
|
|
38
|
+
if (!roleAtLeast(s.user.role, role)) throw new ManagedMutationError(403, 'forbidden')
|
|
39
|
+
return s.user
|
|
40
|
+
}
|
|
41
|
+
async function destination(user: { id: string; role: string }, change: ContentMutation) {
|
|
42
|
+
if (change.type !== 'repo' || change.repoId === null) return
|
|
43
|
+
if (!(await db.listSelectedRepos()).some(repo => repo.repoId === change.repoId)) throw new ManagedMutationError(400, 'bad-repo')
|
|
44
|
+
if (user.role !== 'admin' && !await db.userCanReadRepoPath(user.id, change.repoId, change.directory)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
async mutateReport(sessionId: string, id: string, change: ReportMutation) {
|
|
48
|
+
const user = await authorize(sessionId, 'manage')
|
|
49
|
+
const report = await db.getReport(id)
|
|
50
|
+
if (!report || (user.role !== 'admin' && report.uploadedBy !== user.id && !await db.userCanReadReport(user.id, id))) throw new ManagedMutationError(404, 'no-report')
|
|
51
|
+
if (user.role !== 'admin' && report.repoId !== null && !await db.userCanReadReport(user.id, id)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
52
|
+
if (change.type === 'repo' && report.repoEmbedded) throw new ManagedMutationError(409, 'repo-in-report')
|
|
53
|
+
if (change.type !== 'visibility') await destination(user, change)
|
|
54
|
+
if (change.type === 'delete') await db.deleteReport(id)
|
|
55
|
+
else if (change.type === 'visibility') await db.setReportVisible(id, change.visible)
|
|
56
|
+
else await db.setReportRepo(id, change.repoId, change.directory)
|
|
57
|
+
return { report, user }
|
|
58
|
+
},
|
|
59
|
+
async mutateBundle(sessionId: string, id: string, change: ContentMutation) {
|
|
60
|
+
const user = await authorize(sessionId, 'manage')
|
|
61
|
+
const bundle = await db.getBundle(id)
|
|
62
|
+
if (!bundle || (user.role !== 'admin' && !await db.userCanReadBundle(user.id, id))) throw new ManagedMutationError(404, 'no-bundle')
|
|
63
|
+
if (user.role !== 'admin' && bundle.repoId !== null && !await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
64
|
+
await destination(user, change)
|
|
65
|
+
if (change.type === 'delete') await db.deleteBundle(id)
|
|
66
|
+
else await db.setBundleRepo(id, change.repoId, change.directory)
|
|
67
|
+
return { bundle, user }
|
|
68
|
+
},
|
|
69
|
+
async removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null) {
|
|
70
|
+
const user = await authorize(sessionId, 'admin')
|
|
71
|
+
const repo = (await db.listAllRepos()).find(r => r.repoId === expected.repoId)
|
|
72
|
+
if (!repo) throw new ManagedMutationError(404, 'no-repo')
|
|
73
|
+
if (repo.fullName !== expected.fullName || repo.addedAt !== expected.addedAt) throw new ManagedMutationError(409, 'repository-changed')
|
|
74
|
+
if (annotations && reportReferenceSnapshot(await db.listReports()) !== annotations.reports) throw new ManagedMutationError(409, 'repository-changed')
|
|
75
|
+
const reports = await db.listReportsForRepo(repo.repoId)
|
|
76
|
+
const bundles = await db.listBundlesForRepo(repo.repoId)
|
|
77
|
+
const deletedReports = await db.deleteReportsForRepo(repo.repoId)
|
|
78
|
+
const deletedBundles = await db.deleteBundlesForRepo(repo.repoId)
|
|
79
|
+
const deletedTriage = await db.deleteTriage(annotations?.ids ?? [])
|
|
80
|
+
await db.deleteRepo(repo.repoId)
|
|
81
|
+
await db.recordActivity({ kind: 'delete', actor: user.login, actorId: user.id, repo: repo.fullName,
|
|
82
|
+
action: `removed a repository (${deletedReports} reports, ${deletedBundles} bundles, ${deletedTriage} triage entries)` }, Date.now())
|
|
83
|
+
return { reports, bundles, deletedReports, deletedBundles, deletedTriage }
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|