@cosmicdrift/kumiko-bundled-features 0.1.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 (333) hide show
  1. package/package.json +90 -0
  2. package/src/audit/__tests__/audit.integration.ts +328 -0
  3. package/src/audit/constants.ts +7 -0
  4. package/src/audit/feature.ts +23 -0
  5. package/src/audit/handlers/list.query.ts +98 -0
  6. package/src/audit/index.ts +2 -0
  7. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
  8. package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
  9. package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
  10. package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
  11. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
  12. package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
  13. package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
  14. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
  15. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
  16. package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
  18. package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
  19. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
  20. package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
  21. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
  22. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
  23. package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
  24. package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
  25. package/src/auth-email-password/auth-user-row.ts +41 -0
  26. package/src/auth-email-password/constants.ts +101 -0
  27. package/src/auth-email-password/email-templates.ts +283 -0
  28. package/src/auth-email-password/feature.ts +140 -0
  29. package/src/auth-email-password/handlers/change-password.write.ts +58 -0
  30. package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
  31. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
  32. package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
  33. package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
  34. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
  35. package/src/auth-email-password/handlers/login.write.ts +208 -0
  36. package/src/auth-email-password/handlers/logout.write.ts +12 -0
  37. package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
  38. package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
  39. package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
  40. package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
  41. package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
  42. package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
  43. package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
  44. package/src/auth-email-password/i18n.ts +211 -0
  45. package/src/auth-email-password/identity-v3-hash.ts +97 -0
  46. package/src/auth-email-password/index.ts +35 -0
  47. package/src/auth-email-password/invite-token-store.ts +92 -0
  48. package/src/auth-email-password/lockout-store.ts +118 -0
  49. package/src/auth-email-password/password-hashing.ts +43 -0
  50. package/src/auth-email-password/reset-token.ts +28 -0
  51. package/src/auth-email-password/seeding.ts +183 -0
  52. package/src/auth-email-password/signed-token.ts +85 -0
  53. package/src/auth-email-password/signup-token-store.ts +104 -0
  54. package/src/auth-email-password/stream-tenant.ts +31 -0
  55. package/src/auth-email-password/testing.ts +5 -0
  56. package/src/auth-email-password/token-burn-store.ts +57 -0
  57. package/src/auth-email-password/verification-token.ts +27 -0
  58. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
  59. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
  60. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
  61. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
  62. package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
  63. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
  64. package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
  65. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
  66. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
  67. package/src/auth-email-password/web/auth-client.ts +350 -0
  68. package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
  69. package/src/auth-email-password/web/auth-gate.tsx +33 -0
  70. package/src/auth-email-password/web/client-plugin.ts +48 -0
  71. package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
  72. package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
  73. package/src/auth-email-password/web/index.ts +56 -0
  74. package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
  75. package/src/auth-email-password/web/login-screen.tsx +150 -0
  76. package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
  77. package/src/auth-email-password/web/session.tsx +171 -0
  78. package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
  79. package/src/auth-email-password/web/signup-screen.tsx +130 -0
  80. package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
  81. package/src/auth-email-password/web/use-shell-user.ts +34 -0
  82. package/src/auth-email-password/web/user-menu.tsx +89 -0
  83. package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
  84. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
  85. package/src/billing-foundation/__tests__/feature.test.ts +110 -0
  86. package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
  87. package/src/billing-foundation/aggregate-id.ts +21 -0
  88. package/src/billing-foundation/constants.ts +70 -0
  89. package/src/billing-foundation/entities.ts +50 -0
  90. package/src/billing-foundation/events.ts +71 -0
  91. package/src/billing-foundation/feature.ts +122 -0
  92. package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
  93. package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
  94. package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
  95. package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
  96. package/src/billing-foundation/handlers/process-event.write.ts +160 -0
  97. package/src/billing-foundation/index.ts +42 -0
  98. package/src/billing-foundation/projection.ts +135 -0
  99. package/src/billing-foundation/types.ts +157 -0
  100. package/src/billing-foundation/webhook-handler.ts +184 -0
  101. package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
  102. package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
  103. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
  104. package/src/cap-counter/aggregate-id.ts +61 -0
  105. package/src/cap-counter/constants.ts +32 -0
  106. package/src/cap-counter/enforce-cap.ts +404 -0
  107. package/src/cap-counter/entity.ts +48 -0
  108. package/src/cap-counter/feature.ts +90 -0
  109. package/src/cap-counter/handlers/get-counter.query.ts +43 -0
  110. package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
  111. package/src/cap-counter/handlers/increment.write.ts +92 -0
  112. package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
  113. package/src/cap-counter/index.ts +34 -0
  114. package/src/cap-counter/with-cap-enforcement.ts +179 -0
  115. package/src/channel-email/email-channel.ts +48 -0
  116. package/src/channel-email/feature.ts +15 -0
  117. package/src/channel-email/index.ts +4 -0
  118. package/src/channel-email/smtp-transport.ts +65 -0
  119. package/src/channel-email/types.ts +34 -0
  120. package/src/channel-in-app/constants.ts +11 -0
  121. package/src/channel-in-app/feature.ts +30 -0
  122. package/src/channel-in-app/handlers/inbox.query.ts +28 -0
  123. package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
  124. package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
  125. package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
  126. package/src/channel-in-app/in-app-channel.ts +44 -0
  127. package/src/channel-in-app/index.ts +4 -0
  128. package/src/channel-in-app/tables.ts +22 -0
  129. package/src/channel-push/feature.ts +15 -0
  130. package/src/channel-push/index.ts +3 -0
  131. package/src/channel-push/push-channel.ts +33 -0
  132. package/src/channel-push/types.ts +22 -0
  133. package/src/config/__tests__/app-overrides.test.ts +118 -0
  134. package/src/config/__tests__/config.integration.ts +1246 -0
  135. package/src/config/constants.ts +23 -0
  136. package/src/config/feature.ts +117 -0
  137. package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
  138. package/src/config/handlers/reset.write.ts +45 -0
  139. package/src/config/handlers/schema.query.ts +22 -0
  140. package/src/config/handlers/set.write.ts +93 -0
  141. package/src/config/handlers/values.query.ts +43 -0
  142. package/src/config/index.ts +15 -0
  143. package/src/config/resolver.ts +283 -0
  144. package/src/config/table.ts +35 -0
  145. package/src/config/write-helpers.ts +268 -0
  146. package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
  147. package/src/delivery/__tests__/delivery.integration.ts +1405 -0
  148. package/src/delivery/constants.ts +33 -0
  149. package/src/delivery/delivery-service.ts +489 -0
  150. package/src/delivery/events.ts +18 -0
  151. package/src/delivery/feature.ts +70 -0
  152. package/src/delivery/handlers/log.query.ts +21 -0
  153. package/src/delivery/handlers/preferences.query.ts +18 -0
  154. package/src/delivery/handlers/set-preference.write.ts +28 -0
  155. package/src/delivery/index.ts +35 -0
  156. package/src/delivery/tables.ts +74 -0
  157. package/src/delivery/testing.ts +47 -0
  158. package/src/delivery/types.ts +71 -0
  159. package/src/delivery/unsubscribe.ts +99 -0
  160. package/src/delivery/upsert-preference.ts +145 -0
  161. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
  162. package/src/feature-toggles/constants.ts +20 -0
  163. package/src/feature-toggles/events.ts +18 -0
  164. package/src/feature-toggles/feature.ts +98 -0
  165. package/src/feature-toggles/global-feature-state-table.ts +28 -0
  166. package/src/feature-toggles/handlers/list.query.ts +26 -0
  167. package/src/feature-toggles/handlers/registered.query.ts +56 -0
  168. package/src/feature-toggles/handlers/set.write.ts +158 -0
  169. package/src/feature-toggles/index.ts +9 -0
  170. package/src/feature-toggles/toggle-runtime.ts +73 -0
  171. package/src/file-foundation/__tests__/feature.test.ts +35 -0
  172. package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
  173. package/src/file-foundation/feature.ts +123 -0
  174. package/src/file-foundation/index.ts +7 -0
  175. package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
  176. package/src/file-provider-inmemory/feature.ts +73 -0
  177. package/src/file-provider-inmemory/index.ts +3 -0
  178. package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
  179. package/src/file-provider-s3/feature.ts +169 -0
  180. package/src/file-provider-s3/index.ts +3 -0
  181. package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
  182. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
  183. package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
  184. package/src/files-provider-s3/env-helper.ts +49 -0
  185. package/src/files-provider-s3/index.ts +3 -0
  186. package/src/files-provider-s3/s3-provider.ts +114 -0
  187. package/src/foundation-shared/config-helpers.ts +67 -0
  188. package/src/foundation-shared/index.ts +4 -0
  189. package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
  190. package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
  191. package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
  192. package/src/jobs/constants.ts +21 -0
  193. package/src/jobs/events.ts +39 -0
  194. package/src/jobs/feature.ts +150 -0
  195. package/src/jobs/handlers/detail.query.ts +30 -0
  196. package/src/jobs/handlers/list.query.ts +36 -0
  197. package/src/jobs/handlers/retry.write.ts +69 -0
  198. package/src/jobs/handlers/trigger.write.ts +39 -0
  199. package/src/jobs/index.ts +5 -0
  200. package/src/jobs/job-run-logger.ts +213 -0
  201. package/src/jobs/job-run-table.ts +55 -0
  202. package/src/legal-pages/README.md +195 -0
  203. package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
  204. package/src/legal-pages/constants.ts +36 -0
  205. package/src/legal-pages/feature.ts +187 -0
  206. package/src/legal-pages/index.ts +13 -0
  207. package/src/legal-pages/markdown.ts +69 -0
  208. package/src/mail-foundation/__tests__/feature.test.ts +46 -0
  209. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
  210. package/src/mail-foundation/feature.ts +160 -0
  211. package/src/mail-foundation/index.ts +14 -0
  212. package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
  213. package/src/mail-transport-inmemory/feature.ts +90 -0
  214. package/src/mail-transport-inmemory/index.ts +3 -0
  215. package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
  216. package/src/mail-transport-smtp/feature.ts +182 -0
  217. package/src/mail-transport-smtp/index.ts +3 -0
  218. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
  219. package/src/rate-limiting/constants.ts +9 -0
  220. package/src/rate-limiting/feature.ts +16 -0
  221. package/src/rate-limiting/handlers/status.query.ts +52 -0
  222. package/src/rate-limiting/index.ts +2 -0
  223. package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
  224. package/src/renderer-simple/feature.ts +12 -0
  225. package/src/renderer-simple/index.ts +2 -0
  226. package/src/renderer-simple/simple-renderer.ts +72 -0
  227. package/src/secrets/__tests__/rotate.integration.ts +176 -0
  228. package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
  229. package/src/secrets/__tests__/secrets.integration.ts +118 -0
  230. package/src/secrets/feature.ts +84 -0
  231. package/src/secrets/handlers/delete.write.ts +20 -0
  232. package/src/secrets/handlers/list.query.ts +38 -0
  233. package/src/secrets/handlers/rotate.job.ts +193 -0
  234. package/src/secrets/handlers/set.write.ts +50 -0
  235. package/src/secrets/index.ts +16 -0
  236. package/src/secrets/secrets-context.ts +296 -0
  237. package/src/secrets/table.ts +68 -0
  238. package/src/sessions/__tests__/cleanup.integration.ts +175 -0
  239. package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
  240. package/src/sessions/__tests__/sessions.integration.ts +472 -0
  241. package/src/sessions/__tests__/test-helpers.ts +66 -0
  242. package/src/sessions/constants.ts +43 -0
  243. package/src/sessions/feature.ts +84 -0
  244. package/src/sessions/handlers/cleanup.job.ts +109 -0
  245. package/src/sessions/handlers/list.query.ts +35 -0
  246. package/src/sessions/handlers/mine.query.ts +37 -0
  247. package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
  248. package/src/sessions/handlers/revoke.write.ts +76 -0
  249. package/src/sessions/index.ts +17 -0
  250. package/src/sessions/schema/index.ts +5 -0
  251. package/src/sessions/schema/user-session.ts +67 -0
  252. package/src/sessions/session-callbacks.ts +110 -0
  253. package/src/sessions/testing.ts +42 -0
  254. package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
  255. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
  256. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
  257. package/src/subscription-mollie/constants.ts +33 -0
  258. package/src/subscription-mollie/feature.ts +144 -0
  259. package/src/subscription-mollie/index.ts +13 -0
  260. package/src/subscription-mollie/plugin-methods.ts +79 -0
  261. package/src/subscription-mollie/verify-webhook.ts +244 -0
  262. package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
  263. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
  264. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
  265. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
  266. package/src/subscription-stripe/constants.ts +20 -0
  267. package/src/subscription-stripe/feature.ts +120 -0
  268. package/src/subscription-stripe/index.ts +14 -0
  269. package/src/subscription-stripe/plugin-methods.ts +91 -0
  270. package/src/subscription-stripe/verify-webhook.ts +235 -0
  271. package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
  272. package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
  273. package/src/tenant/__tests__/tenant.integration.ts +347 -0
  274. package/src/tenant/command-schemas.ts +37 -0
  275. package/src/tenant/constants.ts +37 -0
  276. package/src/tenant/feature.ts +109 -0
  277. package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
  278. package/src/tenant/handlers/add-member.write.ts +53 -0
  279. package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
  280. package/src/tenant/handlers/create.write.ts +21 -0
  281. package/src/tenant/handlers/disable.write.ts +18 -0
  282. package/src/tenant/handlers/invitations.query.ts +31 -0
  283. package/src/tenant/handlers/list.query.ts +17 -0
  284. package/src/tenant/handlers/me.query.ts +17 -0
  285. package/src/tenant/handlers/members.query.ts +22 -0
  286. package/src/tenant/handlers/memberships.query.ts +24 -0
  287. package/src/tenant/handlers/remove-member.write.ts +40 -0
  288. package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
  289. package/src/tenant/handlers/update-member-roles.write.ts +54 -0
  290. package/src/tenant/handlers/update.write.ts +20 -0
  291. package/src/tenant/index.ts +12 -0
  292. package/src/tenant/invitation-table.ts +93 -0
  293. package/src/tenant/membership-table.ts +35 -0
  294. package/src/tenant/schema/index.ts +5 -0
  295. package/src/tenant/schema/tenant.ts +27 -0
  296. package/src/tenant/seeding.ts +155 -0
  297. package/src/tenant/testing.ts +8 -0
  298. package/src/text-content/README.md +190 -0
  299. package/src/text-content/__tests__/text-content.integration.ts +415 -0
  300. package/src/text-content/api.ts +92 -0
  301. package/src/text-content/constants.ts +19 -0
  302. package/src/text-content/feature.ts +29 -0
  303. package/src/text-content/handlers/by-slug.query.ts +55 -0
  304. package/src/text-content/handlers/set.write.ts +118 -0
  305. package/src/text-content/index.ts +14 -0
  306. package/src/text-content/seeding.ts +91 -0
  307. package/src/text-content/table.ts +45 -0
  308. package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
  309. package/src/tier-engine/__tests__/drift.test.ts +42 -0
  310. package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
  311. package/src/tier-engine/aggregate-id.ts +27 -0
  312. package/src/tier-engine/compose-app.ts +150 -0
  313. package/src/tier-engine/constants.ts +15 -0
  314. package/src/tier-engine/entity.ts +30 -0
  315. package/src/tier-engine/feature.ts +72 -0
  316. package/src/tier-engine/handlers/active-tier.query.ts +23 -0
  317. package/src/tier-engine/index.ts +22 -0
  318. package/src/user/__tests__/seed-testing.integration.ts +127 -0
  319. package/src/user/__tests__/user.integration.ts +198 -0
  320. package/src/user/command-schemas.ts +15 -0
  321. package/src/user/constants.ts +23 -0
  322. package/src/user/feature.ts +32 -0
  323. package/src/user/handlers/create.write.ts +54 -0
  324. package/src/user/handlers/detail.query.ts +9 -0
  325. package/src/user/handlers/find-for-auth.query.ts +38 -0
  326. package/src/user/handlers/list.query.ts +8 -0
  327. package/src/user/handlers/me.query.ts +15 -0
  328. package/src/user/handlers/update.write.ts +54 -0
  329. package/src/user/index.ts +4 -0
  330. package/src/user/schema/index.ts +5 -0
  331. package/src/user/schema/user.ts +69 -0
  332. package/src/user/seeding.ts +93 -0
  333. package/src/user/testing.ts +5 -0
@@ -0,0 +1,193 @@
1
+ // Rotation job. Scans tenant_secrets for rows whose kekVersion is older
2
+ // than provider.currentVersion() and rewraps their DEK under the new KEK
3
+ // — the ciphertext itself never changes, only the 60-byte DEK wrapper
4
+ // and the kek_version column. See architecture/core-secrets.md for the
5
+ // full rotation story.
6
+ //
7
+ // The job is idempotent: re-running it after a partial failure picks up
8
+ // the remaining old-version rows. Consumers that want a time-bound run
9
+ // pass a maxDurationMs in the payload.
10
+ //
11
+ // Post-ES pivot: each rotation is an executor.update against the
12
+ // tenantSecret aggregate. The resulting `.updated` event carries
13
+ // {changes, previous} with BOTH envelopes — useful for a full rotation
14
+ // audit trail (when did row X flip from v1 to v2, who triggered it).
15
+ // Concurrency-guard shifts from the pre-ES `WHERE kek_version = old`
16
+ // check to the executor's stream-version check; a parallel secrets.set
17
+ // that landed the row on the new kekVersion first surfaces here as a
18
+ // version_conflict error (counted as "skipped", not "failed").
19
+
20
+ import {
21
+ createEventStoreExecutor,
22
+ createTenantDb,
23
+ type DbConnection,
24
+ type TenantDb,
25
+ } from "@cosmicdrift/kumiko-framework/db";
26
+ import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
27
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
28
+ import { rewrapDek } from "@cosmicdrift/kumiko-framework/secrets";
29
+ import { ne } from "drizzle-orm";
30
+ import { type StoredEnvelope, tenantSecretEntity, tenantSecretsTable } from "../table";
31
+
32
+ const DEFAULT_BATCH_SIZE = 100;
33
+ const DEFAULT_MAX_FAILURES = 10;
34
+ const SYSTEM_ROLES = ["system"] as const;
35
+
36
+ const executor = createEventStoreExecutor(tenantSecretsTable, tenantSecretEntity, {
37
+ entityName: "tenant-secret",
38
+ });
39
+
40
+ export type RotateJobPayload = {
41
+ readonly batchSize?: number;
42
+ readonly maxDurationMs?: number;
43
+ readonly maxFailures?: number;
44
+ };
45
+
46
+ export type RotateJobResult = {
47
+ readonly migrated: number;
48
+ readonly failed: number;
49
+ readonly batchesProcessed: number;
50
+ readonly stoppedReason: "empty" | "timeout" | "signal" | "too_many_failures";
51
+ };
52
+
53
+ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
54
+ const payload = rawPayload as RotateJobPayload;
55
+ if (!ctx.masterKeyProvider) {
56
+ throw new InternalError({
57
+ message:
58
+ "[secrets:rotate] ctx.masterKeyProvider missing — wire it via extraContext.masterKeyProvider at boot.",
59
+ });
60
+ }
61
+ const provider = ctx.masterKeyProvider;
62
+ if (!ctx.db) {
63
+ throw new InternalError({
64
+ message: "[secrets:rotate] ctx.db missing — job context requires a database connection.",
65
+ });
66
+ }
67
+ const db = ctx.db as DbConnection;
68
+ const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
69
+ const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
70
+ const deadline = payload.maxDurationMs
71
+ ? Date.now() + payload.maxDurationMs
72
+ : Number.POSITIVE_INFINITY;
73
+
74
+ let migrated = 0;
75
+ let failed = 0;
76
+ let batchesProcessed = 0;
77
+ let stoppedReason: RotateJobResult["stoppedReason"] = "empty";
78
+
79
+ // Reuse a TenantDb-per-tenant map so we don't rebuild the wrapper for
80
+ // each row in the same tenant. Rotation typically hits one tenant in a
81
+ // batch; the map trims an allocation without adding complexity.
82
+ const tdbCache = new Map<TenantId, TenantDb>();
83
+ function tdbFor(tenantId: TenantId): TenantDb {
84
+ let existing = tdbCache.get(tenantId);
85
+ if (!existing) {
86
+ existing = createTenantDb(db, tenantId, "system");
87
+ tdbCache.set(tenantId, existing);
88
+ }
89
+ return existing;
90
+ }
91
+
92
+ while (true) {
93
+ if (ctx.signal?.aborted) {
94
+ stoppedReason = "signal";
95
+ break;
96
+ }
97
+ if (Date.now() >= deadline) {
98
+ stoppedReason = "timeout";
99
+ break;
100
+ }
101
+
102
+ const targetVersion = provider.currentVersion();
103
+ const batch = await db
104
+ .select({
105
+ id: tenantSecretsTable.id,
106
+ tenantId: tenantSecretsTable.tenantId,
107
+ version: tenantSecretsTable.version,
108
+ envelope: tenantSecretsTable.envelope,
109
+ kekVersion: tenantSecretsTable.kekVersion,
110
+ })
111
+ .from(tenantSecretsTable)
112
+ .where(ne(tenantSecretsTable.kekVersion, targetVersion))
113
+ .limit(batchSize);
114
+
115
+ if (batch.length === 0) break;
116
+
117
+ batchesProcessed++;
118
+
119
+ if (failed >= maxFailures) {
120
+ stoppedReason = "too_many_failures";
121
+ break;
122
+ }
123
+
124
+ for (const row of batch) {
125
+ if (failed >= maxFailures) {
126
+ stoppedReason = "too_many_failures";
127
+ break;
128
+ }
129
+ try {
130
+ const oldEnvelope = {
131
+ ciphertext: Buffer.from(row.envelope.ciphertext, "base64"),
132
+ iv: Buffer.from(row.envelope.iv, "base64"),
133
+ authTag: Buffer.from(row.envelope.authTag, "base64"),
134
+ encryptedDek: Buffer.from(row.envelope.encryptedDek, "base64"),
135
+ kekVersion: row.envelope.kekVersion,
136
+ };
137
+ const rotated = await rewrapDek(oldEnvelope, provider);
138
+
139
+ if (rotated.kekVersion === row.kekVersion) continue;
140
+
141
+ const newEnvelope: StoredEnvelope = {
142
+ ciphertext: rotated.ciphertext.toString("base64"),
143
+ iv: rotated.iv.toString("base64"),
144
+ authTag: rotated.authTag.toString("base64"),
145
+ encryptedDek: rotated.encryptedDek.toString("base64"),
146
+ kekVersion: rotated.kekVersion,
147
+ };
148
+
149
+ const actor: SessionUser = {
150
+ id: "system",
151
+ tenantId: row.tenantId as TenantId,
152
+ roles: SYSTEM_ROLES,
153
+ };
154
+
155
+ const result = await executor.update(
156
+ {
157
+ id: row.id,
158
+ version: row.version,
159
+ changes: {
160
+ envelope: newEnvelope,
161
+ kekVersion: rotated.kekVersion,
162
+ },
163
+ },
164
+ actor,
165
+ tdbFor(row.tenantId as TenantId),
166
+ );
167
+
168
+ // version_conflict == another writer (secrets.set or a parallel
169
+ // rotation worker) beat us. Count as "skipped" and move on — the
170
+ // row is already in a valid state, potentially even past target.
171
+ if (!result.isSuccess) {
172
+ if (result.error.code === "version_conflict") continue;
173
+ failed++;
174
+ ctx.log?.warn?.(`[secrets:rotate] executor rejected row ${row.id}`, {
175
+ code: result.error.code,
176
+ });
177
+ continue;
178
+ }
179
+ } catch (err) {
180
+ failed++;
181
+ ctx.log?.warn?.(`[secrets:rotate] failed to rotate row ${row.id}`, { err });
182
+ continue;
183
+ }
184
+ migrated++;
185
+ }
186
+
187
+ if (stoppedReason === "too_many_failures") break;
188
+ if (batch.length < batchSize) break;
189
+ }
190
+
191
+ const result: RotateJobResult = { migrated, failed, batchesProcessed, stoppedReason };
192
+ ctx.log?.info?.(`[secrets:rotate] complete: ${JSON.stringify(result)}`);
193
+ };
@@ -0,0 +1,50 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { z } from "zod";
3
+ import { requireSecretsContext } from "../feature";
4
+
5
+ export const setWrite = defineWriteHandler({
6
+ name: "set",
7
+ schema: z.object({
8
+ key: z.string().min(1).max(100),
9
+ value: z.string(),
10
+ // Optional fixed-length preview — if the caller's UI wants a domain-
11
+ // specific redaction ("sk_live_abc…xyz") it can send it here; else the
12
+ // handler derives a generic one (first-3-chars + bullets).
13
+ redactedPreview: z.string().max(50).optional(),
14
+ hint: z.string().max(200).optional(),
15
+ }),
16
+ access: { roles: ["TenantAdmin"] },
17
+ handler: async (event, ctx) => {
18
+ const secrets = requireSecretsContext(ctx, "secrets:write:set");
19
+ const { key, value, redactedPreview, hint } = event.payload;
20
+
21
+ // Preview-priority: explicit payload param > feature-declared redact
22
+ // (via r.secret()) > generic default. A feature that declared a
23
+ // domain-aware redact (Stripe keys: "sk_test...2345") wins over the
24
+ // framework default unless the caller sent a specific preview.
25
+ const keyDef = ctx.registry.getSecretKey(key);
26
+ const featureRedact = keyDef?.redact;
27
+ const redactFn: (v: string) => string = redactedPreview
28
+ ? () => redactedPreview
29
+ : (featureRedact ?? defaultRedact);
30
+
31
+ await secrets.set(event.user.tenantId, key, value, {
32
+ redact: redactFn,
33
+ ...(hint ? { hint } : {}),
34
+ updatedBy: event.user.id,
35
+ });
36
+
37
+ return {
38
+ isSuccess: true,
39
+ data: { key, redactedPreview: redactedPreview ?? redactFn(value) },
40
+ };
41
+ },
42
+ });
43
+
44
+ // Fallback redaction: show at most the first 3 chars + trailing bullets.
45
+ // Deliberately conservative — a too-generous preview defeats the point.
46
+ function defaultRedact(value: string): string {
47
+ if (value.length === 0) return "";
48
+ const prefix = value.slice(0, Math.min(3, value.length));
49
+ return `${prefix}${"•".repeat(Math.max(1, value.length - 3))}`;
50
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ createSecretsContext,
3
+ createSecretsFeature,
4
+ requireSecretsContext,
5
+ type SecretsContext,
6
+ type SecretsContextOptions,
7
+ type StoredEnvelope,
8
+ type StoredMetadata,
9
+ TENANT_SECRET_READ_EVENT,
10
+ tenantSecretsTable,
11
+ } from "./feature";
12
+ export {
13
+ type RotateJobPayload,
14
+ type RotateJobResult,
15
+ rotateJob,
16
+ } from "./handlers/rotate.job";
@@ -0,0 +1,296 @@
1
+ // Feature-level accessor injected as `ctx.secrets` at boot. Not a HTTP API —
2
+ // feature code that needs a plaintext secret (SMTP-connect, Stripe-call, …)
3
+ // pulls it via ctx.secrets.get. Cleartext never crosses the wire.
4
+ //
5
+ // Post-ES pivot: all three ops (get/set/delete) flow through the events-
6
+ // table.
7
+ // - set → executor.create / .update on the tenantSecret aggregate
8
+ // - delete → executor.delete
9
+ // - get → low-level append of tenantSecretRead-event on a fresh
10
+ // aggregate-stream (one-event-per-read, so parallel reads never
11
+ // race on the secret's own version). The audit invariant ("every
12
+ // read logged") now sits on the events-table instead of a
13
+ // dedicated audit-table.
14
+
15
+ import {
16
+ createEventStoreExecutor,
17
+ createTenantDb,
18
+ type DbConnection,
19
+ fetchOne,
20
+ } from "@cosmicdrift/kumiko-framework/db";
21
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { InternalError, type WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
23
+ import { append, type EventMetadata } from "@cosmicdrift/kumiko-framework/event-store";
24
+ import {
25
+ createDekCache,
26
+ createSecret,
27
+ type DekCache,
28
+ decryptValue,
29
+ encryptValue,
30
+ type MasterKeyProvider,
31
+ type SecretsContext,
32
+ } from "@cosmicdrift/kumiko-framework/secrets";
33
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
34
+ import { and, eq } from "drizzle-orm";
35
+ import { z } from "zod";
36
+ import {
37
+ type StoredEnvelope,
38
+ type StoredMetadata,
39
+ tenantSecretEntity,
40
+ tenantSecretsTable,
41
+ } from "./table";
42
+
43
+ // Re-export the framework interface so consumers of bundled-features/secrets
44
+ // don't need to reach into @cosmicdrift/kumiko-framework/secrets separately.
45
+ export type { Secret, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
46
+
47
+ export type SecretsContextOptions = {
48
+ readonly db: DbConnection;
49
+ readonly masterKeyProvider: MasterKeyProvider;
50
+ // Shared DEK cache. Default: a fresh 5-min TTL cache. Pass in a shared
51
+ // instance if several features decrypt overlapping secret sets — lowers
52
+ // provider-call count across the app.
53
+ readonly dekCache?: DekCache;
54
+ };
55
+
56
+ // Synthetic actor identity for set/delete — the executor wants a full
57
+ // SessionUser, but the secrets-context API takes only a user-id string
58
+ // (via opts.updatedBy / opts.deletedBy). `system`-role mirrors how jobs
59
+ // and seeds attribute out-of-band writes: non-admin paths stay blocked,
60
+ // framework-internal ops keep working.
61
+ const SYSTEM_ROLES = ["system"] as const;
62
+
63
+ // Secret-read audit-event type name + schema. Colocated here instead of
64
+ // in secrets-feature.ts because the feature file imports the context
65
+ // (via createSecretsContext), so schema-in-feature-file would cycle.
66
+ // secrets-feature.ts re-exports `secretReadSchema` for r.defineEvent.
67
+ export const TENANT_SECRET_READ_EVENT = "secrets:event:read";
68
+ export const secretReadSchema = z.object({
69
+ key: z.string(),
70
+ userId: z.string(),
71
+ handlerName: z.string(),
72
+ });
73
+
74
+ const executor = createEventStoreExecutor(tenantSecretsTable, tenantSecretEntity, {
75
+ entityName: "tenant-secret",
76
+ });
77
+
78
+ function resolveKey(keyOrHandle: string | { readonly name: string }): string {
79
+ return typeof keyOrHandle === "string" ? keyOrHandle : keyOrHandle.name;
80
+ }
81
+
82
+ // Wrap a provider so its unwrapDek goes through the cache. Lets decryptValue
83
+ // use the full provider contract without knowing about caching — separation
84
+ // of concerns: decryptValue handles crypto, cache handles cost.
85
+ function cachedProvider(provider: MasterKeyProvider, cache: DekCache): MasterKeyProvider {
86
+ return {
87
+ wrapDek: provider.wrapDek.bind(provider),
88
+ unwrapDek: (encryptedDek, version) => cache.unwrapDek(encryptedDek, version, provider),
89
+ currentVersion: provider.currentVersion.bind(provider),
90
+ isAvailable: provider.isAvailable.bind(provider),
91
+ };
92
+ }
93
+
94
+ function decodeEnvelope(stored: StoredEnvelope): {
95
+ ciphertext: Buffer;
96
+ iv: Buffer;
97
+ authTag: Buffer;
98
+ encryptedDek: Buffer;
99
+ kekVersion: number;
100
+ } {
101
+ return {
102
+ ciphertext: Buffer.from(stored.ciphertext, "base64"),
103
+ iv: Buffer.from(stored.iv, "base64"),
104
+ authTag: Buffer.from(stored.authTag, "base64"),
105
+ encryptedDek: Buffer.from(stored.encryptedDek, "base64"),
106
+ kekVersion: stored.kekVersion,
107
+ };
108
+ }
109
+
110
+ export function createSecretsContext(opts: SecretsContextOptions): SecretsContext {
111
+ const { db, masterKeyProvider } = opts;
112
+ const provider = cachedProvider(masterKeyProvider, opts.dekCache ?? createDekCache());
113
+
114
+ type SecretLookupRow = {
115
+ readonly id: string;
116
+ readonly version: number;
117
+ readonly envelope: StoredEnvelope;
118
+ };
119
+
120
+ async function lookup(tenantId: string, key: string): Promise<SecretLookupRow | undefined> {
121
+ return fetchOne<SecretLookupRow>(
122
+ db,
123
+ tenantSecretsTable,
124
+ eq(tenantSecretsTable.tenantId, tenantId),
125
+ eq(tenantSecretsTable.key, key),
126
+ );
127
+ }
128
+
129
+ return {
130
+ async get(tenantId, keyOrHandle, auditCtx) {
131
+ const key = resolveKey(keyOrHandle);
132
+ // Atomic audit + read: a decrypt that "escaped" the audit trail
133
+ // (because the audit-append threw) would violate the compliance
134
+ // promise "every read is logged". Wrapping both in a TX means
135
+ // either the caller gets the plaintext AND a read-event row, or
136
+ // neither. Reads without audit (framework-internal, rotation job)
137
+ // skip the TX — there's nothing to couple.
138
+ if (!auditCtx) {
139
+ const existing = await lookup(tenantId, key);
140
+ if (!existing) return undefined;
141
+ const plaintext = await decryptValue(decodeEnvelope(existing.envelope), provider);
142
+ return createSecret(plaintext);
143
+ }
144
+
145
+ const plaintext = await db.transaction(async (tx) => {
146
+ // Inline select inside the TX — fetchOne's SelectChainDb shape
147
+ // doesn't widen to drizzle's tx-object cleanly. Structurally
148
+ // identical; the one-off repeat beats a double-cast at the
149
+ // call site.
150
+ const [row] = await tx
151
+ .select({ envelope: tenantSecretsTable.envelope })
152
+ .from(tenantSecretsTable)
153
+ .where(and(eq(tenantSecretsTable.tenantId, tenantId), eq(tenantSecretsTable.key, key)))
154
+ .limit(1);
155
+ if (!row) return undefined;
156
+ const envelope = row.envelope;
157
+ const pt = await decryptValue(decodeEnvelope(envelope), provider);
158
+
159
+ // One event per read on its own aggregate-stream (fresh UUID as
160
+ // aggregateId). Avoids version-conflicts between parallel reads —
161
+ // a shared stream on the tenantSecret-aggregate would force
162
+ // serialization and turn read-amplification into lock-amplification.
163
+ // MSP consumers still group by payload.key if they want per-secret
164
+ // read counts.
165
+ const readId = generateId();
166
+ const metadata: EventMetadata = { userId: auditCtx.userId };
167
+ // Parse against the registered schema so the low-level append
168
+ // here gets the same validation guarantee as ctx.appendEvent.
169
+ // A payload-shape drift between schema + call-site fails at the
170
+ // source instead of landing on the events-stream.
171
+ const payload = secretReadSchema.parse({
172
+ key,
173
+ userId: auditCtx.userId,
174
+ handlerName: auditCtx.handlerName,
175
+ });
176
+ await append(tx, {
177
+ aggregateId: readId,
178
+ aggregateType: "tenantSecretRead",
179
+ tenantId,
180
+ expectedVersion: 0,
181
+ type: TENANT_SECRET_READ_EVENT,
182
+ payload,
183
+ metadata,
184
+ });
185
+ return pt;
186
+ });
187
+
188
+ if (plaintext === undefined) return undefined;
189
+ // Brand the plaintext only after audit committed. The response
190
+ // serializer rejects any Secret<> it finds on the response path.
191
+ return createSecret(plaintext);
192
+ },
193
+
194
+ async set(tenantId, keyOrHandle, value, setOpts = {}) {
195
+ const key = resolveKey(keyOrHandle);
196
+ const envelope = await encryptValue(value, masterKeyProvider);
197
+ const stored: StoredEnvelope = {
198
+ ciphertext: envelope.ciphertext.toString("base64"),
199
+ iv: envelope.iv.toString("base64"),
200
+ authTag: envelope.authTag.toString("base64"),
201
+ encryptedDek: envelope.encryptedDek.toString("base64"),
202
+ kekVersion: envelope.kekVersion,
203
+ };
204
+ const metadata: StoredMetadata = {
205
+ ...(setOpts.redact ? { redactedPreview: setOpts.redact(value) } : {}),
206
+ ...(setOpts.hint ? { hint: setOpts.hint } : {}),
207
+ };
208
+
209
+ const actor: SessionUser = {
210
+ id: setOpts.updatedBy ?? "system",
211
+ tenantId,
212
+ roles: SYSTEM_ROLES,
213
+ };
214
+ const tdb = createTenantDb(db, tenantId, "system");
215
+
216
+ const existing = await lookup(tenantId, key);
217
+ const commonFields = {
218
+ envelope: stored,
219
+ kekVersion: envelope.kekVersion,
220
+ metadata,
221
+ lastRotatedAt: Temporal.Now.instant(),
222
+ };
223
+
224
+ if (existing) {
225
+ const result = await executor.update(
226
+ {
227
+ id: existing.id,
228
+ version: existing.version,
229
+ changes: commonFields,
230
+ },
231
+ actor,
232
+ tdb,
233
+ );
234
+ if (!result.isSuccess) throw wrapSetFailure(result.error);
235
+ // skip: update path done — don't fall through into the create branch below.
236
+ return;
237
+ }
238
+
239
+ try {
240
+ const result = await executor.create(
241
+ {
242
+ key,
243
+ tenantId,
244
+ ...commonFields,
245
+ },
246
+ actor,
247
+ tdb,
248
+ );
249
+ if (!result.isSuccess) throw wrapSetFailure(result.error);
250
+ } catch (err) {
251
+ // Race-fallback: a concurrent set won the insert. Re-lookup and
252
+ // convert to an update. The unique-index on (tenant, key) is what
253
+ // triggers this path.
254
+ const afterRace = await lookup(tenantId, key);
255
+ if (!afterRace) throw err;
256
+ const result = await executor.update(
257
+ {
258
+ id: afterRace.id,
259
+ version: afterRace.version,
260
+ changes: commonFields,
261
+ },
262
+ actor,
263
+ tdb,
264
+ );
265
+ if (!result.isSuccess) throw wrapSetFailure(result.error);
266
+ }
267
+ },
268
+
269
+ async delete(tenantId, keyOrHandle, deleteOpts = {}) {
270
+ const key = resolveKey(keyOrHandle);
271
+ const existing = await lookup(tenantId, key);
272
+ if (!existing) return false;
273
+
274
+ const actor: SessionUser = {
275
+ id: deleteOpts.deletedBy ?? "system",
276
+ tenantId,
277
+ roles: SYSTEM_ROLES,
278
+ };
279
+ const tdb = createTenantDb(db, tenantId, "system");
280
+ const result = await executor.delete({ id: existing.id }, actor, tdb);
281
+ return result.isSuccess;
282
+ },
283
+ };
284
+ }
285
+
286
+ // Wrap an executor-level write failure into a KumikoError so callers of
287
+ // ctx.secrets.set / .delete can still branch on .code / details / i18nKey
288
+ // after it propagates up. Plain `new Error(...)` would have stripped the
289
+ // structured payload the error-contract promises.
290
+ function wrapSetFailure(err: WriteErrorInfo): InternalError {
291
+ return new InternalError({
292
+ message: `[secrets.set] executor returned failure: ${err.code}`,
293
+ i18nKey: "secrets.errors.set_failed",
294
+ details: { executorCode: err.code, executorDetails: err.details ?? {} },
295
+ });
296
+ }
@@ -0,0 +1,68 @@
1
+ import {
2
+ buildBaseColumns,
3
+ instant,
4
+ integer,
5
+ jsonb,
6
+ table,
7
+ text,
8
+ uniqueIndex,
9
+ } from "@cosmicdrift/kumiko-framework/db";
10
+ import {
11
+ createEntity,
12
+ createNumberField,
13
+ createTextField,
14
+ } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { sql } from "drizzle-orm";
16
+
17
+ // Envelope stored as a single jsonb blob. All ops are upsert-by-(tenantId, key)
18
+ // so there's no value in decomposing the envelope into separate columns —
19
+ // we never query or index on any sub-field of the envelope itself.
20
+ //
21
+ // kekVersion IS broken out as its own column so the rotation job can filter
22
+ // `WHERE kek_version != currentVersion()` with an index on just that column
23
+ // without deserializing the jsonb. Duplicated inside envelope too — the two
24
+ // always stay in sync via the write path.
25
+ export type StoredEnvelope = {
26
+ readonly ciphertext: string; // base64
27
+ readonly iv: string; // base64
28
+ readonly authTag: string; // base64
29
+ readonly encryptedDek: string; // base64
30
+ readonly kekVersion: number;
31
+ };
32
+
33
+ export type StoredMetadata = {
34
+ readonly redactedPreview?: string;
35
+ readonly hint?: string;
36
+ };
37
+
38
+ // Entity registration for the ES pivot. Only `key` + `kekVersion` are
39
+ // declared as business-validated fields; the jsonb columns (envelope,
40
+ // metadata) and the instant column (lastRotatedAt) ride along as extra
41
+ // table-columns. The executor writes whatever keys land in `flatData`
42
+ // into both the projection row AND the event payload, so the whole
43
+ // envelope round-trips through events.
44
+ //
45
+ // The envelope is cipher-safe by construction (AES-GCM ciphertext + authTag
46
+ // + DEK encrypted under the KEK). A leaked event row can't recover the
47
+ // plaintext without the master key — so shipping it into the events-table
48
+ // doesn't weaken the threat model vs. the pre-ES tenant_secrets column.
49
+ export const tenantSecretEntity = createEntity({
50
+ table: "read_tenant_secrets",
51
+ fields: {
52
+ key: createTextField({ required: true }),
53
+ kekVersion: createNumberField({ required: true }),
54
+ },
55
+ });
56
+
57
+ export const tenantSecretsTable = table(
58
+ "read_tenant_secrets",
59
+ {
60
+ ...buildBaseColumns(false, "uuid"),
61
+ key: text("key").notNull(),
62
+ envelope: jsonb("envelope").$type<StoredEnvelope>().notNull(),
63
+ kekVersion: integer("kek_version").notNull(),
64
+ metadata: jsonb("metadata").$type<StoredMetadata>().default({}).notNull(),
65
+ lastRotatedAt: instant("last_rotated_at").default(sql`now()`).notNull(),
66
+ },
67
+ (t) => [uniqueIndex("read_tenant_secrets_tenant_key_unique").on(t.tenantId, t.key)],
68
+ );