okengine 0.10.2 → 0.11.0

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 (343) hide show
  1. package/package.json +76 -10
  2. package/site/content/docs/ai/mcp.mdx +0 -1
  3. package/site/content/docs/deployment/docker-swarm.mdx +48 -111
  4. package/site/content/docs/deployment/docker.mdx +43 -36
  5. package/site/content/docs/deployment/index.mdx +9 -9
  6. package/site/content/docs/deployment/kubernetes.mdx +4 -4
  7. package/site/content/docs/elements/ai.mdx +7 -13
  8. package/site/content/docs/elements/channel.mdx +9 -13
  9. package/site/content/docs/elements/clock.mdx +8 -14
  10. package/site/content/docs/elements/flow.mdx +46 -54
  11. package/site/content/docs/elements/gate.mdx +6 -11
  12. package/site/content/docs/elements/signal.mdx +2 -8
  13. package/site/content/docs/elements/store.mdx +79 -93
  14. package/site/content/docs/elements/vault.mdx +89 -76
  15. package/site/content/docs/get-started/basic-usage.mdx +8 -14
  16. package/site/content/docs/get-started/installation.mdx +36 -36
  17. package/site/content/docs/get-started/introduction.mdx +11 -0
  18. package/site/content/docs/get-started/why.mdx +3 -4
  19. package/site/content/docs/index.mdx +0 -1
  20. package/site/content/docs/meta.json +0 -1
  21. package/site/content/docs/plugins/anonymous.mdx +1 -1
  22. package/site/content/docs/plugins/magic-link.mdx +11 -11
  23. package/site/content/docs/plugins/otp.mdx +2 -2
  24. package/site/content/docs/plugins/passkey.mdx +1 -1
  25. package/site/content/docs/plugins/two-factor.mdx +1 -1
  26. package/site/content/docs/plugins/username.mdx +1 -1
  27. package/site/content/docs/recipes/caddy.mdx +1 -1
  28. package/site/content/docs/recipes/cockroachdb.mdx +2 -2
  29. package/site/content/docs/recipes/dragonfly.mdx +1 -1
  30. package/site/content/docs/recipes/index.mdx +1 -2
  31. package/site/content/docs/recipes/llama-cpp.mdx +21 -23
  32. package/site/content/docs/recipes/mailpit.mdx +11 -11
  33. package/site/content/docs/recipes/meilisearch.mdx +10 -10
  34. package/site/content/docs/recipes/meta.json +0 -1
  35. package/site/content/docs/recipes/ollama.mdx +1 -1
  36. package/site/content/docs/recipes/pgdog.mdx +12 -9
  37. package/site/content/docs/recipes/postgres.mdx +3 -3
  38. package/site/content/docs/recipes/redis.mdx +2 -2
  39. package/site/content/docs/recipes/rustfs.mdx +10 -7
  40. package/site/content/docs/recipes/sglang.mdx +1 -1
  41. package/site/content/docs/recipes/supabase-docker.mdx +1 -1
  42. package/site/content/docs/recipes/timescale.mdx +2 -2
  43. package/site/content/docs/recipes/valkey.mdx +1 -1
  44. package/site/content/docs/recipes/vllm.mdx +1 -1
  45. package/site/content/docs/recipes/yugabytedb.mdx +2 -2
  46. package/site/content/docs/reference/cli.md +61 -38
  47. package/site/content/docs/reference/configuration.mdx +145 -70
  48. package/site/content/docs/reference/environment-variables.mdx +36 -28
  49. package/site/content/docs/reference/errors.mdx +28 -29
  50. package/site/content/docs/reference/fx.mdx +14 -7
  51. package/site/content/docs/reference/i18n.mdx +17 -6
  52. package/site/content/docs/reference/index.mdx +5 -0
  53. package/site/content/docs/reference/meta.json +1 -0
  54. package/site/content/docs/reference/migrating-environments.mdx +158 -0
  55. package/site/content/docs/reference/plugins.mdx +2 -2
  56. package/src/auth/gate-auth.test.ts +12 -10
  57. package/src/cli/ai-setup/ai-setup.test.ts +30 -16
  58. package/src/cli/ai-setup/apply.ts +111 -15
  59. package/src/cli/ai-setup/catalog.ts +3 -3
  60. package/src/cli/ai-setup/index.ts +1 -1
  61. package/src/cli/ai-setup/prompts.ts +1 -1
  62. package/src/cli/ai-setup/recommend.test.ts +1 -1
  63. package/src/cli/ask-seed.test.ts +3 -3
  64. package/src/cli/build.ts +8 -9
  65. package/src/cli/db-auto-push.test.ts +6 -3
  66. package/src/cli/db-auto-push.ts +7 -5
  67. package/src/cli/db-seed.ts +7 -8
  68. package/src/cli/db.test.ts +70 -71
  69. package/src/cli/db.ts +86 -12
  70. package/src/cli/dev-controller.test.ts +35 -0
  71. package/src/cli/dev-controller.ts +298 -0
  72. package/src/cli/dev-controls.ts +11 -65
  73. package/src/cli/dev-db-push.test.ts +47 -15
  74. package/src/cli/dev-schema-sync.test.ts +11 -11
  75. package/src/cli/dev-schema-sync.ts +10 -10
  76. package/src/cli/dev-session-lock.test.ts +106 -0
  77. package/src/cli/dev-session-lock.ts +209 -0
  78. package/src/cli/dev.test.ts +246 -114
  79. package/src/cli/dev.ts +153 -205
  80. package/src/cli/doc-staleness.test.ts +3 -6
  81. package/src/cli/docker-clean.test.ts +33 -1
  82. package/src/cli/docker-clean.ts +28 -33
  83. package/src/cli/docker-cli.test.ts +12 -11
  84. package/src/cli/docker.ts +95 -20
  85. package/src/cli/doctor.test.ts +40 -0
  86. package/src/cli/doctor.ts +142 -9
  87. package/src/cli/drizzle-env.test.ts +11 -13
  88. package/src/cli/drizzle-env.ts +18 -11
  89. package/src/cli/ensure-drizzle-config.ts +11 -14
  90. package/src/cli/hero-meta.test.ts +15 -15
  91. package/src/cli/hero-meta.ts +10 -9
  92. package/src/cli/index.ts +36 -6
  93. package/src/cli/load-config.images.test.ts +44 -10
  94. package/src/cli/load-config.source.test.ts +50 -0
  95. package/src/cli/load-config.ts +35 -29
  96. package/src/cli/meilisearch-local.ts +1 -2
  97. package/src/cli/mode.ts +7 -58
  98. package/src/cli/registry.ts +175 -19
  99. package/src/cli/resolve-dev-sql-env.test.ts +8 -16
  100. package/src/cli/resolve-dev-sql-env.ts +13 -15
  101. package/src/cli/safe-defaults.test.ts +29 -11
  102. package/src/cli/schema.ts +65 -16
  103. package/src/cli/test.ts +58 -0
  104. package/src/cli/tui/App.tsx +252 -0
  105. package/src/cli/tui/DevLive.tsx +116 -0
  106. package/src/cli/tui/components/ConfirmDialog.tsx +48 -0
  107. package/src/cli/tui/components/HeaderBar.tsx +67 -0
  108. package/src/cli/tui/components/LogStream.tsx +47 -0
  109. package/src/cli/tui/components/PanelCard.tsx +39 -0
  110. package/src/cli/tui/components/SelectRow.tsx +37 -0
  111. package/src/cli/tui/components/SlashPalette.tsx +172 -0
  112. package/src/cli/tui/components/Spinner.tsx +32 -0
  113. package/src/cli/tui/components/StatusBar.tsx +51 -0
  114. package/src/cli/tui/components/StatusDot.tsx +37 -0
  115. package/src/cli/tui/components/TabBar.tsx +49 -0
  116. package/src/cli/tui/components/Wordmark.tsx +43 -0
  117. package/src/cli/tui/format.ts +38 -0
  118. package/src/cli/tui/keys.test.ts +18 -0
  119. package/src/cli/tui/keys.ts +26 -0
  120. package/src/cli/tui/launch.test.ts +18 -0
  121. package/src/cli/tui/launch.ts +57 -0
  122. package/src/cli/tui/panels/Dashboard.tsx +201 -0
  123. package/src/cli/tui/panels/Database.tsx +198 -0
  124. package/src/cli/tui/panels/Dev.tsx +257 -0
  125. package/src/cli/tui/panels/Docker.tsx +357 -0
  126. package/src/cli/tui/panels/Navigator.test.ts +22 -0
  127. package/src/cli/tui/panels/Navigator.tsx +213 -0
  128. package/src/cli/tui/slash-catalog.test.ts +31 -0
  129. package/src/cli/tui/slash-catalog.ts +184 -0
  130. package/src/cli/tui/slash-run.ts +54 -0
  131. package/src/cli/tui/state/ports.ts +95 -0
  132. package/src/cli/tui/theme.ts +24 -0
  133. package/src/cli/vault-cmd.test.ts +402 -0
  134. package/src/cli/vault-cmd.ts +727 -69
  135. package/src/cli/vault-secure-input.test.ts +56 -0
  136. package/src/cli/vault-secure-input.ts +124 -0
  137. package/src/compiler/effects-infer.ts +7 -0
  138. package/src/compiler/extract.ts +12 -0
  139. package/src/compiler/fixtures/skyport/oke.config.ts +7 -5
  140. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -1
  141. package/src/compiler/fixtures/skyport.expected.json +1 -1
  142. package/src/compiler/generate-adopt.ts +27 -1
  143. package/src/compiler-entry.ts +7 -0
  144. package/src/config/define-config.test.ts +111 -47
  145. package/src/config/driver-defaults.test.ts +115 -0
  146. package/src/config/driver-defaults.ts +156 -0
  147. package/src/config/index.ts +246 -131
  148. package/src/config/resolve-driver.test.ts +14 -19
  149. package/src/console/server/app.ts +4 -2
  150. package/src/console/server/console.test.ts +58 -1
  151. package/src/console/server/flows.ts +22 -2
  152. package/src/console/server/index.ts +3 -0
  153. package/src/console/server/operator-db.test.ts +55 -13
  154. package/src/console/server/operator-db.ts +305 -263
  155. package/src/console/server/serve.ts +16 -7
  156. package/src/console/server/state.ts +31 -9
  157. package/src/console/server/vault.test.ts +120 -5
  158. package/src/console/server/vault.ts +208 -12
  159. package/src/console/ui/dist/assets/{index-D-9KFce5.js → index--Y0Uaz-m.js} +2 -2
  160. package/src/console/ui/dist/assets/panel-vault-BkZdJPX8.js +1 -0
  161. package/src/console/ui/dist/assets/style-C3L349iZ.css +3 -0
  162. package/src/console/ui/dist/index.html +2 -2
  163. package/src/console/ui/shell/client.ts +16 -0
  164. package/src/console/ui/shell/panels/vault/VaultPanel.tsx +54 -1
  165. package/src/console/ui/vault/VaultA11yView.tsx +33 -1
  166. package/src/console/ui/vault/backend.test.ts +75 -0
  167. package/src/console/ui/vault/backend.ts +117 -0
  168. package/src/console/ui/vault/fixture.ts +21 -2
  169. package/src/console/ui/vault/index.ts +11 -1
  170. package/src/console/ui/vault/types.ts +26 -0
  171. package/src/docker/compose.ts +164 -61
  172. package/src/docker/derive.ts +106 -12
  173. package/src/docker/docker.test.ts +156 -110
  174. package/src/docker/helpers.ts +98 -0
  175. package/src/docker/images-config.test.ts +163 -0
  176. package/src/docker/index.ts +30 -5
  177. package/src/docker/recipes/index.ts +0 -3
  178. package/src/docker/recipes/llama-cpp.ts +4 -4
  179. package/src/docker/recipes/pgdog.ts +9 -2
  180. package/src/docker/resources.test.ts +71 -0
  181. package/src/docker/resources.ts +176 -0
  182. package/src/docker/stack-id.test.ts +11 -4
  183. package/src/docker/stack-id.ts +42 -32
  184. package/src/docker/stack.integration.test.ts +4 -15
  185. package/src/docker/types.ts +35 -3
  186. package/src/drivers/ai-ollama.integration.test.ts +2 -2
  187. package/src/drivers/conformance.test.ts +11 -12
  188. package/src/drivers/drizzle-dialect.test.ts +0 -4
  189. package/src/drivers/drizzle-dialect.ts +7 -13
  190. package/src/drivers/index.ts +24 -7
  191. package/src/drivers/journal-postgres.test.ts +3 -1
  192. package/src/drivers/journal-postgres.ts +4 -2
  193. package/src/drivers/meilisearch.ts +1 -1
  194. package/src/drivers/pglite.ts +28 -5
  195. package/src/drivers/types.ts +9 -11
  196. package/src/drivers/vault-aws-secrets-manager.test.ts +159 -0
  197. package/src/drivers/vault-aws-secrets-manager.ts +333 -0
  198. package/src/drivers/vault-builtin.test.ts +110 -0
  199. package/src/drivers/vault-builtin.ts +184 -0
  200. package/src/drivers/vault-driver-removal.test.ts +13 -0
  201. package/src/drivers/vault-managed.test.ts +95 -0
  202. package/src/drivers/vault-managed.ts +101 -25
  203. package/src/drivers/vault-types.ts +25 -5
  204. package/src/elements/ai/runtime.ts +1 -1
  205. package/src/elements/clock/chaos.test.ts +4 -1
  206. package/src/elements/clock/durable.ts +57 -4
  207. package/src/elements/clock.test.ts +13 -14
  208. package/src/elements/gate/constants.ts +18 -0
  209. package/src/elements/gate.test.ts +1 -1
  210. package/src/elements/gate.ts +2 -2
  211. package/src/elements/store/domain-ddl.test.ts +5 -12
  212. package/src/elements/store/emit-drizzle.ts +44 -19
  213. package/src/elements/store/index-boot.test.ts +15 -116
  214. package/src/elements/store/load-plugin-tables.ts +1 -1
  215. package/src/elements/store/resource-list-docs.fixture.ts +12 -6
  216. package/src/elements/store/resource-list-docs.test.ts +64 -44
  217. package/src/elements/store/resource.test.ts +17 -10
  218. package/src/elements/store/runtime.ts +9 -12
  219. package/src/elements/store/schema-decl.test.ts +16 -32
  220. package/src/elements/store/seed.test.ts +1 -2
  221. package/src/elements/store/seed.ts +7 -8
  222. package/src/elements/store/sql-condition.test.ts +2 -2
  223. package/src/elements/store/sql-session.test.ts +8 -10
  224. package/src/elements/store/sql-session.ts +10 -2
  225. package/src/elements/store.test.ts +13 -13
  226. package/src/elements/vault/audit.ts +233 -0
  227. package/src/elements/vault/boot-chain.test.ts +78 -0
  228. package/src/elements/vault/boot-chain.ts +43 -39
  229. package/src/elements/vault/builtin-adapter.test.ts +479 -0
  230. package/src/elements/vault/builtin-adapter.ts +1184 -0
  231. package/src/elements/vault/crypto.test.ts +237 -0
  232. package/src/elements/vault/crypto.ts +431 -0
  233. package/src/elements/vault/declare.ts +164 -3
  234. package/src/elements/vault/errors.ts +56 -0
  235. package/src/elements/vault/kms-unseal.test.ts +76 -0
  236. package/src/elements/vault/kms-unseal.ts +181 -0
  237. package/src/elements/vault/path.ts +90 -0
  238. package/src/elements/vault/resilience.test.ts +102 -0
  239. package/src/elements/vault/resilience.ts +107 -0
  240. package/src/elements/vault/runtime.ts +36 -4
  241. package/src/elements/vault/security-checklist.test.ts +130 -0
  242. package/src/elements/vault/storage.ts +448 -0
  243. package/src/elements/vault/types.ts +288 -0
  244. package/src/elements/vault/unseal.ts +150 -0
  245. package/src/elements/vault.test.ts +72 -4
  246. package/src/elements/vault.ts +91 -2
  247. package/src/full.ts +248 -0
  248. package/src/http.ts +41 -0
  249. package/src/i18n/interpolate-simple.ts +37 -0
  250. package/src/i18n/messages.ts +51 -4
  251. package/src/i18n-entry.ts +7 -0
  252. package/src/index.ts +35 -145
  253. package/src/journal-entry.ts +20 -0
  254. package/src/kernel/adopt-barrel-fresh.test.ts +25 -10
  255. package/src/kernel/app.ts +40 -42
  256. package/src/kernel/auto-registry.test.ts +2 -2
  257. package/src/kernel/boot-bind/ai.test.ts +5 -5
  258. package/src/kernel/boot-bind/ai.ts +2 -1
  259. package/src/kernel/boot-bind/channel.test.ts +6 -6
  260. package/src/kernel/boot-bind/channel.ts +5 -4
  261. package/src/kernel/boot-bind/clock.ts +5 -5
  262. package/src/kernel/boot-bind/gate.ts +6 -7
  263. package/src/kernel/boot-bind/honor-config.test.ts +42 -42
  264. package/src/kernel/boot-bind/journal.ts +5 -5
  265. package/src/kernel/boot-bind/signal.ts +3 -1
  266. package/src/kernel/boot-bind/store.test.ts +55 -37
  267. package/src/kernel/boot-bind/store.ts +24 -34
  268. package/src/kernel/boot-bind/vault.ts +19 -4
  269. package/src/kernel/boot.test.ts +12 -4
  270. package/src/kernel/boot.ts +31 -19
  271. package/src/kernel/budget-entry.ts +1 -1
  272. package/src/kernel/compensate.test.ts +288 -1
  273. package/src/kernel/compensate.ts +126 -0
  274. package/src/kernel/concurrency.test.ts +10 -9
  275. package/src/kernel/effects-stamping.test.ts +27 -2
  276. package/src/kernel/element-registries.ts +5 -3
  277. package/src/kernel/flow.ts +4 -2
  278. package/src/kernel/fx.test.ts +119 -3
  279. package/src/kernel/fx.ts +215 -40
  280. package/src/kernel/index.ts +14 -0
  281. package/src/kernel/journal.ts +130 -7
  282. package/src/kernel/pipeline.test.ts +20 -6
  283. package/src/kernel/redacted.test.ts +35 -0
  284. package/src/kernel/redacted.ts +24 -2
  285. package/src/kernel/router/create-default.ts +16 -0
  286. package/src/kernel/router/create-edge.ts +18 -0
  287. package/src/kernel/router/create.ts +23 -0
  288. package/src/kernel/router/index.ts +23 -0
  289. package/src/kernel/router/linear.ts +88 -0
  290. package/src/kernel/router/regexp.ts +188 -0
  291. package/src/kernel/router/smart.ts +120 -0
  292. package/src/kernel/router/trie.ts +108 -0
  293. package/src/kernel/router/types.ts +56 -0
  294. package/src/kernel/router.ts +18 -532
  295. package/src/kernel-entry.ts +43 -0
  296. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  297. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  298. package/src/plugins/config-source.test.ts +7 -7
  299. package/src/release/build-lib.ts +58 -0
  300. package/src/release/exports.test.ts +4 -2
  301. package/src/release/exports.ts +30 -4
  302. package/src/release/measure.ts +101 -22
  303. package/src/runs/duckdb.ts +7 -2
  304. package/src/shared/optional-peer.ts +41 -0
  305. package/src/term.test.ts +3 -44
  306. package/src/test/create-test-app.ts +12 -0
  307. package/src/test/provisions.integration.test.ts +1 -1
  308. package/src/test/reset-element-registries.ts +2 -0
  309. package/src/upgrade/codemods.test.ts +7 -20
  310. package/src/upgrade/codemods.ts +93 -60
  311. package/site/content/docs/console/access.mdx +0 -29
  312. package/site/content/docs/console/ai.mdx +0 -35
  313. package/site/content/docs/console/architecture.mdx +0 -35
  314. package/site/content/docs/console/channels.mdx +0 -37
  315. package/site/content/docs/console/clock.mdx +0 -51
  316. package/site/content/docs/console/flows.mdx +0 -31
  317. package/site/content/docs/console/gates.mdx +0 -73
  318. package/site/content/docs/console/index.mdx +0 -54
  319. package/site/content/docs/console/manifest-diff.mdx +0 -34
  320. package/site/content/docs/console/meta.json +0 -24
  321. package/site/content/docs/console/overview.mdx +0 -40
  322. package/site/content/docs/console/plugins.mdx +0 -41
  323. package/site/content/docs/console/privacy.mdx +0 -32
  324. package/site/content/docs/console/runs.mdx +0 -50
  325. package/site/content/docs/console/signals.mdx +0 -40
  326. package/site/content/docs/console/store.mdx +0 -32
  327. package/site/content/docs/console/tenancy.mdx +0 -32
  328. package/site/content/docs/console/traces.mdx +0 -38
  329. package/site/content/docs/console/vault.mdx +0 -41
  330. package/site/content/docs/recipes/openbao.mdx +0 -141
  331. package/src/cli/ask-dev-mode.ts +0 -40
  332. package/src/cli/dev-mode.test.ts +0 -45
  333. package/src/cli/dev-mode.ts +0 -78
  334. package/src/cli/openbao-bootstrap.test.ts +0 -150
  335. package/src/cli/openbao-bootstrap.ts +0 -321
  336. package/src/cli/openbao-restart.integration.test.ts +0 -145
  337. package/src/console/ui/dist/assets/panel-vault-Za1GyJfM.js +0 -1
  338. package/src/console/ui/dist/assets/style-C8MxEWPd.css +0 -3
  339. package/src/docker/recipes/openbao.ts +0 -47
  340. package/src/drivers/libsql.ts +0 -179
  341. package/src/drivers/sqlite.ts +0 -44
  342. package/src/drivers/vault-openbao.test.ts +0 -97
  343. package/src/drivers/vault-openbao.ts +0 -143
@@ -0,0 +1,1184 @@
1
+ /**
2
+ * Built-in Vault adapter — encrypted-at-rest secrets over plain SQL.
3
+ *
4
+ * One master key (never stored) derives a KEK; every secret version gets its
5
+ * own DEK, wrapped by that KEK. Two AAD bindings guard the envelope:
6
+ *
7
+ * ```text
8
+ * value AAD = (path, version, algorithm, secrets.kek_version)
9
+ * DEK AAD = (path, version, algorithm, keys.kek_version)
10
+ * ```
11
+ *
12
+ * They are equal at write time and diverge only during a master rotation:
13
+ * {@link BuiltinVaultAdapter.rotateMaster} re-wraps DEKs under a new KEK
14
+ * generation without touching the value ciphertext, so a rotation never has
15
+ * to decrypt-and-re-encrypt every secret. Reads try the active KEK first and
16
+ * fall back to the pending one, which makes an in-flight rewrap invisible to
17
+ * callers.
18
+ *
19
+ * Every operation appends one row to the tamper-evident audit chain. No
20
+ * cleartext value ever reaches the log, an error message, or a `VaultError`.
21
+ */
22
+
23
+ import { createAuditSink, type AuditAction, type AuditSink } from "./audit.ts";
24
+ import {
25
+ ALGORITHM,
26
+ buildAad,
27
+ constantTimeEqualStrings,
28
+ decryptBytes,
29
+ decryptSecret,
30
+ deriveVerifyHash,
31
+ encryptBytes,
32
+ encryptSecret,
33
+ generateDek,
34
+ generateMasterKey,
35
+ importAesKey,
36
+ masterKeyFromBase64,
37
+ masterKeyToBase64,
38
+ unwrapDek,
39
+ wrapDek,
40
+ zeroBytes,
41
+ type SealedBytes,
42
+ } from "./crypto.ts";
43
+ import { VaultError } from "./errors.ts";
44
+ import { canonicalizePath, canonicalizePrefix } from "./path.ts";
45
+ import { createResilientSqlExec } from "./resilience.ts";
46
+ import {
47
+ createSqlAuditWriter,
48
+ ensureVaultTables,
49
+ purgeAuditBefore as purgeAuditRows,
50
+ purgeExpiredSecrets,
51
+ readAuditPage,
52
+ verifyAuditChain,
53
+ type AuditChainResult,
54
+ type AuditPageOptions,
55
+ type PurgeExpiredResult,
56
+ type SqlExec,
57
+ type VaultAuditRecord,
58
+ } from "./storage.ts";
59
+ import { createMemoryUnsealer, type Unsealer } from "./unseal.ts";
60
+ import type {
61
+ VaultActor,
62
+ VaultAdapter,
63
+ VaultAlgorithm,
64
+ VaultGetOptions,
65
+ VaultInitResult,
66
+ VaultListEntry,
67
+ VaultListOptions,
68
+ VaultSecret,
69
+ VaultSetOptions,
70
+ VaultStatus,
71
+ } from "./types.ts";
72
+
73
+ /** Default number of DEKs re-wrapped per {@link BuiltinVaultAdapter.rotateMaster} batch. */
74
+ export const DEFAULT_KEK_REWRAP_BATCH_SIZE = 100;
75
+
76
+ /** Leading magic line of an {@link BuiltinVaultAdapter.exportBackup} bundle. */
77
+ export const BACKUP_MAGIC = "oke-vault-backup-v1\n";
78
+
79
+ /** Synthetic path bound into the backup bundle's AAD. */
80
+ const BACKUP_AAD_PATH = "oke-vault-backup";
81
+
82
+ /** Options for {@link createBuiltinVaultAdapter}. */
83
+ export interface CreateBuiltinVaultOptions {
84
+ /** SQL surface holding the `oke_vault_*` tables. */
85
+ readonly db: SqlExec;
86
+ /**
87
+ * Pre-built unsealer. When supplied the adapter starts unsealed and
88
+ * {@link VaultAdapter.unseal} is not required.
89
+ */
90
+ readonly unsealer?: Unsealer;
91
+ /** Audit destination. Defaults to a `db` sink over `oke_vault_audit`. */
92
+ readonly auditSink?: AuditSink;
93
+ /** DEKs re-wrapped per rotation batch. Defaults to {@link DEFAULT_KEK_REWRAP_BATCH_SIZE}. */
94
+ readonly kekRewrapBatchSize?: number;
95
+ }
96
+
97
+ /** Progress of a master-key rotation. */
98
+ export interface VaultRotateMasterResult {
99
+ /** KEK generation the rewrap is migrating toward. */
100
+ readonly kekVersion: number;
101
+ /** DEKs still wrapped under an older generation after this batch. */
102
+ readonly remaining: number;
103
+ /**
104
+ * Base64 of a freshly generated master key — present only when
105
+ * {@link BuiltinVaultAdapter.rotateMaster} was called without one, and
106
+ * returned exactly once. Store it out of band before the rewrap finishes.
107
+ */
108
+ readonly masterKey?: string;
109
+ }
110
+
111
+ /** Built-in adapter surface: {@link VaultAdapter} plus operator lifecycle. */
112
+ export interface BuiltinVaultAdapter extends VaultAdapter {
113
+ /** Drop the in-memory master key; later reads fail with `SEALED`. */
114
+ seal(): Promise<void>;
115
+ /**
116
+ * Restore the in-memory master key after verifying it against
117
+ * `oke_vault_master.key_hash`.
118
+ *
119
+ * @param masterKey - Base64 text or raw 32 bytes
120
+ */
121
+ unseal(masterKey: string | Uint8Array): Promise<void>;
122
+ /** Generate the master key and persist backend state exactly once. */
123
+ initialize(): Promise<VaultInitResult>;
124
+ /**
125
+ * Start a master-key rotation and re-wrap the first batch of DEKs.
126
+ *
127
+ * Both master keys stay in memory until the rewrap completes, so reads
128
+ * keep working throughout. Call {@link continueRotateMaster} until
129
+ * `remaining` reaches `0`.
130
+ *
131
+ * @param newMasterKey - Raw 32-byte replacement key; generated when omitted
132
+ */
133
+ rotateMaster(newMasterKey?: Uint8Array): Promise<VaultRotateMasterResult>;
134
+ /**
135
+ * Re-wrap the next batch of an in-flight rotation.
136
+ *
137
+ * After a process restart the in-memory pending key is gone — pass the
138
+ * **new** master key (the one printed by {@link rotateMaster}) so the
139
+ * adapter can resume from `rewrap_checkpoint`. The vault must still be
140
+ * unsealed with the **old** master key for dual-read of unrewrapped DEKs.
141
+ *
142
+ * @param newMasterKey - Required after a cold resume; ignored when pending is live
143
+ */
144
+ continueRotateMaster(newMasterKey?: string | Uint8Array): Promise<VaultRotateMasterResult>;
145
+ /**
146
+ * Delete audit rows older than `before`.
147
+ *
148
+ * @param before - Exclusive upper bound on `created_at`
149
+ * @returns Number of rows removed
150
+ */
151
+ purgeAuditBefore(before: Date): Promise<number>;
152
+ /**
153
+ * Hard-delete secret rows with `expires_at` strictly before `before`.
154
+ *
155
+ * @param options - Cutoff and dry-run
156
+ */
157
+ purgeExpired(options?: {
158
+ readonly before?: Date;
159
+ readonly dryRun?: boolean;
160
+ }): Promise<PurgeExpiredResult>;
161
+ /** Walk the audit chain and report the first broken row. */
162
+ verifyAudit(): Promise<AuditChainResult>;
163
+ /**
164
+ * Read the newest audit rows. Never requires an unsealed vault — audit
165
+ * rows describe operations, not values.
166
+ *
167
+ * @param options - Limit / path filter
168
+ */
169
+ listAudit(options?: AuditPageOptions): Promise<readonly VaultAuditRecord[]>;
170
+ /** Export every live secret as one bundle encrypted under the backup KEK. */
171
+ exportBackup(): Promise<Uint8Array>;
172
+ /**
173
+ * Restore a bundle produced by {@link exportBackup}.
174
+ *
175
+ * Entries are replayed through the normal write path: each restored path
176
+ * receives a **new** version wrapped by a fresh DEK under the current KEK.
177
+ *
178
+ * @param blob - Bundle bytes
179
+ */
180
+ importBackup(blob: Uint8Array): Promise<void>;
181
+ /** Active unsealer, or `null` while sealed. */
182
+ getUnsealer(): Unsealer | null;
183
+ }
184
+
185
+ /** Numeric column as any supported driver may hand it back. */
186
+ type SqlNumber = number | string | bigint;
187
+
188
+ /** `oke_vault_secrets` row as returned by the driver. */
189
+ interface SecretRow {
190
+ id: string;
191
+ path: string;
192
+ encrypted_value: unknown;
193
+ iv: unknown;
194
+ auth_tag: unknown;
195
+ version: SqlNumber;
196
+ metadata: unknown;
197
+ algorithm: string;
198
+ kek_version: SqlNumber;
199
+ created_at: Date | string;
200
+ updated_at: Date | string;
201
+ expires_at: Date | string | null;
202
+ }
203
+
204
+ /** `oke_vault_keys` row joined with its secret's AAD inputs. */
205
+ interface KeyRow {
206
+ id: string;
207
+ encrypted_dek: unknown;
208
+ dek_iv: unknown;
209
+ dek_auth_tag: unknown;
210
+ kek_version: SqlNumber;
211
+ }
212
+
213
+ /** `oke_vault_status` singleton row. */
214
+ interface StatusRow {
215
+ sealed: boolean | number;
216
+ initialized: boolean | number;
217
+ master_key_present: boolean | number;
218
+ last_sealed_at: Date | string | null;
219
+ last_unsealed_at: Date | string | null;
220
+ seal_count: SqlNumber;
221
+ rewrap_checkpoint: string | null;
222
+ rewrap_target_kek_version: SqlNumber | null;
223
+ /** Verify-hash of the pending new master (for cold resume). */
224
+ rewrap_key_hash: string | null;
225
+ }
226
+
227
+ /** `oke_vault_master` row. */
228
+ interface MasterRow {
229
+ key_hash: string;
230
+ kek_version: SqlNumber;
231
+ }
232
+
233
+ /** One secret in an export bundle. */
234
+ interface BackupEntry {
235
+ readonly path: string;
236
+ readonly value: string;
237
+ readonly version: number;
238
+ readonly metadata: Record<string, unknown>;
239
+ readonly expiresAt: string | null;
240
+ }
241
+
242
+ /** Non-secret header of an export bundle. */
243
+ interface BackupMetadata {
244
+ readonly format: 1;
245
+ readonly createdAt: string;
246
+ readonly kekVersion: number;
247
+ readonly count: number;
248
+ readonly iv: string;
249
+ readonly tag: string;
250
+ }
251
+
252
+ /** Master key held for the target generation of an in-flight rewrap. */
253
+ interface PendingRotation {
254
+ readonly unsealer: Unsealer;
255
+ readonly kekVersion: number;
256
+ readonly verifyHash: string;
257
+ }
258
+
259
+ /**
260
+ * Create the built-in SQL-backed Vault adapter.
261
+ *
262
+ * The tables are created lazily on first use, so constructing an adapter
263
+ * never touches the database.
264
+ *
265
+ * @param opts - SQL surface, optional unsealer, audit sink, batch size
266
+ */
267
+ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): BuiltinVaultAdapter {
268
+ // Secret CRUD retries on connection blips. Audit appends stay on the raw
269
+ // surface so a retry never duplicates a hash-chain row.
270
+ const rawDb = opts.db;
271
+ const db = createResilientSqlExec(rawDb);
272
+ const batchSize = Math.max(1, opts.kekRewrapBatchSize ?? DEFAULT_KEK_REWRAP_BATCH_SIZE);
273
+ const audit = opts.auditSink ?? createAuditSink("db", { writer: createSqlAuditWriter(rawDb) });
274
+
275
+ let unsealer: Unsealer | null = opts.unsealer ?? null;
276
+ let pending: PendingRotation | null = null;
277
+ let ready: Promise<void> | undefined;
278
+
279
+ /** Create the `oke_vault_*` tables once per adapter. */
280
+ function ensureReady(): Promise<void> {
281
+ ready ??= ensureVaultTables(db);
282
+ return ready;
283
+ }
284
+
285
+ /** Active unsealer, or `SEALED`. */
286
+ function requireUnsealer(): Unsealer {
287
+ if (!unsealer || unsealer.sealed) {
288
+ throw new VaultError("SEALED", "vault: sealed — unseal with the master key first");
289
+ }
290
+ return unsealer;
291
+ }
292
+
293
+ /**
294
+ * Append one audit row, tolerating a sink that is temporarily down.
295
+ *
296
+ * An audit failure must not mask the operation's own outcome, so the
297
+ * append error is swallowed after the operation has already succeeded.
298
+ *
299
+ * @param action - What happened
300
+ * @param actor - Caller identity
301
+ * @param extra - Path and failure detail
302
+ */
303
+ async function record(
304
+ action: AuditAction,
305
+ actor: VaultActor | undefined,
306
+ extra: {
307
+ path?: string;
308
+ success?: boolean;
309
+ errorCode?: VaultError["code"];
310
+ errorMessage?: string;
311
+ } = {},
312
+ ): Promise<void> {
313
+ try {
314
+ await audit.append({
315
+ action,
316
+ actorType: actor?.type ?? "unknown",
317
+ ...(actor?.id === undefined ? {} : { actorId: actor.id }),
318
+ ...(actor?.requestId === undefined ? {} : { requestId: actor.requestId }),
319
+ ...(extra.path === undefined ? {} : { path: extra.path }),
320
+ ...(extra.errorCode === undefined ? {} : { errorCode: extra.errorCode }),
321
+ ...(extra.errorMessage === undefined ? {} : { errorMessage: extra.errorMessage }),
322
+ success: extra.success ?? true,
323
+ });
324
+ } catch {
325
+ // Auditing is best-effort at the tail of an operation.
326
+ }
327
+ }
328
+
329
+ /** Read the singleton status row, creating the tables when needed. */
330
+ async function readStatus(): Promise<StatusRow> {
331
+ await ensureReady();
332
+ const rows = await db.query<StatusRow>(
333
+ `SELECT sealed, initialized, master_key_present, last_sealed_at, last_unsealed_at,
334
+ seal_count, rewrap_checkpoint, rewrap_target_kek_version, rewrap_key_hash
335
+ FROM oke_vault_status WHERE id = 1`,
336
+ );
337
+ const row = rows[0];
338
+ if (!row) {
339
+ throw new VaultError("BACKEND_ERROR", "vault: status row is missing");
340
+ }
341
+ return row;
342
+ }
343
+
344
+ /** Read the master record, or `NOT_INITIALIZED`. */
345
+ async function readMaster(): Promise<MasterRow> {
346
+ await ensureReady();
347
+ const rows = await db.query<MasterRow>(
348
+ `SELECT key_hash, kek_version FROM oke_vault_master ORDER BY created_at ASC LIMIT 1`,
349
+ );
350
+ const row = rows[0];
351
+ if (!row) {
352
+ throw new VaultError("NOT_INITIALIZED", "vault: not initialized — run `oke vault init`");
353
+ }
354
+ return row;
355
+ }
356
+
357
+ /**
358
+ * Unwrap a DEK under the active KEK, falling back to a pending rotation's.
359
+ *
360
+ * @param sealed - Stored wrapped-DEK material
361
+ * @param aad - AAD bound at wrap time
362
+ */
363
+ async function unwrapWithAnyKek(sealed: SealedBytes, aad: Uint8Array): Promise<Uint8Array> {
364
+ const keks = [await requireUnsealer().unwrapKek()];
365
+ if (pending) keks.push(await pending.unsealer.unwrapKek());
366
+ for (const kek of keks) {
367
+ try {
368
+ return await unwrapDek(kek, sealed, aad);
369
+ } catch {
370
+ // Try the other generation before giving up.
371
+ }
372
+ }
373
+ throw new VaultError("INVALID_KEY", "vault: unable to unwrap the data key");
374
+ }
375
+
376
+ /**
377
+ * Fetch a secret row by path.
378
+ *
379
+ * @param path - Canonical path
380
+ * @param version - Pinned version, or latest when omitted
381
+ */
382
+ async function readSecretRow(path: string, version?: number): Promise<SecretRow | undefined> {
383
+ const rows =
384
+ version === undefined
385
+ ? await db.query<SecretRow>(
386
+ `SELECT id, path, encrypted_value, iv, auth_tag, version, metadata, algorithm,
387
+ kek_version, created_at, updated_at, expires_at
388
+ FROM oke_vault_secrets
389
+ WHERE path = $1 AND deleted_at IS NULL
390
+ ORDER BY version DESC LIMIT 1`,
391
+ [path],
392
+ )
393
+ : await db.query<SecretRow>(
394
+ `SELECT id, path, encrypted_value, iv, auth_tag, version, metadata, algorithm,
395
+ kek_version, created_at, updated_at, expires_at
396
+ FROM oke_vault_secrets
397
+ WHERE path = $1 AND version = $2 AND deleted_at IS NULL
398
+ LIMIT 1`,
399
+ [path, version],
400
+ );
401
+ return rows[0];
402
+ }
403
+
404
+ /**
405
+ * Fetch the live wrapped DEK for a secret version.
406
+ *
407
+ * @param secretId - `oke_vault_secrets.id`
408
+ */
409
+ async function readKeyRow(secretId: string): Promise<KeyRow> {
410
+ const rows = await db.query<KeyRow>(
411
+ `SELECT id, encrypted_dek, dek_iv, dek_auth_tag, kek_version
412
+ FROM oke_vault_keys WHERE secret_id = $1 ORDER BY created_at DESC LIMIT 1`,
413
+ [secretId],
414
+ );
415
+ const row = rows[0];
416
+ if (!row) {
417
+ throw new VaultError("BACKEND_ERROR", "vault: secret has no wrapped data key");
418
+ }
419
+ return row;
420
+ }
421
+
422
+ /**
423
+ * Decrypt one secret row into its cleartext value.
424
+ *
425
+ * @param row - Stored secret version
426
+ * @param keyRow - That version's live wrapped DEK
427
+ */
428
+ async function decryptRow(row: SecretRow, keyRow: KeyRow): Promise<string> {
429
+ const path = row.path;
430
+ const version = toInt(row.version);
431
+ const algorithm = row.algorithm as VaultAlgorithm;
432
+
433
+ const dekAad = buildAad(path, version, algorithm, toInt(keyRow.kek_version));
434
+ const dekBytes = await unwrapWithAnyKek(
435
+ {
436
+ iv: toBytes(keyRow.dek_iv),
437
+ ciphertext: toBytes(keyRow.encrypted_dek),
438
+ tag: toBytes(keyRow.dek_auth_tag),
439
+ },
440
+ dekAad,
441
+ );
442
+ try {
443
+ const dek = await importAesKey(dekBytes);
444
+ return await decryptSecret(
445
+ dek,
446
+ {
447
+ iv: toBytes(row.iv),
448
+ ciphertext: toBytes(row.encrypted_value),
449
+ tag: toBytes(row.auth_tag),
450
+ },
451
+ buildAad(path, version, algorithm, toInt(row.kek_version)),
452
+ );
453
+ } finally {
454
+ zeroBytes(dekBytes);
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Write a new version of `path` with a fresh DEK.
460
+ *
461
+ * @param path - Canonical path
462
+ * @param value - Cleartext value
463
+ * @param options - Metadata / expiry / actor
464
+ * @param action - Audit action to record (`set` or `rotate`)
465
+ */
466
+ async function writeVersion(
467
+ path: string,
468
+ value: string,
469
+ options: VaultSetOptions | undefined,
470
+ action: Extract<AuditAction, "set" | "rotate">,
471
+ ): Promise<VaultSecret> {
472
+ const master = await readMaster();
473
+ const kekVersion = toInt(master.kek_version);
474
+ const kek = await requireUnsealer().unwrapKek();
475
+
476
+ const versions = await db.query<{ next: SqlNumber | null }>(
477
+ `SELECT MAX(version) + 1 AS next FROM oke_vault_secrets WHERE path = $1`,
478
+ [path],
479
+ );
480
+ const version = versions[0]?.next == null ? 1 : toInt(versions[0].next);
481
+ const aad = buildAad(path, version, ALGORITHM, kekVersion);
482
+ const metadata = options?.metadata ?? {};
483
+ const expiresAt = resolveExpiry(options);
484
+
485
+ const dekBytes = generateDek();
486
+ let sealedValue: SealedBytes;
487
+ let wrappedDek: SealedBytes;
488
+ try {
489
+ sealedValue = await encryptSecret(await importAesKey(dekBytes), value, aad);
490
+ wrappedDek = await wrapDek(kek, dekBytes, aad);
491
+ } finally {
492
+ zeroBytes(dekBytes);
493
+ }
494
+
495
+ const inserted = await db.query<{
496
+ id: string;
497
+ created_at: Date | string;
498
+ updated_at: Date | string;
499
+ }>(
500
+ `INSERT INTO oke_vault_secrets
501
+ (path, encrypted_value, iv, auth_tag, version, metadata, algorithm, kek_version, expires_at)
502
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9)
503
+ RETURNING id, created_at, updated_at`,
504
+ [
505
+ path,
506
+ sealedValue.ciphertext,
507
+ sealedValue.iv,
508
+ sealedValue.tag,
509
+ version,
510
+ JSON.stringify(metadata),
511
+ ALGORITHM,
512
+ kekVersion,
513
+ expiresAt ?? null,
514
+ ],
515
+ );
516
+ const row = inserted[0];
517
+ if (!row) {
518
+ throw new VaultError("BACKEND_ERROR", "vault: failed to persist the secret version");
519
+ }
520
+
521
+ await db.execute(
522
+ `INSERT INTO oke_vault_keys
523
+ (secret_id, encrypted_dek, dek_iv, dek_auth_tag, algorithm, kek_version)
524
+ VALUES ($1, $2, $3, $4, $5, $6)`,
525
+ [row.id, wrappedDek.ciphertext, wrappedDek.iv, wrappedDek.tag, ALGORITHM, kekVersion],
526
+ );
527
+
528
+ await record(action, options?.actor, { path });
529
+
530
+ return {
531
+ path,
532
+ value,
533
+ version,
534
+ metadata,
535
+ algorithm: ALGORITHM,
536
+ kekVersion,
537
+ createdAt: toDate(row.created_at),
538
+ updatedAt: toDate(row.updated_at),
539
+ ...(expiresAt ? { expiresAt } : {}),
540
+ };
541
+ }
542
+
543
+ /**
544
+ * Re-wrap one batch of DEKs onto the pending KEK generation.
545
+ *
546
+ * @param rotation - In-flight rotation state
547
+ */
548
+ async function rewrapBatch(rotation: PendingRotation): Promise<VaultRotateMasterResult> {
549
+ const target = rotation.kekVersion;
550
+ const nextKek = await rotation.unsealer.unwrapKek();
551
+ const rows = await db.query<KeyRow & { path: string; version: SqlNumber; algorithm: string }>(
552
+ `SELECT k.id, k.encrypted_dek, k.dek_iv, k.dek_auth_tag, k.kek_version,
553
+ s.path, s.version, k.algorithm
554
+ FROM oke_vault_keys k
555
+ JOIN oke_vault_secrets s ON s.id = k.secret_id
556
+ WHERE k.kek_version <> $1
557
+ ORDER BY k.id ASC
558
+ LIMIT $2`,
559
+ [target, batchSize],
560
+ );
561
+
562
+ let checkpoint: string | null = null;
563
+ for (const row of rows) {
564
+ const path = row.path;
565
+ const version = toInt(row.version);
566
+ const algorithm = row.algorithm as VaultAlgorithm;
567
+ const dekBytes = await unwrapWithAnyKek(
568
+ {
569
+ iv: toBytes(row.dek_iv),
570
+ ciphertext: toBytes(row.encrypted_dek),
571
+ tag: toBytes(row.dek_auth_tag),
572
+ },
573
+ buildAad(path, version, algorithm, toInt(row.kek_version)),
574
+ );
575
+ try {
576
+ const rewrapped = await wrapDek(
577
+ nextKek,
578
+ dekBytes,
579
+ buildAad(path, version, algorithm, target),
580
+ );
581
+ await db.execute(
582
+ `UPDATE oke_vault_keys
583
+ SET encrypted_dek = $1, dek_iv = $2, dek_auth_tag = $3, kek_version = $4, rotated_at = now()
584
+ WHERE id = $5`,
585
+ [rewrapped.ciphertext, rewrapped.iv, rewrapped.tag, target, row.id],
586
+ );
587
+ } finally {
588
+ zeroBytes(dekBytes);
589
+ }
590
+ checkpoint = row.id;
591
+ }
592
+
593
+ const left = await db.query<{ count: SqlNumber }>(
594
+ `SELECT COUNT(*) AS count FROM oke_vault_keys WHERE kek_version <> $1`,
595
+ [target],
596
+ );
597
+ const remaining = toInt(left[0]?.count ?? 0);
598
+
599
+ if (remaining === 0) {
600
+ // Commit the new generation: the pending key becomes the only master.
601
+ // Old master is sealed (zeroed) so it leaves process memory.
602
+ await db.execute(
603
+ `UPDATE oke_vault_master SET key_hash = $1, kek_version = $2, updated_at = now()`,
604
+ [rotation.verifyHash, target],
605
+ );
606
+ await db.execute(
607
+ `UPDATE oke_vault_status
608
+ SET rewrap_checkpoint = NULL, rewrap_target_kek_version = NULL,
609
+ rewrap_key_hash = NULL, updated_at = now()
610
+ WHERE id = 1`,
611
+ );
612
+ unsealer?.seal();
613
+ unsealer = rotation.unsealer;
614
+ pending = null;
615
+ } else {
616
+ await db.execute(
617
+ `UPDATE oke_vault_status
618
+ SET rewrap_checkpoint = $1, rewrap_target_kek_version = $2,
619
+ rewrap_key_hash = $3, updated_at = now()
620
+ WHERE id = 1`,
621
+ [checkpoint, target, rotation.verifyHash],
622
+ );
623
+ }
624
+
625
+ await record("rewrap", undefined, { success: true });
626
+ return { kekVersion: target, remaining };
627
+ }
628
+
629
+ const adapter: BuiltinVaultAdapter = {
630
+ id: "vault",
631
+
632
+ async initialize(): Promise<VaultInitResult> {
633
+ const status = await readStatus();
634
+ if (toBool(status.initialized)) {
635
+ throw new VaultError("ALREADY_INITIALIZED", "vault: already initialized");
636
+ }
637
+
638
+ const masterKey = generateMasterKey();
639
+ try {
640
+ const verifyHash = await deriveVerifyHash(masterKey);
641
+ await db.execute(`INSERT INTO oke_vault_master (key_hash, kek_version) VALUES ($1, 1)`, [
642
+ verifyHash,
643
+ ]);
644
+ await db.execute(
645
+ `UPDATE oke_vault_status
646
+ SET initialized = true, sealed = true, master_key_present = true, updated_at = now()
647
+ WHERE id = 1`,
648
+ );
649
+ await record("initialize", undefined);
650
+ return { masterKey: masterKeyToBase64(masterKey), verifyHash, kekVersion: 1 };
651
+ } finally {
652
+ zeroBytes(masterKey);
653
+ }
654
+ },
655
+
656
+ async unseal(masterKey: string | Uint8Array): Promise<void> {
657
+ const master = await readMaster();
658
+ const raw =
659
+ typeof masterKey === "string" ? masterKeyFromBase64(masterKey) : Uint8Array.from(masterKey);
660
+ try {
661
+ const verifyHash = await deriveVerifyHash(raw);
662
+ if (!constantTimeEqualStrings(verifyHash, master.key_hash)) {
663
+ await record("unseal", undefined, {
664
+ success: false,
665
+ errorCode: "INVALID_KEY",
666
+ errorMessage: "vault: master key does not match this vault",
667
+ });
668
+ throw new VaultError("INVALID_KEY", "vault: master key does not match this vault");
669
+ }
670
+ unsealer?.seal();
671
+ unsealer = createMemoryUnsealer(raw);
672
+ } finally {
673
+ zeroBytes(raw);
674
+ }
675
+ await db.execute(
676
+ `UPDATE oke_vault_status
677
+ SET sealed = false, last_unsealed_at = now(), updated_at = now()
678
+ WHERE id = 1`,
679
+ );
680
+ await record("unseal", undefined);
681
+ },
682
+
683
+ async seal(): Promise<void> {
684
+ unsealer?.seal();
685
+ unsealer = null;
686
+ pending?.unsealer.seal();
687
+ pending = null;
688
+ await db.execute(
689
+ `UPDATE oke_vault_status
690
+ SET sealed = true, last_sealed_at = now(), seal_count = seal_count + 1, updated_at = now()
691
+ WHERE id = 1`,
692
+ );
693
+ await record("seal", undefined);
694
+ },
695
+
696
+ async get(path: string, options?: VaultGetOptions): Promise<VaultSecret | undefined> {
697
+ const canonical = canonicalizePath(path);
698
+ await ensureReady();
699
+ requireUnsealer();
700
+
701
+ const row = await readSecretRow(canonical, options?.version);
702
+ if (!row) {
703
+ await record("get", options?.actor, {
704
+ path: canonical,
705
+ success: false,
706
+ errorCode: "SECRET_NOT_FOUND",
707
+ errorMessage: "vault: no such secret",
708
+ });
709
+ return undefined;
710
+ }
711
+
712
+ const expiresAt = row.expires_at === null ? undefined : toDate(row.expires_at);
713
+ if (expiresAt && expiresAt.getTime() <= Date.now() && options?.allowExpired !== true) {
714
+ await record("get", options?.actor, {
715
+ path: canonical,
716
+ success: false,
717
+ errorCode: "EXPIRED",
718
+ errorMessage: "vault: secret has expired",
719
+ });
720
+ throw new VaultError("EXPIRED", "vault: secret has expired");
721
+ }
722
+
723
+ const keyRow = await readKeyRow(row.id);
724
+ const value = await decryptRow(row, keyRow);
725
+ await record("get", options?.actor, { path: canonical });
726
+
727
+ return {
728
+ path: canonical,
729
+ value,
730
+ version: toInt(row.version),
731
+ metadata: toMetadata(row.metadata),
732
+ algorithm: row.algorithm as VaultAlgorithm,
733
+ kekVersion: toInt(keyRow.kek_version),
734
+ createdAt: toDate(row.created_at),
735
+ updatedAt: toDate(row.updated_at),
736
+ ...(expiresAt ? { expiresAt } : {}),
737
+ };
738
+ },
739
+
740
+ async set(path: string, value: string, options?: VaultSetOptions): Promise<VaultSecret> {
741
+ const canonical = canonicalizePath(path);
742
+ await ensureReady();
743
+ requireUnsealer();
744
+ return writeVersion(canonical, value, options, "set");
745
+ },
746
+
747
+ async rotate(path: string, value: string, options?: VaultSetOptions): Promise<VaultSecret> {
748
+ const canonical = canonicalizePath(path);
749
+ await ensureReady();
750
+ requireUnsealer();
751
+ return writeVersion(canonical, value, options, "rotate");
752
+ },
753
+
754
+ async delete(path: string, options?: { readonly actor?: VaultActor }): Promise<boolean> {
755
+ const canonical = canonicalizePath(path);
756
+ await ensureReady();
757
+ requireUnsealer();
758
+ const rows = await db.query<{ id: string }>(
759
+ `UPDATE oke_vault_secrets SET deleted_at = now(), updated_at = now()
760
+ WHERE path = $1 AND deleted_at IS NULL
761
+ RETURNING id`,
762
+ [canonical],
763
+ );
764
+ await record("delete", options?.actor, { path: canonical, success: rows.length > 0 });
765
+ return rows.length > 0;
766
+ },
767
+
768
+ async list(options?: VaultListOptions): Promise<readonly VaultListEntry[]> {
769
+ await ensureReady();
770
+ const prefix = canonicalizePrefix(options?.prefix);
771
+ const filters: string[] = [];
772
+ const params: unknown[] = [];
773
+ if (options?.includeDeleted !== true) filters.push("deleted_at IS NULL");
774
+ if (prefix !== undefined) {
775
+ params.push(prefix);
776
+ filters.push(`(path = $${params.length} OR path LIKE $${params.length} || '/%')`);
777
+ }
778
+ const where = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
779
+ let limit = "";
780
+ if (options?.limit !== undefined && options.limit > 0) {
781
+ params.push(Math.floor(options.limit));
782
+ limit = `LIMIT $${params.length}`;
783
+ }
784
+
785
+ const rows = await db.query<{
786
+ path: string;
787
+ version: SqlNumber;
788
+ updated_at: Date | string;
789
+ expires_at: Date | string | null;
790
+ }>(
791
+ `SELECT DISTINCT ON (path) path, version, updated_at, expires_at
792
+ FROM oke_vault_secrets ${where}
793
+ ORDER BY path ASC, version DESC ${limit}`,
794
+ params,
795
+ );
796
+
797
+ await record("list", options?.actor);
798
+ return rows.map((row) => ({
799
+ path: row.path,
800
+ version: toInt(row.version),
801
+ updatedAt: toDate(row.updated_at),
802
+ ...(row.expires_at === null ? {} : { expiresAt: toDate(row.expires_at) }),
803
+ }));
804
+ },
805
+
806
+ async status(): Promise<VaultStatus> {
807
+ const status = await readStatus();
808
+ const master = await db.query<MasterRow>(
809
+ `SELECT key_hash, kek_version FROM oke_vault_master ORDER BY created_at ASC LIMIT 1`,
810
+ );
811
+ const counted = await db.query<{ count: SqlNumber }>(
812
+ `SELECT COUNT(DISTINCT path) AS count FROM oke_vault_secrets WHERE deleted_at IS NULL`,
813
+ );
814
+ const target = status.rewrap_target_kek_version;
815
+ return {
816
+ sealed: unsealer === null || unsealer.sealed,
817
+ initialized: toBool(status.initialized),
818
+ masterKeyPresent: master.length > 0,
819
+ kekVersion: toInt(master[0]?.kek_version ?? 1),
820
+ secretCount: toInt(counted[0]?.count ?? 0),
821
+ sealCount: toInt(status.seal_count),
822
+ ...(status.last_sealed_at === null ? {} : { lastSealedAt: toDate(status.last_sealed_at) }),
823
+ ...(status.last_unsealed_at === null
824
+ ? {}
825
+ : { lastUnsealedAt: toDate(status.last_unsealed_at) }),
826
+ ...(status.rewrap_checkpoint === null
827
+ ? {}
828
+ : { rewrapCheckpoint: status.rewrap_checkpoint }),
829
+ ...(target === null ? {} : { rewrapTargetKekVersion: toInt(target) }),
830
+ };
831
+ },
832
+
833
+ async rotateMaster(newMasterKey?: Uint8Array): Promise<VaultRotateMasterResult> {
834
+ await ensureReady();
835
+ requireUnsealer();
836
+ if (pending) {
837
+ throw new VaultError("UNSUPPORTED", "vault: a master rotation is already in progress");
838
+ }
839
+ const master = await readMaster();
840
+ const target = toInt(master.kek_version) + 1;
841
+
842
+ const generated = newMasterKey === undefined;
843
+ const raw = newMasterKey ?? generateMasterKey();
844
+ let rotation: PendingRotation;
845
+ let encoded: string | undefined;
846
+ try {
847
+ rotation = {
848
+ unsealer: createMemoryUnsealer(raw),
849
+ kekVersion: target,
850
+ verifyHash: await deriveVerifyHash(raw),
851
+ };
852
+ if (generated) encoded = masterKeyToBase64(raw);
853
+ } finally {
854
+ if (generated) zeroBytes(raw);
855
+ }
856
+ pending = rotation;
857
+
858
+ await db.execute(
859
+ `UPDATE oke_vault_status
860
+ SET rewrap_target_kek_version = $1, rewrap_checkpoint = NULL,
861
+ rewrap_key_hash = $2, updated_at = now()
862
+ WHERE id = 1`,
863
+ [target, rotation.verifyHash],
864
+ );
865
+ const result = await rewrapBatch(rotation);
866
+ return encoded === undefined ? result : { ...result, masterKey: encoded };
867
+ },
868
+
869
+ async continueRotateMaster(
870
+ newMasterKey?: string | Uint8Array,
871
+ ): Promise<VaultRotateMasterResult> {
872
+ await ensureReady();
873
+ requireUnsealer();
874
+ let rotation = pending;
875
+ if (!rotation) {
876
+ // Cold resume after process death: rebuild pending from the new key
877
+ // the operator saved when rotateMaster printed it.
878
+ const status = await readStatus();
879
+ const target = status.rewrap_target_kek_version;
880
+ const expectedHash = status.rewrap_key_hash;
881
+ if (target === null || expectedHash === null || expectedHash.length === 0) {
882
+ throw new VaultError("UNSUPPORTED", "vault: no master rotation is in progress");
883
+ }
884
+ if (newMasterKey === undefined) {
885
+ throw new VaultError(
886
+ "INVALID_KEY",
887
+ "vault: resume rotate-master with the new master key (--new-key / OKE_VAULT_NEW_MASTER_KEY)",
888
+ );
889
+ }
890
+ const raw =
891
+ typeof newMasterKey === "string"
892
+ ? masterKeyFromBase64(newMasterKey)
893
+ : new Uint8Array(newMasterKey);
894
+ try {
895
+ const hash = await deriveVerifyHash(raw);
896
+ if (!constantTimeEqualStrings(hash, expectedHash)) {
897
+ throw new VaultError(
898
+ "INVALID_KEY",
899
+ "vault: new master key does not match in-flight rotation",
900
+ );
901
+ }
902
+ rotation = {
903
+ unsealer: createMemoryUnsealer(raw),
904
+ kekVersion: toInt(target),
905
+ verifyHash: hash,
906
+ };
907
+ pending = rotation;
908
+ } finally {
909
+ zeroBytes(raw);
910
+ }
911
+ }
912
+ return rewrapBatch(rotation);
913
+ },
914
+
915
+ async purgeAuditBefore(before: Date): Promise<number> {
916
+ await ensureReady();
917
+ const removed = await purgeAuditRows(db, before);
918
+ await record("purge", undefined);
919
+ return removed;
920
+ },
921
+
922
+ async purgeExpired(options?: {
923
+ readonly before?: Date;
924
+ readonly dryRun?: boolean;
925
+ }): Promise<PurgeExpiredResult> {
926
+ await ensureReady();
927
+ requireUnsealer();
928
+ const before = options?.before ?? new Date();
929
+ const dryRun = options?.dryRun === true;
930
+ const result = await purgeExpiredSecrets(db, before, dryRun);
931
+ if (!dryRun) {
932
+ await record("purge", undefined, {
933
+ success: true,
934
+ errorMessage: `expired count=${result.count}`,
935
+ });
936
+ }
937
+ return result;
938
+ },
939
+
940
+ async verifyAudit(): Promise<AuditChainResult> {
941
+ await ensureReady();
942
+ return verifyAuditChain(db);
943
+ },
944
+
945
+ async listAudit(options?: AuditPageOptions): Promise<readonly VaultAuditRecord[]> {
946
+ await ensureReady();
947
+ return readAuditPage(db, options ?? {});
948
+ },
949
+
950
+ async exportBackup(): Promise<Uint8Array> {
951
+ await ensureReady();
952
+ const active = requireUnsealer();
953
+ const master = await readMaster();
954
+ const kekVersion = toInt(master.kek_version);
955
+
956
+ const rows = await db.query<SecretRow>(
957
+ `SELECT DISTINCT ON (path) id, path, encrypted_value, iv, auth_tag, version, metadata,
958
+ algorithm, kek_version, created_at, updated_at, expires_at
959
+ FROM oke_vault_secrets WHERE deleted_at IS NULL
960
+ ORDER BY path ASC, version DESC`,
961
+ );
962
+ const entries: BackupEntry[] = [];
963
+ for (const row of rows) {
964
+ entries.push({
965
+ path: row.path,
966
+ value: await decryptRow(row, await readKeyRow(row.id)),
967
+ version: toInt(row.version),
968
+ metadata: toMetadata(row.metadata),
969
+ expiresAt: row.expires_at === null ? null : toDate(row.expires_at).toISOString(),
970
+ });
971
+ }
972
+
973
+ const payload = new TextEncoder().encode(JSON.stringify(entries));
974
+ let sealed: SealedBytes;
975
+ try {
976
+ sealed = await encryptBytes(
977
+ await active.unwrapBackupKek(),
978
+ payload,
979
+ buildAad(BACKUP_AAD_PATH, 1, ALGORITHM, kekVersion),
980
+ );
981
+ } finally {
982
+ zeroBytes(payload);
983
+ }
984
+
985
+ const metadata: BackupMetadata = {
986
+ format: 1,
987
+ createdAt: new Date().toISOString(),
988
+ kekVersion,
989
+ count: entries.length,
990
+ iv: toBase64(sealed.iv),
991
+ tag: toBase64(sealed.tag),
992
+ };
993
+ const header = new TextEncoder().encode(`${BACKUP_MAGIC}${JSON.stringify(metadata)}\n`);
994
+ const blob = new Uint8Array(header.byteLength + sealed.ciphertext.byteLength);
995
+ blob.set(header, 0);
996
+ blob.set(sealed.ciphertext, header.byteLength);
997
+ return blob;
998
+ },
999
+
1000
+ async importBackup(blob: Uint8Array): Promise<void> {
1001
+ await ensureReady();
1002
+ const active = requireUnsealer();
1003
+ const text = new TextDecoder().decode(blob.subarray(0, Math.min(blob.byteLength, 8192)));
1004
+ if (!text.startsWith(BACKUP_MAGIC)) {
1005
+ throw new VaultError("UNSUPPORTED", "vault: not an oke vault backup bundle");
1006
+ }
1007
+ const headerEnd = text.indexOf("\n", BACKUP_MAGIC.length);
1008
+ if (headerEnd < 0) {
1009
+ throw new VaultError("UNSUPPORTED", "vault: backup bundle header is truncated");
1010
+ }
1011
+
1012
+ let metadata: BackupMetadata;
1013
+ try {
1014
+ metadata = JSON.parse(text.slice(BACKUP_MAGIC.length, headerEnd)) as BackupMetadata;
1015
+ } catch {
1016
+ throw new VaultError("UNSUPPORTED", "vault: backup bundle header is not valid JSON");
1017
+ }
1018
+
1019
+ const offset = new TextEncoder().encode(text.slice(0, headerEnd + 1)).byteLength;
1020
+ const plain = await decryptBytes(
1021
+ await active.unwrapBackupKek(),
1022
+ {
1023
+ iv: fromBase64(metadata.iv),
1024
+ ciphertext: blob.subarray(offset),
1025
+ tag: fromBase64(metadata.tag),
1026
+ },
1027
+ buildAad(BACKUP_AAD_PATH, 1, ALGORITHM, metadata.kekVersion),
1028
+ );
1029
+
1030
+ let entries: readonly BackupEntry[];
1031
+ try {
1032
+ entries = JSON.parse(new TextDecoder().decode(plain)) as readonly BackupEntry[];
1033
+ } catch {
1034
+ throw new VaultError("UNSUPPORTED", "vault: backup payload is not valid JSON");
1035
+ } finally {
1036
+ zeroBytes(plain);
1037
+ }
1038
+
1039
+ for (const entry of entries) {
1040
+ await writeVersion(
1041
+ canonicalizePath(entry.path),
1042
+ entry.value,
1043
+ {
1044
+ metadata: entry.metadata,
1045
+ ...(entry.expiresAt === null ? {} : { expiresAt: new Date(entry.expiresAt) }),
1046
+ },
1047
+ "set",
1048
+ );
1049
+ }
1050
+ },
1051
+
1052
+ getUnsealer(): Unsealer | null {
1053
+ return unsealer;
1054
+ },
1055
+ };
1056
+
1057
+ return adapter;
1058
+ }
1059
+
1060
+ /**
1061
+ * Adapt a driver {@link import("../../drivers/types.ts").SqlConnection}-shaped
1062
+ * object to the {@link SqlExec} surface the Vault needs.
1063
+ *
1064
+ * Accepts either `exec` (the driver contract) or `execute`, so a thin custom
1065
+ * wrapper works without a shim.
1066
+ *
1067
+ * @param conn - Connection exposing `query` plus `exec` or `execute`
1068
+ */
1069
+ export function sqlConnectionAsExec(conn: {
1070
+ query(sql: string, params?: readonly unknown[]): Promise<unknown[]>;
1071
+ exec?(sql: string, params?: readonly unknown[]): Promise<unknown>;
1072
+ execute?(sql: string, params?: readonly unknown[]): Promise<void>;
1073
+ }): SqlExec {
1074
+ return {
1075
+ async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
1076
+ return (await conn.query(sql, params ?? [])) as T[];
1077
+ },
1078
+ async execute(sql: string, params?: unknown[]): Promise<void> {
1079
+ if (conn.exec) {
1080
+ await conn.exec(sql, params ?? []);
1081
+ return;
1082
+ }
1083
+ if (conn.execute) {
1084
+ await conn.execute(sql, params ?? []);
1085
+ return;
1086
+ }
1087
+ await conn.query(sql, params ?? []);
1088
+ },
1089
+ };
1090
+ }
1091
+
1092
+ /**
1093
+ * Resolve the absolute expiry for a write.
1094
+ *
1095
+ * @param options - Write options carrying `expiresAt` or `ttlMs`
1096
+ */
1097
+ function resolveExpiry(options: VaultSetOptions | undefined): Date | undefined {
1098
+ if (options?.expiresAt) return options.expiresAt;
1099
+ if (options?.ttlMs !== undefined && options.ttlMs > 0) {
1100
+ return new Date(Date.now() + options.ttlMs);
1101
+ }
1102
+ return undefined;
1103
+ }
1104
+
1105
+ /**
1106
+ * Normalize a `bytea` column to bytes.
1107
+ *
1108
+ * Drivers return `Uint8Array`, a Node `Buffer`, or Postgres hex text
1109
+ * (`\x0a1b…`) depending on the transport.
1110
+ *
1111
+ * @param value - Raw column value
1112
+ */
1113
+ function toBytes(value: unknown): Uint8Array {
1114
+ if (value instanceof Uint8Array) return value;
1115
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
1116
+ if (typeof value === "string") {
1117
+ return new Uint8Array(
1118
+ value.startsWith("\\x") ? Buffer.from(value.slice(2), "hex") : Buffer.from(value, "base64"),
1119
+ );
1120
+ }
1121
+ throw new VaultError("BACKEND_ERROR", "vault: unexpected binary column encoding");
1122
+ }
1123
+
1124
+ /**
1125
+ * Normalize a numeric column (drivers may return `bigint` counts as text).
1126
+ *
1127
+ * @param value - Raw column value
1128
+ */
1129
+ function toInt(value: SqlNumber): number {
1130
+ return typeof value === "number" ? value : Number(value);
1131
+ }
1132
+
1133
+ /**
1134
+ * Normalize a boolean column.
1135
+ *
1136
+ * @param value - Raw column value
1137
+ */
1138
+ function toBool(value: boolean | number): boolean {
1139
+ return value === true || value === 1;
1140
+ }
1141
+
1142
+ /**
1143
+ * Normalize a timestamp column to a `Date`.
1144
+ *
1145
+ * @param value - Raw column value
1146
+ */
1147
+ function toDate(value: Date | string): Date {
1148
+ return value instanceof Date ? value : new Date(value);
1149
+ }
1150
+
1151
+ /**
1152
+ * Normalize a `jsonb` column to a plain object.
1153
+ *
1154
+ * @param value - Raw column value
1155
+ */
1156
+ function toMetadata(value: unknown): Record<string, unknown> {
1157
+ if (typeof value === "string") {
1158
+ try {
1159
+ return JSON.parse(value) as Record<string, unknown>;
1160
+ } catch {
1161
+ return {};
1162
+ }
1163
+ }
1164
+ if (value && typeof value === "object") return value as Record<string, unknown>;
1165
+ return {};
1166
+ }
1167
+
1168
+ /**
1169
+ * Base64-encode bytes for a backup header.
1170
+ *
1171
+ * @param bytes - Buffer to encode
1172
+ */
1173
+ function toBase64(bytes: Uint8Array): string {
1174
+ return Buffer.from(bytes).toString("base64");
1175
+ }
1176
+
1177
+ /**
1178
+ * Decode base64 from a backup header.
1179
+ *
1180
+ * @param text - Base64 text
1181
+ */
1182
+ function fromBase64(text: string): Uint8Array {
1183
+ return new Uint8Array(Buffer.from(text, "base64"));
1184
+ }