@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,38 @@
1
+ import type { DatabaseSync } from 'node:sqlite'
2
+
3
+ // Legacy non-UUID IDs stay intact. Production IDs are UUIDs; only their final
4
+ // component is proposed as a slug, never an arbitrary prefix or abbreviation.
5
+ export function preferredSlug(id: string): string {
6
+ return /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-([a-f\d]{12})$/iu.exec(id)?.[1] ?? id
7
+ }
8
+
9
+ export function allocateMissingSlugs(rows: { id: string; slug: string | null }[]): { id: string; slug: string }[] {
10
+ // Reserve legacy IDs before UUID suffixes can claim them.
11
+ const used = new Set(rows.flatMap(row => row.slug == null ? [row.id] : [row.id, row.slug]))
12
+ const allocated = []
13
+ for (const row of rows) {
14
+ if (row.slug != null) continue
15
+ const candidate = preferredSlug(row.id)
16
+ const slug = used.has(candidate) ? row.id : candidate
17
+ allocated.push({ id: row.id, slug })
18
+ used.add(slug)
19
+ }
20
+ return allocated
21
+ }
22
+
23
+ // Allocate once, across the entire namespace rather than a viewer's teams.
24
+ // Keep existing slugs stable when names, memberships, or collisions change.
25
+ export function migrateSlugs(db: DatabaseSync): void {
26
+ db.exec('BEGIN IMMEDIATE')
27
+ try {
28
+ for (const table of ['managed_team', 'managed_report', 'managed_bundle']) {
29
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
30
+ if (!columns.some(column => column.name === 'slug')) db.exec(`ALTER TABLE ${table} ADD COLUMN slug TEXT`)
31
+ const rows = db.prepare(`SELECT id, slug FROM ${table} ORDER BY id`).all() as { id: string; slug: string | null }[]
32
+ const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?`)
33
+ for (const row of allocateMissingSlugs(rows)) update.run(row.slug, row.id)
34
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${table}_slug_idx ON ${table}(slug)`)
35
+ }
36
+ db.exec('COMMIT')
37
+ } catch (err) { db.exec('ROLLBACK'); throw err }
38
+ }
@@ -0,0 +1,71 @@
1
+ import { normalizeTeamPath } from './repo-path.ts'
2
+
3
+ const MAX_SUGGESTIONS = 100
4
+ const EXCLUDED_DIRECTORIES = new Set(['test', 'tests', 'script', 'scripts', 'mock', 'mocks', 'node_modules', 'vendor', 'out', 'artifacts', 'cache', 'build', 'dist', 'broadcast'])
5
+
6
+ function sourcePath(value: unknown): value is string {
7
+ if (typeof value !== 'string' || !value.endsWith('.sol') || /\.(?:t|s|test|spec)\.sol$/iu.test(value)) return false
8
+ const normalized = normalizeTeamPath(value)
9
+ const directories = value.split('/').slice(0, -1).map(part => part.toLowerCase())
10
+ const sourceRoot = directories.findIndex(part => part === 'src' || part === 'contracts')
11
+ return normalized.ok && normalized.path === value && !directories.some((part, index) =>
12
+ EXCLUDED_DIRECTORIES.has(part) || (part === 'lib' && (sourceRoot < 0 || index < sourceRoot)))
13
+ }
14
+
15
+ type ListingEntry = { name?: unknown; path?: unknown; type?: unknown; sha?: unknown; submodule_git_url?: unknown }
16
+
17
+ // Maintain the alphabetically first 100 unique candidates even when GitHub's
18
+ // input is unordered. Return whether a unique candidate exceeds the capacity.
19
+ export function addSoliditySuggestion(paths: string[], path: string): boolean {
20
+ let start = 0
21
+ let end = paths.length
22
+ while (start < end) {
23
+ const middle = Math.floor((start + end) / 2)
24
+ if (paths[middle]! < path) start = middle + 1
25
+ else end = middle
26
+ }
27
+ if (paths[start] === path) return false
28
+ const full = paths.length === MAX_SUGGESTIONS
29
+ if (start < MAX_SUGGESTIONS) {
30
+ // Evict before inserting, so retained candidates never exceed the cap.
31
+ if (full) paths.pop()
32
+ paths.splice(start, 0, path)
33
+ }
34
+ return full
35
+ }
36
+
37
+ // Suggestions from filenames, not deployability or import analysis. Read at
38
+ // most two source trees, using SHAs from the authorized pinned directory.
39
+ export async function readSolidityEntryPoints(directory: string, listing: unknown[], read: (suffix: string) => Promise<unknown>) {
40
+ const prefix = directory ? directory + '/' : ''
41
+ const entries = listing.filter((entry): entry is ListingEntry => !!entry && typeof entry === 'object').filter(entry =>
42
+ typeof entry.name === 'string' && !entry.name.includes('/') && entry.path === prefix + entry.name && !entry.submodule_git_url)
43
+ const hasFramework = entries.some(entry => entry.type === 'file' && typeof entry.name === 'string'
44
+ && (entry.name === 'foundry.toml' || /^hardhat\.config\.(?:[cm]?js|ts)$/u.test(entry.name)))
45
+ const roots = entries.filter(entry => entry.type === 'dir' && (entry.name === 'contracts' || (hasFramework && entry.name === 'src'))
46
+ && typeof entry.sha === 'string' && /^[a-f\d]{40}$/iu.test(entry.sha)).slice(0, 2)
47
+ const paths: string[] = []
48
+ let limited = false
49
+ for (const entry of entries) {
50
+ if (entry.type === 'file' && sourcePath(entry.path) && addSoliditySuggestion(paths, entry.path)) limited = true
51
+ }
52
+ // Both roots' listings asked together, then read in order.
53
+ const listings = roots.map(root => Promise.resolve().then(() => (sourcePath(`${root.path}/Contract.sol`) ? read(`/git/trees/${root.sha}?recursive=1`) : null)))
54
+ for (const listed of listings) listed.catch(() => {})
55
+ for (const [i, root] of roots.entries()) {
56
+ try {
57
+ const data = await listings[i] as { tree?: unknown; truncated?: unknown } | null
58
+ if (!Array.isArray(data?.tree)) continue
59
+ limited ||= data.truncated === true
60
+ for (const entry of data.tree) {
61
+ if (entry?.type !== 'blob' || !['100644', '100755'].includes(entry.mode) || typeof entry.path !== 'string') continue
62
+ const path = `${root.path}/${entry.path}`
63
+ if (sourcePath(path) && addSoliditySuggestion(paths, path)) limited = true
64
+ }
65
+ } catch {
66
+ // Optional suggestions must not block browsing. The request handler
67
+ // rechecks GitHub access and local path grants after all reads.
68
+ }
69
+ }
70
+ return { paths, limited }
71
+ }
@@ -0,0 +1,34 @@
1
+ // The small SQL dialect used by the shared store. Parameter values never pass
2
+ // through these rewrites; only our own SQL does. Keep aliases case-sensitive.
3
+ export function postgresSql(source: string): { sql: string; names: string[] } {
4
+ const names: string[] = []
5
+ let position = 0
6
+ let sql = source.replaceAll('json_each(:contexts)', 'jsonb_array_elements(:contexts::jsonb)')
7
+ .replaceAll("CAST(unixepoch('subsec') * 1000 AS INTEGER)", 'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)')
8
+ .replaceAll(/json_extract\((\w+\.value), '\$\.(\w+)'\)/gu, "($1->>'$2')")
9
+ .replaceAll('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
10
+ .replaceAll('INSERT OR IGNORE INTO', 'INSERT INTO')
11
+ .replaceAll('CROSS JOIN', 'JOIN')
12
+ .replaceAll(/\binstr\(/gu, 'strpos(')
13
+ // Rows gathered as one JSON array value, NULL (not '[]') where none are.
14
+ .replaceAll(/\bjson_group_array\(/gu, 'json_agg(')
15
+ .replaceAll(/\bjson_object\(/gu, 'json_build_object(')
16
+ // Quoted literals (including ':') are never placeholders or identifiers.
17
+ sql = sql.split(/('(?:''|[^'])*')/u).map((part, i) => i % 2 ? part : part
18
+ .replaceAll(/\?|(?<!:):([a-zA-Z]\w*)/gu, (_match, name: string | undefined) => {
19
+ if (!name) return `$${++position}`
20
+ let index = names.indexOf(name)
21
+ if (index < 0) { index = names.length; names.push(name) }
22
+ return `$${index + 1}`
23
+ })
24
+ .replaceAll(/\b[a-z]+[A-Z]\w*\b/gu, name => `"${name}"`)
25
+ ).join('')
26
+ sql = sql.replaceAll(/(\$\d+) IS NULL/gu, '$1::text IS NULL')
27
+ if (source.includes('INSERT OR IGNORE')) sql += ' ON CONFLICT DO NOTHING'
28
+ return { sql, names }
29
+ }
30
+
31
+ export function postgresSchema(sql: string): string {
32
+ return sql.replaceAll(') STRICT;', ');').replaceAll(/\bINTEGER\b/gu, 'BIGINT')
33
+ .replaceAll(/seq\s+BIGINT PRIMARY KEY/gu, 'seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY')
34
+ }
@@ -0,0 +1,88 @@
1
+ // Each public store operation owns a transaction. SQLite serializes access to
2
+ // its single connection across awaits; Neon can reuse a request-owned connection.
3
+ import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
4
+ import type { ManagedDb } from './db-methods.ts'
5
+ import { countStatement, timed } from './request-timing.ts'
6
+
7
+ type MaybePromise<T> = T | Promise<T>
8
+ export interface ManagedSql {
9
+ prepare(sql: string): {
10
+ get(...params: unknown[]): MaybePromise<unknown>
11
+ all(...params: unknown[]): MaybePromise<unknown[]>
12
+ run(...params: unknown[]): MaybePromise<{ changes: number | bigint }>
13
+ }
14
+ close(): MaybePromise<void>
15
+ }
16
+ export interface ManagedSqlDriver extends ManagedSql {
17
+ scope<T>(write: boolean, work: () => Promise<T>, options?: { lock?: boolean; statement?: boolean }): Promise<T>
18
+ }
19
+
20
+ // These operations execute exactly one SQL statement. Postgres already gives
21
+ // that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
22
+ // Lease and cache writes coordinate through their conditional UPSERT/UPDATE alone.
23
+ const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption', 'getTeam', 'getComment',
24
+ 'userCanReadReport', 'userCanReadRepo', 'userCanReadRepoPath', 'userCanReadBundle', 'reportPermissionsFor', 'listComments', 'listManagedIssues',
25
+ 'listTriageHistory', 'listUsers', 'listUserOptions', 'listActivityReports', 'getRepoFullName', 'getUser', 'getSessionWithUser', 'getViewSessionWithUser',
26
+ 'listTriageFixes', 'getRepoDependencyReviews', 'listTeamDependencyReviews', 'getNpmReviewReader',
27
+ 'listDependencyReviewTeams', 'listDependencyReviewEnabledTeams', 'listUserNpmScopes', 'listTeamNpmScopes', 'hasTeamNpmScope',
28
+ 'listGithubMetadata', 'listGithubRepositoryVisibility', 'setGithubMetadata', 'recordGithubMetadataAttempts', 'setGithubRepositoryVisibility',
29
+ 'listLinkReports', 'listLegacyLinkReports', 'listReportFilenamesWithBundleHash', 'listReportsForRepo', 'listBundlesForRepo',
30
+ 'listFindingCatalogReports', 'userCanReadBundleAdvisories',
31
+ 'sessionWithUser', 'getTeamReportAccessSnapshot', 'getReportAccessSnapshot', 'getBundleAccessSnapshot', 'getReportView', 'getUserGithubId', 'getUserTeamFeedSnapshot', 'getAnnotations', 'getAnnotationRevision', 'getUserTokens',
32
+ 'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
33
+ 'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
34
+ 'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'getFirstUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
35
+ 'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits', 'getAvatarKey', 'listUsersByAccount'])
36
+ // Session presence and upload keys change only their own rows.
37
+ const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
38
+
39
+ export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
40
+ const entries = Object.entries(methods).map(([name, method]) => {
41
+ if (name === 'close') return [name, async () => { await driver.close() }]
42
+ const write = !/^(?:get|list|has|userCanRead|reportPermissionsFor)/u.test(name)
43
+ // Each operation's time told in its request's Server-Timing (request-timing.ts).
44
+ return [name, (...args: unknown[]) => timed('db', () => driver.scope(write, () => method(...args), {
45
+ // Presence writes only one user's timestamp; they need no application
46
+ // writer lock. Keep the session/user read and timestamp in one transaction.
47
+ lock: write && !UNLOCKED_WRITES.has(name), statement: SINGLE_STATEMENTS.has(name),
48
+ }))]
49
+ })
50
+ return Object.fromEntries(entries) as ManagedDb
51
+ }
52
+
53
+ export function createSqliteDriver(db: DatabaseSync): ManagedSqlDriver {
54
+ let queue = Promise.resolve()
55
+ let closed = false
56
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
57
+ const result = queue.then(work)
58
+ queue = result.then(() => undefined, () => undefined)
59
+ return result
60
+ }
61
+ return {
62
+ prepare(sql) {
63
+ const stmt = db.prepare(sql)
64
+ return {
65
+ get: (...args) => { countStatement(); return stmt.get(...args as SQLInputValue[]) },
66
+ all: (...args) => { countStatement(); return stmt.all(...args as SQLInputValue[]) },
67
+ run: (...args) => { countStatement(); return stmt.run(...args as SQLInputValue[]) },
68
+ }
69
+ },
70
+ scope(write, work) {
71
+ if (closed) return Promise.reject(new Error('Managed database is closed'))
72
+ return enqueue(async () => {
73
+ countStatement()
74
+ db.exec(write ? 'BEGIN IMMEDIATE' : 'BEGIN')
75
+ try {
76
+ const result = await work()
77
+ countStatement()
78
+ db.exec('COMMIT')
79
+ return result
80
+ } catch (err) {
81
+ db.exec('ROLLBACK')
82
+ throw err
83
+ }
84
+ })
85
+ },
86
+ async close() { closed = true; await queue; db.close() },
87
+ }
88
+ }
@@ -0,0 +1,28 @@
1
+ import { type StaticHandler, loadStatic } from '../server-e2e/static.ts'
2
+ import { parseManagedRoute } from '../common/managed/routes.js'
3
+
4
+ // A deep page URL must still resolve every asset at the app root. Preload
5
+ // headers resolve against the request URL, so their hrefs must be absolute too.
6
+ export function managedPageHtml(html: string): string {
7
+ return html.replaceAll(/(href|src)="\.\//gu, '$1="/')
8
+ }
9
+
10
+ export function loadManagedStatic(staticDir: string, { indexOnly = false, scanServer = null }: { indexOnly?: boolean, scanServer?: string | null } = {}): StaticHandler {
11
+ const serve = loadStatic(staticDir, scanServer, { transformIndex: managedPageHtml, indexOnly })
12
+ return (req, res) => {
13
+ if (!req.url?.startsWith('/') || req.url.startsWith('//')) return false
14
+ const url = new URL(req.url, 'http://localhost')
15
+ let path
16
+ try { path = decodeURIComponent(url.pathname) } catch { return false }
17
+ // No API request, including an unknown API route, may receive app HTML.
18
+ if (path === '/api' || path.startsWith('/api/')) return false
19
+ if (serve(req, res)) return true
20
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false
21
+ // Finding IDs can contain dots (including URL-shaped IDs). Recognised
22
+ // pages still receive HTML; missing assets retain a 404.
23
+ if (path.split('/').at(-1)?.includes('.') && !parseManagedRoute(url)) return false
24
+ const original = req.url
25
+ req.url = '/'
26
+ try { return serve(req, res) } finally { req.url = original }
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ import { loadManagedConfig } from './config.ts'
2
+ import { openManagedStorageDb } from './storage.ts'
3
+
4
+ // Require the deployment mode: DB_PATH belongs to e2e in combined deployments.
5
+ // Read the database requirement without opening bytes or enabling encryption.
6
+ export async function storageCommand(mode: string | undefined): Promise<void> {
7
+ if (mode !== 'managed' && mode !== 'managed-e2e' && mode !== 'e2e-managed') throw new Error('Storage status requires a deployment mode: --storage-encryption-status managed|managed-e2e|e2e-managed')
8
+ const db = await openManagedStorageDb(loadManagedConfig({ combined: mode !== 'managed' }))
9
+ try {
10
+ const status = await db.getStorageEncryption()
11
+ console.log(JSON.stringify(status ? {
12
+ encryption: 'chacha20-poly1305', complete: status.complete === 1, cleanupComplete: status.cleanupComplete === 1,
13
+ migrated: status.migrated, cursor: status.cursor,
14
+ } : { encryption: 'disabled' }))
15
+ } finally { await db.close() }
16
+ }
@@ -0,0 +1,183 @@
1
+ // Keys belong to the existing upload rows. Only installation state and bounded
2
+ // migration progress need a separate table; payload paths never move.
3
+ import { Buffer } from 'node:buffer'
4
+ import { randomBytes } from 'node:crypto'
5
+ import { type StorageKey, unwrapStorageValue, wrapStorageValue } from '../server-common/storage-crypto.ts'
6
+ import type { ManagedSql } from './sql.ts'
7
+
8
+ export const STORAGE_SCHEMA = `
9
+ CREATE TABLE IF NOT EXISTS managed_storage_encryption (
10
+ id INTEGER PRIMARY KEY CHECK (id = 1), test_value TEXT NOT NULL,
11
+ enabled_at INTEGER NOT NULL, cursor TEXT, complete INTEGER NOT NULL DEFAULT 0,
12
+ migrated INTEGER NOT NULL DEFAULT 0, cleanup_cursor TEXT,
13
+ cleanup_found INTEGER NOT NULL DEFAULT 0, cleanup_complete INTEGER NOT NULL DEFAULT 0
14
+ ) STRICT;
15
+ `
16
+ const SENTINEL = Buffer.from('deepview.storage.required.v1')
17
+ export type StorageRowKind = 'report' | 'bundle'
18
+ export interface StorageEncryptionState {
19
+ enabledAt: number; cursor: string | null; complete: number; migrated: number
20
+ cleanupCursor: string | null; cleanupFound: number; cleanupComplete: number
21
+ }
22
+ export interface StorageRow {
23
+ id: string; dataKey: string | null; encrypted: number; hash: string; kind: string | null
24
+ }
25
+ export interface StorageMigrationRow { position: string; id: string; type: StorageRowKind | 'user' }
26
+ export interface StorageDb {
27
+ enableStorageEncryption(): Promise<StorageEncryptionState>
28
+ getStorageEncryption(): Promise<StorageEncryptionState | null>
29
+ getStorageRow(type: StorageRowKind, id: string): Promise<StorageRow | null>
30
+ ensureStorageDataKey(type: StorageRowKind, id: string): Promise<StorageRow | null>
31
+ markStorageEncrypted(type: StorageRowKind, id: string, dataKey: string): Promise<void>
32
+ listStorageMigrationRows(after: string | null, limit: number): Promise<StorageMigrationRow[]>
33
+ advanceStorageMigration(expected: string | null, next: string | null): Promise<void>
34
+ advanceStorageCleanup(expected: string | null, next: string | null, removed: number): Promise<void>
35
+ migrateStorageUserTokens(id: string): Promise<void>
36
+ }
37
+
38
+ function table(type: StorageRowKind): string {
39
+ if (type !== 'report' && type !== 'bundle') throw new Error('Invalid storage row type')
40
+ return `managed_${type}`
41
+ }
42
+ export const dataKeyIdentity = (type: StorageRowKind, id: string): string => `${table(type)}:${id}`
43
+ export function unwrapDataKey(key: StorageKey, type: StorageRowKind, row: Pick<StorageRow, 'id' | 'dataKey'>): Buffer {
44
+ if (!row.dataKey) throw new Error('Missing storage data key')
45
+ const value = unwrapStorageValue(key, dataKeyIdentity(type, row.id), row.dataKey)
46
+ if (value.length !== 32) { value.fill(0); throw new Error('Invalid storage data key') }
47
+ return value
48
+ }
49
+
50
+ // What storageState reads of managed_storage_encryption, as `alias` names it,
51
+ // for a statement reading it with more (storageStateOf).
52
+ export const storageStateColumns = (alias: string) => `${alias}.test_value AS testValue, ${alias}.enabled_at AS enabledAt,
53
+ ${alias}.cursor AS cursor, ${alias}.complete AS complete, ${alias}.migrated AS migrated, ${alias}.cleanup_cursor AS cleanupCursor,
54
+ ${alias}.cleanup_found AS cleanupFound, ${alias}.cleanup_complete AS cleanupComplete`
55
+ export async function storageState(db: ManagedSql, key: StorageKey | null): Promise<StorageEncryptionState | null> {
56
+ return storageStateOf(await db.prepare(`SELECT ${storageStateColumns('e')} FROM managed_storage_encryption e WHERE e.id = 1`).get() as StoredState | undefined, key)
57
+ }
58
+ type StoredState = StorageEncryptionState & { testValue: string }
59
+ // The state storageStateColumns read, none where its row is missing, its
60
+ // test value checked against `key`.
61
+ export function storageStateOf(row: StoredState | undefined, key: StorageKey | null): StorageEncryptionState | null {
62
+ if (!row) return null
63
+ if (!key) throw new Error('Managed storage requires its configured encryption key')
64
+ const plain = unwrapStorageValue(key, 'managed_storage_encryption:1', row.testValue)
65
+ try { if (!plain.equals(SENTINEL)) throw new Error('Invalid storage encryption test value') }
66
+ finally { plain.fill(0) }
67
+ const { testValue: _testValue, ...state } = row
68
+ return state
69
+ }
70
+
71
+ // Called inside the insert transaction, fencing an old/plain upload that raced
72
+ // activation. The wrapped key never enters public report/bundle DTOs.
73
+ export async function validateStorageUpload(db: ManagedSql, key: StorageKey | null, type: StorageRowKind,
74
+ id: string, dataKey: string | null | undefined): Promise<void> {
75
+ const state = await storageState(db, key)
76
+ if (state) unwrapDataKey(key!, type, { id, dataKey: dataKey ?? null }).fill(0)
77
+ else if (dataKey) throw new Error('Storage encryption is not enabled')
78
+ }
79
+
80
+ export interface StoredTokens { access: string | null; refresh: string | null; exp: number | null; encrypted: number }
81
+ const tokenIdentity = (id: string, field: string) => `managed_user.gh_${field}_token:${id}`
82
+ export function encodeStorageTokens(key: StorageKey, id: string, access: string | null, refresh: string | null) {
83
+ const encode = (field: string, value: string | null) => value === null ? null : wrapStorageValue(key, tokenIdentity(id, field), Buffer.from(value))
84
+ return { access: encode('access', access), refresh: encode('refresh', refresh) }
85
+ }
86
+ export function decodeStorageTokens(key: StorageKey, id: string, row: StoredTokens) {
87
+ const decode = (field: string, value: string | null) => {
88
+ if (value === null) return null
89
+ const plain = unwrapStorageValue(key, tokenIdentity(id, field), value)
90
+ try { return plain.toString('utf8') } finally { plain.fill(0) }
91
+ }
92
+ return { access: decode('access', row.access), refresh: decode('refresh', row.refresh) }
93
+ }
94
+
95
+ // Fixed-width sizes make the saved position sort by type, then numeric byte
96
+ // size, then ID in both SQLite and Postgres. Sixteen digits cover JS safe sizes.
97
+ const PADDED_SIZE = "substr('0000000000000000', 1, 16 - length(CAST(byte_size AS TEXT))) || CAST(byte_size AS TEXT)"
98
+ const PENDING = `SELECT '0:' || ${PADDED_SIZE} || ':' || id AS position, id, 'report' AS type FROM managed_report WHERE storage_encrypted = 0
99
+ UNION ALL SELECT '1:' || ${PADDED_SIZE} || ':' || id AS position, id, 'bundle' AS type FROM managed_bundle WHERE storage_encrypted = 0
100
+ UNION ALL SELECT '2:0000000000000000:' || id AS position, id, 'user' AS type FROM managed_user
101
+ WHERE gh_tokens_encrypted = 0 AND (gh_access_token IS NOT NULL OR gh_refresh_token IS NOT NULL)`
102
+
103
+ // Restart the pending pass for a cursor saved under the previous ID ordering.
104
+ // Completed rows are excluded by PENDING; the original cursor still fences CAS.
105
+ const orderedCursor = (cursor: string | null): string => cursor && /^[012]:[0-9]{16}:/u.test(cursor) ? cursor : ''
106
+
107
+ export function storageMethods(db: ManagedSql, key: StorageKey | null): StorageDb {
108
+ async function requireEnabled() {
109
+ const state = await storageState(db, key)
110
+ if (!state) throw new Error('Storage encryption is not enabled; configure MANAGED_STORAGE_ENCRYPTION_KEY')
111
+ return state
112
+ }
113
+ async function getRow(type: StorageRowKind, id: string): Promise<StorageRow | null> {
114
+ const row = await db.prepare(`SELECT id, data_key AS dataKey, storage_encrypted AS encrypted,
115
+ ${type === 'report' ? 'sha256 AS hash, NULL AS kind' : 'integrity AS hash, kind'} FROM ${table(type)} WHERE id = ?`).get(id) as StorageRow | undefined ?? null
116
+ if (row && row.encrypted !== 0 && row.encrypted !== 1) throw new Error('Invalid storage encryption state')
117
+ if (row?.encrypted && !row.dataKey) throw new Error('Encrypted storage row is missing its data key')
118
+ return row
119
+ }
120
+ return {
121
+ async enableStorageEncryption() {
122
+ if (!key) throw new Error('Set MANAGED_STORAGE_ENCRYPTION_KEY before enabling storage encryption')
123
+ // Called during startup in a writer transaction. Concurrent instances
124
+ // must reuse the marker and validate its key before serving requests.
125
+ const existing = await storageState(db, key)
126
+ if (existing) return existing
127
+ await db.prepare('INSERT INTO managed_storage_encryption (id, test_value, enabled_at) VALUES (1, ?, ?)')
128
+ .run(wrapStorageValue(key, 'managed_storage_encryption:1', SENTINEL), Date.now())
129
+ return (await storageState(db, key))!
130
+ },
131
+ getStorageEncryption: () => storageState(db, key),
132
+ getStorageRow: getRow,
133
+ async ensureStorageDataKey(type, id) {
134
+ await requireEnabled()
135
+ const current = await getRow(type, id)
136
+ if (!current || current.dataKey) return current
137
+ const bytes = randomBytes(32)
138
+ let wrapped: string
139
+ try { wrapped = wrapStorageValue(key!, dataKeyIdentity(type, id), bytes) } finally { bytes.fill(0) }
140
+ await db.prepare(`UPDATE ${table(type)} SET data_key = ? WHERE id = ? AND data_key IS NULL`).run(wrapped, id)
141
+ // Return the persisted winner, never a worker's uncommitted candidate.
142
+ return getRow(type, id)
143
+ },
144
+ async markStorageEncrypted(type, id, wrapped) {
145
+ await requireEnabled()
146
+ const result = await db.prepare(`UPDATE ${table(type)} SET storage_encrypted = 1
147
+ WHERE id = ? AND data_key = ? AND storage_encrypted = 0`).run(id, wrapped)
148
+ if (Number(result.changes)) await db.prepare('UPDATE managed_storage_encryption SET migrated = migrated + 1 WHERE id = 1').run()
149
+ },
150
+ async listStorageMigrationRows(after, limit) {
151
+ await requireEnabled()
152
+ return await db.prepare(`SELECT position, id, type FROM (${PENDING}) AS pending
153
+ WHERE position > ? ORDER BY position LIMIT ?`).all(orderedCursor(after), limit) as StorageMigrationRow[]
154
+ },
155
+ async advanceStorageMigration(expected, next) {
156
+ await requireEnabled()
157
+ if (next !== null && next <= orderedCursor(expected)) return
158
+ // End-of-pass completion checks every pending row, including failed rows
159
+ // before the cursor. A failure cannot be mistaken for a finished pass.
160
+ await db.prepare(`UPDATE managed_storage_encryption SET cursor = ?, complete =
161
+ CASE WHEN ? IS NULL AND NOT EXISTS (SELECT 1 FROM (${PENDING}) AS pending) THEN 1 ELSE 0 END
162
+ WHERE id = 1 AND (cursor = ? OR (cursor IS NULL AND ? IS NULL))`).run(next, next, expected, expected)
163
+ },
164
+ async advanceStorageCleanup(expected, next, removed) {
165
+ const state = await requireEnabled()
166
+ const found = state.cleanupFound + removed
167
+ await db.prepare(`UPDATE managed_storage_encryption SET cleanup_cursor = ?, cleanup_found = ?, cleanup_complete = ?
168
+ WHERE id = 1 AND (cleanup_cursor = ? OR (cleanup_cursor IS NULL AND ? IS NULL))`).run(next, next === null ? 0 : found, next === null && found === 0 ? 1 : 0, expected, expected)
169
+ },
170
+ async migrateStorageUserTokens(id) {
171
+ await requireEnabled()
172
+ const row = await db.prepare(`SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp,
173
+ gh_tokens_encrypted AS encrypted FROM managed_user WHERE id = ?`).get(id) as StoredTokens | undefined
174
+ if (!row || row.encrypted || (row.access === null && row.refresh === null)) return
175
+ const value = encodeStorageTokens(key!, id, row.access, row.refresh)
176
+ // This operation holds the writer transaction; token refresh cannot be
177
+ // overwritten by a migration that read an earlier token value.
178
+ await db.prepare('UPDATE managed_user SET gh_access_token = ?, gh_refresh_token = ?, gh_tokens_encrypted = 1 WHERE id = ?')
179
+ .run(value.access, value.refresh, id)
180
+ await db.prepare('UPDATE managed_storage_encryption SET migrated = migrated + 1 WHERE id = 1').run()
181
+ },
182
+ }
183
+ }