@preventive/triage 1.0.0-alpha.22 → 1.0.0-alpha.25

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.
@@ -6,7 +6,7 @@ import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-met
6
6
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
7
7
  import { COMMENT_SCHEMA } from './comments.ts'
8
8
  import { ACTIVITY_SCHEMA } from './activity.ts'
9
- import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
9
+ import { ManagedCommitError, type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
10
10
  import { postgresSchema, postgresSql } from './sql-postgres.ts'
11
11
  import { managedTableRenames } from './db-table-names.ts'
12
12
  import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
@@ -144,16 +144,23 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
144
144
  async scope(write, work) {
145
145
  if (closed) throw new Error('Managed database is closed')
146
146
  const db = await connect()
147
+ let commitAttempted = false
148
+ let result
147
149
  try {
148
150
  await db.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY')
149
151
  if (write) await db.query(LOCK)
150
- const result = await context.run(db, work)
152
+ result = await context.run(db, work)
153
+ commitAttempted = true
151
154
  await db.query('COMMIT')
152
- return result
153
155
  } catch (err) {
154
156
  await db.query('ROLLBACK').catch(() => {})
157
+ await db.release().catch(() => {})
158
+ if (write && commitAttempted) throw new ManagedCommitError(err)
155
159
  throw err
156
- } finally { await db.release() }
160
+ }
161
+ try { await db.release() }
162
+ catch (err) { if (write) throw new ManagedCommitError(err); throw err }
163
+ return result
157
164
  },
158
165
  close() { closed = true },
159
166
  }
@@ -5,6 +5,9 @@
5
5
  import { Buffer } from 'node:buffer'
6
6
  import { createHash, createSign } from 'node:crypto'
7
7
  import type { ManagedConfig } from './config.ts'
8
+ import { GithubApiError, throwGithubResponseError } from './github-errors.ts'
9
+
10
+ export { GithubApiError } from './github-errors.ts'
8
11
 
9
12
  const GITHUB_API = 'https://api.github.com'
10
13
  const API_VERSION = '2022-11-28'
@@ -39,17 +42,6 @@ export interface ConnectedRepo {
39
42
  installationId: number | null
40
43
  }
41
44
 
42
- // A failure carrying the HTTP status the router should surface. 401 passes
43
- // through so the user-token path can map it to "log in again".
44
- export class GithubApiError extends Error {
45
- status: number
46
- constructor(status: number, message: string) {
47
- super(message)
48
- this.name = 'GithubApiError'
49
- this.status = status
50
- }
51
- }
52
-
53
45
  // App installation credentials are configured (id + private key present) →
54
46
  // private repos can be listed via its installation tokens.
55
47
  export function githubAppConfigured(config: ManagedConfig): boolean {
@@ -89,12 +81,8 @@ export async function githubJson(url: string, token: string | null, fetchImpl: t
89
81
  },
90
82
  })
91
83
  } catch { throw new GithubApiError(502, 'github-unreachable') }
92
- if (res.status === 401) {
93
- discardInstallationToken(token, fetchImpl)
94
- throw new GithubApiError(401, 'github-unauthorized')
95
- }
96
- if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
97
- if (!res.ok) throw new GithubApiError(502, `github-status-${res.status}`)
84
+ if (res.status === 401) discardInstallationToken(token, fetchImpl)
85
+ if (!res.ok) await throwGithubResponseError(res)
98
86
  try { return await res.json() } catch { throw new GithubApiError(502, 'github-malformed') }
99
87
  }
100
88
 
@@ -0,0 +1,28 @@
1
+ // A failure carrying the HTTP status the router should surface. 401 passes
2
+ // through so the user-token path can map it to "log in again".
3
+ export class GithubApiError extends Error {
4
+ status: number
5
+ retryAfter: number | null
6
+ constructor(status: number, message: string, retryAfter: number | null = null) {
7
+ super(message)
8
+ this.name = 'GithubApiError'
9
+ this.status = status
10
+ this.retryAfter = retryAfter
11
+ }
12
+ }
13
+
14
+ export async function throwGithubResponseError(res: Response): Promise<never> {
15
+ if (res.status === 403 || res.status === 429) {
16
+ const body = await res.json().catch(() => null) as { message?: unknown } | null
17
+ if (res.status === 429 || res.headers.get('x-ratelimit-remaining') === '0' || res.headers.has('retry-after')
18
+ || (typeof body?.message === 'string' && /rate limit|abuse detection/iu.test(body.message))) {
19
+ const retry = Number(res.headers.get('retry-after'))
20
+ const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000
21
+ const wait = Math.max(Number.isFinite(retry) ? retry : 0, Number.isFinite(reset) ? (reset - Date.now()) / 1000 : 0)
22
+ throw new GithubApiError(429, 'github-rate-limited', Math.max(60, Math.ceil(wait)))
23
+ }
24
+ }
25
+ if (res.status === 401) throw new GithubApiError(401, 'github-unauthorized')
26
+ if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
27
+ throw new GithubApiError(502, `github-status-${res.status}`)
28
+ }
@@ -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/'
@@ -291,6 +293,7 @@ async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps:
291
293
  try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
292
294
  catch { sendJson(res, 413, { error: 'too-large' }); return }
293
295
  if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
296
+ if (await manageMutation(req, res, deps, cookie) == null) return
294
297
  await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
295
298
  sendJson(res, 200, { ok: true })
296
299
  }
@@ -462,7 +465,7 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
462
465
  // access (never trust a client-supplied id) and capture the server-derived read
463
466
  // context (installation id, default branch). A PRIVATE repo with no installation
464
467
  // 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> {
468
+ async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number, cookie: string | undefined): Promise<void> {
466
469
  if (!canAddRepositories(user)) { sendJson(res, 403, { error: 'forbidden' }); return }
467
470
  const userId = user.id
468
471
  const token = await ensureUserAccessToken(deps.config, deps.db, userId, Date.now())
@@ -489,6 +492,7 @@ async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user
489
492
  sendJson(res, 403, { error: 'forbidden' }); return
490
493
  }
491
494
  }
495
+ if (await readAdminSession(res, deps, cookie) == null) return
492
496
  await connectRepository(res, deps, user, repo)
493
497
  }
494
498
 
@@ -580,11 +584,12 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
580
584
  }
581
585
  if (!selected) {
582
586
  const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
587
+ if (await readAdminSession(res, deps, cookie) == null) return
583
588
  await deps.db.deactivateRepo(repoId)
584
589
  if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
585
590
  sendJson(res, 200, { ok: true, selected: false }); return
586
591
  }
587
- await selectRepository(res, deps, s.user, repoId)
592
+ await selectRepository(res, deps, s.user, repoId, cookie)
588
593
  }
589
594
 
590
595
  async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
@@ -614,13 +619,15 @@ async function repositoryImpact(deps: ManagedHttpDeps, repoId: number) {
614
619
  }
615
620
  }
616
621
 
617
- async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[]): Promise<string[]> {
622
+ async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[], annotationsOnly = true): Promise<string[]> {
618
623
  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])
624
+ const triage = annotationsOnly ? await deps.db.listTriage([...targetIds]) : []
625
+ const commentIds = annotationsOnly ? await deps.db.listCommentedFindingIds([...targetIds]) : []
626
+ // Removal includes every exclusive finding, including annotations created
627
+ // during the blob scan; impact only needs the currently annotated subset.
628
+ const annotatedIds = annotationsOnly ? new Set([...triage.map(entry => entry.findingId), ...commentIds]) : targetIds
622
629
  if (annotatedIds.size === 0) return []
623
- // Only annotated findings need an overlap check.
630
+ // Impact can skip unannotated findings; deletion must also cover new writes.
624
631
  // TODO(managed): Persist finding IDs per report at upload time and maintain
625
632
  // the index on report deletion. Use it for repository impact and triage
626
633
  // cleanup so overlap checks do not fetch and parse every other report blob.
@@ -661,13 +668,12 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
661
668
  const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
662
669
  if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
663
670
  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)
671
+ const allReports = deleteTriage ? await deps.db.listReports() : []
666
672
  const triageIds = deleteTriage
667
- ? await repositoryExclusiveTriageIds(deps, reports, (await deps.db.listReports()).filter((report) => report.repoId !== repoId))
673
+ ? await repositoryExclusiveTriageIds(deps, allReports.filter(report => report.repoId === repoId), allReports.filter(report => report.repoId !== repoId), false)
668
674
  : []
669
- const deletedReports = await deps.db.deleteReportsForRepo(repoId)
670
- const deletedBundles = await deps.db.deleteBundlesForRepo(repoId)
675
+ const { reports, bundles, deletedReports, deletedBundles, deletedTriage } = await deps.db.removeRepository(s.session.id, repo,
676
+ deleteTriage ? { reports: reportReferenceSnapshot(allReports), ids: triageIds } : null)
671
677
  // Remove metadata first so a blob-store failure leaves an orphaned blob for
672
678
  // later cleanup, rather than a live row pointing at missing report data.
673
679
  for (const report of reports) {
@@ -675,15 +681,11 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
675
681
  await deps.reportStore.delete(report.id).catch(() => {})
676
682
  }
677
683
  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.
684
+ // Best-effort derivative cleanup follows the committed metadata removal.
680
685
  await deps.bundleCache?.delete(bundle.id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
681
686
  await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
682
687
  await deps.bundleStore.delete(bundle.id).catch(() => {})
683
688
  }
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
689
  sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
688
690
  }
689
691
 
@@ -733,15 +735,6 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
733
735
  return { ok: true, repoId: n }
734
736
  }
735
737
 
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
738
  async function canChangeReportRepo(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
746
739
  if (!roleAtLeast(user.role, 'manage') || !(await canViewReport(deps, user, reportId))) return false
747
740
  const report = await deps.db.getReport(reportId)
@@ -761,18 +754,12 @@ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, de
761
754
  const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
762
755
  const directory = (body as { directory?: unknown } | null)?.directory ?? ''
763
756
  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 }
757
+ if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
770
758
  const normalized = normalizeTeamPath(directory)
771
759
  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 }
760
+ const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'repo', repoId, directory: normalized.path ?? '' })
774
761
  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) })
762
+ 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
763
  }
777
764
  sendJson(res, 200, { ok: true, repoId, repoDirectory: normalized.path ?? '' })
778
765
  }
@@ -787,12 +774,8 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
787
774
  const reportId = (body as { reportId?: unknown } | null)?.reportId
788
775
  const visible = (body as { visible?: unknown } | null)?.visible
789
776
  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) })
777
+ const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'visibility', visible })
778
+ 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
779
  sendJson(res, 200, { ok: true, visible })
797
780
  }
798
781
 
@@ -809,16 +792,9 @@ async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, de
809
792
  if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
810
793
  const directory = repoId == null ? '' : normalized.path ?? ''
811
794
  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) })
795
+ if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
796
+ const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'repo', repoId, directory })
797
+ 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
798
  sendJson(res, 200, { ok: true, repoId, repoDirectory: directory })
823
799
  }
824
800
 
@@ -873,8 +849,9 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
873
849
  // headers when the report has no repository metadata. The bundle link is
874
850
  // auto-resolved from the report's bundleHashes. New reports start hidden until
875
851
  // 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.
852
+ // are written first (keyed by a fresh uuid) then the metadata row. A definite
853
+ // insert failure or concurrent duplicate drops the orphan blob; an uncertain
854
+ // commit retains it. 413 over the cap, 400 on empty.
878
855
  async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
879
856
  let s = await checkMutation(req, res, deps, cookie)
880
857
  if (s == null) return
@@ -931,9 +908,10 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
931
908
  id, filename, contentType, byteSize: bytes.length, sha256,
932
909
  uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
933
910
  repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
934
- }, Date.now())
911
+ }, Date.now(), s.session.id)
935
912
  } catch (err) {
936
- await deps.reportStore.delete(id).catch(() => {})
913
+ // Losing the commit acknowledgement cannot turn primary bytes into garbage.
914
+ if (!(err instanceof ManagedCommitError)) await deps.reportStore.delete(id).catch(() => {})
937
915
  throw err
938
916
  }
939
917
  if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
@@ -976,15 +954,10 @@ async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, dep
976
954
  const s = await checkMutation(req, res, deps, cookie)
977
955
  if (s == null) return
978
956
  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)
957
+ const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
984
958
  await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
985
959
  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) })
960
+ 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
961
  sendJson(res, 200, { ok: true })
989
962
  }
990
963
 
@@ -1057,10 +1030,13 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1057
1030
  if (!current) return
1058
1031
  if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1059
1032
  sendJson(res, 200, refs ? result : {
1060
- ...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false } : {}), path, commit,
1033
+ ...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [] } : {}), path, commit,
1061
1034
  })
1062
1035
  } catch (err) {
1063
- if (err instanceof GithubApiError) { sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }); return }
1036
+ if (err instanceof GithubApiError) {
1037
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
1038
+ return
1039
+ }
1064
1040
  throw err
1065
1041
  }
1066
1042
  }
@@ -1203,6 +1179,8 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1203
1179
  const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
1204
1180
  const existing = await deps.db.getBundleByIntegrity(integrity)
1205
1181
  if (existing != null) {
1182
+ s = await manageMutation(req, res, deps, cookie)
1183
+ if (!s) return
1206
1184
  if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1207
1185
  // Reports uploaded while this bundle was inaccessible retain only its
1208
1186
  // integrity. An authorized re-upload repairs those pending links too.
@@ -1218,14 +1196,18 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1218
1196
  await deps.db.insertBundle({
1219
1197
  id, integrity, filename, kind,
1220
1198
  byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
1221
- }, Date.now())
1199
+ }, Date.now(), s.session.id)
1222
1200
  } catch (err) {
1223
- await deps.bundleStore.delete(id).catch(() => {})
1201
+ // A committed row may already point here even though insertion rejected.
1202
+ if (!(err instanceof ManagedCommitError)) await deps.bundleStore.delete(id).catch(() => {})
1203
+ if (err instanceof ManagedMutationError) throw err
1224
1204
  // A concurrent upload of identical bytes can insert this integrity (UNIQUE)
1225
1205
  // between our dedup check and this insert — treat that as a dedup, not a 500.
1226
1206
  // Any other failure rethrows.
1227
1207
  const raced = await deps.db.getBundleByIntegrity(integrity)
1228
1208
  if (raced != null) {
1209
+ s = await manageMutation(req, res, deps, cookie)
1210
+ if (!s) return
1229
1211
  if (!(await canAccessBundle(deps, s.user, raced.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1230
1212
  await deps.db.linkReportsToBundle(integrity, raced.id, s.user.role === 'admin' ? undefined : s.user.id)
1231
1213
  prebuildBundle(deps, raced.id)
@@ -1233,6 +1215,8 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1233
1215
  throw err
1234
1216
  }
1235
1217
  // Auto-link reports that declared this integrity before the bundle existed.
1218
+ s = await manageMutation(req, res, deps, cookie)
1219
+ if (!s) return
1236
1220
  await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
1237
1221
  prebuildBundle(deps, id)
1238
1222
  sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
@@ -1276,16 +1260,11 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
1276
1260
  const s = await checkMutation(req, res, deps, cookie)
1277
1261
  if (s == null) return
1278
1262
  if (!requireManageRole(res, s.user)) return
1279
- if (!(await canAccessBundle(deps, s.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
1280
- const bundle = await deps.db.getBundle(id)
1281
- if (bundle == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
1282
- if (!(await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
1283
- const existed = await deps.db.deleteBundle(id)
1263
+ const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
1284
1264
  await deps.bundleCache?.delete(id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
1285
1265
  await deps.reportSourcesCache?.deleteBundle(id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
1286
1266
  await deps.bundleStore.delete(id).catch((err) => { console.warn('managed: bundle bytes delete failed:', err) })
1287
- if (!existed) { sendJson(res, 404, { error: 'no-bundle' }); return }
1288
- 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) })
1267
+ 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) })
1289
1268
  sendJson(res, 200, { ok: true })
1290
1269
  }
1291
1270
 
@@ -1586,25 +1565,29 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
1586
1565
  }
1587
1566
 
1588
1567
  // GET /api/reports/<id>/triage/history?finding=<fid> — one finding's triage
1589
- // trail (newest first, capped), gated exactly like the entries: the caller
1590
- // may view the report, and the finding is one their visibility permissions
1568
+ // trail (newest first, capped). The caller must have at least triage access
1569
+ // to the report, and the finding is one their visibility permissions
1591
1570
  // keep in it (a stripped or foreign id 404s without revealing whether it
1592
1571
  // exists). Each event is the entry as written then (null = a clear), who
1593
1572
  // wrote it and when; "what changed" is the diff against the next-older event.
1594
1573
  async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
1595
1574
  const s = await readSession(deps.config, deps.db, cookie, Date.now())
1596
1575
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1597
- if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1576
+ if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1598
1577
  const finding = query.get('finding') ?? ''
1599
1578
  if (finding === '' || finding.length > MAX_FINDING_ID) { sendJson(res, 400, { error: 'bad-request' }); return }
1600
1579
  await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
1601
1580
  const current = await readSession(deps.config, deps.db, cookie, Date.now())
1602
- if (!current || !(await canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
1581
+ if (!current || !(await canTriageReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
1603
1582
  const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
1604
1583
  if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
1605
1584
  const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
1606
1585
  seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
1607
1586
  }))
1587
+ // A database read can outlive a role, membership, or visibility change.
1588
+ const latest = await readSession(deps.config, deps.db, cookie, Date.now())
1589
+ if (!latest || latest.user.role !== current.user.role || !(await canTriageReport(deps, latest.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1590
+ if (!(await visibleFindingIds(deps, latest.user, id, latest.session.id, teamId)).has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
1608
1591
  sendJson(res, 200, { finding, events })
1609
1592
  }
1610
1593
 
@@ -1651,7 +1634,7 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
1651
1634
 
1652
1635
  // Admin imports compare the exact snapshot shown by the conflict dialog before
1653
1636
  // writing. The store commits shared finding triage and imported comments together.
1654
- async function handleImportReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1637
+ async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
1655
1638
  const s = await adminMutation(req, res, deps, cookie)
1656
1639
  if (!s) return
1657
1640
  let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
@@ -1675,10 +1658,15 @@ async function handleImportReportTriage(req: IncomingMessage, res: ServerRespons
1675
1658
  parsed.push([findingId, patch])
1676
1659
  }
1677
1660
  }
1678
- if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
1679
- const visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
1661
+ // Workspace imports remain report-scoped. Standalone local triage can be
1662
+ // imported before its reports; only administrators have this global access.
1663
+ let visible: Set<string> | undefined
1664
+ if (id !== undefined) {
1665
+ if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
1666
+ visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
1667
+ }
1680
1668
  if (await readAdminSession(res, deps, cookie) == null) return
1681
- if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
1669
+ if (visible && ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
1682
1670
  if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
1683
1671
  const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
1684
1672
  sendJson(res, applied ? 200 : 409, { ok: applied })
@@ -2076,10 +2064,14 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2076
2064
  if (method !== 'POST') { send405(res, 'POST'); return }
2077
2065
  await handleSetReportVisible(req, res, deps, cookie); return
2078
2066
  }
2067
+ if (path === '/api/admin/import-triage') {
2068
+ if (method !== 'POST') { send405(res, 'POST'); return }
2069
+ await handleImportTriage(req, res, deps, cookie); return
2070
+ }
2079
2071
  const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
2080
2072
  if (importTriage) {
2081
2073
  if (method !== 'POST') { send405(res, 'POST'); return }
2082
- await handleImportReportTriage(req, res, deps, cookie, importTriage[1]!); return
2074
+ await handleImportTriage(req, res, deps, cookie, importTriage[1]!); return
2083
2075
  }
2084
2076
  if (path.startsWith(REPORT_PREFIX)) {
2085
2077
  const id = path.slice(REPORT_PREFIX.length)
@@ -2201,7 +2193,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2201
2193
  return (req, res) => {
2202
2194
  if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
2203
2195
  const work = route(req, res).catch((err) => {
2204
- if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
2196
+ if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
2205
2197
  console.warn('managed: request handler error:', err)
2206
2198
  if (res.headersSent) { try { res.destroy() } catch {} }
2207
2199
  else sendJson(res, 500, { error: 'internal' })
@@ -11,7 +11,7 @@ export interface ImportTriageSnapshot {
11
11
  }
12
12
  export interface ImportTriageStore {
13
13
  getImportTriage(ids: readonly string[]): Promise<Record<string, ImportTriageSnapshot>>
14
- importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string, now: number): Promise<boolean>
14
+ importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string | undefined, now: number): Promise<boolean>
15
15
  }
16
16
 
17
17
  // Raw store methods share one transaction for comparison, triage, and comments.
@@ -38,7 +38,7 @@ export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listC
38
38
  changes.push([id, Object.keys(entry).length > 0 ? entry : null])
39
39
  if (comment && !current[id]!.comments.some(existing => existing.body === comment)) {
40
40
  await store.createComment({ findingId: id, body: comment, authorId: null, authorLogin: null,
41
- actor, createdAt: null, updatedAt: null, reportId }, now)
41
+ actor, createdAt: null, updatedAt: null, reportId: reportId ?? null }, now)
42
42
  }
43
43
  }
44
44
  await store.setTriageEntries(changes, actor.id, actor.login, now, reportId)
@@ -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
+ }