@preventive/triage 1.0.0-alpha.32 → 1.0.0-alpha.34

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 (57) 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 +169 -169
  16. package/out/view.css +1 -1
  17. package/out/view.js +417 -201
  18. package/package.json +23 -17
  19. package/server-managed/activity.ts +15 -1
  20. package/server-managed/brotli.ts +3 -3
  21. package/server-managed/bundle-advisories.ts +8 -3
  22. package/server-managed/bundle-advisory-inventory.ts +27 -25
  23. package/server-managed/bundle-build-worker.js +45 -9
  24. package/server-managed/bundle-build.ts +35 -11
  25. package/server-managed/bundle-cache.ts +1 -1
  26. package/server-managed/bundle.ts +17 -1
  27. package/server-managed/config.ts +7 -2
  28. package/server-managed/db-methods.ts +170 -49
  29. package/server-managed/db-neon.ts +59 -4
  30. package/server-managed/db-schema.ts +12 -3
  31. package/server-managed/db.ts +9 -1
  32. package/server-managed/github-issue-links.ts +127 -0
  33. package/server-managed/github-issues.ts +1 -1
  34. package/server-managed/http-response.ts +7 -1
  35. package/server-managed/http.ts +248 -80
  36. package/server-managed/index.ts +4 -0
  37. package/server-managed/link-reports.ts +158 -0
  38. package/server-managed/managed-issues.ts +31 -3
  39. package/server-managed/management.ts +17 -4
  40. package/server-managed/object-storage-disk.ts +7 -3
  41. package/server-managed/object-storage-vercel.ts +6 -2
  42. package/server-managed/object-storage.ts +3 -2
  43. package/server-managed/public-workspace.ts +5 -2
  44. package/server-managed/report-sources.ts +6 -5
  45. package/server-managed/report-store.ts +47 -0
  46. package/server-managed/repository-aliases.ts +91 -0
  47. package/server-managed/revisions.ts +2 -2
  48. package/server-managed/sql.ts +3 -2
  49. package/server-managed/storage-encryption.ts +72 -6
  50. package/server-managed/storage-maintenance.ts +64 -49
  51. package/server-managed/storage-payload.ts +11 -10
  52. package/server-managed/storage-stores.ts +3 -1
  53. package/server-managed/storage.ts +17 -0
  54. package/server-managed/team-feed.ts +3 -3
  55. package/server-managed/team-reports.ts +24 -15
  56. package/server-managed/upstream-cache.ts +73 -0
  57. 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,18 +337,20 @@ 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
313
344
  slug: string
314
345
  name: string
346
+ permissions: TeamUserPermissions
315
347
  cacheKey?: string
316
348
  reports: UserTeamReport[]
317
349
  bundles: UserTeamBundle[]
318
350
  }
319
351
 
320
352
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
321
- 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 {
322
354
  claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
323
355
  finishMaintenanceLease(owner: string, until: number): Promise<void>
324
356
  getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
@@ -374,7 +406,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
374
406
  getReport(id: string): Promise<ReportRecord | null>
375
407
  // Writer-locked reconciliation waits for an uncertain earlier insert.
376
408
  resolveReportUpload(id: string): Promise<ReportRecord | null>
377
- getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
409
+ getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string, reportId?: string | null): Promise<TeamReportAccessSnapshot | null>
378
410
  getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
379
411
  listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]>
380
412
  deleteReport(id: string): Promise<boolean>
@@ -394,7 +426,8 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
394
426
  // stamp nor the trail. Every change also appends to managed_finding_triage_event;
395
427
  // listTriageHistory walks one finding's trail, newest first.
396
428
  listTriage(findingIds: readonly string[]): Promise<TriageRow[]>
397
- 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>
398
431
  // A content-free fingerprint of current annotations, read in one snapshot.
399
432
  getAnnotationRevision(findingIds: readonly string[]): Promise<string>
400
433
  setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void>
@@ -406,8 +439,14 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
406
439
  // uploader login + repo; deleteBundle resolves true iff a row was removed
407
440
  // (referencing reports' bundle_id null out via the FK). linkReportsToBundle
408
441
  // attaches a freshly-stored bundle to the (still-unlinked) reports that
409
- // 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).
410
448
  insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
449
+ reuseBundle(id: string, reuse: BundleReuse): Promise<boolean>
411
450
  getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
412
451
  resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
413
452
  getBundle(id: string): Promise<ManagedBundle | null>
@@ -426,31 +465,34 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
426
465
  // Set a bundle's repository and normalized directory (repoId null detaches
427
466
  // and clears the directory). The caller validates the location and access.
428
467
  setBundleRepo(id: string, repoId: number | null, directory?: string): Promise<boolean>
468
+ setBundleVisible(id: string, visible: boolean): Promise<boolean>
429
469
  linkReportsToBundle(integrity: string, bundleId: string, userId?: string): Promise<void>
430
470
  // Teams ("Manage teams"). createTeam inserts a team (false iff the name is
431
471
  // taken); renameTeam changes a team's name ('name-taken' iff another team
432
472
  // already has it, 'not-found' iff no such team, 'ok' otherwise — same name is
433
473
  // idempotent); deleteTeam drops it (cascading its links); listTeams returns
434
- // 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
435
475
  // set for the member picker. The set*/remove* pairs maintain the link tables:
436
476
  // setTeamRepo adds a path (whole repo replaces paths); setTeamMember upserts a
437
477
  // membership + its visibility permissions (each resolves true iff a row was
438
478
  // written / removed; the caller validates the team/repo/user exist first).
439
479
  createTeam(id: string, name: string, now: number): Promise<boolean>
440
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>
441
483
  deleteTeam(id: string): Promise<boolean>
442
- 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>
443
485
  listTeams(): Promise<AdminTeam[]>
444
486
  listUserOptions(): Promise<UserOption[]>
445
487
  // Current grants for manager content reads, writes, and repository pickers.
446
488
  listRepoScopesForUser(userId: string): Promise<{ repoId: number; path: string | null }[]>
447
- // 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
448
490
  // bundles attached to that team's repos — for that user's own sidebar Teams section.
449
491
  // Any user; only their own memberships.
450
492
  listTeamsForUser(userId: string): Promise<UserTeam[]>
451
493
  getUserTeamFeedSnapshot(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; revision: string; teams: UserTeam[]; catalog: number } | null>
452
494
  // Whether `userId` may read `reportId`: true iff the report's repo belongs to
453
- // 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.
454
496
  userCanReadReport(userId: string, reportId: string): Promise<boolean>
455
497
  // The viewer's effective visibility permissions for a report, OR'd across the
456
498
  // teams (holding the report's repo) the user belongs to — drives the content
@@ -479,6 +521,14 @@ const REPORT_IN_TEAM_PATH_SQL = `(tr.path IS NULL OR tr.path = ''
479
521
 
480
522
  const BUNDLE_IN_TEAM_PATH_SQL = REPORT_IN_TEAM_PATH_SQL.replaceAll('r.repo_directory', 'b.repo_directory')
481
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
+
482
532
  // Prepare every statement the store uses, returned as a bag the factory
483
533
  // destructures — keeps openSqliteManagedDb itself small (one place per query).
484
534
  function prepareStatements(db: ManagedSql) {
@@ -597,6 +647,7 @@ function prepareStatements(db: ManagedSql) {
597
647
  LEFT JOIN managed_bundle b ON b.id = r.bundle_id
598
648
  WHERE (? IS NULL OR r.uploaded_by = ? OR EXISTS (
599
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
600
651
  WHERE tr.repo_id = r.repo_id AND tu.user_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}))
601
652
  ORDER BY r.uploaded_at DESC, r.filename ASC`,
602
653
  ),
@@ -618,6 +669,7 @@ function prepareStatements(db: ManagedSql) {
618
669
  FROM managed_report r JOIN requested q ON q.id = r.id
619
670
  JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
620
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
621
673
  GROUP BY r.id
622
674
  )
623
675
  SELECT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256,
@@ -629,21 +681,23 @@ function prepareStatements(db: ManagedSql) {
629
681
  WHERE ? = 1 OR (? = 1 AND r.uploaded_by = ?)
630
682
  OR (g.id IS NOT NULL AND (? = 1 OR r.visible = 1))`,
631
683
  ),
632
- 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`),
633
686
  selectTeamRepositoriesStmt: db.prepare(
634
687
  `SELECT tr.repo_id AS repoId, sr.full_name AS github, tr.path AS path
635
688
  FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
636
689
  WHERE tr.team_id = ? ORDER BY tr.repo_id, tr.path`,
637
690
  ),
638
691
  selectTeamReportAccessStmt: db.prepare(
639
- `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,
640
693
  r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
641
694
  tu.view_dependencies AS dependencies, tu.view_security AS security
642
695
  FROM managed_team_user tu
696
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
643
697
  JOIN managed_team_repo tr ON tr.team_id = tu.team_id
644
698
  JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
645
699
  LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
646
- 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 = ?))
647
701
  ORDER BY r.id`,
648
702
  ),
649
703
  reportFilenamesWithBundleHashStmt: db.prepare(`SELECT DISTINCT filename FROM managed_report WHERE bundle_id = ? AND sha256 = ?`),
@@ -706,58 +760,80 @@ function prepareStatements(db: ManagedSql) {
706
760
  WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
707
761
  insertBundleStmt: db.prepare(
708
762
  `WITH candidate(id, slug) AS (VALUES (?, ?))
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)
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)
710
764
  SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_bundle WHERE slug = candidate.slug) THEN id ELSE slug END,
711
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? 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))))`,
712
778
  ),
713
779
  selectBundleByIntegrityStmt: db.prepare(
714
780
  `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
715
- 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
716
783
  FROM managed_bundle WHERE integrity = ?`,
717
784
  ),
718
785
  selectBundleStmt: db.prepare(
719
786
  `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
720
- 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
721
789
  FROM managed_bundle WHERE id = ?`,
722
790
  ),
723
791
  selectBundlesStmt: db.prepare(
724
792
  `SELECT b.id AS id, b.slug AS slug, b.integrity AS integrity, b.filename AS filename, b.kind AS kind,
725
793
  b.byte_size AS byteSize, COALESCE(u.login, b.uploaded_by_login) AS uploadedByLogin,
726
- 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
727
795
  FROM managed_bundle b
728
796
  LEFT JOIN managed_user u ON u.id = b.uploaded_by
729
797
  LEFT JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
730
798
  WHERE (? IS NULL OR (b.uploaded_by = ? OR EXISTS (
731
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
732
801
  WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?)))
733
802
  ORDER BY b.uploaded_at DESC, b.filename ASC`,
734
803
  ),
735
804
  selectBundleReadableStmt: db.prepare(
736
805
  `SELECT 1 FROM managed_bundle b WHERE b.id = ? AND (b.uploaded_by = ? OR EXISTS (
737
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
738
808
  WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
739
809
  ),
740
810
  selectReadableBundleIdsStmt: db.prepare(
741
811
  `SELECT b.id FROM managed_bundle b WHERE b.id IN (SELECT value FROM json_each(?)) AND (b.uploaded_by = ? OR EXISTS (
742
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
743
814
  WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
744
815
  ),
745
816
  selectRepoReadableStmt: db.prepare(
746
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
747
819
  WHERE tr.repo_id = ? AND tu.user_id = ? LIMIT 1`,
748
820
  ),
749
821
  selectRepoPathReadableStmt: db.prepare(
750
822
  `WITH r(repo_directory) AS (VALUES (?))
751
823
  SELECT 1 FROM r JOIN managed_team_repo tr ON tr.repo_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}
752
- 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`,
753
827
  ),
754
828
  deleteBundleStmt: db.prepare(`DELETE FROM managed_bundle WHERE id = ?`),
755
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 = ?`),
756
831
  // Attach a freshly-stored bundle to the reports that declared its integrity
757
832
  // but haven't been linked yet (bundle uploaded after the report).
758
833
  linkReportsToBundleStmt: db.prepare(
759
834
  `UPDATE managed_report AS r SET bundle_id = ? WHERE bundle_integrity = ? AND bundle_id IS NULL
760
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
761
837
  WHERE tu.user_id = ? AND tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}))`,
762
838
  ),
763
839
  // OR IGNORE: a duplicate name (UNIQUE) is the "taken" signal (0 changes); the
@@ -767,30 +843,33 @@ function prepareStatements(db: ManagedSql) {
767
843
  SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_team WHERE slug = candidate.slug) THEN id ELSE slug END,
768
844
  ?, ?, ? FROM candidate`),
769
845
  deleteTeamStmt: db.prepare(`DELETE FROM managed_team WHERE id = ?`),
770
- 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 = ?`),
771
847
  selectTeamByNameStmt: db.prepare(`SELECT id FROM managed_team WHERE name = ?`),
772
848
  renameTeamStmt: db.prepare(`UPDATE managed_team SET name = ?, updated_at = ? WHERE id = ?`),
773
- 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`),
774
851
  selectTeamsForUserStmt: db.prepare(
775
852
  `SELECT t.id AS id, t.slug AS slug, t.name AS name,
776
853
  tu.view_dependencies AS dependencies, tu.view_security AS security
777
854
  FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
778
- 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`,
779
856
  ),
780
857
  selectUserTeamScopesStmt: db.prepare(
781
858
  `SELECT tr.team_id AS teamId, tr.repo_id AS repoId, tr.path AS path, sr.full_name AS fullName
782
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
783
861
  JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
784
862
  WHERE tu.user_id = ? ORDER BY tr.team_id, tr.repo_id, tr.path`,
785
863
  ),
786
864
  // Reports attached to the repos of the user's teams, tagged by team (a report
787
865
  // shows under every team whose repo it's attached to). Newest first.
788
866
  selectUserTeamReportsStmt: db.prepare(
789
- `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,
790
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,
791
869
  tu.view_dependencies AS dependencies, tu.view_security AS security,
792
870
  (SELECT role FROM managed_user WHERE id = tu.user_id) AS role
793
871
  FROM managed_team_user tu
872
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
794
873
  JOIN managed_team_repo tr ON tr.team_id = tu.team_id
795
874
  JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
796
875
  LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
@@ -798,33 +877,37 @@ function prepareStatements(db: ManagedSql) {
798
877
  ORDER BY r.uploaded_at DESC, r.filename ASC, r.id ASC`,
799
878
  ),
800
879
  // Bundles are scoped through the same team -> repository links as reports.
801
- // They have no visibility flag: the bundle directory must fall inside a
802
- // team repository scope to appear in that team's sidebar.
880
+ // Hidden bundles remain available to managers within their team scopes.
803
881
  selectUserTeamBundlesStmt: db.prepare(
804
882
  `SELECT DISTINCT tr.team_id AS teamId, b.id AS id, b.slug AS slug, b.integrity AS integrity,
805
- 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
806
884
  FROM managed_team_user tu
885
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
807
886
  JOIN managed_team_repo tr ON tr.team_id = tu.team_id
808
887
  JOIN managed_bundle b ON b.repo_id = tr.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
809
888
  JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
810
- 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'))
811
890
  ORDER BY b.uploaded_at DESC, b.filename ASC, b.id ASC`,
812
891
  ),
813
892
  selectUserRepoScopesStmt: db.prepare(
814
893
  `SELECT DISTINCT tr.repo_id AS repoId, NULLIF(tr.path, '') AS path
815
- 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 = ?`,
816
897
  ),
817
898
  // A report is readable iff one of the user's team scopes contains it.
818
899
  selectReportReadableStmt: db.prepare(
819
900
  `SELECT 1 FROM managed_report r
820
901
  JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
821
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
822
904
  WHERE r.id = ? AND tu.user_id = ? LIMIT 1`,
823
905
  ),
824
906
  selectBundleSecurityStmt: db.prepare(
825
907
  `SELECT 1 FROM managed_bundle b
826
908
  JOIN managed_team_repo tr ON tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
827
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
828
911
  WHERE b.id = ? AND tu.user_id = ? AND tu.view_security = 1
829
912
  AND (? IS NULL OR tr.team_id = ?) LIMIT 1`,
830
913
  ),
@@ -836,6 +919,7 @@ function prepareStatements(db: ManagedSql) {
836
919
  FROM managed_report r
837
920
  JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
838
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
839
923
  WHERE r.id = ?`,
840
924
  ),
841
925
  selectUserOptionsStmt: db.prepare(`SELECT id, login, name, role FROM managed_user ORDER BY login ASC`),
@@ -1006,18 +1090,23 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1006
1090
  async listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]> {
1007
1091
  return await stmts.findingCatalogReportsStmt.all(after, limit) as Pick<ReportRecord, 'id' | 'byteSize'>[]
1008
1092
  },
1009
- 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> {
1010
1094
  const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1011
1095
  if (!session) return null
1012
1096
  const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
1013
1097
  if (user.role === 'none' || !await stmts.selectTeamAccessStmt.get(user.id, teamId)) return { user, teamId: null, reports: [], repositories: [] }
1014
1098
  const whole = user.role === 'admin' || user.role === 'manage'
1015
- 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 {
1016
1100
  id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
1017
- repoFullName: string | null; dependencies: number; security: number
1101
+ repoFullName: string | null; dependencies: number; security: number; visible: number
1018
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
1019
1108
  const repositories = await stmts.selectTeamRepositoriesStmt.all(teamId) as TeamReportAccessSnapshot['repositories']
1020
- 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 => ({
1021
1110
  id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
1022
1111
  repo: { github: row.repoFullName, directory: row.repoDirectory },
1023
1112
  permissions: { dependencies: whole || row.dependencies === 1, security: whole || row.security === 1 },
@@ -1034,7 +1123,7 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1034
1123
  id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
1035
1124
  repoFullName: string | null; dependencies: number; security: number
1036
1125
  }[]
1037
- return { user, reports: rows.map(row => ({
1126
+ return { user, linkRevision: await linkRevision(db), reports: rows.map(row => ({
1038
1127
  id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
1039
1128
  repo: { github: row.repoFullName, directory: row.repoDirectory },
1040
1129
  permissions: { dependencies: admin || manage || row.dependencies === 1, security: admin || manage || row.security === 1 },
@@ -1101,6 +1190,8 @@ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit
1101
1190
  return JSON.stringify([
1102
1191
  await stmts.annotationTriageRevisionStmt.all(ids),
1103
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),
1104
1195
  ])
1105
1196
  },
1106
1197
  async deleteTriage(findingIds: readonly string[]): Promise<number> {
@@ -1182,18 +1273,23 @@ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit
1182
1273
  }
1183
1274
 
1184
1275
  type BundleRow = {
1276
+ visible: number
1185
1277
  id: string; slug: string; integrity: string; filename: string; kind: string | null; byteSize: number
1186
- 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
1187
1280
  }
1188
1281
  type BundleListRow = {
1282
+ visible: number
1189
1283
  id: string; slug: string; integrity: string; filename: string; kind: string | null; byteSize: number
1190
1284
  uploadedByLogin: string | null; repoId: number | null; repoFullName: string | null; repoDirectory: string; uploadedAt: number
1285
+ provenance: BundleProvenance | null
1191
1286
  }
1192
1287
 
1193
1288
  function mapBundle(r: BundleRow): ManagedBundle {
1194
1289
  return {
1195
1290
  id: r.id, slug: r.slug, integrity: r.integrity, filename: r.filename, kind: r.kind,
1196
- 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,
1197
1293
  }
1198
1294
  }
1199
1295
 
@@ -1213,9 +1309,14 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1213
1309
  await validateStorageUpload(db, key, 'bundle', bundle.id, bundle.dataKey)
1214
1310
  await insertBundleStmt.run(
1215
1311
  bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
1216
- 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),
1217
1314
  )
1218
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
+ },
1219
1320
  getBundleByIntegrity,
1220
1321
  // The distinct method name selects a writer-locked reconciliation scope.
1221
1322
  resolveBundleUpload: getBundleByIntegrity,
@@ -1231,6 +1332,7 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1231
1332
  const row = await selectBundleStmt.get(id) as BundleRow | undefined
1232
1333
  if (!row) return denied
1233
1334
  const manager = roleAtLeast(session.role, 'manage')
1335
+ if (!manager && row.visible !== 1) return denied
1234
1336
  if (session.role !== 'admin') {
1235
1337
  const allowed = manager ? await selectBundleReadableStmt.get(id, session.uid, session.uid)
1236
1338
  : row.repoId !== null && await selectRepoPathReadableStmt.get(row.repoDirectory, row.repoId, session.uid)
@@ -1244,8 +1346,8 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1244
1346
  const rows = (await selectBundlesStmt.all(userId ?? null, userId ?? null, userId ?? null)) as BundleListRow[]
1245
1347
  return rows.map((r) => ({
1246
1348
  id: r.id, slug: r.slug, integrity: r.integrity, filename: r.filename, kind: r.kind, byteSize: r.byteSize,
1247
- uploadedByLogin: r.uploadedByLogin, repoId: r.repoId, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory,
1248
- 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,
1249
1351
  }))
1250
1352
  },
1251
1353
  async userCanReadBundle(userId: string, id: string): Promise<boolean> {
@@ -1265,6 +1367,9 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1265
1367
  async deleteBundle(id: string): Promise<boolean> {
1266
1368
  return Number((await deleteBundleStmt.run(id)).changes) > 0
1267
1369
  },
1370
+ async setBundleVisible(id: string, visible: boolean): Promise<boolean> {
1371
+ return Number((await stmts.setBundleVisibleStmt.run(visible ? 1 : 0, id)).changes) > 0
1372
+ },
1268
1373
  async setBundleRepo(id: string, repoId: number | null, directory?: string): Promise<boolean> {
1269
1374
  return Number((await setBundleRepoStmt.run(repoId, repoId == null ? '' : directory ?? '', id)).changes) > 0
1270
1375
  },
@@ -1274,7 +1379,7 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1274
1379
  }
1275
1380
  }
1276
1381
 
1277
- type TeamRow = { id: string; slug: string; name: string }
1382
+ type TeamRow = { id: string; slug: string; name: string; hidden: number }
1278
1383
  type TeamRepoRow = { teamId: string; repoId: number; fullName: string; path: string | null }
1279
1384
  type TeamMemberRow = { teamId: string; userId: string; login: string; viewDependencies: number; viewSecurity: number }
1280
1385
 
@@ -1283,7 +1388,7 @@ type TeamMemberRow = { teamId: string; userId: string; login: string; viewDepend
1283
1388
  // workspace has.
1284
1389
  function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql) {
1285
1390
  const {
1286
- insertTeamStmt, selectTeamByNameStmt, renameTeamStmt, deleteTeamStmt, selectTeamStmt,
1391
+ insertTeamStmt, selectTeamByNameStmt, renameTeamStmt, setTeamHiddenStmt, deleteTeamStmt, selectTeamStmt,
1287
1392
  selectUserRepoScopesStmt, selectTeamsStmt, selectTeamsForUserStmt, selectUserTeamReportsStmt, selectUserTeamBundlesStmt, selectReportReadableStmt,
1288
1393
  selectReportPermsStmt, selectUserOptionsStmt, selectTeamReposStmt, selectTeamMembersStmt,
1289
1394
  upsertTeamRepoStmt, deleteTeamRepoStmt, deleteTeamRepoPathStmt, upsertTeamMemberStmt, deleteTeamMemberStmt,
@@ -1303,9 +1408,12 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1303
1408
  async deleteTeam(id: string): Promise<boolean> {
1304
1409
  return Number((await deleteTeamStmt.run(id)).changes) > 0
1305
1410
  },
1306
- 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> {
1307
1415
  const row = (await selectTeamStmt.get(id)) as TeamRow | undefined
1308
- 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 }
1309
1417
  },
1310
1418
  async listUserOptions(): Promise<UserOption[]> {
1311
1419
  return ((await selectUserOptionsStmt.all()) as UserOption[]).map((u) => ({ id: u.id, login: u.login, name: u.name, role: u.role }))
@@ -1316,29 +1424,31 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1316
1424
  async listTeamsForUser(userId: string): Promise<UserTeam[]> {
1317
1425
  const teams = (await selectTeamsForUserStmt.all(userId)) as { id: string; slug: string; name: string; dependencies: number; security: number }[]
1318
1426
  const scopes = await stmts.selectUserTeamScopesStmt.all(userId) as { teamId: string; repoId: number; path: string; fullName: string }[]
1427
+ const links = await linkRevision(db)
1319
1428
  const reportsByTeam = new Map<string, UserTeamReport[]>()
1320
1429
  for (const r of (await selectUserTeamReportsStmt.all(userId)) as {
1321
1430
  teamId: string; id: string; slug: string; filename: string; analyzer: string | null; sha256: string; bundleId: string | null; repoId: number
1322
- 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
1323
1432
  }[]) {
1324
1433
  const list = reportsByTeam.get(r.teamId) ?? []
1325
1434
  const cacheKey = createHash('sha256').update(JSON.stringify([
1326
- 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,
1327
1436
  ])).digest('base64url')
1328
- 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 })
1329
1438
  reportsByTeam.set(r.teamId, list)
1330
1439
  }
1331
1440
  const bundlesByTeam = new Map<string, UserTeamBundle[]>()
1332
- 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 })[]) {
1333
1442
  const list = bundlesByTeam.get(b.teamId) ?? []
1334
- list.push({ id: b.id, slug: b.slug, integrity: b.integrity, filename: b.filename,
1335
- 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 })
1336
1445
  bundlesByTeam.set(b.teamId, list)
1337
1446
  }
1338
1447
  return teams.map((t) => ({
1339
1448
  id: t.id, slug: t.slug, name: t.name,
1449
+ permissions: { dependencies: t.dependencies === 1, security: t.security === 1 },
1340
1450
  cacheKey: createHash('sha256').update(JSON.stringify([
1341
- t.dependencies, t.security, scopes.filter(scope => scope.teamId === t.id),
1451
+ t.dependencies, t.security, links, scopes.filter(scope => scope.teamId === t.id),
1342
1452
  ])).digest('base64url'),
1343
1453
  reports: reportsByTeam.get(t.id) ?? [],
1344
1454
  bundles: bundlesByTeam.get(t.id) ?? [],
@@ -1384,6 +1494,7 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1384
1494
  }
1385
1495
  return teams.map((t) => ({
1386
1496
  id: t.id, slug: t.slug, name: t.name,
1497
+ hidden: t.hidden === 1,
1387
1498
  repos: reposByTeam.get(t.id) ?? [],
1388
1499
  members: membersByTeam.get(t.id) ?? [],
1389
1500
  }))
@@ -1429,7 +1540,10 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1429
1540
 
1430
1541
  const methods: Omit<ManagedDb, keyof ManagementStore | keyof ManagementCatalogStore> = {
1431
1542
  ...storageMethods(db, key),
1543
+ ...linkReportMethods(db, key),
1544
+ ...repositoryAliasMethods(db),
1432
1545
  ...bundleBuildLeaseMethods(db),
1546
+ ...upstreamCacheMethods(db),
1433
1547
  async claimMaintenanceLease(owner, now, until, migration = false) {
1434
1548
  return !!await db.prepare(`INSERT INTO managed_maintenance_lease (id, owner, expires_at) VALUES (?, ?, ?)
1435
1549
  ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
@@ -1501,7 +1615,14 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1501
1615
  ...activity,
1502
1616
  ...comments,
1503
1617
  async getAnnotations(ids) {
1504
- 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
1505
1626
  },
1506
1627
  ...workspaceShareMethods(db),
1507
1628
  ...reports,