@preventive/triage 1.0.0-alpha.26 → 1.0.0-alpha.28

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 (96) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/client/linked-findings.js +1 -1
  3. package/common/bundle-metadata.js +12 -10
  4. package/common/code-language.js +17 -2
  5. package/common/default-scan-models.ts +3 -3
  6. package/common/finding-id.js +1 -1
  7. package/common/managed/report-content.ts +1 -1
  8. package/common/managed/report-filter.ts +1 -1
  9. package/out/client-managed-import.js +24 -24
  10. package/out/client-managed.js +93 -68
  11. package/out/graph.js +11 -11
  12. package/out/index.html +1 -1
  13. package/out/prism.js +2 -2
  14. package/out/stasis.svg +0 -25
  15. package/out/terminal.js +163 -163
  16. package/out/view.css +1 -1
  17. package/out/view.js +223 -190
  18. package/package.json +29 -29
  19. package/server-common/storage-crypto.ts +182 -0
  20. package/server-common/storage-log.ts +5 -1
  21. package/server-common/vercel-blob.ts +6 -3
  22. package/server-e2e/static.ts +3 -4
  23. package/server-managed/avatar-store.ts +1 -40
  24. package/server-managed/blob-store.ts +3 -52
  25. package/server-managed/bundle-advisories.ts +53 -0
  26. package/server-managed/bundle-advisory-inventory.ts +75 -0
  27. package/server-managed/bundle-build-leases.ts +41 -0
  28. package/server-managed/bundle-build-worker.js +44 -0
  29. package/server-managed/bundle-build.ts +141 -0
  30. package/server-managed/bundle-cache.ts +22 -41
  31. package/server-managed/bundle-store.ts +3 -7
  32. package/server-managed/bundle-summary-cache.ts +27 -9
  33. package/server-managed/bundle.ts +66 -1
  34. package/server-managed/cache-storage.ts +4 -25
  35. package/server-managed/cli.js +10 -2
  36. package/server-managed/config.ts +12 -1
  37. package/server-managed/content-access.ts +1 -1
  38. package/server-managed/db-methods.ts +163 -40
  39. package/server-managed/db-neon.ts +124 -30
  40. package/server-managed/db-schema.ts +2 -0
  41. package/server-managed/db.ts +11 -1
  42. package/server-managed/github-app.ts +10 -15
  43. package/server-managed/github-errors.ts +21 -0
  44. package/server-managed/github-issues.ts +1 -1
  45. package/server-managed/github-metadata.ts +34 -1
  46. package/server-managed/github-pulls.ts +142 -30
  47. package/server-managed/http.ts +278 -167
  48. package/server-managed/index.ts +26 -9
  49. package/server-managed/management-catalog.ts +45 -0
  50. package/server-managed/object-storage-disk.ts +144 -0
  51. package/server-managed/object-storage-vercel.ts +60 -0
  52. package/server-managed/object-storage.ts +71 -0
  53. package/server-managed/public-workspace.ts +20 -14
  54. package/server-managed/report-migration.ts +1 -1
  55. package/server-managed/report-sources.ts +14 -4
  56. package/server-managed/repository-browser.ts +8 -1
  57. package/server-managed/repository-discovery.ts +1 -1
  58. package/server-managed/revisions.ts +35 -0
  59. package/server-managed/solidity-entry-points.ts +69 -0
  60. package/server-managed/sql-postgres.ts +1 -0
  61. package/server-managed/sql.ts +14 -8
  62. package/server-managed/storage-command.ts +16 -0
  63. package/server-managed/storage-db.ts +175 -0
  64. package/server-managed/storage-encryption.ts +211 -0
  65. package/server-managed/storage-maintenance.ts +197 -0
  66. package/server-managed/storage-payload.ts +72 -0
  67. package/server-managed/storage-stores.ts +81 -0
  68. package/server-managed/storage.ts +48 -24
  69. package/server-managed/team-feed.ts +50 -5
  70. package/server-managed/team-reports.ts +85 -22
  71. package/server-managed/uploads.ts +2 -2
  72. package/server-managed/workspace-shares.ts +15 -3
  73. package/report/index.js +0 -260
  74. package/report/src/finding-id.js +0 -80
  75. package/report/src/finding.js +0 -314
  76. package/report/src/labels.js +0 -35
  77. package/report/src/md-structure.js +0 -471
  78. package/report/src/md-text.js +0 -167
  79. package/report/src/meta.js +0 -76
  80. package/report/src/parse-codex.js +0 -149
  81. package/report/src/parse-deepsec.js +0 -199
  82. package/report/src/parse-deepview-fields.js +0 -375
  83. package/report/src/parse-deepview-md.js +0 -186
  84. package/report/src/parse-md-id.js +0 -137
  85. package/report/src/parse-md.js +0 -323
  86. package/report/src/parse-piolium-id.js +0 -79
  87. package/report/src/parse-piolium-rows.js +0 -126
  88. package/report/src/parse-piolium-tokens.js +0 -185
  89. package/report/src/parse-piolium.js +0 -399
  90. package/report/src/security.js +0 -63
  91. package/report/src/severity.js +0 -18
  92. package/report/src/utf8.js +0 -21
  93. package/report/src/write-md-finding.js +0 -273
  94. package/report/src/write-md.js +0 -291
  95. package/server-managed/blob-vercel.ts +0 -127
  96. /package/{LICENSE → LICENSE-MIT} +0 -0
@@ -8,10 +8,14 @@ import { type CommentStore, commentMethods } from './comments.ts'
8
8
  import { type ActivityStore, activityMethods } from './activity.ts'
9
9
  import { type GithubMetadataStore, githubMetadataMethods } from './github-metadata.ts'
10
10
  import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
11
+ import { type BundleBuildLeaseStore, bundleBuildLeaseMethods } from './bundle-build-leases.ts'
11
12
  import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
12
13
  import type { ManagedSql } from './sql.ts'
13
14
  import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
14
15
  import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
16
+ import { type ManagementCatalogStore, managementCatalogMethods } from './management-catalog.ts'
17
+ import { type StorageKey } from '../server-common/storage-crypto.ts'
18
+ import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, validateStorageUpload } from './storage-db.ts'
15
19
 
16
20
  // A managed user identity (the subset of GitHub's `GET /user` we keep). Input
17
21
  // to the upsert; `githubUserId` is the provider lookup key, never exposed to
@@ -70,6 +74,9 @@ export interface SelectedRepo {
70
74
  private: boolean
71
75
  installationId: number | null
72
76
  defaultBranch: string
77
+ // Last live default observed by the repository browser, independent of the
78
+ // selection/discovery snapshot above. NULL means unknown or unavailable.
79
+ cachedDefaultBranch: string | null
73
80
  htmlUrl: string
74
81
  addedBy: string | null
75
82
  addedAt: number
@@ -84,7 +91,7 @@ export interface ManagedRepo extends SelectedRepo {
84
91
 
85
92
  // What a caller supplies to select (upsert) a repo; the store stamps the
86
93
  // timestamps.
87
- export type SelectedRepoInput = Omit<SelectedRepo, 'addedAt'>
94
+ export type SelectedRepoInput = Omit<SelectedRepo, 'addedAt' | 'cachedDefaultBranch'>
88
95
 
89
96
  // A stored report's metadata. The bytes live in the blob-store keyed by `id`;
90
97
  // `contentType` + `filename` ride here so a download can label them, `sha256`
@@ -131,6 +138,7 @@ export interface ReportAccessSnapshot {
131
138
  // bundle links; `bundleIntegrity` is the report's declared primary bundle (kept
132
139
  // so a later bundle upload of that integrity re-links it).
133
140
  export interface ReportRecordInput {
141
+ dataKey?: string | null
134
142
  id: string
135
143
  filename: string
136
144
  contentType: string
@@ -228,7 +236,7 @@ export interface ManagedBundle {
228
236
 
229
237
  // What the upload handler supplies to record a bundle; the store stamps
230
238
  // uploaded_at. `uploadedByLogin` is the durable uploader-login snapshot.
231
- export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string }
239
+ export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string; dataKey?: string | null }
232
240
 
233
241
  // A bundle row for the "Manage bundles" list — adds the uploader login + repo
234
242
  // full name display joins (null when absent / since removed).
@@ -310,7 +318,12 @@ export interface UserTeam {
310
318
  }
311
319
 
312
320
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
313
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore {
321
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, BundleBuildLeaseStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb {
322
+ claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
323
+ finishMaintenanceLease(owner: string, until: number): Promise<void>
324
+ getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
325
+ // Reuse connections within a finite request or one feed polling iteration.
326
+ withRequest?<T>(work: () => Promise<T>): Promise<T>
314
327
  // Upsert the identity; returns the user's opaque id (stable across logins).
315
328
  // Initial-admin approval comes only from trusted login configuration. It
316
329
  // promotes a matching No access identity only while it is the sole user.
@@ -330,6 +343,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
330
343
  // mutable context while keeping the original added_by/added_at; deselectRepo
331
344
  // resolves true iff a row was removed.
332
345
  selectRepo(repo: SelectedRepoInput, now: number): Promise<void>
346
+ cacheRepoDefaultBranch(repo: SelectedRepo, branch: string | null): Promise<boolean>
333
347
  connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean>
334
348
  deselectRepo(repoId: number): Promise<boolean>
335
349
  listSelectedRepos(): Promise<SelectedRepo[]>
@@ -358,6 +372,8 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
358
372
  listReports(userId?: string): Promise<AdminReport[]>
359
373
  listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]>
360
374
  getReport(id: string): Promise<ReportRecord | null>
375
+ // Writer-locked reconciliation waits for an uncertain earlier insert.
376
+ resolveReportUpload(id: string): Promise<ReportRecord | null>
361
377
  getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
362
378
  getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
363
379
  listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]>
@@ -378,6 +394,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
378
394
  // stamp nor the trail. Every change also appends to managed_finding_triage_event;
379
395
  // listTriageHistory walks one finding's trail, newest first.
380
396
  listTriage(findingIds: readonly string[]): Promise<TriageRow[]>
397
+ getAnnotations(findingIds: readonly string[]): Promise<{ triage: TriageRow[]; comments: Awaited<ReturnType<CommentStore['listComments']>> }>
381
398
  // A content-free fingerprint of current annotations, read in one snapshot.
382
399
  getAnnotationRevision(findingIds: readonly string[]): Promise<string>
383
400
  setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void>
@@ -392,8 +409,15 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
392
409
  // declared its integrity.
393
410
  insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
394
411
  getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
412
+ resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
395
413
  getBundle(id: string): Promise<ManagedBundle | null>
414
+ // One read snapshot of the live session, bundle and grants. Passing a team
415
+ // (or null for any team) also checks advisory security access; omission skips it.
416
+ getBundleAccessSnapshot(sessionId: string, now: number, id: string, advisoriesTeamId?: string | null): Promise<{
417
+ bundle: ManagedBundle | null; canReadAdvisories: boolean
418
+ } | null>
396
419
  listBundles(userId?: string): Promise<AdminBundle[]>
420
+ listReadableBundleIds(userId: string, ids: readonly string[]): Promise<string[]>
397
421
  userCanReadBundle(userId: string, id: string): Promise<boolean>
398
422
  userCanReadRepo(userId: string, repoId: number): Promise<boolean>
399
423
  userCanReadBundleAdvisories(userId: string, bundleId: string, teamId: string | null): Promise<boolean>
@@ -424,7 +448,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
424
448
  // bundles attached to that team's repos — for that user's own sidebar Teams section.
425
449
  // Any user; only their own memberships.
426
450
  listTeamsForUser(userId: string): Promise<UserTeam[]>
427
- getUserTeamFeedSnapshot(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; revision: string } | null>
451
+ getUserTeamFeedSnapshot(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; revision: string; teams: UserTeam[]; catalog: number } | null>
428
452
  // Whether `userId` may read `reportId`: true iff the report's repo belongs to
429
453
  // a team the user is a member of. Backs the team-scoped report view endpoint.
430
454
  userCanReadReport(userId: string, reportId: string): Promise<boolean>
@@ -499,11 +523,11 @@ function prepareStatements(db: ManagedSql) {
499
523
  updateRoleStmt: db.prepare(`UPDATE managed_user SET role = ?, updated_at = ? WHERE id = ?`),
500
524
  updateTokensStmt: db.prepare(
501
525
  `UPDATE managed_user
502
- SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, updated_at = ?
526
+ SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, gh_tokens_encrypted = ?, updated_at = ?
503
527
  WHERE id = ?`,
504
528
  ),
505
529
  selectTokensStmt: db.prepare(
506
- `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp
530
+ `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp, gh_tokens_encrypted AS encrypted
507
531
  FROM managed_user WHERE id = ?`,
508
532
  ),
509
533
  deleteSessionStmt: db.prepare(`DELETE FROM managed_session WHERE id = ?`),
@@ -522,6 +546,8 @@ function prepareStatements(db: ManagedSql) {
522
546
  WHERE repo_id = ? AND full_name = ? AND added_at = ? AND installation_id IS NULL
523
547
  AND EXISTS (SELECT 1 FROM managed_session s JOIN managed_user u ON u.id = s.user_id
524
548
  WHERE s.id = ? AND s.expires_at > ? AND u.role = 'admin')`),
549
+ cacheRepoDefaultBranchStmt: db.prepare(`UPDATE managed_selected_repo SET cached_default_branch = ?
550
+ WHERE repo_id = ? AND full_name = ? AND added_at = ? AND active = 1`),
525
551
  deleteRepoStmt: db.prepare(`DELETE FROM managed_selected_repo WHERE repo_id = ?`),
526
552
  deactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 0 WHERE repo_id = ? AND active = 1`),
527
553
  reactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 1 WHERE repo_id = ? AND active = 0`),
@@ -539,21 +565,21 @@ function prepareStatements(db: ManagedSql) {
539
565
  UNION SELECT finding_id FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))) AS annotations`),
540
566
  selectReposStmt: db.prepare(
541
567
  `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
542
- installation_id AS installId, default_branch AS branch, html_url AS htmlUrl,
568
+ installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
543
569
  added_by AS addedBy, added_at AS addedAt, active AS active
544
570
  FROM managed_selected_repo WHERE active = 1 ORDER BY full_name ASC`,
545
571
  ),
546
572
  selectAllReposStmt: db.prepare(
547
573
  `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
548
- installation_id AS installId, default_branch AS branch, html_url AS htmlUrl,
574
+ installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
549
575
  added_by AS addedBy, added_at AS addedAt, active AS active
550
576
  FROM managed_selected_repo ORDER BY full_name ASC`,
551
577
  ),
552
578
  insertReportStmt: db.prepare(
553
579
  `WITH candidate(id, slug) AS (VALUES (?, ?))
554
- 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)
580
+ 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)
555
581
  SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_report WHERE slug = candidate.slug) THEN id ELSE slug END,
556
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
582
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
557
583
  ),
558
584
  // LEFT JOINs so a report whose uploader / repo / bundle was removed (the FK
559
585
  // nulled) still lists, with null display fields.
@@ -680,9 +706,9 @@ function prepareStatements(db: ManagedSql) {
680
706
  WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
681
707
  insertBundleStmt: db.prepare(
682
708
  `WITH candidate(id, slug) AS (VALUES (?, ?))
683
- INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at)
709
+ 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)
684
710
  SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_bundle WHERE slug = candidate.slug) THEN id ELSE slug END,
685
- ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
711
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
686
712
  ),
687
713
  selectBundleByIntegrityStmt: db.prepare(
688
714
  `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
@@ -711,6 +737,11 @@ function prepareStatements(db: ManagedSql) {
711
737
  SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
712
738
  WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
713
739
  ),
740
+ selectReadableBundleIdsStmt: db.prepare(
741
+ `SELECT b.id FROM managed_bundle b WHERE b.id IN (SELECT value FROM json_each(?)) AND (b.uploaded_by = ? OR EXISTS (
742
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
743
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
744
+ ),
714
745
  selectRepoReadableStmt: db.prepare(
715
746
  `SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
716
747
  WHERE tr.repo_id = ? AND tu.user_id = ? LIMIT 1`,
@@ -836,7 +867,7 @@ function prepareStatements(db: ManagedSql) {
836
867
 
837
868
  type RepoRow = {
838
869
  repoId: number; fullName: string; priv: number; installId: number | null
839
- branch: string; htmlUrl: string; addedBy: string | null; addedAt: number; active: number
870
+ branch: string; cachedDefaultBranch: string | null; htmlUrl: string; addedBy: string | null; addedAt: number; active: number
840
871
  }
841
872
 
842
873
  // The repo-selection slice of ManagedDb, split out to keep openSqliteManagedDb
@@ -846,7 +877,7 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
846
877
  selectReportsForRepoStmt, selectBundlesForRepoStmt, deleteReportsForRepoStmt, deleteBundlesForRepoStmt } = stmts
847
878
  const readRepo = (r: RepoRow): SelectedRepo => ({
848
879
  repoId: r.repoId, fullName: r.fullName, private: r.priv === 1,
849
- installationId: r.installId, defaultBranch: r.branch, htmlUrl: r.htmlUrl,
880
+ installationId: r.installId, defaultBranch: r.branch, cachedDefaultBranch: r.cachedDefaultBranch, htmlUrl: r.htmlUrl,
850
881
  addedBy: r.addedBy, addedAt: r.addedAt,
851
882
  })
852
883
  const readManagedRepo = (r: RepoRow): ManagedRepo => ({ ...readRepo(r), active: r.active === 1 })
@@ -857,6 +888,9 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
857
888
  repo.defaultBranch, repo.htmlUrl, repo.addedBy, now, now,
858
889
  )
859
890
  },
891
+ async cacheRepoDefaultBranch(repo: SelectedRepo, branch: string | null): Promise<boolean> {
892
+ return Number((await stmts.cacheRepoDefaultBranchStmt.run(branch, repo.repoId, repo.fullName, repo.addedAt)).changes) > 0
893
+ },
860
894
  async deselectRepo(repoId: number): Promise<boolean> {
861
895
  return Number((await deleteRepoStmt.run(repoId)).changes) > 0
862
896
  },
@@ -924,7 +958,7 @@ async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sess
924
958
  }
925
959
 
926
960
  // The report slice of ManagedDb closes over its prepared statements.
927
- function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
961
+ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
928
962
  const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
929
963
  async function getReport(id: string): Promise<ReportRecord | null> {
930
964
  const row = (await selectReportStmt.get(id)) as ReportRow | undefined
@@ -936,16 +970,18 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
936
970
  return row ? getReport(row.id) : null
937
971
  }
938
972
  async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
973
+ await validateStorageUpload(db, key, 'report', report.id, report.dataKey)
939
974
  await insertReportStmt.run(
940
975
  report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
941
976
  report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
942
977
  report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
943
- report.bundleIntegrity ?? null, now,
978
+ report.bundleIntegrity ?? null, now, report.dataKey ?? null, report.dataKey ? 1 : 0,
944
979
  )
945
980
  }
946
981
  return {
947
982
  insertReport,
948
983
  getReport,
984
+ resolveReportUpload: getReport,
949
985
  getReportByHash,
950
986
  async insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord> {
951
987
  await authorizeUpload(stmts, sessionId, report)
@@ -1038,7 +1074,7 @@ type TriageEventDbRow = TriageStateDbRow & {
1038
1074
  // current row is skipped altogether, so a client re-pushing what already
1039
1075
  // stands neither re-stamps the writer nor echoes into the trail. `historyLimit`
1040
1076
  // > 0 keeps only that many events per finding; 0 keeps everything.
1041
- function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimit: number) {
1077
+ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit: number, db: ManagedSql) {
1042
1078
  const { upsertTriageStmt, selectTriageStmt, selectTriageStateStmt, insertTriageEventStmt, trimTriageEventsStmt, selectTriageHistoryStmt,
1043
1079
  deleteTriageStmt, deleteTriageHistoryStmt, deleteCommentsStmt, deleteCommentHistoryStmt, countAnnotationsStmt } = stmts
1044
1080
  async function writeEntry(findingId: string, entry: TriageEntryPatch | null, batchId: string, updatedBy: string | null, updatedByLogin: string | null, now: number, reportId: string | null = null): Promise<void> {
@@ -1104,7 +1140,43 @@ function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimi
1104
1140
  // (and costs one fsync under synchronous = FULL, not one per row); one
1105
1141
  // batch id groups its rows in the trail.
1106
1142
  const batchId = randomUUID()
1107
- for (const [findingId, entry] of entries) await writeEntry(findingId, entry, batchId, updatedBy, updatedByLogin, now, reportId)
1143
+ if (new Set(entries.map(([id]) => id)).size !== entries.length) {
1144
+ // Preserve the ordered history of callers that supply repeated IDs.
1145
+ for (const [id, entry] of entries) await writeEntry(id, entry, batchId, updatedBy, updatedByLogin, now, reportId)
1146
+ return
1147
+ }
1148
+ const current = new Map((await selectTriageStmt.all(JSON.stringify(entries.map(([id]) => id))) as TriageDbRow[])
1149
+ .map(row => [row.findingId, row]))
1150
+ const changed = entries.flatMap(([id, entry]) => {
1151
+ const e = entry ?? {}, previous = current.get(id)
1152
+ const next = { color: e.color ?? null, triage: e.triage ?? null, comment: e.comment ?? null,
1153
+ fix: e.fix ?? null, flagged: e.flagged == null ? null : e.flagged ? 1 : 0 }
1154
+ if (previous && Object.entries(next).every(([key, value]) => previous[key as keyof TriageStateDbRow] === value)) return []
1155
+ return [[id, next.color, next.triage, next.comment, next.fix, next.flagged]]
1156
+ })
1157
+ // Bound SQL parameters while keeping the entire batch/history atomic.
1158
+ for (let offset = 0; offset < changed.length; offset += 200) {
1159
+ const rows = changed.slice(offset, offset + 200), values = rows.flat()
1160
+ const input = `WITH changes(finding_id, color, triage, comment, fix, flagged, ordinal) AS
1161
+ (VALUES ${rows.map((_, index) => `(?, ?, ?, ?, ?, CAST(? AS INTEGER), ${index})`).join(',')})`
1162
+ await db.prepare(`${input}
1163
+ INSERT INTO managed_finding_triage (finding_id, color, triage, comment, fix, flagged, updated_by, updated_by_login, updated_at)
1164
+ SELECT finding_id, color, triage, comment, fix, flagged, ?, ?, CAST(? AS BIGINT) FROM changes WHERE TRUE
1165
+ ON CONFLICT(finding_id) DO UPDATE SET color = excluded.color, triage = excluded.triage,
1166
+ comment = excluded.comment, fix = excluded.fix, flagged = excluded.flagged, updated_by = excluded.updated_by,
1167
+ updated_by_login = excluded.updated_by_login, updated_at = excluded.updated_at`).run(...values, updatedBy, updatedByLogin, now)
1168
+ await db.prepare(`${input}
1169
+ INSERT INTO managed_finding_triage_event (finding_id, batch_id, color, triage, comment, fix, flagged, actor_id, actor_login, at, report_id, report, repo)
1170
+ SELECT c.finding_id, ?, c.color, c.triage, c.comment, c.fix, c.flagged, ?, ?, CAST(? AS BIGINT), ?, r.filename, p.full_name
1171
+ FROM changes c LEFT JOIN managed_report r ON r.id = ? LEFT JOIN managed_selected_repo p ON p.repo_id = r.repo_id ORDER BY c.ordinal`)
1172
+ .run(...values, batchId, updatedBy, updatedByLogin, now, reportId ?? null, reportId ?? null)
1173
+ if (historyLimit > 0) {
1174
+ await db.prepare(`DELETE FROM managed_finding_triage_event WHERE seq IN (
1175
+ SELECT seq FROM (SELECT seq, ROW_NUMBER() OVER (PARTITION BY finding_id ORDER BY seq DESC) AS position
1176
+ FROM managed_finding_triage_event WHERE finding_id IN (SELECT value FROM json_each(?))) ranked WHERE position > ?)`)
1177
+ .run(JSON.stringify(rows.map(row => row[0])), historyLimit)
1178
+ }
1179
+ }
1108
1180
  },
1109
1181
  }
1110
1182
  }
@@ -1126,27 +1198,48 @@ function mapBundle(r: BundleRow): ManagedBundle {
1126
1198
  }
1127
1199
 
1128
1200
  // The bundle slice of ManagedDb. Closes over its prepared statements.
1129
- function bundleMethods(stmts: ReturnType<typeof prepareStatements>) {
1201
+ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
1130
1202
  const {
1131
1203
  insertBundleStmt, selectBundleByIntegrityStmt, selectBundleStmt,
1132
1204
  selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
1133
1205
  } = stmts
1206
+ async function getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
1207
+ const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
1208
+ return row == null ? null : mapBundle(row)
1209
+ }
1134
1210
  return {
1135
1211
  async insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void> {
1136
1212
  await authorizeUpload(stmts, sessionId, bundle)
1213
+ await validateStorageUpload(db, key, 'bundle', bundle.id, bundle.dataKey)
1137
1214
  await insertBundleStmt.run(
1138
1215
  bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
1139
- bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now,
1216
+ bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now, bundle.dataKey ?? null, bundle.dataKey ? 1 : 0,
1140
1217
  )
1141
1218
  },
1142
- async getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
1143
- const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
1144
- return row == null ? null : mapBundle(row)
1145
- },
1219
+ getBundleByIntegrity,
1220
+ // The distinct method name selects a writer-locked reconciliation scope.
1221
+ resolveBundleUpload: getBundleByIntegrity,
1146
1222
  async getBundle(id: string): Promise<ManagedBundle | null> {
1147
1223
  const row = (await selectBundleStmt.get(id)) as BundleRow | undefined
1148
1224
  return row == null ? null : mapBundle(row)
1149
1225
  },
1226
+ async getBundleAccessSnapshot(sessionId: string, now: number, id: string, advisoriesTeamId?: string | null) {
1227
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1228
+ if (!session) return null
1229
+ const denied = { bundle: null, canReadAdvisories: false }
1230
+ if (!roleAtLeast(session.role, 'view')) return denied
1231
+ const row = await selectBundleStmt.get(id) as BundleRow | undefined
1232
+ if (!row) return denied
1233
+ const manager = roleAtLeast(session.role, 'manage')
1234
+ if (session.role !== 'admin') {
1235
+ const allowed = manager ? await selectBundleReadableStmt.get(id, session.uid, session.uid)
1236
+ : row.repoId !== null && await selectRepoPathReadableStmt.get(row.repoDirectory, row.repoId, session.uid)
1237
+ if (!allowed) return denied
1238
+ }
1239
+ const canReadAdvisories = manager || (advisoriesTeamId !== undefined
1240
+ && await stmts.selectBundleSecurityStmt.get(id, session.uid, advisoriesTeamId, advisoriesTeamId) != null)
1241
+ return { bundle: mapBundle(row), canReadAdvisories }
1242
+ },
1150
1243
  async listBundles(userId?: string): Promise<AdminBundle[]> {
1151
1244
  const rows = (await selectBundlesStmt.all(userId ?? null, userId ?? null, userId ?? null)) as BundleListRow[]
1152
1245
  return rows.map((r) => ({
@@ -1158,6 +1251,11 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>) {
1158
1251
  async userCanReadBundle(userId: string, id: string): Promise<boolean> {
1159
1252
  return (await selectBundleReadableStmt.get(id, userId, userId)) != null
1160
1253
  },
1254
+ async listReadableBundleIds(userId: string, ids: readonly string[]): Promise<string[]> {
1255
+ if (ids.length === 0) return []
1256
+ const rows = await stmts.selectReadableBundleIdsStmt.all(JSON.stringify([...new Set(ids)]), userId, userId) as { id: string }[]
1257
+ return rows.map(row => row.id)
1258
+ },
1161
1259
  async userCanReadRepo(userId: string, repoId: number): Promise<boolean> {
1162
1260
  return (await selectRepoReadableStmt.get(repoId, userId)) != null
1163
1261
  },
@@ -1183,7 +1281,7 @@ type TeamMemberRow = { teamId: string; userId: string; login: string; viewDepend
1183
1281
  // The team slice of ManagedDb. listTeams reads the three tables in full and
1184
1282
  // groups in JS (3 queries, not N+1) — fine for the handful of teams a managed
1185
1283
  // workspace has.
1186
- function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1284
+ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql) {
1187
1285
  const {
1188
1286
  insertTeamStmt, selectTeamByNameStmt, renameTeamStmt, deleteTeamStmt, selectTeamStmt,
1189
1287
  selectUserRepoScopesStmt, selectTeamsStmt, selectTeamsForUserStmt, selectUserTeamReportsStmt, selectUserTeamBundlesStmt, selectReportReadableStmt,
@@ -1246,15 +1344,19 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1246
1344
  bundles: bundlesByTeam.get(t.id) ?? [],
1247
1345
  }))
1248
1346
  },
1347
+ async getFeedState(sessionId: string, now: number) {
1348
+ const row = await db.prepare(`SELECT u.id, u.role, r.catalog, r.annotations FROM managed_session s
1349
+ JOIN managed_user u ON u.id = s.user_id JOIN managed_change_revision r ON r.id = 1
1350
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, now) as { id: string; role: Role; catalog: number; annotations: number } | undefined
1351
+ return row ? { user: { id: row.id, role: row.role }, catalog: row.catalog, annotations: row.annotations } : null
1352
+ },
1249
1353
  async getUserTeamFeedSnapshot(sessionId: string, now: number) {
1250
1354
  // One short read-only transaction, shared by SQLite and Postgres. Never
1251
1355
  // touch last-seen timestamps or include other users' memberships.
1252
- const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1253
- if (!session) return null
1254
- const teams = session.role === 'none' ? [] : await methods.listTeamsForUser(session.uid)
1255
- return { user: { id: session.uid, role: session.role },
1256
- revision: teamCatalogRevision(teams),
1257
- }
1356
+ const state = await methods.getFeedState(sessionId, now)
1357
+ if (!state) return null
1358
+ const teams = state.user.role === 'none' ? [] : await methods.listTeamsForUser(state.user.id)
1359
+ return { user: state.user, catalog: state.catalog, teams, revision: teamCatalogRevision(teams) }
1258
1360
  },
1259
1361
  async userCanReadReport(userId: string, reportId: string): Promise<boolean> {
1260
1362
  return (await selectReportReadableStmt.get(reportId, userId)) != null
@@ -1309,21 +1411,33 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1309
1411
  // `triageHistoryLimit`: events kept per finding in the triage trail; 0 (the
1310
1412
  // default) keeps everything. See ManagedConfig.
1311
1413
  export interface ManagedDbOptions {
1414
+ storageEncryptionKey?: StorageKey | null
1312
1415
  triageHistoryLimit?: number
1313
1416
  }
1314
1417
 
1315
1418
  export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions = {}): ManagedDb {
1419
+ const key = options.storageEncryptionKey ?? null
1316
1420
  const comments = commentMethods(db)
1317
1421
  const activity = activityMethods(db)
1318
1422
  const stmts = prepareStatements(db)
1319
- const reports = reportMethods(stmts)
1320
- const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
1423
+ const reports = reportMethods(stmts, db, key)
1424
+ const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0, db)
1321
1425
  const {
1322
1426
  upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
1323
1427
  touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, deleteSessionStmt, deleteExpiredStmt,
1324
1428
  } = stmts
1325
1429
 
1326
- const methods: Omit<ManagedDb, keyof ManagementStore> = {
1430
+ const methods: Omit<ManagedDb, keyof ManagementStore | keyof ManagementCatalogStore> = {
1431
+ ...storageMethods(db, key),
1432
+ ...bundleBuildLeaseMethods(db),
1433
+ async claimMaintenanceLease(owner, now, until, migration = false) {
1434
+ return !!await db.prepare(`INSERT INTO managed_maintenance_lease (id, owner, expires_at) VALUES (?, ?, ?)
1435
+ ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
1436
+ WHERE managed_maintenance_lease.expires_at <= ? RETURNING id`).get(migration ? 2 : 1, owner, until, now)
1437
+ },
1438
+ async finishMaintenanceLease(owner, until) {
1439
+ await db.prepare('UPDATE managed_maintenance_lease SET expires_at = ? WHERE owner = ?').run(until, owner)
1440
+ },
1327
1441
  async upsertUser(user, now, initialAdminGithubId = null) {
1328
1442
  // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1329
1443
  // user's id (DO UPDATE leaves it untouched), so re-read to return it.
@@ -1364,12 +1478,18 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1364
1478
  return Number((await updateRoleStmt.run(role, Date.now(), id)).changes) > 0
1365
1479
  },
1366
1480
  async setUserTokens(id, tokens) {
1367
- await updateTokensStmt.run(tokens.accessToken, tokens.refreshToken, tokens.expiresAt, Date.now(), id)
1481
+ const state = await storageState(db, key)
1482
+ const value = state ? encodeStorageTokens(key!, id, tokens.accessToken, tokens.refreshToken) : { access: tokens.accessToken, refresh: tokens.refreshToken }
1483
+ await updateTokensStmt.run(value.access, value.refresh, tokens.expiresAt, state ? 1 : 0, Date.now(), id)
1368
1484
  },
1369
1485
  async getUserTokens(id) {
1370
- const row = (await selectTokensStmt.get(id)) as { access: string | null; refresh: string | null; exp: number | null } | undefined
1486
+ const state = await storageState(db, key)
1487
+ const row = (await selectTokensStmt.get(id)) as StoredTokens | undefined
1371
1488
  if (row == null || row.access == null) return null
1372
- return { accessToken: row.access, refreshToken: row.refresh, expiresAt: row.exp }
1489
+ if (row.encrypted && !state) throw new Error('Encrypted tokens without storage encryption state')
1490
+ if (!row.encrypted && state?.complete) throw new Error('Unexpected plaintext GitHub tokens')
1491
+ const value = row.encrypted ? decodeStorageTokens(key!, id, row) : row
1492
+ return { accessToken: value.access!, refreshToken: value.refresh, expiresAt: row.exp }
1373
1493
  },
1374
1494
  async getUserGithubId(id) {
1375
1495
  const row = (await selectGithubIdStmt.get(id)) as { githubId: number } | undefined
@@ -1380,15 +1500,18 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1380
1500
  ...selectedRepoMethods(stmts),
1381
1501
  ...activity,
1382
1502
  ...comments,
1503
+ async getAnnotations(ids) {
1504
+ return { triage: await triage.listTriage(ids), comments: await comments.listComments(ids) }
1505
+ },
1383
1506
  ...workspaceShareMethods(db),
1384
1507
  ...reports,
1385
1508
  ...triage,
1386
1509
  ...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
1387
- ...bundleMethods(stmts),
1388
- ...teamMethods(stmts),
1510
+ ...bundleMethods(stmts, db, key),
1511
+ ...teamMethods(stmts, db),
1389
1512
  async close() {
1390
1513
  await db.close()
1391
1514
  },
1392
1515
  }
1393
- return { ...methods, ...managementMethods(methods) }
1516
+ return { ...methods, ...managementMethods(methods), ...managementCatalogMethods(methods) }
1394
1517
  }