@preventive/triage 1.0.0-alpha.23 → 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.
@@ -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,7 +1030,7 @@ 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
1036
  if (err instanceof GithubApiError) {
@@ -1206,6 +1179,8 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1206
1179
  const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
1207
1180
  const existing = await deps.db.getBundleByIntegrity(integrity)
1208
1181
  if (existing != null) {
1182
+ s = await manageMutation(req, res, deps, cookie)
1183
+ if (!s) return
1209
1184
  if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1210
1185
  // Reports uploaded while this bundle was inaccessible retain only its
1211
1186
  // integrity. An authorized re-upload repairs those pending links too.
@@ -1221,14 +1196,18 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1221
1196
  await deps.db.insertBundle({
1222
1197
  id, integrity, filename, kind,
1223
1198
  byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
1224
- }, Date.now())
1199
+ }, Date.now(), s.session.id)
1225
1200
  } catch (err) {
1226
- 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
1227
1204
  // A concurrent upload of identical bytes can insert this integrity (UNIQUE)
1228
1205
  // between our dedup check and this insert — treat that as a dedup, not a 500.
1229
1206
  // Any other failure rethrows.
1230
1207
  const raced = await deps.db.getBundleByIntegrity(integrity)
1231
1208
  if (raced != null) {
1209
+ s = await manageMutation(req, res, deps, cookie)
1210
+ if (!s) return
1232
1211
  if (!(await canAccessBundle(deps, s.user, raced.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1233
1212
  await deps.db.linkReportsToBundle(integrity, raced.id, s.user.role === 'admin' ? undefined : s.user.id)
1234
1213
  prebuildBundle(deps, raced.id)
@@ -1236,6 +1215,8 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1236
1215
  throw err
1237
1216
  }
1238
1217
  // Auto-link reports that declared this integrity before the bundle existed.
1218
+ s = await manageMutation(req, res, deps, cookie)
1219
+ if (!s) return
1239
1220
  await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
1240
1221
  prebuildBundle(deps, id)
1241
1222
  sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
@@ -1279,16 +1260,11 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
1279
1260
  const s = await checkMutation(req, res, deps, cookie)
1280
1261
  if (s == null) return
1281
1262
  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)
1263
+ const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
1287
1264
  await deps.bundleCache?.delete(id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
1288
1265
  await deps.reportSourcesCache?.deleteBundle(id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
1289
1266
  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) })
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) })
1292
1268
  sendJson(res, 200, { ok: true })
1293
1269
  }
1294
1270
 
@@ -1589,25 +1565,29 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
1589
1565
  }
1590
1566
 
1591
1567
  // 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
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
1594
1570
  // keep in it (a stripped or foreign id 404s without revealing whether it
1595
1571
  // exists). Each event is the entry as written then (null = a clear), who
1596
1572
  // wrote it and when; "what changed" is the diff against the next-older event.
1597
1573
  async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
1598
1574
  const s = await readSession(deps.config, deps.db, cookie, Date.now())
1599
1575
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1600
- 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 }
1601
1577
  const finding = query.get('finding') ?? ''
1602
1578
  if (finding === '' || finding.length > MAX_FINDING_ID) { sendJson(res, 400, { error: 'bad-request' }); return }
1603
1579
  await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
1604
1580
  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 }
1581
+ if (!current || !(await canTriageReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
1606
1582
  const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
1607
1583
  if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
1608
1584
  const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
1609
1585
  seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
1610
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 }
1611
1591
  sendJson(res, 200, { finding, events })
1612
1592
  }
1613
1593
 
@@ -1654,7 +1634,7 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
1654
1634
 
1655
1635
  // Admin imports compare the exact snapshot shown by the conflict dialog before
1656
1636
  // 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> {
1637
+ async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
1658
1638
  const s = await adminMutation(req, res, deps, cookie)
1659
1639
  if (!s) return
1660
1640
  let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
@@ -1678,10 +1658,15 @@ async function handleImportReportTriage(req: IncomingMessage, res: ServerRespons
1678
1658
  parsed.push([findingId, patch])
1679
1659
  }
1680
1660
  }
1681
- if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
1682
- 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
+ }
1683
1668
  if (await readAdminSession(res, deps, cookie) == null) return
1684
- 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 }
1685
1670
  if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
1686
1671
  const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
1687
1672
  sendJson(res, applied ? 200 : 409, { ok: applied })
@@ -2079,10 +2064,14 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2079
2064
  if (method !== 'POST') { send405(res, 'POST'); return }
2080
2065
  await handleSetReportVisible(req, res, deps, cookie); return
2081
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
+ }
2082
2071
  const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
2083
2072
  if (importTriage) {
2084
2073
  if (method !== 'POST') { send405(res, 'POST'); return }
2085
- await handleImportReportTriage(req, res, deps, cookie, importTriage[1]!); return
2074
+ await handleImportTriage(req, res, deps, cookie, importTriage[1]!); return
2086
2075
  }
2087
2076
  if (path.startsWith(REPORT_PREFIX)) {
2088
2077
  const id = path.slice(REPORT_PREFIX.length)
@@ -2204,7 +2193,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2204
2193
  return (req, res) => {
2205
2194
  if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
2206
2195
  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 }
2196
+ if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
2208
2197
  console.warn('managed: request handler error:', err)
2209
2198
  if (res.headersSent) { try { res.destroy() } catch {} }
2210
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
+ }
@@ -0,0 +1,51 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { normalizeTeamPath } from './repo-path.ts'
3
+
4
+ export const MAX_PACKAGE_BYTES = 256 * 1024
5
+ const MAX_ENTRY_POINTS = 100
6
+
7
+ // Declared paths, not a Node resolver: never guess defaults, expand export
8
+ // patterns, or execute package code. Keep every suggestion in its package.
9
+ export function packageEntryPoints(manifest: unknown, directory: string): string[] {
10
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return []
11
+ const pkg = manifest as { main?: unknown; exports?: unknown; bin?: unknown }
12
+ const paths = new Set<string>()
13
+ const add = (value: unknown) => {
14
+ if (typeof value !== 'string' || value.startsWith('/') || value.endsWith('/') || /[*?]|^[a-z][a-z\d+.-]*:/iu.test(value)) return
15
+ const normalized = normalizeTeamPath(value)
16
+ if (!normalized.ok || !normalized.path || normalized.path === 'package.json') return
17
+ const path = directory ? `${directory}/${normalized.path}` : normalized.path
18
+ if (path.length <= 500 && paths.size < MAX_ENTRY_POINTS) paths.add(path)
19
+ }
20
+ add(pkg.main)
21
+ const pending = [pkg.exports]
22
+ for (let visited = 0; pending.length > 0 && visited < 4096; visited++) {
23
+ const value = pending.pop()
24
+ if (typeof value === 'string') { if (value.startsWith('./')) add(value); continue }
25
+ if (Array.isArray(value)) pending.push(...value.slice(0, 4096 - visited).toReversed())
26
+ else if (value && typeof value === 'object') {
27
+ const entries = Object.entries(value).filter(([key]) => key !== 'types' && !key.startsWith('types@'))
28
+ pending.push(...entries.slice(0, 4096 - visited).toReversed().map(([, target]) => target))
29
+ }
30
+ }
31
+ if (typeof pkg.bin === 'string') add(pkg.bin)
32
+ else if (pkg.bin && typeof pkg.bin === 'object' && !Array.isArray(pkg.bin)) Object.values(pkg.bin).forEach(add)
33
+ return [...paths]
34
+ }
35
+
36
+ // Use the immutable blob from the authorized listing, never download_url or a
37
+ // resolved symlink target. Malformed or oversized manifests are optional.
38
+ export async function readPackageEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
39
+ try {
40
+ const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
41
+ if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
42
+ || blob.size < 0 || blob.size > MAX_PACKAGE_BYTES || blob.content.length > MAX_PACKAGE_BYTES * 2) return []
43
+ const bytes = Buffer.from(blob.content, 'base64')
44
+ if (bytes.length !== blob.size || bytes.length > MAX_PACKAGE_BYTES) return []
45
+ return packageEntryPoints(JSON.parse(bytes.toString('utf8')), directory)
46
+ } catch {
47
+ // Suggestions must not prevent browsing. The handler still rechecks
48
+ // GitHub and managed access after this optional upstream read.
49
+ return []
50
+ }
51
+ }
@@ -8,7 +8,6 @@ import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
8
8
  import { sendJson } from './http-response.ts'
9
9
  import { hashToken } from './crypto.ts'
10
10
  import { TeamReportsError, loadTeamReports, teamReportVisibility } from './team-reports.ts'
11
- import { MAX_FINDING_ID, MAX_TRIAGE_HISTORY } from '../common/managed/triage.ts'
12
11
  import { triageWireEntry } from './triage-response.ts'
13
12
  import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
14
13
  import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
@@ -30,7 +29,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
30
29
  if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
31
30
  const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
32
31
  const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed)$/u.exec(url.pathname)
33
- const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|triage\/history|comments|sources)$/u.exec(url.pathname)
32
+ const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
34
33
  const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
35
34
  if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
36
35
  const tokenHash = hashToken(token)
@@ -85,15 +84,6 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
85
84
  await recheck()
86
85
  if (part === 'sources') { await sources(res, deps, snapshot, id, visible.sourcePaths, recheck, stream); return }
87
86
  if (part === 'comments') { await send({ comments: await deps.db.listComments([...visible.ids]) }); return }
88
- if (part === 'triage/history') {
89
- const finding = url.searchParams.get('finding') ?? ''
90
- if (!finding || finding.length > MAX_FINDING_ID) { json(res, 400, { error: 'bad-request' }); return }
91
- if (!visible.ids.has(finding)) { json(res, 404, { error: 'no-finding' }); return }
92
- const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map(row => ({
93
- seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
94
- }))
95
- await send({ finding, events }); return
96
- }
97
87
  const entries = Object.fromEntries((await deps.db.listTriage([...visible.ids])).map(row => [row.findingId, triageWireEntry(row)]))
98
88
  await send({ entries }); return
99
89
  }
@@ -1,6 +1,7 @@
1
1
  import type { ManagedConfig } from './config.ts'
2
2
  import type { SelectedRepo } from './db.ts'
3
3
  import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
4
+ import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
4
5
 
5
6
  export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
6
7
 
@@ -121,7 +122,11 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
121
122
  const type = item.submodule_git_url ? 'submodule' : item.type
122
123
  if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
123
124
  }
124
- return { entries, limited: data.length >= 1000 }
125
+ const manifestPath = path ? `${path}/package.json` : 'package.json'
126
+ const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
127
+ && typeof item.size === 'number' && item.size >= 0 && item.size <= MAX_PACKAGE_BYTES && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
128
+ const packageEntryPoints = manifest ? await readPackageEntryPoints(manifest.sha, path, read) : undefined
129
+ return { entries, limited: data.length >= 1000, ...(packageEntryPoints ? { packageEntryPoints } : {}) }
125
130
  },
126
131
  }
127
132
  }
@@ -4,6 +4,11 @@ import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
4
4
  import type { ManagedDb } from './db-methods.ts'
5
5
 
6
6
  type MaybePromise<T> = T | Promise<T>
7
+ // A failed COMMIT acknowledgement (or connection close after COMMIT) does not
8
+ // establish rollback. Callers must retain primary bytes until reconciled.
9
+ export class ManagedCommitError extends Error {
10
+ constructor(cause: unknown) { super('Managed commit outcome is uncertain', { cause }) }
11
+ }
7
12
  export interface ManagedSql {
8
13
  prepare(sql: string): {
9
14
  get(...params: unknown[]): MaybePromise<unknown>