@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,21 @@
1
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { tenantEntity, tenantTable } from "../schema/tenant";
5
+
6
+ const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
7
+
8
+ // Optional `id`: SystemAdmin-only handler — legitimer Pfad für Seeds und
9
+ // externe Provisionierung (SCIM, IdP-Sync, Migration aus bestehenden Systemen),
10
+ // wo der Tenant mit einer vom Caller gewählten UUID angelegt werden muss.
11
+ // Wenn nicht gesetzt, Postgres vergibt via gen_random_uuid() eine neue UUID.
12
+ export const createWrite = defineWriteHandler({
13
+ name: "create",
14
+ schema: z.object({
15
+ id: z.uuid().optional(),
16
+ key: z.string().min(1).max(50),
17
+ name: z.string().min(1).max(200),
18
+ }),
19
+ access: { roles: ["SystemAdmin"] },
20
+ handler: async (event, ctx) => crud.create(event.payload, event.user, ctx.db),
21
+ });
@@ -0,0 +1,18 @@
1
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { tenantEntity, tenantTable } from "../schema/tenant";
5
+
6
+ const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
7
+
8
+ export const disableWrite = defineWriteHandler({
9
+ name: "disable",
10
+ schema: z.object({ id: z.uuid() }),
11
+ access: { roles: ["SystemAdmin"] },
12
+ // Admin flip: last-writer-wins is fine. SystemAdmin is the only caller and
13
+ // there's no meaningful concurrent-edit race on this single boolean.
14
+ handler: async (event, ctx) =>
15
+ crud.update({ id: event.payload.id, changes: { isEnabled: false } }, event.user, ctx.db, {
16
+ skipOptimisticLock: true,
17
+ }),
18
+ });
@@ -0,0 +1,31 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { and, eq } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table";
5
+
6
+ // Pending-Invitations-Liste für den aktuellen Tenant. Admin-only.
7
+ // Filter: status="pending" — accepted/cancelled/expired sind für die
8
+ // UI uninteressant (UI zeigt nur "ausstehende Einladungen"; Audit-Log
9
+ // für historische gehört in ein separates Audit-Feature).
10
+ //
11
+ // SQL-side filter (vorher JS-side .filter): bei Tenants mit vielen
12
+ // historischen invitations lädt die Query sonst alle Rows in den
13
+ // Node-process um die meisten wegzuwerfen — DB indexed das auf den
14
+ // (tenantId, …)-key, JS-filter ist redundant.
15
+ export const invitationsQuery = defineQueryHandler({
16
+ name: "invitations",
17
+ schema: z.object({}),
18
+ access: { roles: ["Admin", "SystemAdmin"] },
19
+ handler: async (query, ctx) => {
20
+ const rows = await ctx.db
21
+ ?.select()
22
+ .from(tenantInvitationsTable)
23
+ .where(
24
+ and(
25
+ eq(tenantInvitationsTable.tenantId, query.user.tenantId),
26
+ eq(tenantInvitationsTable.status, INVITATION_STATUS.pending),
27
+ ),
28
+ );
29
+ return rows ?? [];
30
+ },
31
+ });
@@ -0,0 +1,17 @@
1
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { tenantEntity, tenantTable } from "../schema/tenant";
5
+
6
+ const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
7
+
8
+ export const listQuery = defineQueryHandler({
9
+ name: "list",
10
+ schema: z.object({
11
+ cursor: z.string().optional(),
12
+ limit: z.number().optional(),
13
+ search: z.string().optional(),
14
+ }),
15
+ access: { roles: ["SystemAdmin"] },
16
+ handler: async (query, ctx) => crud.list(query.payload, query.user, ctx.db),
17
+ });
@@ -0,0 +1,17 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { tenantTable } from "../schema/tenant";
6
+
7
+ // Direct query — query-handlers haben keinen tenant-crud-Handle. Direct-select
8
+ // ist trivial: WHERE id = tenantId (beides UUID). Kein CRUD-Detour nötig.
9
+ export const meQuery = defineQueryHandler({
10
+ name: "me",
11
+ schema: z.object({}),
12
+ access: { openToAll: true },
13
+ handler: async (query, ctx) => {
14
+ const row = await fetchOne(ctx.db, tenantTable, eq(tenantTable["id"], query.user.tenantId));
15
+ return row ?? null;
16
+ },
17
+ });
@@ -0,0 +1,22 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
3
+ import { eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { tenantMembershipsTable } from "../membership-table";
6
+
7
+ export const membersQuery = defineQueryHandler({
8
+ name: "members",
9
+ schema: z.object({}),
10
+ access: { roles: ["Admin", "SystemAdmin"] },
11
+ handler: async (query, ctx) => {
12
+ const rows = await ctx.db
13
+ ?.select()
14
+ .from(tenantMembershipsTable)
15
+ .where(eq(tenantMembershipsTable.tenantId, query.user.tenantId));
16
+
17
+ return rows.map((row) => ({
18
+ ...row,
19
+ roles: parseRoles(row["roles"]),
20
+ }));
21
+ },
22
+ });
@@ -0,0 +1,24 @@
1
+ import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
3
+ import { eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { tenantMembershipsTable } from "../membership-table";
6
+
7
+ export const membershipsQuery = defineQueryHandler({
8
+ name: "memberships",
9
+ schema: z.object({ userId: z.string() }),
10
+ // Called via ctx.queryAs(systemUser, ...) during login/switch-tenant, or
11
+ // directly by tenant admins managing memberships in the admin UI.
12
+ access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
13
+ handler: async (query, ctx) => {
14
+ const rows = await ctx.db
15
+ ?.select()
16
+ .from(tenantMembershipsTable)
17
+ .where(eq(tenantMembershipsTable.userId, query.payload.userId));
18
+
19
+ return rows.map((row) => ({
20
+ ...row,
21
+ roles: parseRoles(row["roles"]),
22
+ }));
23
+ },
24
+ });
@@ -0,0 +1,40 @@
1
+ import { createEventStoreExecutor, type DbRow, fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler, withResponseData } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
7
+
8
+ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
9
+ entityName: "tenant-membership",
10
+ });
11
+
12
+ export const removeMemberWrite = defineWriteHandler({
13
+ name: "removeMember",
14
+ schema: z.object({ userId: z.string(), tenantId: z.string() }),
15
+ access: { roles: ["SystemAdmin"] },
16
+ handler: async (event, ctx) => {
17
+ const db = ctx.db;
18
+ const existing = await fetchOne(
19
+ db,
20
+ tenantMembershipsTable,
21
+ eq(tenantMembershipsTable.userId, event.payload.userId),
22
+ eq(tenantMembershipsTable.tenantId, event.payload.tenantId),
23
+ );
24
+ if (!existing) {
25
+ return writeFailure(
26
+ new NotFoundError("membership", undefined, {
27
+ i18nKey: "tenant.errors.membershipNotFound",
28
+ i18nParams: { userId: event.payload.userId, tenantId: event.payload.tenantId },
29
+ }),
30
+ );
31
+ }
32
+
33
+ const result = await executor.delete(
34
+ { id: (existing as DbRow)["id"] as string },
35
+ event.user,
36
+ db,
37
+ );
38
+ return withResponseData(result, event.payload);
39
+ },
40
+ });
@@ -0,0 +1,43 @@
1
+ import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { eq } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { tenantMembershipsTable } from "../membership-table";
5
+
6
+ // Cross-feature query: resolve user IDs by tenantId or userId.
7
+ // Other features (delivery, jobs, etc.) use this to get user lists
8
+ // without knowing about membership internals.
9
+ //
10
+ // Examples:
11
+ // { tenantId: 1 } → all user IDs in tenant 1
12
+ // { userId: 5 } → [5] if member of any tenant, [] if not
13
+ export const resolveUserIdsQuery = defineQueryHandler({
14
+ name: "resolveUserIds",
15
+ schema: z.object({
16
+ tenantId: z.string().optional(),
17
+ userId: z.string().optional(),
18
+ }),
19
+ // System-internal: invoked by other features (delivery, jobs) through queryAs(systemUser, ...).
20
+ // Never called directly by an end-user request.
21
+ access: { roles: [SYSTEM_ROLE] },
22
+ handler: async (query, ctx) => {
23
+ const { tenantId, userId } = query.payload;
24
+
25
+ if (tenantId !== undefined) {
26
+ const rows = await ctx.db
27
+ .select({ userId: tenantMembershipsTable.userId })
28
+ .from(tenantMembershipsTable)
29
+ .where(eq(tenantMembershipsTable.tenantId, tenantId));
30
+ return rows.map((r) => r["userId"] as number);
31
+ }
32
+
33
+ if (userId !== undefined) {
34
+ const rows = await ctx.db
35
+ .select({ userId: tenantMembershipsTable.userId })
36
+ .from(tenantMembershipsTable)
37
+ .where(eq(tenantMembershipsTable.userId, userId));
38
+ return rows.length > 0 ? [userId] : [];
39
+ }
40
+
41
+ return [];
42
+ },
43
+ });
@@ -0,0 +1,54 @@
1
+ import { createEventStoreExecutor, type DbRow, fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler, withResponseData } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
7
+
8
+ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
9
+ entityName: "tenant-membership",
10
+ });
11
+
12
+ export const updateMemberRolesWrite = defineWriteHandler({
13
+ name: "updateMemberRoles",
14
+ schema: z.object({
15
+ userId: z.string(),
16
+ tenantId: z.string(),
17
+ roles: z.array(z.string()).min(1),
18
+ }),
19
+ access: { roles: ["SystemAdmin"] },
20
+ handler: async (event, ctx) => {
21
+ const db = ctx.db;
22
+ const existing = await fetchOne(
23
+ db,
24
+ tenantMembershipsTable,
25
+ eq(tenantMembershipsTable.userId, event.payload.userId),
26
+ eq(tenantMembershipsTable.tenantId, event.payload.tenantId),
27
+ );
28
+ if (!existing) {
29
+ return writeFailure(
30
+ new NotFoundError("membership", undefined, {
31
+ i18nKey: "tenant.errors.membershipNotFound",
32
+ i18nParams: { userId: event.payload.userId, tenantId: event.payload.tenantId },
33
+ }),
34
+ );
35
+ }
36
+
37
+ // fetchOne already gave us the stream version — hand it to the executor
38
+ // instead of skipping the lock. Race window (another SystemAdmin writing
39
+ // between this read and append) surfaces as version_conflict rather than
40
+ // silent overwrite. Per-membership parallelism is rare; if it happens,
41
+ // the client retries on the error.
42
+ const row = existing as DbRow;
43
+ const result = await executor.update(
44
+ {
45
+ id: row["id"] as string,
46
+ version: row["version"] as number,
47
+ changes: { roles: JSON.stringify(event.payload.roles) },
48
+ },
49
+ event.user,
50
+ db,
51
+ );
52
+ return withResponseData(result, event.payload);
53
+ },
54
+ });
@@ -0,0 +1,20 @@
1
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { tenantEntity, tenantTable } from "../schema/tenant";
5
+
6
+ const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
7
+
8
+ export const updateWrite = defineWriteHandler({
9
+ name: "update",
10
+ schema: z.object({
11
+ id: z.uuid(),
12
+ // Clients must send the version they read. The CrudExecutor rejects
13
+ // missing versions with version_conflict — see the optimistic-locking
14
+ // design note in crud-executor.ts.
15
+ version: z.number(),
16
+ changes: z.object({ name: z.string().min(1).max(200).optional() }),
17
+ }),
18
+ access: { roles: ["Admin", "SystemAdmin"] },
19
+ handler: async (event, ctx) => crud.update(event.payload, event.user, ctx.db),
20
+ });
@@ -0,0 +1,12 @@
1
+ export { TenantCommandSchemas } from "./command-schemas";
2
+ export { TENANT_FEATURE, TenantErrors, TenantHandlers, TenantQueries } from "./constants";
3
+ export { createTenantFeature } from "./feature";
4
+ export type { InvitationStatus } from "./invitation-table";
5
+ export {
6
+ INVITATION_STATUS,
7
+ INVITATION_STATUSES,
8
+ tenantInvitationEntity,
9
+ tenantInvitationsTable,
10
+ } from "./invitation-table";
11
+ export { tenantMembershipsTable } from "./membership-table";
12
+ export { tenantEntity, tenantTable } from "./schema/tenant";
@@ -0,0 +1,93 @@
1
+ // Tenant-Invitations: Pre-Membership-Records mit Magic-Link-Token-Flow.
2
+ //
3
+ // Lifecycle:
4
+ // 1. Admin invitet email → DB-Row entsteht mit status="pending",
5
+ // Random-Token in Redis (signup-style bidirektional)
6
+ // 2. Mail an die invited Email mit Activation-URL
7
+ // 3. Klick auf Link → 3 Branches:
8
+ // a) Eingeloggt + Email matched session-user → Membership-Add
9
+ // b) Anonymous + Email existiert in users → Login → Auto-Accept
10
+ // c) Anonymous + Email neu → Password setzen → user+membership entstehen
11
+ // 4. Bei Erfolg: status="accepted", token aus Redis gelöscht (single-use-burn)
12
+ // 5. Bei Cancel durch Admin: status="cancelled", token aus Redis gelöscht
13
+ // 6. Bei TTL-Ablauf: Redis räumt Token, DB-Row bleibt mit status="pending"
14
+ // (Cleanup-Job marked sie als "expired" — separater Concern)
15
+ //
16
+ // Single-Truth für expiry: Redis-TTL. DB-row.expiresAt ist nur UI-
17
+ // Anzeige ("läuft in 6 Tagen ab"). Bei Lookup: pending in DB + token
18
+ // nicht mehr in Redis → effectively expired, accept schlägt fehl mit
19
+ // invalid-token.
20
+ //
21
+ // Idempotenz: zweiter invite für gleiche (tenantId, email) während
22
+ // pending → re-use existing row + refresh Redis-token + send mail
23
+ // (analog zu signup-Resend).
24
+
25
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
26
+ import {
27
+ createEntity,
28
+ createSelectField,
29
+ createTextField,
30
+ createTimestampField,
31
+ } from "@cosmicdrift/kumiko-framework/engine";
32
+
33
+ // Status-const-Object damit Handler-Code keine Magic-Strings nutzt.
34
+ // Bei rename (z.B. "cancelled" → "revoked") fällt jeder caller auf
35
+ // einmal auf statt verstreut über 5 Stellen.
36
+ export const INVITATION_STATUS = {
37
+ pending: "pending",
38
+ accepted: "accepted",
39
+ cancelled: "cancelled",
40
+ expired: "expired",
41
+ } as const;
42
+ export type InvitationStatus = (typeof INVITATION_STATUS)[keyof typeof INVITATION_STATUS];
43
+
44
+ // Order MUSS bit-identisch zur DB-Migration sein. Object.values
45
+ // bewahrt insertion-order (JS-spec-stable für string-keys). Wenn
46
+ // jemand INVITATION_STATUS reordnet, generiert drizzle-kit eine
47
+ // neue Migration. Hardcoded-Tuple zur Sicherheit gegen versehentliches
48
+ // Refactoring der Object-Keys.
49
+ export const INVITATION_STATUSES = [
50
+ INVITATION_STATUS.pending,
51
+ INVITATION_STATUS.accepted,
52
+ INVITATION_STATUS.cancelled,
53
+ INVITATION_STATUS.expired,
54
+ ] as const;
55
+
56
+ export const tenantInvitationEntity = createEntity({
57
+ table: "read_tenant_invitations",
58
+ fields: {
59
+ // Eingeladene Email — case-insensitive normalisiert beim Insert.
60
+ email: createTextField({ required: true, maxLength: 320 }),
61
+ // Membership-Rolle die dem User nach Accept gegeben wird. Default
62
+ // im handler ist "Admin" (Co-Admin-Pattern für kleine Teams).
63
+ role: createTextField({ required: true, maxLength: 50 }),
64
+ // Lifecycle-State. Default "pending"; transitions:
65
+ // pending → accepted | cancelled | expired
66
+ status: createSelectField({
67
+ options: INVITATION_STATUSES,
68
+ required: true,
69
+ default: "pending",
70
+ }),
71
+ // userId des einladenden Admins (für Audit-Trail "wer hat eingeladen").
72
+ invitedBy: createTextField({ required: true }),
73
+ // UI-Anzeige — Wahrheit liegt in Redis-TTL.
74
+ expiresAt: createTimestampField({ required: true }),
75
+ },
76
+ // Eine Invitation-Row pro (tenantId, email). Bei Re-Invite (Admin
77
+ // invitet zweite Mal nach Cancel/Accept) wird die existing row
78
+ // updated: status pending → cancelled → pending zurück, expiresAt
79
+ // refreshed. Verhindert Token-Doppel-Gabe + macht Resend-Idempotenz
80
+ // im handler trivial.
81
+ indexes: [
82
+ {
83
+ unique: true,
84
+ columns: ["tenantId", "email"],
85
+ name: "read_tenant_invitations_tenant_email_unique",
86
+ },
87
+ ],
88
+ });
89
+
90
+ export const tenantInvitationsTable = buildDrizzleTable(
91
+ "tenant-invitation",
92
+ tenantInvitationEntity,
93
+ );
@@ -0,0 +1,35 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
3
+
4
+ // Membership is event-sourced. Each (userId, tenantId) pair is its own
5
+ // aggregate stream — lifecycle events `tenantMembership.created /
6
+ // .updated / .deleted` flow through createEventStoreExecutor, which writes
7
+ // the stream + this projection in one TX. Queries read straight from the
8
+ // projection.
9
+ //
10
+ // UUID PK is mandatory for the event-store (aggregateId is uuid). The
11
+ // unique index on (userId, tenantId) stays — it was the effective PK under
12
+ // the old serial-id design and keeps duplicate-write protection at the
13
+ // database level independent of the handler lookup.
14
+ //
15
+ // Single-Source-of-Truth: `tenantMembershipEntity`. Die DB-Tabelle wird
16
+ // aus der EntityDefinition über buildDrizzleTable abgeleitet, der
17
+ // unique-Index ist via entity.indexes deklariert.
18
+ export const tenantMembershipEntity = createEntity({
19
+ table: "read_tenant_memberships",
20
+ fields: {
21
+ userId: createTextField({ required: true }),
22
+ // JSON-encoded string[] — parseRoles() deserializes at read time.
23
+ // Mirrors how roles were stored under the pre-ES row model so the
24
+ // read-side stays byte-compatible and no MSP/consumer needs rewrites.
25
+ roles: createTextField({ required: true }),
26
+ },
27
+ indexes: [
28
+ { unique: true, columns: ["userId", "tenantId"], name: "read_tenant_memberships_unique" },
29
+ ],
30
+ });
31
+
32
+ export const tenantMembershipsTable = buildDrizzleTable(
33
+ "tenant-membership",
34
+ tenantMembershipEntity,
35
+ );
@@ -0,0 +1,5 @@
1
+ // Re-exports aus den schema/<entity>.ts Files. Eine Datei pro Entity
2
+ // — wenn das Feature später weitere Entities hinzubekommt, kommen sie
3
+ // als zusätzliche Datei rein und werden hier exportiert.
4
+
5
+ export * from "./tenant";
@@ -0,0 +1,27 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createBooleanField,
4
+ createEntity,
5
+ createTextField,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ export const tenantEntity = createEntity({
9
+ table: "read_tenants",
10
+ // tenant.id IS the tenantId-value that every other table references as FK.
11
+ // Alle tenantId-Spalten sind UUID (Migration 2026-04-16) → tenant.id muss
12
+ // UUID sein, sonst findet der tenants-Lookup nie. Default gen_random_uuid().
13
+ fields: {
14
+ key: createTextField({ required: true, maxLength: 50 }),
15
+ name: createTextField({ required: true, maxLength: 200, searchable: true }),
16
+ isEnabled: createBooleanField({ default: true }),
17
+ },
18
+ // tenant.key wird in Admin-URLs verwendet (`admin.<host>/<key>/...`) und
19
+ // muss eindeutig sein. Ohne unique-constraint hätte ein konkurrenter
20
+ // Self-Signup-Confirm einen TOCTOU-Race zwischen generateUniqueName-
21
+ // isAvailable-check und insert: zwei Tabs könnten sequentiell denselben
22
+ // Slug claimen, beide commits durch, der dritte User landet auf einem
23
+ // shared admin-URL-prefix.
24
+ indexes: [{ unique: true, columns: ["key"] }],
25
+ });
26
+
27
+ export const tenantTable = buildDrizzleTable("tenant", tenantEntity);
@@ -0,0 +1,155 @@
1
+ // Testing helpers for the tenant feature. `seedTenantMembership` replaces
2
+ // the pre-ES pattern of `db.insert(tenantMembershipsTable).values({...})`
3
+ // in test fixtures — a direct-write bypasses the event-store executor, so
4
+ // seeded memberships have no stream, no `.created` event, and projections
5
+ // that consume membership events stay empty.
6
+ //
7
+ // The helper runs through the executor (same TX-semantics as the
8
+ // add-member handler), which means fixtures are event-sourced end-to-end:
9
+ // - events table gets a `tenantMembership.created` row
10
+ // - projection row (tenant_memberships) is written in the same TX
11
+ // - consumers (MSPs, audit) see the event just like a real call would
12
+ //
13
+ // Why this lives in bundled-features/tenant/testing rather than
14
+ // framework/testing: the helper closes over `tenantMembershipEntity` +
15
+ // `tenantMembershipsTable`, both owned by this feature. framework/testing
16
+ // stays shape-independent.
17
+ //
18
+ // Why not "just call the addMember handler via stack.http.writeOk":
19
+ // 1. Handler requires SystemAdmin — test fixtures often seed OTHER users
20
+ // before any admin exists, so the handler would 403.
21
+ // 2. Handler goes through HTTP → JWT mint → dispatcher. Overhead for
22
+ // fixture state-setup that the test doesn't exercise.
23
+ // The executor path skips access-checks by design (no HTTP, no JWT — this
24
+ // IS a test fixture, not a user request) while still producing the
25
+ // correct event + projection.
26
+ //
27
+ // Idempotent: calling twice for the same (userId, tenantId) is a no-op on
28
+ // the second call. Test fixtures that seed the same membership across
29
+ // `beforeEach` runs don't need explicit cleanup. A real `addMember` handler
30
+ // returns ConflictError on duplicates — that's the user-facing contract.
31
+ // Fixture-seeding prioritises "make the state exist" over "detect duplicate
32
+ // seeding", which is usually a test-author bug we don't need to surface.
33
+
34
+ import {
35
+ createEventStoreExecutor,
36
+ createTenantDb,
37
+ type DbConnection,
38
+ fetchOne,
39
+ } from "@cosmicdrift/kumiko-framework/db";
40
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
41
+ import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
42
+ import { eq } from "drizzle-orm";
43
+ import { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table";
44
+ import { tenantEntity, tenantTable } from "./schema/tenant";
45
+
46
+ const tenantExecutor = createEventStoreExecutor(tenantTable, tenantEntity, {
47
+ entityName: "tenant",
48
+ });
49
+
50
+ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
51
+ entityName: "tenant-membership",
52
+ });
53
+
54
+ export type SeedTenantMembershipOptions = {
55
+ readonly userId: string;
56
+ readonly tenantId: TenantId;
57
+ readonly roles: readonly string[];
58
+ /**
59
+ * SessionUser to bill the event against (goes into event.metadata.userId +
60
+ * the projection's inserted_by_id column). Defaults to TestUsers.systemAdmin
61
+ * — mirrors the real call-path, where add-member is SystemAdmin-only.
62
+ */
63
+ readonly by?: SessionUser;
64
+ };
65
+
66
+ export type SeedTenantOptions = {
67
+ /** Stable UUID — required for fixtures so the FE/BE können dieselbe ID
68
+ * hardcoden (Sample-Switcher zeigt den Tenant beim Namen, der Test
69
+ * prüft Memberships gegen exakt diese ID). Ohne ID müsste der Caller
70
+ * den lookup-by-key extra machen. */
71
+ readonly id: TenantId;
72
+ /** URL-/Slug-Form (z.B. "dev", "acme"). Indexed unique in der DB. */
73
+ readonly key: string;
74
+ /** Human-readable label (im Switcher angezeigt). */
75
+ readonly name: string;
76
+ readonly by?: SessionUser;
77
+ };
78
+
79
+ /**
80
+ * Seed a tenant through the event-store executor. Idempotent: a second
81
+ * call for the same `id` is a no-op. Same TX-semantics as the real
82
+ * `TenantHandlers.create`, minus the SystemAdmin-access-check and minus
83
+ * ConflictError-on-duplicate.
84
+ */
85
+ export async function seedTenant(db: DbConnection, options: SeedTenantOptions): Promise<TenantId> {
86
+ const by = options.by ?? TestUsers.systemAdmin;
87
+ // executor.create erwartet eine TenantDb (mit .insert()-API), nicht
88
+ // die rohe DbConnection. Auch wenn das Tenant-Aggregat selbst NICHT
89
+ // tenant-scoped ist, braucht der Wrap-Layer für die runtime-API zu
90
+ // existieren. by.tenantId reicht — keine Override-Semantik wie bei
91
+ // seedTenantMembership nötig.
92
+ const tdb = createTenantDb(db, by.tenantId, "system");
93
+
94
+ const existing = await fetchOne(db, tenantTable, eq(tenantTable["id"], options.id));
95
+ if (existing) return options.id;
96
+
97
+ const result = await tenantExecutor.create(
98
+ { id: options.id, key: options.key, name: options.name },
99
+ by,
100
+ tdb,
101
+ );
102
+ if (!result.isSuccess) {
103
+ throw new Error(
104
+ `seedTenant failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
105
+ );
106
+ }
107
+ return options.id;
108
+ }
109
+
110
+ /**
111
+ * Seed a tenant membership through the event-store executor. Writes
112
+ * both a `tenantMembership.created` event and the corresponding
113
+ * projection row in one transaction — identical effect to
114
+ * `TenantHandlers.addMember`, minus the access-check and minus the
115
+ * ConflictError on duplicates (duplicate calls no-op).
116
+ */
117
+ export async function seedTenantMembership(
118
+ db: DbConnection,
119
+ options: SeedTenantMembershipOptions,
120
+ ): Promise<void> {
121
+ const by = options.by ?? TestUsers.systemAdmin;
122
+ // Wrap into a system-scoped TenantDb so the insert respects the tenant-
123
+ // override (we write into options.tenantId, which may differ from by.tenantId).
124
+ const tdb = createTenantDb(db, by.tenantId, "system");
125
+
126
+ // Idempotency: duplicate seeds are common across beforeEach-resets where
127
+ // only certain tables get truncated. A plain executor.create would trip
128
+ // the (user_id, tenant_id) unique index; the fixture call-site would then
129
+ // have to juggle try/catch. Lookup-first keeps call-sites clean.
130
+ const existing = await fetchOne(
131
+ db,
132
+ tenantMembershipsTable,
133
+ eq(tenantMembershipsTable.userId, options.userId),
134
+ eq(tenantMembershipsTable.tenantId, options.tenantId),
135
+ );
136
+ // skip: idempotent no-op — duplicate seed is expected across beforeEach-
137
+ // resets that don't truncate this table. Cheaper than try/catch on the
138
+ // unique-index, and documented in the function JSDoc above.
139
+ if (existing) return;
140
+
141
+ const result = await executor.create(
142
+ {
143
+ userId: options.userId,
144
+ tenantId: options.tenantId,
145
+ roles: JSON.stringify(options.roles),
146
+ },
147
+ by,
148
+ tdb,
149
+ );
150
+ if (!result.isSuccess) {
151
+ throw new Error(
152
+ `seedTenantMembership failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
153
+ );
154
+ }
155
+ }
@@ -0,0 +1,8 @@
1
+ // /testing re-exportiert /seeding. Ehemalige Heimat der seed-Helpers,
2
+ // jetzt nur noch Aggregation. Die Helpers leben in `/seeding` weil sie
3
+ // genauso vom Dev-Server-Bootstrap (runDevApp) konsumiert werden — nicht
4
+ // nur von Tests. Vertrag der Helpers ist stabil, test-spezifische
5
+ // Knöpfe gehören NICHT hier rein (würde dev-boots brechen wenn jemand
6
+ // einen lockout-test-Knopf einbaut).
7
+
8
+ export * from "./seeding";