@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,1246 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ access,
5
+ createSystemConfig,
6
+ createTenantConfig,
7
+ createUserConfig,
8
+ defineFeature,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import {
12
+ createTestUser,
13
+ pushTables,
14
+ setupTestStack,
15
+ type TestStack,
16
+ TestUsers,
17
+ } from "@cosmicdrift/kumiko-framework/stack";
18
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
19
+ import { eq } from "drizzle-orm";
20
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
21
+ import { z } from "zod";
22
+ import { ConfigHandlers, ConfigQueries } from "../constants";
23
+ import { createConfigAccessor, createConfigAccessorFactory, createConfigFeature } from "../feature";
24
+ import { type ConfigResolver, createConfigResolver, validateAppOverrides } from "../resolver";
25
+ import { configValuesTable } from "../table";
26
+
27
+ // --- Setup ---
28
+
29
+ let stack: TestStack;
30
+ let db: DbConnection;
31
+ let resolver: ConfigResolver;
32
+
33
+ const systemAdmin = TestUsers.systemAdmin;
34
+ const tenantAdmin = createTestUser({ id: 2 });
35
+ const billingUser = createTestUser({ id: 3, roles: ["Billing"] });
36
+ const normalUser = createTestUser({ id: 4, roles: ["User"] });
37
+ const otherTenantAdmin = createTestUser({
38
+ id: 5,
39
+ tenantId: "00000000-0000-4000-8000-000000000002",
40
+ });
41
+
42
+ // --- Features that register config keys (based on 6 real scenarios) ---
43
+
44
+ // Scenario 1: System URL — one value for the whole system
45
+ // Scenario 2: Mail server — system default + tenant override
46
+ const appFeature = defineFeature("app", (r) => {
47
+ r.requires("config");
48
+
49
+ r.config({
50
+ keys: {
51
+ // Scenario 1: system-only URL
52
+ serviceUrl: createSystemConfig("text", {
53
+ default: "https://default.example.com",
54
+ write: access.systemAdmin,
55
+ }),
56
+ // Scenario 2: mail server with system default + tenant override
57
+ mailServer: createTenantConfig("text", {
58
+ default: "smtp.default.com",
59
+ write: access.roles("SystemAdmin", "Admin"),
60
+ read: access.admin,
61
+ }),
62
+ },
63
+ });
64
+ });
65
+
66
+ // Scenario 3: Tenant mail signature — admin can change
67
+ // Scenario 4: Tenant invoice pattern — billing can change
68
+ const invoicingFeature = defineFeature("invoicing", (r) => {
69
+ r.requires("config");
70
+
71
+ r.config({
72
+ keys: {
73
+ // Scenario 3
74
+ mailSignature: createTenantConfig("text", {
75
+ default: "Best regards",
76
+ write: access.roles("Admin"),
77
+ }),
78
+ // Scenario 4
79
+ invoicePattern: createTenantConfig("text", {
80
+ default: "INV-{year}-{number}",
81
+ write: access.roles("Billing"),
82
+ read: access.roles("Admin", "Billing"),
83
+ }),
84
+ },
85
+ });
86
+ });
87
+
88
+ // Scenario 5: User push notification setting. Setup-return becomes
89
+ // `notificationsFeature.exports` (defineFeature generic) — that's how the
90
+ // probe handler below reaches the typed handle without module-capture.
91
+ const notificationsFeature = defineFeature("notifications", (r) => {
92
+ r.requires("config");
93
+ return r.config({
94
+ keys: {
95
+ pushEnabled: createUserConfig("boolean", { default: true }),
96
+ },
97
+ });
98
+ });
99
+
100
+ // Scenario 6: Feature setting per tenant
101
+ const ordersFeature = defineFeature("orders", (r) => {
102
+ r.requires("config");
103
+ return r.config({
104
+ keys: {
105
+ maxOrderCount: createTenantConfig("number", { default: 100, write: access.roles("Admin") }),
106
+ // Scenario 7: numeric key with bounds — reject-path for out-of-range values.
107
+ maxUploadSizeMB: createTenantConfig("number", {
108
+ default: 10,
109
+ bounds: { min: 1, max: 1000 },
110
+ write: access.roles("Admin"),
111
+ }),
112
+ // Scenario 9: computed key — simulates plan-based quota. Fake-lookup
113
+ // by tenantId suffix so the test stays hermetic. In a real app,
114
+ // computed would `ctx.db.select()...` a subscription table.
115
+ planBasedQuotaGB: createTenantConfig("number", {
116
+ default: 1,
117
+ write: access.roles("Admin"),
118
+ computed: async ({ tenantId }) => {
119
+ // Tenant 2 = "Pro" plan, everyone else gets basic.
120
+ if (tenantId.endsWith("0000000002")) return 500;
121
+ return 50;
122
+ },
123
+ }),
124
+ },
125
+ });
126
+ });
127
+
128
+ // Probe feature: a real writeHandler reads two configs through ctx.config
129
+ // so the dispatcher-wiring path is exercised end-to-end (not just the
130
+ // factory in isolation). Captures the resolved values for the test to assert.
131
+ const probe: { orders: number | undefined; push: boolean | undefined } = {
132
+ orders: undefined,
133
+ push: undefined,
134
+ };
135
+ const probeFeature = defineFeature("probe", (r) => {
136
+ r.requires("config");
137
+ r.requires("orders");
138
+ r.requires("notifications");
139
+
140
+ r.writeHandler(
141
+ "read-config",
142
+ z.object({}),
143
+ async (_event, ctx) => {
144
+ if (!ctx.config) throw new Error("ctx.config not wired — _configAccessorFactory missing");
145
+ probe.orders = await ctx.config(ordersFeature.exports.maxOrderCount);
146
+ probe.push = await ctx.config(notificationsFeature.exports.pushEnabled);
147
+ return { isSuccess: true, data: { orders: probe.orders, push: probe.push } };
148
+ },
149
+ { access: { openToAll: true } },
150
+ );
151
+ });
152
+
153
+ // Encrypted config key
154
+ const integrationFeature = defineFeature("integration", (r) => {
155
+ r.requires("config");
156
+
157
+ r.config({
158
+ keys: {
159
+ apiSecret: createTenantConfig("text", {
160
+ write: access.systemAdmin,
161
+ read: access.systemAdmin,
162
+ encrypted: true,
163
+ }),
164
+ // Dedicated key for the lifecycle-event tests below. Kept in its own
165
+ // key so `.created` / `.updated` assertions don't race with earlier
166
+ // scenarios that mutate shared keys (max-order-count etc.).
167
+ lifecycleProbe: createTenantConfig("text", {
168
+ default: "initial",
169
+ write: access.roles("Admin"),
170
+ }),
171
+ },
172
+ });
173
+ });
174
+
175
+ const configFeature = createConfigFeature();
176
+ const testEncryptionKey = randomBytes(32).toString("base64");
177
+
178
+ beforeAll(async () => {
179
+ const encryption = createEncryptionProvider(testEncryptionKey);
180
+ resolver = createConfigResolver({ encryption });
181
+
182
+ stack = await setupTestStack({
183
+ features: [
184
+ configFeature,
185
+ appFeature,
186
+ invoicingFeature,
187
+ notificationsFeature,
188
+ ordersFeature,
189
+ integrationFeature,
190
+ probeFeature,
191
+ ],
192
+ // Wire `ctx.config()` for real handlers: pass the resolver-bound factory
193
+ // so the dispatcher can mint a per-user accessor inside buildHandlerContext.
194
+ extraContext: ({ registry }) => ({
195
+ configResolver: resolver,
196
+ configEncryption: encryption,
197
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
198
+ }),
199
+ });
200
+ db = stack.db;
201
+
202
+ await pushTables(db, { configValuesTable });
203
+ // setupTestStack already calls createEventsTable + createArchivedStreamsTable
204
+ // for us; nothing extra needed for the config-changed event-store writes.
205
+ });
206
+
207
+ afterAll(async () => {
208
+ await stack.cleanup();
209
+ });
210
+
211
+ // --- Scenario 1: System URL — einmal pro System ---
212
+
213
+ describe("scenario 1: system-scoped service URL", () => {
214
+ test("returns default when no value is set", async () => {
215
+ const configFn = createConfigAccessor(
216
+ stack.registry,
217
+ resolver,
218
+ tenantAdmin.tenantId,
219
+ tenantAdmin.id,
220
+ db,
221
+ );
222
+ const value = await configFn("app:config:service-url");
223
+ expect(value).toBe("https://default.example.com");
224
+ });
225
+
226
+ test("SystemAdmin can set system-scoped value", async () => {
227
+ await stack.http.writeOk(
228
+ ConfigHandlers.set,
229
+ {
230
+ key: "app:config:service-url",
231
+ value: "https://custom.example.com",
232
+ },
233
+ systemAdmin,
234
+ );
235
+
236
+ const configFn = createConfigAccessor(
237
+ stack.registry,
238
+ resolver,
239
+ tenantAdmin.tenantId,
240
+ tenantAdmin.id,
241
+ db,
242
+ );
243
+ const value = await configFn("app:config:service-url");
244
+ expect(value).toBe("https://custom.example.com");
245
+ });
246
+
247
+ test("tenant Admin cannot set system-scoped value", async () => {
248
+ const error = await stack.http.writeErr(
249
+ ConfigHandlers.set,
250
+ {
251
+ key: "app:config:service-url",
252
+ value: "https://hacked.com",
253
+ },
254
+ tenantAdmin,
255
+ );
256
+ expectErrorIncludes(error, "access_denied");
257
+ });
258
+ });
259
+
260
+ // --- Scenario 2: Mail Server — system default + tenant override ---
261
+
262
+ describe("scenario 2: tenant-scoped mail server with system fallback", () => {
263
+ test("returns declared default when nothing is set", async () => {
264
+ const configFn = createConfigAccessor(
265
+ stack.registry,
266
+ resolver,
267
+ tenantAdmin.tenantId,
268
+ tenantAdmin.id,
269
+ db,
270
+ );
271
+ const value = await configFn("app:config:mail-server");
272
+ expect(value).toBe("smtp.default.com");
273
+ });
274
+
275
+ test("SystemAdmin sets system-level value (acts as global default)", async () => {
276
+ await stack.http.writeOk(
277
+ ConfigHandlers.set,
278
+ {
279
+ key: "app:config:mail-server",
280
+ value: "smtp.company.com",
281
+ scope: "system",
282
+ },
283
+ systemAdmin,
284
+ );
285
+
286
+ // Both tenants see the system value
287
+ const configT1 = createConfigAccessor(
288
+ stack.registry,
289
+ resolver,
290
+ "00000000-0000-4000-8000-000000000001",
291
+ tenantAdmin.id,
292
+ db,
293
+ );
294
+ expect(await configT1("app:config:mail-server")).toBe("smtp.company.com");
295
+
296
+ const configT2 = createConfigAccessor(
297
+ stack.registry,
298
+ resolver,
299
+ "00000000-0000-4000-8000-000000000002",
300
+ otherTenantAdmin.id,
301
+ db,
302
+ );
303
+ expect(await configT2("app:config:mail-server")).toBe("smtp.company.com");
304
+ });
305
+
306
+ test("tenant Admin overrides with tenant-level value", async () => {
307
+ await stack.http.writeOk(
308
+ ConfigHandlers.set,
309
+ {
310
+ key: "app:config:mail-server",
311
+ value: "smtp.tenant1.com",
312
+ scope: "tenant",
313
+ },
314
+ tenantAdmin,
315
+ );
316
+
317
+ // Tenant 1 sees override, tenant 2 still sees system value
318
+ const configT1 = createConfigAccessor(
319
+ stack.registry,
320
+ resolver,
321
+ "00000000-0000-4000-8000-000000000001",
322
+ tenantAdmin.id,
323
+ db,
324
+ );
325
+ expect(await configT1("app:config:mail-server")).toBe("smtp.tenant1.com");
326
+
327
+ const configT2 = createConfigAccessor(
328
+ stack.registry,
329
+ resolver,
330
+ "00000000-0000-4000-8000-000000000002",
331
+ otherTenantAdmin.id,
332
+ db,
333
+ );
334
+ expect(await configT2("app:config:mail-server")).toBe("smtp.company.com");
335
+ });
336
+
337
+ test("reset tenant value falls back to system value", async () => {
338
+ await stack.http.writeOk(
339
+ ConfigHandlers.reset,
340
+ {
341
+ key: "app:config:mail-server",
342
+ scope: "tenant",
343
+ },
344
+ tenantAdmin,
345
+ );
346
+
347
+ const configFn = createConfigAccessor(
348
+ stack.registry,
349
+ resolver,
350
+ "00000000-0000-4000-8000-000000000001",
351
+ tenantAdmin.id,
352
+ db,
353
+ );
354
+ expect(await configFn("app:config:mail-server")).toBe("smtp.company.com");
355
+ });
356
+ });
357
+
358
+ // --- Scenario 3: Tenant mail signature — Admin can change ---
359
+
360
+ describe("scenario 3: tenant mail signature", () => {
361
+ test("Admin can set tenant-level signature", async () => {
362
+ await stack.http.writeOk(
363
+ ConfigHandlers.set,
364
+ {
365
+ key: "invoicing:config:mail-signature",
366
+ value: "Mit freundlichen Grüßen, Firma ABC",
367
+ },
368
+ tenantAdmin,
369
+ );
370
+
371
+ const configFn = createConfigAccessor(
372
+ stack.registry,
373
+ resolver,
374
+ "00000000-0000-4000-8000-000000000001",
375
+ normalUser.id,
376
+ db,
377
+ );
378
+ expect(await configFn("invoicing:config:mail-signature")).toBe(
379
+ "Mit freundlichen Grüßen, Firma ABC",
380
+ );
381
+ });
382
+
383
+ test("normal User cannot change signature", async () => {
384
+ const error = await stack.http.writeErr(
385
+ ConfigHandlers.set,
386
+ {
387
+ key: "invoicing:config:mail-signature",
388
+ value: "Hacked signature",
389
+ },
390
+ normalUser,
391
+ );
392
+ expectErrorIncludes(error, "access_denied");
393
+ });
394
+ });
395
+
396
+ // --- Scenario 4: Invoice pattern — Billing can change ---
397
+
398
+ describe("scenario 4: tenant invoice pattern (Billing role)", () => {
399
+ test("Billing user can set invoice pattern", async () => {
400
+ await stack.http.writeOk(
401
+ ConfigHandlers.set,
402
+ {
403
+ key: "invoicing:config:invoice-pattern",
404
+ value: "RE-{year}/{number}",
405
+ },
406
+ billingUser,
407
+ );
408
+
409
+ const configFn = createConfigAccessor(
410
+ stack.registry,
411
+ resolver,
412
+ "00000000-0000-4000-8000-000000000001",
413
+ billingUser.id,
414
+ db,
415
+ );
416
+ expect(await configFn("invoicing:config:invoice-pattern")).toBe("RE-{year}/{number}");
417
+ });
418
+
419
+ test("Admin cannot change invoice pattern (only Billing)", async () => {
420
+ const error = await stack.http.writeErr(
421
+ ConfigHandlers.set,
422
+ {
423
+ key: "invoicing:config:invoice-pattern",
424
+ value: "ADM-{number}",
425
+ },
426
+ tenantAdmin,
427
+ );
428
+ expectErrorIncludes(error, "access_denied");
429
+ });
430
+ });
431
+
432
+ // --- Scenario 5: User push notification setting ---
433
+
434
+ describe("scenario 5: user-scoped push notifications", () => {
435
+ test("default is true", async () => {
436
+ const configFn = createConfigAccessor(
437
+ stack.registry,
438
+ resolver,
439
+ "00000000-0000-4000-8000-000000000001",
440
+ normalUser.id,
441
+ db,
442
+ );
443
+ expect(await configFn("notifications:config:push-enabled")).toBe(true);
444
+ });
445
+
446
+ test("user can disable for themselves", async () => {
447
+ await stack.http.writeOk(
448
+ ConfigHandlers.set,
449
+ {
450
+ key: "notifications:config:push-enabled",
451
+ value: false,
452
+ },
453
+ normalUser,
454
+ );
455
+
456
+ // This user sees false
457
+ const configUser = createConfigAccessor(
458
+ stack.registry,
459
+ resolver,
460
+ "00000000-0000-4000-8000-000000000001",
461
+ normalUser.id,
462
+ db,
463
+ );
464
+ expect(await configUser("notifications:config:push-enabled")).toBe(false);
465
+
466
+ // Other user still sees default (true)
467
+ const configOther = createConfigAccessor(
468
+ stack.registry,
469
+ resolver,
470
+ "00000000-0000-4000-8000-000000000001",
471
+ tenantAdmin.id,
472
+ db,
473
+ );
474
+ expect(await configOther("notifications:config:push-enabled")).toBe(true);
475
+ });
476
+
477
+ test("tenant-level default overrides declared default", async () => {
478
+ // Admin sets tenant-level default to false
479
+ await stack.http.writeOk(
480
+ ConfigHandlers.set,
481
+ {
482
+ key: "notifications:config:push-enabled",
483
+ value: false,
484
+ scope: "tenant",
485
+ },
486
+ tenantAdmin,
487
+ );
488
+
489
+ // User who hasn't set their own value now sees false (tenant default)
490
+ const configAdmin = createConfigAccessor(
491
+ stack.registry,
492
+ resolver,
493
+ "00000000-0000-4000-8000-000000000001",
494
+ tenantAdmin.id,
495
+ db,
496
+ );
497
+ expect(await configAdmin("notifications:config:push-enabled")).toBe(false);
498
+
499
+ // User who already set their value still sees their value (false)
500
+ const configUser = createConfigAccessor(
501
+ stack.registry,
502
+ resolver,
503
+ "00000000-0000-4000-8000-000000000001",
504
+ normalUser.id,
505
+ db,
506
+ );
507
+ expect(await configUser("notifications:config:push-enabled")).toBe(false);
508
+ });
509
+ });
510
+
511
+ // --- Scenario 6: Feature setting per tenant ---
512
+
513
+ describe("scenario 6: feature number setting per tenant", () => {
514
+ test("returns typed number default", async () => {
515
+ const configFn = createConfigAccessor(
516
+ stack.registry,
517
+ resolver,
518
+ "00000000-0000-4000-8000-000000000001",
519
+ normalUser.id,
520
+ db,
521
+ );
522
+ const value = await configFn("orders:config:max-order-count");
523
+ expect(value).toBe(100);
524
+ expect(typeof value).toBe("number");
525
+ });
526
+
527
+ test("Admin sets number value, code can do > comparison", async () => {
528
+ await stack.http.writeOk(
529
+ ConfigHandlers.set,
530
+ {
531
+ key: "orders:config:max-order-count",
532
+ value: 50,
533
+ },
534
+ tenantAdmin,
535
+ );
536
+
537
+ const configFn = createConfigAccessor(
538
+ stack.registry,
539
+ resolver,
540
+ "00000000-0000-4000-8000-000000000001",
541
+ normalUser.id,
542
+ db,
543
+ );
544
+ const maxOrders = await configFn("orders:config:max-order-count");
545
+
546
+ expect(typeof maxOrders).toBe("number");
547
+ expect((maxOrders as number) > 25).toBe(true);
548
+ expect((maxOrders as number) > 75).toBe(false);
549
+ });
550
+
551
+ test("different tenants have different values", async () => {
552
+ await stack.http.writeOk(
553
+ ConfigHandlers.set,
554
+ {
555
+ key: "orders:config:max-order-count",
556
+ value: 200,
557
+ },
558
+ otherTenantAdmin,
559
+ );
560
+
561
+ const configT1 = createConfigAccessor(
562
+ stack.registry,
563
+ resolver,
564
+ "00000000-0000-4000-8000-000000000001",
565
+ normalUser.id,
566
+ db,
567
+ );
568
+ const configT2 = createConfigAccessor(
569
+ stack.registry,
570
+ resolver,
571
+ "00000000-0000-4000-8000-000000000002",
572
+ otherTenantAdmin.id,
573
+ db,
574
+ );
575
+
576
+ expect(await configT1("orders:config:max-order-count")).toBe(50);
577
+ expect(await configT2("orders:config:max-order-count")).toBe(200);
578
+ });
579
+ });
580
+
581
+ // --- ctx.config() integration ---
582
+
583
+ describe("ctx.config() in handler context", () => {
584
+ test("handler can read config via ctx.config()", async () => {
585
+ const configFn = createConfigAccessor(
586
+ stack.registry,
587
+ resolver,
588
+ "00000000-0000-4000-8000-000000000001",
589
+ tenantAdmin.id,
590
+ db,
591
+ );
592
+
593
+ const maxOrders = await configFn("orders:config:max-order-count");
594
+ const currentOrders = 60;
595
+
596
+ expect(typeof maxOrders).toBe("number");
597
+ expect(maxOrders).toBe(50);
598
+ expect(currentOrders > (maxOrders as number)).toBe(true);
599
+ });
600
+
601
+ test("handle from r.config() carries the qualified key the registry stores", () => {
602
+ expect(ordersFeature.exports.maxOrderCount.name).toBe("orders:config:max-order-count");
603
+ expect(notificationsFeature.exports.pushEnabled.name).toBe("notifications:config:push-enabled");
604
+ });
605
+
606
+ test("ctx.config(handle) is wired through the dispatcher into real handlers", async () => {
607
+ // Reset so prior test order doesn't pollute the assertion.
608
+ probe.orders = undefined;
609
+ probe.push = undefined;
610
+ await stack.http.writeOk("probe:write:read-config", {}, tenantAdmin);
611
+ // The probe handler ran ctx.config(handle) for both keys — typeof
612
+ // catches the regression where the handle path silently returns the
613
+ // broad union instead of the narrowed primitive.
614
+ expect(typeof probe.orders).toBe("number");
615
+ expect(typeof probe.push).toBe("boolean");
616
+ });
617
+ });
618
+
619
+ // --- config.values query ---
620
+
621
+ describe("config.values query handler", () => {
622
+ test("returns all visible config values for user", async () => {
623
+ const values = await stack.http.queryOk<Record<string, { value: unknown; scope: string }>>(
624
+ ConfigQueries.values,
625
+ {},
626
+ tenantAdmin,
627
+ );
628
+
629
+ expect(values["app:config:service-url"]).toBeDefined();
630
+ expect(values["app:config:mail-server"]).toBeDefined();
631
+ expect(values["invoicing:config:mail-signature"]).toBeDefined();
632
+ expect(values["notifications:config:push-enabled"]).toBeDefined();
633
+ expect(values["orders:config:max-order-count"]).toBeDefined();
634
+ });
635
+
636
+ test("filters by read access", async () => {
637
+ const values = await stack.http.queryOk<Record<string, { value: unknown; scope: string }>>(
638
+ ConfigQueries.values,
639
+ {},
640
+ normalUser,
641
+ );
642
+
643
+ // normalUser (role: User) should see "all" read access keys
644
+ expect(values["invoicing:config:mail-signature"]).toBeDefined();
645
+ expect(values["notifications:config:push-enabled"]).toBeDefined();
646
+ expect(values["orders:config:max-order-count"]).toBeDefined();
647
+
648
+ // But NOT keys restricted to Admin/SystemAdmin
649
+ expect(values["app:config:service-url"]).toBeUndefined();
650
+ expect(values["app:config:mail-server"]).toBeUndefined();
651
+ });
652
+ });
653
+
654
+ // --- config.schema query ---
655
+
656
+ describe("config.schema query handler", () => {
657
+ test("returns key definitions filtered by read access", async () => {
658
+ const schema = await stack.http.queryOk<Record<string, unknown>>(
659
+ ConfigQueries.schema,
660
+ {},
661
+ normalUser,
662
+ );
663
+
664
+ expect(schema["invoicing:config:mail-signature"]).toBeDefined();
665
+ expect(schema["app:config:service-url"]).toBeUndefined();
666
+ });
667
+ });
668
+
669
+ // --- Type validation ---
670
+
671
+ describe("type validation", () => {
672
+ test("rejects string for number key", async () => {
673
+ const error = await stack.http.writeErr(
674
+ ConfigHandlers.set,
675
+ {
676
+ key: "orders:config:max-order-count",
677
+ value: "not a number",
678
+ },
679
+ tenantAdmin,
680
+ );
681
+ expectErrorIncludes(error, "invalid_type");
682
+ });
683
+
684
+ test("rejects number for boolean key", async () => {
685
+ const error = await stack.http.writeErr(
686
+ ConfigHandlers.set,
687
+ {
688
+ key: "notifications:config:push-enabled",
689
+ value: 42,
690
+ },
691
+ normalUser,
692
+ );
693
+ expectErrorIncludes(error, "invalid_type");
694
+ });
695
+
696
+ test("rejects unknown config key", async () => {
697
+ const error = await stack.http.writeErr(
698
+ ConfigHandlers.set,
699
+ {
700
+ key: "nonexistent:config:key",
701
+ value: "test",
702
+ },
703
+ systemAdmin,
704
+ );
705
+ // unknown config key maps to NotFoundError — reason includes the snake entity name
706
+ expectErrorIncludes(error, "config_key_not_found");
707
+ });
708
+ });
709
+
710
+ // --- Encrypted config ---
711
+
712
+ describe("encrypted config", () => {
713
+ test("encrypted value is stored encrypted in DB, read back decrypted", async () => {
714
+ await stack.http.writeOk(
715
+ ConfigHandlers.set,
716
+ {
717
+ key: "integration:config:api-secret",
718
+ value: "sk-super-secret-key-12345",
719
+ },
720
+ systemAdmin,
721
+ );
722
+
723
+ // Read via config accessor — should be decrypted
724
+ const configFn = createConfigAccessor(
725
+ stack.registry,
726
+ resolver,
727
+ "00000000-0000-4000-8000-000000000001",
728
+ systemAdmin.id,
729
+ db,
730
+ );
731
+ const value = await configFn("integration:config:api-secret");
732
+ expect(value).toBe("sk-super-secret-key-12345");
733
+
734
+ // Verify raw DB value is NOT plaintext
735
+ const { eq } = await import("drizzle-orm");
736
+ const [raw] = await db
737
+ .select({ value: configValuesTable.value })
738
+ .from(configValuesTable)
739
+ .where(eq(configValuesTable.key, "integration:config:api-secret"));
740
+ const rawValue = raw?.value as string;
741
+ expect(rawValue).not.toBe("sk-super-secret-key-12345");
742
+ expect(rawValue).not.toContain("sk-super-secret");
743
+ });
744
+
745
+ test("non-SystemAdmin cannot read encrypted key", async () => {
746
+ const values = await stack.http.queryOk<Record<string, unknown>>(
747
+ ConfigQueries.values,
748
+ {},
749
+ tenantAdmin,
750
+ );
751
+ expect(values["integration:config:api-secret"]).toBeUndefined();
752
+ });
753
+
754
+ test("config.values returns masked value for encrypted key even with read access", async () => {
755
+ const values = await stack.http.queryOk<Record<string, { value: unknown; scope: string }>>(
756
+ ConfigQueries.values,
757
+ {},
758
+ systemAdmin,
759
+ );
760
+ expect(values["integration:config:api-secret"]).toBeDefined();
761
+ expect(values["integration:config:api-secret"]?.value).toBe("••••••");
762
+ });
763
+
764
+ test("ctx.config() returns decrypted value", async () => {
765
+ const configFn = createConfigAccessor(
766
+ stack.registry,
767
+ resolver,
768
+ "00000000-0000-4000-8000-000000000001",
769
+ systemAdmin.id,
770
+ db,
771
+ );
772
+ const value = await configFn("integration:config:api-secret");
773
+ expect(value).toBe("sk-super-secret-key-12345");
774
+ });
775
+ });
776
+
777
+ // --- Config lifecycle events ---
778
+ //
779
+ // Post-ES refactor: each (key, scope) pair is its own aggregate stream with
780
+ // auto-lifecycle events `configValue.created / .updated / .deleted`. The
781
+ // pre-ES flat "config:event:config-changed" stream on a per-tenant
782
+ // aggregate is gone — subscribers listen to the auto-events via
783
+ // r.multiStreamProjection instead, and per-key replay/asOf falls out of the
784
+ // per-value stream granularity.
785
+
786
+ describe("configValue lifecycle events", () => {
787
+ test("set emits configValue.updated carrying the serialized new value", async () => {
788
+ await stack.http.writeOk(
789
+ ConfigHandlers.set,
790
+ { key: "orders:config:max-order-count", value: 250 },
791
+ tenantAdmin,
792
+ );
793
+ const events = await db
794
+ .select()
795
+ .from(eventsTable)
796
+ .where(eq(eventsTable.aggregateType, "config-value"));
797
+ // The first set in the suite created the row; subsequent sets update it.
798
+ // Look at the most recent update carrying our value to verify the
799
+ // serialized JSON lands in the event payload (key stays on the row,
800
+ // only value moves on updates — the executor emits a changes/previous
801
+ // diff).
802
+ const updates = events.filter(
803
+ (e) =>
804
+ e.type === "config-value.updated" &&
805
+ (e.payload as { previous?: { key?: string } })?.previous?.key ===
806
+ "orders:config:max-order-count",
807
+ );
808
+ expect(updates.length).toBeGreaterThanOrEqual(1);
809
+ const last = updates[updates.length - 1];
810
+ expect((last?.payload as { changes?: { value?: string } })?.changes?.value).toBe(
811
+ JSON.stringify(250),
812
+ );
813
+ });
814
+
815
+ test("reset emits configValue.deleted for the value row", async () => {
816
+ // Set first so reset has something to roll back.
817
+ await stack.http.writeOk(
818
+ ConfigHandlers.set,
819
+ { key: "invoicing:config:mail-signature", value: "Cheers" },
820
+ tenantAdmin,
821
+ );
822
+ await stack.http.writeOk(
823
+ ConfigHandlers.reset,
824
+ { key: "invoicing:config:mail-signature" },
825
+ tenantAdmin,
826
+ );
827
+ const events = await db
828
+ .select()
829
+ .from(eventsTable)
830
+ .where(eq(eventsTable.aggregateType, "config-value"));
831
+ const deletes = events.filter(
832
+ (e) =>
833
+ e.type === "config-value.deleted" &&
834
+ (e.payload as { previous?: { key?: string } })?.previous?.key ===
835
+ "invoicing:config:mail-signature",
836
+ );
837
+ expect(deletes.length).toBeGreaterThanOrEqual(1);
838
+ });
839
+
840
+ test("first set on a fresh key emits configValue.created with key + serialized value", async () => {
841
+ // Uses a dedicated key (integration:config:lifecycle-probe) that no
842
+ // earlier scenario touches — guarantees the FIRST event is a .created,
843
+ // not a .updated, so the assertion reaches the create-path of the
844
+ // executor without depending on test execution order.
845
+ await stack.http.writeOk(
846
+ ConfigHandlers.set,
847
+ { key: "integration:config:lifecycle-probe", value: "alpha" },
848
+ tenantAdmin,
849
+ );
850
+ const events = await db
851
+ .select()
852
+ .from(eventsTable)
853
+ .where(eq(eventsTable.aggregateType, "config-value"));
854
+ const created = events.filter(
855
+ (e) =>
856
+ e.type === "config-value.created" &&
857
+ (e.payload as { key?: string })?.key === "integration:config:lifecycle-probe",
858
+ );
859
+ expect(created.length).toBe(1);
860
+ expect(created[0]?.payload).toMatchObject({
861
+ key: "integration:config:lifecycle-probe",
862
+ value: JSON.stringify("alpha"),
863
+ });
864
+ });
865
+
866
+ test("subsequent set emits configValue.updated carrying both changes and previous", async () => {
867
+ // Change the value we seeded above to exercise the .updated-event
868
+ // shape: the executor stamps BOTH halves of the diff onto the payload
869
+ // (changes = what the user sent, previous = the pre-update row). MSPs
870
+ // reading across aggregates need `previous` to decrement / undo when
871
+ // a parent-FK moves — dropping it would break replays.
872
+ await stack.http.writeOk(
873
+ ConfigHandlers.set,
874
+ { key: "integration:config:lifecycle-probe", value: "beta" },
875
+ tenantAdmin,
876
+ );
877
+ const events = await db
878
+ .select()
879
+ .from(eventsTable)
880
+ .where(eq(eventsTable.aggregateType, "config-value"));
881
+ const updates = events.filter(
882
+ (e) =>
883
+ e.type === "config-value.updated" &&
884
+ (e.payload as { previous?: { key?: string } })?.previous?.key ===
885
+ "integration:config:lifecycle-probe",
886
+ );
887
+ expect(updates.length).toBeGreaterThanOrEqual(1);
888
+ const last = updates[updates.length - 1];
889
+ const payload = last?.payload as {
890
+ changes?: { value?: string };
891
+ previous?: { value?: string; key?: string };
892
+ };
893
+ expect(payload.changes?.value).toBe(JSON.stringify("beta"));
894
+ expect(payload.previous?.value).toBe(JSON.stringify("alpha"));
895
+ expect(payload.previous?.key).toBe("integration:config:lifecycle-probe");
896
+ });
897
+
898
+ test("encrypted-key plaintext never appears in the event payload", async () => {
899
+ await stack.http.writeOk(
900
+ ConfigHandlers.set,
901
+ { key: "integration:config:api-secret", value: "rotated-secret-987" },
902
+ systemAdmin,
903
+ );
904
+ const events = await db
905
+ .select()
906
+ .from(eventsTable)
907
+ .where(eq(eventsTable.aggregateType, "config-value"));
908
+ const created = events.filter(
909
+ (e) =>
910
+ e.type === "config-value.created" &&
911
+ (e.payload as { key?: string })?.key === "integration:config:api-secret",
912
+ );
913
+ expect(created.length).toBeGreaterThanOrEqual(1);
914
+ const last = created[created.length - 1];
915
+ // The serialized ciphertext (not plaintext) is what landed in the
916
+ // payload — the resolver wraps set() in the encryption provider before
917
+ // the executor hands flatData to the event writer.
918
+ const serializedPayload = JSON.stringify(last?.payload);
919
+ expect(serializedPayload).not.toContain("rotated-secret-987");
920
+ });
921
+ });
922
+
923
+ // --- Scenario 7: Bounds enforcement on numeric keys ---
924
+ //
925
+ // orders:config:max-upload-size-mb declares bounds: { min: 1, max: 1000 }.
926
+ // A tenant-admin SET with a value outside that range must hard-reject with
927
+ // a validation error — silent clamping is explicitly ruled out (see
928
+ // types/config.ts comment on ConfigBounds).
929
+
930
+ describe("scenario 7: bounds enforcement", () => {
931
+ const boundedKey = "orders:config:max-upload-size-mb";
932
+
933
+ test("accepts value inside bounds", async () => {
934
+ const result = await stack.http.writeOk(
935
+ ConfigHandlers.set,
936
+ { key: boundedKey, value: 500 },
937
+ tenantAdmin,
938
+ );
939
+ expect(result).toMatchObject({ value: 500 });
940
+ });
941
+
942
+ test("accepts boundary values (min + max exact)", async () => {
943
+ const atMin = await stack.http.writeOk(
944
+ ConfigHandlers.set,
945
+ { key: boundedKey, value: 1 },
946
+ tenantAdmin,
947
+ );
948
+ expect(atMin).toMatchObject({ value: 1 });
949
+ const atMax = await stack.http.writeOk(
950
+ ConfigHandlers.set,
951
+ { key: boundedKey, value: 1000 },
952
+ tenantAdmin,
953
+ );
954
+ expect(atMax).toMatchObject({ value: 1000 });
955
+ });
956
+
957
+ test("rejects value below min with out_of_bounds", async () => {
958
+ const error = await stack.http.writeErr(
959
+ ConfigHandlers.set,
960
+ { key: boundedKey, value: 0 },
961
+ tenantAdmin,
962
+ );
963
+ expectErrorIncludes(error, "out_of_bounds");
964
+ });
965
+
966
+ test("rejects value above max with out_of_bounds", async () => {
967
+ const error = await stack.http.writeErr(
968
+ ConfigHandlers.set,
969
+ { key: boundedKey, value: 10_000 },
970
+ tenantAdmin,
971
+ );
972
+ expectErrorIncludes(error, "out_of_bounds");
973
+ });
974
+
975
+ test("rejects even when caller has write-role — bounds override role-grants", async () => {
976
+ // tenantAdmin has Admin role → passes access check, then fails bounds.
977
+ const error = await stack.http.writeErr(
978
+ ConfigHandlers.set,
979
+ { key: boundedKey, value: -1 },
980
+ tenantAdmin,
981
+ );
982
+ expectErrorIncludes(error, "out_of_bounds");
983
+ });
984
+ });
985
+
986
+ // --- Scenario 8: App-Boot-Overrides ---
987
+ //
988
+ // buildServer-level overrides sit between the scope-specific rows and the
989
+ // feature-declared default. Key rule: a deliberate Set from a tenant-admin
990
+ // still wins. The override is the *better default for this deploy*, not a
991
+ // hard policy.
992
+
993
+ describe("scenario 8: app-boot overrides", () => {
994
+ const OVERRIDE_KEY = "orders:config:max-order-count";
995
+
996
+ test("validateAppOverrides throws synchronously for bad values (prevents broken deploy)", () => {
997
+ expect(() =>
998
+ validateAppOverrides(stack.registry, {
999
+ "orders:config:max-upload-size-mb": 99_999, // above bounds.max = 1000
1000
+ }),
1001
+ ).toThrow(/above bounds\.max/i);
1002
+
1003
+ expect(() =>
1004
+ validateAppOverrides(stack.registry, {
1005
+ "does-not-exist:config:foo": 1,
1006
+ }),
1007
+ ).toThrow(/unknown config key/i);
1008
+ });
1009
+
1010
+ test("override is returned when no row exists for the key", async () => {
1011
+ // Fresh tenant-id that earlier tests haven't touched — so the cascade
1012
+ // finds no tenant-row, no system-row, and falls through to the override.
1013
+ const freshTenant = "00000000-0000-4000-8000-0000000000aa";
1014
+ const resolverWithOverride = createConfigResolver({
1015
+ appOverrides: validateAppOverrides(stack.registry, {
1016
+ [OVERRIDE_KEY]: 250,
1017
+ }),
1018
+ });
1019
+
1020
+ const keyDef = stack.registry.getConfigKey(OVERRIDE_KEY);
1021
+ if (!keyDef) throw new Error("key missing");
1022
+ const value = await resolverWithOverride.get(
1023
+ OVERRIDE_KEY,
1024
+ keyDef,
1025
+ // biome-ignore lint/suspicious/noExplicitAny: throwaway TenantId brand
1026
+ freshTenant as any,
1027
+ "00000000-0000-4000-8000-0000000000aa",
1028
+ db,
1029
+ );
1030
+ expect(value).toBe(250);
1031
+ });
1032
+
1033
+ test("tenant-row wins over app-boot-override (admin intent > deploy default)", async () => {
1034
+ // Set a tenant-row first.
1035
+ await stack.http.writeOk(ConfigHandlers.set, { key: OVERRIDE_KEY, value: 77 }, tenantAdmin);
1036
+
1037
+ // Now build a resolver with a different override value.
1038
+ const resolverWithOverride = createConfigResolver({
1039
+ appOverrides: validateAppOverrides(stack.registry, {
1040
+ [OVERRIDE_KEY]: 250,
1041
+ }),
1042
+ });
1043
+
1044
+ const keyDef = stack.registry.getConfigKey(OVERRIDE_KEY);
1045
+ if (!keyDef) throw new Error("key missing");
1046
+ const value = await resolverWithOverride.get(
1047
+ OVERRIDE_KEY,
1048
+ keyDef,
1049
+ tenantAdmin.tenantId,
1050
+ tenantAdmin.id,
1051
+ db,
1052
+ );
1053
+ // Row wins: 77, not 250.
1054
+ expect(value).toBe(77);
1055
+ });
1056
+
1057
+ test("falls back to feature-declared default when no row AND no override", async () => {
1058
+ const resolverNoOverride = createConfigResolver();
1059
+ // Use a fresh tenant id so no tenant-row interferes.
1060
+ const freshTenant = "00000000-0000-4000-8000-000000000999";
1061
+ const keyDef = stack.registry.getConfigKey(OVERRIDE_KEY);
1062
+ if (!keyDef) throw new Error("key missing");
1063
+ const value = await resolverNoOverride.get(
1064
+ OVERRIDE_KEY,
1065
+ keyDef,
1066
+ // biome-ignore lint/suspicious/noExplicitAny: TenantId brand for a throwaway test value
1067
+ freshTenant as any,
1068
+ "00000000-0000-4000-8000-000000000999",
1069
+ db,
1070
+ );
1071
+ expect(value).toBe(100); // keyDef.default
1072
+ });
1073
+ });
1074
+
1075
+ // --- Scenario 9: Computed resolver (plan-based values) ---
1076
+ //
1077
+ // `computed` sits between app-override and default in the cascade. Row
1078
+ // still wins — a tenant-admin SET beats the plan. This matches the
1079
+ // documented "admin intent > deploy default > plan default > hard default"
1080
+ // hierarchy from configuration-layers.md.
1081
+
1082
+ describe("scenario 9: computed resolver", () => {
1083
+ const COMPUTED_KEY = "orders:config:plan-based-quota-gb";
1084
+
1085
+ test("computed returns Pro-plan value for Tenant 2 (endsWith 0002)", async () => {
1086
+ const keyDef = stack.registry.getConfigKey(COMPUTED_KEY);
1087
+ if (!keyDef) throw new Error("key missing");
1088
+ const value = await resolver.get(
1089
+ COMPUTED_KEY,
1090
+ keyDef,
1091
+ otherTenantAdmin.tenantId, // ends in 0002 → Pro
1092
+ otherTenantAdmin.id,
1093
+ db,
1094
+ );
1095
+ expect(value).toBe(500);
1096
+ });
1097
+
1098
+ test("computed returns basic-plan value for Tenant 1 (no Pro suffix)", async () => {
1099
+ const keyDef = stack.registry.getConfigKey(COMPUTED_KEY);
1100
+ if (!keyDef) throw new Error("key missing");
1101
+ // Tenant admin's tenantId doesn't end in 0002 — gets basic.
1102
+ const value = await resolver.get(
1103
+ COMPUTED_KEY,
1104
+ keyDef,
1105
+ tenantAdmin.tenantId,
1106
+ tenantAdmin.id,
1107
+ db,
1108
+ );
1109
+ expect(value).toBe(50);
1110
+ });
1111
+
1112
+ test("row wins over computed — admin SET beats plan-default", async () => {
1113
+ // Tenant admin manually sets a value.
1114
+ await stack.http.writeOk(ConfigHandlers.set, { key: COMPUTED_KEY, value: 999 }, tenantAdmin);
1115
+
1116
+ const keyDef = stack.registry.getConfigKey(COMPUTED_KEY);
1117
+ if (!keyDef) throw new Error("key missing");
1118
+ const value = await resolver.get(
1119
+ COMPUTED_KEY,
1120
+ keyDef,
1121
+ tenantAdmin.tenantId,
1122
+ tenantAdmin.id,
1123
+ db,
1124
+ );
1125
+ // computed would return 50, row says 999 → row wins.
1126
+ expect(value).toBe(999);
1127
+ });
1128
+
1129
+ test("app-override on a computed key is rejected at validation time", async () => {
1130
+ // Post Task-17: combining computed with an app-override silently bypasses
1131
+ // the plan-logic. validateAppOverrides refuses at boot. This test pins
1132
+ // that guarantee — a future relaxation would need to update it explicitly.
1133
+ expect(() =>
1134
+ validateAppOverrides(stack.registry, {
1135
+ [COMPUTED_KEY]: 77,
1136
+ }),
1137
+ ).toThrow(/computed resolver.*app-overrides would silently bypass/i);
1138
+ });
1139
+ });
1140
+
1141
+ // --- Scenario 10: getWithSource — Debug/Ops-Introspection ---
1142
+ //
1143
+ // Same cascade as get() but also reports WHICH layer produced the value.
1144
+ // Ops-tooling needs this for "warum ist mein Wert X?" — debugging a
1145
+ // cascade with 6 possible sources by hand is no fun.
1146
+
1147
+ describe("scenario 10: getWithSource reports source-of-truth", () => {
1148
+ const TENANT_KEY = "orders:config:max-order-count"; // default: 100, scope: tenant
1149
+
1150
+ test("source=tenant-row when a tenant-row exists", async () => {
1151
+ await stack.http.writeOk(ConfigHandlers.set, { key: TENANT_KEY, value: 77 }, tenantAdmin);
1152
+ const keyDef = stack.registry.getConfigKey(TENANT_KEY);
1153
+ if (!keyDef) throw new Error("key missing");
1154
+ const traced = await resolver.getWithSource(
1155
+ TENANT_KEY,
1156
+ keyDef,
1157
+ tenantAdmin.tenantId,
1158
+ tenantAdmin.id,
1159
+ db,
1160
+ );
1161
+ expect(traced.value).toBe(77);
1162
+ expect(traced.source).toBe("tenant-row");
1163
+ });
1164
+
1165
+ test("source=default when no row, no override, no computed", async () => {
1166
+ // Fresh tenant with no row for this key → falls through to keyDef.default.
1167
+ const freshTenant = "00000000-0000-4000-8000-0000000000dd";
1168
+ const keyDef = stack.registry.getConfigKey(TENANT_KEY);
1169
+ if (!keyDef) throw new Error("key missing");
1170
+ const traced = await resolver.getWithSource(
1171
+ TENANT_KEY,
1172
+ keyDef,
1173
+ // biome-ignore lint/suspicious/noExplicitAny: throwaway TenantId brand
1174
+ freshTenant as any,
1175
+ "00000000-0000-4000-8000-0000000000dd",
1176
+ db,
1177
+ );
1178
+ expect(traced.value).toBe(100); // keyDef.default
1179
+ expect(traced.source).toBe("default");
1180
+ });
1181
+
1182
+ test("source=app-override when appOverrides has the key and no row exists", async () => {
1183
+ const resolverWithOverride = createConfigResolver({
1184
+ appOverrides: validateAppOverrides(stack.registry, { [TENANT_KEY]: 333 }),
1185
+ });
1186
+ const freshTenant = "00000000-0000-4000-8000-0000000000ee";
1187
+ const keyDef = stack.registry.getConfigKey(TENANT_KEY);
1188
+ if (!keyDef) throw new Error("key missing");
1189
+ const traced = await resolverWithOverride.getWithSource(
1190
+ TENANT_KEY,
1191
+ keyDef,
1192
+ // biome-ignore lint/suspicious/noExplicitAny: throwaway TenantId brand
1193
+ freshTenant as any,
1194
+ "00000000-0000-4000-8000-0000000000ee",
1195
+ db,
1196
+ );
1197
+ expect(traced.value).toBe(333);
1198
+ expect(traced.source).toBe("app-override");
1199
+ });
1200
+
1201
+ test("source=computed when no row AND no override AND keyDef.computed exists", async () => {
1202
+ const freshTenant = "00000000-0000-4000-8000-0000000000ff";
1203
+ const keyDef = stack.registry.getConfigKey("orders:config:plan-based-quota-gb");
1204
+ if (!keyDef) throw new Error("key missing");
1205
+ const traced = await resolver.getWithSource(
1206
+ "orders:config:plan-based-quota-gb",
1207
+ keyDef,
1208
+ // biome-ignore lint/suspicious/noExplicitAny: throwaway TenantId brand
1209
+ freshTenant as any,
1210
+ "00000000-0000-4000-8000-0000000000ff",
1211
+ db,
1212
+ );
1213
+ expect(traced.source).toBe("computed");
1214
+ expect(traced.value).toBe(50); // basic plan
1215
+ });
1216
+
1217
+ test("source=missing when no row, no override, no computed, no default", async () => {
1218
+ // Key with no default + no row + no override.
1219
+ const noDefaultKey = createTenantConfig("number");
1220
+ const freshTenant = "00000000-0000-4000-8000-000000000011";
1221
+ const traced = await resolver.getWithSource(
1222
+ "throwaway:config:no-default",
1223
+ noDefaultKey,
1224
+ // biome-ignore lint/suspicious/noExplicitAny: throwaway TenantId brand
1225
+ freshTenant as any,
1226
+ "00000000-0000-4000-8000-000000000011",
1227
+ db,
1228
+ );
1229
+ expect(traced.value).toBeUndefined();
1230
+ expect(traced.source).toBe("missing");
1231
+ });
1232
+
1233
+ test("get() and getWithSource() return equivalent values for the same cascade", async () => {
1234
+ const keyDef = stack.registry.getConfigKey(TENANT_KEY);
1235
+ if (!keyDef) throw new Error("key missing");
1236
+ const flat = await resolver.get(TENANT_KEY, keyDef, tenantAdmin.tenantId, tenantAdmin.id, db);
1237
+ const traced = await resolver.getWithSource(
1238
+ TENANT_KEY,
1239
+ keyDef,
1240
+ tenantAdmin.tenantId,
1241
+ tenantAdmin.id,
1242
+ db,
1243
+ );
1244
+ expect(traced.value).toBe(flat);
1245
+ });
1246
+ });