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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2KBF5TZ7.js +2 -0
  72. package/out/chunk-2XUEZHIP.js +28 -0
  73. package/out/chunk-35VE6CDH.js +2 -0
  74. package/out/chunk-3BZ7JERB.js +1 -0
  75. package/out/chunk-4F6JSF57.js +3 -0
  76. package/out/chunk-4XCDFGDP.js +10 -0
  77. package/out/chunk-AQ75QITE.js +1 -0
  78. package/out/chunk-B2RQY6OP.js +2 -0
  79. package/out/chunk-C4Z6ZBXV.js +10 -0
  80. package/out/chunk-CMQA4DM6.js +1 -0
  81. package/out/chunk-CVVJAXVY.js +2 -0
  82. package/out/chunk-DIJJNTRH.js +10 -0
  83. package/out/chunk-GJ4IUCNS.js +1 -0
  84. package/out/chunk-GNXOKIGP.js +80 -0
  85. package/out/chunk-GZ4MS3TE.js +13 -0
  86. package/out/chunk-IISEF2BZ.js +1 -0
  87. package/out/chunk-IU3CCINR.js +3 -0
  88. package/out/chunk-K3X5EJBQ.js +2 -0
  89. package/out/chunk-KEX2ATEW.js +2 -0
  90. package/out/chunk-KYCNJVB7.js +1 -0
  91. package/out/chunk-MYDDTA7W.js +225 -0
  92. package/out/chunk-NRPC74AM.js +1 -0
  93. package/out/chunk-OOJM4CTU.js +1 -0
  94. package/out/chunk-PNHBZGDD.js +1 -0
  95. package/out/chunk-R5O6FAW4.js +2 -0
  96. package/out/chunk-RFZQQXSG.js +17 -0
  97. package/out/chunk-RSHMVYNS.js +1 -0
  98. package/out/chunk-TGANXVTA.js +1 -0
  99. package/out/chunk-TUII34K2.js +1 -0
  100. package/out/chunk-TZ5KB2Y5.js +10 -0
  101. package/out/chunk-UN5ZSYLE.js +1 -0
  102. package/out/chunk-UNTRGE26.js +2 -0
  103. package/out/chunk-UXNUAIIC.js +33 -0
  104. package/out/chunk-UYAXVRVL.js +1 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XYDGU6BM.js +51 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +297 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +358 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +142 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +219 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/specs.js +124 -0
  154. package/risk/npm/tarball.js +113 -0
  155. package/server-common/database-config.ts +16 -0
  156. package/server-common/initialize.ts +18 -0
  157. package/server-common/npm-advisories.ts +101 -0
  158. package/{server → server-common}/origin.ts +5 -5
  159. package/server-common/reap.ts +48 -0
  160. package/server-common/scan-config.ts +19 -0
  161. package/server-common/standalone.ts +29 -0
  162. package/server-common/storage-crypto.ts +182 -0
  163. package/server-common/storage-log.ts +38 -0
  164. package/server-common/vercel-blob.ts +103 -0
  165. package/server-e2e/app.ts +486 -0
  166. package/{server → server-e2e}/auth.ts +5 -1
  167. package/{server → server-e2e}/bus-receiver.ts +2 -1
  168. package/{server → server-e2e}/cli.js +9 -4
  169. package/{server → server-e2e}/config.ts +56 -35
  170. package/{server → server-e2e}/db-neon.ts +2 -2
  171. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  172. package/{server → server-e2e}/db-stmt.ts +2 -2
  173. package/{server → server-e2e}/db.ts +7 -7
  174. package/{server → server-e2e}/http.ts +116 -18
  175. package/{server → server-e2e}/hub.ts +40 -9
  176. package/server-e2e/index.ts +17 -0
  177. package/server-e2e/lifecycle.ts +95 -0
  178. package/{server → server-e2e}/neon-driver.ts +2 -2
  179. package/{server → server-e2e}/npm-proxy.ts +12 -145
  180. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  181. package/{server → server-e2e}/objstore/blob.ts +1 -1
  182. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  183. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  184. package/{server → server-e2e}/objstore/init.ts +35 -18
  185. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  186. package/server-e2e/objstore/rest-deny.ts +28 -0
  187. package/server-e2e/objstore/rest-mint.ts +224 -0
  188. package/{server → server-e2e}/objstore/rest.ts +27 -26
  189. package/{server → server-e2e}/objstore/sign.ts +105 -0
  190. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  191. package/{server → server-e2e}/objstore/store.ts +7 -7
  192. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  193. package/{server → server-e2e}/pubsub.ts +12 -9
  194. package/{server → server-e2e}/sign.ts +4 -4
  195. package/{server → server-e2e}/sse-server.ts +102 -68
  196. package/{server → server-e2e}/sse-session.ts +28 -8
  197. package/{server → server-e2e}/static.ts +31 -21
  198. package/server-e2e/sync-handlers.ts +408 -0
  199. package/{server → server-e2e}/ws-server.ts +20 -9
  200. package/server-managed/activity.ts +245 -0
  201. package/server-managed/avatar-store.ts +12 -0
  202. package/server-managed/blob-store.ts +17 -0
  203. package/server-managed/brotli.ts +10 -0
  204. package/server-managed/bundle-advisories.ts +120 -0
  205. package/server-managed/bundle-advisory-inventory.ts +77 -0
  206. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  207. package/server-managed/bundle-build-diagnostics.js +23 -0
  208. package/server-managed/bundle-build-leases.ts +41 -0
  209. package/server-managed/bundle-build-worker.js +111 -0
  210. package/server-managed/bundle-build.ts +198 -0
  211. package/server-managed/bundle-cache.ts +199 -0
  212. package/server-managed/bundle-catalog.ts +29 -0
  213. package/server-managed/bundle-commits.ts +140 -0
  214. package/server-managed/bundle-store.ts +24 -0
  215. package/server-managed/bundle-summary-cache.ts +130 -0
  216. package/server-managed/bundle.ts +120 -0
  217. package/server-managed/cache-storage.ts +19 -0
  218. package/server-managed/cli.js +21 -0
  219. package/server-managed/combined.ts +46 -0
  220. package/server-managed/comments.ts +151 -0
  221. package/server-managed/config.ts +158 -0
  222. package/server-managed/content-access.ts +15 -0
  223. package/server-managed/crypto.ts +25 -0
  224. package/server-managed/db-methods.ts +1725 -0
  225. package/server-managed/db-neon.ts +367 -0
  226. package/server-managed/db-schema.ts +227 -0
  227. package/server-managed/db-table-names.ts +22 -0
  228. package/server-managed/db.ts +132 -0
  229. package/server-managed/dependency-reviews.ts +248 -0
  230. package/server-managed/github-app.ts +391 -0
  231. package/server-managed/github-errors.ts +68 -0
  232. package/server-managed/github-issue-links.ts +127 -0
  233. package/server-managed/github-issue-oauth.ts +47 -0
  234. package/server-managed/github-issues.ts +152 -0
  235. package/server-managed/github-metadata.ts +213 -0
  236. package/server-managed/github-oauth.ts +235 -0
  237. package/server-managed/github-pulls.ts +227 -0
  238. package/server-managed/http-response.ts +50 -0
  239. package/server-managed/http.ts +3472 -0
  240. package/server-managed/import-triage.ts +51 -0
  241. package/server-managed/index.ts +187 -0
  242. package/server-managed/kept.ts +28 -0
  243. package/server-managed/link-reports.ts +158 -0
  244. package/server-managed/locked-packages.ts +85 -0
  245. package/server-managed/lru.ts +30 -0
  246. package/server-managed/managed-issues.ts +75 -0
  247. package/server-managed/management-catalog.ts +45 -0
  248. package/server-managed/management.ts +99 -0
  249. package/server-managed/npm-insights.ts +440 -0
  250. package/server-managed/npm-kept.ts +65 -0
  251. package/server-managed/npm-loads.ts +167 -0
  252. package/server-managed/npm-manifest.ts +115 -0
  253. package/server-managed/npm-packages.ts +378 -0
  254. package/server-managed/npm-provenance.ts +123 -0
  255. package/server-managed/npm-risk.ts +177 -0
  256. package/server-managed/object-storage-disk.ts +148 -0
  257. package/server-managed/object-storage-vercel.ts +67 -0
  258. package/server-managed/object-storage.ts +74 -0
  259. package/server-managed/package-entry-points.ts +51 -0
  260. package/server-managed/pretty-print.ts +352 -0
  261. package/server-managed/public-workspace.ts +220 -0
  262. package/server-managed/rename-diff-worker.js +26 -0
  263. package/server-managed/rename-diff.ts +270 -0
  264. package/server-managed/repo-path.ts +21 -0
  265. package/server-managed/report-migration.ts +35 -0
  266. package/server-managed/report-query.ts +10 -0
  267. package/server-managed/report-response.ts +16 -0
  268. package/server-managed/report-sources.ts +178 -0
  269. package/server-managed/report-store.ts +47 -0
  270. package/server-managed/repository-aliases.ts +91 -0
  271. package/server-managed/repository-browser.ts +153 -0
  272. package/server-managed/repository-discovery.ts +82 -0
  273. package/server-managed/repository-policy.ts +27 -0
  274. package/server-managed/repository-risk.ts +418 -0
  275. package/server-managed/revisions.ts +35 -0
  276. package/server-managed/session.ts +92 -0
  277. package/server-managed/slugs.ts +38 -0
  278. package/server-managed/solidity-entry-points.ts +69 -0
  279. package/server-managed/sql-postgres.ts +31 -0
  280. package/server-managed/sql.ts +76 -0
  281. package/server-managed/static.ts +28 -0
  282. package/server-managed/storage-command.ts +16 -0
  283. package/server-managed/storage-db.ts +175 -0
  284. package/server-managed/storage-encryption.ts +279 -0
  285. package/server-managed/storage-maintenance.ts +256 -0
  286. package/server-managed/storage-payload.ts +103 -0
  287. package/server-managed/storage-stores.ts +85 -0
  288. package/server-managed/storage.ts +78 -0
  289. package/server-managed/team-app.ts +94 -0
  290. package/server-managed/team-catalog.ts +7 -0
  291. package/server-managed/team-feed.ts +173 -0
  292. package/server-managed/team-npm-scopes.ts +77 -0
  293. package/server-managed/team-reports.ts +228 -0
  294. package/server-managed/triage-response.ts +16 -0
  295. package/server-managed/type-only-worker.js +19 -0
  296. package/server-managed/type-only.ts +103 -0
  297. package/server-managed/uploads.ts +86 -0
  298. package/server-managed/upstream-cache.ts +215 -0
  299. package/server-managed/view-as.ts +58 -0
  300. package/server-managed/workspace-shares.ts +166 -0
  301. package/server.ts +50 -0
  302. package/server/index.ts +0 -477
  303. package/server/lifecycle.ts +0 -204
  304. package/server/sync-handlers.ts +0 -320
  305. /package/{LICENSE → LICENSE-MIT} +0 -0
  306. /package/{server → server-e2e}/config.example.json +0 -0
  307. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  308. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  309. /package/{server → server-e2e}/peer.ts +0 -0
  310. /package/{server → server-e2e}/util.ts +0 -0
  311. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,31 @@
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
+ // Quoted literals (including ':') are never placeholders or identifiers.
14
+ sql = sql.split(/('(?:''|[^'])*')/u).map((part, i) => i % 2 ? part : part
15
+ .replaceAll(/\?|(?<!:):([a-zA-Z]\w*)/gu, (_match, name: string | undefined) => {
16
+ if (!name) return `$${++position}`
17
+ let index = names.indexOf(name)
18
+ if (index < 0) { index = names.length; names.push(name) }
19
+ return `$${index + 1}`
20
+ })
21
+ .replaceAll(/\b[a-z]+[A-Z]\w*\b/gu, name => `"${name}"`)
22
+ ).join('')
23
+ sql = sql.replaceAll(/(\$\d+) IS NULL/gu, '$1::text IS NULL')
24
+ if (source.includes('INSERT OR IGNORE')) sql += ' ON CONFLICT DO NOTHING'
25
+ return { sql, names }
26
+ }
27
+
28
+ export function postgresSchema(sql: string): string {
29
+ return sql.replaceAll(') STRICT;', ');').replaceAll(/\bINTEGER\b/gu, 'BIGINT')
30
+ .replaceAll(/seq\s+BIGINT PRIMARY KEY/gu, 'seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY')
31
+ }
@@ -0,0 +1,76 @@
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
+
6
+ type MaybePromise<T> = T | Promise<T>
7
+ export interface ManagedSql {
8
+ prepare(sql: string): {
9
+ get(...params: unknown[]): MaybePromise<unknown>
10
+ all(...params: unknown[]): MaybePromise<unknown[]>
11
+ run(...params: unknown[]): MaybePromise<{ changes: number | bigint }>
12
+ }
13
+ close(): MaybePromise<void>
14
+ }
15
+ export interface ManagedSqlDriver extends ManagedSql {
16
+ scope<T>(write: boolean, work: () => Promise<T>, options?: { lock?: boolean; statement?: boolean }): Promise<T>
17
+ }
18
+
19
+ // These operations execute exactly one SQL statement. Postgres already gives
20
+ // that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
21
+ // Lease and cache writes coordinate through their conditional UPSERT/UPDATE alone.
22
+ const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
23
+ 'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
24
+ 'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
25
+ 'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'ensureSessionUploadKey',
26
+ 'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits'])
27
+ // Session presence and upload keys change only their own rows.
28
+ const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
29
+
30
+ export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
31
+ const entries = Object.entries(methods).map(([name, method]) => {
32
+ if (name === 'close') return [name, async () => { await driver.close() }]
33
+ const write = !/^(?:get|list|userCanRead|reportPermissionsFor)/u.test(name)
34
+ return [name, (...args: unknown[]) => driver.scope(write, () => method(...args), {
35
+ // Presence writes only one user's timestamp; they need no application
36
+ // writer lock. Keep the session/user read and timestamp in one transaction.
37
+ lock: write && !UNLOCKED_WRITES.has(name), statement: SINGLE_STATEMENTS.has(name),
38
+ })]
39
+ })
40
+ return Object.fromEntries(entries) as ManagedDb
41
+ }
42
+
43
+ export function createSqliteDriver(db: DatabaseSync): ManagedSqlDriver {
44
+ let queue = Promise.resolve()
45
+ let closed = false
46
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
47
+ const result = queue.then(work)
48
+ queue = result.then(() => undefined, () => undefined)
49
+ return result
50
+ }
51
+ return {
52
+ prepare(sql) {
53
+ const stmt = db.prepare(sql)
54
+ return {
55
+ get: (...args) => stmt.get(...args as SQLInputValue[]),
56
+ all: (...args) => stmt.all(...args as SQLInputValue[]),
57
+ run: (...args) => stmt.run(...args as SQLInputValue[]),
58
+ }
59
+ },
60
+ scope(write, work) {
61
+ if (closed) return Promise.reject(new Error('Managed database is closed'))
62
+ return enqueue(async () => {
63
+ db.exec(write ? 'BEGIN IMMEDIATE' : 'BEGIN')
64
+ try {
65
+ const result = await work()
66
+ db.exec('COMMIT')
67
+ return result
68
+ } catch (err) {
69
+ db.exec('ROLLBACK')
70
+ throw err
71
+ }
72
+ })
73
+ },
74
+ async close() { closed = true; await queue; db.close() },
75
+ }
76
+ }
@@ -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,175 @@
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
+ export async function storageState(db: ManagedSql, key: StorageKey | null): Promise<StorageEncryptionState | null> {
51
+ const row = await db.prepare(`SELECT test_value AS testValue, enabled_at AS enabledAt, cursor, complete, migrated,
52
+ cleanup_cursor AS cleanupCursor, cleanup_found AS cleanupFound, cleanup_complete AS cleanupComplete
53
+ FROM managed_storage_encryption WHERE id = 1`).get() as (StorageEncryptionState & { testValue: string }) | undefined
54
+ if (!row) return null
55
+ if (!key) throw new Error('Managed storage requires its configured encryption key')
56
+ const plain = unwrapStorageValue(key, 'managed_storage_encryption:1', row.testValue)
57
+ try { if (!plain.equals(SENTINEL)) throw new Error('Invalid storage encryption test value') }
58
+ finally { plain.fill(0) }
59
+ const { testValue: _testValue, ...state } = row
60
+ return state
61
+ }
62
+
63
+ // Called inside the insert transaction, fencing an old/plain upload that raced
64
+ // activation. The wrapped key never enters public report/bundle DTOs.
65
+ export async function validateStorageUpload(db: ManagedSql, key: StorageKey | null, type: StorageRowKind,
66
+ id: string, dataKey: string | null | undefined): Promise<void> {
67
+ const state = await storageState(db, key)
68
+ if (state) unwrapDataKey(key!, type, { id, dataKey: dataKey ?? null }).fill(0)
69
+ else if (dataKey) throw new Error('Storage encryption is not enabled')
70
+ }
71
+
72
+ export interface StoredTokens { access: string | null; refresh: string | null; exp: number | null; encrypted: number }
73
+ const tokenIdentity = (id: string, field: string) => `managed_user.gh_${field}_token:${id}`
74
+ export function encodeStorageTokens(key: StorageKey, id: string, access: string | null, refresh: string | null) {
75
+ const encode = (field: string, value: string | null) => value === null ? null : wrapStorageValue(key, tokenIdentity(id, field), Buffer.from(value))
76
+ return { access: encode('access', access), refresh: encode('refresh', refresh) }
77
+ }
78
+ export function decodeStorageTokens(key: StorageKey, id: string, row: StoredTokens) {
79
+ const decode = (field: string, value: string | null) => {
80
+ if (value === null) return null
81
+ const plain = unwrapStorageValue(key, tokenIdentity(id, field), value)
82
+ try { return plain.toString('utf8') } finally { plain.fill(0) }
83
+ }
84
+ return { access: decode('access', row.access), refresh: decode('refresh', row.refresh) }
85
+ }
86
+
87
+ // Fixed-width sizes make the saved position sort by type, then numeric byte
88
+ // size, then ID in both SQLite and Postgres. Sixteen digits cover JS safe sizes.
89
+ const PADDED_SIZE = "substr('0000000000000000', 1, 16 - length(CAST(byte_size AS TEXT))) || CAST(byte_size AS TEXT)"
90
+ const PENDING = `SELECT '0:' || ${PADDED_SIZE} || ':' || id AS position, id, 'report' AS type FROM managed_report WHERE storage_encrypted = 0
91
+ UNION ALL SELECT '1:' || ${PADDED_SIZE} || ':' || id AS position, id, 'bundle' AS type FROM managed_bundle WHERE storage_encrypted = 0
92
+ UNION ALL SELECT '2:0000000000000000:' || id AS position, id, 'user' AS type FROM managed_user
93
+ WHERE gh_tokens_encrypted = 0 AND (gh_access_token IS NOT NULL OR gh_refresh_token IS NOT NULL)`
94
+
95
+ // Restart the pending pass for a cursor saved under the previous ID ordering.
96
+ // Completed rows are excluded by PENDING; the original cursor still fences CAS.
97
+ const orderedCursor = (cursor: string | null): string => cursor && /^[012]:[0-9]{16}:/u.test(cursor) ? cursor : ''
98
+
99
+ export function storageMethods(db: ManagedSql, key: StorageKey | null): StorageDb {
100
+ async function requireEnabled() {
101
+ const state = await storageState(db, key)
102
+ if (!state) throw new Error('Storage encryption is not enabled; configure MANAGED_STORAGE_ENCRYPTION_KEY')
103
+ return state
104
+ }
105
+ async function getRow(type: StorageRowKind, id: string): Promise<StorageRow | null> {
106
+ const row = await db.prepare(`SELECT id, data_key AS dataKey, storage_encrypted AS encrypted,
107
+ ${type === 'report' ? 'sha256 AS hash, NULL AS kind' : 'integrity AS hash, kind'} FROM ${table(type)} WHERE id = ?`).get(id) as StorageRow | undefined ?? null
108
+ if (row && row.encrypted !== 0 && row.encrypted !== 1) throw new Error('Invalid storage encryption state')
109
+ if (row?.encrypted && !row.dataKey) throw new Error('Encrypted storage row is missing its data key')
110
+ return row
111
+ }
112
+ return {
113
+ async enableStorageEncryption() {
114
+ if (!key) throw new Error('Set MANAGED_STORAGE_ENCRYPTION_KEY before enabling storage encryption')
115
+ // Called during startup in a writer transaction. Concurrent instances
116
+ // must reuse the marker and validate its key before serving requests.
117
+ const existing = await storageState(db, key)
118
+ if (existing) return existing
119
+ await db.prepare('INSERT INTO managed_storage_encryption (id, test_value, enabled_at) VALUES (1, ?, ?)')
120
+ .run(wrapStorageValue(key, 'managed_storage_encryption:1', SENTINEL), Date.now())
121
+ return (await storageState(db, key))!
122
+ },
123
+ getStorageEncryption: () => storageState(db, key),
124
+ getStorageRow: getRow,
125
+ async ensureStorageDataKey(type, id) {
126
+ await requireEnabled()
127
+ const current = await getRow(type, id)
128
+ if (!current || current.dataKey) return current
129
+ const bytes = randomBytes(32)
130
+ let wrapped: string
131
+ try { wrapped = wrapStorageValue(key!, dataKeyIdentity(type, id), bytes) } finally { bytes.fill(0) }
132
+ await db.prepare(`UPDATE ${table(type)} SET data_key = ? WHERE id = ? AND data_key IS NULL`).run(wrapped, id)
133
+ // Return the persisted winner, never a worker's uncommitted candidate.
134
+ return getRow(type, id)
135
+ },
136
+ async markStorageEncrypted(type, id, wrapped) {
137
+ await requireEnabled()
138
+ const result = await db.prepare(`UPDATE ${table(type)} SET storage_encrypted = 1
139
+ WHERE id = ? AND data_key = ? AND storage_encrypted = 0`).run(id, wrapped)
140
+ if (Number(result.changes)) await db.prepare('UPDATE managed_storage_encryption SET migrated = migrated + 1 WHERE id = 1').run()
141
+ },
142
+ async listStorageMigrationRows(after, limit) {
143
+ await requireEnabled()
144
+ return await db.prepare(`SELECT position, id, type FROM (${PENDING}) AS pending
145
+ WHERE position > ? ORDER BY position LIMIT ?`).all(orderedCursor(after), limit) as StorageMigrationRow[]
146
+ },
147
+ async advanceStorageMigration(expected, next) {
148
+ await requireEnabled()
149
+ if (next !== null && next <= orderedCursor(expected)) return
150
+ // End-of-pass completion checks every pending row, including failed rows
151
+ // before the cursor. A failure cannot be mistaken for a finished pass.
152
+ await db.prepare(`UPDATE managed_storage_encryption SET cursor = ?, complete =
153
+ CASE WHEN ? IS NULL AND NOT EXISTS (SELECT 1 FROM (${PENDING}) AS pending) THEN 1 ELSE 0 END
154
+ WHERE id = 1 AND (cursor = ? OR (cursor IS NULL AND ? IS NULL))`).run(next, next, expected, expected)
155
+ },
156
+ async advanceStorageCleanup(expected, next, removed) {
157
+ const state = await requireEnabled()
158
+ const found = state.cleanupFound + removed
159
+ await db.prepare(`UPDATE managed_storage_encryption SET cleanup_cursor = ?, cleanup_found = ?, cleanup_complete = ?
160
+ 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)
161
+ },
162
+ async migrateStorageUserTokens(id) {
163
+ await requireEnabled()
164
+ const row = await db.prepare(`SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp,
165
+ gh_tokens_encrypted AS encrypted FROM managed_user WHERE id = ?`).get(id) as StoredTokens | undefined
166
+ if (!row || row.encrypted || (row.access === null && row.refresh === null)) return
167
+ const value = encodeStorageTokens(key!, id, row.access, row.refresh)
168
+ // This operation holds the writer transaction; token refresh cannot be
169
+ // overwritten by a migration that read an earlier token value.
170
+ await db.prepare('UPDATE managed_user SET gh_access_token = ?, gh_refresh_token = ?, gh_tokens_encrypted = 1 WHERE id = ?')
171
+ .run(value.access, value.refresh, id)
172
+ await db.prepare('UPDATE managed_storage_encryption SET migrated = migrated + 1 WHERE id = 1').run()
173
+ },
174
+ }
175
+ }
@@ -0,0 +1,279 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { Readable } from 'node:stream'
4
+ import { type StorageKey, decryptStorageStream, encryptStorageStream, wrapStorageValue } from '../server-common/storage-crypto.ts'
5
+ import { ENCRYPTED_CACHE_PREFIX, type ObjectStorage, type RawObject, type RawObjectStorage, deleteObjects, objectPath, openObjectVersion } from './object-storage.ts'
6
+ import { type StorageDb, type StorageEncryptionState, type StorageRow, type StorageRowKind, dataKeyIdentity, unwrapDataKey } from './storage-db.ts'
7
+ import { PUBLIC_CACHE_PREFIX, inspectStorageObject, storageOwner, storageRowPaths, verifyStoragePayload } from './storage-payload.ts'
8
+
9
+ const STORAGE_WRITE_MS = 180_000
10
+ export const STORAGE_UPLOAD_TTL_MS = 86_400_000
11
+ export const STORAGE_DISABLED_TTL_MS = 5_000
12
+
13
+ async function readBytes(stored: Awaited<ReturnType<ObjectStorage['open']>>): Promise<Buffer | null> {
14
+ if (!stored) return null
15
+ const parts: Buffer[] = []
16
+ try { for await (const part of stored.stream) parts.push(Buffer.from(part)); return Buffer.concat(parts) }
17
+ finally { stored.stream.destroy() }
18
+ }
19
+ function logicalKey(identity: string) {
20
+ objectPath(identity)
21
+ if (identity.startsWith(ENCRYPTED_CACHE_PREFIX)) throw new Error('Encrypted cache paths are internal')
22
+ }
23
+ const encryptedCachePath = (identity: string) => `${ENCRYPTED_CACHE_PREFIX}${identity.slice('cache/'.length)}`
24
+ const deletionPaths = (identity: string) => identity.startsWith('cache/') ? [identity, encryptedCachePath(identity)] : [identity]
25
+ // Public bytes, stored as they are whether or not storage is encrypted.
26
+ const publicPath = (identity: string) => identity.startsWith('avatars/') || identity.startsWith(PUBLIC_CACHE_PREFIX)
27
+
28
+ // A PUT can commit and still throw. Remove only this attempted plaintext,
29
+ // comparing bytes rather than magic: arbitrary uploads can share the header.
30
+ // A concurrent migration's ciphertext must survive this reconciliation.
31
+ async function discardPlaintextWrite(raw: RawObjectStorage, identity: string, bytes: Buffer): Promise<void> {
32
+ // Leave time to report the original failure inside a serverless invocation.
33
+ const signal = AbortSignal.timeout(30_000)
34
+ const current = await raw.open(identity, signal)
35
+ if (!current) return
36
+ try {
37
+ let offset = 0
38
+ for await (const part of current.stream) {
39
+ const chunk = Buffer.from(part)
40
+ if (!chunk.equals(bytes.subarray(offset, offset + chunk.length))) return
41
+ offset += chunk.length
42
+ }
43
+ if (offset === bytes.length) await raw.delete(identity, current.version, signal)
44
+ } finally { current.stream.destroy() }
45
+ }
46
+
47
+ async function putPlaintext(raw: RawObjectStorage, identity: string, bytes: Buffer, mode: () => Promise<StorageEncryptionState | null>): Promise<null> {
48
+ try {
49
+ await raw.put(identity, bytes, AbortSignal.timeout(STORAGE_WRITE_MS))
50
+ if (!await mode()) return null
51
+ throw new Error('Storage encryption was enabled during upload; retry the upload')
52
+ } catch (err) {
53
+ try { await discardPlaintextWrite(raw, identity, bytes) }
54
+ catch (cleanup) { throw new AggregateError([err, cleanup], 'Storage write and plaintext cleanup failed', { cause: cleanup }) }
55
+ throw err
56
+ }
57
+ }
58
+
59
+ async function decryptOwned(stored: RawObject, key: StorageKey, type: StorageRowKind, row: StorageRow, identity: string) {
60
+ let dataKey: Buffer | undefined
61
+ try {
62
+ dataKey = unwrapDataKey(key, type, row)
63
+ return await decryptStorageStream(stored.stream, dataKey, identity)
64
+ } catch (err) { stored.stream.destroy(); throw err }
65
+ finally { dataKey?.fill(0) }
66
+ }
67
+
68
+ async function openEncrypted(raw: RawObjectStorage, db: StorageDb, key: StorageKey, identity: string, state: StorageEncryptionState): ReturnType<ObjectStorage['open']> {
69
+ const owner = storageOwner(identity)
70
+ if (!owner) {
71
+ if (!identity.startsWith('uploads/')) throw new Error('Unknown managed storage owner')
72
+ const stored = await raw.open(identity)
73
+ if (!stored) return null
74
+ const inspected = await inspectStorageObject(stored)
75
+ if (inspected.encrypted) return decryptStorageStream(inspected.stream, key.bytes, identity)
76
+ if (stored.modifiedAt <= state.enabledAt && Date.now() < state.enabledAt + STORAGE_UPLOAD_TTL_MS) return inspected
77
+ inspected.stream.destroy()
78
+ throw new Error('Unexpected plaintext upload part')
79
+ }
80
+ for (let attempt = 0; attempt < 3; attempt++) {
81
+ const row = await db.getStorageRow(owner.type, owner.id)
82
+ if (!row) return null
83
+ if (!owner.cache && !storageRowPaths(owner.type, row).includes(identity)) return null
84
+ if (owner.cache && !row.dataKey) return null
85
+ if (!owner.cache && !row.encrypted) state = (await db.getStorageEncryption())!
86
+ const stored = await raw.open(owner.cache ? encryptedCachePath(identity) : identity)
87
+ if (!stored) return null
88
+ // Completed uploads and all new caches are always encrypted. Only legacy
89
+ // pending rows need header inspection and hash-verified plaintext fallback.
90
+ if (row.encrypted || owner.cache || state.complete) {
91
+ return decryptOwned(stored, key, owner.type, row, identity)
92
+ }
93
+ let inspected = await inspectStorageObject(stored)
94
+ if (inspected.encrypted && !row.dataKey && (await db.getStorageRow(owner.type, owner.id))?.dataKey) {
95
+ inspected.stream.destroy(); continue
96
+ }
97
+ if (inspected.encrypted && row.dataKey) {
98
+ try { return await decryptOwned(inspected, key, owner.type, row, identity) }
99
+ catch {
100
+ // An arbitrary legacy upload can begin with our magic bytes. Only
101
+ // its original SQL hash can authorize that plaintext interpretation.
102
+ const legacy = await openObjectVersion(raw, identity, stored.version)
103
+ if (!legacy) continue
104
+ inspected = { ...legacy, encrypted: false }
105
+ }
106
+ }
107
+ // Only a pending row may read plaintext, and only the original upload.
108
+ // Verify first, then reopen that exact version before exposing bytes.
109
+ await verifyStoragePayload(owner.type, row, inspected.stream, undefined, identity)
110
+ const current = await openObjectVersion(raw, identity, stored.version)
111
+ if (current) return current
112
+ }
113
+ throw new Error('Storage changed repeatedly during read')
114
+ }
115
+
116
+ async function verifyReportConversion(db: StorageDb, identity: string, bytes: Buffer): Promise<string> {
117
+ const owner = storageOwner(identity)
118
+ if (owner?.type !== 'report' || owner.cache || identity !== `reports/${owner.id}.br`) throw new Error('Invalid report conversion')
119
+ const row = await db.getStorageRow('report', owner.id)
120
+ if (!row) throw new Error('Report deleted during compression')
121
+ // Representation changes must preserve the immutable upload hash.
122
+ await verifyStoragePayload('report', row, Readable.from([bytes]), undefined, identity)
123
+ return owner.id
124
+ }
125
+
126
+ async function finishReportConversion(raw: RawObjectStorage, db: StorageDb, id: string, encrypted: boolean): Promise<void> {
127
+ const row = await db.getStorageRow('report', id)
128
+ if (!row) { await raw.delete(`reports/${id}.br`); throw new Error('Report deleted during compression') }
129
+ // Conversion preserves the original row/key. Keep the original until the
130
+ // compressed PUT has succeeded, and mark encrypted only after removing it.
131
+ await raw.delete(`reports/${id}`)
132
+ if (encrypted) await db.markStorageEncrypted('report', id, row.dataKey!)
133
+ }
134
+
135
+ async function existingReportConversion(raw: RawObjectStorage, identity: string, row: StorageRow, key: StorageKey | null,
136
+ encryptedMode: boolean): Promise<{ version: string; encrypted: boolean } | null> {
137
+ const stored = await raw.open(identity)
138
+ if (!stored) return null
139
+ const inspected = await inspectStorageObject(stored)
140
+ // Activation or a concurrent key allocation can win after the SQL read.
141
+ // Retry with fresh policy/row state before interpreting any ciphertext.
142
+ if (inspected.encrypted && (!encryptedMode || !row.dataKey)) {
143
+ inspected.stream.destroy()
144
+ return { version: stored.version, encrypted: true }
145
+ }
146
+ const source = inspected.encrypted ? await decryptOwned(inspected, key!, 'report', row, identity) : inspected
147
+ await verifyStoragePayload('report', row, source.stream, undefined, identity)
148
+ return { version: stored.version, encrypted: inspected.encrypted }
149
+ }
150
+
151
+ async function publishReportConversion(raw: RawObjectStorage, identity: string, bytes: Buffer, row: StorageRow,
152
+ key: StorageKey | null, expected: string | null, encrypted: boolean): Promise<boolean> {
153
+ if (!encrypted) return raw.put(identity, bytes, AbortSignal.timeout(STORAGE_WRITE_MS), expected)
154
+ const dataKey = unwrapDataKey(key!, 'report', row)
155
+ const source = encryptStorageStream(Readable.from([bytes]), dataKey, identity, bytes.length)
156
+ try { return await raw.put(identity, source, AbortSignal.timeout(STORAGE_WRITE_MS), expected, bytes.length) }
157
+ finally { source.destroy(); dataKey.fill(0) }
158
+ }
159
+
160
+ async function convertStoredReport(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null, identity: string, bytes: Buffer,
161
+ mode: (fresh?: boolean) => Promise<StorageEncryptionState | null>): Promise<null> {
162
+ const id = await verifyReportConversion(db, identity, bytes)
163
+ for (let attempt = 0; attempt < 3; attempt++) {
164
+ const state = await mode(true)
165
+ const row = state ? await db.ensureStorageDataKey('report', id) : await db.getStorageRow('report', id)
166
+ if (!row) throw new Error('Report deleted during compression')
167
+ const current = await existingReportConversion(raw, identity, row, key, Boolean(state))
168
+ if (current?.encrypted && (!state || !row.dataKey)) continue
169
+ // A stale plaintext writer must never replace a concurrent encrypted
170
+ // winner. Retain the original on uncertain writes; the next open resumes.
171
+ if ((!current || state && !current.encrypted) &&
172
+ !await publishReportConversion(raw, identity, bytes, row, key, current?.version ?? null, Boolean(state))) continue
173
+ if (!state && await mode(true)) continue
174
+ await raw.sync?.(identity)
175
+ await finishReportConversion(raw, db, id, Boolean(state))
176
+ return null
177
+ }
178
+ throw new Error('Report changed repeatedly during compression')
179
+ }
180
+
181
+ // Startup enables encryption before constructing stores; validate the persisted
182
+ // requirement even when this adapter is opened without a key.
183
+ async function storagePolicy(db: StorageDb, key: StorageKey | null) {
184
+ let enabled = await db.getStorageEncryption()
185
+ let checkedAt = Date.now()
186
+ return async (fresh = false) => {
187
+ // Enabled is irreversible. Keyless instances cache "off" briefly, while
188
+ // writes and detected ciphertext always recheck the activation fence.
189
+ if (!enabled && (fresh || key || Date.now() - checkedAt >= STORAGE_DISABLED_TTL_MS)) {
190
+ const state = await db.getStorageEncryption()
191
+ if (state) enabled = state // A delayed disabled read cannot undo "on".
192
+ checkedAt = Date.now()
193
+ }
194
+ if (enabled && !key) throw new Error('Managed storage requires its configured encryption key')
195
+ return enabled
196
+ }
197
+ }
198
+
199
+ export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null): Promise<ObjectStorage> {
200
+ const mode = await storagePolicy(db, key)
201
+ async function open(identity: string): ReturnType<ObjectStorage['open']> {
202
+ logicalKey(identity)
203
+ const state = await mode()
204
+ if (publicPath(identity)) return raw.open(identity)
205
+ if (state) return openEncrypted(raw, db, key!, identity, state)
206
+ const stored = await raw.open(identity)
207
+ if (!stored) return null
208
+ const inspected = await inspectStorageObject(stored)
209
+ // Activation/migration can win after the disabled-state read. Never send
210
+ // that ciphertext to a caller expecting the original plaintext bytes.
211
+ if (inspected.encrypted) {
212
+ let current: StorageEncryptionState | null
213
+ try { current = await mode(true) } catch (err) { inspected.stream.destroy(); throw err }
214
+ if (current) { inspected.stream.destroy(); return openEncrypted(raw, db, key!, identity, current) }
215
+ }
216
+ return inspected
217
+ }
218
+ return {
219
+ open,
220
+ get: async identity => readBytes(await open(identity)),
221
+ async exists(identity) {
222
+ logicalKey(identity)
223
+ const owner = storageOwner(identity), state = await mode()
224
+ if (state && owner) {
225
+ const row = await db.getStorageRow(owner.type, owner.id)
226
+ if (!row || (owner.cache ? !row.dataKey : !storageRowPaths(owner.type, row).includes(identity))) return false
227
+ }
228
+ // Existence is metadata only; open() authenticates the actual bytes.
229
+ return await raw.head(state && owner?.cache ? encryptedCachePath(identity) : identity) !== null
230
+ },
231
+ async put(identity, bytes, { replaceReport = false } = {}) {
232
+ logicalKey(identity)
233
+ if (replaceReport) return convertStoredReport(raw, db, key, identity, bytes, mode)
234
+ const state = await mode()
235
+ const owner = storageOwner(identity)
236
+ if (publicPath(identity)) { await raw.put(identity, bytes); return null }
237
+ if (!state) return putPlaintext(raw, identity, bytes, () => mode(true))
238
+ let dataKey: Buffer, wrapped: string | null = null
239
+ if (owner?.cache) {
240
+ const row = await db.ensureStorageDataKey(owner.type, owner.id)
241
+ if (!row) throw new Error('Cache owner unavailable')
242
+ dataKey = unwrapDataKey(key!, owner.type, row)
243
+ } else if (owner) {
244
+ if (await db.getStorageRow(owner.type, owner.id)) throw new Error('Stored payloads are immutable')
245
+ dataKey = randomBytes(32)
246
+ wrapped = wrapStorageValue(key!, dataKeyIdentity(owner.type, owner.id), dataKey)
247
+ } else {
248
+ if (!identity.startsWith('uploads/')) throw new Error('Unknown managed storage owner')
249
+ dataKey = Buffer.from(key!.bytes)
250
+ }
251
+ const target = owner?.cache ? encryptedCachePath(identity) : identity
252
+ const encrypted = encryptStorageStream(Readable.from([bytes]), dataKey, identity, bytes.length)
253
+ try {
254
+ await raw.put(target, encrypted, AbortSignal.timeout(STORAGE_WRITE_MS), undefined, bytes.length)
255
+ // A late cache builder must not leave a readable cache for a deleted
256
+ // bundle. The missing SQL key also makes failed cleanup unreadable.
257
+ if (owner?.cache && !await db.getStorageRow(owner.type, owner.id)) await raw.delete(target)
258
+ return wrapped
259
+ } finally { encrypted.destroy(); dataKey.fill(0) }
260
+ },
261
+ async delete(identity) {
262
+ logicalKey(identity)
263
+ await mode()
264
+ // Always remove both generations, including caches predating activation
265
+ // when migration is disabled or another instance has just enabled it.
266
+ for (const target of deletionPaths(identity)) await raw.delete(target)
267
+ },
268
+ async deletePrefix(prefix) {
269
+ logicalKey(`${prefix}validate`)
270
+ if (!prefix.endsWith('/')) throw new Error('Invalid storage prefix')
271
+ await mode()
272
+ const signal = AbortSignal.timeout(STORAGE_WRITE_MS)
273
+ for (const target of deletionPaths(prefix)) {
274
+ await deleteObjects(raw, target, signal)
275
+ await raw.prune?.(target, signal)
276
+ }
277
+ },
278
+ }
279
+ }