@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,347 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
+ import {
5
+ createEntityTable,
6
+ createTestUser,
7
+ pushTables,
8
+ setupTestStack,
9
+ type TestStack,
10
+ TestUsers,
11
+ } from "@cosmicdrift/kumiko-framework/stack";
12
+ import { expectErrorIncludes, rolesOf } from "@cosmicdrift/kumiko-framework/testing";
13
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
14
+ import { createConfigAccessor, createConfigFeature } from "../../config";
15
+ import { ConfigHandlers, ConfigQueries } from "../../config/constants";
16
+ import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
17
+ import { configValuesTable } from "../../config/table";
18
+ import { TenantHandlers, TenantQueries } from "../constants";
19
+ import { createTenantFeature } from "../feature";
20
+ import { tenantEntity } from "../schema/tenant";
21
+
22
+ // --- Setup ---
23
+
24
+ let stack: TestStack;
25
+ let db: DbConnection;
26
+ let resolver: ConfigResolver;
27
+
28
+ const systemAdmin = TestUsers.systemAdmin;
29
+ const tenantAdmin = createTestUser({ id: 2 });
30
+
31
+ const configFeature = createConfigFeature();
32
+ const tenantFeature = createTenantFeature();
33
+ const testEncryptionKey = randomBytes(32).toString("base64");
34
+
35
+ beforeAll(async () => {
36
+ const encryption = createEncryptionProvider(testEncryptionKey);
37
+ resolver = createConfigResolver({ encryption });
38
+
39
+ stack = await setupTestStack({
40
+ features: [configFeature, tenantFeature],
41
+ extraContext: { configResolver: resolver, configEncryption: encryption },
42
+ });
43
+ db = stack.db;
44
+
45
+ await createEntityTable(db, tenantEntity);
46
+ await pushTables(db, { configValuesTable });
47
+ await createEventsTable(db);
48
+ });
49
+
50
+ afterAll(async () => {
51
+ await stack.cleanup();
52
+ });
53
+
54
+ // --- Scenario 1: SystemAdmin erstellt Tenant ---
55
+
56
+ describe("scenario 1: tenant.create", () => {
57
+ test("SystemAdmin can create a tenant", async () => {
58
+ // Explicit id so the following scenarios (tenant.me, update, disable) run
59
+ // against THIS tenant — systemAdmin.tenantId is a fixed UUID in tests.
60
+ const data = await stack.http.writeOk(
61
+ TenantHandlers.create,
62
+ { id: systemAdmin.tenantId, key: "acme", name: "ACME Corp" },
63
+ systemAdmin,
64
+ );
65
+ expect(data!["data"]).toMatchObject({
66
+ key: "acme",
67
+ name: "ACME Corp",
68
+ isEnabled: true,
69
+ });
70
+ expect(data!["isNew"]).toBe(true);
71
+ });
72
+
73
+ test("normal User cannot create a tenant", async () => {
74
+ const error = await stack.http.writeErr(
75
+ TenantHandlers.create,
76
+ { key: "hacked", name: "Hacked" },
77
+ tenantAdmin,
78
+ );
79
+ expectErrorIncludes(error, "access_denied");
80
+ });
81
+ });
82
+
83
+ // --- Scenario 2: tenant.me ---
84
+
85
+ describe("scenario 2: tenant.me", () => {
86
+ test("returns the current user's tenant", async () => {
87
+ const tenant = await stack.http.queryOk<Record<string, unknown>>(
88
+ TenantQueries.me,
89
+ {},
90
+ systemAdmin,
91
+ );
92
+ expect(tenant["key"]).toBe("acme");
93
+ expect(tenant["name"]).toBe("ACME Corp");
94
+ });
95
+
96
+ test("returns null for non-existent tenant", async () => {
97
+ const otherUser = createTestUser({ id: 99, tenantId: "00000000-0000-4000-8000-000000000999" });
98
+ const result = await stack.http.queryOk(TenantQueries.me, {}, otherUser);
99
+ expect(result).toBeNull();
100
+ });
101
+ });
102
+
103
+ // --- Scenario 3: Admin updates Tenant-Stammdaten ---
104
+
105
+ describe("scenario 3: tenant.update", () => {
106
+ test("Admin can update tenant name", async () => {
107
+ const me = await stack.http.queryOk<Record<string, unknown>>(TenantQueries.me, {}, systemAdmin);
108
+ const tenantId = me["id"] as string;
109
+
110
+ const data = await stack.http.writeOk(
111
+ TenantHandlers.update,
112
+ { id: tenantId, changes: { name: "ACME Corporation" }, version: 1 },
113
+ tenantAdmin,
114
+ );
115
+ expect(data!["data"]).toMatchObject({
116
+ name: "ACME Corporation",
117
+ });
118
+ expect(data!["changes"]).toEqual({ name: "ACME Corporation" });
119
+ expect(data!["isNew"]).toBe(false);
120
+ });
121
+
122
+ test("update handler requires Admin or SystemAdmin role", async () => {
123
+ expect(rolesOf(stack.registry.getWriteHandler(TenantHandlers.update)?.access)).toEqual([
124
+ "Admin",
125
+ "SystemAdmin",
126
+ ]);
127
+ });
128
+ });
129
+
130
+ // --- Scenario 4: SystemAdmin disables Tenant ---
131
+
132
+ describe("scenario 4: tenant.disable", () => {
133
+ test("SystemAdmin can disable a tenant", async () => {
134
+ const me = await stack.http.queryOk<Record<string, unknown>>(TenantQueries.me, {}, systemAdmin);
135
+ const tenantId = me["id"] as string;
136
+
137
+ const data = await stack.http.writeOk(TenantHandlers.disable, { id: tenantId }, systemAdmin);
138
+ expect(data!["data"]).toMatchObject({
139
+ isEnabled: false,
140
+ });
141
+ });
142
+
143
+ test("disable handler requires SystemAdmin role", async () => {
144
+ expect(rolesOf(stack.registry.getWriteHandler(TenantHandlers.disable)?.access)).toEqual([
145
+ "SystemAdmin",
146
+ ]);
147
+ });
148
+ });
149
+
150
+ // --- Scenario 5: tenant.list ---
151
+
152
+ describe("scenario 5: tenant.list", () => {
153
+ test("returns all tenants", async () => {
154
+ await stack.http.writeOk(TenantHandlers.create, { key: "beta", name: "Beta Inc" }, systemAdmin);
155
+
156
+ const result = await stack.http.queryOk<{
157
+ rows: Record<string, unknown>[];
158
+ nextCursor: string | null;
159
+ }>(TenantQueries.list, {}, systemAdmin);
160
+
161
+ expect(result.rows.length).toBeGreaterThanOrEqual(2);
162
+ const keys = result.rows.map((r) => r["key"]);
163
+ expect(keys).toContain("acme");
164
+ expect(keys).toContain("beta");
165
+ });
166
+
167
+ test("list handler requires SystemAdmin role", async () => {
168
+ expect(rolesOf(stack.registry.getQueryHandler(TenantQueries.list)?.access)).toEqual([
169
+ "SystemAdmin",
170
+ ]);
171
+ });
172
+ });
173
+
174
+ // --- Scenario 6: Config integration ---
175
+
176
+ describe("scenario 6: config integration with tenant", () => {
177
+ test("SystemAdmin sets smtpHost for tenant", async () => {
178
+ await stack.http.writeOk(
179
+ ConfigHandlers.set,
180
+ { key: "tenant:config:smtp-host", value: "smtp.acme.com" },
181
+ systemAdmin,
182
+ );
183
+
184
+ const configFn = createConfigAccessor(
185
+ stack.registry,
186
+ resolver,
187
+ "00000000-0000-4000-8000-000000000001",
188
+ systemAdmin.id,
189
+ db,
190
+ );
191
+ expect(await configFn("tenant:config:smtp-host")).toBe("smtp.acme.com");
192
+ });
193
+
194
+ test("Admin cannot set smtpHost (only SystemAdmin)", async () => {
195
+ const error = await stack.http.writeErr(
196
+ ConfigHandlers.set,
197
+ { key: "tenant:config:smtp-host", value: "smtp.hacked.com" },
198
+ tenantAdmin,
199
+ );
200
+ expectErrorIncludes(error, "access_denied");
201
+ });
202
+
203
+ test("smtpPass is encrypted and accessible via ctx.config()", async () => {
204
+ await stack.http.writeOk(
205
+ ConfigHandlers.set,
206
+ { key: "tenant:config:smtp-pass", value: "super-secret-pw" },
207
+ systemAdmin,
208
+ );
209
+
210
+ const configFn = createConfigAccessor(
211
+ stack.registry,
212
+ resolver,
213
+ "00000000-0000-4000-8000-000000000001",
214
+ systemAdmin.id,
215
+ db,
216
+ );
217
+ expect(await configFn("tenant:config:smtp-pass")).toBe("super-secret-pw");
218
+
219
+ // Admin cannot see smtpPass in config.values (no read access)
220
+ const values = await stack.http.queryOk<Record<string, unknown>>(
221
+ ConfigQueries.values,
222
+ {},
223
+ tenantAdmin,
224
+ );
225
+ expect(values["tenant:config:smtp-pass"]).toBeUndefined();
226
+ });
227
+
228
+ test("maxUsers is system-only, returns default 50", async () => {
229
+ const configFn = createConfigAccessor(
230
+ stack.registry,
231
+ resolver,
232
+ "00000000-0000-4000-8000-000000000001",
233
+ tenantAdmin.id,
234
+ db,
235
+ );
236
+ expect(await configFn("tenant:config:max-users")).toBe(50);
237
+ });
238
+
239
+ test("maxUsers cannot be set by Admin (system-only)", async () => {
240
+ const error = await stack.http.writeErr(
241
+ ConfigHandlers.set,
242
+ { key: "tenant:config:max-users", value: 100 },
243
+ tenantAdmin,
244
+ );
245
+ expectErrorIncludes(error, "config_key_is_system_only");
246
+ });
247
+
248
+ test("maxUsers cannot be set by SystemAdmin either (system-only)", async () => {
249
+ const error = await stack.http.writeErr(
250
+ ConfigHandlers.set,
251
+ { key: "tenant:config:max-users", value: 100 },
252
+ systemAdmin,
253
+ );
254
+ expectErrorIncludes(error, "config_key_is_system_only");
255
+ });
256
+
257
+ test("companyName: Tenant-Admin sets, all read", async () => {
258
+ await stack.http.writeOk(
259
+ ConfigHandlers.set,
260
+ { key: "tenant:config:company-name", value: "ACME GmbH" },
261
+ tenantAdmin,
262
+ );
263
+ const configFn = createConfigAccessor(
264
+ stack.registry,
265
+ resolver,
266
+ "00000000-0000-4000-8000-000000000001",
267
+ tenantAdmin.id,
268
+ db,
269
+ );
270
+ expect(await configFn("tenant:config:company-name")).toBe("ACME GmbH");
271
+ });
272
+
273
+ test("locale: select with valid option succeeds, invalid option rejected", async () => {
274
+ await stack.http.writeOk(
275
+ ConfigHandlers.set,
276
+ { key: "tenant:config:locale", value: "fr" },
277
+ tenantAdmin,
278
+ );
279
+ const configFn = createConfigAccessor(
280
+ stack.registry,
281
+ resolver,
282
+ "00000000-0000-4000-8000-000000000001",
283
+ tenantAdmin.id,
284
+ db,
285
+ );
286
+ expect(await configFn("tenant:config:locale")).toBe("fr");
287
+
288
+ const invalid = await stack.http.writeErr(
289
+ ConfigHandlers.set,
290
+ { key: "tenant:config:locale", value: "klingon" },
291
+ tenantAdmin,
292
+ );
293
+ expectErrorIncludes(invalid, "invalid_option");
294
+ });
295
+
296
+ test("timezone: defaults to Europe/Berlin when unset", async () => {
297
+ const configFn = createConfigAccessor(
298
+ stack.registry,
299
+ resolver,
300
+ "00000000-0000-4000-8000-000000000001",
301
+ tenantAdmin.id,
302
+ db,
303
+ );
304
+ expect(await configFn("tenant:config:timezone")).toBe("Europe/Berlin");
305
+ });
306
+
307
+ test("priceModel: system-only, defaults to basic, Admin cannot override", async () => {
308
+ const configFn = createConfigAccessor(
309
+ stack.registry,
310
+ resolver,
311
+ "00000000-0000-4000-8000-000000000001",
312
+ tenantAdmin.id,
313
+ db,
314
+ );
315
+ expect(await configFn("tenant:config:price-model")).toBe("basic");
316
+
317
+ const error = await stack.http.writeErr(
318
+ ConfigHandlers.set,
319
+ { key: "tenant:config:price-model", value: "pro" },
320
+ tenantAdmin,
321
+ );
322
+ expectErrorIncludes(error, "config_key_is_system_only");
323
+ });
324
+ });
325
+
326
+ // --- Scenario 7: Access denial ---
327
+
328
+ describe("scenario 7: access rules on handlers", () => {
329
+ test("all handlers have correct access rules", async () => {
330
+ expect(rolesOf(stack.registry.getWriteHandler(TenantHandlers.create)?.access)).toEqual([
331
+ "SystemAdmin",
332
+ ]);
333
+ expect(rolesOf(stack.registry.getWriteHandler(TenantHandlers.update)?.access)).toEqual([
334
+ "Admin",
335
+ "SystemAdmin",
336
+ ]);
337
+ expect(rolesOf(stack.registry.getWriteHandler(TenantHandlers.disable)?.access)).toEqual([
338
+ "SystemAdmin",
339
+ ]);
340
+ expect(rolesOf(stack.registry.getQueryHandler(TenantQueries.list)?.access)).toEqual([
341
+ "SystemAdmin",
342
+ ]);
343
+ expect(stack.registry.getQueryHandler(TenantQueries.me)?.access).toEqual({
344
+ openToAll: true,
345
+ });
346
+ });
347
+ });
@@ -0,0 +1,37 @@
1
+ // Command-input schemas for the tenant write handlers, re-exposed for
2
+ // external consumers — primarily migration mappers that need to write events
3
+ // directly into core streams via `eventStore.appendRaw` (Marten-bypass) and
4
+ // must validate their payloads against the exact handler contract.
5
+ //
6
+ // IMPORTANT — schema vs. event payload:
7
+ // The schema below describes the **command input** (what `dispatch()` accepts).
8
+ // The actual event payload that lands in the `events` table is derived from
9
+ // it by the CrudExecutor (see `db/event-store-executor.ts`):
10
+ // - `tenant.created` payload = command minus the optional `id` field,
11
+ // `applyDefaults` applied, sensitive fields
12
+ // stripped, compound types flattened
13
+ // (locatedTimestamp → 2 cols, money → cents).
14
+ // - `tenant.updated` payload = `{ changes, previous, version }` (different shape)
15
+ // - `tenant.archived/disabled` = `{ previous }`
16
+ //
17
+ // Migration mappers writing `tenant.created` directly via `appendRaw` MUST
18
+ // replicate strip-id themselves — `aggregateId` lives on the event row, not
19
+ // in `payload`. Tenant + user have no compound types or sensitive fields
20
+ // today, but new fields could change that — keep the executor as the
21
+ // reference if the mapper output ever diverges from a replayed read-model.
22
+
23
+ import { addMemberWrite } from "./handlers/add-member.write";
24
+ import { createWrite } from "./handlers/create.write";
25
+ import { disableWrite } from "./handlers/disable.write";
26
+ import { removeMemberWrite } from "./handlers/remove-member.write";
27
+ import { updateWrite } from "./handlers/update.write";
28
+ import { updateMemberRolesWrite } from "./handlers/update-member-roles.write";
29
+
30
+ export const TenantCommandSchemas = {
31
+ create: createWrite.schema,
32
+ update: updateWrite.schema,
33
+ disable: disableWrite.schema,
34
+ addMember: addMemberWrite.schema,
35
+ removeMember: removeMemberWrite.schema,
36
+ updateMemberRoles: updateMemberRolesWrite.schema,
37
+ } as const;
@@ -0,0 +1,37 @@
1
+ // @runtime client
2
+ // Pure string-Konstanten — `@runtime client` damit auch Browser-Code
3
+ // (Members-Screen) sie importieren kann (siehe auth-email-password/
4
+ // constants.ts für die Begründung). Runtime importiert client → server
5
+ // kann sie weiter nutzen.
6
+
7
+ // Feature name
8
+ export const TENANT_FEATURE = "tenant" as const;
9
+
10
+ // Qualified write handler names (QN format: scope:type:name)
11
+ export const TenantHandlers = {
12
+ create: "tenant:write:create",
13
+ update: "tenant:write:update",
14
+ disable: "tenant:write:disable",
15
+ addMember: "tenant:write:add-member",
16
+ removeMember: "tenant:write:remove-member",
17
+ updateMemberRoles: "tenant:write:update-member-roles",
18
+ cancelInvitation: "tenant:write:cancel-invitation",
19
+ } as const;
20
+
21
+ // Qualified query handler names (QN format: scope:type:name)
22
+ export const TenantQueries = {
23
+ me: "tenant:query:me",
24
+ list: "tenant:query:list",
25
+ memberships: "tenant:query:memberships",
26
+ members: "tenant:query:members",
27
+ activeTenantIds: "tenant:query:active-tenant-ids",
28
+ resolveUserIds: "tenant:query:resolve-user-ids",
29
+ // Pending Invitations für den aktuellen Tenant (Admin-UI-Liste).
30
+ invitations: "tenant:query:invitations",
31
+ } as const;
32
+
33
+ // Error codes
34
+ export const TenantErrors = {
35
+ membershipNotFound: "membership_not_found",
36
+ membershipAlreadyExists: "membership_already_exists",
37
+ } as const;
@@ -0,0 +1,109 @@
1
+ import {
2
+ access,
3
+ createSystemConfig,
4
+ createTenantConfig,
5
+ defineFeature,
6
+ type FeatureDefinition,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { activeTenantIdsQuery } from "./handlers/active-tenant-ids.query";
9
+ import { addMemberWrite } from "./handlers/add-member.write";
10
+ import { cancelInvitationWrite } from "./handlers/cancel-invitation.write";
11
+ import { createWrite } from "./handlers/create.write";
12
+ import { disableWrite } from "./handlers/disable.write";
13
+ import { invitationsQuery } from "./handlers/invitations.query";
14
+ import { listQuery } from "./handlers/list.query";
15
+ import { meQuery } from "./handlers/me.query";
16
+ import { membersQuery } from "./handlers/members.query";
17
+ import { membershipsQuery } from "./handlers/memberships.query";
18
+ import { removeMemberWrite } from "./handlers/remove-member.write";
19
+ import { resolveUserIdsQuery } from "./handlers/resolve-user-ids.query";
20
+ import { updateWrite } from "./handlers/update.write";
21
+ import { updateMemberRolesWrite } from "./handlers/update-member-roles.write";
22
+ import { tenantInvitationEntity } from "./invitation-table";
23
+ import { tenantMembershipEntity } from "./membership-table";
24
+ import { tenantEntity } from "./schema/tenant";
25
+
26
+ export { tenantEntity, tenantTable } from "./schema/tenant";
27
+
28
+ // --- Feature ---
29
+
30
+ export function createTenantFeature(): FeatureDefinition {
31
+ return defineFeature("tenant", (r) => {
32
+ r.systemScope();
33
+ r.requires("config");
34
+ r.entity("tenant", tenantEntity);
35
+ r.entity("tenant-membership", tenantMembershipEntity);
36
+ r.entity("tenant-invitation", tenantInvitationEntity);
37
+
38
+ r.config({
39
+ keys: {
40
+ // Stammdaten-Settings — Tenant-Admin darf ändern, alle dürfen lesen.
41
+ companyName: createTenantConfig("text", { default: "" }),
42
+ // Pragmatisch kuratierte Liste — IANA hat hunderte, hier die in der
43
+ // Praxis relevantesten. Erweiterung später wenn echter Bedarf.
44
+ timezone: createTenantConfig("select", {
45
+ default: "Europe/Berlin",
46
+ options: [
47
+ "UTC",
48
+ "Europe/Berlin",
49
+ "Europe/London",
50
+ "Europe/Paris",
51
+ "Europe/Madrid",
52
+ "Europe/Rome",
53
+ "America/New_York",
54
+ "America/Los_Angeles",
55
+ "America/Sao_Paulo",
56
+ "Asia/Tokyo",
57
+ "Asia/Singapore",
58
+ "Australia/Sydney",
59
+ ],
60
+ }),
61
+ locale: createTenantConfig("select", {
62
+ default: "de",
63
+ options: ["de", "en", "fr", "es"],
64
+ }),
65
+
66
+ // SMTP — nur SystemAdmin (Plattform-Operator) ändert; smtpPass ist
67
+ // verschlüsselt + nur für SystemAdmin lesbar.
68
+ smtpHost: createTenantConfig("text", { write: access.systemAdmin, read: access.admin }),
69
+ smtpPass: createTenantConfig("text", {
70
+ write: access.systemAdmin,
71
+ read: access.systemAdmin,
72
+ encrypted: true,
73
+ }),
74
+
75
+ // System-Settings — nur programmatisch (SYSTEM_USER) änderbar,
76
+ // Tenant-Admin sieht readonly.
77
+ priceModel: createSystemConfig("select", {
78
+ default: "basic",
79
+ options: ["basic", "pro", "enterprise"],
80
+ }),
81
+ maxUsers: createSystemConfig("number", { default: 50 }),
82
+ },
83
+ });
84
+
85
+ // Tenant CRUD
86
+ const handlers = {
87
+ create: r.writeHandler(createWrite),
88
+ update: r.writeHandler(updateWrite),
89
+ disable: r.writeHandler(disableWrite),
90
+ addMember: r.writeHandler(addMemberWrite),
91
+ removeMember: r.writeHandler(removeMemberWrite),
92
+ updateMemberRoles: r.writeHandler(updateMemberRolesWrite),
93
+ cancelInvitation: r.writeHandler(cancelInvitationWrite),
94
+ };
95
+
96
+ // Queries
97
+ const queries = {
98
+ me: r.queryHandler(meQuery),
99
+ list: r.queryHandler(listQuery),
100
+ memberships: r.queryHandler(membershipsQuery),
101
+ members: r.queryHandler(membersQuery),
102
+ activeTenantIds: r.queryHandler(activeTenantIdsQuery),
103
+ resolveUserIds: r.queryHandler(resolveUserIdsQuery),
104
+ invitations: r.queryHandler(invitationsQuery),
105
+ };
106
+
107
+ return { handlers, queries };
108
+ });
109
+ }
@@ -0,0 +1,19 @@
1
+ import type { DbRow } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { tenantTable } from "../schema/tenant";
6
+
7
+ export const activeTenantIdsQuery = defineQueryHandler({
8
+ name: "activeTenantIds",
9
+ schema: z.object({}),
10
+ access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
11
+ handler: async (_query, ctx) => {
12
+ const rows = await ctx.db
13
+ ?.select({ id: tenantTable["id"] })
14
+ .from(tenantTable)
15
+ .where(eq(tenantTable["isEnabled"], true));
16
+
17
+ return rows.map((row) => (row as DbRow)["id"] as number);
18
+ },
19
+ });
@@ -0,0 +1,53 @@
1
+ import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import { TenantErrors } from "../constants";
7
+ import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
8
+
9
+ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
10
+ entityName: "tenant-membership",
11
+ });
12
+
13
+ export const addMemberWrite = defineWriteHandler({
14
+ name: "addMember",
15
+ schema: z.object({
16
+ userId: z.string(),
17
+ tenantId: z.string(),
18
+ roles: z.array(z.string()).min(1),
19
+ }),
20
+ access: { roles: ["SystemAdmin"] },
21
+ handler: async (event, ctx) => {
22
+ const db = ctx.db;
23
+ const existing = await fetchOne(
24
+ db,
25
+ tenantMembershipsTable,
26
+ eq(tenantMembershipsTable.userId, event.payload.userId),
27
+ eq(tenantMembershipsTable.tenantId, event.payload.tenantId),
28
+ );
29
+ if (existing) {
30
+ return writeFailure(
31
+ new ConflictError({
32
+ message: "membership already exists",
33
+ i18nKey: "tenant.errors.membershipAlreadyExists",
34
+ details: {
35
+ reason: TenantErrors.membershipAlreadyExists,
36
+ userId: event.payload.userId,
37
+ tenantId: event.payload.tenantId,
38
+ },
39
+ }),
40
+ );
41
+ }
42
+
43
+ return executor.create(
44
+ {
45
+ userId: event.payload.userId,
46
+ tenantId: event.payload.tenantId,
47
+ roles: JSON.stringify(event.payload.roles),
48
+ },
49
+ event.user,
50
+ db,
51
+ );
52
+ },
53
+ });
@@ -0,0 +1,87 @@
1
+ // Cancel-Handler für pending Invitations.
2
+ //
3
+ // Admin sieht eine pending Invitation und entscheidet sie zurückzu-
4
+ // nehmen (User soll doch nicht beitreten, falsche Email getippt etc.).
5
+ // Effekt:
6
+ // - DB-row.status → "cancelled"
7
+ // - Token aus Redis gelöscht (gemerkt im invite-token-store)
8
+ //
9
+ // Idempotent: cancellen einer schon-cancelled / accepted / expired
10
+ // invitation = no-op + 200. Cancellen einer non-existent invitation
11
+ // = invitation_not_found.
12
+
13
+ import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
14
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
16
+ import { eq } from "drizzle-orm";
17
+ import { z } from "zod";
18
+ // kumiko-lint-ignore cross-feature-import cancel needs invite-token-store für Redis-cleanup
19
+ import {
20
+ deleteInviteToken,
21
+ getTokenForInvitation,
22
+ } from "../../auth-email-password/invite-token-store";
23
+ import {
24
+ INVITATION_STATUS,
25
+ tenantInvitationEntity,
26
+ tenantInvitationsTable,
27
+ } from "../invitation-table";
28
+
29
+ const CancelInvitationSchema = z.object({
30
+ invitationId: z.string(),
31
+ });
32
+
33
+ const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
34
+ entityName: "tenant-invitation",
35
+ });
36
+
37
+ export const cancelInvitationWrite = defineWriteHandler({
38
+ name: "cancel-invitation",
39
+ schema: CancelInvitationSchema,
40
+ access: { roles: ["Admin"] },
41
+ handler: async (event, ctx) => {
42
+ const invitation = await fetchOne(
43
+ ctx.db.raw,
44
+ tenantInvitationsTable,
45
+ eq(tenantInvitationsTable.id, event.payload.invitationId),
46
+ );
47
+ if (!invitation || invitation["tenantId"] !== event.user.tenantId) {
48
+ return writeFailure(
49
+ new NotFoundError("tenantInvitation", event.payload.invitationId, {
50
+ i18nKey: "tenant.errors.invitationNotFound",
51
+ }),
52
+ );
53
+ }
54
+
55
+ // Idempotent: schon !pending → no-op success.
56
+ if (invitation["status"] !== INVITATION_STATUS.pending) {
57
+ return { isSuccess: true, data: { id: event.payload.invitationId, alreadyDone: true } };
58
+ }
59
+
60
+ // Status update via event-store
61
+ const updateResult = await executor.update(
62
+ {
63
+ id: event.payload.invitationId,
64
+ version: invitation["version"] as number,
65
+ changes: { status: INVITATION_STATUS.cancelled },
66
+ },
67
+ event.user,
68
+ ctx.db,
69
+ );
70
+ if (!updateResult.isSuccess) return updateResult;
71
+
72
+ // Token aus Redis löschen (falls noch da). Wenn Redis nicht
73
+ // verfügbar oder Token schon expired: kein Problem, DB-row ist
74
+ // die Single-Source für UI.
75
+ if (ctx.redis) {
76
+ const token = await getTokenForInvitation(ctx.redis, event.payload.invitationId);
77
+ if (token) {
78
+ await deleteInviteToken(ctx.redis, {
79
+ invitationId: event.payload.invitationId,
80
+ token,
81
+ });
82
+ }
83
+ }
84
+
85
+ return { isSuccess: true, data: { id: event.payload.invitationId, alreadyDone: false } };
86
+ },
87
+ });