@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.40

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 (311) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2KBF5TZ7.js +2 -0
  72. package/out/chunk-2XUEZHIP.js +28 -0
  73. package/out/chunk-35VE6CDH.js +2 -0
  74. package/out/chunk-3BZ7JERB.js +1 -0
  75. package/out/chunk-4F6JSF57.js +3 -0
  76. package/out/chunk-4XCDFGDP.js +10 -0
  77. package/out/chunk-AQ75QITE.js +1 -0
  78. package/out/chunk-B2RQY6OP.js +2 -0
  79. package/out/chunk-C4Z6ZBXV.js +10 -0
  80. package/out/chunk-CMQA4DM6.js +1 -0
  81. package/out/chunk-CVVJAXVY.js +2 -0
  82. package/out/chunk-DIJJNTRH.js +10 -0
  83. package/out/chunk-GJ4IUCNS.js +1 -0
  84. package/out/chunk-GNXOKIGP.js +80 -0
  85. package/out/chunk-GZ4MS3TE.js +13 -0
  86. package/out/chunk-IISEF2BZ.js +1 -0
  87. package/out/chunk-IU3CCINR.js +3 -0
  88. package/out/chunk-K3X5EJBQ.js +2 -0
  89. package/out/chunk-KEX2ATEW.js +2 -0
  90. package/out/chunk-KYCNJVB7.js +1 -0
  91. package/out/chunk-MYDDTA7W.js +225 -0
  92. package/out/chunk-NRPC74AM.js +1 -0
  93. package/out/chunk-OOJM4CTU.js +1 -0
  94. package/out/chunk-PNHBZGDD.js +1 -0
  95. package/out/chunk-R5O6FAW4.js +2 -0
  96. package/out/chunk-RFZQQXSG.js +17 -0
  97. package/out/chunk-RSHMVYNS.js +1 -0
  98. package/out/chunk-TGANXVTA.js +1 -0
  99. package/out/chunk-TUII34K2.js +1 -0
  100. package/out/chunk-TZ5KB2Y5.js +10 -0
  101. package/out/chunk-UN5ZSYLE.js +1 -0
  102. package/out/chunk-UNTRGE26.js +2 -0
  103. package/out/chunk-UXNUAIIC.js +33 -0
  104. package/out/chunk-UYAXVRVL.js +1 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XYDGU6BM.js +51 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +297 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +358 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +142 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +219 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/specs.js +124 -0
  154. package/risk/npm/tarball.js +113 -0
  155. package/server-common/database-config.ts +16 -0
  156. package/server-common/initialize.ts +18 -0
  157. package/server-common/npm-advisories.ts +101 -0
  158. package/{server → server-common}/origin.ts +5 -5
  159. package/server-common/reap.ts +48 -0
  160. package/server-common/scan-config.ts +19 -0
  161. package/server-common/standalone.ts +29 -0
  162. package/server-common/storage-crypto.ts +182 -0
  163. package/server-common/storage-log.ts +38 -0
  164. package/server-common/vercel-blob.ts +103 -0
  165. package/server-e2e/app.ts +486 -0
  166. package/{server → server-e2e}/auth.ts +5 -1
  167. package/{server → server-e2e}/bus-receiver.ts +2 -1
  168. package/{server → server-e2e}/cli.js +9 -4
  169. package/{server → server-e2e}/config.ts +56 -35
  170. package/{server → server-e2e}/db-neon.ts +2 -2
  171. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  172. package/{server → server-e2e}/db-stmt.ts +2 -2
  173. package/{server → server-e2e}/db.ts +7 -7
  174. package/{server → server-e2e}/http.ts +116 -18
  175. package/{server → server-e2e}/hub.ts +40 -9
  176. package/server-e2e/index.ts +17 -0
  177. package/server-e2e/lifecycle.ts +95 -0
  178. package/{server → server-e2e}/neon-driver.ts +2 -2
  179. package/{server → server-e2e}/npm-proxy.ts +12 -145
  180. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  181. package/{server → server-e2e}/objstore/blob.ts +1 -1
  182. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  183. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  184. package/{server → server-e2e}/objstore/init.ts +35 -18
  185. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  186. package/server-e2e/objstore/rest-deny.ts +28 -0
  187. package/server-e2e/objstore/rest-mint.ts +224 -0
  188. package/{server → server-e2e}/objstore/rest.ts +27 -26
  189. package/{server → server-e2e}/objstore/sign.ts +105 -0
  190. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  191. package/{server → server-e2e}/objstore/store.ts +7 -7
  192. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  193. package/{server → server-e2e}/pubsub.ts +12 -9
  194. package/{server → server-e2e}/sign.ts +4 -4
  195. package/{server → server-e2e}/sse-server.ts +102 -68
  196. package/{server → server-e2e}/sse-session.ts +28 -8
  197. package/{server → server-e2e}/static.ts +31 -21
  198. package/server-e2e/sync-handlers.ts +408 -0
  199. package/{server → server-e2e}/ws-server.ts +20 -9
  200. package/server-managed/activity.ts +245 -0
  201. package/server-managed/avatar-store.ts +12 -0
  202. package/server-managed/blob-store.ts +17 -0
  203. package/server-managed/brotli.ts +10 -0
  204. package/server-managed/bundle-advisories.ts +120 -0
  205. package/server-managed/bundle-advisory-inventory.ts +77 -0
  206. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  207. package/server-managed/bundle-build-diagnostics.js +23 -0
  208. package/server-managed/bundle-build-leases.ts +41 -0
  209. package/server-managed/bundle-build-worker.js +111 -0
  210. package/server-managed/bundle-build.ts +198 -0
  211. package/server-managed/bundle-cache.ts +199 -0
  212. package/server-managed/bundle-catalog.ts +29 -0
  213. package/server-managed/bundle-commits.ts +140 -0
  214. package/server-managed/bundle-store.ts +24 -0
  215. package/server-managed/bundle-summary-cache.ts +130 -0
  216. package/server-managed/bundle.ts +120 -0
  217. package/server-managed/cache-storage.ts +19 -0
  218. package/server-managed/cli.js +21 -0
  219. package/server-managed/combined.ts +46 -0
  220. package/server-managed/comments.ts +151 -0
  221. package/server-managed/config.ts +158 -0
  222. package/server-managed/content-access.ts +15 -0
  223. package/server-managed/crypto.ts +25 -0
  224. package/server-managed/db-methods.ts +1725 -0
  225. package/server-managed/db-neon.ts +367 -0
  226. package/server-managed/db-schema.ts +227 -0
  227. package/server-managed/db-table-names.ts +22 -0
  228. package/server-managed/db.ts +132 -0
  229. package/server-managed/dependency-reviews.ts +248 -0
  230. package/server-managed/github-app.ts +391 -0
  231. package/server-managed/github-errors.ts +68 -0
  232. package/server-managed/github-issue-links.ts +127 -0
  233. package/server-managed/github-issue-oauth.ts +47 -0
  234. package/server-managed/github-issues.ts +152 -0
  235. package/server-managed/github-metadata.ts +213 -0
  236. package/server-managed/github-oauth.ts +235 -0
  237. package/server-managed/github-pulls.ts +227 -0
  238. package/server-managed/http-response.ts +50 -0
  239. package/server-managed/http.ts +3472 -0
  240. package/server-managed/import-triage.ts +51 -0
  241. package/server-managed/index.ts +187 -0
  242. package/server-managed/kept.ts +28 -0
  243. package/server-managed/link-reports.ts +158 -0
  244. package/server-managed/locked-packages.ts +85 -0
  245. package/server-managed/lru.ts +30 -0
  246. package/server-managed/managed-issues.ts +75 -0
  247. package/server-managed/management-catalog.ts +45 -0
  248. package/server-managed/management.ts +99 -0
  249. package/server-managed/npm-insights.ts +440 -0
  250. package/server-managed/npm-kept.ts +65 -0
  251. package/server-managed/npm-loads.ts +167 -0
  252. package/server-managed/npm-manifest.ts +115 -0
  253. package/server-managed/npm-packages.ts +378 -0
  254. package/server-managed/npm-provenance.ts +123 -0
  255. package/server-managed/npm-risk.ts +177 -0
  256. package/server-managed/object-storage-disk.ts +148 -0
  257. package/server-managed/object-storage-vercel.ts +67 -0
  258. package/server-managed/object-storage.ts +74 -0
  259. package/server-managed/package-entry-points.ts +51 -0
  260. package/server-managed/pretty-print.ts +352 -0
  261. package/server-managed/public-workspace.ts +220 -0
  262. package/server-managed/rename-diff-worker.js +26 -0
  263. package/server-managed/rename-diff.ts +270 -0
  264. package/server-managed/repo-path.ts +21 -0
  265. package/server-managed/report-migration.ts +35 -0
  266. package/server-managed/report-query.ts +10 -0
  267. package/server-managed/report-response.ts +16 -0
  268. package/server-managed/report-sources.ts +178 -0
  269. package/server-managed/report-store.ts +47 -0
  270. package/server-managed/repository-aliases.ts +91 -0
  271. package/server-managed/repository-browser.ts +153 -0
  272. package/server-managed/repository-discovery.ts +82 -0
  273. package/server-managed/repository-policy.ts +27 -0
  274. package/server-managed/repository-risk.ts +418 -0
  275. package/server-managed/revisions.ts +35 -0
  276. package/server-managed/session.ts +92 -0
  277. package/server-managed/slugs.ts +38 -0
  278. package/server-managed/solidity-entry-points.ts +69 -0
  279. package/server-managed/sql-postgres.ts +31 -0
  280. package/server-managed/sql.ts +76 -0
  281. package/server-managed/static.ts +28 -0
  282. package/server-managed/storage-command.ts +16 -0
  283. package/server-managed/storage-db.ts +175 -0
  284. package/server-managed/storage-encryption.ts +279 -0
  285. package/server-managed/storage-maintenance.ts +256 -0
  286. package/server-managed/storage-payload.ts +103 -0
  287. package/server-managed/storage-stores.ts +85 -0
  288. package/server-managed/storage.ts +78 -0
  289. package/server-managed/team-app.ts +94 -0
  290. package/server-managed/team-catalog.ts +7 -0
  291. package/server-managed/team-feed.ts +173 -0
  292. package/server-managed/team-npm-scopes.ts +77 -0
  293. package/server-managed/team-reports.ts +228 -0
  294. package/server-managed/triage-response.ts +16 -0
  295. package/server-managed/type-only-worker.js +19 -0
  296. package/server-managed/type-only.ts +103 -0
  297. package/server-managed/uploads.ts +86 -0
  298. package/server-managed/upstream-cache.ts +215 -0
  299. package/server-managed/view-as.ts +58 -0
  300. package/server-managed/workspace-shares.ts +166 -0
  301. package/server.ts +50 -0
  302. package/server/index.ts +0 -477
  303. package/server/lifecycle.ts +0 -204
  304. package/server/sync-handlers.ts +0 -320
  305. /package/{LICENSE → LICENSE-MIT} +0 -0
  306. /package/{server → server-e2e}/config.example.json +0 -0
  307. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  308. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  309. /package/{server → server-e2e}/peer.ts +0 -0
  310. /package/{server → server-e2e}/util.ts +0 -0
  311. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,1725 @@
1
+ import { type LinkReportStore, linkReportMethods, linkRevision } from './link-reports.ts'
2
+ import { type RepositoryAliasStore, repositoryAliasMethods } from './repository-aliases.ts'
3
+ import { type DependencyReviewStore, dependencyReviewMethods } from './dependency-reviews.ts'
4
+ import { type TeamNpmScopeStore, teamNpmScopeMethods } from './team-npm-scopes.ts'
5
+ import { createHash, randomUUID } from 'node:crypto'
6
+ import { teamCatalogRevision } from './team-catalog.ts'
7
+ import { type Role, roleAtLeast } from '../common/managed/roles.ts'
8
+ import type { TeamUserPermissions } from '../common/managed/permissions.ts'
9
+ import type { TriageEntryPatch } from '../common/managed/triage.ts'
10
+ import { preferredSlug } from './slugs.ts'
11
+ import { type CommentStore, commentMethods } from './comments.ts'
12
+ import { type ActivityStore, activityMethods } from './activity.ts'
13
+ import { type GithubCommitStore, type GithubMetadataStore, githubCommitMethods, githubMetadataMethods } from './github-metadata.ts'
14
+ import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
15
+ import type { IssueFixUpdate } from './github-issue-links.ts'
16
+ import { type BundleBuildLeaseStore, bundleBuildLeaseMethods } from './bundle-build-leases.ts'
17
+ import { type UpstreamCacheStore, upstreamCacheMethods } from './upstream-cache.ts'
18
+ import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
19
+ import type { ManagedSql } from './sql.ts'
20
+ import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
21
+ import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
22
+ import { type ManagementCatalogStore, managementCatalogMethods } from './management-catalog.ts'
23
+ import { type StorageKey } from '../server-common/storage-crypto.ts'
24
+ import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, validateStorageUpload } from './storage-db.ts'
25
+
26
+ // A managed user identity (the subset of GitHub's `GET /user` we keep). Input
27
+ // to the upsert; `githubUserId` is the provider lookup key, never exposed to
28
+ // clients.
29
+ export interface ManagedUser {
30
+ githubUserId: number
31
+ login: string
32
+ name: string | null
33
+ avatarUrl: string | null
34
+ }
35
+
36
+ // A persisted user as read back from a session — identified by the opaque `id`;
37
+ // the GitHub id stays server-internal.
38
+ export interface StoredUser {
39
+ id: string
40
+ login: string
41
+ name: string | null
42
+ avatarUrl: string | null
43
+ role: Role
44
+ }
45
+
46
+ // A user row for the admin users list.
47
+ export interface AdminUser {
48
+ id: string
49
+ login: string
50
+ name: string | null
51
+ role: Role
52
+ createdAt: number
53
+ lastSeenAt: number | null
54
+ lastActivityAt: number | null
55
+ }
56
+
57
+ // A user's persisted GitHub user-to-server token. `refreshToken` / `expiresAt`
58
+ // are null when the App issues non-expiring tokens.
59
+ export interface UserTokens {
60
+ accessToken: string
61
+ refreshToken: string | null
62
+ expiresAt: number | null
63
+ }
64
+
65
+ export interface ManagedSession {
66
+ id: string
67
+ userId: string
68
+ csrfToken: string
69
+ expiresAt: number
70
+ // Private JWK for sealed uploads; null until the session first asks for it.
71
+ uploadKey?: string | null
72
+ // Set on an administrator's read-only view as `userId` (see view-as.ts).
73
+ viewer?: ManagedViewer
74
+ }
75
+
76
+ export interface ManagedViewer {
77
+ id: string
78
+ login: string
79
+ name: string | null
80
+ }
81
+
82
+ // A repository selected for the workspace to operate on, with the context to
83
+ // read its contents: `installationId` mints an App installation token (Contents:
84
+ // Read) for a PRIVATE repo — null for a PUBLIC repo readable without the App —
85
+ // and `fullName` + `defaultBranch` locate the contents
86
+ // (GET /repos/{fullName}/contents?ref={defaultBranch}).
87
+ export interface SelectedRepo {
88
+ repoId: number
89
+ fullName: string
90
+ private: boolean
91
+ installationId: number | null
92
+ defaultBranch: string
93
+ // Last live default observed by the repository browser, independent of the
94
+ // selection/discovery snapshot above. NULL means unknown or unavailable.
95
+ cachedDefaultBranch: string | null
96
+ htmlUrl: string
97
+ addedBy: string | null
98
+ addedAt: number
99
+ }
100
+
101
+ // The admin repository directory includes inactive connections as well. Their
102
+ // metadata remains available for existing reports/bundles, while only active
103
+ // rows are returned by listSelectedRepos for new scans and attachments.
104
+ export interface ManagedRepo extends SelectedRepo {
105
+ active: boolean
106
+ }
107
+
108
+ // What a caller supplies to select (upsert) a repo; the store stamps the
109
+ // timestamps.
110
+ export type SelectedRepoInput = Omit<SelectedRepo, 'addedAt' | 'cachedDefaultBranch'>
111
+
112
+ // A stored report's metadata. The bytes live in the blob-store keyed by `id`;
113
+ // `contentType` + `filename` ride here so a download can label them, `sha256`
114
+ // (base64url) is the content hash for integrity, `uploadedBy` is the opaque
115
+ // uploader id (null once that user is removed).
116
+ export interface ReportRecord {
117
+ id: string
118
+ slug: string
119
+ filename: string
120
+ contentType: string
121
+ byteSize: number
122
+ sha256: string
123
+ uploadedBy: string | null
124
+ uploadedAt: number
125
+ repoId: number | null
126
+ repoDirectory: string
127
+ repoEmbedded: boolean
128
+ analyzer: string | null
129
+ visible: boolean
130
+ bundleId: string | null
131
+ }
132
+
133
+ // One transaction snapshots all metadata needed to authorize, filter and
134
+ // revalidate a report batch. Unauthorized/missing reports are omitted.
135
+ export interface ReportAccessRecord {
136
+ id: string
137
+ filename: string
138
+ byteSize: number
139
+ sha256: string
140
+ repo: { github: string | null; directory: string }
141
+ permissions: TeamUserPermissions
142
+ }
143
+ export interface TeamReportAccessSnapshot extends ReportAccessSnapshot {
144
+ teamId: string | null
145
+ reportId?: string | null
146
+ hiddenReportId?: string | null
147
+ repositories: { repoId: number; github: string; path: string | null }[]
148
+ }
149
+ export interface ReportAccessSnapshot {
150
+ user: StoredUser
151
+ linkRevision?: string
152
+ reports: ReportAccessRecord[]
153
+ }
154
+
155
+ // What the upload handler supplies to record a report; the store stamps
156
+ // uploaded_at. `repoId` / `bundleId` are the (nullable) repo + auto-resolved
157
+ // bundle links; `bundleIntegrity` is the report's declared primary bundle (kept
158
+ // so a later bundle upload of that integrity re-links it).
159
+ export interface ReportRecordInput {
160
+ dataKey?: string | null
161
+ id: string
162
+ filename: string
163
+ contentType: string
164
+ byteSize: number
165
+ sha256: string
166
+ uploadedBy: string | null
167
+ uploadedByLogin: string | null
168
+ repoId: number | null
169
+ repoDirectory: string
170
+ repoEmbedded?: boolean
171
+ analyzer: string | null
172
+ visible: boolean
173
+ bundleId: string | null
174
+ bundleIntegrity: string | null
175
+ }
176
+
177
+ // A report row for the "Manage reports" list — adds display joins: uploader
178
+ // login, repo full name, and the linked bundle's filename (each null when
179
+ // absent / since removed).
180
+ export interface AdminReport {
181
+ id: string
182
+ slug: string
183
+ filename: string
184
+ contentType: string
185
+ byteSize: number
186
+ sha256: string
187
+ uploadedByLogin: string | null
188
+ repoId: number | null
189
+ repoFullName: string | null
190
+ repoDirectory: string
191
+ repoEmbedded: boolean
192
+ analyzer: string | null
193
+ visible: boolean
194
+ bundleId: string | null
195
+ bundleFilename: string | null
196
+ bundleIntegrity: string | null
197
+ uploadedAt: number
198
+ }
199
+
200
+ export interface RepoDataItem {
201
+ id: string
202
+ filename: string
203
+ }
204
+
205
+ export type RepoReportItem = Pick<ReportRecord, 'id' | 'filename' | 'sha256' | 'bundleId'>
206
+
207
+ // A stored per-finding triage row, with the last writer's login resolved like
208
+ // listReports (live login, falling back to the durable snapshot). `flagged`
209
+ // maps the tri-state column: null unset, true/false set. Every field null =
210
+ // the tombstone of a cleared entry.
211
+ export interface TriageRow {
212
+ findingId: string
213
+ color: string | null
214
+ triage: string | null
215
+ comment: string | null
216
+ fix: string | null
217
+ flagged: boolean | null
218
+ updatedByLogin: string | null
219
+ updatedAt: number
220
+ }
221
+
222
+ // One row of a finding's triage trail: the entry as written by that event
223
+ // (every field null = a clear), the actor's login resolved like TriageRow's
224
+ // writer, and the request it came in with.
225
+ export interface TriageEventRow {
226
+ seq: number
227
+ findingId: string
228
+ batchId: string
229
+ color: string | null
230
+ triage: string | null
231
+ comment: string | null
232
+ fix: string | null
233
+ flagged: boolean | null
234
+ actorId: string | null
235
+ actorLogin: string | null
236
+ actorName: string | null
237
+ at: number
238
+ }
239
+
240
+ // How a bundle's bytes arrived: uploaded, or generated by this server from a
241
+ // repository. null predates the distinction (possibly built).
242
+ export type BundleProvenance = 'upload' | 'build'
243
+
244
+ // The preset, export conditions and Metro platforms a server build of a JS/TS
245
+ // bundle resolved with, as the build request named them. Stored as JSON.
246
+ export interface BundleBuildConditions { preset: 'node' | 'browser' | 'metro'; conditions: string[]; platforms: string[] }
247
+
248
+ // A stored bundle's metadata. Bytes live in the blob-store keyed by `id`;
249
+ // `integrity` (sha512-<base64>) is the content-addressed identity (UNIQUE),
250
+ // matched against a report's bundleHashes to auto-link.
251
+ export interface ManagedBundle {
252
+ visible: boolean
253
+ id: string
254
+ slug: string
255
+ integrity: string
256
+ filename: string
257
+ kind: string | null
258
+ byteSize: number
259
+ uploadedBy: string | null
260
+ repoDirectory: string
261
+ repoId: number | null
262
+ uploadedAt: number
263
+ provenance: BundleProvenance | null
264
+ // null for uploads, Solidity builds, and builds predating the column.
265
+ buildConditions: BundleBuildConditions | null
266
+ }
267
+
268
+ // What the upload handler supplies to record a bundle; the store stamps
269
+ // uploaded_at. `uploadedByLogin` is the durable uploader-login snapshot.
270
+ export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory' | 'visible' | 'provenance' | 'buildConditions'> & {
271
+ visible?: boolean; uploadedByLogin: string | null; repoDirectory?: string; dataKey?: string | null; provenance?: BundleProvenance | null
272
+ buildConditions?: BundleBuildConditions | null
273
+ }
274
+
275
+ // A repeated upload or build of stored bytes (see reuseBundle).
276
+ export interface BundleReuse { provenance: BundleProvenance; filename: string; kind: string | null; rename: boolean; buildConditions?: BundleBuildConditions | null }
277
+
278
+ // A bundle row for the "Manage bundles" list — adds the uploader login + repo
279
+ // full name display joins (null when absent / since removed).
280
+ export interface AdminBundle {
281
+ visible: boolean
282
+ id: string
283
+ slug: string
284
+ integrity: string
285
+ filename: string
286
+ kind: string | null
287
+ byteSize: number
288
+ uploadedByLogin: string | null
289
+ repoDirectory: string
290
+ repoId: number | null
291
+ repoFullName: string | null
292
+ uploadedAt: number
293
+ provenance: BundleProvenance | null
294
+ }
295
+
296
+ // A team's repo link (with its optional subpath) and member (with resolved
297
+ // login + visibility permissions), as carried in the AdminTeam detail.
298
+ export interface TeamRepoLink {
299
+ repoId: number
300
+ fullName: string
301
+ path: string | null
302
+ }
303
+ export interface TeamMember extends TeamUserPermissions {
304
+ userId: string
305
+ login: string
306
+ }
307
+
308
+ // A team with its links inlined, for the "Manage teams" page.
309
+ export interface AdminTeam {
310
+ id: string
311
+ slug: string
312
+ name: string
313
+ hidden: boolean
314
+ repos: TeamRepoLink[]
315
+ members: TeamMember[]
316
+ }
317
+
318
+ // A user id + login, for the team-member picker (lighter than the admin users
319
+ // list, and usable by manage — not just admin). A `type` (not interface) so a
320
+ // SQLite row casts straight to it.
321
+ export type UserOption = {
322
+ id: string
323
+ login: string
324
+ name: string | null
325
+ role: Role
326
+ }
327
+
328
+ // A team as shown in a member's own sidebar: the team name plus the reports and
329
+ // bundles attached to the team's repos (id + filename, newest first). An item
330
+ // shows under a team when its repo is one of the team's linked repos.
331
+ export interface UserTeamReport {
332
+ visible: boolean
333
+ id: string
334
+ slug: string
335
+ filename: string
336
+ analyzer: string | null
337
+ repoFullName: string | null
338
+ repoDirectory: string
339
+ cacheKey: string
340
+ }
341
+ export interface UserTeamBundle {
342
+ visible: boolean
343
+ id: string
344
+ slug: string
345
+ integrity: string
346
+ filename: string
347
+ kind: string | null
348
+ byteSize: number
349
+ repoDirectory: string
350
+ repoId: number
351
+ repoFullName: string
352
+ provenance: BundleProvenance | null
353
+ }
354
+ export interface UserTeam {
355
+ id: string
356
+ slug: string
357
+ name: string
358
+ dependencyReview?: boolean
359
+ permissions: TeamUserPermissions
360
+ cacheKey?: string
361
+ reports: UserTeamReport[]
362
+ bundles: UserTeamBundle[]
363
+ }
364
+
365
+ // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
366
+ export interface ManagedDb extends DependencyReviewStore, ActivityStore, CommentStore, GithubMetadataStore, GithubCommitStore, ManagedIssueStore, BundleBuildLeaseStore, UpstreamCacheStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb, LinkReportStore, RepositoryAliasStore, TeamNpmScopeStore {
367
+ claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
368
+ finishMaintenanceLease(owner: string, until: number): Promise<void>
369
+ getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
370
+ // Reuse connections within a finite request or one feed polling iteration.
371
+ withRequest?<T>(work: () => Promise<T>): Promise<T>
372
+ // Upsert the identity; returns the user's opaque id (stable across logins).
373
+ // Initial-admin approval comes only from trusted login configuration. It
374
+ // promotes a matching No access identity only while it is the sole user.
375
+ upsertUser(user: ManagedUser, now: number, initialAdminGithubId?: number | null): Promise<string>
376
+ createSession(session: ManagedSession, now: number): Promise<void>
377
+ // A sign-in session; never a view session, so a view token in the session
378
+ // cookie authenticates nothing.
379
+ sessionWithUser(id: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
380
+ // Replace the view opened by an admin's session with a view as `userId`.
381
+ // False, keeping any earlier view, unless that session is current, its user
382
+ // is still an admin, and `userId` is another existing account. The view
383
+ // expires with that session.
384
+ createViewSession(view: { id: string; viewerSessionId: string; userId: string; csrfToken: string }, now: number): Promise<boolean>
385
+ // A view session opened by `viewerSessionId`, while that session is current
386
+ // and its user is still an admin. Presence is the admin's, not the viewed user's.
387
+ viewSessionWithUser(id: string, viewerSessionId: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
388
+ // Store `candidate` unless the session already has an upload key; returns
389
+ // the stored key, or null when the session no longer exists.
390
+ ensureSessionUploadKey(id: string, candidate: string): Promise<string | null>
391
+ deleteSession(id: string): Promise<void>
392
+ deleteExpiredSessions(now: number): Promise<number>
393
+ listUsers(): Promise<AdminUser[]>
394
+ // Set a user's role; resolves true iff a matching user row was updated.
395
+ setUserRole(id: string, role: Role): Promise<boolean>
396
+ // Persist / read a user's GitHub token (for on-demand repo listing).
397
+ setUserTokens(id: string, tokens: UserTokens): Promise<void>
398
+ getUserTokens(id: string): Promise<UserTokens | null>
399
+ getUserGithubId(id: string): Promise<number | null>
400
+ // Repo selection ("operate on"). selectRepo upserts by repo id, refreshing the
401
+ // mutable context while keeping the original added_by/added_at; deselectRepo
402
+ // resolves true iff a row was removed.
403
+ selectRepo(repo: SelectedRepoInput, now: number): Promise<void>
404
+ cacheRepoDefaultBranch(repo: SelectedRepo, branch: string | null): Promise<boolean>
405
+ connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean>
406
+ deselectRepo(repoId: number): Promise<boolean>
407
+ listSelectedRepos(): Promise<SelectedRepo[]>
408
+ listAllRepos(): Promise<ManagedRepo[]>
409
+ deactivateRepo(repoId: number): Promise<boolean>
410
+ reactivateRepo(repoId: number): Promise<boolean>
411
+ deleteRepo(repoId: number): Promise<boolean>
412
+ listReportsForRepo(repoId: number): Promise<RepoReportItem[]>
413
+ listBundlesForRepo(repoId: number): Promise<RepoDataItem[]>
414
+ deleteReportsForRepo(repoId: number): Promise<number>
415
+ deleteBundlesForRepo(repoId: number): Promise<number>
416
+ // Permanently delete current annotations AND their history atomically.
417
+ // Returns the number of current rows removed (not the number of events).
418
+ deleteTriage(findingIds: readonly string[]): Promise<number>
419
+ // Reports ("Manage reports"). insertReport records an uploaded report's
420
+ // metadata (bytes are written to the blob-store separately); listReports joins
421
+ // the uploader login + repo + linked-bundle filename for the admin list;
422
+ // getReport reads one row (for download); deleteReport resolves true iff a row
423
+ // was removed.
424
+ insertReport(report: ReportRecordInput, now: number): Promise<void>
425
+ // Content reuse is atomic with insertion, including across server instances.
426
+ // Existing copies are kept; the oldest matching row is the stable identity.
427
+ // HTTP uploads pass sessionId to recheck role/destination in that transaction.
428
+ insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord>
429
+ getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
430
+ listReports(userId?: string): Promise<AdminReport[]>
431
+ listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]>
432
+ getReport(id: string): Promise<ReportRecord | null>
433
+ // Writer-locked reconciliation waits for an uncertain earlier insert.
434
+ resolveReportUpload(id: string): Promise<ReportRecord | null>
435
+ getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string, reportId?: string | null): Promise<TeamReportAccessSnapshot | null>
436
+ getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
437
+ listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]>
438
+ deleteReport(id: string): Promise<boolean>
439
+ // Attach / detach a report's repo + directory link (repoId null = detach);
440
+ // resolves true iff the report exists. The caller validates repoId and the
441
+ // directory path.
442
+ setReportRepo(id: string, repoId: number | null, repoDirectory?: string): Promise<boolean>
443
+ setReportVisible(id: string, visible: boolean): Promise<boolean>
444
+ // Per-finding triage annotations, keyed by finding id alone (shared by every
445
+ // report carrying the finding). listTriage reads the rows for a set of ids —
446
+ // the endpoint passes a viewer's visible findings of one report; a cleared
447
+ // entry comes back as a row with every field null (its tombstone).
448
+ // setTriage replaces one row wholesale, stamping the writer — a null/empty
449
+ // entry writes the tombstone rather than deleting; setTriageEntries does the
450
+ // same for a batch in one transaction: it lands whole or not at all. A write
451
+ // that leaves the entry as it is changes nothing — neither the row's writer
452
+ // stamp nor the trail. Every change also appends to managed_finding_triage_event;
453
+ // listTriageHistory walks one finding's trail, newest first.
454
+ listTriage(findingIds: readonly string[]): Promise<TriageRow[]>
455
+ getAnnotations(findingIds: readonly string[]): Promise<{ triage: TriageRow[]; comments: Awaited<ReturnType<CommentStore['listComments']>>; issues: Awaited<ReturnType<ManagedIssueStore['listManagedIssues']>> }>
456
+ applyManagedIssueFixes(sessionId: string, snapshot: TeamReportAccessSnapshot, updates: IssueFixUpdate[]): Promise<boolean>
457
+ // A content-free fingerprint of current annotations, read in one snapshot.
458
+ getAnnotationRevision(findingIds: readonly string[]): Promise<string>
459
+ setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void>
460
+ setTriageEntries(entries: readonly (readonly [string, TriageEntryPatch | null])[], updatedBy: string | null, updatedByLogin: string | null, now: number, reportId?: string): Promise<void>
461
+ listTriageHistory(findingId: string, limit: number): Promise<TriageEventRow[]>
462
+ // Bundles ("Manage bundles"). insertBundle records an uploaded bundle (bytes
463
+ // in the blob-store); getBundleByIntegrity dedupes uploads + resolves a
464
+ // report's bundleHashes; getBundle reads one row (download); listBundles joins
465
+ // uploader login + repo; deleteBundle resolves true iff a row was removed
466
+ // (referencing reports' bundle_id null out via the FK). linkReportsToBundle
467
+ // attaches a freshly-stored bundle to the (still-unlinked) reports that
468
+ // declared its integrity. reuseBundle records a repeated upload or build of
469
+ // stored bytes: a build always marks the row built and records its conditions
470
+ // where the row has none, and with `rename` an uploaded row takes the new
471
+ // filename/kind. A built row is never renamed; one predating provenance is
472
+ // renamed only by a build. Resolves true iff changed.
473
+ // The caller authorizes `rename` (see reuseBundleUpload).
474
+ insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
475
+ reuseBundle(id: string, reuse: BundleReuse): Promise<boolean>
476
+ getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
477
+ resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
478
+ getBundle(id: string): Promise<ManagedBundle | null>
479
+ // One read snapshot of the live session, bundle and grants. Passing a team
480
+ // (or null for any team) also checks advisory security access; omission skips it.
481
+ getBundleAccessSnapshot(sessionId: string, now: number, id: string, advisoriesTeamId?: string | null): Promise<{
482
+ bundle: ManagedBundle | null; canReadAdvisories: boolean
483
+ } | null>
484
+ listBundles(userId?: string): Promise<AdminBundle[]>
485
+ listReadableBundleIds(userId: string, ids: readonly string[]): Promise<string[]>
486
+ userCanReadBundle(userId: string, id: string): Promise<boolean>
487
+ userCanReadRepo(userId: string, repoId: number): Promise<boolean>
488
+ userCanReadBundleAdvisories(userId: string, bundleId: string, teamId: string | null): Promise<boolean>
489
+ userCanReadRepoPath(userId: string, repoId: number, directory: string): Promise<boolean>
490
+ deleteBundle(id: string): Promise<boolean>
491
+ // Set a bundle's repository and normalized directory (repoId null detaches
492
+ // and clears the directory). The caller validates the location and access.
493
+ setBundleRepo(id: string, repoId: number | null, directory?: string): Promise<boolean>
494
+ setBundleVisible(id: string, visible: boolean): Promise<boolean>
495
+ linkReportsToBundle(integrity: string, bundleId: string, userId?: string): Promise<void>
496
+ // Teams ("Manage teams"). createTeam inserts a team (false iff the name is
497
+ // taken); renameTeam changes a team's name ('name-taken' iff another team
498
+ // already has it, 'not-found' iff no such team, 'ok' otherwise — same name is
499
+ // idempotent); deleteTeam drops it (cascading its links); listTeams returns
500
+ // every team (including hidden ones) with its repos + members inlined; listUserOptions is the id+login+name
501
+ // set for the member picker. The set*/remove* pairs maintain the link tables:
502
+ // setTeamRepo adds a path (whole repo replaces paths); setTeamMember upserts a
503
+ // membership + its visibility permissions (each resolves true iff a row was
504
+ // written / removed; the caller validates the team/repo/user exist first).
505
+ createTeam(id: string, name: string, now: number): Promise<boolean>
506
+ renameTeam(id: string, name: string, now: number): Promise<'ok' | 'name-taken' | 'not-found'>
507
+ // Hiding preserves links but disables all grants and workspace access.
508
+ setTeamHidden(id: string, hidden: boolean, now: number): Promise<boolean>
509
+ deleteTeam(id: string): Promise<boolean>
510
+ getTeam(id: string): Promise<{ id: string; slug: string; name: string; hidden: boolean } | null>
511
+ listTeams(): Promise<AdminTeam[]>
512
+ listUserOptions(): Promise<UserOption[]>
513
+ // Current grants for manager content reads, writes, and repository pickers.
514
+ listRepoScopesForUser(userId: string): Promise<{ repoId: number; path: string | null }[]>
515
+ // The non-hidden teams a given user belongs to (name-sorted), each with reports and
516
+ // bundles attached to that team's repos — for that user's own sidebar Teams section.
517
+ // Any user; only their own memberships.
518
+ listTeamsForUser(userId: string): Promise<UserTeam[]>
519
+ getUserTeamFeedSnapshot(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; revision: string; teams: UserTeam[]; catalog: number } | null>
520
+ // Whether `userId` may read `reportId`: true iff the report's repo belongs to
521
+ // a non-hidden team the user is a member of. Backs the team-scoped report view endpoint.
522
+ userCanReadReport(userId: string, reportId: string): Promise<boolean>
523
+ // The viewer's effective visibility permissions for a report, OR'd across the
524
+ // teams (holding the report's repo) the user belongs to — drives the content
525
+ // filter (a viewer without a permission has those findings stripped).
526
+ reportPermissionsFor(userId: string, reportId: string): Promise<TeamUserPermissions>
527
+ setTeamRepo(teamId: string, repoId: number, path: string | null): Promise<void>
528
+ removeTeamRepo(teamId: string, repoId: number, path?: string | null): Promise<boolean>
529
+ setTeamMember(teamId: string, userId: string, perms: TeamUserPermissions): Promise<void>
530
+ removeTeamMember(teamId: string, userId: string): Promise<boolean>
531
+ close(): Promise<void>
532
+ }
533
+
534
+ type SessionRow = {
535
+ id: string; csrf: string; exp: number; uploadKey: string | null
536
+ uid: string; login: string; name: string | null; avatar: string | null; role: Role
537
+ }
538
+ type ViewSessionRow = SessionRow & { viewerId: string; viewerLogin: string; viewerName: string | null }
539
+
540
+ type UserRow = { id: string; login: string; name: string | null; role: Role; created: number; lastSeen: number | null; lastActivity: number | null }
541
+
542
+ // Shared by listing, access checks, and permission aggregation. A team sees
543
+ // reports rooted at its path or below it. Literal substring comparison keeps
544
+ // separators, case, and SQL wildcard characters significant.
545
+ const REPORT_IN_TEAM_PATH_SQL = `(tr.path IS NULL OR tr.path = ''
546
+ OR r.repo_directory = tr.path
547
+ OR substr(r.repo_directory, 1, length(tr.path) + 1) = tr.path || '/')`
548
+
549
+ const BUNDLE_IN_TEAM_PATH_SQL = REPORT_IN_TEAM_PATH_SQL.replaceAll('r.repo_directory', 'b.repo_directory')
550
+
551
+ // Whether a repeated upload/build renames the stored row. Uploads never rename
552
+ // a built row, or one whose provenance is unknown; builds rename either. The
553
+ // kind must stay readable from its blob store (sourcemaps are stored apart):
554
+ // keep it, or let an unrecognized archive become Stasis — never downgrade.
555
+ const BUNDLE_RENAMED_SQL = `(:rename = 1 AND (provenance = 'upload' OR (:provenance = 'build' AND provenance IS NULL))
556
+ AND (COALESCE(kind, '') = COALESCE(:kind, '') OR (kind IS NULL AND :kind = 'stasis'))
557
+ AND (filename <> :filename OR kind IS DISTINCT FROM :kind))`
558
+
559
+ // Prepare every statement the store uses, returned as a bag the factory
560
+ // destructures — keeps openSqliteManagedDb itself small (one place per query).
561
+ function prepareStatements(db: ManagedSql) {
562
+ return {
563
+ // Identity refreshes preserve roles. Login can separately bootstrap the
564
+ // configured sole No access user within this same writer transaction.
565
+ upsertUserStmt: db.prepare(
566
+ `INSERT INTO managed_user (id, github_user_id, login, name, avatar_url, role, created_at, updated_at)
567
+ VALUES (?, ?, ?, ?, ?, 'none', ?, ?)
568
+ ON CONFLICT(github_user_id) DO UPDATE SET
569
+ login = excluded.login, name = excluded.name,
570
+ avatar_url = excluded.avatar_url, updated_at = excluded.updated_at`,
571
+ ),
572
+ selectUserIdStmt: db.prepare(`SELECT id, role FROM managed_user WHERE github_user_id = ?`),
573
+ promoteInitialAdminStmt: db.prepare(
574
+ `UPDATE managed_user SET role = 'admin' WHERE id = ? AND role = 'none'
575
+ AND NOT EXISTS(SELECT 1 FROM managed_user WHERE id <> ?)`,
576
+ ),
577
+ selectGithubIdStmt: db.prepare(`SELECT github_user_id AS githubId FROM managed_user WHERE id = ?`),
578
+ insertSessionStmt: db.prepare(
579
+ `INSERT INTO managed_session (id, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
580
+ ),
581
+ selectSessionStmt: db.prepare(
582
+ `SELECT s.id AS id, s.csrf_token AS csrf, s.expires_at AS exp, s.upload_key AS uploadKey,
583
+ u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role
584
+ FROM managed_session s
585
+ JOIN managed_user u ON u.id = s.user_id
586
+ WHERE s.id = ? AND s.expires_at > ?`,
587
+ ),
588
+ selectOwnSessionStmt: db.prepare(
589
+ `SELECT s.id AS id, s.csrf_token AS csrf, s.expires_at AS exp, s.upload_key AS uploadKey,
590
+ u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role
591
+ FROM managed_session s
592
+ JOIN managed_user u ON u.id = s.user_id
593
+ WHERE s.id = ? AND s.expires_at > ? AND s.viewer_session IS NULL`,
594
+ ),
595
+ selectViewSessionStmt: db.prepare(
596
+ `SELECT s.id AS id, s.csrf_token AS csrf, s.expires_at AS exp, s.upload_key AS uploadKey,
597
+ u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role,
598
+ a.id AS viewerId, a.login AS viewerLogin, a.name AS viewerName
599
+ FROM managed_session s
600
+ JOIN managed_user u ON u.id = s.user_id
601
+ JOIN managed_session p ON p.id = s.viewer_session
602
+ JOIN managed_user a ON a.id = p.user_id
603
+ WHERE s.id = ? AND s.viewer_session = ? AND s.expires_at > ?
604
+ AND p.expires_at > ? AND p.viewer_session IS NULL AND a.role = 'admin'`,
605
+ ),
606
+ deleteOtherViewsStmt: db.prepare(`DELETE FROM managed_session WHERE viewer_session = ? AND id <> ?`),
607
+ insertViewSessionStmt: db.prepare(
608
+ `INSERT INTO managed_session (id, user_id, csrf_token, created_at, expires_at, viewer_session)
609
+ SELECT ?, u.id, ?, ?, p.expires_at, p.id
610
+ FROM managed_session p
611
+ JOIN managed_user a ON a.id = p.user_id
612
+ JOIN managed_user u ON u.id = ?
613
+ WHERE p.id = ? AND p.expires_at > ? AND p.viewer_session IS NULL AND a.role = 'admin' AND u.id <> a.id`,
614
+ ),
615
+ // A former admin's views end with the role, even if it is granted again.
616
+ deleteDemotedViewsStmt: db.prepare(
617
+ `DELETE FROM managed_session WHERE viewer_session IN (SELECT id FROM managed_session WHERE user_id = ?)`,
618
+ ),
619
+ selectUsersStmt: db.prepare(
620
+ `SELECT u.id, u.login, u.name, u.role, u.created_at AS created, u.last_seen_at AS lastSeen,
621
+ (SELECT MAX(at) FROM (
622
+ SELECT MAX(e.at) AS at FROM managed_finding_triage_event e WHERE e.actor_id = u.id
623
+ UNION ALL SELECT MAX(a.at) AS at FROM managed_activity a WHERE a.actor_id = u.id
624
+ UNION ALL SELECT MAX(c.at) AS at FROM managed_finding_comment_event c WHERE c.actor_id = u.id
625
+ ) AS events) AS lastActivity
626
+ FROM managed_user u ORDER BY u.created_at ASC, u.login ASC`,
627
+ ),
628
+ touchUserSeenStmt: db.prepare(
629
+ `UPDATE managed_user SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)`,
630
+ ),
631
+ updateRoleStmt: db.prepare(`UPDATE managed_user SET role = ?, updated_at = ? WHERE id = ?`),
632
+ updateTokensStmt: db.prepare(
633
+ `UPDATE managed_user
634
+ SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, gh_tokens_encrypted = ?, updated_at = ?
635
+ WHERE id = ?`,
636
+ ),
637
+ selectTokensStmt: db.prepare(
638
+ `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp, gh_tokens_encrypted AS encrypted
639
+ FROM managed_user WHERE id = ?`,
640
+ ),
641
+ // Concurrent first requests keep whichever key was stored first.
642
+ ensureUploadKeyStmt: db.prepare(
643
+ `UPDATE managed_session SET upload_key = COALESCE(upload_key, ?) WHERE id = ? RETURNING upload_key AS uploadKey`,
644
+ ),
645
+ deleteSessionStmt: db.prepare(`DELETE FROM managed_session WHERE id = ?`),
646
+ deleteExpiredStmt: db.prepare(`DELETE FROM managed_session WHERE expires_at <= ?`),
647
+ upsertRepoStmt: db.prepare(
648
+ `INSERT INTO managed_selected_repo (repo_id, full_name, is_private, installation_id, default_branch, html_url, added_by, added_at, updated_at, active)
649
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
650
+ ON CONFLICT(repo_id) DO UPDATE SET
651
+ full_name = excluded.full_name, is_private = excluded.is_private,
652
+ installation_id = excluded.installation_id, default_branch = excluded.default_branch,
653
+ html_url = excluded.html_url, updated_at = excluded.updated_at, active = 1`,
654
+ ),
655
+ // Never reactivate or recreate a repo, overwrite another connection, or
656
+ // commit after the administrator's session/role was revoked during GitHub I/O.
657
+ connectRepoInstallationStmt: db.prepare(`UPDATE managed_selected_repo SET installation_id = ?, updated_at = ?
658
+ WHERE repo_id = ? AND full_name = ? AND added_at = ? AND installation_id IS NULL
659
+ AND EXISTS (SELECT 1 FROM managed_session s JOIN managed_user u ON u.id = s.user_id
660
+ WHERE s.id = ? AND s.expires_at > ? AND u.role = 'admin')`),
661
+ cacheRepoDefaultBranchStmt: db.prepare(`UPDATE managed_selected_repo SET cached_default_branch = ?
662
+ WHERE repo_id = ? AND full_name = ? AND added_at = ? AND active = 1`),
663
+ deleteRepoStmt: db.prepare(`DELETE FROM managed_selected_repo WHERE repo_id = ?`),
664
+ deactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 0 WHERE repo_id = ? AND active = 1`),
665
+ reactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 1 WHERE repo_id = ? AND active = 0`),
666
+ selectReportsForRepoStmt: db.prepare(`SELECT id, filename, sha256, bundle_id AS bundleId FROM managed_report WHERE repo_id = ? ORDER BY filename ASC`),
667
+ selectBundlesForRepoStmt: db.prepare(`SELECT id, filename FROM managed_bundle WHERE repo_id = ? ORDER BY filename ASC`),
668
+ deleteReportsForRepoStmt: db.prepare(`DELETE FROM managed_report WHERE repo_id = ?`),
669
+ deleteBundlesForRepoStmt: db.prepare(`DELETE FROM managed_bundle WHERE repo_id = ?`),
670
+ deleteTriageStmt: db.prepare(`DELETE FROM managed_finding_triage WHERE finding_id IN (SELECT value FROM json_each(?))`),
671
+ deleteTriageHistoryStmt: db.prepare(`DELETE FROM managed_finding_triage_event WHERE finding_id IN (SELECT value FROM json_each(?))`),
672
+ deleteCommentsStmt: db.prepare(`DELETE FROM managed_finding_comment WHERE finding_id IN (SELECT value FROM json_each(?))`),
673
+ deleteCommentHistoryStmt: db.prepare(`DELETE FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))`),
674
+ countAnnotationsStmt: db.prepare(`SELECT count(*) AS total FROM (
675
+ SELECT finding_id FROM managed_finding_triage WHERE finding_id IN (SELECT value FROM json_each(?))
676
+ UNION SELECT finding_id FROM managed_finding_comment WHERE finding_id IN (SELECT value FROM json_each(?))
677
+ UNION SELECT finding_id FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))) AS annotations`),
678
+ selectReposStmt: db.prepare(
679
+ `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
680
+ installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
681
+ added_by AS addedBy, added_at AS addedAt, active AS active
682
+ FROM managed_selected_repo WHERE active = 1 ORDER BY full_name ASC`,
683
+ ),
684
+ selectAllReposStmt: db.prepare(
685
+ `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
686
+ installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
687
+ added_by AS addedBy, added_at AS addedAt, active AS active
688
+ FROM managed_selected_repo ORDER BY full_name ASC`,
689
+ ),
690
+ insertReportStmt: db.prepare(
691
+ `WITH candidate(id, slug) AS (VALUES (?, ?))
692
+ INSERT INTO managed_report (id, slug, filename, content_type, byte_size, sha256, uploaded_by, uploaded_by_login, repo_id, repo_directory, repo_embedded, analyzer, visible, bundle_id, bundle_integrity, uploaded_at, data_key, storage_encrypted)
693
+ SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_report WHERE slug = candidate.slug) THEN id ELSE slug END,
694
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
695
+ ),
696
+ // LEFT JOINs so a report whose uploader / repo / bundle was removed (the FK
697
+ // nulled) still lists, with null display fields.
698
+ selectReportsStmt: db.prepare(
699
+ `SELECT r.id AS id, r.slug AS slug, r.filename AS filename, r.content_type AS contentType,
700
+ r.byte_size AS byteSize, r.sha256 AS sha256, COALESCE(u.login, r.uploaded_by_login) AS uploadedByLogin,
701
+ r.repo_id AS repoId, sr.full_name AS repoFullName,
702
+ r.repo_directory AS repoDirectory, r.repo_embedded AS repoEmbedded,
703
+ r.analyzer AS analyzer, r.visible AS visible,
704
+ r.bundle_id AS bundleId, b.filename AS bundleFilename, r.bundle_integrity AS bundleIntegrity,
705
+ r.uploaded_at AS uploadedAt
706
+ FROM managed_report r
707
+ LEFT JOIN managed_user u ON u.id = r.uploaded_by
708
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
709
+ LEFT JOIN managed_bundle b ON b.id = r.bundle_id
710
+ WHERE (? IS NULL OR r.uploaded_by = ? OR EXISTS (
711
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
712
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
713
+ WHERE tr.repo_id = r.repo_id AND tu.user_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}))
714
+ ORDER BY r.uploaded_at DESC, r.filename ASC`,
715
+ ),
716
+ findingCatalogReportsStmt: db.prepare(`SELECT id, byte_size AS byteSize FROM managed_report WHERE id > ? ORDER BY id LIMIT ?`),
717
+ selectReportStmt: db.prepare(
718
+ `SELECT id, slug, filename, content_type AS contentType, byte_size AS byteSize,
719
+ sha256, uploaded_by AS uploadedBy, uploaded_at AS uploadedAt,
720
+ repo_id AS repoId, repo_directory AS repoDirectory, repo_embedded AS repoEmbedded,
721
+ analyzer AS analyzer, visible AS visible, bundle_id AS bundleId
722
+ FROM managed_report WHERE id = ?`,
723
+ ),
724
+ selectReportByHashStmt: db.prepare(
725
+ `SELECT id FROM managed_report WHERE sha256 = ? AND COALESCE(analyzer, '') = COALESCE(?, '')
726
+ ORDER BY uploaded_at, id LIMIT 1`,
727
+ ),
728
+ selectReportAccessStmt: db.prepare(
729
+ `WITH requested AS (SELECT value AS id FROM json_each(?)), report_grants AS (
730
+ SELECT r.id AS id, MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
731
+ FROM managed_report r JOIN requested q ON q.id = r.id
732
+ JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
733
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id AND tu.user_id = ?
734
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
735
+ GROUP BY r.id
736
+ )
737
+ SELECT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256,
738
+ r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
739
+ COALESCE(g.dependencies, 0) AS dependencies, COALESCE(g.security, 0) AS security
740
+ FROM managed_report r JOIN requested q ON q.id = r.id
741
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
742
+ LEFT JOIN report_grants g ON g.id = r.id
743
+ WHERE ? = 1 OR (? = 1 AND r.uploaded_by = ?)
744
+ OR (g.id IS NOT NULL AND (? = 1 OR r.visible = 1))`,
745
+ ),
746
+ selectTeamAccessStmt: db.prepare(`SELECT 1 FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
747
+ WHERE tu.user_id = ? AND tu.team_id = ? AND t.hidden = 0`),
748
+ selectTeamRepositoriesStmt: db.prepare(
749
+ `SELECT tr.repo_id AS repoId, sr.full_name AS github, tr.path AS path
750
+ FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
751
+ WHERE tr.team_id = ? ORDER BY tr.repo_id, tr.path`,
752
+ ),
753
+ selectTeamReportAccessStmt: db.prepare(
754
+ `SELECT DISTINCT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256, r.visible AS visible,
755
+ r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
756
+ tu.view_dependencies AS dependencies, tu.view_security AS security
757
+ FROM managed_team_user tu
758
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
759
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
760
+ JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
761
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
762
+ WHERE tu.user_id = ? AND tu.team_id = ? AND (r.visible = 1 OR (? = 1 AND r.id = ?))
763
+ ORDER BY r.id`,
764
+ ),
765
+ reportFilenamesWithBundleHashStmt: db.prepare(`SELECT DISTINCT filename FROM managed_report WHERE bundle_id = ? AND sha256 = ?`),
766
+ deleteReportStmt: db.prepare(`DELETE FROM managed_report WHERE id = ?`),
767
+ setReportRepoStmt: db.prepare(`UPDATE managed_report SET repo_id = ?, repo_directory = ? WHERE id = ?`),
768
+ setReportVisibleStmt: db.prepare(`UPDATE managed_report SET visible = ? WHERE id = ?`),
769
+ upsertTriageStmt: db.prepare(
770
+ `INSERT INTO managed_finding_triage (finding_id, color, triage, comment, fix, flagged, updated_by, updated_by_login, updated_at)
771
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
772
+ ON CONFLICT(finding_id) DO UPDATE SET
773
+ color = excluded.color, triage = excluded.triage, comment = excluded.comment,
774
+ fix = excluded.fix, flagged = excluded.flagged, updated_by = excluded.updated_by,
775
+ updated_by_login = excluded.updated_by_login, updated_at = excluded.updated_at`,
776
+ ),
777
+ selectTriageStateStmt: db.prepare(
778
+ `SELECT color, triage, comment, fix, flagged FROM managed_finding_triage WHERE finding_id = ?`,
779
+ ),
780
+ insertTriageEventStmt: db.prepare(
781
+ `INSERT INTO managed_finding_triage_event (finding_id, batch_id, color, triage, comment, fix, flagged, actor_id, actor_login, at, report_id, report, repo)
782
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
783
+ (SELECT filename FROM managed_report WHERE id = ?),
784
+ (SELECT p.full_name FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id WHERE r.id = ?))`,
785
+ ),
786
+ // With a retention limit set: keep the newest N events of a finding.
787
+ trimTriageEventsStmt: db.prepare(
788
+ `DELETE FROM managed_finding_triage_event
789
+ WHERE finding_id = ?
790
+ AND seq NOT IN (SELECT seq FROM managed_finding_triage_event WHERE finding_id = ? ORDER BY seq DESC LIMIT ?)`,
791
+ ),
792
+ selectTriageHistoryStmt: db.prepare(
793
+ `SELECT e.seq AS seq, e.finding_id AS findingId, e.batch_id AS batchId, e.color AS color, e.triage AS triage,
794
+ e.comment AS comment, e.fix AS fix, e.flagged AS flagged,
795
+ u.id AS actorId, COALESCE(u.login, e.actor_login) AS actorLogin, u.name AS actorName, e.at AS at
796
+ FROM managed_finding_triage_event e
797
+ LEFT JOIN managed_user u ON u.id = e.actor_id
798
+ WHERE e.finding_id = ?
799
+ ORDER BY e.seq DESC
800
+ LIMIT ?`,
801
+ ),
802
+ // The ids arrive as one JSON array (json_each), so a report's worth of
803
+ // them is one statement, not a chunked IN list. COALESCE the live login
804
+ // over the durable snapshot, like selectReportsStmt.
805
+ selectTriageStmt: db.prepare(
806
+ `SELECT t.finding_id AS findingId, t.color AS color, t.triage AS triage,
807
+ t.comment AS comment, t.fix AS fix, t.flagged AS flagged,
808
+ COALESCE(u.login, t.updated_by_login) AS updatedByLogin, t.updated_at AS updatedAt
809
+ FROM managed_finding_triage t
810
+ LEFT JOIN managed_user u ON u.id = t.updated_by
811
+ WHERE t.finding_id IN (SELECT value FROM json_each(?))
812
+ ORDER BY t.finding_id ASC`,
813
+ ),
814
+ annotationTriageRevisionStmt: db.prepare(`
815
+ SELECT t.finding_id AS id,
816
+ (SELECT e.batch_id FROM managed_finding_triage_event e
817
+ WHERE e.finding_id = t.finding_id ORDER BY e.seq DESC LIMIT 1) AS revision
818
+ FROM managed_finding_triage t WHERE t.finding_id IN (SELECT value FROM json_each(?))
819
+ ORDER BY t.finding_id`),
820
+ annotationCommentRevisionStmt: db.prepare(`
821
+ SELECT id, version FROM managed_finding_comment
822
+ WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
823
+ insertBundleStmt: db.prepare(
824
+ `WITH candidate(id, slug) AS (VALUES (?, ?))
825
+ 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)
826
+ SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_bundle WHERE slug = candidate.slug) THEN id ELSE slug END,
827
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
828
+ ),
829
+ // SET reads the pre-update row. Unchanged rows match nothing, so identical
830
+ // retries do not bump the catalog revision. Conditions that rebuild the same
831
+ // bytes describe them equally, so the first recorded ones stay.
832
+ reuseBundleStmt: db.prepare(
833
+ `UPDATE managed_bundle SET
834
+ filename = CASE WHEN ${BUNDLE_RENAMED_SQL} THEN :filename ELSE filename END,
835
+ kind = CASE WHEN ${BUNDLE_RENAMED_SQL} THEN :kind ELSE kind END,
836
+ provenance = CASE WHEN :provenance = 'build' THEN 'build' ELSE provenance END,
837
+ build_conditions = CASE WHEN :provenance = 'build' THEN COALESCE(build_conditions, :buildConditions) ELSE build_conditions END
838
+ WHERE id = :id AND (${BUNDLE_RENAMED_SQL} OR (:provenance = 'build' AND (provenance IS DISTINCT FROM 'build'
839
+ OR build_conditions IS DISTINCT FROM COALESCE(build_conditions, :buildConditions))))`,
840
+ ),
841
+ selectBundleByIntegrityStmt: db.prepare(
842
+ `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
843
+ uploaded_by AS uploadedBy, repo_id AS repoId, repo_directory AS repoDirectory, visible, uploaded_at AS uploadedAt, provenance,
844
+ build_conditions AS buildConditions
845
+ FROM managed_bundle WHERE integrity = ?`,
846
+ ),
847
+ selectBundleStmt: db.prepare(
848
+ `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
849
+ uploaded_by AS uploadedBy, repo_id AS repoId, repo_directory AS repoDirectory, visible, uploaded_at AS uploadedAt, provenance,
850
+ build_conditions AS buildConditions
851
+ FROM managed_bundle WHERE id = ?`,
852
+ ),
853
+ selectBundlesStmt: db.prepare(
854
+ `SELECT b.id AS id, b.slug AS slug, b.integrity AS integrity, b.filename AS filename, b.kind AS kind,
855
+ b.byte_size AS byteSize, COALESCE(u.login, b.uploaded_by_login) AS uploadedByLogin,
856
+ b.repo_id AS repoId, b.repo_directory AS repoDirectory, b.visible, sr.full_name AS repoFullName, b.uploaded_at AS uploadedAt, b.provenance
857
+ FROM managed_bundle b
858
+ LEFT JOIN managed_user u ON u.id = b.uploaded_by
859
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
860
+ WHERE (? IS NULL OR (b.uploaded_by = ? OR EXISTS (
861
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
862
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
863
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?)))
864
+ ORDER BY b.uploaded_at DESC, b.filename ASC`,
865
+ ),
866
+ selectBundleReadableStmt: db.prepare(
867
+ `SELECT 1 FROM managed_bundle b WHERE b.id = ? AND (b.uploaded_by = ? OR EXISTS (
868
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
869
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
870
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
871
+ ),
872
+ selectReadableBundleIdsStmt: db.prepare(
873
+ `SELECT b.id FROM managed_bundle b WHERE b.id IN (SELECT value FROM json_each(?)) AND (b.uploaded_by = ? OR EXISTS (
874
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
875
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
876
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
877
+ ),
878
+ selectRepoReadableStmt: db.prepare(
879
+ `SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
880
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
881
+ WHERE tr.repo_id = ? AND tu.user_id = ? LIMIT 1`,
882
+ ),
883
+ selectRepoPathReadableStmt: db.prepare(
884
+ `WITH r(repo_directory) AS (VALUES (?))
885
+ SELECT 1 FROM r JOIN managed_team_repo tr ON tr.repo_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}
886
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id
887
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
888
+ WHERE tu.user_id = ? LIMIT 1`,
889
+ ),
890
+ deleteBundleStmt: db.prepare(`DELETE FROM managed_bundle WHERE id = ?`),
891
+ setBundleRepoStmt: db.prepare(`UPDATE managed_bundle SET repo_id = ?, repo_directory = ? WHERE id = ?`),
892
+ setBundleVisibleStmt: db.prepare(`UPDATE managed_bundle SET visible = ? WHERE id = ?`),
893
+ // Attach a freshly-stored bundle to the reports that declared its integrity
894
+ // but haven't been linked yet (bundle uploaded after the report).
895
+ linkReportsToBundleStmt: db.prepare(
896
+ `UPDATE managed_report AS r SET bundle_id = ? WHERE bundle_integrity = ? AND bundle_id IS NULL
897
+ 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
898
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
899
+ WHERE tu.user_id = ? AND tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}))`,
900
+ ),
901
+ // OR IGNORE: a duplicate name (UNIQUE) is the "taken" signal (0 changes); the
902
+ // uuid PK never collides.
903
+ insertTeamStmt: db.prepare(`WITH candidate(id, slug) AS (VALUES (?, ?))
904
+ INSERT OR IGNORE INTO managed_team (id, slug, name, created_at, updated_at)
905
+ SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_team WHERE slug = candidate.slug) THEN id ELSE slug END,
906
+ ?, ?, ? FROM candidate`),
907
+ deleteTeamStmt: db.prepare(`DELETE FROM managed_team WHERE id = ?`),
908
+ selectTeamStmt: db.prepare(`SELECT id, slug, name, hidden FROM managed_team WHERE id = ?`),
909
+ selectTeamByNameStmt: db.prepare(`SELECT id FROM managed_team WHERE name = ?`),
910
+ renameTeamStmt: db.prepare(`UPDATE managed_team SET name = ?, updated_at = ? WHERE id = ?`),
911
+ setTeamHiddenStmt: db.prepare(`UPDATE managed_team SET hidden = ?, updated_at = ? WHERE id = ?`),
912
+ selectTeamsStmt: db.prepare(`SELECT id, slug, name, hidden FROM managed_team ORDER BY name ASC`),
913
+ selectTeamsForUserStmt: db.prepare(
914
+ `SELECT t.id AS id, t.slug AS slug, t.name AS name, t.dependency_review AS dependencyReview,
915
+ tu.view_dependencies AS dependencies, tu.view_security AS security
916
+ FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
917
+ WHERE tu.user_id = ? AND t.hidden = 0 ORDER BY t.name ASC, t.id ASC`,
918
+ ),
919
+ selectUserTeamScopesStmt: db.prepare(
920
+ `SELECT tr.team_id AS teamId, tr.repo_id AS repoId, tr.path AS path, sr.full_name AS fullName
921
+ FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
922
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
923
+ JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
924
+ WHERE tu.user_id = ? ORDER BY tr.team_id, tr.repo_id, tr.path`,
925
+ ),
926
+ // Reports attached to the repos of the user's teams, tagged by team (a report
927
+ // shows under every team whose repo it's attached to). Newest first.
928
+ selectUserTeamReportsStmt: db.prepare(
929
+ `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,
930
+ r.sha256 AS sha256, r.bundle_id AS bundleId, r.repo_id AS repoId, r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
931
+ tu.view_dependencies AS dependencies, tu.view_security AS security,
932
+ (SELECT role FROM managed_user WHERE id = tu.user_id) AS role
933
+ FROM managed_team_user tu
934
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
935
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
936
+ JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
937
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
938
+ WHERE tu.user_id = ? AND (r.visible = 1 OR (SELECT role FROM managed_user WHERE id = tu.user_id) IN ('admin', 'manage'))
939
+ ORDER BY r.uploaded_at DESC, r.filename ASC, r.id ASC`,
940
+ ),
941
+ // Bundles are scoped through the same team -> repository links as reports.
942
+ // Hidden bundles remain available to managers within their team scopes.
943
+ selectUserTeamBundlesStmt: db.prepare(
944
+ `SELECT DISTINCT tr.team_id AS teamId, b.id AS id, b.slug AS slug, b.integrity AS integrity,
945
+ 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
946
+ FROM managed_team_user tu
947
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
948
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
949
+ JOIN managed_bundle b ON b.repo_id = tr.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
950
+ JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
951
+ WHERE tu.user_id = ? AND (b.visible = 1 OR (SELECT role FROM managed_user WHERE id = tu.user_id) IN ('admin', 'manage'))
952
+ ORDER BY b.uploaded_at DESC, b.filename ASC, b.id ASC`,
953
+ ),
954
+ selectUserRepoScopesStmt: db.prepare(
955
+ `SELECT DISTINCT tr.repo_id AS repoId, NULLIF(tr.path, '') AS path
956
+ FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
957
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
958
+ WHERE tu.user_id = ?`,
959
+ ),
960
+ // A report is readable iff one of the user's team scopes contains it.
961
+ selectReportReadableStmt: db.prepare(
962
+ `SELECT 1 FROM managed_report r
963
+ JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
964
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id
965
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
966
+ WHERE r.id = ? AND tu.user_id = ? LIMIT 1`,
967
+ ),
968
+ selectBundleSecurityStmt: db.prepare(
969
+ `SELECT 1 FROM managed_bundle b
970
+ JOIN managed_team_repo tr ON tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
971
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id
972
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
973
+ WHERE b.id = ? AND tu.user_id = ? AND tu.view_security = 1
974
+ AND (? IS NULL OR tr.team_id = ?) LIMIT 1`,
975
+ ),
976
+ // The viewer's effective visibility permissions for a report: OR'd (MAX over
977
+ // 0/1) across memberships whose repository path contains the report. NULLs
978
+ // (no such membership) read as 0 = no permission.
979
+ selectReportPermsStmt: db.prepare(
980
+ `SELECT MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
981
+ FROM managed_report r
982
+ JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
983
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id AND tu.user_id = ?
984
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
985
+ WHERE r.id = ?`,
986
+ ),
987
+ selectUserOptionsStmt: db.prepare(`SELECT id, login, name, role FROM managed_user ORDER BY login ASC`),
988
+ selectTeamReposStmt: db.prepare(
989
+ `SELECT tr.team_id AS teamId, tr.repo_id AS repoId, sr.full_name AS fullName, NULLIF(tr.path, '') AS path
990
+ FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
991
+ ORDER BY sr.full_name ASC, tr.path ASC`,
992
+ ),
993
+ selectTeamMembersStmt: db.prepare(
994
+ `SELECT tu.team_id AS teamId, tu.user_id AS userId, u.login AS login,
995
+ tu.view_dependencies AS viewDependencies, tu.view_security AS viewSecurity
996
+ FROM managed_team_user tu JOIN managed_user u ON u.id = tu.user_id
997
+ ORDER BY u.login ASC`,
998
+ ),
999
+ upsertTeamRepoStmt: db.prepare(
1000
+ `INSERT OR IGNORE INTO managed_team_repo (team_id, repo_id, path)
1001
+ SELECT ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM managed_team_repo WHERE team_id = ? AND repo_id = ? AND path = '')`,
1002
+ ),
1003
+ deleteTeamRepoStmt: db.prepare(`DELETE FROM managed_team_repo WHERE team_id = ? AND repo_id = ?`),
1004
+ deleteTeamRepoPathStmt: db.prepare(`DELETE FROM managed_team_repo WHERE team_id = ? AND repo_id = ? AND path = ?`),
1005
+ upsertTeamMemberStmt: db.prepare(
1006
+ `INSERT INTO managed_team_user (team_id, user_id, view_dependencies, view_security) VALUES (?, ?, ?, ?)
1007
+ ON CONFLICT(team_id, user_id) DO UPDATE SET
1008
+ view_dependencies = excluded.view_dependencies, view_security = excluded.view_security`,
1009
+ ),
1010
+ deleteTeamMemberStmt: db.prepare(`DELETE FROM managed_team_user WHERE team_id = ? AND user_id = ?`),
1011
+ }
1012
+ }
1013
+
1014
+ type RepoRow = {
1015
+ repoId: number; fullName: string; priv: number; installId: number | null
1016
+ branch: string; cachedDefaultBranch: string | null; htmlUrl: string; addedBy: string | null; addedAt: number; active: number
1017
+ }
1018
+
1019
+ // The repo-selection slice of ManagedDb, split out to keep openSqliteManagedDb
1020
+ // within the per-function line budget. Closes over its prepared statements.
1021
+ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
1022
+ const { upsertRepoStmt, deleteRepoStmt, deactivateRepoStmt, reactivateRepoStmt, selectReposStmt, selectAllReposStmt,
1023
+ selectReportsForRepoStmt, selectBundlesForRepoStmt, deleteReportsForRepoStmt, deleteBundlesForRepoStmt } = stmts
1024
+ const readRepo = (r: RepoRow): SelectedRepo => ({
1025
+ repoId: r.repoId, fullName: r.fullName, private: r.priv === 1,
1026
+ installationId: r.installId, defaultBranch: r.branch, cachedDefaultBranch: r.cachedDefaultBranch, htmlUrl: r.htmlUrl,
1027
+ addedBy: r.addedBy, addedAt: r.addedAt,
1028
+ })
1029
+ const readManagedRepo = (r: RepoRow): ManagedRepo => ({ ...readRepo(r), active: r.active === 1 })
1030
+ return {
1031
+ async selectRepo(repo: SelectedRepoInput, now: number): Promise<void> {
1032
+ await upsertRepoStmt.run(
1033
+ repo.repoId, repo.fullName, repo.private ? 1 : 0, repo.installationId,
1034
+ repo.defaultBranch, repo.htmlUrl, repo.addedBy, now, now,
1035
+ )
1036
+ },
1037
+ async cacheRepoDefaultBranch(repo: SelectedRepo, branch: string | null): Promise<boolean> {
1038
+ return Number((await stmts.cacheRepoDefaultBranchStmt.run(branch, repo.repoId, repo.fullName, repo.addedAt)).changes) > 0
1039
+ },
1040
+ async deselectRepo(repoId: number): Promise<boolean> {
1041
+ return Number((await deleteRepoStmt.run(repoId)).changes) > 0
1042
+ },
1043
+ async connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean> {
1044
+ return Number((await stmts.connectRepoInstallationStmt.run(installationId, now, repo.repoId, repo.fullName, repo.addedAt, sessionId, now)).changes) > 0
1045
+ },
1046
+ async listSelectedRepos(): Promise<SelectedRepo[]> {
1047
+ const rows = (await selectReposStmt.all()) as RepoRow[]
1048
+ return rows.map(readRepo)
1049
+ },
1050
+ async listAllRepos(): Promise<ManagedRepo[]> {
1051
+ const rows = (await selectAllReposStmt.all()) as RepoRow[]
1052
+ return rows.map(readManagedRepo)
1053
+ },
1054
+ async deactivateRepo(repoId: number): Promise<boolean> {
1055
+ return Number((await deactivateRepoStmt.run(repoId)).changes) > 0
1056
+ },
1057
+ async reactivateRepo(repoId: number): Promise<boolean> {
1058
+ return Number((await reactivateRepoStmt.run(repoId)).changes) > 0
1059
+ },
1060
+ async deleteRepo(repoId: number): Promise<boolean> {
1061
+ return Number((await deleteRepoStmt.run(repoId)).changes) > 0
1062
+ },
1063
+ async listReportsForRepo(repoId: number): Promise<RepoReportItem[]> {
1064
+ return (await selectReportsForRepoStmt.all(repoId)) as unknown as RepoReportItem[]
1065
+ },
1066
+ async listBundlesForRepo(repoId: number): Promise<RepoDataItem[]> {
1067
+ return (await selectBundlesForRepoStmt.all(repoId)) as unknown as RepoDataItem[]
1068
+ },
1069
+ async deleteReportsForRepo(repoId: number): Promise<number> {
1070
+ return Number((await deleteReportsForRepoStmt.run(repoId)).changes)
1071
+ },
1072
+ async deleteBundlesForRepo(repoId: number): Promise<number> {
1073
+ return Number((await deleteBundlesForRepoStmt.run(repoId)).changes)
1074
+ },
1075
+ }
1076
+ }
1077
+
1078
+ type ReportListRow = {
1079
+ id: string; slug: string; filename: string; contentType: string; byteSize: number
1080
+ sha256: string; uploadedByLogin: string | null
1081
+ repoId: number | null; repoFullName: string | null
1082
+ repoDirectory: string; repoEmbedded: number; analyzer: string | null; visible: number
1083
+ bundleId: string | null; bundleFilename: string | null; bundleIntegrity: string | null
1084
+ uploadedAt: number
1085
+ }
1086
+ type ReportRow = {
1087
+ id: string; slug: string; filename: string; contentType: string; byteSize: number
1088
+ sha256: string; uploadedBy: string | null; uploadedAt: number
1089
+ repoId: number | null; repoDirectory: string; repoEmbedded: number; analyzer: string | null; visible: number
1090
+ bundleId: string | null
1091
+ }
1092
+
1093
+ // Byte storage may outlast a logout or grant change. HTTP uploads reauthorize
1094
+ // inside the metadata writer transaction after their bytes have been stored.
1095
+ async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sessionId: string | undefined,
1096
+ item: { uploadedBy: string | null; repoId: number | null; repoDirectory?: string }) {
1097
+ if (sessionId === undefined) return // Trusted imports and fixtures use the raw store API.
1098
+ const user = await stmts.selectOwnSessionStmt.get(sessionId, Date.now()) as SessionRow | undefined
1099
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
1100
+ if (user.uid !== item.uploadedBy || !roleAtLeast(user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
1101
+ if (item.repoId === null) return
1102
+ if (!(await stmts.selectReposStmt.all() as RepoRow[]).some(repo => repo.repoId === item.repoId)) throw new ManagedMutationError(400, 'repo-not-selected')
1103
+ if (user.role !== 'admin' && !await stmts.selectRepoPathReadableStmt.get(item.repoDirectory ?? '', item.repoId, user.uid)) throw new ManagedMutationError(403, 'repo-forbidden')
1104
+ }
1105
+
1106
+ // The report slice of ManagedDb closes over its prepared statements.
1107
+ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
1108
+ const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
1109
+ async function getReport(id: string): Promise<ReportRecord | null> {
1110
+ const row = (await selectReportStmt.get(id)) as ReportRow | undefined
1111
+ if (row == null) return null
1112
+ return { ...row, repoEmbedded: row.repoEmbedded === 1, visible: row.visible === 1 }
1113
+ }
1114
+ async function getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null> {
1115
+ const row = await stmts.selectReportByHashStmt.get(sha256, analyzer) as { id: string } | undefined
1116
+ return row ? getReport(row.id) : null
1117
+ }
1118
+ async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
1119
+ await validateStorageUpload(db, key, 'report', report.id, report.dataKey)
1120
+ await insertReportStmt.run(
1121
+ report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
1122
+ report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
1123
+ report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
1124
+ report.bundleIntegrity ?? null, now, report.dataKey ?? null, report.dataKey ? 1 : 0,
1125
+ )
1126
+ }
1127
+ return {
1128
+ insertReport,
1129
+ getReport,
1130
+ resolveReportUpload: getReport,
1131
+ getReportByHash,
1132
+ async insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord> {
1133
+ await authorizeUpload(stmts, sessionId, report)
1134
+ // The driver holds its writer lock for this whole operation. Checking
1135
+ // again here closes the race between the HTTP lookup and blob upload.
1136
+ const existing = await getReportByHash(report.sha256, report.analyzer ?? null)
1137
+ if (existing) return existing
1138
+ await insertReport(report, now)
1139
+ return (await getReport(report.id))!
1140
+ },
1141
+ async listReports(userId?: string): Promise<AdminReport[]> {
1142
+ const rows = (await selectReportsStmt.all(userId ?? null, userId ?? null, userId ?? null)) as ReportListRow[]
1143
+ return rows.map((r) => ({
1144
+ id: r.id, slug: r.slug, filename: r.filename, contentType: r.contentType, byteSize: r.byteSize,
1145
+ sha256: r.sha256, uploadedByLogin: r.uploadedByLogin,
1146
+ repoId: r.repoId, repoFullName: r.repoFullName,
1147
+ repoDirectory: r.repoDirectory, repoEmbedded: r.repoEmbedded === 1, analyzer: r.analyzer, visible: r.visible === 1,
1148
+ bundleId: r.bundleId, bundleFilename: r.bundleFilename, bundleIntegrity: r.bundleIntegrity,
1149
+ uploadedAt: r.uploadedAt,
1150
+ }))
1151
+ },
1152
+ async listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]> {
1153
+ return await stmts.findingCatalogReportsStmt.all(after, limit) as Pick<ReportRecord, 'id' | 'byteSize'>[]
1154
+ },
1155
+ async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string, reportId: string | null = null): Promise<TeamReportAccessSnapshot | null> {
1156
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1157
+ if (!session) return null
1158
+ const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
1159
+ if (user.role === 'none' || !await stmts.selectTeamAccessStmt.get(user.id, teamId)) return { user, teamId: null, reports: [], repositories: [] }
1160
+ const whole = user.role === 'admin' || user.role === 'manage'
1161
+ const rows = await stmts.selectTeamReportAccessStmt.all(user.id, teamId, whole ? 1 : 0, reportId) as {
1162
+ id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
1163
+ repoFullName: string | null; dependencies: number; security: number; visible: number
1164
+ }[]
1165
+ // Only privileged callers can receive a hidden row from this query. Give
1166
+ // that individual preview its own capacity; published report selections
1167
+ // still use the complete workspace for cross-report classification.
1168
+ const hidden = rows.find(row => row.id === reportId && row.visible === 0)
1169
+ const selected = hidden ? [hidden] : rows
1170
+ const repositories = await stmts.selectTeamRepositoriesStmt.all(teamId) as TeamReportAccessSnapshot['repositories']
1171
+ return { user, teamId, reportId, hiddenReportId: hidden?.id ?? null, repositories, linkRevision: await linkRevision(db), reports: selected.map(row => ({
1172
+ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
1173
+ repo: { github: row.repoFullName, directory: row.repoDirectory },
1174
+ permissions: { dependencies: whole || row.dependencies === 1, security: whole || row.security === 1 },
1175
+ })) }
1176
+ },
1177
+ async getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null> {
1178
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1179
+ if (!session) return null
1180
+ const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
1181
+ if (user.role === 'none' || ids.length === 0) return { user, reports: [] }
1182
+ const admin = user.role === 'admin', manage = user.role === 'manage'
1183
+ const rows = await stmts.selectReportAccessStmt.all(JSON.stringify([...new Set(ids)]), user.id,
1184
+ admin ? 1 : 0, manage ? 1 : 0, user.id, manage ? 1 : 0) as {
1185
+ id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
1186
+ repoFullName: string | null; dependencies: number; security: number
1187
+ }[]
1188
+ return { user, linkRevision: await linkRevision(db), reports: rows.map(row => ({
1189
+ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
1190
+ repo: { github: row.repoFullName, directory: row.repoDirectory },
1191
+ permissions: { dependencies: admin || manage || row.dependencies === 1, security: admin || manage || row.security === 1 },
1192
+ })) }
1193
+ },
1194
+ async listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]> {
1195
+ const rows = (await stmts.reportFilenamesWithBundleHashStmt.all(bundleId, sha256)) as { filename: string }[]
1196
+ return rows.map(row => row.filename)
1197
+ },
1198
+ async deleteReport(id: string): Promise<boolean> {
1199
+ return Number((await deleteReportStmt.run(id)).changes) > 0
1200
+ },
1201
+ async setReportRepo(id: string, repoId: number | null, repoDirectory = ''): Promise<boolean> {
1202
+ return Number((await setReportRepoStmt.run(repoId, repoId == null ? '' : repoDirectory, id)).changes) > 0
1203
+ },
1204
+ async setReportVisible(id: string, visible: boolean): Promise<boolean> {
1205
+ return Number((await setReportVisibleStmt.run(visible ? 1 : 0, id)).changes) > 0
1206
+ },
1207
+ }
1208
+ }
1209
+
1210
+ type TriageDbRow = {
1211
+ findingId: string; color: string | null; triage: string | null
1212
+ comment: string | null; fix: string | null; flagged: number | null
1213
+ updatedByLogin: string | null; updatedAt: number
1214
+ }
1215
+ type TriageStateDbRow = { color: string | null; triage: string | null; comment: string | null; fix: string | null; flagged: number | null }
1216
+ type TriageEventDbRow = TriageStateDbRow & {
1217
+ seq: number; findingId: string; batchId: string; actorId: string | null; actorLogin: string | null; actorName: string | null; at: number
1218
+ }
1219
+
1220
+ // The per-finding triage slice of ManagedDb. Closes over its prepared
1221
+ // statements (and the handle, for the batch write's transaction). A
1222
+ // null/empty entry writes the tombstone — every field NULL, writer and time
1223
+ // stamped — so a later reader learns the entry was cleared rather than never
1224
+ // set. Every change is also appended to the trail; a write equal to the
1225
+ // current row is skipped altogether, so a client re-pushing what already
1226
+ // stands neither re-stamps the writer nor echoes into the trail. `historyLimit`
1227
+ // > 0 keeps only that many events per finding; 0 keeps everything.
1228
+ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit: number, db: ManagedSql) {
1229
+ const { upsertTriageStmt, selectTriageStmt, selectTriageStateStmt, insertTriageEventStmt, trimTriageEventsStmt, selectTriageHistoryStmt,
1230
+ deleteTriageStmt, deleteTriageHistoryStmt, deleteCommentsStmt, deleteCommentHistoryStmt, countAnnotationsStmt } = stmts
1231
+ async function writeEntry(findingId: string, entry: TriageEntryPatch | null, batchId: string, updatedBy: string | null, updatedByLogin: string | null, now: number, reportId: string | null = null): Promise<void> {
1232
+ const e = entry ?? {}
1233
+ // `flagged: false` is a real value (the explicit un-flag tombstone), so it
1234
+ // is 0 here and only null/absent maps to NULL.
1235
+ const next: TriageStateDbRow = {
1236
+ color: e.color ?? null, triage: e.triage ?? null, comment: e.comment ?? null,
1237
+ fix: e.fix ?? null, flagged: e.flagged == null ? null : (e.flagged ? 1 : 0),
1238
+ }
1239
+ const cur = (await selectTriageStateStmt.get(findingId)) as TriageStateDbRow | undefined
1240
+ if (cur != null && cur.color === next.color && cur.triage === next.triage && cur.comment === next.comment
1241
+ && cur.fix === next.fix && cur.flagged === next.flagged) return
1242
+ await upsertTriageStmt.run(findingId, next.color, next.triage, next.comment, next.fix, next.flagged, updatedBy, updatedByLogin, now)
1243
+ await insertTriageEventStmt.run(findingId, batchId, next.color, next.triage, next.comment, next.fix, next.flagged, updatedBy, updatedByLogin, now, reportId, reportId, reportId)
1244
+ if (historyLimit > 0) await trimTriageEventsStmt.run(findingId, findingId, historyLimit)
1245
+ }
1246
+ return {
1247
+ async getAnnotationRevision(findingIds: readonly string[]): Promise<string> {
1248
+ if (findingIds.length === 0) return '[]'
1249
+ const ids = JSON.stringify(findingIds)
1250
+ // Batch UUIDs survive history trimming and distinguish same-timestamp
1251
+ // writes, even if SQLite reuses a sequence after an annotation purge.
1252
+ return JSON.stringify([
1253
+ await stmts.annotationTriageRevisionStmt.all(ids),
1254
+ await stmts.annotationCommentRevisionStmt.all(ids),
1255
+ await db.prepare(`SELECT finding_id, issue_url, auto_fix_url FROM managed_finding_issue
1256
+ WHERE finding_id IN (SELECT value FROM json_each(?)) AND issue_url IS NOT NULL ORDER BY finding_id`).all(ids),
1257
+ ])
1258
+ },
1259
+ async deleteTriage(findingIds: readonly string[]): Promise<number> {
1260
+ if (findingIds.length === 0) return 0
1261
+ const ids = JSON.stringify(findingIds)
1262
+ const { total: deleted } = (await countAnnotationsStmt.get(ids, ids, ids)) as { total: number }
1263
+ await deleteTriageStmt.run(ids)
1264
+ await deleteTriageHistoryStmt.run(ids)
1265
+ await deleteCommentsStmt.run(ids)
1266
+ await deleteCommentHistoryStmt.run(ids)
1267
+ return deleted
1268
+ },
1269
+ async listTriageHistory(findingId: string, limit: number): Promise<TriageEventRow[]> {
1270
+ const rows = (await selectTriageHistoryStmt.all(findingId, limit)) as TriageEventDbRow[]
1271
+ return rows.map((r) => ({
1272
+ seq: r.seq, findingId: r.findingId, batchId: r.batchId,
1273
+ color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1274
+ flagged: r.flagged == null ? null : r.flagged === 1,
1275
+ actorId: r.actorId, actorLogin: r.actorLogin, actorName: r.actorName, at: r.at,
1276
+ }))
1277
+ },
1278
+ async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
1279
+ if (findingIds.length === 0) return []
1280
+ const rows = (await selectTriageStmt.all(JSON.stringify(findingIds))) as TriageDbRow[]
1281
+ return rows.map((r) => ({
1282
+ findingId: r.findingId, color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1283
+ flagged: r.flagged == null ? null : r.flagged === 1,
1284
+ updatedByLogin: r.updatedByLogin, updatedAt: r.updatedAt,
1285
+ }))
1286
+ },
1287
+ async setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void> {
1288
+ // A single write is its own batch. The row and its event go together.
1289
+ await writeEntry(findingId, entry, randomUUID(), updatedBy, updatedByLogin, now)
1290
+ },
1291
+ async setTriageEntries(entries: readonly (readonly [string, TriageEntryPatch | null])[], updatedBy: string | null, updatedByLogin: string | null, now: number, reportId?: string): Promise<void> {
1292
+ // One transaction, so a batch is never half-applied by a mid-loop error
1293
+ // (and costs one fsync under synchronous = FULL, not one per row); one
1294
+ // batch id groups its rows in the trail.
1295
+ const batchId = randomUUID()
1296
+ if (new Set(entries.map(([id]) => id)).size !== entries.length) {
1297
+ // Preserve the ordered history of callers that supply repeated IDs.
1298
+ for (const [id, entry] of entries) await writeEntry(id, entry, batchId, updatedBy, updatedByLogin, now, reportId)
1299
+ return
1300
+ }
1301
+ const current = new Map((await selectTriageStmt.all(JSON.stringify(entries.map(([id]) => id))) as TriageDbRow[])
1302
+ .map(row => [row.findingId, row]))
1303
+ const changed = entries.flatMap(([id, entry]) => {
1304
+ const e = entry ?? {}, previous = current.get(id)
1305
+ const next = { color: e.color ?? null, triage: e.triage ?? null, comment: e.comment ?? null,
1306
+ fix: e.fix ?? null, flagged: e.flagged == null ? null : e.flagged ? 1 : 0 }
1307
+ if (previous && Object.entries(next).every(([key, value]) => previous[key as keyof TriageStateDbRow] === value)) return []
1308
+ return [[id, next.color, next.triage, next.comment, next.fix, next.flagged]]
1309
+ })
1310
+ // Bound SQL parameters while keeping the entire batch/history atomic.
1311
+ for (let offset = 0; offset < changed.length; offset += 200) {
1312
+ const rows = changed.slice(offset, offset + 200), values = rows.flat()
1313
+ const input = `WITH changes(finding_id, color, triage, comment, fix, flagged, ordinal) AS
1314
+ (VALUES ${rows.map((_, index) => `(?, ?, ?, ?, ?, CAST(? AS INTEGER), ${index})`).join(',')})`
1315
+ await db.prepare(`${input}
1316
+ INSERT INTO managed_finding_triage (finding_id, color, triage, comment, fix, flagged, updated_by, updated_by_login, updated_at)
1317
+ SELECT finding_id, color, triage, comment, fix, flagged, ?, ?, CAST(? AS BIGINT) FROM changes WHERE TRUE
1318
+ ON CONFLICT(finding_id) DO UPDATE SET color = excluded.color, triage = excluded.triage,
1319
+ comment = excluded.comment, fix = excluded.fix, flagged = excluded.flagged, updated_by = excluded.updated_by,
1320
+ updated_by_login = excluded.updated_by_login, updated_at = excluded.updated_at`).run(...values, updatedBy, updatedByLogin, now)
1321
+ await db.prepare(`${input}
1322
+ INSERT INTO managed_finding_triage_event (finding_id, batch_id, color, triage, comment, fix, flagged, actor_id, actor_login, at, report_id, report, repo)
1323
+ SELECT c.finding_id, ?, c.color, c.triage, c.comment, c.fix, c.flagged, ?, ?, CAST(? AS BIGINT), ?, r.filename, p.full_name
1324
+ FROM changes c LEFT JOIN managed_report r ON r.id = ? LEFT JOIN managed_selected_repo p ON p.repo_id = r.repo_id ORDER BY c.ordinal`)
1325
+ .run(...values, batchId, updatedBy, updatedByLogin, now, reportId ?? null, reportId ?? null)
1326
+ if (historyLimit > 0) {
1327
+ await db.prepare(`DELETE FROM managed_finding_triage_event WHERE seq IN (
1328
+ SELECT seq FROM (SELECT seq, ROW_NUMBER() OVER (PARTITION BY finding_id ORDER BY seq DESC) AS position
1329
+ FROM managed_finding_triage_event WHERE finding_id IN (SELECT value FROM json_each(?))) ranked WHERE position > ?)`)
1330
+ .run(JSON.stringify(rows.map(row => row[0])), historyLimit)
1331
+ }
1332
+ }
1333
+ },
1334
+ }
1335
+ }
1336
+
1337
+ type BundleRow = {
1338
+ visible: number
1339
+ id: string; slug: string; integrity: string; filename: string; kind: string | null; byteSize: number
1340
+ uploadedBy: string | null; repoId: number | null; repoDirectory: string; uploadedAt: number; provenance: BundleProvenance | null
1341
+ buildConditions: string | null
1342
+ }
1343
+ type BundleListRow = {
1344
+ visible: number
1345
+ id: string; slug: string; integrity: string; filename: string; kind: string | null; byteSize: number
1346
+ uploadedByLogin: string | null; repoId: number | null; repoFullName: string | null; repoDirectory: string; uploadedAt: number
1347
+ provenance: BundleProvenance | null
1348
+ }
1349
+
1350
+ function mapBundle(r: BundleRow): ManagedBundle {
1351
+ return {
1352
+ id: r.id, slug: r.slug, integrity: r.integrity, filename: r.filename, kind: r.kind,
1353
+ byteSize: r.byteSize, uploadedBy: r.uploadedBy, repoId: r.repoId, repoDirectory: r.repoDirectory, uploadedAt: r.uploadedAt, visible: r.visible === 1,
1354
+ provenance: r.provenance, buildConditions: r.buildConditions == null ? null : JSON.parse(r.buildConditions) as BundleBuildConditions,
1355
+ }
1356
+ }
1357
+
1358
+ // The bundle slice of ManagedDb. Closes over its prepared statements.
1359
+ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
1360
+ const {
1361
+ insertBundleStmt, selectBundleByIntegrityStmt, selectBundleStmt,
1362
+ selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
1363
+ } = stmts
1364
+ async function getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
1365
+ const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
1366
+ return row == null ? null : mapBundle(row)
1367
+ }
1368
+ return {
1369
+ async insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void> {
1370
+ await authorizeUpload(stmts, sessionId, bundle)
1371
+ await validateStorageUpload(db, key, 'bundle', bundle.id, bundle.dataKey)
1372
+ await insertBundleStmt.run(
1373
+ bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
1374
+ 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,
1375
+ bundle.provenance ?? null, bundle.buildConditions == null ? null : JSON.stringify(bundle.buildConditions),
1376
+ )
1377
+ },
1378
+ async reuseBundle(id: string, { provenance, filename, kind, rename, buildConditions }: BundleReuse): Promise<boolean> {
1379
+ return Number((await stmts.reuseBundleStmt.run({ id, provenance, filename, kind, rename: rename ? 1 : 0,
1380
+ buildConditions: buildConditions == null ? null : JSON.stringify(buildConditions) })).changes) > 0
1381
+ },
1382
+ getBundleByIntegrity,
1383
+ // The distinct method name selects a writer-locked reconciliation scope.
1384
+ resolveBundleUpload: getBundleByIntegrity,
1385
+ async getBundle(id: string): Promise<ManagedBundle | null> {
1386
+ const row = (await selectBundleStmt.get(id)) as BundleRow | undefined
1387
+ return row == null ? null : mapBundle(row)
1388
+ },
1389
+ async getBundleAccessSnapshot(sessionId: string, now: number, id: string, advisoriesTeamId?: string | null) {
1390
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1391
+ if (!session) return null
1392
+ const denied = { bundle: null, canReadAdvisories: false }
1393
+ if (!roleAtLeast(session.role, 'view')) return denied
1394
+ const row = await selectBundleStmt.get(id) as BundleRow | undefined
1395
+ if (!row) return denied
1396
+ const manager = roleAtLeast(session.role, 'manage')
1397
+ if (!manager && row.visible !== 1) return denied
1398
+ if (session.role !== 'admin') {
1399
+ const allowed = manager ? await selectBundleReadableStmt.get(id, session.uid, session.uid)
1400
+ : row.repoId !== null && await selectRepoPathReadableStmt.get(row.repoDirectory, row.repoId, session.uid)
1401
+ if (!allowed) return denied
1402
+ }
1403
+ const canReadAdvisories = manager || (advisoriesTeamId !== undefined
1404
+ && await stmts.selectBundleSecurityStmt.get(id, session.uid, advisoriesTeamId, advisoriesTeamId) != null)
1405
+ return { bundle: mapBundle(row), canReadAdvisories }
1406
+ },
1407
+ async listBundles(userId?: string): Promise<AdminBundle[]> {
1408
+ const rows = (await selectBundlesStmt.all(userId ?? null, userId ?? null, userId ?? null)) as BundleListRow[]
1409
+ return rows.map((r) => ({
1410
+ id: r.id, slug: r.slug, integrity: r.integrity, filename: r.filename, kind: r.kind, byteSize: r.byteSize,
1411
+ uploadedByLogin: r.uploadedByLogin, repoId: r.repoId, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, visible: r.visible === 1,
1412
+ uploadedAt: r.uploadedAt, provenance: r.provenance,
1413
+ }))
1414
+ },
1415
+ async userCanReadBundle(userId: string, id: string): Promise<boolean> {
1416
+ return (await selectBundleReadableStmt.get(id, userId, userId)) != null
1417
+ },
1418
+ async listReadableBundleIds(userId: string, ids: readonly string[]): Promise<string[]> {
1419
+ if (ids.length === 0) return []
1420
+ const rows = await stmts.selectReadableBundleIdsStmt.all(JSON.stringify([...new Set(ids)]), userId, userId) as { id: string }[]
1421
+ return rows.map(row => row.id)
1422
+ },
1423
+ async userCanReadRepo(userId: string, repoId: number): Promise<boolean> {
1424
+ return (await selectRepoReadableStmt.get(repoId, userId)) != null
1425
+ },
1426
+ async userCanReadRepoPath(userId: string, repoId: number, directory: string): Promise<boolean> {
1427
+ return (await selectRepoPathReadableStmt.get(directory, repoId, userId)) != null
1428
+ },
1429
+ async deleteBundle(id: string): Promise<boolean> {
1430
+ return Number((await deleteBundleStmt.run(id)).changes) > 0
1431
+ },
1432
+ async setBundleVisible(id: string, visible: boolean): Promise<boolean> {
1433
+ return Number((await stmts.setBundleVisibleStmt.run(visible ? 1 : 0, id)).changes) > 0
1434
+ },
1435
+ async setBundleRepo(id: string, repoId: number | null, directory?: string): Promise<boolean> {
1436
+ return Number((await setBundleRepoStmt.run(repoId, repoId == null ? '' : directory ?? '', id)).changes) > 0
1437
+ },
1438
+ async linkReportsToBundle(integrity: string, bundleId: string, userId?: string): Promise<void> {
1439
+ await linkReportsToBundleStmt.run(bundleId, integrity, userId ?? null, userId ?? null, userId ?? null)
1440
+ },
1441
+ }
1442
+ }
1443
+
1444
+ type TeamRow = { id: string; slug: string; name: string; hidden: number }
1445
+ type TeamRepoRow = { teamId: string; repoId: number; fullName: string; path: string | null }
1446
+ type TeamMemberRow = { teamId: string; userId: string; login: string; viewDependencies: number; viewSecurity: number }
1447
+
1448
+ // The team slice of ManagedDb. listTeams reads the three tables in full and
1449
+ // groups in JS (3 queries, not N+1) — fine for the handful of teams a managed
1450
+ // workspace has.
1451
+ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql) {
1452
+ const {
1453
+ insertTeamStmt, selectTeamByNameStmt, renameTeamStmt, setTeamHiddenStmt, deleteTeamStmt, selectTeamStmt,
1454
+ selectUserRepoScopesStmt, selectTeamsStmt, selectTeamsForUserStmt, selectUserTeamReportsStmt, selectUserTeamBundlesStmt, selectReportReadableStmt,
1455
+ selectReportPermsStmt, selectUserOptionsStmt, selectTeamReposStmt, selectTeamMembersStmt,
1456
+ upsertTeamRepoStmt, deleteTeamRepoStmt, deleteTeamRepoPathStmt, upsertTeamMemberStmt, deleteTeamMemberStmt,
1457
+ } = stmts
1458
+ const methods = {
1459
+ async createTeam(id: string, name: string, now: number): Promise<boolean> {
1460
+ return Number((await insertTeamStmt.run(id, preferredSlug(id), name, now, now)).changes) > 0
1461
+ },
1462
+ async renameTeam(id: string, name: string, now: number): Promise<'ok' | 'name-taken' | 'not-found'> {
1463
+ // The operation transaction serializes the existence/name checks and rename.
1464
+ if ((await selectTeamStmt.get(id)) == null) return 'not-found'
1465
+ const clash = (await selectTeamByNameStmt.get(name)) as { id: string } | undefined
1466
+ if (clash != null && clash.id !== id) return 'name-taken'
1467
+ await renameTeamStmt.run(name, now, id)
1468
+ return 'ok'
1469
+ },
1470
+ async deleteTeam(id: string): Promise<boolean> {
1471
+ return Number((await deleteTeamStmt.run(id)).changes) > 0
1472
+ },
1473
+ async setTeamHidden(id: string, hidden: boolean, now: number): Promise<boolean> {
1474
+ return Number((await setTeamHiddenStmt.run(hidden ? 1 : 0, now, id)).changes) > 0
1475
+ },
1476
+ async getTeam(id: string): Promise<{ id: string; slug: string; name: string; hidden: boolean } | null> {
1477
+ const row = (await selectTeamStmt.get(id)) as TeamRow | undefined
1478
+ return row == null ? null : { id: row.id, slug: row.slug, name: row.name, hidden: row.hidden === 1 }
1479
+ },
1480
+ async listUserOptions(): Promise<UserOption[]> {
1481
+ return ((await selectUserOptionsStmt.all()) as UserOption[]).map((u) => ({ id: u.id, login: u.login, name: u.name, role: u.role }))
1482
+ },
1483
+ async listRepoScopesForUser(userId: string): Promise<{ repoId: number; path: string | null }[]> {
1484
+ return (await selectUserRepoScopesStmt.all(userId)) as { repoId: number; path: string | null }[]
1485
+ },
1486
+ async listTeamsForUser(userId: string): Promise<UserTeam[]> {
1487
+ const teams = (await selectTeamsForUserStmt.all(userId)) as { id: string; slug: string; name: string; dependencyReview: number; dependencies: number; security: number }[]
1488
+ const scopes = await stmts.selectUserTeamScopesStmt.all(userId) as { teamId: string; repoId: number; path: string; fullName: string }[]
1489
+ const links = await linkRevision(db)
1490
+ const reportsByTeam = new Map<string, UserTeamReport[]>()
1491
+ for (const r of (await selectUserTeamReportsStmt.all(userId)) as {
1492
+ teamId: string; id: string; slug: string; filename: string; analyzer: string | null; sha256: string; bundleId: string | null; repoId: number
1493
+ repoDirectory: string; repoFullName: string | null; visible: number; dependencies: number; security: number; role: string
1494
+ }[]) {
1495
+ const list = reportsByTeam.get(r.teamId) ?? []
1496
+ const cacheKey = createHash('sha256').update(JSON.stringify([
1497
+ r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role, links,
1498
+ ])).digest('base64url')
1499
+ 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 })
1500
+ reportsByTeam.set(r.teamId, list)
1501
+ }
1502
+ const bundlesByTeam = new Map<string, UserTeamBundle[]>()
1503
+ for (const b of (await selectUserTeamBundlesStmt.all(userId)) as (Omit<UserTeamBundle, 'visible'> & { teamId: string; visible: number })[]) {
1504
+ const list = bundlesByTeam.get(b.teamId) ?? []
1505
+ list.push({ id: b.id, slug: b.slug, integrity: b.integrity, filename: b.filename, visible: b.visible === 1,
1506
+ kind: b.kind, byteSize: b.byteSize, repoId: b.repoId, repoDirectory: b.repoDirectory, repoFullName: b.repoFullName, provenance: b.provenance })
1507
+ bundlesByTeam.set(b.teamId, list)
1508
+ }
1509
+ return teams.map((t) => ({
1510
+ id: t.id, slug: t.slug, name: t.name,
1511
+ ...(t.dependencyReview === 1 ? { dependencyReview: true } : {}),
1512
+ permissions: { dependencies: t.dependencies === 1, security: t.security === 1 },
1513
+ cacheKey: createHash('sha256').update(JSON.stringify([
1514
+ t.dependencies, t.security, links, scopes.filter(scope => scope.teamId === t.id),
1515
+ ])).digest('base64url'),
1516
+ reports: reportsByTeam.get(t.id) ?? [],
1517
+ bundles: bundlesByTeam.get(t.id) ?? [],
1518
+ }))
1519
+ },
1520
+ async getFeedState(sessionId: string, now: number) {
1521
+ const row = await db.prepare(`SELECT u.id, u.role, r.catalog, r.annotations FROM managed_session s
1522
+ JOIN managed_user u ON u.id = s.user_id JOIN managed_change_revision r ON r.id = 1
1523
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, now) as { id: string; role: Role; catalog: number; annotations: number } | undefined
1524
+ return row ? { user: { id: row.id, role: row.role }, catalog: row.catalog, annotations: row.annotations } : null
1525
+ },
1526
+ async getUserTeamFeedSnapshot(sessionId: string, now: number) {
1527
+ // One short read-only transaction, shared by SQLite and Postgres. Never
1528
+ // touch last-seen timestamps or include other users' memberships.
1529
+ const state = await methods.getFeedState(sessionId, now)
1530
+ if (!state) return null
1531
+ const teams = state.user.role === 'none' ? [] : await methods.listTeamsForUser(state.user.id)
1532
+ return { user: state.user, catalog: state.catalog, teams, revision: teamCatalogRevision(teams) }
1533
+ },
1534
+ async userCanReadReport(userId: string, reportId: string): Promise<boolean> {
1535
+ return (await selectReportReadableStmt.get(reportId, userId)) != null
1536
+ },
1537
+ async userCanReadBundleAdvisories(userId: string, bundleId: string, teamId: string | null): Promise<boolean> {
1538
+ return (await stmts.selectBundleSecurityStmt.get(bundleId, userId, teamId, teamId)) != null
1539
+ },
1540
+ async reportPermissionsFor(userId: string, reportId: string): Promise<TeamUserPermissions> {
1541
+ const row = (await selectReportPermsStmt.get(userId, reportId)) as { dependencies: number | null; security: number | null } | undefined
1542
+ return { dependencies: row?.dependencies === 1, security: row?.security === 1 }
1543
+ },
1544
+ async listTeams(): Promise<AdminTeam[]> {
1545
+ const teams = (await selectTeamsStmt.all()) as TeamRow[]
1546
+ const reposByTeam = new Map<string, TeamRepoLink[]>()
1547
+ for (const r of (await selectTeamReposStmt.all()) as TeamRepoRow[]) {
1548
+ const list = reposByTeam.get(r.teamId) ?? []
1549
+ list.push({ repoId: r.repoId, fullName: r.fullName, path: r.path })
1550
+ reposByTeam.set(r.teamId, list)
1551
+ }
1552
+ const membersByTeam = new Map<string, TeamMember[]>()
1553
+ for (const m of (await selectTeamMembersStmt.all()) as TeamMemberRow[]) {
1554
+ const list = membersByTeam.get(m.teamId) ?? []
1555
+ list.push({ userId: m.userId, login: m.login, dependencies: m.viewDependencies === 1, security: m.viewSecurity === 1 })
1556
+ membersByTeam.set(m.teamId, list)
1557
+ }
1558
+ return teams.map((t) => ({
1559
+ id: t.id, slug: t.slug, name: t.name,
1560
+ hidden: t.hidden === 1,
1561
+ repos: reposByTeam.get(t.id) ?? [],
1562
+ members: membersByTeam.get(t.id) ?? [],
1563
+ }))
1564
+ },
1565
+ async setTeamRepo(teamId: string, repoId: number, path: string | null): Promise<void> {
1566
+ // A whole-repository grant supersedes all its path grants atomically.
1567
+ // Adding a redundant path to an existing whole-repo grant is a no-op.
1568
+ if (path == null || path === '') await deleteTeamRepoStmt.run(teamId, repoId)
1569
+ await upsertTeamRepoStmt.run(teamId, repoId, path ?? '', teamId, repoId)
1570
+ },
1571
+ async removeTeamRepo(teamId: string, repoId: number, path?: string | null): Promise<boolean> {
1572
+ const result = path === undefined ? (await deleteTeamRepoStmt.run(teamId, repoId)) : (await deleteTeamRepoPathStmt.run(teamId, repoId, path ?? ''))
1573
+ return Number(result.changes) > 0
1574
+ },
1575
+ async setTeamMember(teamId: string, userId: string, perms: TeamUserPermissions): Promise<void> {
1576
+ await upsertTeamMemberStmt.run(teamId, userId, perms.dependencies ? 1 : 0, perms.security ? 1 : 0)
1577
+ },
1578
+ async removeTeamMember(teamId: string, userId: string): Promise<boolean> {
1579
+ return Number((await deleteTeamMemberStmt.run(teamId, userId)).changes) > 0
1580
+ },
1581
+ }
1582
+ return methods
1583
+ }
1584
+
1585
+ // `triageHistoryLimit`: events kept per finding in the triage trail; 0 (the
1586
+ // default) keeps everything. See ManagedConfig.
1587
+ export interface ManagedDbOptions {
1588
+ storageEncryptionKey?: StorageKey | null
1589
+ triageHistoryLimit?: number
1590
+ }
1591
+
1592
+ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions = {}): ManagedDb {
1593
+ const key = options.storageEncryptionKey ?? null
1594
+ const comments = commentMethods(db)
1595
+ const activity = activityMethods(db)
1596
+ const stmts = prepareStatements(db)
1597
+ const reports = reportMethods(stmts, db, key)
1598
+ const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0, db)
1599
+ const {
1600
+ upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectOwnSessionStmt, selectUsersStmt,
1601
+ touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, ensureUploadKeyStmt, deleteSessionStmt, deleteExpiredStmt,
1602
+ selectViewSessionStmt, deleteOtherViewsStmt, insertViewSessionStmt, deleteDemotedViewsStmt,
1603
+ } = stmts
1604
+
1605
+ const methods: Omit<ManagedDb, keyof ManagementStore | keyof ManagementCatalogStore> = {
1606
+ ...storageMethods(db, key),
1607
+ ...linkReportMethods(db, key),
1608
+ ...repositoryAliasMethods(db),
1609
+ ...teamNpmScopeMethods(db),
1610
+ ...dependencyReviewMethods(db),
1611
+ ...bundleBuildLeaseMethods(db),
1612
+ ...upstreamCacheMethods(db),
1613
+ async claimMaintenanceLease(owner, now, until, migration = false) {
1614
+ return !!await db.prepare(`INSERT INTO managed_maintenance_lease (id, owner, expires_at) VALUES (?, ?, ?)
1615
+ ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
1616
+ WHERE managed_maintenance_lease.expires_at <= ? RETURNING id`).get(migration ? 2 : 1, owner, until, now)
1617
+ },
1618
+ async finishMaintenanceLease(owner, until) {
1619
+ await db.prepare('UPDATE managed_maintenance_lease SET expires_at = ? WHERE owner = ?').run(until, owner)
1620
+ },
1621
+ async upsertUser(user, now, initialAdminGithubId = null) {
1622
+ // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1623
+ // user's id (DO UPDATE leaves it untouched), so re-read to return it.
1624
+ await upsertUserStmt.run(randomUUID(), user.githubUserId, user.login, user.name, user.avatarUrl, now, now)
1625
+ const row = (await selectUserIdStmt.get(user.githubUserId)) as { id: string; role: Role }
1626
+ // Avoid the population check unless this login matches the configured
1627
+ // identity and still needs approval. NOT EXISTS stops at any other user.
1628
+ if (initialAdminGithubId != null && Number.isSafeInteger(initialAdminGithubId) && initialAdminGithubId > 0
1629
+ && user.githubUserId === initialAdminGithubId && row.role === 'none') {
1630
+ await promoteInitialAdminStmt.run(row.id, row.id)
1631
+ }
1632
+ return row.id
1633
+ },
1634
+ async createSession(session, now) {
1635
+ await insertSessionStmt.run(session.id, session.userId, session.csrfToken, now, session.expiresAt)
1636
+ await touchUserSeenStmt.run(now, session.userId, now)
1637
+ },
1638
+ async sessionWithUser(id, now) {
1639
+ const row = (await selectOwnSessionStmt.get(id, now)) as SessionRow | undefined
1640
+ if (row == null) return null
1641
+ await touchUserSeenStmt.run(now, row.uid, now)
1642
+ return {
1643
+ session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey },
1644
+ user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
1645
+ }
1646
+ },
1647
+ async createViewSession(view, now) {
1648
+ if (Number((await insertViewSessionStmt.run(view.id, view.csrfToken, now, view.userId, view.viewerSessionId, now)).changes) === 0) return false
1649
+ await deleteOtherViewsStmt.run(view.viewerSessionId, view.id)
1650
+ return true
1651
+ },
1652
+ async viewSessionWithUser(id, viewerSessionId, now) {
1653
+ const row = (await selectViewSessionStmt.get(id, viewerSessionId, now, now)) as ViewSessionRow | undefined
1654
+ if (row == null) return null
1655
+ await touchUserSeenStmt.run(now, row.viewerId, now)
1656
+ return {
1657
+ session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey,
1658
+ viewer: { id: row.viewerId, login: row.viewerLogin, name: row.viewerName } },
1659
+ user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
1660
+ }
1661
+ },
1662
+ async ensureSessionUploadKey(id, candidate) {
1663
+ const row = (await ensureUploadKeyStmt.get(candidate, id)) as { uploadKey: string } | undefined
1664
+ return row?.uploadKey ?? null
1665
+ },
1666
+ async deleteSession(id) {
1667
+ await deleteSessionStmt.run(id)
1668
+ },
1669
+ async deleteExpiredSessions(now) {
1670
+ return Number((await deleteExpiredStmt.run(now)).changes)
1671
+ },
1672
+ async listUsers() {
1673
+ const rows = (await selectUsersStmt.all()) as UserRow[]
1674
+ 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 }))
1675
+ },
1676
+ async setUserRole(id, role) {
1677
+ if (role !== 'admin') await deleteDemotedViewsStmt.run(id)
1678
+ return Number((await updateRoleStmt.run(role, Date.now(), id)).changes) > 0
1679
+ },
1680
+ async setUserTokens(id, tokens) {
1681
+ const state = await storageState(db, key)
1682
+ const value = state ? encodeStorageTokens(key!, id, tokens.accessToken, tokens.refreshToken) : { access: tokens.accessToken, refresh: tokens.refreshToken }
1683
+ await updateTokensStmt.run(value.access, value.refresh, tokens.expiresAt, state ? 1 : 0, Date.now(), id)
1684
+ },
1685
+ async getUserTokens(id) {
1686
+ const state = await storageState(db, key)
1687
+ const row = (await selectTokensStmt.get(id)) as StoredTokens | undefined
1688
+ if (row == null || row.access == null) return null
1689
+ if (row.encrypted && !state) throw new Error('Encrypted tokens without storage encryption state')
1690
+ if (!row.encrypted && state?.complete) throw new Error('Unexpected plaintext GitHub tokens')
1691
+ const value = row.encrypted ? decodeStorageTokens(key!, id, row) : row
1692
+ return { accessToken: value.access!, refreshToken: value.refresh, expiresAt: row.exp }
1693
+ },
1694
+ async getUserGithubId(id) {
1695
+ const row = (await selectGithubIdStmt.get(id)) as { githubId: number } | undefined
1696
+ return row?.githubId ?? null
1697
+ },
1698
+ ...githubMetadataMethods(db),
1699
+ ...githubCommitMethods(db),
1700
+ ...managedIssueMethods(db),
1701
+ ...selectedRepoMethods(stmts),
1702
+ ...activity,
1703
+ ...comments,
1704
+ async getAnnotations(ids) {
1705
+ return { triage: await triage.listTriage(ids), comments: await comments.listComments(ids), issues: await methods.listManagedIssues(ids) }
1706
+ },
1707
+ async applyManagedIssueFixes(sessionId, snapshot, updates) {
1708
+ // Recheck access inside the same write transaction as the derived field.
1709
+ const current = await methods.getTeamReportAccessSnapshot(sessionId, Date.now(), snapshot.teamId!, snapshot.reportId)
1710
+ if (!current?.teamId || JSON.stringify(current) !== JSON.stringify(snapshot)) return false
1711
+ for (const update of updates) await methods.setManagedIssueAutoFix(update.findingId, update.issueUrl, update.previous, update.next, update.checkedAt)
1712
+ return true
1713
+ },
1714
+ ...workspaceShareMethods(db),
1715
+ ...reports,
1716
+ ...triage,
1717
+ ...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
1718
+ ...bundleMethods(stmts, db, key),
1719
+ ...teamMethods(stmts, db),
1720
+ async close() {
1721
+ await db.close()
1722
+ },
1723
+ }
1724
+ return { ...methods, ...managementMethods(methods), ...managementCatalogMethods(methods) }
1725
+ }