@preventive/triage 1.0.0-alpha.33 → 1.0.0-alpha.35

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 (48) hide show
  1. package/client/linked-findings.js +5 -4
  2. package/common/bundle-metadata.js +4 -3
  3. package/common/bundle-source-links.d.ts +2 -1
  4. package/common/bundle-source-links.js +133 -6
  5. package/common/bundle-sources.d.ts +2 -0
  6. package/common/bundle-sources.js +2 -4
  7. package/common/managed/repository-alias.ts +65 -0
  8. package/common/managed/routes.js +20 -1
  9. package/common/managed/triage.ts +2 -2
  10. package/out/client-managed-import.js +51 -46
  11. package/out/client-managed.js +252 -72
  12. package/out/client-sync.js +14 -14
  13. package/out/graph.js +11 -11
  14. package/out/index.html +1 -0
  15. package/out/terminal.js +320 -212
  16. package/out/view.css +1 -1
  17. package/out/view.js +410 -202
  18. package/package.json +22 -17
  19. package/server-managed/activity.ts +15 -1
  20. package/server-managed/bundle-advisories.ts +8 -3
  21. package/server-managed/bundle-advisory-inventory.ts +27 -25
  22. package/server-managed/bundle-build-worker.js +45 -9
  23. package/server-managed/bundle-build.ts +35 -11
  24. package/server-managed/bundle-cache.ts +1 -1
  25. package/server-managed/bundle.ts +17 -1
  26. package/server-managed/config.ts +5 -0
  27. package/server-managed/db-methods.ts +168 -49
  28. package/server-managed/db-neon.ts +59 -4
  29. package/server-managed/db-schema.ts +12 -3
  30. package/server-managed/db.ts +9 -1
  31. package/server-managed/github-issue-links.ts +127 -0
  32. package/server-managed/github-issues.ts +1 -1
  33. package/server-managed/http-response.ts +7 -1
  34. package/server-managed/http.ts +248 -80
  35. package/server-managed/index.ts +4 -0
  36. package/server-managed/link-reports.ts +158 -0
  37. package/server-managed/managed-issues.ts +31 -3
  38. package/server-managed/management.ts +17 -4
  39. package/server-managed/public-workspace.ts +5 -2
  40. package/server-managed/report-sources.ts +6 -5
  41. package/server-managed/repository-aliases.ts +91 -0
  42. package/server-managed/revisions.ts +2 -2
  43. package/server-managed/sql.ts +3 -2
  44. package/server-managed/storage.ts +17 -0
  45. package/server-managed/team-feed.ts +3 -3
  46. package/server-managed/team-reports.ts +24 -15
  47. package/server-managed/upstream-cache.ts +73 -0
  48. package/server-managed/workspace-shares.ts +16 -12
@@ -1,3 +1,5 @@
1
+ import { type LinkReportStore, linkReportMethods, linkRevision } from './link-reports.ts'
2
+ import { type RepositoryAliasStore, repositoryAliasMethods } from './repository-aliases.ts'
1
3
  import { createHash, randomUUID } from 'node:crypto'
2
4
  import { teamCatalogRevision } from './team-catalog.ts'
3
5
  import { type Role, roleAtLeast } from '../common/managed/roles.ts'
@@ -8,7 +10,9 @@ import { type CommentStore, commentMethods } from './comments.ts'
8
10
  import { type ActivityStore, activityMethods } from './activity.ts'
9
11
  import { type GithubMetadataStore, githubMetadataMethods } from './github-metadata.ts'
10
12
  import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
13
+ import type { IssueFixUpdate } from './github-issue-links.ts'
11
14
  import { type BundleBuildLeaseStore, bundleBuildLeaseMethods } from './bundle-build-leases.ts'
15
+ import { type UpstreamCacheStore, upstreamCacheMethods } from './upstream-cache.ts'
12
16
  import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
13
17
  import type { ManagedSql } from './sql.ts'
14
18
  import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
@@ -126,10 +130,13 @@ export interface ReportAccessRecord {
126
130
  }
127
131
  export interface TeamReportAccessSnapshot extends ReportAccessSnapshot {
128
132
  teamId: string | null
133
+ reportId?: string | null
134
+ hiddenReportId?: string | null
129
135
  repositories: { repoId: number; github: string; path: string | null }[]
130
136
  }
131
137
  export interface ReportAccessSnapshot {
132
138
  user: StoredUser
139
+ linkRevision?: string
133
140
  reports: ReportAccessRecord[]
134
141
  }
135
142
 
@@ -218,10 +225,19 @@ export interface TriageEventRow {
218
225
  at: number
219
226
  }
220
227
 
228
+ // How a bundle's bytes arrived: uploaded, or generated by this server from a
229
+ // repository. null predates the distinction (possibly built).
230
+ export type BundleProvenance = 'upload' | 'build'
231
+
232
+ // The preset, export conditions and Metro platforms a server build of a JS/TS
233
+ // bundle resolved with, as the build request named them. Stored as JSON.
234
+ export interface BundleBuildConditions { preset: 'node' | 'browser' | 'metro'; conditions: string[]; platforms: string[] }
235
+
221
236
  // A stored bundle's metadata. Bytes live in the blob-store keyed by `id`;
222
237
  // `integrity` (sha512-<base64>) is the content-addressed identity (UNIQUE),
223
238
  // matched against a report's bundleHashes to auto-link.
224
239
  export interface ManagedBundle {
240
+ visible: boolean
225
241
  id: string
226
242
  slug: string
227
243
  integrity: string
@@ -232,15 +248,25 @@ export interface ManagedBundle {
232
248
  repoDirectory: string
233
249
  repoId: number | null
234
250
  uploadedAt: number
251
+ provenance: BundleProvenance | null
252
+ // null for uploads, Solidity builds, and builds predating the column.
253
+ buildConditions: BundleBuildConditions | null
235
254
  }
236
255
 
237
256
  // What the upload handler supplies to record a bundle; the store stamps
238
257
  // uploaded_at. `uploadedByLogin` is the durable uploader-login snapshot.
239
- export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string; dataKey?: string | null }
258
+ export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory' | 'visible' | 'provenance' | 'buildConditions'> & {
259
+ visible?: boolean; uploadedByLogin: string | null; repoDirectory?: string; dataKey?: string | null; provenance?: BundleProvenance | null
260
+ buildConditions?: BundleBuildConditions | null
261
+ }
262
+
263
+ // A repeated upload or build of stored bytes (see reuseBundle).
264
+ export interface BundleReuse { provenance: BundleProvenance; filename: string; kind: string | null; rename: boolean; buildConditions?: BundleBuildConditions | null }
240
265
 
241
266
  // A bundle row for the "Manage bundles" list — adds the uploader login + repo
242
267
  // full name display joins (null when absent / since removed).
243
268
  export interface AdminBundle {
269
+ visible: boolean
244
270
  id: string
245
271
  slug: string
246
272
  integrity: string
@@ -252,6 +278,7 @@ export interface AdminBundle {
252
278
  repoId: number | null
253
279
  repoFullName: string | null
254
280
  uploadedAt: number
281
+ provenance: BundleProvenance | null
255
282
  }
256
283
 
257
284
  // A team's repo link (with its optional subpath) and member (with resolved
@@ -271,6 +298,7 @@ export interface AdminTeam {
271
298
  id: string
272
299
  slug: string
273
300
  name: string
301
+ hidden: boolean
274
302
  repos: TeamRepoLink[]
275
303
  members: TeamMember[]
276
304
  }
@@ -289,6 +317,7 @@ export type UserOption = {
289
317
  // bundles attached to the team's repos (id + filename, newest first). An item
290
318
  // shows under a team when its repo is one of the team's linked repos.
291
319
  export interface UserTeamReport {
320
+ visible: boolean
292
321
  id: string
293
322
  slug: string
294
323
  filename: string
@@ -298,6 +327,7 @@ export interface UserTeamReport {
298
327
  cacheKey: string
299
328
  }
300
329
  export interface UserTeamBundle {
330
+ visible: boolean
301
331
  id: string
302
332
  slug: string
303
333
  integrity: string
@@ -307,6 +337,7 @@ export interface UserTeamBundle {
307
337
  repoDirectory: string
308
338
  repoId: number
309
339
  repoFullName: string
340
+ provenance: BundleProvenance | null
310
341
  }
311
342
  export interface UserTeam {
312
343
  id: string
@@ -319,7 +350,7 @@ export interface UserTeam {
319
350
  }
320
351
 
321
352
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
322
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, BundleBuildLeaseStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb {
353
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, BundleBuildLeaseStore, UpstreamCacheStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb, LinkReportStore, RepositoryAliasStore {
323
354
  claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
324
355
  finishMaintenanceLease(owner: string, until: number): Promise<void>
325
356
  getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
@@ -375,7 +406,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
375
406
  getReport(id: string): Promise<ReportRecord | null>
376
407
  // Writer-locked reconciliation waits for an uncertain earlier insert.
377
408
  resolveReportUpload(id: string): Promise<ReportRecord | null>
378
- getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
409
+ getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string, reportId?: string | null): Promise<TeamReportAccessSnapshot | null>
379
410
  getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
380
411
  listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]>
381
412
  deleteReport(id: string): Promise<boolean>
@@ -395,7 +426,8 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
395
426
  // stamp nor the trail. Every change also appends to managed_finding_triage_event;
396
427
  // listTriageHistory walks one finding's trail, newest first.
397
428
  listTriage(findingIds: readonly string[]): Promise<TriageRow[]>
398
- getAnnotations(findingIds: readonly string[]): Promise<{ triage: TriageRow[]; comments: Awaited<ReturnType<CommentStore['listComments']>> }>
429
+ getAnnotations(findingIds: readonly string[]): Promise<{ triage: TriageRow[]; comments: Awaited<ReturnType<CommentStore['listComments']>>; issues: Awaited<ReturnType<ManagedIssueStore['listManagedIssues']>> }>
430
+ applyManagedIssueFixes(sessionId: string, snapshot: TeamReportAccessSnapshot, updates: IssueFixUpdate[]): Promise<boolean>
399
431
  // A content-free fingerprint of current annotations, read in one snapshot.
400
432
  getAnnotationRevision(findingIds: readonly string[]): Promise<string>
401
433
  setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void>
@@ -407,8 +439,14 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
407
439
  // uploader login + repo; deleteBundle resolves true iff a row was removed
408
440
  // (referencing reports' bundle_id null out via the FK). linkReportsToBundle
409
441
  // attaches a freshly-stored bundle to the (still-unlinked) reports that
410
- // declared its integrity.
442
+ // declared its integrity. reuseBundle records a repeated upload or build of
443
+ // stored bytes: a build always marks the row built and records its conditions
444
+ // where the row has none, and with `rename` an uploaded row takes the new
445
+ // filename/kind. A built row is never renamed; one predating provenance is
446
+ // renamed only by a build. Resolves true iff changed.
447
+ // The caller authorizes `rename` (see reuseBundleUpload).
411
448
  insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
449
+ reuseBundle(id: string, reuse: BundleReuse): Promise<boolean>
412
450
  getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
413
451
  resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
414
452
  getBundle(id: string): Promise<ManagedBundle | null>
@@ -427,31 +465,34 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
427
465
  // Set a bundle's repository and normalized directory (repoId null detaches
428
466
  // and clears the directory). The caller validates the location and access.
429
467
  setBundleRepo(id: string, repoId: number | null, directory?: string): Promise<boolean>
468
+ setBundleVisible(id: string, visible: boolean): Promise<boolean>
430
469
  linkReportsToBundle(integrity: string, bundleId: string, userId?: string): Promise<void>
431
470
  // Teams ("Manage teams"). createTeam inserts a team (false iff the name is
432
471
  // taken); renameTeam changes a team's name ('name-taken' iff another team
433
472
  // already has it, 'not-found' iff no such team, 'ok' otherwise — same name is
434
473
  // idempotent); deleteTeam drops it (cascading its links); listTeams returns
435
- // every team with its repos + members inlined; listUserOptions is the id+login+name
474
+ // every team (including hidden ones) with its repos + members inlined; listUserOptions is the id+login+name
436
475
  // set for the member picker. The set*/remove* pairs maintain the link tables:
437
476
  // setTeamRepo adds a path (whole repo replaces paths); setTeamMember upserts a
438
477
  // membership + its visibility permissions (each resolves true iff a row was
439
478
  // written / removed; the caller validates the team/repo/user exist first).
440
479
  createTeam(id: string, name: string, now: number): Promise<boolean>
441
480
  renameTeam(id: string, name: string, now: number): Promise<'ok' | 'name-taken' | 'not-found'>
481
+ // Hiding preserves links but disables all grants and workspace access.
482
+ setTeamHidden(id: string, hidden: boolean, now: number): Promise<boolean>
442
483
  deleteTeam(id: string): Promise<boolean>
443
- getTeam(id: string): Promise<{ id: string; slug: string; name: string } | null>
484
+ getTeam(id: string): Promise<{ id: string; slug: string; name: string; hidden: boolean } | null>
444
485
  listTeams(): Promise<AdminTeam[]>
445
486
  listUserOptions(): Promise<UserOption[]>
446
487
  // Current grants for manager content reads, writes, and repository pickers.
447
488
  listRepoScopesForUser(userId: string): Promise<{ repoId: number; path: string | null }[]>
448
- // The teams a given user belongs to (name-sorted), each with reports and
489
+ // The non-hidden teams a given user belongs to (name-sorted), each with reports and
449
490
  // bundles attached to that team's repos — for that user's own sidebar Teams section.
450
491
  // Any user; only their own memberships.
451
492
  listTeamsForUser(userId: string): Promise<UserTeam[]>
452
493
  getUserTeamFeedSnapshot(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; revision: string; teams: UserTeam[]; catalog: number } | null>
453
494
  // Whether `userId` may read `reportId`: true iff the report's repo belongs to
454
- // a team the user is a member of. Backs the team-scoped report view endpoint.
495
+ // a non-hidden team the user is a member of. Backs the team-scoped report view endpoint.
455
496
  userCanReadReport(userId: string, reportId: string): Promise<boolean>
456
497
  // The viewer's effective visibility permissions for a report, OR'd across the
457
498
  // teams (holding the report's repo) the user belongs to — drives the content
@@ -480,6 +521,14 @@ const REPORT_IN_TEAM_PATH_SQL = `(tr.path IS NULL OR tr.path = ''
480
521
 
481
522
  const BUNDLE_IN_TEAM_PATH_SQL = REPORT_IN_TEAM_PATH_SQL.replaceAll('r.repo_directory', 'b.repo_directory')
482
523
 
524
+ // Whether a repeated upload/build renames the stored row. Uploads never rename
525
+ // a built row, or one whose provenance is unknown; builds rename either. The
526
+ // kind must stay readable from its blob store (sourcemaps are stored apart):
527
+ // keep it, or let an unrecognized archive become Stasis — never downgrade.
528
+ const BUNDLE_RENAMED_SQL = `(:rename = 1 AND (provenance = 'upload' OR (:provenance = 'build' AND provenance IS NULL))
529
+ AND (COALESCE(kind, '') = COALESCE(:kind, '') OR (kind IS NULL AND :kind = 'stasis'))
530
+ AND (filename <> :filename OR kind IS DISTINCT FROM :kind))`
531
+
483
532
  // Prepare every statement the store uses, returned as a bag the factory
484
533
  // destructures — keeps openSqliteManagedDb itself small (one place per query).
485
534
  function prepareStatements(db: ManagedSql) {
@@ -598,6 +647,7 @@ function prepareStatements(db: ManagedSql) {
598
647
  LEFT JOIN managed_bundle b ON b.id = r.bundle_id
599
648
  WHERE (? IS NULL OR r.uploaded_by = ? OR EXISTS (
600
649
  SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
650
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
601
651
  WHERE tr.repo_id = r.repo_id AND tu.user_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}))
602
652
  ORDER BY r.uploaded_at DESC, r.filename ASC`,
603
653
  ),
@@ -619,6 +669,7 @@ function prepareStatements(db: ManagedSql) {
619
669
  FROM managed_report r JOIN requested q ON q.id = r.id
620
670
  JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
621
671
  JOIN managed_team_user tu ON tu.team_id = tr.team_id AND tu.user_id = ?
672
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
622
673
  GROUP BY r.id
623
674
  )
624
675
  SELECT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256,
@@ -630,21 +681,23 @@ function prepareStatements(db: ManagedSql) {
630
681
  WHERE ? = 1 OR (? = 1 AND r.uploaded_by = ?)
631
682
  OR (g.id IS NOT NULL AND (? = 1 OR r.visible = 1))`,
632
683
  ),
633
- selectTeamAccessStmt: db.prepare(`SELECT 1 FROM managed_team_user WHERE user_id = ? AND team_id = ?`),
684
+ selectTeamAccessStmt: db.prepare(`SELECT 1 FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
685
+ WHERE tu.user_id = ? AND tu.team_id = ? AND t.hidden = 0`),
634
686
  selectTeamRepositoriesStmt: db.prepare(
635
687
  `SELECT tr.repo_id AS repoId, sr.full_name AS github, tr.path AS path
636
688
  FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
637
689
  WHERE tr.team_id = ? ORDER BY tr.repo_id, tr.path`,
638
690
  ),
639
691
  selectTeamReportAccessStmt: db.prepare(
640
- `SELECT DISTINCT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256,
692
+ `SELECT DISTINCT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256, r.visible AS visible,
641
693
  r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
642
694
  tu.view_dependencies AS dependencies, tu.view_security AS security
643
695
  FROM managed_team_user tu
696
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
644
697
  JOIN managed_team_repo tr ON tr.team_id = tu.team_id
645
698
  JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
646
699
  LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
647
- WHERE tu.user_id = ? AND tu.team_id = ? AND (r.visible = 1 OR ? = 1)
700
+ WHERE tu.user_id = ? AND tu.team_id = ? AND (r.visible = 1 OR (? = 1 AND r.id = ?))
648
701
  ORDER BY r.id`,
649
702
  ),
650
703
  reportFilenamesWithBundleHashStmt: db.prepare(`SELECT DISTINCT filename FROM managed_report WHERE bundle_id = ? AND sha256 = ?`),
@@ -707,58 +760,80 @@ function prepareStatements(db: ManagedSql) {
707
760
  WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
708
761
  insertBundleStmt: db.prepare(
709
762
  `WITH candidate(id, slug) AS (VALUES (?, ?))
710
- 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)
763
+ INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, visible, uploaded_at, data_key, storage_encrypted, provenance, build_conditions)
711
764
  SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_bundle WHERE slug = candidate.slug) THEN id ELSE slug END,
712
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
765
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
766
+ ),
767
+ // SET reads the pre-update row. Unchanged rows match nothing, so identical
768
+ // retries do not bump the catalog revision. Conditions that rebuild the same
769
+ // bytes describe them equally, so the first recorded ones stay.
770
+ reuseBundleStmt: db.prepare(
771
+ `UPDATE managed_bundle SET
772
+ filename = CASE WHEN ${BUNDLE_RENAMED_SQL} THEN :filename ELSE filename END,
773
+ kind = CASE WHEN ${BUNDLE_RENAMED_SQL} THEN :kind ELSE kind END,
774
+ provenance = CASE WHEN :provenance = 'build' THEN 'build' ELSE provenance END,
775
+ build_conditions = CASE WHEN :provenance = 'build' THEN COALESCE(build_conditions, :buildConditions) ELSE build_conditions END
776
+ WHERE id = :id AND (${BUNDLE_RENAMED_SQL} OR (:provenance = 'build' AND (provenance IS DISTINCT FROM 'build'
777
+ OR build_conditions IS DISTINCT FROM COALESCE(build_conditions, :buildConditions))))`,
713
778
  ),
714
779
  selectBundleByIntegrityStmt: db.prepare(
715
780
  `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
716
- uploaded_by AS uploadedBy, repo_id AS repoId, repo_directory AS repoDirectory, uploaded_at AS uploadedAt
781
+ uploaded_by AS uploadedBy, repo_id AS repoId, repo_directory AS repoDirectory, visible, uploaded_at AS uploadedAt, provenance,
782
+ build_conditions AS buildConditions
717
783
  FROM managed_bundle WHERE integrity = ?`,
718
784
  ),
719
785
  selectBundleStmt: db.prepare(
720
786
  `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
721
- uploaded_by AS uploadedBy, repo_id AS repoId, repo_directory AS repoDirectory, uploaded_at AS uploadedAt
787
+ uploaded_by AS uploadedBy, repo_id AS repoId, repo_directory AS repoDirectory, visible, uploaded_at AS uploadedAt, provenance,
788
+ build_conditions AS buildConditions
722
789
  FROM managed_bundle WHERE id = ?`,
723
790
  ),
724
791
  selectBundlesStmt: db.prepare(
725
792
  `SELECT b.id AS id, b.slug AS slug, b.integrity AS integrity, b.filename AS filename, b.kind AS kind,
726
793
  b.byte_size AS byteSize, COALESCE(u.login, b.uploaded_by_login) AS uploadedByLogin,
727
- b.repo_id AS repoId, b.repo_directory AS repoDirectory, sr.full_name AS repoFullName, b.uploaded_at AS uploadedAt
794
+ b.repo_id AS repoId, b.repo_directory AS repoDirectory, b.visible, sr.full_name AS repoFullName, b.uploaded_at AS uploadedAt, b.provenance
728
795
  FROM managed_bundle b
729
796
  LEFT JOIN managed_user u ON u.id = b.uploaded_by
730
797
  LEFT JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
731
798
  WHERE (? IS NULL OR (b.uploaded_by = ? OR EXISTS (
732
799
  SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
800
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
733
801
  WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?)))
734
802
  ORDER BY b.uploaded_at DESC, b.filename ASC`,
735
803
  ),
736
804
  selectBundleReadableStmt: db.prepare(
737
805
  `SELECT 1 FROM managed_bundle b WHERE b.id = ? AND (b.uploaded_by = ? OR EXISTS (
738
806
  SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
807
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
739
808
  WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
740
809
  ),
741
810
  selectReadableBundleIdsStmt: db.prepare(
742
811
  `SELECT b.id FROM managed_bundle b WHERE b.id IN (SELECT value FROM json_each(?)) AND (b.uploaded_by = ? OR EXISTS (
743
812
  SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
813
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
744
814
  WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
745
815
  ),
746
816
  selectRepoReadableStmt: db.prepare(
747
817
  `SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
818
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
748
819
  WHERE tr.repo_id = ? AND tu.user_id = ? LIMIT 1`,
749
820
  ),
750
821
  selectRepoPathReadableStmt: db.prepare(
751
822
  `WITH r(repo_directory) AS (VALUES (?))
752
823
  SELECT 1 FROM r JOIN managed_team_repo tr ON tr.repo_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}
753
- JOIN managed_team_user tu ON tu.team_id = tr.team_id WHERE tu.user_id = ? LIMIT 1`,
824
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id
825
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
826
+ WHERE tu.user_id = ? LIMIT 1`,
754
827
  ),
755
828
  deleteBundleStmt: db.prepare(`DELETE FROM managed_bundle WHERE id = ?`),
756
829
  setBundleRepoStmt: db.prepare(`UPDATE managed_bundle SET repo_id = ?, repo_directory = ? WHERE id = ?`),
830
+ setBundleVisibleStmt: db.prepare(`UPDATE managed_bundle SET visible = ? WHERE id = ?`),
757
831
  // Attach a freshly-stored bundle to the reports that declared its integrity
758
832
  // but haven't been linked yet (bundle uploaded after the report).
759
833
  linkReportsToBundleStmt: db.prepare(
760
834
  `UPDATE managed_report AS r SET bundle_id = ? WHERE bundle_integrity = ? AND bundle_id IS NULL
761
835
  AND (? IS NULL OR r.uploaded_by = ? OR EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
836
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
762
837
  WHERE tu.user_id = ? AND tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}))`,
763
838
  ),
764
839
  // OR IGNORE: a duplicate name (UNIQUE) is the "taken" signal (0 changes); the
@@ -768,30 +843,33 @@ function prepareStatements(db: ManagedSql) {
768
843
  SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_team WHERE slug = candidate.slug) THEN id ELSE slug END,
769
844
  ?, ?, ? FROM candidate`),
770
845
  deleteTeamStmt: db.prepare(`DELETE FROM managed_team WHERE id = ?`),
771
- selectTeamStmt: db.prepare(`SELECT id, slug, name FROM managed_team WHERE id = ?`),
846
+ selectTeamStmt: db.prepare(`SELECT id, slug, name, hidden FROM managed_team WHERE id = ?`),
772
847
  selectTeamByNameStmt: db.prepare(`SELECT id FROM managed_team WHERE name = ?`),
773
848
  renameTeamStmt: db.prepare(`UPDATE managed_team SET name = ?, updated_at = ? WHERE id = ?`),
774
- selectTeamsStmt: db.prepare(`SELECT id, slug, name FROM managed_team ORDER BY name ASC`),
849
+ setTeamHiddenStmt: db.prepare(`UPDATE managed_team SET hidden = ?, updated_at = ? WHERE id = ?`),
850
+ selectTeamsStmt: db.prepare(`SELECT id, slug, name, hidden FROM managed_team ORDER BY name ASC`),
775
851
  selectTeamsForUserStmt: db.prepare(
776
852
  `SELECT t.id AS id, t.slug AS slug, t.name AS name,
777
853
  tu.view_dependencies AS dependencies, tu.view_security AS security
778
854
  FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
779
- WHERE tu.user_id = ? ORDER BY t.name ASC, t.id ASC`,
855
+ WHERE tu.user_id = ? AND t.hidden = 0 ORDER BY t.name ASC, t.id ASC`,
780
856
  ),
781
857
  selectUserTeamScopesStmt: db.prepare(
782
858
  `SELECT tr.team_id AS teamId, tr.repo_id AS repoId, tr.path AS path, sr.full_name AS fullName
783
859
  FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
860
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
784
861
  JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
785
862
  WHERE tu.user_id = ? ORDER BY tr.team_id, tr.repo_id, tr.path`,
786
863
  ),
787
864
  // Reports attached to the repos of the user's teams, tagged by team (a report
788
865
  // shows under every team whose repo it's attached to). Newest first.
789
866
  selectUserTeamReportsStmt: db.prepare(
790
- `SELECT DISTINCT tr.team_id AS teamId, r.id AS id, r.slug AS slug, r.filename AS filename, r.analyzer, r.uploaded_at,
867
+ `SELECT DISTINCT tr.team_id AS teamId, r.id AS id, r.slug AS slug, r.filename AS filename, r.analyzer, r.visible, r.uploaded_at,
791
868
  r.sha256 AS sha256, r.bundle_id AS bundleId, r.repo_id AS repoId, r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
792
869
  tu.view_dependencies AS dependencies, tu.view_security AS security,
793
870
  (SELECT role FROM managed_user WHERE id = tu.user_id) AS role
794
871
  FROM managed_team_user tu
872
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
795
873
  JOIN managed_team_repo tr ON tr.team_id = tu.team_id
796
874
  JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
797
875
  LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
@@ -799,33 +877,37 @@ function prepareStatements(db: ManagedSql) {
799
877
  ORDER BY r.uploaded_at DESC, r.filename ASC, r.id ASC`,
800
878
  ),
801
879
  // Bundles are scoped through the same team -> repository links as reports.
802
- // They have no visibility flag: the bundle directory must fall inside a
803
- // team repository scope to appear in that team's sidebar.
880
+ // Hidden bundles remain available to managers within their team scopes.
804
881
  selectUserTeamBundlesStmt: db.prepare(
805
882
  `SELECT DISTINCT tr.team_id AS teamId, b.id AS id, b.slug AS slug, b.integrity AS integrity,
806
- b.filename AS filename, b.kind, b.byte_size AS byteSize, b.repo_id AS repoId, b.repo_directory AS repoDirectory, sr.full_name AS repoFullName, b.uploaded_at
883
+ b.filename AS filename, b.kind, b.visible, b.byte_size AS byteSize, b.repo_id AS repoId, b.repo_directory AS repoDirectory, sr.full_name AS repoFullName, b.uploaded_at, b.provenance
807
884
  FROM managed_team_user tu
885
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
808
886
  JOIN managed_team_repo tr ON tr.team_id = tu.team_id
809
887
  JOIN managed_bundle b ON b.repo_id = tr.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
810
888
  JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
811
- WHERE tu.user_id = ?
889
+ WHERE tu.user_id = ? AND (b.visible = 1 OR (SELECT role FROM managed_user WHERE id = tu.user_id) IN ('admin', 'manage'))
812
890
  ORDER BY b.uploaded_at DESC, b.filename ASC, b.id ASC`,
813
891
  ),
814
892
  selectUserRepoScopesStmt: db.prepare(
815
893
  `SELECT DISTINCT tr.repo_id AS repoId, NULLIF(tr.path, '') AS path
816
- FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id WHERE tu.user_id = ?`,
894
+ FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
895
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
896
+ WHERE tu.user_id = ?`,
817
897
  ),
818
898
  // A report is readable iff one of the user's team scopes contains it.
819
899
  selectReportReadableStmt: db.prepare(
820
900
  `SELECT 1 FROM managed_report r
821
901
  JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
822
902
  JOIN managed_team_user tu ON tu.team_id = tr.team_id
903
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
823
904
  WHERE r.id = ? AND tu.user_id = ? LIMIT 1`,
824
905
  ),
825
906
  selectBundleSecurityStmt: db.prepare(
826
907
  `SELECT 1 FROM managed_bundle b
827
908
  JOIN managed_team_repo tr ON tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
828
909
  JOIN managed_team_user tu ON tu.team_id = tr.team_id
910
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
829
911
  WHERE b.id = ? AND tu.user_id = ? AND tu.view_security = 1
830
912
  AND (? IS NULL OR tr.team_id = ?) LIMIT 1`,
831
913
  ),
@@ -837,6 +919,7 @@ function prepareStatements(db: ManagedSql) {
837
919
  FROM managed_report r
838
920
  JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
839
921
  JOIN managed_team_user tu ON tu.team_id = tr.team_id AND tu.user_id = ?
922
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
840
923
  WHERE r.id = ?`,
841
924
  ),
842
925
  selectUserOptionsStmt: db.prepare(`SELECT id, login, name, role FROM managed_user ORDER BY login ASC`),
@@ -1007,18 +1090,23 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1007
1090
  async listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]> {
1008
1091
  return await stmts.findingCatalogReportsStmt.all(after, limit) as Pick<ReportRecord, 'id' | 'byteSize'>[]
1009
1092
  },
1010
- async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
1093
+ async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string, reportId: string | null = null): Promise<TeamReportAccessSnapshot | null> {
1011
1094
  const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1012
1095
  if (!session) return null
1013
1096
  const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
1014
1097
  if (user.role === 'none' || !await stmts.selectTeamAccessStmt.get(user.id, teamId)) return { user, teamId: null, reports: [], repositories: [] }
1015
1098
  const whole = user.role === 'admin' || user.role === 'manage'
1016
- const rows = await stmts.selectTeamReportAccessStmt.all(user.id, teamId, whole ? 1 : 0) as {
1099
+ const rows = await stmts.selectTeamReportAccessStmt.all(user.id, teamId, whole ? 1 : 0, reportId) as {
1017
1100
  id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
1018
- repoFullName: string | null; dependencies: number; security: number
1101
+ repoFullName: string | null; dependencies: number; security: number; visible: number
1019
1102
  }[]
1103
+ // Only privileged callers can receive a hidden row from this query. Give
1104
+ // that individual preview its own capacity; published report selections
1105
+ // still use the complete workspace for cross-report classification.
1106
+ const hidden = rows.find(row => row.id === reportId && row.visible === 0)
1107
+ const selected = hidden ? [hidden] : rows
1020
1108
  const repositories = await stmts.selectTeamRepositoriesStmt.all(teamId) as TeamReportAccessSnapshot['repositories']
1021
- return { user, teamId, repositories, reports: rows.map(row => ({
1109
+ return { user, teamId, reportId, hiddenReportId: hidden?.id ?? null, repositories, linkRevision: await linkRevision(db), reports: selected.map(row => ({
1022
1110
  id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
1023
1111
  repo: { github: row.repoFullName, directory: row.repoDirectory },
1024
1112
  permissions: { dependencies: whole || row.dependencies === 1, security: whole || row.security === 1 },
@@ -1035,7 +1123,7 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1035
1123
  id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
1036
1124
  repoFullName: string | null; dependencies: number; security: number
1037
1125
  }[]
1038
- return { user, reports: rows.map(row => ({
1126
+ return { user, linkRevision: await linkRevision(db), reports: rows.map(row => ({
1039
1127
  id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
1040
1128
  repo: { github: row.repoFullName, directory: row.repoDirectory },
1041
1129
  permissions: { dependencies: admin || manage || row.dependencies === 1, security: admin || manage || row.security === 1 },
@@ -1102,6 +1190,8 @@ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit
1102
1190
  return JSON.stringify([
1103
1191
  await stmts.annotationTriageRevisionStmt.all(ids),
1104
1192
  await stmts.annotationCommentRevisionStmt.all(ids),
1193
+ await db.prepare(`SELECT finding_id, issue_url, auto_fix_url FROM managed_finding_issue
1194
+ WHERE finding_id IN (SELECT value FROM json_each(?)) AND issue_url IS NOT NULL ORDER BY finding_id`).all(ids),
1105
1195
  ])
1106
1196
  },
1107
1197
  async deleteTriage(findingIds: readonly string[]): Promise<number> {
@@ -1183,18 +1273,23 @@ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit
1183
1273
  }
1184
1274
 
1185
1275
  type BundleRow = {
1276
+ visible: number
1186
1277
  id: string; slug: string; integrity: string; filename: string; kind: string | null; byteSize: number
1187
- uploadedBy: string | null; repoId: number | null; repoDirectory: string; uploadedAt: number
1278
+ uploadedBy: string | null; repoId: number | null; repoDirectory: string; uploadedAt: number; provenance: BundleProvenance | null
1279
+ buildConditions: string | null
1188
1280
  }
1189
1281
  type BundleListRow = {
1282
+ visible: number
1190
1283
  id: string; slug: string; integrity: string; filename: string; kind: string | null; byteSize: number
1191
1284
  uploadedByLogin: string | null; repoId: number | null; repoFullName: string | null; repoDirectory: string; uploadedAt: number
1285
+ provenance: BundleProvenance | null
1192
1286
  }
1193
1287
 
1194
1288
  function mapBundle(r: BundleRow): ManagedBundle {
1195
1289
  return {
1196
1290
  id: r.id, slug: r.slug, integrity: r.integrity, filename: r.filename, kind: r.kind,
1197
- byteSize: r.byteSize, uploadedBy: r.uploadedBy, repoId: r.repoId, repoDirectory: r.repoDirectory, uploadedAt: r.uploadedAt,
1291
+ byteSize: r.byteSize, uploadedBy: r.uploadedBy, repoId: r.repoId, repoDirectory: r.repoDirectory, uploadedAt: r.uploadedAt, visible: r.visible === 1,
1292
+ provenance: r.provenance, buildConditions: r.buildConditions == null ? null : JSON.parse(r.buildConditions) as BundleBuildConditions,
1198
1293
  }
1199
1294
  }
1200
1295
 
@@ -1214,9 +1309,14 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1214
1309
  await validateStorageUpload(db, key, 'bundle', bundle.id, bundle.dataKey)
1215
1310
  await insertBundleStmt.run(
1216
1311
  bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
1217
- bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now, bundle.dataKey ?? null, bundle.dataKey ? 1 : 0,
1312
+ bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', bundle.visible === false ? 0 : 1, now, bundle.dataKey ?? null, bundle.dataKey ? 1 : 0,
1313
+ bundle.provenance ?? null, bundle.buildConditions == null ? null : JSON.stringify(bundle.buildConditions),
1218
1314
  )
1219
1315
  },
1316
+ async reuseBundle(id: string, { provenance, filename, kind, rename, buildConditions }: BundleReuse): Promise<boolean> {
1317
+ return Number((await stmts.reuseBundleStmt.run({ id, provenance, filename, kind, rename: rename ? 1 : 0,
1318
+ buildConditions: buildConditions == null ? null : JSON.stringify(buildConditions) })).changes) > 0
1319
+ },
1220
1320
  getBundleByIntegrity,
1221
1321
  // The distinct method name selects a writer-locked reconciliation scope.
1222
1322
  resolveBundleUpload: getBundleByIntegrity,
@@ -1232,6 +1332,7 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1232
1332
  const row = await selectBundleStmt.get(id) as BundleRow | undefined
1233
1333
  if (!row) return denied
1234
1334
  const manager = roleAtLeast(session.role, 'manage')
1335
+ if (!manager && row.visible !== 1) return denied
1235
1336
  if (session.role !== 'admin') {
1236
1337
  const allowed = manager ? await selectBundleReadableStmt.get(id, session.uid, session.uid)
1237
1338
  : row.repoId !== null && await selectRepoPathReadableStmt.get(row.repoDirectory, row.repoId, session.uid)
@@ -1245,8 +1346,8 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1245
1346
  const rows = (await selectBundlesStmt.all(userId ?? null, userId ?? null, userId ?? null)) as BundleListRow[]
1246
1347
  return rows.map((r) => ({
1247
1348
  id: r.id, slug: r.slug, integrity: r.integrity, filename: r.filename, kind: r.kind, byteSize: r.byteSize,
1248
- uploadedByLogin: r.uploadedByLogin, repoId: r.repoId, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory,
1249
- uploadedAt: r.uploadedAt,
1349
+ uploadedByLogin: r.uploadedByLogin, repoId: r.repoId, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, visible: r.visible === 1,
1350
+ uploadedAt: r.uploadedAt, provenance: r.provenance,
1250
1351
  }))
1251
1352
  },
1252
1353
  async userCanReadBundle(userId: string, id: string): Promise<boolean> {
@@ -1266,6 +1367,9 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1266
1367
  async deleteBundle(id: string): Promise<boolean> {
1267
1368
  return Number((await deleteBundleStmt.run(id)).changes) > 0
1268
1369
  },
1370
+ async setBundleVisible(id: string, visible: boolean): Promise<boolean> {
1371
+ return Number((await stmts.setBundleVisibleStmt.run(visible ? 1 : 0, id)).changes) > 0
1372
+ },
1269
1373
  async setBundleRepo(id: string, repoId: number | null, directory?: string): Promise<boolean> {
1270
1374
  return Number((await setBundleRepoStmt.run(repoId, repoId == null ? '' : directory ?? '', id)).changes) > 0
1271
1375
  },
@@ -1275,7 +1379,7 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1275
1379
  }
1276
1380
  }
1277
1381
 
1278
- type TeamRow = { id: string; slug: string; name: string }
1382
+ type TeamRow = { id: string; slug: string; name: string; hidden: number }
1279
1383
  type TeamRepoRow = { teamId: string; repoId: number; fullName: string; path: string | null }
1280
1384
  type TeamMemberRow = { teamId: string; userId: string; login: string; viewDependencies: number; viewSecurity: number }
1281
1385
 
@@ -1284,7 +1388,7 @@ type TeamMemberRow = { teamId: string; userId: string; login: string; viewDepend
1284
1388
  // workspace has.
1285
1389
  function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql) {
1286
1390
  const {
1287
- insertTeamStmt, selectTeamByNameStmt, renameTeamStmt, deleteTeamStmt, selectTeamStmt,
1391
+ insertTeamStmt, selectTeamByNameStmt, renameTeamStmt, setTeamHiddenStmt, deleteTeamStmt, selectTeamStmt,
1288
1392
  selectUserRepoScopesStmt, selectTeamsStmt, selectTeamsForUserStmt, selectUserTeamReportsStmt, selectUserTeamBundlesStmt, selectReportReadableStmt,
1289
1393
  selectReportPermsStmt, selectUserOptionsStmt, selectTeamReposStmt, selectTeamMembersStmt,
1290
1394
  upsertTeamRepoStmt, deleteTeamRepoStmt, deleteTeamRepoPathStmt, upsertTeamMemberStmt, deleteTeamMemberStmt,
@@ -1304,9 +1408,12 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1304
1408
  async deleteTeam(id: string): Promise<boolean> {
1305
1409
  return Number((await deleteTeamStmt.run(id)).changes) > 0
1306
1410
  },
1307
- async getTeam(id: string): Promise<{ id: string; slug: string; name: string } | null> {
1411
+ async setTeamHidden(id: string, hidden: boolean, now: number): Promise<boolean> {
1412
+ return Number((await setTeamHiddenStmt.run(hidden ? 1 : 0, now, id)).changes) > 0
1413
+ },
1414
+ async getTeam(id: string): Promise<{ id: string; slug: string; name: string; hidden: boolean } | null> {
1308
1415
  const row = (await selectTeamStmt.get(id)) as TeamRow | undefined
1309
- return row == null ? null : { id: row.id, slug: row.slug, name: row.name }
1416
+ return row == null ? null : { id: row.id, slug: row.slug, name: row.name, hidden: row.hidden === 1 }
1310
1417
  },
1311
1418
  async listUserOptions(): Promise<UserOption[]> {
1312
1419
  return ((await selectUserOptionsStmt.all()) as UserOption[]).map((u) => ({ id: u.id, login: u.login, name: u.name, role: u.role }))
@@ -1317,30 +1424,31 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1317
1424
  async listTeamsForUser(userId: string): Promise<UserTeam[]> {
1318
1425
  const teams = (await selectTeamsForUserStmt.all(userId)) as { id: string; slug: string; name: string; dependencies: number; security: number }[]
1319
1426
  const scopes = await stmts.selectUserTeamScopesStmt.all(userId) as { teamId: string; repoId: number; path: string; fullName: string }[]
1427
+ const links = await linkRevision(db)
1320
1428
  const reportsByTeam = new Map<string, UserTeamReport[]>()
1321
1429
  for (const r of (await selectUserTeamReportsStmt.all(userId)) as {
1322
1430
  teamId: string; id: string; slug: string; filename: string; analyzer: string | null; sha256: string; bundleId: string | null; repoId: number
1323
- repoDirectory: string; repoFullName: string | null; dependencies: number; security: number; role: string
1431
+ repoDirectory: string; repoFullName: string | null; visible: number; dependencies: number; security: number; role: string
1324
1432
  }[]) {
1325
1433
  const list = reportsByTeam.get(r.teamId) ?? []
1326
1434
  const cacheKey = createHash('sha256').update(JSON.stringify([
1327
- r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role,
1435
+ r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role, links,
1328
1436
  ])).digest('base64url')
1329
- list.push({ id: r.id, slug: r.slug, filename: r.filename, analyzer: r.analyzer, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, cacheKey })
1437
+ list.push({ id: r.id, slug: r.slug, filename: r.filename, visible: r.visible === 1, analyzer: r.analyzer, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, cacheKey })
1330
1438
  reportsByTeam.set(r.teamId, list)
1331
1439
  }
1332
1440
  const bundlesByTeam = new Map<string, UserTeamBundle[]>()
1333
- for (const b of (await selectUserTeamBundlesStmt.all(userId)) as (UserTeamBundle & { teamId: string })[]) {
1441
+ for (const b of (await selectUserTeamBundlesStmt.all(userId)) as (Omit<UserTeamBundle, 'visible'> & { teamId: string; visible: number })[]) {
1334
1442
  const list = bundlesByTeam.get(b.teamId) ?? []
1335
- list.push({ id: b.id, slug: b.slug, integrity: b.integrity, filename: b.filename,
1336
- kind: b.kind, byteSize: b.byteSize, repoId: b.repoId, repoDirectory: b.repoDirectory, repoFullName: b.repoFullName })
1443
+ list.push({ id: b.id, slug: b.slug, integrity: b.integrity, filename: b.filename, visible: b.visible === 1,
1444
+ kind: b.kind, byteSize: b.byteSize, repoId: b.repoId, repoDirectory: b.repoDirectory, repoFullName: b.repoFullName, provenance: b.provenance })
1337
1445
  bundlesByTeam.set(b.teamId, list)
1338
1446
  }
1339
1447
  return teams.map((t) => ({
1340
1448
  id: t.id, slug: t.slug, name: t.name,
1341
1449
  permissions: { dependencies: t.dependencies === 1, security: t.security === 1 },
1342
1450
  cacheKey: createHash('sha256').update(JSON.stringify([
1343
- t.dependencies, t.security, scopes.filter(scope => scope.teamId === t.id),
1451
+ t.dependencies, t.security, links, scopes.filter(scope => scope.teamId === t.id),
1344
1452
  ])).digest('base64url'),
1345
1453
  reports: reportsByTeam.get(t.id) ?? [],
1346
1454
  bundles: bundlesByTeam.get(t.id) ?? [],
@@ -1386,6 +1494,7 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1386
1494
  }
1387
1495
  return teams.map((t) => ({
1388
1496
  id: t.id, slug: t.slug, name: t.name,
1497
+ hidden: t.hidden === 1,
1389
1498
  repos: reposByTeam.get(t.id) ?? [],
1390
1499
  members: membersByTeam.get(t.id) ?? [],
1391
1500
  }))
@@ -1431,7 +1540,10 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1431
1540
 
1432
1541
  const methods: Omit<ManagedDb, keyof ManagementStore | keyof ManagementCatalogStore> = {
1433
1542
  ...storageMethods(db, key),
1543
+ ...linkReportMethods(db, key),
1544
+ ...repositoryAliasMethods(db),
1434
1545
  ...bundleBuildLeaseMethods(db),
1546
+ ...upstreamCacheMethods(db),
1435
1547
  async claimMaintenanceLease(owner, now, until, migration = false) {
1436
1548
  return !!await db.prepare(`INSERT INTO managed_maintenance_lease (id, owner, expires_at) VALUES (?, ?, ?)
1437
1549
  ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
@@ -1503,7 +1615,14 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1503
1615
  ...activity,
1504
1616
  ...comments,
1505
1617
  async getAnnotations(ids) {
1506
- return { triage: await triage.listTriage(ids), comments: await comments.listComments(ids) }
1618
+ return { triage: await triage.listTriage(ids), comments: await comments.listComments(ids), issues: await methods.listManagedIssues(ids) }
1619
+ },
1620
+ async applyManagedIssueFixes(sessionId, snapshot, updates) {
1621
+ // Recheck access inside the same write transaction as the derived field.
1622
+ const current = await methods.getTeamReportAccessSnapshot(sessionId, Date.now(), snapshot.teamId!, snapshot.reportId)
1623
+ if (!current?.teamId || JSON.stringify(current) !== JSON.stringify(snapshot)) return false
1624
+ for (const update of updates) await methods.setManagedIssueAutoFix(update.findingId, update.issueUrl, update.previous, update.next, update.checkedAt)
1625
+ return true
1507
1626
  },
1508
1627
  ...workspaceShareMethods(db),
1509
1628
  ...reports,