@preventive/triage 1.0.0-alpha.46 → 1.0.0-alpha.47

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 (71) hide show
  1. package/common/managed/agent-tasks.ts +5 -3
  2. package/common/managed/github-agents.ts +28 -10
  3. package/out/activity.js +4 -0
  4. package/out/chunk-5AHIQFFG.js +36 -0
  5. package/out/chunk-6ANSHRFQ.js +6 -0
  6. package/out/chunk-6WF3KNTL.js +3 -0
  7. package/out/chunk-7BKKYVWR.js +7 -0
  8. package/out/{chunk-7XRR4OFU.js → chunk-FH24VHSJ.js} +1 -1
  9. package/out/chunk-NOZJULWS.js +52 -0
  10. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  11. package/out/chunk-S6U2J62U.js +232 -0
  12. package/out/{chunk-G24A42V6.js → chunk-SPEFPML6.js} +1 -1
  13. package/out/chunk-UENXT2HK.js +3 -0
  14. package/out/chunk-VL43BTSO.js +1 -0
  15. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  16. package/out/chunk-ZCS4UYHI.js +1 -0
  17. package/out/chunk-ZGNMDGSB.js +1 -0
  18. package/out/client-managed-import.js +1 -1
  19. package/out/client-managed.js +22 -22
  20. package/out/client-sync.js +2 -2
  21. package/out/compare.js +14 -14
  22. package/out/dialogs.js +40 -40
  23. package/out/graph.js +11 -11
  24. package/out/index.html +11 -11
  25. package/out/scan.js +2 -2
  26. package/out/terminal.js +6 -6
  27. package/out/view.css +1 -1
  28. package/out/view.js +15 -15
  29. package/package.json +2 -1
  30. package/risk/index.d.ts +4 -2
  31. package/risk/npm/assess.js +15 -4
  32. package/risk/npm/registry.js +6 -1
  33. package/server-managed/activity.ts +24 -11
  34. package/server-managed/app-agents.ts +10 -21
  35. package/server-managed/bundle-build-worker.js +7 -3
  36. package/server-managed/bundle-build.ts +5 -3
  37. package/server-managed/bundle-cache.ts +9 -3
  38. package/server-managed/bundle.ts +3 -4
  39. package/server-managed/commit-coauthors.ts +5 -9
  40. package/server-managed/db-methods.ts +286 -149
  41. package/server-managed/db-neon.ts +11 -4
  42. package/server-managed/http.ts +75 -33
  43. package/server-managed/link-reports.ts +5 -1
  44. package/server-managed/npm-kept.ts +19 -5
  45. package/server-managed/npm-loads.ts +27 -15
  46. package/server-managed/npm-packages.ts +18 -11
  47. package/server-managed/npm-risk.ts +64 -17
  48. package/server-managed/pretty-print.ts +45 -12
  49. package/server-managed/report-store.ts +16 -11
  50. package/server-managed/repository-agents.ts +1 -1
  51. package/server-managed/repository-browser.ts +4 -2
  52. package/server-managed/repository-code.ts +28 -11
  53. package/server-managed/repository-pulse.ts +1 -1
  54. package/server-managed/solidity-entry-points.ts +5 -3
  55. package/server-managed/sql-postgres.ts +3 -0
  56. package/server-managed/sql.ts +2 -1
  57. package/server-managed/storage-db.ts +11 -3
  58. package/server-managed/storage-encryption.ts +7 -4
  59. package/server-managed/storage.ts +4 -2
  60. package/server-managed/team-feed.ts +3 -1
  61. package/server-managed/team-reports.ts +6 -1
  62. package/server-managed/uploads.ts +29 -9
  63. package/out/chunk-4UFAMJAX.js +0 -1
  64. package/out/chunk-BOIXMUU5.js +0 -3
  65. package/out/chunk-CC4OM6TZ.js +0 -3
  66. package/out/chunk-JKGQ3O7O.js +0 -6
  67. package/out/chunk-LE5ZWLG4.js +0 -235
  68. package/out/chunk-MSION3G4.js +0 -36
  69. package/out/chunk-S6GIXWQW.js +0 -52
  70. package/out/chunk-V3KWNNMY.js +0 -1
  71. package/out/chunk-WQ74ZBZF.js +0 -7
@@ -1,4 +1,4 @@
1
- import { type LinkReportStore, linkReportMethods, linkRevision } from './link-reports.ts'
1
+ import { type LinkReportStore, linkReportMethods, linkRevision, linkRevisionOf } from './link-reports.ts'
2
2
  import { type RepositoryAliasStore, repositoryAliasMethods } from './repository-aliases.ts'
3
3
  import { type DependencyReviewStore, dependencyReviewMethods } from './dependency-reviews.ts'
4
4
  import { type RepositoryDependencyReviewStore, repositoryDependencyReviewMethods } from './repository-dependency-reviews.ts'
@@ -13,7 +13,7 @@ import { preferredSlug } from './slugs.ts'
13
13
  import { type CommentStore, commentMethods } from './comments.ts'
14
14
  import { type ActivityStore, activityMethods } from './activity.ts'
15
15
  import { type GithubCommitStore, type GithubMetadataStore, githubCommitMethods, githubMetadataMethods } from './github-metadata.ts'
16
- import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
16
+ import { type ManagedIssue, type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
17
17
  import type { IssueFixUpdate } from './github-issue-links.ts'
18
18
  import { type BundleBuildLeaseStore, bundleBuildLeaseMethods } from './bundle-build-leases.ts'
19
19
  import { type UpstreamCacheStore, upstreamCacheMethods } from './upstream-cache.ts'
@@ -23,7 +23,7 @@ import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-sha
23
23
  import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
24
24
  import { type ManagementCatalogStore, managementCatalogMethods } from './management-catalog.ts'
25
25
  import { type StorageKey } from '../server-common/storage-crypto.ts'
26
- import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, validateStorageUpload } from './storage-db.ts'
26
+ import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, storageStateColumns, storageStateOf, validateStorageUpload } from './storage-db.ts'
27
27
 
28
28
  // A managed user identity (the subset of GitHub's `GET /user` we keep). Input
29
29
  // to the upsert; `githubUserId` is the provider lookup key, never exposed to
@@ -402,6 +402,9 @@ export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyRe
402
402
  deleteSession(id: string): Promise<void>
403
403
  deleteExpiredSessions(now: number): Promise<number>
404
404
  listUsers(): Promise<AdminUser[]>
405
+ // One user by id, as their sessions read them, or null; for an action naming
406
+ // one, where listUsers would work out every user's latest activity.
407
+ getUser(id: string): Promise<StoredUser | null>
405
408
  // Set a user's role; resolves true iff a matching user row was updated.
406
409
  setUserRole(id: string, role: Role): Promise<boolean>
407
410
  // Persist / read a user's GitHub token (for on-demand repo listing).
@@ -535,6 +538,9 @@ export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyRe
535
538
  // Whether `userId` may read `reportId`: true iff the report's repo belongs to
536
539
  // a non-hidden team the user is a member of. Backs the team-scoped report view endpoint.
537
540
  userCanReadReport(userId: string, reportId: string): Promise<boolean>
541
+ // What viewing `reportId` turns on, in one read: who uploaded it, whether it
542
+ // is published, and whether userCanReadReport; null where there is none.
543
+ getReportView(userId: string, reportId: string): Promise<{ uploadedBy: string | null; visible: boolean; readable: boolean } | null>
538
544
  // The viewer's effective visibility permissions for a report, OR'd across the
539
545
  // teams (holding the report's repo) the user belongs to — drives the content
540
546
  // filter (a viewer without a permission has those findings stripped).
@@ -563,6 +569,10 @@ const viewSession = (row: ViewSessionRow) => ({
563
569
 
564
570
  type UserRow = { id: string; login: string; name: string | null; role: Role; created: number; lastSeen: number | null; lastActivity: number | null }
565
571
 
572
+ // Rows a statement gathered into one JSON array value (json_group_array): its
573
+ // text on SQLite, parsed already on Postgres, where none gather as NULL.
574
+ const jsonRows = <T>(value: unknown): T[] => value == null ? [] : (typeof value === 'string' ? JSON.parse(value) : value) as T[]
575
+
566
576
  // Shared by listing, access checks, and permission aggregation. A team sees
567
577
  // reports rooted at its path or below it. Literal substring comparison keeps
568
578
  // separators, case, and SQL wildcard characters significant.
@@ -572,6 +582,46 @@ const REPORT_IN_TEAM_PATH_SQL = `(tr.path IS NULL OR tr.path = ''
572
582
 
573
583
  const BUNDLE_IN_TEAM_PATH_SQL = REPORT_IN_TEAM_PATH_SQL.replaceAll('r.repo_directory', 'b.repo_directory')
574
584
 
585
+ // What a user's team catalog reads (listTeamsForUser), for `user`: a
586
+ // placeholder, or a column of a statement reading it with more. Their teams,
587
+ // `dependencyReview` where the user reviews dependencies in the team
588
+ // (dependency-reviews.ts reviewsIn): one that reviews them, and a member
589
+ // who manages, or triages and may see its dependencies; an admin in any.
590
+ const USER_TEAMS_SQL = (user: string) => `SELECT t.id AS id, t.slug AS slug, t.name AS name,
591
+ CASE WHEN t.dependency_review = 1 AND (u.role IN ('admin', 'manage') OR (u.role = 'triage' AND tu.view_dependencies = 1))
592
+ THEN 1 ELSE 0 END AS dependencyReview,
593
+ tu.view_dependencies AS dependencies, tu.view_security AS security
594
+ FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id JOIN managed_user u ON u.id = tu.user_id
595
+ WHERE tu.user_id = ${user} AND t.hidden = 0`
596
+ // Their teams' repository scopes.
597
+ const USER_TEAM_SCOPES_SQL = (user: string) => `SELECT tr.team_id AS teamId, tr.repo_id AS repoId, tr.path AS path, sr.full_name AS fullName
598
+ FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
599
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
600
+ JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
601
+ WHERE tu.user_id = ${user}`
602
+ // Reports attached to the repos of their teams, tagged by team (a report
603
+ // shows under every team whose repo it's attached to).
604
+ const USER_TEAM_REPORTS_SQL = (user: string) => `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,
605
+ r.sha256 AS sha256, r.bundle_id AS bundleId, r.repo_id AS repoId, r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
606
+ tu.view_dependencies AS dependencies, tu.view_security AS security,
607
+ (SELECT role FROM managed_user WHERE id = tu.user_id) AS role
608
+ FROM managed_team_user tu
609
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
610
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
611
+ JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
612
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
613
+ WHERE tu.user_id = ${user} AND (r.visible = 1 OR (SELECT role FROM managed_user WHERE id = tu.user_id) IN ('admin', 'manage'))`
614
+ // Bundles, scoped through the same team -> repository links as reports.
615
+ // Hidden bundles remain available to managers within their team scopes.
616
+ const USER_TEAM_BUNDLES_SQL = (user: string) => `SELECT DISTINCT tr.team_id AS teamId, b.id AS id, b.slug AS slug, b.integrity AS integrity,
617
+ 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
618
+ FROM managed_team_user tu
619
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
620
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
621
+ JOIN managed_bundle b ON b.repo_id = tr.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
622
+ JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
623
+ WHERE tu.user_id = ${user} AND (b.visible = 1 OR (SELECT role FROM managed_user WHERE id = tu.user_id) IN ('admin', 'manage'))`
624
+
575
625
  // Whether a repeated upload/build renames the stored row. Uploads never rename
576
626
  // a built row, or one whose provenance is unknown; builds rename either. The
577
627
  // kind must stay readable from its blob store (sourcemaps are stored apart):
@@ -580,6 +630,49 @@ const BUNDLE_RENAMED_SQL = `(:rename = 1 AND (provenance = 'upload' OR (:provena
580
630
  AND (COALESCE(kind, '') = COALESCE(:kind, '') OR (kind IS NULL AND :kind = 'stasis'))
581
631
  AND (filename <> :filename OR kind IS DISTINCT FROM :kind))`
582
632
 
633
+ // The rows a user's team catalog is built of (USER_TEAMS_SQL and the rest).
634
+ type UserTeamRows = {
635
+ teams: { id: string; slug: string; name: string; dependencyReview: number; dependencies: number; security: number }[]
636
+ scopes: { teamId: string; repoId: number; path: string; fullName: string }[]
637
+ reports: {
638
+ teamId: string; id: string; slug: string; filename: string; analyzer: string | null; sha256: string; bundleId: string | null; repoId: number
639
+ repoDirectory: string; repoFullName: string | null; visible: number; dependencies: number; security: number; role: string
640
+ }[]
641
+ bundles: (Omit<UserTeamBundle, 'visible'> & { teamId: string; visible: number })[]
642
+ }
643
+
644
+ // A user's team catalog from its rows, `links` the link reports' revision.
645
+ function userTeams(teams: UserTeamRows['teams'], scopes: UserTeamRows['scopes'], links: string, reports: UserTeamRows['reports'], bundles: UserTeamRows['bundles']): UserTeam[] {
646
+ const reportsByTeam = new Map<string, UserTeamReport[]>()
647
+ for (const r of reports) {
648
+ const list = reportsByTeam.get(r.teamId) ?? []
649
+ const cacheKey = createHash('sha256').update(JSON.stringify([
650
+ r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role, links,
651
+ ])).digest('base64url')
652
+ 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 })
653
+ reportsByTeam.set(r.teamId, list)
654
+ }
655
+ const bundlesByTeam = new Map<string, UserTeamBundle[]>()
656
+ for (const b of bundles) {
657
+ const list = bundlesByTeam.get(b.teamId) ?? []
658
+ list.push({ id: b.id, slug: b.slug, integrity: b.integrity, filename: b.filename, visible: b.visible === 1,
659
+ kind: b.kind, byteSize: b.byteSize, repoId: b.repoId, repoDirectory: b.repoDirectory, repoFullName: b.repoFullName, provenance: b.provenance })
660
+ bundlesByTeam.set(b.teamId, list)
661
+ }
662
+ return teams.map((t) => ({
663
+ id: t.id, slug: t.slug, name: t.name,
664
+ ...(t.dependencyReview === 1 ? { dependencyReview: true } : {}),
665
+ permissions: { dependencies: t.dependencies === 1, security: t.security === 1 },
666
+ cacheKey: createHash('sha256').update(JSON.stringify([
667
+ t.dependencies, t.security, links, scopes.filter(scope => scope.teamId === t.id),
668
+ ])).digest('base64url'),
669
+ repos: scopes.filter(scope => scope.teamId === t.id).map(scope => ({ repoId: scope.repoId, fullName: scope.fullName, path: scope.path || null }))
670
+ .toSorted((a, b) => a.fullName.localeCompare(b.fullName) || (a.path ?? '').localeCompare(b.path ?? '')),
671
+ reports: reportsByTeam.get(t.id) ?? [],
672
+ bundles: bundlesByTeam.get(t.id) ?? [],
673
+ }))
674
+ }
675
+
583
676
  // Prepare every statement the store uses, returned as a bag the factory
584
677
  // destructures — keeps openSqliteManagedDb itself small (one place per query).
585
678
  function prepareStatements(db: ManagedSql) {
@@ -618,6 +711,15 @@ function prepareStatements(db: ManagedSql) {
618
711
  JOIN managed_user u ON u.id = s.user_id
619
712
  WHERE s.id = ? AND s.expires_at > ? AND s.viewer_session IS NULL`,
620
713
  ),
714
+ // selectOwnSessionStmt with touchUserSeenStmt in one statement: the user of
715
+ // a live own session seen, and returned with it.
716
+ touchOwnSessionStmt: db.prepare(
717
+ `UPDATE managed_user SET last_seen_at = CASE WHEN last_seen_at IS NULL OR last_seen_at < ? THEN ? ELSE last_seen_at END
718
+ WHERE id = (SELECT user_id FROM managed_session WHERE id = ? AND expires_at > ? AND viewer_session IS NULL)
719
+ RETURNING (SELECT id FROM managed_session WHERE id = ?) AS id, (SELECT csrf_token FROM managed_session WHERE id = ?) AS csrf,
720
+ (SELECT expires_at FROM managed_session WHERE id = ?) AS exp, (SELECT upload_key FROM managed_session WHERE id = ?) AS uploadKey,
721
+ id AS uid, login AS login, name AS name, avatar_url AS avatar, role AS role`,
722
+ ),
621
723
  selectViewSessionStmt: db.prepare(
622
724
  `SELECT s.id AS id, s.csrf_token AS csrf, s.expires_at AS exp, s.upload_key AS uploadKey,
623
725
  u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role,
@@ -651,6 +753,7 @@ function prepareStatements(db: ManagedSql) {
651
753
  ) AS events) AS lastActivity
652
754
  FROM managed_user u ORDER BY u.created_at ASC, u.login ASC`,
653
755
  ),
756
+ selectUserStmt: db.prepare(`SELECT id, login, name, avatar_url AS avatarUrl, role FROM managed_user WHERE id = ?`),
654
757
  touchUserSeenStmt: db.prepare(
655
758
  `UPDATE managed_user SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)`,
656
759
  ),
@@ -660,9 +763,14 @@ function prepareStatements(db: ManagedSql) {
660
763
  SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, gh_tokens_encrypted = ?, updated_at = ?
661
764
  WHERE id = ?`,
662
765
  ),
663
- selectTokensStmt: db.prepare(
664
- `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp, gh_tokens_encrypted AS encrypted
665
- FROM managed_user WHERE id = ?`,
766
+ // A user's GitHub tokens with storage-db.ts storageState's read, in one
767
+ // statement.
768
+ selectTokensStateStmt: db.prepare(
769
+ `SELECT u.id AS userId, u.gh_access_token AS access, u.gh_refresh_token AS refresh, u.gh_token_expires_at AS exp, u.gh_tokens_encrypted AS encrypted,
770
+ e.id AS stateId, ${storageStateColumns('e')}
771
+ FROM (SELECT 1 AS one) AS o
772
+ LEFT JOIN managed_user u ON u.id = ?
773
+ LEFT JOIN managed_storage_encryption e ON e.id = 1`,
666
774
  ),
667
775
  // Concurrent first requests keep whichever key was stored first.
668
776
  ensureUploadKeyStmt: db.prepare(
@@ -752,42 +860,68 @@ function prepareStatements(db: ManagedSql) {
752
860
  `SELECT id FROM managed_report WHERE sha256 = ? AND COALESCE(analyzer, '') = COALESCE(?, '')
753
861
  ORDER BY uploaded_at, id LIMIT 1`,
754
862
  ),
755
- selectReportAccessStmt: db.prepare(
756
- `WITH requested AS (SELECT value AS id FROM json_each(?)), report_grants AS (
863
+ // A report access snapshot's reads in one statement, so one snapshot of
864
+ // the database answers them all: the session's user; those of the
865
+ // requested reports (a JSON array of ids) they may read, each with the
866
+ // grants their teams give them (a JSON array); and the enabled link
867
+ // reports (linkRevision).
868
+ selectReportSnapshotStmt: db.prepare(
869
+ `WITH viewer AS (
870
+ SELECT u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role
871
+ FROM managed_session s JOIN managed_user u ON u.id = s.user_id
872
+ WHERE s.id = ? AND s.expires_at > ?
873
+ ), requested AS (SELECT value AS id FROM json_each(?)), report_grants AS (
757
874
  SELECT r.id AS id, MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
758
875
  FROM managed_report r JOIN requested q ON q.id = r.id
759
876
  JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
760
- JOIN managed_team_user tu ON tu.team_id = tr.team_id AND tu.user_id = ?
877
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id AND tu.user_id = (SELECT uid FROM viewer)
761
878
  JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
762
879
  GROUP BY r.id
763
880
  )
764
- SELECT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256,
765
- r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
766
- COALESCE(g.dependencies, 0) AS dependencies, COALESCE(g.security, 0) AS security
767
- FROM managed_report r JOIN requested q ON q.id = r.id
768
- LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
769
- LEFT JOIN report_grants g ON g.id = r.id
770
- WHERE ? = 1 OR (? = 1 AND r.uploaded_by = ?)
771
- OR (g.id IS NOT NULL AND (? = 1 OR r.visible = 1))`,
772
- ),
773
- selectTeamAccessStmt: db.prepare(`SELECT 1 FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
774
- WHERE tu.user_id = ? AND tu.team_id = ? AND t.hidden = 0`),
775
- selectTeamRepositoriesStmt: db.prepare(
776
- `SELECT tr.repo_id AS repoId, sr.full_name AS github, tr.path AS path
777
- FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
778
- WHERE tr.team_id = ? ORDER BY tr.repo_id, tr.path`,
881
+ SELECT v.uid AS uid, v.login AS login, v.name AS name, v.avatar AS avatar, v.role AS role,
882
+ (SELECT json_group_array(json_object('id', r.id, 'filename', r.filename, 'byteSize', r.byte_size, 'sha256', r.sha256,
883
+ 'repoDirectory', r.repo_directory, 'repoFullName', sr.full_name,
884
+ 'dependencies', COALESCE(g.dependencies, 0), 'security', COALESCE(g.security, 0)) ORDER BY r.id)
885
+ FROM managed_report r JOIN requested q ON q.id = r.id
886
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
887
+ LEFT JOIN report_grants g ON g.id = r.id
888
+ WHERE v.role = 'admin' OR (v.role = 'manage' AND r.uploaded_by = v.uid)
889
+ OR (g.id IS NOT NULL AND (v.role = 'manage' OR r.visible = 1))) AS reports,
890
+ (SELECT json_group_array(json_object('id', id, 'sha256', sha256) ORDER BY id) FROM managed_link_report WHERE enabled = 1) AS links
891
+ FROM viewer v`,
779
892
  ),
780
- selectTeamReportAccessStmt: db.prepare(
781
- `SELECT DISTINCT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256, r.visible AS visible,
782
- r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
783
- tu.view_dependencies AS dependencies, tu.view_security AS security
784
- FROM managed_team_user tu
785
- JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
786
- JOIN managed_team_repo tr ON tr.team_id = tu.team_id
787
- JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
788
- LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
789
- WHERE tu.user_id = ? AND tu.team_id = ? AND (r.visible = 1 OR (? = 1 AND r.id = ?))
790
- ORDER BY r.id`,
893
+ // A team snapshot's reads in one statement, so one snapshot of the
894
+ // database answers them all: the session's user; whether they see the
895
+ // team; the team's visible reports in the scopes it grants them, with the
896
+ // grants, and the hidden one asked for where their role may preview it;
897
+ // the team's repositories (each a JSON array); and the enabled link
898
+ // reports (linkRevision).
899
+ selectTeamSnapshotStmt: db.prepare(
900
+ `WITH viewer AS (
901
+ SELECT u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role
902
+ FROM managed_session s JOIN managed_user u ON u.id = s.user_id
903
+ WHERE s.id = ? AND s.expires_at > ?
904
+ )
905
+ SELECT v.uid AS uid, v.login AS login, v.name AS name, v.avatar AS avatar, v.role AS role,
906
+ EXISTS(SELECT 1 FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
907
+ WHERE tu.user_id = v.uid AND tu.team_id = ? AND t.hidden = 0) AS member,
908
+ (SELECT json_group_array(json_object('id', x.id, 'filename', x.filename, 'byteSize', x.byteSize, 'sha256', x.sha256,
909
+ 'visible', x.visible, 'repoDirectory', x.repoDirectory, 'repoFullName', x.repoFullName,
910
+ 'dependencies', x.dependencies, 'security', x.security) ORDER BY x.id)
911
+ FROM (SELECT DISTINCT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256, r.visible AS visible,
912
+ r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
913
+ tu.view_dependencies AS dependencies, tu.view_security AS security
914
+ FROM managed_team_user tu
915
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
916
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
917
+ JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
918
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
919
+ WHERE tu.user_id = v.uid AND tu.team_id = ? AND (r.visible = 1 OR (v.role IN ('admin', 'manage') AND r.id = ?))) AS x) AS reports,
920
+ (SELECT json_group_array(json_object('repoId', tr.repo_id, 'github', sr.full_name, 'path', tr.path) ORDER BY tr.repo_id, tr.path)
921
+ FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
922
+ WHERE tr.team_id = ?) AS repositories,
923
+ (SELECT json_group_array(json_object('id', id, 'sha256', sha256) ORDER BY id) FROM managed_link_report WHERE enabled = 1) AS links
924
+ FROM viewer v`,
791
925
  ),
792
926
  reportFilenamesWithBundleHashStmt: db.prepare(`SELECT DISTINCT filename FROM managed_report WHERE bundle_id = ? AND sha256 = ?`),
793
927
  deleteReportStmt: db.prepare(`DELETE FROM managed_report WHERE id = ?`),
@@ -838,6 +972,26 @@ function prepareStatements(db: ManagedSql) {
838
972
  WHERE t.finding_id IN (SELECT value FROM json_each(?))
839
973
  ORDER BY t.finding_id ASC`,
840
974
  ),
975
+ // A set of findings' triage (selectTriageStmt), comments (comments.ts
976
+ // listComments) and issues (managed-issues.ts listManagedIssues), each
977
+ // a JSON array of the rows those read, in their order, from one snapshot.
978
+ selectAnnotationsStmt: db.prepare(
979
+ `WITH requested AS (SELECT value AS id FROM json_each(?))
980
+ SELECT (SELECT json_group_array(json_object('findingId', t.finding_id, 'color', t.color, 'triage', t.triage, 'comment', t.comment,
981
+ 'fix', t.fix, 'flagged', t.flagged, 'updatedByLogin', COALESCE(u.login, t.updated_by_login), 'updatedAt', t.updated_at)
982
+ ORDER BY t.finding_id ASC)
983
+ FROM managed_finding_triage t LEFT JOIN managed_user u ON u.id = t.updated_by
984
+ WHERE t.finding_id IN (SELECT id FROM requested)) AS triage,
985
+ (SELECT json_group_array(json_object('id', c.id, 'findingId', c.finding_id, 'body', c.body, 'authorId', c.author_id,
986
+ 'authorLogin', COALESCE(u.login, c.author_login), 'createdAt', c.created_at, 'updatedAt', c.updated_at, 'version', c.version)
987
+ ORDER BY c.created_at NULLS FIRST, c.id)
988
+ FROM managed_finding_comment c LEFT JOIN managed_user u ON u.id = c.author_id
989
+ WHERE c.finding_id IN (SELECT id FROM requested)) AS comments,
990
+ (SELECT json_group_array(json_object('findingId', i.finding_id, 'repoId', i.repo_id, 'repository', i.repository, 'requestId', i.request_id,
991
+ 'issueUrl', i.issue_url, 'autoFixUrl', i.auto_fix_url, 'autoFixCheckedAt', i.auto_fix_checked_at,
992
+ 'createdBy', i.created_by, 'createdAt', i.created_at) ORDER BY i.finding_id)
993
+ FROM managed_finding_issue i WHERE i.finding_id IN (SELECT id FROM requested)) AS issues`,
994
+ ),
841
995
  selectTriageFixesStmt: db.prepare(`SELECT DISTINCT fix FROM managed_finding_triage
842
996
  WHERE finding_id IN (SELECT value FROM json_each(?)) AND fix IS NOT NULL ORDER BY fix`),
843
997
  annotationTriageRevisionStmt: db.prepare(`
@@ -939,51 +1093,39 @@ function prepareStatements(db: ManagedSql) {
939
1093
  renameTeamStmt: db.prepare(`UPDATE managed_team SET name = ?, updated_at = ? WHERE id = ?`),
940
1094
  setTeamHiddenStmt: db.prepare(`UPDATE managed_team SET hidden = ?, updated_at = ? WHERE id = ?`),
941
1095
  selectTeamsStmt: db.prepare(`SELECT id, slug, name, hidden FROM managed_team ORDER BY name ASC`),
942
- // `dependencyReview` where the user reviews dependencies in the team
943
- // (dependency-reviews.ts reviewsIn): one that reviews them, and a member
944
- // who manages, or triages and may see its dependencies; an admin in any.
945
- selectTeamsForUserStmt: db.prepare(
946
- `SELECT t.id AS id, t.slug AS slug, t.name AS name,
947
- CASE WHEN t.dependency_review = 1 AND (u.role IN ('admin', 'manage') OR (u.role = 'triage' AND tu.view_dependencies = 1))
948
- THEN 1 ELSE 0 END AS dependencyReview,
949
- tu.view_dependencies AS dependencies, tu.view_security AS security
950
- FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id JOIN managed_user u ON u.id = tu.user_id
951
- WHERE tu.user_id = ? AND t.hidden = 0 ORDER BY t.name ASC, t.id ASC`,
952
- ),
953
- selectUserTeamScopesStmt: db.prepare(
954
- `SELECT tr.team_id AS teamId, tr.repo_id AS repoId, tr.path AS path, sr.full_name AS fullName
955
- FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
956
- JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
957
- JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
958
- WHERE tu.user_id = ? ORDER BY tr.team_id, tr.repo_id, tr.path`,
959
- ),
960
- // Reports attached to the repos of the user's teams, tagged by team (a report
961
- // shows under every team whose repo it's attached to). Newest first.
962
- selectUserTeamReportsStmt: db.prepare(
963
- `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,
964
- r.sha256 AS sha256, r.bundle_id AS bundleId, r.repo_id AS repoId, r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
965
- tu.view_dependencies AS dependencies, tu.view_security AS security,
966
- (SELECT role FROM managed_user WHERE id = tu.user_id) AS role
967
- FROM managed_team_user tu
968
- JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
969
- JOIN managed_team_repo tr ON tr.team_id = tu.team_id
970
- JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
971
- LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
972
- WHERE tu.user_id = ? AND (r.visible = 1 OR (SELECT role FROM managed_user WHERE id = tu.user_id) IN ('admin', 'manage'))
973
- ORDER BY r.uploaded_at DESC, r.filename ASC, r.id ASC`,
974
- ),
975
- // Bundles are scoped through the same team -> repository links as reports.
976
- // Hidden bundles remain available to managers within their team scopes.
977
- selectUserTeamBundlesStmt: db.prepare(
978
- `SELECT DISTINCT tr.team_id AS teamId, b.id AS id, b.slug AS slug, b.integrity AS integrity,
979
- 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
980
- FROM managed_team_user tu
981
- JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
982
- JOIN managed_team_repo tr ON tr.team_id = tu.team_id
983
- JOIN managed_bundle b ON b.repo_id = tr.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
984
- JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
985
- WHERE tu.user_id = ? AND (b.visible = 1 OR (SELECT role FROM managed_user WHERE id = tu.user_id) IN ('admin', 'manage'))
986
- ORDER BY b.uploaded_at DESC, b.filename ASC, b.id ASC`,
1096
+ selectTeamsForUserStmt: db.prepare(`${USER_TEAMS_SQL('?')} ORDER BY t.name ASC, t.id ASC`),
1097
+ selectUserTeamScopesStmt: db.prepare(`${USER_TEAM_SCOPES_SQL('?')} ORDER BY tr.team_id, tr.repo_id, tr.path`),
1098
+ selectUserTeamReportsStmt: db.prepare(`${USER_TEAM_REPORTS_SQL('?')} ORDER BY r.uploaded_at DESC, r.filename ASC, r.id ASC`),
1099
+ selectUserTeamBundlesStmt: db.prepare(`${USER_TEAM_BUNDLES_SQL('?')} ORDER BY b.uploaded_at DESC, b.filename ASC, b.id ASC`),
1100
+ // getUserTeamFeedSnapshot's reads in one statement, so one snapshot of the
1101
+ // database answers them all: the session's user and the change revisions
1102
+ // (getFeedState), and listTeamsForUser's lists, each a JSON array in the
1103
+ // order those statements read it.
1104
+ selectUserTeamFeedStmt: db.prepare(
1105
+ `WITH viewer AS (
1106
+ SELECT u.id AS id, u.role AS role, r.catalog AS catalog, r.annotations AS annotations
1107
+ FROM managed_session s JOIN managed_user u ON u.id = s.user_id JOIN managed_change_revision r ON r.id = 1
1108
+ WHERE s.id = ? AND s.expires_at > ?
1109
+ )
1110
+ SELECT v.id AS id, v.role AS role, v.catalog AS catalog, v.annotations AS annotations,
1111
+ (SELECT json_group_array(json_object('id', x.id, 'slug', x.slug, 'name', x.name, 'dependencyReview', x.dependencyReview,
1112
+ 'dependencies', x.dependencies, 'security', x.security) ORDER BY x.name ASC, x.id ASC)
1113
+ FROM (${USER_TEAMS_SQL('v.id')}) AS x) AS teams,
1114
+ (SELECT json_group_array(json_object('teamId', x.teamId, 'repoId', x.repoId, 'path', x.path, 'fullName', x.fullName)
1115
+ ORDER BY x.teamId, x.repoId, x.path)
1116
+ FROM (${USER_TEAM_SCOPES_SQL('v.id')}) AS x) AS scopes,
1117
+ (SELECT json_group_array(json_object('id', id, 'sha256', sha256) ORDER BY id) FROM managed_link_report WHERE enabled = 1) AS links,
1118
+ (SELECT json_group_array(json_object('teamId', x.teamId, 'id', x.id, 'slug', x.slug, 'filename', x.filename, 'analyzer', x.analyzer,
1119
+ 'visible', x.visible, 'sha256', x.sha256, 'bundleId', x.bundleId, 'repoId', x.repoId, 'repoDirectory', x.repoDirectory,
1120
+ 'repoFullName', x.repoFullName, 'dependencies', x.dependencies, 'security', x.security, 'role', x.role)
1121
+ ORDER BY x.uploaded_at DESC, x.filename ASC, x.id ASC)
1122
+ FROM (${USER_TEAM_REPORTS_SQL('v.id')}) AS x) AS reports,
1123
+ (SELECT json_group_array(json_object('teamId', x.teamId, 'id', x.id, 'slug', x.slug, 'integrity', x.integrity, 'filename', x.filename,
1124
+ 'kind', x.kind, 'visible', x.visible, 'byteSize', x.byteSize, 'repoId', x.repoId, 'repoDirectory', x.repoDirectory,
1125
+ 'repoFullName', x.repoFullName, 'provenance', x.provenance)
1126
+ ORDER BY x.uploaded_at DESC, x.filename ASC, x.id ASC)
1127
+ FROM (${USER_TEAM_BUNDLES_SQL('v.id')}) AS x) AS bundles
1128
+ FROM viewer v`,
987
1129
  ),
988
1130
  selectUserRepoScopesStmt: db.prepare(
989
1131
  `SELECT DISTINCT tr.repo_id AS repoId, NULLIF(tr.path, '') AS path
@@ -999,6 +1141,14 @@ function prepareStatements(db: ManagedSql) {
999
1141
  JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
1000
1142
  WHERE r.id = ? AND tu.user_id = ? LIMIT 1`,
1001
1143
  ),
1144
+ selectReportViewStmt: db.prepare(
1145
+ `SELECT r.uploaded_by AS uploadedBy, r.visible AS visible,
1146
+ EXISTS(SELECT 1 FROM managed_team_repo tr
1147
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id
1148
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
1149
+ WHERE tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL} AND tu.user_id = ?) AS readable
1150
+ FROM managed_report r WHERE r.id = ?`,
1151
+ ),
1002
1152
  selectBundleSecurityStmt: db.prepare(
1003
1153
  `SELECT 1 FROM managed_bundle b
1004
1154
  JOIN managed_team_repo tr ON tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
@@ -1190,39 +1340,40 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1190
1340
  return await stmts.findingCatalogReportsStmt.all(after, limit) as Pick<ReportRecord, 'id' | 'byteSize'>[]
1191
1341
  },
1192
1342
  async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string, reportId: string | null = null): Promise<TeamReportAccessSnapshot | null> {
1193
- const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1343
+ const session = await stmts.selectTeamSnapshotStmt.get(sessionId, now, teamId, teamId, reportId, teamId) as
1344
+ SessionRow & { member: unknown; reports: unknown; repositories: unknown; links: unknown } | undefined
1194
1345
  if (!session) return null
1195
1346
  const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
1196
- if (user.role === 'none' || !await stmts.selectTeamAccessStmt.get(user.id, teamId)) return { user, teamId: null, reports: [], repositories: [] }
1347
+ if (user.role === 'none' || !session.member) return { user, teamId: null, reports: [], repositories: [] }
1197
1348
  const whole = user.role === 'admin' || user.role === 'manage'
1198
- const rows = await stmts.selectTeamReportAccessStmt.all(user.id, teamId, whole ? 1 : 0, reportId) as {
1349
+ const rows = jsonRows<{
1199
1350
  id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
1200
1351
  repoFullName: string | null; dependencies: number; security: number; visible: number
1201
- }[]
1352
+ }>(session.reports)
1202
1353
  // Only privileged callers can receive a hidden row from this query. Give
1203
1354
  // that individual preview its own capacity; published report selections
1204
1355
  // still use the complete workspace for cross-report classification.
1205
1356
  const hidden = rows.find(row => row.id === reportId && row.visible === 0)
1206
1357
  const selected = hidden ? [hidden] : rows
1207
- const repositories = await stmts.selectTeamRepositoriesStmt.all(teamId) as TeamReportAccessSnapshot['repositories']
1208
- return { user, teamId, reportId, hiddenReportId: hidden?.id ?? null, repositories, linkRevision: await linkRevision(db), reports: selected.map(row => ({
1358
+ const repositories = jsonRows<TeamReportAccessSnapshot['repositories'][number]>(session.repositories)
1359
+ return { user, teamId, reportId, hiddenReportId: hidden?.id ?? null, repositories, linkRevision: linkRevisionOf(jsonRows(session.links)), reports: selected.map(row => ({
1209
1360
  id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
1210
1361
  repo: { github: row.repoFullName, directory: row.repoDirectory },
1211
1362
  permissions: { dependencies: whole || row.dependencies === 1, security: whole || row.security === 1 },
1212
1363
  })) }
1213
1364
  },
1214
1365
  async getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null> {
1215
- const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1366
+ const session = await stmts.selectReportSnapshotStmt.get(sessionId, now, JSON.stringify([...new Set(ids)])) as
1367
+ SessionRow & { reports: unknown; links: unknown } | undefined
1216
1368
  if (!session) return null
1217
1369
  const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
1218
1370
  if (user.role === 'none' || ids.length === 0) return { user, reports: [] }
1219
1371
  const admin = user.role === 'admin', manage = user.role === 'manage'
1220
- const rows = await stmts.selectReportAccessStmt.all(JSON.stringify([...new Set(ids)]), user.id,
1221
- admin ? 1 : 0, manage ? 1 : 0, user.id, manage ? 1 : 0) as {
1372
+ const rows = jsonRows<{
1222
1373
  id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
1223
1374
  repoFullName: string | null; dependencies: number; security: number
1224
- }[]
1225
- return { user, linkRevision: await linkRevision(db), reports: rows.map(row => ({
1375
+ }>(session.reports)
1376
+ return { user, linkRevision: linkRevisionOf(jsonRows(session.links)), reports: rows.map(row => ({
1226
1377
  id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
1227
1378
  repo: { github: row.repoFullName, directory: row.repoDirectory },
1228
1379
  permissions: { dependencies: admin || manage || row.dependencies === 1, security: admin || manage || row.security === 1 },
@@ -1249,6 +1400,11 @@ type TriageDbRow = {
1249
1400
  comment: string | null; fix: string | null; flagged: number | null
1250
1401
  updatedByLogin: string | null; updatedAt: number
1251
1402
  }
1403
+ const triageRow = (r: TriageDbRow): TriageRow => ({
1404
+ findingId: r.findingId, color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1405
+ flagged: r.flagged == null ? null : r.flagged === 1,
1406
+ updatedByLogin: r.updatedByLogin, updatedAt: r.updatedAt,
1407
+ })
1252
1408
  type TriageStateDbRow = { color: string | null; triage: string | null; comment: string | null; fix: string | null; flagged: number | null }
1253
1409
  type TriageEventDbRow = TriageStateDbRow & {
1254
1410
  seq: number; findingId: string; batchId: string; actorId: string | null; actorLogin: string | null; actorName: string | null; at: number
@@ -1318,12 +1474,7 @@ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit
1318
1474
  },
1319
1475
  async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
1320
1476
  if (findingIds.length === 0) return []
1321
- const rows = (await selectTriageStmt.all(JSON.stringify(findingIds))) as TriageDbRow[]
1322
- return rows.map((r) => ({
1323
- findingId: r.findingId, color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1324
- flagged: r.flagged == null ? null : r.flagged === 1,
1325
- updatedByLogin: r.updatedByLogin, updatedAt: r.updatedAt,
1326
- }))
1477
+ return ((await selectTriageStmt.all(JSON.stringify(findingIds))) as TriageDbRow[]).map(triageRow)
1327
1478
  },
1328
1479
  async setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void> {
1329
1480
  // A single write is its own batch. The row and its event go together.
@@ -1525,40 +1676,8 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1525
1676
  return (await selectUserRepoScopesStmt.all(userId)) as { repoId: number; path: string | null }[]
1526
1677
  },
1527
1678
  async listTeamsForUser(userId: string): Promise<UserTeam[]> {
1528
- const teams = (await selectTeamsForUserStmt.all(userId)) as { id: string; slug: string; name: string; dependencyReview: number; dependencies: number; security: number }[]
1529
- const scopes = await stmts.selectUserTeamScopesStmt.all(userId) as { teamId: string; repoId: number; path: string; fullName: string }[]
1530
- const links = await linkRevision(db)
1531
- const reportsByTeam = new Map<string, UserTeamReport[]>()
1532
- for (const r of (await selectUserTeamReportsStmt.all(userId)) as {
1533
- teamId: string; id: string; slug: string; filename: string; analyzer: string | null; sha256: string; bundleId: string | null; repoId: number
1534
- repoDirectory: string; repoFullName: string | null; visible: number; dependencies: number; security: number; role: string
1535
- }[]) {
1536
- const list = reportsByTeam.get(r.teamId) ?? []
1537
- const cacheKey = createHash('sha256').update(JSON.stringify([
1538
- r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role, links,
1539
- ])).digest('base64url')
1540
- 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 })
1541
- reportsByTeam.set(r.teamId, list)
1542
- }
1543
- const bundlesByTeam = new Map<string, UserTeamBundle[]>()
1544
- for (const b of (await selectUserTeamBundlesStmt.all(userId)) as (Omit<UserTeamBundle, 'visible'> & { teamId: string; visible: number })[]) {
1545
- const list = bundlesByTeam.get(b.teamId) ?? []
1546
- list.push({ id: b.id, slug: b.slug, integrity: b.integrity, filename: b.filename, visible: b.visible === 1,
1547
- kind: b.kind, byteSize: b.byteSize, repoId: b.repoId, repoDirectory: b.repoDirectory, repoFullName: b.repoFullName, provenance: b.provenance })
1548
- bundlesByTeam.set(b.teamId, list)
1549
- }
1550
- return teams.map((t) => ({
1551
- id: t.id, slug: t.slug, name: t.name,
1552
- ...(t.dependencyReview === 1 ? { dependencyReview: true } : {}),
1553
- permissions: { dependencies: t.dependencies === 1, security: t.security === 1 },
1554
- cacheKey: createHash('sha256').update(JSON.stringify([
1555
- t.dependencies, t.security, links, scopes.filter(scope => scope.teamId === t.id),
1556
- ])).digest('base64url'),
1557
- repos: scopes.filter(scope => scope.teamId === t.id).map(scope => ({ repoId: scope.repoId, fullName: scope.fullName, path: scope.path || null }))
1558
- .toSorted((a, b) => a.fullName.localeCompare(b.fullName) || (a.path ?? '').localeCompare(b.path ?? '')),
1559
- reports: reportsByTeam.get(t.id) ?? [],
1560
- bundles: bundlesByTeam.get(t.id) ?? [],
1561
- }))
1679
+ return userTeams(await selectTeamsForUserStmt.all(userId) as UserTeamRows['teams'], await stmts.selectUserTeamScopesStmt.all(userId) as UserTeamRows['scopes'],
1680
+ await linkRevision(db), await selectUserTeamReportsStmt.all(userId) as UserTeamRows['reports'], await selectUserTeamBundlesStmt.all(userId) as UserTeamRows['bundles'])
1562
1681
  },
1563
1682
  async getFeedState(sessionId: string, now: number) {
1564
1683
  const row = await db.prepare(`SELECT u.id, u.role, r.catalog, r.annotations FROM managed_session s
@@ -1567,16 +1686,22 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1567
1686
  return row ? { user: { id: row.id, role: row.role }, catalog: row.catalog, annotations: row.annotations } : null
1568
1687
  },
1569
1688
  async getUserTeamFeedSnapshot(sessionId: string, now: number) {
1570
- // One short read-only transaction, shared by SQLite and Postgres. Never
1571
- // touch last-seen timestamps or include other users' memberships.
1572
- const state = await methods.getFeedState(sessionId, now)
1573
- if (!state) return null
1574
- const teams = state.user.role === 'none' ? [] : await methods.listTeamsForUser(state.user.id)
1575
- return { user: state.user, catalog: state.catalog, teams, revision: teamCatalogRevision(teams) }
1689
+ // One statement, shared by SQLite and Postgres. Never touch last-seen
1690
+ // timestamps or include other users' memberships.
1691
+ const row = await stmts.selectUserTeamFeedStmt.get(sessionId, now) as {
1692
+ id: string; role: Role; catalog: number; annotations: number; teams: unknown; scopes: unknown; links: unknown; reports: unknown; bundles: unknown
1693
+ } | undefined
1694
+ if (!row) return null
1695
+ const teams = row.role === 'none' ? [] : userTeams(jsonRows(row.teams), jsonRows(row.scopes), linkRevisionOf(jsonRows(row.links)), jsonRows(row.reports), jsonRows(row.bundles))
1696
+ return { user: { id: row.id, role: row.role }, catalog: row.catalog, teams, revision: teamCatalogRevision(teams) }
1576
1697
  },
1577
1698
  async userCanReadReport(userId: string, reportId: string): Promise<boolean> {
1578
1699
  return (await selectReportReadableStmt.get(reportId, userId)) != null
1579
1700
  },
1701
+ async getReportView(userId: string, reportId: string) {
1702
+ const row = await stmts.selectReportViewStmt.get(userId, reportId) as { uploadedBy: string | null; visible: number; readable: unknown } | undefined
1703
+ return row ? { uploadedBy: row.uploadedBy, visible: row.visible === 1, readable: Boolean(row.readable) } : null
1704
+ },
1580
1705
  async userCanReadBundleAdvisories(userId: string, bundleId: string, teamId: string | null): Promise<boolean> {
1581
1706
  return (await stmts.selectBundleSecurityStmt.get(bundleId, userId, teamId, teamId)) != null
1582
1707
  },
@@ -1641,10 +1766,16 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1641
1766
  const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0, db)
1642
1767
  const {
1643
1768
  upsertUserStmt, selectLinkedStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectOwnSessionStmt, selectUsersStmt,
1644
- touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, ensureUploadKeyStmt, deleteSessionStmt, deleteExpiredStmt,
1769
+ selectUserStmt, touchOwnSessionStmt, touchUserSeenStmt, updateRoleStmt, updateTokensStmt, ensureUploadKeyStmt, deleteSessionStmt, deleteExpiredStmt,
1645
1770
  selectViewSessionStmt, deleteOtherViewsStmt, insertViewSessionStmt, deleteDemotedViewsStmt,
1646
1771
  } = stmts
1647
1772
 
1773
+ // Claimed at every automatic reap: prepared once.
1774
+ const claimLeaseStmt = db.prepare(`INSERT INTO managed_maintenance_lease (id, owner, expires_at) VALUES (?, ?, ?)
1775
+ ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
1776
+ WHERE managed_maintenance_lease.expires_at <= ? RETURNING id`)
1777
+ const finishLeaseStmt = db.prepare('UPDATE managed_maintenance_lease SET expires_at = ? WHERE owner = ?')
1778
+
1648
1779
  const methods: Omit<ManagedDb, keyof ManagementStore | keyof ManagementCatalogStore> = {
1649
1780
  ...storageMethods(db, key),
1650
1781
  ...linkReportMethods(db, key),
@@ -1656,12 +1787,10 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1656
1787
  ...bundleBuildLeaseMethods(db),
1657
1788
  ...upstreamCacheMethods(db),
1658
1789
  async claimMaintenanceLease(owner, now, until, migration = false) {
1659
- return !!await db.prepare(`INSERT INTO managed_maintenance_lease (id, owner, expires_at) VALUES (?, ?, ?)
1660
- ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
1661
- WHERE managed_maintenance_lease.expires_at <= ? RETURNING id`).get(migration ? 2 : 1, owner, until, now)
1790
+ return !!await claimLeaseStmt.get(migration ? 2 : 1, owner, until, now)
1662
1791
  },
1663
1792
  async finishMaintenanceLease(owner, until) {
1664
- await db.prepare('UPDATE managed_maintenance_lease SET expires_at = ? WHERE owner = ?').run(until, owner)
1793
+ await finishLeaseStmt.run(until, owner)
1665
1794
  },
1666
1795
  async upsertUser(user, now, initialAdminGithubId = null) {
1667
1796
  if (await selectLinkedStmt.get(user.githubUserId)) return null
@@ -1682,10 +1811,8 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1682
1811
  await touchUserSeenStmt.run(now, session.userId, now)
1683
1812
  },
1684
1813
  async sessionWithUser(id, now) {
1685
- const row = (await selectOwnSessionStmt.get(id, now)) as SessionRow | undefined
1686
- if (row == null) return null
1687
- await touchUserSeenStmt.run(now, row.uid, now)
1688
- return ownSession(row)
1814
+ const row = (await touchOwnSessionStmt.get(now, now, id, now, id, id, id, id)) as SessionRow | undefined
1815
+ return row == null ? null : ownSession(row)
1689
1816
  },
1690
1817
  async getSessionWithUser(id, now) {
1691
1818
  const row = (await selectOwnSessionStmt.get(id, now)) as SessionRow | undefined
@@ -1720,6 +1847,10 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1720
1847
  const rows = (await selectUsersStmt.all()) as UserRow[]
1721
1848
  return rows.map((r) => ({ id: r.id, login: r.login, name: r.name, role: r.role, createdAt: r.created, lastSeenAt: r.lastSeen, lastActivityAt: r.lastActivity }))
1722
1849
  },
1850
+ async getUser(id) {
1851
+ const row = (await selectUserStmt.get(id)) as StoredUser | undefined
1852
+ return row == null ? null : { id: row.id, login: row.login, name: row.name, avatarUrl: row.avatarUrl, role: row.role }
1853
+ },
1723
1854
  async setUserRole(id, role) {
1724
1855
  if (role !== 'admin') await deleteDemotedViewsStmt.run(id)
1725
1856
  return Number((await updateRoleStmt.run(role, Date.now(), id)).changes) > 0
@@ -1730,8 +1861,11 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1730
1861
  await updateTokensStmt.run(value.access, value.refresh, tokens.expiresAt, state ? 1 : 0, Date.now(), id)
1731
1862
  },
1732
1863
  async getUserTokens(id) {
1733
- const state = await storageState(db, key)
1734
- const row = (await selectTokensStmt.get(id)) as StoredTokens | undefined
1864
+ // The tokens and the storage encryption state they were kept under, in
1865
+ // one read: a user missing reads as no tokens, no encryption row as none.
1866
+ const read = (await stmts.selectTokensStateStmt.get(id)) as StoredTokens & Parameters<typeof storageStateOf>[0] & { userId: string | null; stateId: number | null }
1867
+ const state = storageStateOf(read.stateId == null ? undefined : read, key)
1868
+ const row = read.userId == null ? undefined : read
1735
1869
  if (row == null || row.access == null) return null
1736
1870
  if (row.encrypted && !state) throw new Error('Encrypted tokens without storage encryption state')
1737
1871
  if (!row.encrypted && state?.complete) throw new Error('Unexpected plaintext GitHub tokens')
@@ -1749,7 +1883,10 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1749
1883
  ...activity,
1750
1884
  ...comments,
1751
1885
  async getAnnotations(ids) {
1752
- return { triage: await triage.listTriage(ids), comments: await comments.listComments(ids), issues: await methods.listManagedIssues(ids) }
1886
+ // listTriage, listComments and listManagedIssues in one statement.
1887
+ const row = await stmts.selectAnnotationsStmt.get(JSON.stringify(ids)) as { triage: unknown; comments: unknown; issues: unknown }
1888
+ return { triage: ids.length > 0 ? jsonRows<TriageDbRow>(row.triage).map(triageRow) : [], comments: ids.length > 0 ? jsonRows<Awaited<ReturnType<CommentStore['listComments']>>[number]>(row.comments) : [],
1889
+ issues: jsonRows<ManagedIssue>(row.issues) }
1753
1890
  },
1754
1891
  async applyManagedIssueFixes(sessionId, snapshot, updates) {
1755
1892
  // Recheck access inside the same write transaction as the derived field.