@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,203 @@
1
+ // Tenant-Invite Step 2 — Branch 2 (anon User mit existing email).
2
+ //
3
+ // Flow:
4
+ // 1. User (nicht eingeloggt) klickt Invite-Link → /invite/accept?token=...
5
+ // 2. Frontend zeigt Login-Form mit pre-filled email (von der Invitation-
6
+ // Page geliefert via separate Lookup-Query, oder vom User getippt)
7
+ // 3. User submitted email + password + token an diesen Handler
8
+ // 4. Server: login + accept in einem Schritt:
9
+ // a. Token → invitationId → invitation row
10
+ // b. Login-Check: Password gegen userTable für invitation.email
11
+ // c. Email-Match (vom User-Input) === invitation.email
12
+ // d. Membership-Add im invited Tenant
13
+ // e. Invitation → status=accepted, Token gelöscht
14
+ // 5. Response: SessionUser + tenantKey für Auto-Login (analog signup-confirm)
15
+ //
16
+ // Anders als signup-confirm: KEIN neuer Tenant entsteht, KEIN neuer
17
+ // User entsteht — beide existieren bereits. Magic ist die kombinierte
18
+ // Login+Accept-Operation in einem Roundtrip.
19
+
20
+ import {
21
+ createEventStoreExecutor,
22
+ createTenantDb,
23
+ type DbConnection,
24
+ fetchOne,
25
+ } from "@cosmicdrift/kumiko-framework/db";
26
+ import {
27
+ createSystemUser,
28
+ defineWriteHandler,
29
+ type SessionUser,
30
+ type TenantId,
31
+ } from "@cosmicdrift/kumiko-framework/engine";
32
+ import {
33
+ InternalError,
34
+ UnprocessableError,
35
+ writeFailure,
36
+ } from "@cosmicdrift/kumiko-framework/errors";
37
+ import { eq } from "drizzle-orm";
38
+ import { z } from "zod";
39
+ // kumiko-lint-ignore cross-feature-import invite-flow
40
+ import {
41
+ INVITATION_STATUS,
42
+ tenantInvitationEntity,
43
+ tenantInvitationsTable,
44
+ } from "../../tenant/invitation-table";
45
+ // kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
46
+ import { seedTenantMembership } from "../../tenant/seeding";
47
+ // kumiko-lint-ignore cross-feature-import login-style password-check
48
+ import { userTable } from "../../user/schema/user";
49
+ import { AuthErrors } from "../constants";
50
+ import {
51
+ burnInviteToken,
52
+ deleteInviteToken,
53
+ getInvitationIdForToken,
54
+ unburnInviteToken,
55
+ } from "../invite-token-store";
56
+ import { verifyPassword } from "../password-hashing";
57
+
58
+ const InviteAcceptWithLoginSchema = z.object({
59
+ token: z.string().min(1),
60
+ email: z.email(),
61
+ password: z.string().min(8).max(200),
62
+ });
63
+
64
+ export type InviteAcceptWithLoginData = {
65
+ readonly kind: "auth-session";
66
+ readonly session: SessionUser;
67
+ readonly tenantId: TenantId;
68
+ readonly role: string;
69
+ };
70
+
71
+ const invitationExecutor = createEventStoreExecutor(
72
+ tenantInvitationsTable,
73
+ tenantInvitationEntity,
74
+ { entityName: "tenant-invitation" },
75
+ );
76
+
77
+ function invalidInviteToken() {
78
+ return writeFailure(
79
+ new UnprocessableError(AuthErrors.invalidInviteToken, {
80
+ i18nKey: "auth.errors.invalidInviteToken",
81
+ }),
82
+ );
83
+ }
84
+
85
+ export function createInviteAcceptWithLoginHandler() {
86
+ return defineWriteHandler<
87
+ "invite-accept-with-login",
88
+ typeof InviteAcceptWithLoginSchema,
89
+ InviteAcceptWithLoginData
90
+ >({
91
+ name: "invite-accept-with-login",
92
+ schema: InviteAcceptWithLoginSchema,
93
+ access: { roles: ["all"] },
94
+ handler: async (event, ctx) => {
95
+ if (!ctx.redis) {
96
+ return writeFailure(
97
+ new InternalError({ message: "invite-accept-with-login requires ctx.redis" }),
98
+ );
99
+ }
100
+
101
+ const invitationId = await getInvitationIdForToken(ctx.redis, event.payload.token);
102
+ if (!invitationId) return invalidInviteToken();
103
+
104
+ const burn = await burnInviteToken(ctx.redis, event.payload.token);
105
+ if (burn === "already-used") return invalidInviteToken();
106
+
107
+ let committed = false;
108
+ try {
109
+ const invitation = await fetchOne(
110
+ ctx.db.raw,
111
+ tenantInvitationsTable,
112
+ eq(tenantInvitationsTable.id, invitationId),
113
+ );
114
+ if (!invitation || invitation["status"] !== INVITATION_STATUS.pending)
115
+ return invalidInviteToken();
116
+
117
+ const invitationTenantId = invitation["tenantId"] as TenantId;
118
+ const invitationEmail = invitation["email"] as string;
119
+ const invitationRole = invitation["role"] as string;
120
+ const invitationVersion = invitation["version"] as number;
121
+
122
+ // Email-Match vom User-Input (nicht aus session — User ist anon)
123
+ if (event.payload.email.toLowerCase() !== invitationEmail) {
124
+ return writeFailure(
125
+ new UnprocessableError(AuthErrors.inviteEmailMismatch, {
126
+ i18nKey: "auth.errors.inviteEmailMismatch",
127
+ }),
128
+ );
129
+ }
130
+
131
+ // Password-Check gegen userTable. Anti-enumeration: bei
132
+ // user-not-found ODER wrong-password collapsed beides auf
133
+ // invalidInviteToken (gleicher anti-enum-Trade-off wie reset).
134
+ const userRow = await fetchOne(ctx.db.raw, userTable, eq(userTable.email, invitationEmail));
135
+ if (!userRow?.["passwordHash"]) return invalidInviteToken();
136
+ const passwordValid = await verifyPassword(
137
+ userRow["passwordHash"] as string,
138
+ event.payload.password,
139
+ );
140
+ if (!passwordValid) return invalidInviteToken();
141
+
142
+ const userId = userRow["id"] as string;
143
+
144
+ // Already-Member-Check (idempotent)
145
+ const memberships = (await ctx.queryAs(
146
+ createSystemUser(invitationTenantId),
147
+ "tenant:query:memberships",
148
+ { userId },
149
+ )) as Array<{ tenantId: string }>;
150
+ const alreadyMember = memberships.some((m) => m.tenantId === invitationTenantId);
151
+
152
+ // @cast-boundary db-runner — TenantDb.raw is DbRunner
153
+ const dbConn = ctx.db.raw as DbConnection;
154
+
155
+ if (!alreadyMember) {
156
+ await seedTenantMembership(dbConn, {
157
+ userId,
158
+ tenantId: invitationTenantId,
159
+ roles: [invitationRole],
160
+ });
161
+ }
162
+
163
+ // Invitation → accepted: TenantDb für invitation-tenant.
164
+ const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
165
+ const updateResult = await invitationExecutor.update(
166
+ {
167
+ id: invitationId,
168
+ version: invitationVersion,
169
+ changes: { status: INVITATION_STATUS.accepted },
170
+ },
171
+ createSystemUser(invitationTenantId),
172
+ invitationTdb,
173
+ );
174
+ if (!updateResult.isSuccess) return updateResult;
175
+
176
+ await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
177
+
178
+ // SessionUser für JWT-Mint im invited Tenant. Roles =
179
+ // [invitationRole] (Admin/Editor/User je nach invite).
180
+ const session: SessionUser = {
181
+ id: userId,
182
+ tenantId: invitationTenantId,
183
+ roles: [invitationRole],
184
+ };
185
+
186
+ committed = true;
187
+ return {
188
+ isSuccess: true,
189
+ data: {
190
+ kind: "auth-session",
191
+ session,
192
+ tenantId: invitationTenantId,
193
+ role: invitationRole,
194
+ },
195
+ };
196
+ } finally {
197
+ if (!committed && ctx.redis) {
198
+ await unburnInviteToken(ctx.redis, event.payload.token);
199
+ }
200
+ }
201
+ },
202
+ });
203
+ }
@@ -0,0 +1,189 @@
1
+ // Tenant-Invite Step 2 — Branch 1 (logged-in user accepts).
2
+ //
3
+ // User ist eingeloggt (in irgendeinem Tenant), klickt Accept-Link.
4
+ // Server:
5
+ // 1. Token → invitationId (Redis)
6
+ // 2. Burn (single-use)
7
+ // 3. Invitation-Row aus DB
8
+ // 4. Email-Match: invitation.email === user.email (sonst inviteEmailMismatch)
9
+ // 5. Already-Member-Check: User schon Member im invited Tenant → no-op success
10
+ // 6. Membership-Add via system-dispatcher (TenantHandlers.addMember)
11
+ // 7. Invitation-Row → status=accepted
12
+ // 8. Redis-Keys löschen (Burn-Key bleibt für Replay-Schutz)
13
+ //
14
+ // Branch 1 ist der klassische "shared workspace bei eingeloggter
15
+ // Session"-Flow. Branch 2 (anon + existing email) und Branch 3 (anon +
16
+ // new email) kommen als separate Handler.
17
+
18
+ import {
19
+ createEventStoreExecutor,
20
+ createTenantDb,
21
+ type DbConnection,
22
+ fetchOne,
23
+ } from "@cosmicdrift/kumiko-framework/db";
24
+ import {
25
+ createSystemUser,
26
+ defineWriteHandler,
27
+ type TenantId,
28
+ } from "@cosmicdrift/kumiko-framework/engine";
29
+ import {
30
+ InternalError,
31
+ UnprocessableError,
32
+ writeFailure,
33
+ } from "@cosmicdrift/kumiko-framework/errors";
34
+ import { eq } from "drizzle-orm";
35
+ import { z } from "zod";
36
+ // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link), DB-row-owner ist tenant-feature
37
+ import {
38
+ INVITATION_STATUS,
39
+ tenantInvitationEntity,
40
+ tenantInvitationsTable,
41
+ } from "../../tenant/invitation-table";
42
+ // kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add (analog provisionSignupAccount)
43
+ import { seedTenantMembership } from "../../tenant/seeding";
44
+ // kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
45
+ import { userTable } from "../../user/schema/user";
46
+ import { AuthErrors } from "../constants";
47
+ import {
48
+ burnInviteToken,
49
+ deleteInviteToken,
50
+ getInvitationIdForToken,
51
+ unburnInviteToken,
52
+ } from "../invite-token-store";
53
+
54
+ const InviteAcceptSchema = z.object({
55
+ token: z.string().min(1),
56
+ });
57
+
58
+ export type InviteAcceptData = {
59
+ readonly kind: "invite-accepted";
60
+ readonly tenantId: TenantId;
61
+ readonly role: string;
62
+ readonly alreadyMember: boolean;
63
+ };
64
+
65
+ const invitationExecutor = createEventStoreExecutor(
66
+ tenantInvitationsTable,
67
+ tenantInvitationEntity,
68
+ { entityName: "tenant-invitation" },
69
+ );
70
+
71
+ function invalidInviteToken() {
72
+ return writeFailure(
73
+ new UnprocessableError(AuthErrors.invalidInviteToken, {
74
+ i18nKey: "auth.errors.invalidInviteToken",
75
+ }),
76
+ );
77
+ }
78
+
79
+ export function createInviteAcceptHandler() {
80
+ return defineWriteHandler<"invite-accept", typeof InviteAcceptSchema, InviteAcceptData>({
81
+ name: "invite-accept",
82
+ schema: InviteAcceptSchema,
83
+ // openToAll: any authenticated user (Branch 1). Branch 2+3 (anon)
84
+ // nutzen `roles: ["all"]` weil dort GUEST_USER mit ["all"]-role
85
+ // dispatched wird.
86
+ access: { openToAll: true },
87
+ handler: async (event, ctx) => {
88
+ if (!ctx.redis) {
89
+ return writeFailure(
90
+ new InternalError({ message: "invite-accept requires ctx.redis for token consumption" }),
91
+ );
92
+ }
93
+
94
+ const invitationId = await getInvitationIdForToken(ctx.redis, event.payload.token);
95
+ if (!invitationId) return invalidInviteToken();
96
+
97
+ const burn = await burnInviteToken(ctx.redis, event.payload.token);
98
+ if (burn === "already-used") return invalidInviteToken();
99
+
100
+ let committed = false;
101
+ try {
102
+ const invitation = await fetchOne(
103
+ ctx.db.raw,
104
+ tenantInvitationsTable,
105
+ eq(tenantInvitationsTable.id, invitationId),
106
+ );
107
+ if (!invitation || invitation["status"] !== INVITATION_STATUS.pending)
108
+ return invalidInviteToken();
109
+
110
+ const invitationTenantId = invitation["tenantId"] as TenantId;
111
+ const invitationEmail = invitation["email"] as string;
112
+ const invitationRole = invitation["role"] as string;
113
+ const invitationVersion = invitation["version"] as number;
114
+
115
+ // Email-Match: User muss mit der eingeladenen Email matchen.
116
+ // Sonst kann ein Angreifer mit Zugriff zur invitee-Mail seinen
117
+ // eigenen Account dem Tenant zuschlagen.
118
+ const userRow = await fetchOne(ctx.db.raw, userTable, eq(userTable.id, event.user.id));
119
+ if (!userRow || (userRow["email"] as string).toLowerCase() !== invitationEmail) {
120
+ return writeFailure(
121
+ new UnprocessableError(AuthErrors.inviteEmailMismatch, {
122
+ i18nKey: "auth.errors.inviteEmailMismatch",
123
+ }),
124
+ );
125
+ }
126
+
127
+ // Already-Member-Check via memberships-query. Wenn der User schon
128
+ // im invited Tenant Member ist, kein Error — no-op + 200 mit
129
+ // alreadyMember=true (advisor-Constraint #4: idempotent).
130
+ const memberships = (await ctx.queryAs(
131
+ createSystemUser(invitationTenantId),
132
+ "tenant:query:memberships",
133
+ { userId: event.user.id },
134
+ )) as Array<{ tenantId: string }>;
135
+ const alreadyMember = memberships.some((m) => m.tenantId === invitationTenantId);
136
+
137
+ // @cast-boundary db-runner — TenantDb.raw is DbRunner
138
+ const dbConn = ctx.db.raw as DbConnection;
139
+
140
+ if (!alreadyMember) {
141
+ // Membership-Add via seedTenantMembership-helper (event-store-
142
+ // executor pattern, gleich wie provisionSignupAccount). Nicht
143
+ // dispatcher.writeAs(addMember) weil addMember-Handler nur
144
+ // ["SystemAdmin"]-Role akzeptiert; createSystemUser produziert
145
+ // "system"-Role die NICHT matcht. Direkt-via-Executor bypassed
146
+ // den Access-Check für privilegierte Cross-Tenant-Operationen.
147
+ await seedTenantMembership(dbConn, {
148
+ userId: event.user.id,
149
+ tenantId: invitationTenantId,
150
+ roles: [invitationRole],
151
+ });
152
+ }
153
+
154
+ // Invitation-Status → accepted via event-store-executor.
155
+ // Tenant-scoping: ctx.db ist auf event.user.tenantId gescopt
156
+ // (= NICHT der invitation-tenant). Eigene TenantDb für den
157
+ // invitation-tenant bauen damit der executor die row findet.
158
+ const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
159
+ const updateResult = await invitationExecutor.update(
160
+ {
161
+ id: invitationId,
162
+ version: invitationVersion,
163
+ changes: { status: INVITATION_STATUS.accepted },
164
+ },
165
+ createSystemUser(invitationTenantId),
166
+ invitationTdb,
167
+ );
168
+ if (!updateResult.isSuccess) return updateResult;
169
+
170
+ await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
171
+
172
+ committed = true;
173
+ return {
174
+ isSuccess: true,
175
+ data: {
176
+ kind: "invite-accepted",
177
+ tenantId: invitationTenantId,
178
+ role: invitationRole,
179
+ alreadyMember,
180
+ },
181
+ };
182
+ } finally {
183
+ if (!committed && ctx.redis) {
184
+ await unburnInviteToken(ctx.redis, event.payload.token);
185
+ }
186
+ }
187
+ },
188
+ });
189
+ }
@@ -0,0 +1,145 @@
1
+ // Tenant-Invite Step 1 (create).
2
+ //
3
+ // Admin invitet email → DB-Row entsteht via event-store-executor (oder
4
+ // wird re-used bei Re-Invite), Random-Token in Redis bidirektional,
5
+ // Route-Layer schickt Mail mit Activation-URL.
6
+ //
7
+ // Resend-Idempotenz: Re-Invite für gleiche (tenantId, email) während
8
+ // pending → existing row + token re-genutzt + TTL refresh + zweite Mail
9
+ // mit GLEICHEM Link. Bei status="cancelled" oder "accepted": existing
10
+ // row updated zurück auf status=pending + neuer token.
11
+ //
12
+ // Always-200 für unbekannten User: bei invitee-Email die nicht in users
13
+ // existiert wird trotzdem ein Invite erstellt — Branch-3-Accept-Flow
14
+ // erlaubt new-user-signup mit dem Token. Keine Enumeration durchs
15
+ // invite-create.
16
+
17
+ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
18
+ import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
19
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
21
+ import { eq } from "drizzle-orm";
22
+ import { Temporal } from "temporal-polyfill";
23
+ import { z } from "zod";
24
+ // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
25
+ import {
26
+ INVITATION_STATUS,
27
+ tenantInvitationEntity,
28
+ tenantInvitationsTable,
29
+ } from "../../tenant/invitation-table";
30
+ import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
31
+ import { getTokenForInvitation, storeInviteToken } from "../invite-token-store";
32
+
33
+ const InviteCreateSchema = z.object({
34
+ email: z.email(),
35
+ role: z.string().min(1).max(50),
36
+ });
37
+
38
+ export type InviteCreateData = {
39
+ readonly kind: "invite-created";
40
+ readonly invitationId: string;
41
+ readonly tenantId: string;
42
+ readonly email: string;
43
+ readonly role: string;
44
+ readonly token: string;
45
+ readonly expiresAt: string;
46
+ };
47
+
48
+ export type InviteCreateOptions = {
49
+ /** TTL für den Activation-Token. Default 7 Tage. */
50
+ readonly tokenTtlMinutes?: number;
51
+ };
52
+
53
+ const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
54
+ entityName: "tenant-invitation",
55
+ });
56
+
57
+ export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
58
+ const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES;
59
+ const ttlSeconds = ttlMinutes * 60;
60
+
61
+ return defineWriteHandler<"invite-create", typeof InviteCreateSchema, InviteCreateData>({
62
+ name: "invite-create",
63
+ schema: InviteCreateSchema,
64
+ access: { roles: ["Admin"] },
65
+ handler: async (event, ctx) => {
66
+ if (!ctx.redis) {
67
+ return writeFailure(
68
+ new InternalError({ message: "invite-create requires ctx.redis for token store" }),
69
+ );
70
+ }
71
+
72
+ const email = event.payload.email.toLowerCase();
73
+ const tenantId = event.user.tenantId;
74
+ const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
75
+
76
+ // Existing row für (tenantId, email) — unique-index garantiert
77
+ // max. eine Row. Status egal (cancelled/accepted/expired/pending);
78
+ // wir setzen sie auf pending zurück und vergeben einen frischen
79
+ // Token wenn der bisherige nicht mehr lebt.
80
+ const existing = await fetchOne(
81
+ ctx.db.raw,
82
+ tenantInvitationsTable,
83
+ eq(tenantInvitationsTable.tenantId, tenantId),
84
+ eq(tenantInvitationsTable.email, email),
85
+ );
86
+
87
+ let invitationId: string;
88
+ let token: string;
89
+ if (existing) {
90
+ invitationId = existing["id"] as string;
91
+ const existingVersion = existing["version"] as number;
92
+ // Resend-Idempotenz: Token aus Redis re-use wenn noch lebend.
93
+ // Sonst neuen mintinen (alter ist abgelaufen).
94
+ const existingToken = await getTokenForInvitation(ctx.redis, invitationId);
95
+ token = existingToken ?? generateToken();
96
+
97
+ const updateResult = await executor.update(
98
+ {
99
+ id: invitationId,
100
+ version: existingVersion,
101
+ changes: {
102
+ role: event.payload.role,
103
+ status: INVITATION_STATUS.pending,
104
+ invitedBy: event.user.id,
105
+ expiresAt,
106
+ },
107
+ },
108
+ event.user,
109
+ ctx.db,
110
+ );
111
+ if (!updateResult.isSuccess) return updateResult;
112
+ } else {
113
+ const createResult = await executor.create(
114
+ {
115
+ email,
116
+ role: event.payload.role,
117
+ status: INVITATION_STATUS.pending,
118
+ invitedBy: event.user.id,
119
+ expiresAt,
120
+ },
121
+ event.user,
122
+ ctx.db,
123
+ );
124
+ if (!createResult.isSuccess) return createResult;
125
+ invitationId = (createResult.data as { id: string }).id;
126
+ token = generateToken();
127
+ }
128
+
129
+ await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
130
+
131
+ return {
132
+ isSuccess: true,
133
+ data: {
134
+ kind: "invite-created",
135
+ invitationId,
136
+ tenantId,
137
+ email,
138
+ role: event.payload.role,
139
+ token,
140
+ expiresAt: expiresAt.toString(),
141
+ },
142
+ };
143
+ },
144
+ });
145
+ }