@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,170 @@
1
+ // Magic-Link-Signup, Step 2 (confirm).
2
+ //
3
+ // Token aus URL + Password vom User → wir lösen den Token in Redis ein
4
+ // und legen Tenant + User + Admin-Membership atomar an. emailVerified
5
+ // wird sofort auf true gesetzt — der Magic-Link IST der Beweis.
6
+ //
7
+ // Pipeline:
8
+ // 1. Redis check: token → email lookup
9
+ // 2. Single-Use-Burn (SETNX) — gleichzeitiger Klick aus zwei Tabs
10
+ // gewinnt nur einer
11
+ // 3. Tenant-Key generieren (generateUniqueName mit DB-isAvailable-
12
+ // check gegen tenants.key)
13
+ // 4. provisionSignupAccount: Tenant + User + Membership in einem
14
+ // Rutsch (durch event-store-executor; events + projection +
15
+ // MSPs sehen das wie einen regulären create)
16
+ // 5. Token-Keys löschen (burn-key bleibt für TTL-Replay-Protection)
17
+ //
18
+ // Failure-Recovery: jeder Pfad nach dem burn checked `committed`-Flag;
19
+ // bei !committed wird der burn released damit ein legitimer Retry
20
+ // nicht durch einen stale Marker geblockt wird (wie reset/verify).
21
+
22
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
23
+ import {
24
+ defineWriteHandler,
25
+ type SessionUser,
26
+ type TenantId,
27
+ } from "@cosmicdrift/kumiko-framework/engine";
28
+ import {
29
+ InternalError,
30
+ UnprocessableError,
31
+ writeFailure,
32
+ } from "@cosmicdrift/kumiko-framework/errors";
33
+ import { generateUniqueName } from "@cosmicdrift/kumiko-framework/random";
34
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
35
+ import { eq } from "drizzle-orm";
36
+ import { z } from "zod";
37
+ // kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
38
+ import { tenantTable } from "../../tenant/schema/tenant";
39
+ import { AuthErrors } from "../constants";
40
+ // kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
41
+ import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding";
42
+ import {
43
+ burnSignupToken,
44
+ deleteSignupToken,
45
+ getEmailForSignupToken,
46
+ unburnSignupToken,
47
+ } from "../signup-token-store";
48
+
49
+ const SignupConfirmSchema = z.object({
50
+ token: z.string().min(8),
51
+ password: z.string().min(8).max(200),
52
+ });
53
+
54
+ // Mirror der login-handler-Shape (kind: "auth-session", session: SessionUser)
55
+ // damit die Route-Layer den signup-confirm-success genauso behandeln kann
56
+ // wie einen erfolgreichen login: JWT-Mint, Cookies setzen, Session-Body
57
+ // returnen. Der zusätzliche tenantKey landet als sibling am data-objekt
58
+ // (NICHT in SessionUser — der ist generic, tenantKey ist signup-spezifisch
59
+ // für den Post-Signup-Redirect zu /<tenantKey>/).
60
+ export type SignupConfirmData = {
61
+ readonly kind: "auth-session";
62
+ readonly session: SessionUser;
63
+ readonly tenantKey: string;
64
+ };
65
+
66
+ function invalidSignupToken() {
67
+ return writeFailure(
68
+ new UnprocessableError(AuthErrors.invalidSignupToken, {
69
+ i18nKey: "auth.errors.invalidSignupToken",
70
+ }),
71
+ );
72
+ }
73
+
74
+ export function createSignupConfirmHandler() {
75
+ return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
76
+ name: "signup-confirm",
77
+ schema: SignupConfirmSchema,
78
+ access: { roles: ["all"] },
79
+ handler: async (event, ctx) => {
80
+ if (!ctx.redis) {
81
+ return writeFailure(
82
+ new InternalError({
83
+ message: "signup-confirm requires ctx.redis for token consumption",
84
+ }),
85
+ );
86
+ }
87
+
88
+ // Token-Lookup: nicht-existent / abgelaufen / schon konsumiert →
89
+ // alle collapsen auf invalid_signup_token. Anti-enumeration.
90
+ const email = await getEmailForSignupToken(ctx.redis, event.payload.token);
91
+ if (!email) return invalidSignupToken();
92
+
93
+ // Single-Use-Burn: zwei parallele Confirms aus verschiedenen
94
+ // Tabs — einer wins, der andere kriegt invalid_signup_token.
95
+ const burn = await burnSignupToken(ctx.redis, event.payload.token);
96
+ if (burn === "already-used") return invalidSignupToken();
97
+
98
+ let committed = false;
99
+ try {
100
+ // Tenant-Key: 2-Wort-Slug aus framework/random, mit DB-Conflict-
101
+ // Check gegen tenants.key. 22.500 Default-Combos + Suffix-
102
+ // Fallback bei Kollision (siehe generateUniqueName).
103
+ // @cast-boundary db-runner — TenantDb.raw is DbRunner (Connection|Tx);
104
+ // provisioning helpers operate on plain drizzle-API that both shapes
105
+ // expose identically. Inside an event-store transaction the cast lands
106
+ // on the Tx flavor — same drizzle calls, same behavior.
107
+ const dbConn = ctx.db.raw as DbConnection;
108
+
109
+ const tenantKey = await generateUniqueName({
110
+ isAvailable: async (slug) => {
111
+ const existing = await dbConn
112
+ .select({ id: tenantTable.id })
113
+ .from(tenantTable)
114
+ .where(eq(tenantTable.key, slug))
115
+ .limit(1);
116
+ return existing.length === 0;
117
+ },
118
+ });
119
+
120
+ const tenantId = generateId() as TenantId;
121
+ // Display-Name aus email-prefix als sinnvolles Default; User kann
122
+ // den Tenant-Namen + sein eigenes displayName später ändern.
123
+ const displayName = email.split("@")[0] ?? email;
124
+
125
+ const provisioned = await provisionSignupAccount(dbConn, {
126
+ email,
127
+ password: event.payload.password,
128
+ displayName,
129
+ tenantId,
130
+ tenantKey,
131
+ // Tenant-Display-Name als Default = Email. User wechselt das im
132
+ // Settings-Screen. Konzept "Tenant" leakt nicht in die Signup-UI.
133
+ tenantName: email,
134
+ });
135
+
136
+ // Cleanup beider Token-Lookup-Keys. Burn-Key bleibt für die
137
+ // restliche Burn-TTL als Replay-Schutz.
138
+ await deleteSignupToken(ctx.redis, { email, token: event.payload.token });
139
+
140
+ // SessionUser für JWT-Mint. Roles aus INITIAL_SIGNUP_ROLES
141
+ // damit DB-write (provisionSignupAccount) und Session-claim
142
+ // dieselbe Quelle teilen — sonst hätten zwei Stellen "Admin"
143
+ // hardcoded und ein Refactor würde role-mismatch zwischen DB
144
+ // und JWT erzeugen.
145
+ const session: SessionUser = {
146
+ id: provisioned.userId,
147
+ tenantId: provisioned.tenantId,
148
+ roles: [...INITIAL_SIGNUP_ROLES],
149
+ };
150
+
151
+ committed = true;
152
+ return {
153
+ isSuccess: true,
154
+ data: {
155
+ kind: "auth-session",
156
+ session,
157
+ tenantKey,
158
+ },
159
+ };
160
+ } finally {
161
+ if (!committed && ctx.redis) {
162
+ // Burn-release damit ein retry nach DB-Hiccup nicht blockt.
163
+ // Token-Lookup-Keys bleiben — der User kann seinen Mail-Link
164
+ // erneut klicken.
165
+ await unburnSignupToken(ctx.redis, event.payload.token);
166
+ }
167
+ }
168
+ },
169
+ });
170
+ }
@@ -0,0 +1,104 @@
1
+ // Magic-Link-Signup, Step 1 (request).
2
+ //
3
+ // User gibt Email ein → wir minten einen opaken Random-Token, speichern
4
+ // ihn bidirektional in Redis (token↔email), und der Route-Layer schickt
5
+ // die Activation-Mail. Anders als reset/verify-Flows existiert der User
6
+ // HIER NOCH NICHT — daher kein userId-lookup, kein HMAC-signing (wofür
7
+ // gäbe es kein Subject), kein "skip if user already exists in DB"-pattern.
8
+ //
9
+ // Resend-Idempotenz: wenn für die Email bereits ein lebender Token in
10
+ // Redis liegt, geben wir denselben Token zurück (und refreshen TTL auf
11
+ // beiden Keys). Der User bekommt dann eine zweite Mail mit dem GLEICHEN
12
+ // Activation-Link. Erste Mail bleibt gültig — kein "old link broken"-
13
+ // annoyance.
14
+ //
15
+ // Always-200 (enumeration-safe): das Response sieht für jede Email
16
+ // gleich aus, egal ob sie schon registriert ist oder nicht. Anders als
17
+ // reset (das ein "no-op" zurückgibt wenn User nicht existiert) gibt's
18
+ // hier nichts zu enumerieren — eine Email kann nicht "schon registriert
19
+ // sein" weil bei Magic-Link der User-Row erst beim Confirm entsteht.
20
+ // Was es geben könnte: dieselbe Email versucht es zum N-ten Mal —
21
+ // Resend-Pfad ist by-design idempotent.
22
+
23
+ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
24
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
26
+ import { Temporal } from "temporal-polyfill";
27
+ import { z } from "zod";
28
+ import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
29
+ import { getTokenForSignupEmail, normalizeEmail, storeSignupToken } from "../signup-token-store";
30
+
31
+ const SignupRequestSchema = z.object({
32
+ email: z.email(),
33
+ });
34
+
35
+ export type SignupRequestData =
36
+ | {
37
+ readonly kind: "signup-requested";
38
+ readonly email: string;
39
+ readonly token: string;
40
+ readonly expiresAt: string;
41
+ }
42
+ | { readonly kind: "no-op" };
43
+
44
+ export type SignupRequestOptions = {
45
+ /** TTL für den Activation-Token. Default 24 h — lang genug damit User
46
+ * "morgen aktivieren" können ohne Resend-Spam. */
47
+ readonly tokenTtlMinutes?: number;
48
+ };
49
+
50
+ export function createSignupRequestHandler(opts: SignupRequestOptions = {}) {
51
+ const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_SIGNUP_DEFAULT_TTL_MINUTES;
52
+ const ttlSeconds = ttlMinutes * 60;
53
+
54
+ return defineWriteHandler<"signup-request", typeof SignupRequestSchema, SignupRequestData>({
55
+ name: "signup-request",
56
+ schema: SignupRequestSchema,
57
+ access: { roles: ["all"] },
58
+ handler: async (event, ctx) => {
59
+ if (!ctx.redis) {
60
+ return writeFailure(
61
+ new InternalError({
62
+ message: "signup-request requires ctx.redis for the activation-token store",
63
+ }),
64
+ );
65
+ }
66
+
67
+ // Email-Normalisierung lebt im Store (signup-token-store). Der
68
+ // Handler reicht die raw email durch — eine Quelle, kein Drift
69
+ // zwischen Lookup-Pfaden die unterschiedlich (oder gar nicht)
70
+ // lowercased haben.
71
+ const email = event.payload.email;
72
+
73
+ // Resend-Idempotenz: wenn ein Token für diese Email noch lebt,
74
+ // re-use ihn und refreshe beide Keys. Der User kriegt eine zweite
75
+ // Mail mit dem GLEICHEN Link.
76
+ const existingToken = await getTokenForSignupEmail(ctx.redis, email);
77
+ // 32 random bytes = 256 bits unguessable randomness, base64url
78
+ // encoded zu 43 chars. Math.random war früher ein Bug:
79
+ // xorshift128+ hat ~128 Bit State der nach ~5 beobachteten
80
+ // Outputs rekonstruierbar ist — Angreifer könnte eigene
81
+ // signup-requests triggern und die Tokens fremder User
82
+ // vorhersagen. generateToken nutzt randomBytes aus node:crypto,
83
+ // dieselbe Quelle wie CSRF/Session-Tokens.
84
+ const token = existingToken ?? generateToken();
85
+
86
+ const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
87
+
88
+ await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
89
+
90
+ return {
91
+ isSuccess: true,
92
+ data: {
93
+ kind: "signup-requested",
94
+ // normalizeEmail aus dem Store — eine Quelle für die
95
+ // Normalisierungs-Verantwortung; Mail-Callback kriegt
96
+ // konsistent das gleiche Format wie der Lookup-Pfad.
97
+ email: normalizeEmail(email),
98
+ token,
99
+ expiresAt: expiresAt.toString(),
100
+ },
101
+ };
102
+ },
103
+ });
104
+ }
@@ -0,0 +1,114 @@
1
+ // Shared factory for the request-side of out-of-band token flows
2
+ // (password-reset, email-verification). Both follow the same shape:
3
+ //
4
+ // POST email
5
+ // → resolve user (system-scoped query)
6
+ // → skip silently if user doesn't exist / is deleted / already done
7
+ // → mint an HMAC-signed token
8
+ // → return { kind: <successKind>, email, token, expiresAt }
9
+ //
10
+ // Differences between the flows are four parameters (successKind, sign fn,
11
+ // default TTL, extra skip condition) + two error codes — encoded on the
12
+ // spec rather than duplicated across two near-identical handler bodies.
13
+
14
+ import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
16
+ import type { Temporal } from "temporal-polyfill";
17
+ import { z } from "zod";
18
+ import { UserQueries } from "../../user";
19
+ import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row";
20
+
21
+ const RequestTokenSchema = z.object({
22
+ email: z.email(),
23
+ });
24
+
25
+ // What the route layer reads off `result.data` after dispatching. Identical
26
+ // shape for both flows; only the `kind` discriminator differs so the route
27
+ // knows whether to forward to sendResetEmail or sendVerificationEmail.
28
+ export type TokenRequestSuccess<K extends string> = {
29
+ readonly kind: K;
30
+ readonly email: string;
31
+ readonly token: string;
32
+ readonly expiresAt: string;
33
+ };
34
+
35
+ export type TokenRequestNoOp = { readonly kind: "no-op" };
36
+
37
+ export type TokenRequestData<K extends string> = TokenRequestSuccess<K> | TokenRequestNoOp;
38
+
39
+ export type TokenRequestSpec<TName extends string, TSuccessKind extends string> = {
40
+ readonly handlerName: TName;
41
+ readonly successKind: TSuccessKind;
42
+ readonly defaultTtlMinutes: number;
43
+ // Feature-specific sign function. Signature matches both signResetToken
44
+ // and signVerificationToken (thin wrappers over signed-token.ts).
45
+ readonly sign: (
46
+ userId: string,
47
+ ttlMinutes: number,
48
+ secret: string,
49
+ ) => { token: string; expiresAt: Temporal.Instant };
50
+ // Error code + i18nKey returned when the feature-factory wasn't given a
51
+ // working hmacSecret. Should never happen — feature-factory validates at
52
+ // boot — but defensive coverage for lazy secret-providers.
53
+ readonly notConfiguredError: string;
54
+ readonly notConfiguredI18nKey: string;
55
+ // Extra silent-skip predicate on top of "user doesn't exist or is
56
+ // soft-deleted". Verification skips when emailVerified is already true;
57
+ // password-reset has no extra condition (returns false).
58
+ readonly extraSilentSkip: (user: AuthUserRow) => boolean;
59
+ };
60
+
61
+ export type TokenRequestOptions = {
62
+ readonly hmacSecret: string;
63
+ readonly tokenTtlMinutes?: number;
64
+ };
65
+
66
+ export function createTokenRequestHandler<TName extends string, TSuccessKind extends string>(
67
+ spec: TokenRequestSpec<TName, TSuccessKind>,
68
+ opts: TokenRequestOptions,
69
+ ) {
70
+ const ttl = opts.tokenTtlMinutes ?? spec.defaultTtlMinutes;
71
+
72
+ return defineWriteHandler<TName, typeof RequestTokenSchema, TokenRequestData<TSuccessKind>>({
73
+ name: spec.handlerName,
74
+ schema: RequestTokenSchema,
75
+ access: { roles: ["all"] },
76
+ handler: async (event, ctx) => {
77
+ if (!opts.hmacSecret) {
78
+ // Feature-factory guards this at boot; defensive here for lazy-
79
+ // provided secrets that show up empty at runtime.
80
+ return writeFailure(
81
+ new UnprocessableError(spec.notConfiguredError, {
82
+ i18nKey: spec.notConfiguredI18nKey,
83
+ }),
84
+ );
85
+ }
86
+
87
+ const systemUser = createSystemUser(event.user.tenantId);
88
+
89
+ const user = parseAuthUserRow(
90
+ await ctx.queryAs(systemUser, UserQueries.findForAuth, {
91
+ email: event.payload.email,
92
+ }),
93
+ );
94
+
95
+ // Silent-success branches all return the SAME shape with kind="no-op".
96
+ // Response-level timing stays uniform (200 / isSuccess: true); the
97
+ // small difference in handler-internal work is accepted — no probing
98
+ // client can observe it through the HTTP surface.
99
+ if (!user || user.isDeleted || !user.email || spec.extraSilentSkip(user)) {
100
+ const data: TokenRequestData<TSuccessKind> = { kind: "no-op" };
101
+ return { isSuccess: true, data };
102
+ }
103
+
104
+ const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
105
+ const data: TokenRequestData<TSuccessKind> = {
106
+ kind: spec.successKind,
107
+ email: user.email,
108
+ token,
109
+ expiresAt: expiresAt.toString(),
110
+ };
111
+ return { isSuccess: true, data };
112
+ },
113
+ });
114
+ }
@@ -0,0 +1,62 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { z } from "zod";
4
+ import { AuthErrors } from "../constants";
5
+ import { verifyVerificationToken } from "../verification-token";
6
+ import { runConfirmTokenFlow } from "./confirm-token-flow";
7
+
8
+ export type VerifyEmailOptions = {
9
+ readonly hmacSecret: string;
10
+ };
11
+
12
+ const VerifyEmailSchema = z.object({
13
+ token: z.string().min(1),
14
+ });
15
+
16
+ export type VerifyEmailData = { readonly kind: "verified" } | { readonly kind: "already-verified" };
17
+
18
+ function invalidToken() {
19
+ return writeFailure(
20
+ new UnprocessableError(AuthErrors.invalidVerificationToken, {
21
+ i18nKey: "auth.errors.invalidVerificationToken",
22
+ }),
23
+ );
24
+ }
25
+
26
+ // Sets user.emailVerified = true on a valid token. Idempotent via the
27
+ // `alreadyDone` short-circuit — when the row already reads verified
28
+ // (reached through another flow), we skip the write but keep the burn
29
+ // so replays still see invalid_verification_token on the burn check.
30
+ // Sessions are NOT revoked on verification — no security reason to
31
+ // nuke active logins when a user finally confirms their address.
32
+ export function createVerifyEmailHandler(opts: VerifyEmailOptions) {
33
+ return defineWriteHandler<"verify-email", typeof VerifyEmailSchema, VerifyEmailData>({
34
+ name: "verify-email",
35
+ schema: VerifyEmailSchema,
36
+ access: { roles: ["all"] },
37
+ handler: async (event, ctx) => {
38
+ if (!opts.hmacSecret) {
39
+ return writeFailure(
40
+ new UnprocessableError(AuthErrors.verificationNotConfigured, {
41
+ i18nKey: "auth.errors.verificationNotConfigured",
42
+ }),
43
+ );
44
+ }
45
+
46
+ const verify = verifyVerificationToken(event.payload.token, opts.hmacSecret);
47
+ if (!verify.ok) return invalidToken();
48
+
49
+ return runConfirmTokenFlow<VerifyEmailData>(ctx, verify.userId, verify.expiresAtMs, {
50
+ purpose: "verify",
51
+ redisRequiredMessage: "email-verification requires ctx.redis to enforce token single-use",
52
+ invalidToken,
53
+ buildChanges: async () => ({ emailVerified: true }),
54
+ successData: { kind: "verified" },
55
+ alreadyDone: {
56
+ check: (me) => me.emailVerified === true,
57
+ data: { kind: "already-verified" },
58
+ },
59
+ });
60
+ },
61
+ });
62
+ }
@@ -0,0 +1,211 @@
1
+ // @runtime client
2
+ // Default-Bundles für die Feature-UI. Werden vom emailPasswordClient()
3
+ // als Fallback-Bundle in den LocaleProvider gehängt — Apps können
4
+ // einzelne Keys via `emailPasswordClient({ translations: { de: { ... } } })`
5
+ // überschreiben, ohne das ganze Bundle kopieren zu müssen.
6
+ //
7
+ // Keys folgen dem Schema `auth.<area>.<slug>` — `auth.login.*` für die
8
+ // Formular-UI, `auth.errors.*` für Reason-Codes aus dem Login-Handler
9
+ // (1:1 gespiegelt zu AuthErrors im server-side Feature).
10
+
11
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
12
+
13
+ export const defaultTranslations: TranslationsByLocale = {
14
+ de: {
15
+ "auth.login.title": "Anmelden",
16
+ "auth.login.email": "E-Mail",
17
+ "auth.login.password": "Passwort",
18
+ "auth.login.submit": "Einloggen",
19
+ "auth.login.submitting": "…",
20
+ "auth.login.forgotPassword": "Passwort vergessen?",
21
+ "auth.errors.invalidCredentials": "E-Mail oder Passwort falsch.",
22
+ "auth.errors.noMembership": "Dieses Konto hat keinen Tenant-Zugang.",
23
+ "auth.errors.accountLocked": "Konto vorübergehend gesperrt.",
24
+ "auth.errors.accountLockedRetry": "Konto gesperrt. Neuer Versuch in {minutes} Minuten.",
25
+ "auth.errors.emailNotVerified": "E-Mail-Adresse noch nicht bestätigt.",
26
+ "auth.errors.rateLimited": "Zu viele Login-Versuche. Bitte kurz warten.",
27
+ "auth.errors.invalidBody": "Ungültige Eingabe.",
28
+ "auth.errors.loginFailed": "Login fehlgeschlagen.",
29
+ "auth.errors.invalidResetToken":
30
+ "Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
31
+ "auth.errors.invalidVerificationToken": "Der Bestätigungs-Link ist ungültig oder abgelaufen.",
32
+ "auth.errors.invalidSignupToken":
33
+ "Der Aktivierungs-Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
34
+ "auth.errors.unknownError": "Etwas ist schief gegangen. Bitte erneut versuchen.",
35
+ "auth.forgotPassword.title": "Passwort zurücksetzen",
36
+ "auth.forgotPassword.intro":
37
+ "Gib deine E-Mail-Adresse ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.",
38
+ "auth.forgotPassword.email": "E-Mail",
39
+ "auth.forgotPassword.submit": "Link anfordern",
40
+ "auth.forgotPassword.submitting": "…",
41
+ "auth.forgotPassword.successTitle": "Mail gesendet",
42
+ "auth.forgotPassword.successBody":
43
+ "Falls die E-Mail in unserem System existiert, ist eine Nachricht mit einem Reset-Link unterwegs. Bitte schau in deinen Posteingang.",
44
+ "auth.forgotPassword.backToLogin": "Zurück zum Login",
45
+ "auth.resetPassword.title": "Neues Passwort setzen",
46
+ "auth.resetPassword.intro": "Wähle ein neues Passwort (mindestens 8 Zeichen).",
47
+ "auth.resetPassword.newPassword": "Neues Passwort",
48
+ "auth.resetPassword.confirmPassword": "Passwort bestätigen",
49
+ "auth.resetPassword.mismatch": "Die Passwörter stimmen nicht überein.",
50
+ "auth.resetPassword.tooShort": "Passwort muss mindestens 8 Zeichen lang sein.",
51
+ "auth.resetPassword.submit": "Passwort speichern",
52
+ "auth.resetPassword.submitting": "…",
53
+ "auth.resetPassword.successTitle": "Passwort gesetzt",
54
+ "auth.resetPassword.successBody": "Du kannst dich jetzt mit deinem neuen Passwort anmelden.",
55
+ "auth.resetPassword.goToLogin": "Zum Login",
56
+ "auth.resetPassword.missingToken":
57
+ "Der Reset-Link enthält keinen Token. Bitte fordere einen neuen an.",
58
+ "auth.verifyEmail.verifying": "E-Mail wird bestätigt …",
59
+ "auth.verifyEmail.successTitle": "E-Mail bestätigt",
60
+ "auth.verifyEmail.successBody": "Danke! Du kannst dich jetzt anmelden.",
61
+ "auth.verifyEmail.errorTitle": "Bestätigung fehlgeschlagen",
62
+ "auth.verifyEmail.errorBody":
63
+ "Der Link ist ungültig oder abgelaufen. Bitte fordere eine neue Bestätigungs-Mail an.",
64
+ "auth.verifyEmail.goToLogin": "Zum Login",
65
+ "auth.verifyEmail.missingToken": "Der Bestätigungs-Link enthält keinen Token.",
66
+ "auth.signup.title": "Account erstellen",
67
+ "auth.signup.intro":
68
+ "Gib deine E-Mail-Adresse ein. Wir schicken dir einen Aktivierungs-Link, mit dem du dein Passwort setzt.",
69
+ "auth.signup.email": "E-Mail",
70
+ "auth.signup.submit": "Aktivierungs-Link senden",
71
+ "auth.signup.submitting": "…",
72
+ "auth.signup.successTitle": "Mail gesendet",
73
+ "auth.signup.successBody":
74
+ "Wir haben dir einen Aktivierungs-Link an deine E-Mail-Adresse geschickt. Klicke ihn an, um dein Passwort zu setzen und dich einzuloggen.",
75
+ "auth.signup.resend": "Mail erneut senden",
76
+ "auth.signup.haveAccount": "Bereits einen Account? Anmelden",
77
+ "auth.signupComplete.title": "Passwort setzen",
78
+ "auth.signupComplete.intro":
79
+ "Wähle ein Passwort (mindestens 8 Zeichen) für deinen neuen Account.",
80
+ "auth.signupComplete.password": "Passwort",
81
+ "auth.signupComplete.confirmPassword": "Passwort bestätigen",
82
+ "auth.signupComplete.tooShort": "Passwort muss mindestens 8 Zeichen lang sein.",
83
+ "auth.signupComplete.mismatch": "Die Passwörter stimmen nicht überein.",
84
+ "auth.signupComplete.submit": "Account aktivieren",
85
+ "auth.signupComplete.submitting": "…",
86
+ "auth.signupComplete.missingToken":
87
+ "Der Aktivierungs-Link enthält keinen Token. Bitte fordere einen neuen an.",
88
+ "auth.inviteAccept.title": "Einladung annehmen",
89
+ "auth.inviteAccept.intro":
90
+ "Du wurdest zu einem Workspace eingeladen. Klicke auf 'Annehmen' um Mitglied zu werden.",
91
+ "auth.inviteAccept.loggedInAs": "Du bist eingeloggt — klicke 'Annehmen' um Mitglied zu werden.",
92
+ "auth.inviteAccept.email": "E-Mail",
93
+ "auth.inviteAccept.password": "Passwort",
94
+ "auth.inviteAccept.acceptButton": "Annehmen",
95
+ "auth.inviteAccept.submit": "Annehmen + Anmelden",
96
+ "auth.inviteAccept.submitting": "…",
97
+ "auth.inviteAccept.useOtherAccount": "Mit anderem Account anmelden",
98
+ "auth.inviteAccept.toggleNew": "Ich habe noch keinen Account",
99
+ "auth.inviteAccept.toggleExisting": "Ich habe schon einen Account",
100
+ "auth.inviteAccept.missingToken": "Der Einladungs-Link enthält keinen Token oder ist ungültig.",
101
+ "auth.inviteAccept.goToLogin": "Zum Login",
102
+ "auth.user.menu.label": "Konto",
103
+ "auth.user.menu.logout": "Abmelden",
104
+ "auth.tenant.switcher.label": "Tenant",
105
+ "auth.tenant.switcher.none": "Kein Tenant",
106
+ },
107
+ en: {
108
+ "auth.login.title": "Sign in",
109
+ "auth.login.email": "Email",
110
+ "auth.login.password": "Password",
111
+ "auth.login.submit": "Sign in",
112
+ "auth.login.submitting": "…",
113
+ "auth.login.forgotPassword": "Forgot password?",
114
+ "auth.errors.invalidCredentials": "Invalid email or password.",
115
+ "auth.errors.noMembership": "This account has no tenant access.",
116
+ "auth.errors.accountLocked": "Account temporarily locked.",
117
+ "auth.errors.accountLockedRetry": "Account locked. Try again in {minutes} minutes.",
118
+ "auth.errors.emailNotVerified": "Email address not yet verified.",
119
+ "auth.errors.rateLimited": "Too many login attempts. Please wait briefly.",
120
+ "auth.errors.invalidBody": "Invalid input.",
121
+ "auth.errors.loginFailed": "Login failed.",
122
+ "auth.errors.invalidResetToken": "Link is invalid or expired. Please request a new one.",
123
+ "auth.errors.invalidVerificationToken": "Verification link is invalid or expired.",
124
+ "auth.errors.invalidSignupToken":
125
+ "Activation link is invalid or expired. Please request a new one.",
126
+ "auth.errors.unknownError": "Something went wrong. Please try again.",
127
+ "auth.forgotPassword.title": "Reset password",
128
+ "auth.forgotPassword.intro":
129
+ "Enter your email. If an account exists, we'll send you a reset link.",
130
+ "auth.forgotPassword.email": "Email",
131
+ "auth.forgotPassword.submit": "Request link",
132
+ "auth.forgotPassword.submitting": "…",
133
+ "auth.forgotPassword.successTitle": "Email sent",
134
+ "auth.forgotPassword.successBody":
135
+ "If your email exists in our system, a reset link is on its way. Please check your inbox.",
136
+ "auth.forgotPassword.backToLogin": "Back to sign in",
137
+ "auth.resetPassword.title": "Set new password",
138
+ "auth.resetPassword.intro": "Choose a new password (at least 8 characters).",
139
+ "auth.resetPassword.newPassword": "New password",
140
+ "auth.resetPassword.confirmPassword": "Confirm password",
141
+ "auth.resetPassword.mismatch": "Passwords do not match.",
142
+ "auth.resetPassword.tooShort": "Password must be at least 8 characters.",
143
+ "auth.resetPassword.submit": "Save password",
144
+ "auth.resetPassword.submitting": "…",
145
+ "auth.resetPassword.successTitle": "Password set",
146
+ "auth.resetPassword.successBody": "You can now sign in with your new password.",
147
+ "auth.resetPassword.goToLogin": "Go to sign in",
148
+ "auth.resetPassword.missingToken": "Reset link is missing a token. Please request a new one.",
149
+ "auth.verifyEmail.verifying": "Verifying email …",
150
+ "auth.verifyEmail.successTitle": "Email verified",
151
+ "auth.verifyEmail.successBody": "Thanks! You can sign in now.",
152
+ "auth.verifyEmail.errorTitle": "Verification failed",
153
+ "auth.verifyEmail.errorBody":
154
+ "Link is invalid or expired. Please request a new verification email.",
155
+ "auth.verifyEmail.goToLogin": "Go to sign in",
156
+ "auth.verifyEmail.missingToken": "Verification link is missing a token.",
157
+ "auth.signup.title": "Create account",
158
+ "auth.signup.intro":
159
+ "Enter your email. We'll send you an activation link to set your password.",
160
+ "auth.signup.email": "Email",
161
+ "auth.signup.submit": "Send activation link",
162
+ "auth.signup.submitting": "…",
163
+ "auth.signup.successTitle": "Email sent",
164
+ "auth.signup.successBody":
165
+ "We've sent you an activation link. Click it to set your password and sign in.",
166
+ "auth.signup.resend": "Send email again",
167
+ "auth.signup.haveAccount": "Already have an account? Sign in",
168
+ "auth.signupComplete.title": "Set password",
169
+ "auth.signupComplete.intro": "Choose a password (at least 8 characters) for your new account.",
170
+ "auth.signupComplete.password": "Password",
171
+ "auth.signupComplete.confirmPassword": "Confirm password",
172
+ "auth.signupComplete.tooShort": "Password must be at least 8 characters.",
173
+ "auth.signupComplete.mismatch": "Passwords do not match.",
174
+ "auth.signupComplete.submit": "Activate account",
175
+ "auth.signupComplete.submitting": "…",
176
+ "auth.signupComplete.missingToken":
177
+ "Activation link is missing a token. Please request a new one.",
178
+ "auth.inviteAccept.title": "Accept invitation",
179
+ "auth.inviteAccept.intro": "You've been invited to a workspace. Click 'Accept' to join.",
180
+ "auth.inviteAccept.loggedInAs": "Signed in as {email}",
181
+ "auth.inviteAccept.email": "Email",
182
+ "auth.inviteAccept.password": "Password",
183
+ "auth.inviteAccept.acceptButton": "Accept",
184
+ "auth.inviteAccept.submit": "Accept + sign in",
185
+ "auth.inviteAccept.submitting": "…",
186
+ "auth.inviteAccept.useOtherAccount": "Sign in with a different account",
187
+ "auth.inviteAccept.toggleNew": "I don't have an account yet",
188
+ "auth.inviteAccept.toggleExisting": "I already have an account",
189
+ "auth.inviteAccept.missingToken": "The invitation link is missing or invalid.",
190
+ "auth.inviteAccept.goToLogin": "Go to sign in",
191
+ "auth.user.menu.label": "Account",
192
+ "auth.user.menu.logout": "Sign out",
193
+ "auth.tenant.switcher.label": "Tenant",
194
+ "auth.tenant.switcher.none": "No tenant",
195
+ },
196
+ };
197
+
198
+ /** Merged zwei TranslationsByLocale-Maps — der override gewinnt pro Key,
199
+ * die Locales werden zusammengeführt. Wird von emailPasswordClient()
200
+ * benutzt, um App-Overrides über die Defaults zu legen. */
201
+ export function mergeTranslations(
202
+ base: TranslationsByLocale,
203
+ override: TranslationsByLocale,
204
+ ): TranslationsByLocale {
205
+ const locales = new Set([...Object.keys(base), ...Object.keys(override)]);
206
+ const merged: Record<string, Record<string, string>> = {};
207
+ for (const locale of locales) {
208
+ merged[locale] = { ...(base[locale] ?? {}), ...(override[locale] ?? {}) };
209
+ }
210
+ return merged;
211
+ }