@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.
Files changed (37) hide show
  1. package/common/server-info.ts +1 -0
  2. package/out/client-managed-import.js +41 -41
  3. package/out/client-managed.js +79 -63
  4. package/out/graph.js +6 -6
  5. package/out/prism.js +2 -2
  6. package/out/view.css +1 -1
  7. package/out/view.js +187 -186
  8. package/package.json +4 -1
  9. package/report/index.js +7 -1
  10. package/report/src/finding-id.js +1 -1
  11. package/report/src/finding.js +6 -4
  12. package/report/src/labels.js +3 -1
  13. package/report/src/parse-codex.js +3 -1
  14. package/report/src/parse-deepsec.js +3 -1
  15. package/report/src/parse-deepview-md.js +2 -1
  16. package/report/src/parse-md.js +3 -2
  17. package/report/src/parse-piolium-rows.js +7 -12
  18. package/report/src/parse-piolium-tokens.js +12 -2
  19. package/report/src/parse-piolium.js +7 -8
  20. package/report/src/severity.js +18 -0
  21. package/server-common/vercel-blob.ts +7 -18
  22. package/server-e2e/config.ts +4 -0
  23. package/server-e2e/objstore/blob-vercel.ts +4 -4
  24. package/server-managed/blob-vercel.ts +10 -8
  25. package/server-managed/config.ts +2 -1
  26. package/server-managed/db-methods.ts +41 -14
  27. package/server-managed/db-neon.ts +11 -4
  28. package/server-managed/http.ts +112 -90
  29. package/server-managed/import-triage.ts +4 -1
  30. package/server-managed/index.ts +30 -8
  31. package/server-managed/management.ts +86 -0
  32. package/server-managed/package-entry-points.ts +51 -0
  33. package/server-managed/public-workspace.ts +1 -11
  34. package/server-managed/report-query.ts +6 -0
  35. package/server-managed/repository-browser.ts +6 -1
  36. package/server-managed/sql.ts +5 -0
  37. package/server-managed/uploads.ts +28 -10
@@ -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
- if (req.method !== 'POST') { send405(res, 'POST'); return }
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)\/([^/]+)\/(0|[1-9][0-9]*)$/u.exec(path)
286
- if (!match) { sendJson(res, 400, { error: 'bad-upload' }); return }
287
- const id = match[2]!, index = Number(match[3]), kind = match[1] as UploadKind
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
- if (!validUploadPart(id, index, maxBytes)) { sendJson(res, 400, { error: 'bad-upload' }); return }
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
- const annotatedIds = new Set([...triage.map(entry => entry.findingId), ...commentIds])
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
- // Only annotated findings need an overlap check.
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 reports = await deps.db.listReportsForRepo(repoId)
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, reports, (await deps.db.listReports()).filter((report) => report.repoId !== repoId))
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.deleteReportsForRepo(repoId)
670
- const deletedBundles = await deps.db.deleteBundlesForRepo(repoId)
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
- // Cache cleanup must not interrupt triage/repository removal after the
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 (!(await canViewReport(deps, s.user, reportId))) { sendJson(res, 404, { error: 'no-report' }); return }
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
- if (repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId as number, normalized.path ?? ''))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
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, s.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 as number | null) ?? report.repoId) })
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
- if (!(await canViewReport(deps, s.user, reportId))) { sendJson(res, 404, { error: 'no-report' }); return }
791
- if (!(await canChangeReportRepo(deps, s.user, reportId))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
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 (!(await canAccessBundle(deps, s.user, bundleId))) { sendJson(res, 404, { error: 'no-bundle' }); return }
813
- const bundle = await deps.db.getBundle(bundleId)
814
- if (bundle == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
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 — a failed
877
- // insert or concurrent duplicate drops the orphan blob. 413 over the cap, 400 on empty.
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
- await deps.reportStore.delete(id).catch(() => {})
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
- if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
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
- if (!existed) { sendJson(res, 404, { error: 'no-report' }); return }
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
- await deps.bundleStore.delete(id).catch(() => {})
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
- if (!(await canAccessBundle(deps, s.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
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
- if (!existed) { sendJson(res, 404, { error: 'no-bundle' }); return }
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), gated exactly like the entries: the caller
1593
- // may view the report, and the finding is one their visibility permissions
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 canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
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 canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
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, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
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 handleImportReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
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 ? { uploadChunkBytes: UPLOAD_CHUNK_BYTES } : {}) } })
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 handleImportReportTriage(req, res, deps, cookie, importTriage[1]!); return
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][] = []
@@ -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
- // Expired-session sweep period. Lookups already exclude expired rows
16
- // (`WHERE expires_at > now`), so this is housekeeping, not a security control.
17
- const SESSION_GC_INTERVAL_MS = 3_600_000
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 handleRequest = createManagedRequestHandler({
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 reap().catch(err => console.warn('managed: maintenance failed:', err))
60
- }, SESSION_GC_INTERVAL_MS)
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
+ }