@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,283 @@
1
+ import {
2
+ type DbConnection,
3
+ type EncryptionProvider,
4
+ fetchOne,
5
+ type TenantDb,
6
+ } from "@cosmicdrift/kumiko-framework/db";
7
+ import type {
8
+ ConfigKeyDefinition,
9
+ ConfigResolver,
10
+ ConfigValueSource,
11
+ ConfigValueWithSource,
12
+ } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { assertUnreachable, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
15
+ import { and, eq, isNull, or } from "drizzle-orm";
16
+ import { configValuesTable } from "./table";
17
+
18
+ type ConfigRow = {
19
+ id: string;
20
+ key: string;
21
+ value: string | null;
22
+ tenantId: string;
23
+ userId: string | null;
24
+ };
25
+
26
+ // Re-export so existing call sites that imported ConfigResolver from
27
+ // "../resolver" keep compiling — the shape now lives in the framework.
28
+ export type { ConfigResolver };
29
+
30
+ export function deserializeValue(
31
+ raw: string | null,
32
+ type: ConfigKeyDefinition["type"],
33
+ ): string | number | boolean | undefined {
34
+ if (raw === null || raw === undefined) return undefined;
35
+ const parsed = parseJsonOrThrow<unknown>(raw, `config value (type=${type})`);
36
+ switch (type) {
37
+ case "number":
38
+ return typeof parsed === "number" ? parsed : Number(parsed);
39
+ case "boolean":
40
+ return typeof parsed === "boolean" ? parsed : parsed === "true";
41
+ case "text":
42
+ case "select":
43
+ return String(parsed);
44
+ default:
45
+ assertUnreachable(type, "config key type");
46
+ }
47
+ }
48
+
49
+ // App-Boot overrides: deploy-time defaults that sit between the
50
+ // tenant/system rows and the feature-declared `default`. Use-case: the
51
+ // framework ships a default of 10 MB for maxUploadSizeMB, but this
52
+ // particular deploy is a photo-app that wants 200 MB — no DB-seed needed,
53
+ // just a single Map at boot.
54
+ //
55
+ // Keys are the fully-qualified names (e.g. "files:config:max-upload-size-mb").
56
+ // Values are the raw primitive — serialization happens as part of the
57
+ // cascade so the boot-code doesn't need to know about JSON encoding.
58
+ //
59
+ // Validation at construction time (buildServer-path): unknown keys,
60
+ // type-mismatches, and bounds violations throw synchronously. See
61
+ // validateAppOverrides below.
62
+ export type AppConfigOverrides = ReadonlyMap<string, string | number | boolean>;
63
+
64
+ export type ConfigResolverOptions = {
65
+ encryption?: EncryptionProvider;
66
+ appOverrides?: AppConfigOverrides;
67
+ };
68
+
69
+ export function createConfigResolver(options: ConfigResolverOptions = {}): ConfigResolver {
70
+ const { encryption, appOverrides } = options;
71
+ async function findRow(
72
+ key: string,
73
+ tenantId: string,
74
+ userId: string | null,
75
+ db: DbConnection | TenantDb,
76
+ ): Promise<ConfigRow | null> {
77
+ const userCond =
78
+ userId !== null ? eq(configValuesTable.userId, userId) : isNull(configValuesTable.userId);
79
+
80
+ const row = await fetchOne<ConfigRow>(
81
+ db,
82
+ configValuesTable,
83
+ eq(configValuesTable.key, key),
84
+ eq(configValuesTable.tenantId, tenantId),
85
+ userCond,
86
+ );
87
+
88
+ return row ?? null;
89
+ }
90
+
91
+ return {
92
+ async get(qualifiedKey, keyDef, tenantId, userId, db) {
93
+ // get() is a thin wrapper around getWithSource that discards the
94
+ // source tag. Keeps the hot-path a single implementation.
95
+ const result = await this.getWithSource(qualifiedKey, keyDef, tenantId, userId, db);
96
+ return result.value;
97
+ },
98
+
99
+ async getWithSource(
100
+ qualifiedKey,
101
+ keyDef,
102
+ tenantId,
103
+ userId,
104
+ db,
105
+ ): Promise<ConfigValueWithSource> {
106
+ // Resolution cascade based on scope
107
+ // user: userId+tenantId → tenantId → default
108
+ // tenant: tenantId → SYSTEM_TENANT_ID → default
109
+ // system: SYSTEM_TENANT_ID → default
110
+ const lookups: Array<{
111
+ tenantId: string;
112
+ userId: string | null;
113
+ source: ConfigValueSource;
114
+ }> = [];
115
+
116
+ switch (keyDef.scope) {
117
+ case "user":
118
+ lookups.push({ tenantId, userId, source: "user-row" });
119
+ lookups.push({ tenantId, userId: null, source: "tenant-row" });
120
+ break;
121
+ case "tenant":
122
+ lookups.push({ tenantId, userId: null, source: "tenant-row" });
123
+ lookups.push({ tenantId: SYSTEM_TENANT_ID, userId: null, source: "system-row" });
124
+ break;
125
+ case "system":
126
+ lookups.push({ tenantId: SYSTEM_TENANT_ID, userId: null, source: "system-row" });
127
+ break;
128
+ default:
129
+ assertUnreachable(keyDef.scope, "config scope");
130
+ }
131
+
132
+ for (const lookup of lookups) {
133
+ const row = await findRow(qualifiedKey, lookup.tenantId, lookup.userId, db);
134
+ if (row?.value !== null && row?.value !== undefined) {
135
+ let raw = row.value;
136
+ if (keyDef.encrypted && encryption) {
137
+ raw = encryption.decrypt(raw);
138
+ }
139
+ return { value: deserializeValue(raw, keyDef.type), source: lookup.source };
140
+ }
141
+ }
142
+
143
+ // App-Boot-Override: one step above the feature-declared default.
144
+ // The override only kicks in when no scope-specific row exists —
145
+ // a tenant-admin that deliberately set a value still wins.
146
+ if (appOverrides?.has(qualifiedKey)) {
147
+ return { value: appOverrides.get(qualifiedKey), source: "app-override" };
148
+ }
149
+
150
+ // Computed fallback: plan-based values, feature-flag-Resolver etc.
151
+ // Called after rows + app-overrides miss, before the static default.
152
+ if (keyDef.computed) {
153
+ const value = await keyDef.computed({ tenantId, userId, db });
154
+ return { value, source: "computed" };
155
+ }
156
+
157
+ if (keyDef.default !== undefined) {
158
+ return { value: keyDef.default, source: "default" };
159
+ }
160
+
161
+ return { value: undefined, source: "missing" };
162
+ },
163
+
164
+ async getAll(tenantId, userId, db) {
165
+ // Only load rows relevant to this user/tenant (system + tenant + user scope)
166
+ const rows = await db
167
+ .select()
168
+ .from(configValuesTable)
169
+ .where(
170
+ or(
171
+ // System-level values
172
+ and(eq(configValuesTable.tenantId, SYSTEM_TENANT_ID), isNull(configValuesTable.userId)),
173
+ // Tenant-level values
174
+ and(eq(configValuesTable.tenantId, tenantId), isNull(configValuesTable.userId)),
175
+ // User-level values
176
+ and(eq(configValuesTable.tenantId, tenantId), eq(configValuesTable.userId, userId)),
177
+ ),
178
+ );
179
+
180
+ const result = new Map<string, ConfigRow>();
181
+ for (const row of rows) {
182
+ const r = row as ConfigRow;
183
+ // Higher specificity wins: user > tenant > system. Under the ES
184
+ // schema system rows carry SYSTEM_TENANT_ID instead of NULL, so the
185
+ // "tenant set" check compares against the sentinel rather than null.
186
+ const specificityOf = (candidate: ConfigRow) =>
187
+ (candidate.userId !== null ? 2 : 0) + (candidate.tenantId !== SYSTEM_TENANT_ID ? 1 : 0);
188
+ const existing = result.get(r.key);
189
+ if (!existing || specificityOf(r) > specificityOf(existing)) {
190
+ result.set(r.key, r);
191
+ }
192
+ }
193
+
194
+ return result;
195
+ },
196
+ };
197
+ }
198
+
199
+ // Validates an app-override Map against a registry before the resolver
200
+ // ingests it. Call this from buildServer (or the app's boot wiring) with
201
+ // the registry's config keys + the overrides the app-dev provided.
202
+ //
203
+ // Four classes of errors, all thrown eagerly so a typo in boot-code fails
204
+ // immediately instead of silently returning stale defaults in production:
205
+ // 1. unknown key → feature probably renamed or not required
206
+ // 2. type mismatch → wrong primitive (number for a text key, etc.)
207
+ // 3. bounds / options violation → same rule as tenant-admin Set
208
+ // 4. computed conflict → app-override would silently beat plan-based
209
+ // logic; incompatible paradigm, requires explicit resolution
210
+ //
211
+ // The return is a narrowed Map ready to hand to createConfigResolver.
212
+ export function validateAppOverrides(
213
+ registry: {
214
+ getConfigKey: (
215
+ key: string,
216
+ ) => import("@cosmicdrift/kumiko-framework/engine").ConfigKeyDefinition | undefined;
217
+ },
218
+ overrides: Readonly<Record<string, string | number | boolean>>,
219
+ ): AppConfigOverrides {
220
+ const validated = new Map<string, string | number | boolean>();
221
+
222
+ for (const [key, value] of Object.entries(overrides)) {
223
+ const keyDef = registry.getConfigKey(key);
224
+ if (!keyDef) {
225
+ throw new Error(
226
+ `App-Boot-Override for unknown config key "${key}" — no feature declares it. Typo or missing feature-require?`,
227
+ );
228
+ }
229
+
230
+ // computed keys encode plan-based business logic ("zahlender Tenant
231
+ // bekommt 100 MB"). An app-override would silently beat that — the
232
+ // cascade puts overrides above computed, so the plan becomes invisible.
233
+ // Force an explicit decision: either drop the override and trust the
234
+ // computed function, or drop computed if the deploy really wants a
235
+ // static default for everyone. Mixing silently is a footgun.
236
+ if (keyDef.computed) {
237
+ throw new Error(
238
+ `App-Boot-Override for "${key}": this key has a computed resolver (plan-based / derived). App-overrides would silently bypass that logic — remove the override, or remove the computed resolver if a flat deploy-default is intended.`,
239
+ );
240
+ }
241
+
242
+ // Per keyDef.type narrow value inline — TS narrowt nicht durch
243
+ // `typeof value !== typeForKey(...)` (typeForKey returnt string,
244
+ // kein discriminator). Das vermeidet `value as string|number` casts
245
+ // unten, weil value innerhalb des Branches schon typed ist.
246
+ if (keyDef.type === "number") {
247
+ if (typeof value !== "number") {
248
+ throw new Error(`App-Boot-Override for "${key}": expected number, got ${typeof value}`);
249
+ }
250
+ if (keyDef.bounds) {
251
+ const { min, max } = keyDef.bounds;
252
+ if (min !== undefined && value < min) {
253
+ throw new Error(
254
+ `App-Boot-Override for "${key}": value ${value} is below bounds.min (${min})`,
255
+ );
256
+ }
257
+ if (max !== undefined && value > max) {
258
+ throw new Error(
259
+ `App-Boot-Override for "${key}": value ${value} is above bounds.max (${max})`,
260
+ );
261
+ }
262
+ }
263
+ } else if (keyDef.type === "boolean") {
264
+ if (typeof value !== "boolean") {
265
+ throw new Error(`App-Boot-Override for "${key}": expected boolean, got ${typeof value}`);
266
+ }
267
+ } else {
268
+ // text or select
269
+ if (typeof value !== "string") {
270
+ throw new Error(`App-Boot-Override for "${key}": expected string, got ${typeof value}`);
271
+ }
272
+ if (keyDef.type === "select" && keyDef.options && !keyDef.options.includes(value)) {
273
+ throw new Error(
274
+ `App-Boot-Override for "${key}": value "${value}" is not in options [${keyDef.options.join(", ")}]`,
275
+ );
276
+ }
277
+ }
278
+
279
+ validated.set(key, value);
280
+ }
281
+
282
+ return validated;
283
+ }
@@ -0,0 +1,35 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
3
+
4
+ // Config values are event-sourced. Each (key, scope) is its own aggregate
5
+ // stream — lifecycle events `configValue.created / .updated / .deleted`
6
+ // flow through createEventStoreExecutor, which writes the stream + this
7
+ // projection in one TX. Reads stay O(1) against the projection.
8
+ //
9
+ // System-scope rows use SYSTEM_TENANT_ID (not null) — buildBaseColumns
10
+ // (via buildDrizzleTable) forces tenant_id NOT NULL, so die pre-ES "NULL
11
+ // means system" convention is replaced with a fixed sentinel. Der unique
12
+ // index über (key, tenant_id, user_id) prevent duplicate writes at the DB
13
+ // level — deklariert via entity.indexes.
14
+ //
15
+ // Single-Source-of-Truth: `configValueEntity`. Die DB-Tabelle wird über
16
+ // buildDrizzleTable aus der EntityDefinition abgeleitet, der unique-Index
17
+ // ist via entity.indexes deklariert. Plural-Re-Export `configValuesTable`
18
+ // dient handlers (`reset.write.ts` etc.) als typisierte Drizzle-Table-Ref.
19
+ export const configValueEntity = createEntity({
20
+ table: "read_config_values",
21
+ fields: {
22
+ key: createTextField({ required: true }),
23
+ // value is JSON-encoded primitive (or encrypted blob). Nullable so a
24
+ // deleted-then-recreated stream can signal "reset to default" without
25
+ // breaking the null-vs-missing distinction the resolver already draws.
26
+ value: createTextField({}),
27
+ // user-scope row: userId populated. tenant- / system-scope: null.
28
+ userId: createTextField({}),
29
+ },
30
+ indexes: [
31
+ { unique: true, columns: ["key", "tenantId", "userId"], name: "read_config_values_unique" },
32
+ ],
33
+ });
34
+
35
+ export const configValuesTable = buildDrizzleTable("config-value", configValueEntity);
@@ -0,0 +1,268 @@
1
+ // Shared helpers for the config feature's write + query handlers.
2
+ // Extracted from set.write.ts so reset.write.ts + values.query.ts don't
3
+ // have to cross-import from another handler file.
4
+
5
+ import { type DbConnection, fetchOne, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
6
+ import {
7
+ type ConfigKeyDefinition,
8
+ type ConfigScope,
9
+ ConfigScopes,
10
+ type Registry,
11
+ type SessionUser,
12
+ SYSTEM_ROLE,
13
+ SYSTEM_TENANT_ID,
14
+ type TenantId,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import {
17
+ AccessDeniedError,
18
+ type KumikoError,
19
+ NotFoundError,
20
+ UnprocessableError,
21
+ ValidationError,
22
+ type WriteFailure,
23
+ writeFailure,
24
+ } from "@cosmicdrift/kumiko-framework/errors";
25
+ import { assertUnreachable } from "@cosmicdrift/kumiko-framework/utils";
26
+ import { eq, isNull } from "drizzle-orm";
27
+ import { ConfigErrors } from "./constants";
28
+ import { configValuesTable } from "./table";
29
+
30
+ export type ConfigRowLookup = {
31
+ readonly id: string;
32
+ readonly version: number;
33
+ readonly value: string | null;
34
+ };
35
+
36
+ // Locate an existing config_values row by the (key, tenant, user) triple —
37
+ // the effective natural key. System-scope rows carry SYSTEM_TENANT_ID on
38
+ // the tenant_id column (the post-ES projection is NOT NULL), so callers
39
+ // hand in that sentinel directly instead of null. userId stays nullable
40
+ // because tenant-scope rows have no user.
41
+ export async function findConfigRow(
42
+ db: DbConnection | TenantDb,
43
+ key: string,
44
+ tenantId: TenantId,
45
+ userId: string | null,
46
+ ): Promise<ConfigRowLookup | null> {
47
+ const userCond =
48
+ userId !== null ? eq(configValuesTable.userId, userId) : isNull(configValuesTable.userId);
49
+ const row = await fetchOne<ConfigRowLookup>(
50
+ db,
51
+ configValuesTable,
52
+ eq(configValuesTable.key, key),
53
+ eq(configValuesTable.tenantId, tenantId),
54
+ userCond,
55
+ );
56
+ if (!row) return null;
57
+ return {
58
+ id: row.id,
59
+ version: row.version,
60
+ value: row.value ?? null,
61
+ };
62
+ }
63
+
64
+ // Three-stage pre-write gate that set + reset both need: resolve the key
65
+ // definition (404 when unknown), check the user's roles can write it (403),
66
+ // and map the target scope to the (tenantId | null, userId | null) pair the
67
+ // resolver expects. Handler-specific follow-ups (scope-compat check, value
68
+ // type check) stay inline in the handler that needs them.
69
+ export type PrepareConfigWriteArgs = {
70
+ readonly registry: Registry;
71
+ readonly user: SessionUser;
72
+ readonly key: string;
73
+ // When omitted (or explicit `undefined`), falls back to `keyDef.scope`.
74
+ readonly scope?: ConfigScope | undefined;
75
+ };
76
+
77
+ export type PrepareConfigWriteResult =
78
+ | { readonly ok: false; readonly failure: WriteFailure }
79
+ | {
80
+ readonly ok: true;
81
+ readonly keyDef: ConfigKeyDefinition;
82
+ readonly scope: ConfigScope;
83
+ // Non-null even for system-scope (SYSTEM_TENANT_ID sentinel) — the
84
+ // projection column is NOT NULL and callers should never have to
85
+ // bridge null → sentinel themselves.
86
+ readonly tenantId: TenantId;
87
+ readonly userId: string | null;
88
+ };
89
+
90
+ export function prepareConfigWrite(args: PrepareConfigWriteArgs): PrepareConfigWriteResult {
91
+ const { registry, user, key, scope: requestedScope } = args;
92
+
93
+ const keyDef = registry.getConfigKey(key);
94
+ if (!keyDef) {
95
+ return {
96
+ ok: false,
97
+ failure: writeFailure(
98
+ new NotFoundError("configKey", key, { i18nKey: "config.errors.unknownKey" }),
99
+ ),
100
+ };
101
+ }
102
+
103
+ const writeError = checkWriteAccess(keyDef, user.roles);
104
+ if (writeError) return { ok: false, failure: writeFailure(writeError) };
105
+
106
+ const scope = requestedScope ?? keyDef.scope;
107
+ const { tenantId, userId } = resolveScopeIds(scope, user.tenantId, user.id);
108
+ return { ok: true, keyDef, scope, tenantId, userId };
109
+ }
110
+
111
+ export function hasConfigAccess(
112
+ accessList: readonly string[],
113
+ userRoles: readonly string[],
114
+ ): boolean {
115
+ if (accessList.includes("all")) return true;
116
+ return userRoles.some((role) => accessList.includes(role));
117
+ }
118
+
119
+ export function checkWriteAccess(
120
+ keyDef: ConfigKeyDefinition,
121
+ userRoles: readonly string[],
122
+ ): KumikoError | null {
123
+ if (keyDef.access.write.includes(SYSTEM_ROLE)) {
124
+ // Pre-ES the system-only block was absolute — out-of-band writes went
125
+ // through resolver.set, bypassing the whole access layer. Post-ES
126
+ // every write flows through this handler + executor, so the escape
127
+ // hatch becomes explicit: SYSTEM_ROLE (jobs / seeds / framework-
128
+ // internal work) may write; everyone else is rejected.
129
+ if (userRoles.includes(SYSTEM_ROLE)) return null;
130
+ return new AccessDeniedError({
131
+ message: "config key is system-only",
132
+ i18nKey: "config.errors.systemOnly",
133
+ details: { reason: ConfigErrors.systemOnly },
134
+ });
135
+ }
136
+ if (!hasConfigAccess(keyDef.access.write, userRoles)) {
137
+ return new AccessDeniedError({
138
+ message: "config write access denied",
139
+ details: { requiredRoles: keyDef.access.write },
140
+ });
141
+ }
142
+ return null;
143
+ }
144
+
145
+ export function validateScope(
146
+ requestedScope: ConfigScope,
147
+ definedScope: ConfigScope,
148
+ key: string,
149
+ ): KumikoError | null {
150
+ const levels: Record<ConfigScope, number> = {
151
+ [ConfigScopes.system]: 0,
152
+ [ConfigScopes.tenant]: 1,
153
+ [ConfigScopes.user]: 2,
154
+ };
155
+ if (levels[requestedScope] > levels[definedScope]) {
156
+ return new UnprocessableError("invalid_scope", {
157
+ i18nKey: "config.errors.invalidScope",
158
+ details: { key, definedScope, requestedScope },
159
+ });
160
+ }
161
+ return null;
162
+ }
163
+
164
+ export function resolveScopeIds(
165
+ scope: ConfigScope,
166
+ tenantId: TenantId,
167
+ userId: string,
168
+ ): { tenantId: TenantId; userId: string | null } {
169
+ switch (scope) {
170
+ case ConfigScopes.system:
171
+ return { tenantId: SYSTEM_TENANT_ID, userId: null };
172
+ case ConfigScopes.tenant:
173
+ return { tenantId, userId: null };
174
+ case ConfigScopes.user:
175
+ return { tenantId, userId };
176
+ default:
177
+ assertUnreachable(scope, "config scope");
178
+ }
179
+ }
180
+
181
+ export function validateType(
182
+ value: string | number | boolean,
183
+ keyDef: ConfigKeyDefinition,
184
+ ): KumikoError | null {
185
+ switch (keyDef.type) {
186
+ case "number":
187
+ if (typeof value !== "number") return typeMismatch("number", typeof value);
188
+ break;
189
+ case "boolean":
190
+ if (typeof value !== "boolean") return typeMismatch("boolean", typeof value);
191
+ break;
192
+ case "text":
193
+ if (typeof value !== "string") return typeMismatch("string", typeof value);
194
+ break;
195
+ case "select":
196
+ if (typeof value !== "string") return typeMismatch("string", typeof value);
197
+ if (keyDef.options && !keyDef.options.includes(value)) {
198
+ return new ValidationError({
199
+ fields: [
200
+ {
201
+ path: "value",
202
+ code: "invalid_option",
203
+ i18nKey: "errors.validation.invalid_option",
204
+ params: { value, options: keyDef.options },
205
+ },
206
+ ],
207
+ });
208
+ }
209
+ break;
210
+ default:
211
+ assertUnreachable(keyDef.type, "config key type");
212
+ }
213
+ return null;
214
+ }
215
+
216
+ function typeMismatch(expected: string, actual: string): KumikoError {
217
+ return new ValidationError({
218
+ fields: [
219
+ {
220
+ path: "value",
221
+ code: "invalid_type",
222
+ i18nKey: "errors.validation.invalid_type",
223
+ params: { expected, received: actual },
224
+ },
225
+ ],
226
+ });
227
+ }
228
+
229
+ // Bounds enforcement for numeric config keys. Returns null when OK or when
230
+ // bounds don't apply (non-number key, no bounds declared, or upstream
231
+ // type-validation would already reject non-numeric values).
232
+ export function validateBounds(
233
+ value: string | number | boolean,
234
+ keyDef: ConfigKeyDefinition,
235
+ ): KumikoError | null {
236
+ if (keyDef.type !== "number" || !keyDef.bounds) return null;
237
+ // skip: validateType runs first and catches non-numeric values
238
+ if (typeof value !== "number") return null;
239
+
240
+ const { min, max } = keyDef.bounds;
241
+
242
+ if (min !== undefined && value < min) {
243
+ return new ValidationError({
244
+ fields: [
245
+ {
246
+ path: "value",
247
+ code: "out_of_bounds",
248
+ i18nKey: "errors.validation.out_of_bounds",
249
+ params: { value, min, max: max ?? null },
250
+ },
251
+ ],
252
+ });
253
+ }
254
+ if (max !== undefined && value > max) {
255
+ return new ValidationError({
256
+ fields: [
257
+ {
258
+ path: "value",
259
+ code: "out_of_bounds",
260
+ i18nKey: "errors.validation.out_of_bounds",
261
+ params: { value, min: min ?? null, max },
262
+ },
263
+ ],
264
+ });
265
+ }
266
+
267
+ return null;
268
+ }