@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.51

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