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

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 (40) hide show
  1. package/common/server-info.ts +1 -0
  2. package/out/client-managed-import.js +18 -18
  3. package/out/client-managed.js +66 -65
  4. package/out/prism.js +2 -2
  5. package/out/view.css +1 -1
  6. package/out/view.js +182 -182
  7. package/package.json +12 -2
  8. package/server-common/storage-crypto.ts +182 -0
  9. package/server-common/storage-log.ts +5 -1
  10. package/server-common/vercel-blob.ts +6 -3
  11. package/server-e2e/config.ts +4 -0
  12. package/server-managed/avatar-store.ts +1 -40
  13. package/server-managed/blob-store.ts +3 -52
  14. package/server-managed/bundle-cache.ts +0 -28
  15. package/server-managed/bundle-store.ts +3 -7
  16. package/server-managed/cache-storage.ts +0 -25
  17. package/server-managed/cli.js +10 -2
  18. package/server-managed/config.ts +14 -2
  19. package/server-managed/db-methods.ts +56 -25
  20. package/server-managed/db-neon.ts +9 -4
  21. package/server-managed/db.ts +7 -1
  22. package/server-managed/http.ts +93 -62
  23. package/server-managed/import-triage.ts +6 -3
  24. package/server-managed/index.ts +32 -9
  25. package/server-managed/object-storage-disk.ts +144 -0
  26. package/server-managed/object-storage-vercel.ts +60 -0
  27. package/server-managed/object-storage.ts +71 -0
  28. package/server-managed/report-query.ts +6 -0
  29. package/server-managed/repository-browser.ts +5 -1
  30. package/server-managed/solidity-entry-points.ts +69 -0
  31. package/server-managed/sql.ts +0 -5
  32. package/server-managed/storage-command.ts +16 -0
  33. package/server-managed/storage-db.ts +168 -0
  34. package/server-managed/storage-encryption.ts +211 -0
  35. package/server-managed/storage-maintenance.ts +182 -0
  36. package/server-managed/storage-payload.ts +60 -0
  37. package/server-managed/storage-stores.ts +81 -0
  38. package/server-managed/storage.ts +46 -24
  39. package/server-managed/uploads.ts +30 -12
  40. package/server-managed/blob-vercel.ts +0 -125
@@ -12,6 +12,8 @@ import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
12
12
  import type { ManagedSql } from './sql.ts'
13
13
  import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
14
14
  import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
15
+ import { type StorageKey } from '../server-common/storage-crypto.ts'
16
+ import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, validateStorageUpload } from './storage-db.ts'
15
17
 
16
18
  // A managed user identity (the subset of GitHub's `GET /user` we keep). Input
17
19
  // to the upsert; `githubUserId` is the provider lookup key, never exposed to
@@ -131,6 +133,7 @@ export interface ReportAccessSnapshot {
131
133
  // bundle links; `bundleIntegrity` is the report's declared primary bundle (kept
132
134
  // so a later bundle upload of that integrity re-links it).
133
135
  export interface ReportRecordInput {
136
+ dataKey?: string | null
134
137
  id: string
135
138
  filename: string
136
139
  contentType: string
@@ -204,7 +207,9 @@ export interface TriageEventRow {
204
207
  comment: string | null
205
208
  fix: string | null
206
209
  flagged: boolean | null
210
+ actorId: string | null
207
211
  actorLogin: string | null
212
+ actorName: string | null
208
213
  at: number
209
214
  }
210
215
 
@@ -226,7 +231,7 @@ export interface ManagedBundle {
226
231
 
227
232
  // What the upload handler supplies to record a bundle; the store stamps
228
233
  // uploaded_at. `uploadedByLogin` is the durable uploader-login snapshot.
229
- export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string }
234
+ export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string; dataKey?: string | null }
230
235
 
231
236
  // A bundle row for the "Manage bundles" list — adds the uploader login + repo
232
237
  // full name display joins (null when absent / since removed).
@@ -308,7 +313,7 @@ export interface UserTeam {
308
313
  }
309
314
 
310
315
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
311
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore {
316
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, StorageDb {
312
317
  // Upsert the identity; returns the user's opaque id (stable across logins).
313
318
  // Initial-admin approval comes only from trusted login configuration. It
314
319
  // promotes a matching No access identity only while it is the sole user.
@@ -354,7 +359,10 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
354
359
  insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord>
355
360
  getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
356
361
  listReports(userId?: string): Promise<AdminReport[]>
362
+ listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]>
357
363
  getReport(id: string): Promise<ReportRecord | null>
364
+ // Writer-locked reconciliation waits for an uncertain earlier insert.
365
+ resolveReportUpload(id: string): Promise<ReportRecord | null>
358
366
  getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
359
367
  getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
360
368
  listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]>
@@ -389,6 +397,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
389
397
  // declared its integrity.
390
398
  insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
391
399
  getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
400
+ resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
392
401
  getBundle(id: string): Promise<ManagedBundle | null>
393
402
  listBundles(userId?: string): Promise<AdminBundle[]>
394
403
  userCanReadBundle(userId: string, id: string): Promise<boolean>
@@ -496,11 +505,11 @@ function prepareStatements(db: ManagedSql) {
496
505
  updateRoleStmt: db.prepare(`UPDATE managed_user SET role = ?, updated_at = ? WHERE id = ?`),
497
506
  updateTokensStmt: db.prepare(
498
507
  `UPDATE managed_user
499
- SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, updated_at = ?
508
+ SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, gh_tokens_encrypted = ?, updated_at = ?
500
509
  WHERE id = ?`,
501
510
  ),
502
511
  selectTokensStmt: db.prepare(
503
- `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp
512
+ `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp, gh_tokens_encrypted AS encrypted
504
513
  FROM managed_user WHERE id = ?`,
505
514
  ),
506
515
  deleteSessionStmt: db.prepare(`DELETE FROM managed_session WHERE id = ?`),
@@ -548,9 +557,9 @@ function prepareStatements(db: ManagedSql) {
548
557
  ),
549
558
  insertReportStmt: db.prepare(
550
559
  `WITH candidate(id, slug) AS (VALUES (?, ?))
551
- INSERT INTO managed_report (id, slug, filename, content_type, byte_size, sha256, uploaded_by, uploaded_by_login, repo_id, repo_directory, repo_embedded, analyzer, visible, bundle_id, bundle_integrity, uploaded_at)
560
+ INSERT INTO managed_report (id, slug, filename, content_type, byte_size, sha256, uploaded_by, uploaded_by_login, repo_id, repo_directory, repo_embedded, analyzer, visible, bundle_id, bundle_integrity, uploaded_at, data_key, storage_encrypted)
552
561
  SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_report WHERE slug = candidate.slug) THEN id ELSE slug END,
553
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
562
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
554
563
  ),
555
564
  // LEFT JOINs so a report whose uploader / repo / bundle was removed (the FK
556
565
  // nulled) still lists, with null display fields.
@@ -571,6 +580,7 @@ function prepareStatements(db: ManagedSql) {
571
580
  WHERE tr.repo_id = r.repo_id AND tu.user_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}))
572
581
  ORDER BY r.uploaded_at DESC, r.filename ASC`,
573
582
  ),
583
+ findingCatalogReportsStmt: db.prepare(`SELECT id, byte_size AS byteSize FROM managed_report WHERE id > ? ORDER BY id LIMIT ?`),
574
584
  selectReportStmt: db.prepare(
575
585
  `SELECT id, slug, filename, content_type AS contentType, byte_size AS byteSize,
576
586
  sha256, uploaded_by AS uploadedBy, uploaded_at AS uploadedAt,
@@ -646,7 +656,7 @@ function prepareStatements(db: ManagedSql) {
646
656
  selectTriageHistoryStmt: db.prepare(
647
657
  `SELECT e.seq AS seq, e.finding_id AS findingId, e.batch_id AS batchId, e.color AS color, e.triage AS triage,
648
658
  e.comment AS comment, e.fix AS fix, e.flagged AS flagged,
649
- COALESCE(u.login, e.actor_login) AS actorLogin, e.at AS at
659
+ u.id AS actorId, COALESCE(u.login, e.actor_login) AS actorLogin, u.name AS actorName, e.at AS at
650
660
  FROM managed_finding_triage_event e
651
661
  LEFT JOIN managed_user u ON u.id = e.actor_id
652
662
  WHERE e.finding_id = ?
@@ -676,9 +686,9 @@ function prepareStatements(db: ManagedSql) {
676
686
  WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
677
687
  insertBundleStmt: db.prepare(
678
688
  `WITH candidate(id, slug) AS (VALUES (?, ?))
679
- INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at)
689
+ INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at, data_key, storage_encrypted)
680
690
  SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_bundle WHERE slug = candidate.slug) THEN id ELSE slug END,
681
- ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
691
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
682
692
  ),
683
693
  selectBundleByIntegrityStmt: db.prepare(
684
694
  `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
@@ -920,7 +930,7 @@ async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sess
920
930
  }
921
931
 
922
932
  // The report slice of ManagedDb closes over its prepared statements.
923
- function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
933
+ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
924
934
  const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
925
935
  async function getReport(id: string): Promise<ReportRecord | null> {
926
936
  const row = (await selectReportStmt.get(id)) as ReportRow | undefined
@@ -932,16 +942,18 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
932
942
  return row ? getReport(row.id) : null
933
943
  }
934
944
  async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
945
+ await validateStorageUpload(db, key, 'report', report.id, report.dataKey)
935
946
  await insertReportStmt.run(
936
947
  report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
937
948
  report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
938
949
  report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
939
- report.bundleIntegrity ?? null, now,
950
+ report.bundleIntegrity ?? null, now, report.dataKey ?? null, report.dataKey ? 1 : 0,
940
951
  )
941
952
  }
942
953
  return {
943
954
  insertReport,
944
955
  getReport,
956
+ resolveReportUpload: getReport,
945
957
  getReportByHash,
946
958
  async insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord> {
947
959
  await authorizeUpload(stmts, sessionId, report)
@@ -963,6 +975,9 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
963
975
  uploadedAt: r.uploadedAt,
964
976
  }))
965
977
  },
978
+ async listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]> {
979
+ return await stmts.findingCatalogReportsStmt.all(after, limit) as Pick<ReportRecord, 'id' | 'byteSize'>[]
980
+ },
966
981
  async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
967
982
  const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
968
983
  if (!session) return null
@@ -1019,7 +1034,9 @@ type TriageDbRow = {
1019
1034
  updatedByLogin: string | null; updatedAt: number
1020
1035
  }
1021
1036
  type TriageStateDbRow = { color: string | null; triage: string | null; comment: string | null; fix: string | null; flagged: number | null }
1022
- type TriageEventDbRow = TriageStateDbRow & { seq: number; findingId: string; batchId: string; actorLogin: string | null; at: number }
1037
+ type TriageEventDbRow = TriageStateDbRow & {
1038
+ seq: number; findingId: string; batchId: string; actorId: string | null; actorLogin: string | null; actorName: string | null; at: number
1039
+ }
1023
1040
 
1024
1041
  // The per-finding triage slice of ManagedDb. Closes over its prepared
1025
1042
  // statements (and the handle, for the batch write's transaction). A
@@ -1074,7 +1091,7 @@ function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimi
1074
1091
  seq: r.seq, findingId: r.findingId, batchId: r.batchId,
1075
1092
  color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1076
1093
  flagged: r.flagged == null ? null : r.flagged === 1,
1077
- actorLogin: r.actorLogin, at: r.at,
1094
+ actorId: r.actorId, actorLogin: r.actorLogin, actorName: r.actorName, at: r.at,
1078
1095
  }))
1079
1096
  },
1080
1097
  async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
@@ -1117,23 +1134,27 @@ function mapBundle(r: BundleRow): ManagedBundle {
1117
1134
  }
1118
1135
 
1119
1136
  // The bundle slice of ManagedDb. Closes over its prepared statements.
1120
- function bundleMethods(stmts: ReturnType<typeof prepareStatements>) {
1137
+ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
1121
1138
  const {
1122
1139
  insertBundleStmt, selectBundleByIntegrityStmt, selectBundleStmt,
1123
1140
  selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
1124
1141
  } = stmts
1142
+ async function getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
1143
+ const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
1144
+ return row == null ? null : mapBundle(row)
1145
+ }
1125
1146
  return {
1126
1147
  async insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void> {
1127
1148
  await authorizeUpload(stmts, sessionId, bundle)
1149
+ await validateStorageUpload(db, key, 'bundle', bundle.id, bundle.dataKey)
1128
1150
  await insertBundleStmt.run(
1129
1151
  bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
1130
- bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now,
1152
+ bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now, bundle.dataKey ?? null, bundle.dataKey ? 1 : 0,
1131
1153
  )
1132
1154
  },
1133
- async getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
1134
- const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
1135
- return row == null ? null : mapBundle(row)
1136
- },
1155
+ getBundleByIntegrity,
1156
+ // The distinct method name selects a writer-locked reconciliation scope.
1157
+ resolveBundleUpload: getBundleByIntegrity,
1137
1158
  async getBundle(id: string): Promise<ManagedBundle | null> {
1138
1159
  const row = (await selectBundleStmt.get(id)) as BundleRow | undefined
1139
1160
  return row == null ? null : mapBundle(row)
@@ -1300,13 +1321,16 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1300
1321
  // `triageHistoryLimit`: events kept per finding in the triage trail; 0 (the
1301
1322
  // default) keeps everything. See ManagedConfig.
1302
1323
  export interface ManagedDbOptions {
1324
+ storageEncryptionKey?: StorageKey | null
1303
1325
  triageHistoryLimit?: number
1304
1326
  }
1305
1327
 
1306
1328
  export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions = {}): ManagedDb {
1329
+ const key = options.storageEncryptionKey ?? null
1307
1330
  const comments = commentMethods(db)
1308
1331
  const activity = activityMethods(db)
1309
1332
  const stmts = prepareStatements(db)
1333
+ const reports = reportMethods(stmts, db, key)
1310
1334
  const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
1311
1335
  const {
1312
1336
  upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
@@ -1314,6 +1338,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1314
1338
  } = stmts
1315
1339
 
1316
1340
  const methods: Omit<ManagedDb, keyof ManagementStore> = {
1341
+ ...storageMethods(db, key),
1317
1342
  async upsertUser(user, now, initialAdminGithubId = null) {
1318
1343
  // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1319
1344
  // user's id (DO UPDATE leaves it untouched), so re-read to return it.
@@ -1354,12 +1379,18 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1354
1379
  return Number((await updateRoleStmt.run(role, Date.now(), id)).changes) > 0
1355
1380
  },
1356
1381
  async setUserTokens(id, tokens) {
1357
- await updateTokensStmt.run(tokens.accessToken, tokens.refreshToken, tokens.expiresAt, Date.now(), id)
1382
+ const state = await storageState(db, key)
1383
+ const value = state ? encodeStorageTokens(key!, id, tokens.accessToken, tokens.refreshToken) : { access: tokens.accessToken, refresh: tokens.refreshToken }
1384
+ await updateTokensStmt.run(value.access, value.refresh, tokens.expiresAt, state ? 1 : 0, Date.now(), id)
1358
1385
  },
1359
1386
  async getUserTokens(id) {
1360
- const row = (await selectTokensStmt.get(id)) as { access: string | null; refresh: string | null; exp: number | null } | undefined
1387
+ const state = await storageState(db, key)
1388
+ const row = (await selectTokensStmt.get(id)) as StoredTokens | undefined
1361
1389
  if (row == null || row.access == null) return null
1362
- return { accessToken: row.access, refreshToken: row.refresh, expiresAt: row.exp }
1390
+ if (row.encrypted && !state) throw new Error('Encrypted tokens without storage encryption state')
1391
+ if (!row.encrypted && state?.complete) throw new Error('Unexpected plaintext GitHub tokens')
1392
+ const value = row.encrypted ? decodeStorageTokens(key!, id, row) : row
1393
+ return { accessToken: value.access!, refreshToken: value.refresh, expiresAt: row.exp }
1363
1394
  },
1364
1395
  async getUserGithubId(id) {
1365
1396
  const row = (await selectGithubIdStmt.get(id)) as { githubId: number } | undefined
@@ -1371,10 +1402,10 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1371
1402
  ...activity,
1372
1403
  ...comments,
1373
1404
  ...workspaceShareMethods(db),
1374
- ...reportMethods(stmts),
1405
+ ...reports,
1375
1406
  ...triage,
1376
- ...importTriageMethods({ ...triage, ...comments }),
1377
- ...bundleMethods(stmts),
1407
+ ...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
1408
+ ...bundleMethods(stmts, db, key),
1378
1409
  ...teamMethods(stmts),
1379
1410
  async close() {
1380
1411
  await db.close()
@@ -2,11 +2,12 @@ import { AsyncLocalStorage } from 'node:async_hooks'
2
2
  import { WebSocket } from 'ws'
3
3
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
4
4
  import { MANAGED_SCHEMA } from './db-schema.ts'
5
+ import { STORAGE_SCHEMA } from './storage-db.ts'
5
6
  import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
6
7
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
7
8
  import { COMMENT_SCHEMA } from './comments.ts'
8
9
  import { ACTIVITY_SCHEMA } from './activity.ts'
9
- import { ManagedCommitError, type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
10
+ import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
10
11
  import { postgresSchema, postgresSql } from './sql-postgres.ts'
11
12
  import { managedTableRenames } from './db-table-names.ts'
12
13
  import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
@@ -57,7 +58,7 @@ async function initialize(db: PgConnection): Promise<void> {
57
58
  await db.query(postgresSchema(WORKSPACE_SHARE_SCHEMA))
58
59
  await db.query('INSERT INTO managed_schema_version VALUES (4)')
59
60
  }
60
- await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA))
61
+ await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA))
61
62
  await db.query(`ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS state_reason ${GITHUB_STATE_REASON_COLUMN}`)
62
63
  await db.query('ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS attempted_at BIGINT')
63
64
  if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 5')).rows.length === 0) {
@@ -83,6 +84,10 @@ async function initialize(db: PgConnection): Promise<void> {
83
84
  await db.query('CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id)')
84
85
  await db.query('INSERT INTO managed_schema_version VALUES (8)')
85
86
  }
87
+ for (const table of ['managed_report', 'managed_bundle']) {
88
+ await db.query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS data_key TEXT, ADD COLUMN IF NOT EXISTS storage_encrypted INTEGER NOT NULL DEFAULT 0`)
89
+ }
90
+ await db.query('ALTER TABLE managed_user ADD COLUMN IF NOT EXISTS gh_tokens_encrypted INTEGER NOT NULL DEFAULT 0')
86
91
  await db.query('COMMIT')
87
92
  } catch (err) {
88
93
  await db.query('ROLLBACK')
@@ -155,11 +160,11 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
155
160
  } catch (err) {
156
161
  await db.query('ROLLBACK').catch(() => {})
157
162
  await db.release().catch(() => {})
158
- if (write && commitAttempted) throw new ManagedCommitError(err)
163
+ if (write && commitAttempted) throw new Error('Managed commit outcome is uncertain', { cause: err })
159
164
  throw err
160
165
  }
161
166
  try { await db.release() }
162
- catch (err) { if (write) throw new ManagedCommitError(err); throw err }
167
+ catch (err) { if (write) throw new Error('Managed commit outcome is uncertain', { cause: err }); throw err }
163
168
  return result
164
169
  },
165
170
  close() { closed = true },
@@ -7,6 +7,7 @@ import { migrateReportLocations } from './report-migration.ts'
7
7
  import { initCommentMethods } from './comments.ts'
8
8
  import { initActivityMethods } from './activity.ts'
9
9
  import { MANAGED_SCHEMA } from './db-schema.ts'
10
+ import { STORAGE_SCHEMA } from './storage-db.ts'
10
11
  import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
11
12
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
12
13
  import { managedTableRenames } from './db-table-names.ts'
@@ -65,7 +66,12 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
65
66
  db.exec('PRAGMA synchronous = FULL;')
66
67
  db.exec('PRAGMA foreign_keys = ON;')
67
68
  migrateManagedTableNames(db)
68
- db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA)
69
+ db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA)
70
+ for (const table of ['managed_report', 'managed_bundle']) {
71
+ ensureColumn(db, table, 'data_key', 'TEXT')
72
+ ensureColumn(db, table, 'storage_encrypted', 'INTEGER NOT NULL DEFAULT 0')
73
+ }
74
+ ensureColumn(db, 'managed_user', 'gh_tokens_encrypted', 'INTEGER NOT NULL DEFAULT 0')
69
75
  ensureColumn(db, 'managed_github_metadata', 'state_reason', GITHUB_STATE_REASON_COLUMN)
70
76
  ensureColumn(db, 'managed_github_metadata', 'attempted_at', 'INTEGER')
71
77
  // Existing public links must opt in too; never preserve an implicit grant.
@@ -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'
@@ -59,7 +59,7 @@ import type { AvatarStore } from './avatar-store.ts'
59
59
  import type { BlobStore } from './blob-store.ts'
60
60
  import { bundleIntegrity, bundleKind, reportBundleHashes } from './bundle.ts'
61
61
  import type { ManagedConfig } from './config.ts'
62
- import type { ManagedDb, ManagedSession, ReportRecord, StoredUser, TriageEventRow } from './db.ts'
62
+ import type { ManagedBundle, ManagedDb, ManagedSession, ReportRecord, StoredUser, TriageEventRow } from './db.ts'
63
63
  import type { OriginGate } from '../server-common/origin.ts'
64
64
  import { isRole, roleAtLeast } from '../common/managed/roles.ts'
65
65
  import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.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,7 +92,6 @@ 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
95
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
97
96
 
98
97
  const SESSION_PATH = '/api/auth/session'
@@ -280,19 +279,32 @@ function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: st
280
279
  }
281
280
 
282
281
  async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
283
- if (req.method !== 'POST') { send405(res, 'POST'); return }
282
+ const cancel = req.method === 'DELETE'
283
+ if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
284
284
  const s = await checkMutation(req, res, deps, cookie)
285
285
  if (!s || !requireManageRole(res, s.user)) return
286
286
  if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
287
- const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)\/(0|[1-9][0-9]*)$/u.exec(path)
288
- if (!match) { sendJson(res, 400, { error: 'bad-upload' }); return }
289
- const id = match[2]!, index = Number(match[3]), kind = match[1] as UploadKind
287
+ const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
288
+ if (!match || (cancel ? match[3] !== undefined : match[3] === undefined)) { sendJson(res, 400, { error: 'bad-upload' }); return }
289
+ const id = match[2]!, kind = match[1] as UploadKind
290
+ if (cancel) {
291
+ const count = Number(req.headers['x-upload-parts'])
292
+ if (!validUpload(id, count)) { sendJson(res, 400, { error: 'bad-upload' }); return }
293
+ await deleteUpload(deps.uploadStore, s.session.id, kind, id, count)
294
+ sendJson(res, 200, { ok: true }); return
295
+ }
296
+ const index = Number(match[3])
290
297
  const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
291
- if (!validUploadPart(id, index, maxBytes)) { sendJson(res, 400, { error: 'bad-upload' }); return }
298
+ const discard = async () => {
299
+ if (validUpload(id, index + 1)) {
300
+ await deleteUpload(deps.uploadStore!, s.session.id, kind, id, index + 1).catch(err => console.warn('managed: upload cleanup failed:', err))
301
+ }
302
+ }
303
+ if (!validUploadPart(id, index, maxBytes)) { await discard(); sendJson(res, 400, { error: 'bad-upload' }); return }
292
304
  let bytes
293
305
  try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
294
- catch { sendJson(res, 413, { error: 'too-large' }); return }
295
- if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
306
+ catch { await discard(); sendJson(res, 413, { error: 'too-large' }); return }
307
+ if (bytes.length === 0) { await discard(); sendJson(res, 400, { error: 'empty' }); return }
296
308
  if (await manageMutation(req, res, deps, cookie) == null) return
297
309
  await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
298
310
  sendJson(res, 200, { ok: true })
@@ -901,18 +913,22 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
901
913
  const id = randomUUID()
902
914
  const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
903
915
  const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
904
- await deps.reportStore.put(id, bytes)
916
+ const dataKey = await deps.reportStore.put(id, bytes)
905
917
  let report: ReportRecord
906
918
  try {
907
919
  report = await deps.db.insertOrReuseReport({
908
- id, filename, contentType, byteSize: bytes.length, sha256,
920
+ id, filename, contentType, byteSize: bytes.length, sha256, dataKey,
909
921
  uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
910
922
  repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
911
923
  }, Date.now(), s.session.id)
912
924
  } catch (err) {
913
- // Losing the commit acknowledgement cannot turn primary bytes into garbage.
914
- if (!(err instanceof ManagedCommitError)) await deps.reportStore.delete(id).catch(() => {})
915
- throw err
925
+ // A successful COMMIT can lose its acknowledgement. Never delete a file
926
+ // until a writer-locked read confirms that this candidate did not commit.
927
+ let committed: ReportRecord | null
928
+ try { committed = await deps.db.resolveReportUpload(id) }
929
+ catch (lookup) { throw new AggregateError([err, lookup], 'Report upload reconciliation failed', { cause: lookup }) }
930
+ if (committed) report = committed
931
+ else { await deps.reportStore.delete(id).catch(() => {}); throw err }
916
932
  }
917
933
  if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
918
934
  await sendUploadedReport(req, res, deps, cookie, report, report.id !== id)
@@ -1030,7 +1046,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1030
1046
  if (!current) return
1031
1047
  if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1032
1048
  sendJson(res, 200, refs ? result : {
1033
- ...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [] } : {}), path, commit,
1049
+ ...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false } : {}), path, commit,
1034
1050
  })
1035
1051
  } catch (err) {
1036
1052
  if (err instanceof GithubApiError) {
@@ -1142,6 +1158,18 @@ async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, coo
1142
1158
  await backfillBundleSummaries(bundles, deps.bundleCache)
1143
1159
  }
1144
1160
 
1161
+ type UploadedBundle = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
1162
+ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, bundle: UploadedBundle, deduped: boolean): Promise<void> {
1163
+ const s = await manageMutation(req, res, deps, cookie)
1164
+ if (!s) return
1165
+ if (deduped && !(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1166
+ const { id, integrity, filename, byteSize, repoId, repoDirectory } = bundle
1167
+ // Re-uploading also repairs reports uploaded while the bundle was inaccessible.
1168
+ await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
1169
+ prebuildBundle(deps, id)
1170
+ sendJson(res, deduped ? 200 : 201, { id, integrity, filename, repoId, repoDirectory, ...(deduped ? { deduped: true } : { byteSize }) })
1171
+ }
1172
+
1145
1173
  // POST /api/admin/bundles — upload a bundle. Mutation: same-origin + CSRF,
1146
1174
  // admin|manage. Raw bytes; X-Bundle-Filename names it, optional X-Repo-Id and
1147
1175
  // X-Repo-Directory assign a repository location. The bundle's identity is its content hash (sha512), UNIQUE — a
@@ -1178,48 +1206,31 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1178
1206
  const integrity = bundleIntegrity(bytes)
1179
1207
  const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
1180
1208
  const existing = await deps.db.getBundleByIntegrity(integrity)
1181
- if (existing != null) {
1182
- s = await manageMutation(req, res, deps, cookie)
1183
- if (!s) return
1184
- if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1185
- // Reports uploaded while this bundle was inaccessible retain only its
1186
- // integrity. An authorized re-upload repairs those pending links too.
1187
- await deps.db.linkReportsToBundle(integrity, existing.id, s.user.role === 'admin' ? undefined : s.user.id)
1188
- prebuildBundle(deps, existing.id)
1189
- sendJson(res, 200, { id: existing.id, integrity, filename: existing.filename, repoId: existing.repoId, repoDirectory: existing.repoDirectory, deduped: true })
1190
- return
1191
- }
1209
+ if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, true); return }
1192
1210
  const id = randomUUID()
1193
1211
  const kind = bundleKind(filename)
1194
- await deps.bundleStore.put(id, bytes, kind)
1212
+ const dataKey = await deps.bundleStore.put(id, bytes, kind)
1195
1213
  try {
1196
1214
  await deps.db.insertBundle({
1197
- id, integrity, filename, kind,
1215
+ id, integrity, filename, kind, dataKey,
1198
1216
  byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
1199
1217
  }, Date.now(), s.session.id)
1200
1218
  } catch (err) {
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
1204
1219
  // A concurrent upload of identical bytes can insert this integrity (UNIQUE)
1205
1220
  // between our dedup check and this insert — treat that as a dedup, not a 500.
1206
1221
  // Any other failure rethrows.
1207
- const raced = await deps.db.getBundleByIntegrity(integrity)
1208
- if (raced != null) {
1209
- s = await manageMutation(req, res, deps, cookie)
1210
- if (!s) return
1211
- if (!(await canAccessBundle(deps, s.user, raced.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1212
- await deps.db.linkReportsToBundle(integrity, raced.id, s.user.role === 'admin' ? undefined : s.user.id)
1213
- prebuildBundle(deps, raced.id)
1214
- sendJson(res, 200, { id: raced.id, integrity, filename: raced.filename, repoId: raced.repoId, repoDirectory: raced.repoDirectory, deduped: true }); return }
1215
- throw err
1222
+ let raced: ManagedBundle | null
1223
+ try { raced = await deps.db.resolveBundleUpload(integrity) }
1224
+ catch (lookup) { throw new AggregateError([err, lookup], 'Bundle upload reconciliation failed', { cause: lookup }) }
1225
+ // Preserve a committed candidate after a lost acknowledgement. If this
1226
+ // read fails too, leave the bytes for later reconciliation.
1227
+ if (raced?.id !== id) await deps.bundleStore.delete(id).catch(() => {})
1228
+ if (err instanceof ManagedMutationError) throw err
1229
+ if (!raced) throw err
1230
+ if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, true); return }
1231
+ // Our insert committed: continue with the ordinary creation response.
1216
1232
  }
1217
- // Auto-link reports that declared this integrity before the bundle existed.
1218
- s = await manageMutation(req, res, deps, cookie)
1219
- if (!s) return
1220
- await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
1221
- prebuildBundle(deps, id)
1222
- sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
1233
+ await sendUploadedBundle(req, res, deps, cookie, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory }, false)
1223
1234
  }
1224
1235
 
1225
1236
  // GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
@@ -1582,7 +1593,8 @@ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHt
1582
1593
  const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
1583
1594
  if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
1584
1595
  const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
1585
- seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
1596
+ seq: row.seq, at: row.at, actorId: row.actorId, actorLogin: row.actorLogin, actorName: row.actorName,
1597
+ batchId: row.batchId, entry: triageWireEntry(row, true),
1586
1598
  }))
1587
1599
  // A database read can outlive a role, membership, or visibility change.
1588
1600
  const latest = await readSession(deps.config, deps.db, cookie, Date.now())
@@ -1632,9 +1644,31 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
1632
1644
  sendJson(res, 200, { ok: true })
1633
1645
  }
1634
1646
 
1647
+ // GET only: clients match local annotations against these IDs without sending
1648
+ // their local finding IDs or annotation bodies to discover which reports exist.
1649
+ async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
1650
+ const s = await readAdminSession(res, deps, cookie)
1651
+ if (!s) return
1652
+ const after = search.get('after') ?? ''
1653
+ if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
1654
+ const stored = await deps.db.listFindingCatalogReports(after, FINDING_CATALOG_PAGE_COUNT + 1)
1655
+ const reports = []
1656
+ let bytes = 0, cursor = after, processed = 0
1657
+ for (const report of stored) {
1658
+ if (processed >= FINDING_CATALOG_PAGE_COUNT || (processed > 0 && bytes + report.byteSize > FINDING_CATALOG_PAGE_BYTES)) break
1659
+ bytes += report.byteSize
1660
+ processed++
1661
+ cursor = report.id
1662
+ const findingIds = [...await visibleFindingIds(deps, s.user, report.id, s.session.id, null)]
1663
+ if (findingIds.length > 0) reports.push({ id: report.id, findingIds })
1664
+ }
1665
+ if (await readAdminSession(res, deps, cookie) == null) return
1666
+ sendJson(res, 200, { reports, nextCursor: processed < stored.length ? cursor : null })
1667
+ }
1668
+
1635
1669
  // Admin imports compare the exact snapshot shown by the conflict dialog before
1636
1670
  // writing. The store commits shared finding triage and imported comments together.
1637
- async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
1671
+ async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1638
1672
  const s = await adminMutation(req, res, deps, cookie)
1639
1673
  if (!s) return
1640
1674
  let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
@@ -1658,15 +1692,10 @@ async function handleImportTriage(req: IncomingMessage, res: ServerResponse, dep
1658
1692
  parsed.push([findingId, patch])
1659
1693
  }
1660
1694
  }
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
- }
1695
+ if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
1696
+ const visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
1668
1697
  if (await readAdminSession(res, deps, cookie) == null) return
1669
- if (visible && ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
1698
+ if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
1670
1699
  if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
1671
1700
  const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
1672
1701
  sendJson(res, applied ? 200 : 409, { ok: applied })
@@ -1925,7 +1954,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
1925
1954
  if (method !== 'GET') { send405(res, 'GET'); return }
1926
1955
  const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
1927
1956
  sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
1928
- managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? { uploadChunkBytes: UPLOAD_CHUNK_BYTES } : {}) } })
1957
+ managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
1958
+ uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
1959
+ } : {}) } })
1929
1960
  return
1930
1961
  }
1931
1962
  // OAuth: start → redirect to GitHub with the CSRF state cookie.
@@ -2064,9 +2095,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2064
2095
  if (method !== 'POST') { send405(res, 'POST'); return }
2065
2096
  await handleSetReportVisible(req, res, deps, cookie); return
2066
2097
  }
2067
- if (path === '/api/admin/import-triage') {
2068
- if (method !== 'POST') { send405(res, 'POST'); return }
2069
- await handleImportTriage(req, res, deps, cookie); return
2098
+ if (path === '/api/admin/reports/finding-ids') {
2099
+ if (method !== 'GET') { send405(res, 'GET'); return }
2100
+ await handleImportFindingIds(res, deps, cookie, url.searchParams); return
2070
2101
  }
2071
2102
  const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
2072
2103
  if (importTriage) {