@cosmicdrift/kumiko-bundled-features 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. package/package.json +90 -0
  2. package/src/audit/__tests__/audit.integration.ts +328 -0
  3. package/src/audit/constants.ts +7 -0
  4. package/src/audit/feature.ts +23 -0
  5. package/src/audit/handlers/list.query.ts +98 -0
  6. package/src/audit/index.ts +2 -0
  7. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
  8. package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
  9. package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
  10. package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
  11. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
  12. package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
  13. package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
  14. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
  15. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
  16. package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
  18. package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
  19. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
  20. package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
  21. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
  22. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
  23. package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
  24. package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
  25. package/src/auth-email-password/auth-user-row.ts +41 -0
  26. package/src/auth-email-password/constants.ts +101 -0
  27. package/src/auth-email-password/email-templates.ts +283 -0
  28. package/src/auth-email-password/feature.ts +140 -0
  29. package/src/auth-email-password/handlers/change-password.write.ts +58 -0
  30. package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
  31. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
  32. package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
  33. package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
  34. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
  35. package/src/auth-email-password/handlers/login.write.ts +208 -0
  36. package/src/auth-email-password/handlers/logout.write.ts +12 -0
  37. package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
  38. package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
  39. package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
  40. package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
  41. package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
  42. package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
  43. package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
  44. package/src/auth-email-password/i18n.ts +211 -0
  45. package/src/auth-email-password/identity-v3-hash.ts +97 -0
  46. package/src/auth-email-password/index.ts +35 -0
  47. package/src/auth-email-password/invite-token-store.ts +92 -0
  48. package/src/auth-email-password/lockout-store.ts +118 -0
  49. package/src/auth-email-password/password-hashing.ts +43 -0
  50. package/src/auth-email-password/reset-token.ts +28 -0
  51. package/src/auth-email-password/seeding.ts +183 -0
  52. package/src/auth-email-password/signed-token.ts +85 -0
  53. package/src/auth-email-password/signup-token-store.ts +104 -0
  54. package/src/auth-email-password/stream-tenant.ts +31 -0
  55. package/src/auth-email-password/testing.ts +5 -0
  56. package/src/auth-email-password/token-burn-store.ts +57 -0
  57. package/src/auth-email-password/verification-token.ts +27 -0
  58. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
  59. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
  60. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
  61. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
  62. package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
  63. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
  64. package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
  65. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
  66. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
  67. package/src/auth-email-password/web/auth-client.ts +350 -0
  68. package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
  69. package/src/auth-email-password/web/auth-gate.tsx +33 -0
  70. package/src/auth-email-password/web/client-plugin.ts +48 -0
  71. package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
  72. package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
  73. package/src/auth-email-password/web/index.ts +56 -0
  74. package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
  75. package/src/auth-email-password/web/login-screen.tsx +150 -0
  76. package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
  77. package/src/auth-email-password/web/session.tsx +171 -0
  78. package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
  79. package/src/auth-email-password/web/signup-screen.tsx +130 -0
  80. package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
  81. package/src/auth-email-password/web/use-shell-user.ts +34 -0
  82. package/src/auth-email-password/web/user-menu.tsx +89 -0
  83. package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
  84. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
  85. package/src/billing-foundation/__tests__/feature.test.ts +110 -0
  86. package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
  87. package/src/billing-foundation/aggregate-id.ts +21 -0
  88. package/src/billing-foundation/constants.ts +70 -0
  89. package/src/billing-foundation/entities.ts +50 -0
  90. package/src/billing-foundation/events.ts +71 -0
  91. package/src/billing-foundation/feature.ts +122 -0
  92. package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
  93. package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
  94. package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
  95. package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
  96. package/src/billing-foundation/handlers/process-event.write.ts +160 -0
  97. package/src/billing-foundation/index.ts +42 -0
  98. package/src/billing-foundation/projection.ts +135 -0
  99. package/src/billing-foundation/types.ts +157 -0
  100. package/src/billing-foundation/webhook-handler.ts +184 -0
  101. package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
  102. package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
  103. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
  104. package/src/cap-counter/aggregate-id.ts +61 -0
  105. package/src/cap-counter/constants.ts +32 -0
  106. package/src/cap-counter/enforce-cap.ts +404 -0
  107. package/src/cap-counter/entity.ts +48 -0
  108. package/src/cap-counter/feature.ts +90 -0
  109. package/src/cap-counter/handlers/get-counter.query.ts +43 -0
  110. package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
  111. package/src/cap-counter/handlers/increment.write.ts +92 -0
  112. package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
  113. package/src/cap-counter/index.ts +34 -0
  114. package/src/cap-counter/with-cap-enforcement.ts +179 -0
  115. package/src/channel-email/email-channel.ts +48 -0
  116. package/src/channel-email/feature.ts +15 -0
  117. package/src/channel-email/index.ts +4 -0
  118. package/src/channel-email/smtp-transport.ts +65 -0
  119. package/src/channel-email/types.ts +34 -0
  120. package/src/channel-in-app/constants.ts +11 -0
  121. package/src/channel-in-app/feature.ts +30 -0
  122. package/src/channel-in-app/handlers/inbox.query.ts +28 -0
  123. package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
  124. package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
  125. package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
  126. package/src/channel-in-app/in-app-channel.ts +44 -0
  127. package/src/channel-in-app/index.ts +4 -0
  128. package/src/channel-in-app/tables.ts +22 -0
  129. package/src/channel-push/feature.ts +15 -0
  130. package/src/channel-push/index.ts +3 -0
  131. package/src/channel-push/push-channel.ts +33 -0
  132. package/src/channel-push/types.ts +22 -0
  133. package/src/config/__tests__/app-overrides.test.ts +118 -0
  134. package/src/config/__tests__/config.integration.ts +1246 -0
  135. package/src/config/constants.ts +23 -0
  136. package/src/config/feature.ts +117 -0
  137. package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
  138. package/src/config/handlers/reset.write.ts +45 -0
  139. package/src/config/handlers/schema.query.ts +22 -0
  140. package/src/config/handlers/set.write.ts +93 -0
  141. package/src/config/handlers/values.query.ts +43 -0
  142. package/src/config/index.ts +15 -0
  143. package/src/config/resolver.ts +283 -0
  144. package/src/config/table.ts +35 -0
  145. package/src/config/write-helpers.ts +268 -0
  146. package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
  147. package/src/delivery/__tests__/delivery.integration.ts +1405 -0
  148. package/src/delivery/constants.ts +33 -0
  149. package/src/delivery/delivery-service.ts +489 -0
  150. package/src/delivery/events.ts +18 -0
  151. package/src/delivery/feature.ts +70 -0
  152. package/src/delivery/handlers/log.query.ts +21 -0
  153. package/src/delivery/handlers/preferences.query.ts +18 -0
  154. package/src/delivery/handlers/set-preference.write.ts +28 -0
  155. package/src/delivery/index.ts +35 -0
  156. package/src/delivery/tables.ts +74 -0
  157. package/src/delivery/testing.ts +47 -0
  158. package/src/delivery/types.ts +71 -0
  159. package/src/delivery/unsubscribe.ts +99 -0
  160. package/src/delivery/upsert-preference.ts +145 -0
  161. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
  162. package/src/feature-toggles/constants.ts +20 -0
  163. package/src/feature-toggles/events.ts +18 -0
  164. package/src/feature-toggles/feature.ts +98 -0
  165. package/src/feature-toggles/global-feature-state-table.ts +28 -0
  166. package/src/feature-toggles/handlers/list.query.ts +26 -0
  167. package/src/feature-toggles/handlers/registered.query.ts +56 -0
  168. package/src/feature-toggles/handlers/set.write.ts +158 -0
  169. package/src/feature-toggles/index.ts +9 -0
  170. package/src/feature-toggles/toggle-runtime.ts +73 -0
  171. package/src/file-foundation/__tests__/feature.test.ts +35 -0
  172. package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
  173. package/src/file-foundation/feature.ts +123 -0
  174. package/src/file-foundation/index.ts +7 -0
  175. package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
  176. package/src/file-provider-inmemory/feature.ts +73 -0
  177. package/src/file-provider-inmemory/index.ts +3 -0
  178. package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
  179. package/src/file-provider-s3/feature.ts +169 -0
  180. package/src/file-provider-s3/index.ts +3 -0
  181. package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
  182. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
  183. package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
  184. package/src/files-provider-s3/env-helper.ts +49 -0
  185. package/src/files-provider-s3/index.ts +3 -0
  186. package/src/files-provider-s3/s3-provider.ts +114 -0
  187. package/src/foundation-shared/config-helpers.ts +67 -0
  188. package/src/foundation-shared/index.ts +4 -0
  189. package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
  190. package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
  191. package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
  192. package/src/jobs/constants.ts +21 -0
  193. package/src/jobs/events.ts +39 -0
  194. package/src/jobs/feature.ts +150 -0
  195. package/src/jobs/handlers/detail.query.ts +30 -0
  196. package/src/jobs/handlers/list.query.ts +36 -0
  197. package/src/jobs/handlers/retry.write.ts +69 -0
  198. package/src/jobs/handlers/trigger.write.ts +39 -0
  199. package/src/jobs/index.ts +5 -0
  200. package/src/jobs/job-run-logger.ts +213 -0
  201. package/src/jobs/job-run-table.ts +55 -0
  202. package/src/legal-pages/README.md +195 -0
  203. package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
  204. package/src/legal-pages/constants.ts +36 -0
  205. package/src/legal-pages/feature.ts +187 -0
  206. package/src/legal-pages/index.ts +13 -0
  207. package/src/legal-pages/markdown.ts +69 -0
  208. package/src/mail-foundation/__tests__/feature.test.ts +46 -0
  209. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
  210. package/src/mail-foundation/feature.ts +160 -0
  211. package/src/mail-foundation/index.ts +14 -0
  212. package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
  213. package/src/mail-transport-inmemory/feature.ts +90 -0
  214. package/src/mail-transport-inmemory/index.ts +3 -0
  215. package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
  216. package/src/mail-transport-smtp/feature.ts +182 -0
  217. package/src/mail-transport-smtp/index.ts +3 -0
  218. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
  219. package/src/rate-limiting/constants.ts +9 -0
  220. package/src/rate-limiting/feature.ts +16 -0
  221. package/src/rate-limiting/handlers/status.query.ts +52 -0
  222. package/src/rate-limiting/index.ts +2 -0
  223. package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
  224. package/src/renderer-simple/feature.ts +12 -0
  225. package/src/renderer-simple/index.ts +2 -0
  226. package/src/renderer-simple/simple-renderer.ts +72 -0
  227. package/src/secrets/__tests__/rotate.integration.ts +176 -0
  228. package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
  229. package/src/secrets/__tests__/secrets.integration.ts +118 -0
  230. package/src/secrets/feature.ts +84 -0
  231. package/src/secrets/handlers/delete.write.ts +20 -0
  232. package/src/secrets/handlers/list.query.ts +38 -0
  233. package/src/secrets/handlers/rotate.job.ts +193 -0
  234. package/src/secrets/handlers/set.write.ts +50 -0
  235. package/src/secrets/index.ts +16 -0
  236. package/src/secrets/secrets-context.ts +296 -0
  237. package/src/secrets/table.ts +68 -0
  238. package/src/sessions/__tests__/cleanup.integration.ts +175 -0
  239. package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
  240. package/src/sessions/__tests__/sessions.integration.ts +472 -0
  241. package/src/sessions/__tests__/test-helpers.ts +66 -0
  242. package/src/sessions/constants.ts +43 -0
  243. package/src/sessions/feature.ts +84 -0
  244. package/src/sessions/handlers/cleanup.job.ts +109 -0
  245. package/src/sessions/handlers/list.query.ts +35 -0
  246. package/src/sessions/handlers/mine.query.ts +37 -0
  247. package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
  248. package/src/sessions/handlers/revoke.write.ts +76 -0
  249. package/src/sessions/index.ts +17 -0
  250. package/src/sessions/schema/index.ts +5 -0
  251. package/src/sessions/schema/user-session.ts +67 -0
  252. package/src/sessions/session-callbacks.ts +110 -0
  253. package/src/sessions/testing.ts +42 -0
  254. package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
  255. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
  256. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
  257. package/src/subscription-mollie/constants.ts +33 -0
  258. package/src/subscription-mollie/feature.ts +144 -0
  259. package/src/subscription-mollie/index.ts +13 -0
  260. package/src/subscription-mollie/plugin-methods.ts +79 -0
  261. package/src/subscription-mollie/verify-webhook.ts +244 -0
  262. package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
  263. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
  264. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
  265. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
  266. package/src/subscription-stripe/constants.ts +20 -0
  267. package/src/subscription-stripe/feature.ts +120 -0
  268. package/src/subscription-stripe/index.ts +14 -0
  269. package/src/subscription-stripe/plugin-methods.ts +91 -0
  270. package/src/subscription-stripe/verify-webhook.ts +235 -0
  271. package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
  272. package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
  273. package/src/tenant/__tests__/tenant.integration.ts +347 -0
  274. package/src/tenant/command-schemas.ts +37 -0
  275. package/src/tenant/constants.ts +37 -0
  276. package/src/tenant/feature.ts +109 -0
  277. package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
  278. package/src/tenant/handlers/add-member.write.ts +53 -0
  279. package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
  280. package/src/tenant/handlers/create.write.ts +21 -0
  281. package/src/tenant/handlers/disable.write.ts +18 -0
  282. package/src/tenant/handlers/invitations.query.ts +31 -0
  283. package/src/tenant/handlers/list.query.ts +17 -0
  284. package/src/tenant/handlers/me.query.ts +17 -0
  285. package/src/tenant/handlers/members.query.ts +22 -0
  286. package/src/tenant/handlers/memberships.query.ts +24 -0
  287. package/src/tenant/handlers/remove-member.write.ts +40 -0
  288. package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
  289. package/src/tenant/handlers/update-member-roles.write.ts +54 -0
  290. package/src/tenant/handlers/update.write.ts +20 -0
  291. package/src/tenant/index.ts +12 -0
  292. package/src/tenant/invitation-table.ts +93 -0
  293. package/src/tenant/membership-table.ts +35 -0
  294. package/src/tenant/schema/index.ts +5 -0
  295. package/src/tenant/schema/tenant.ts +27 -0
  296. package/src/tenant/seeding.ts +155 -0
  297. package/src/tenant/testing.ts +8 -0
  298. package/src/text-content/README.md +190 -0
  299. package/src/text-content/__tests__/text-content.integration.ts +415 -0
  300. package/src/text-content/api.ts +92 -0
  301. package/src/text-content/constants.ts +19 -0
  302. package/src/text-content/feature.ts +29 -0
  303. package/src/text-content/handlers/by-slug.query.ts +55 -0
  304. package/src/text-content/handlers/set.write.ts +118 -0
  305. package/src/text-content/index.ts +14 -0
  306. package/src/text-content/seeding.ts +91 -0
  307. package/src/text-content/table.ts +45 -0
  308. package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
  309. package/src/tier-engine/__tests__/drift.test.ts +42 -0
  310. package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
  311. package/src/tier-engine/aggregate-id.ts +27 -0
  312. package/src/tier-engine/compose-app.ts +150 -0
  313. package/src/tier-engine/constants.ts +15 -0
  314. package/src/tier-engine/entity.ts +30 -0
  315. package/src/tier-engine/feature.ts +72 -0
  316. package/src/tier-engine/handlers/active-tier.query.ts +23 -0
  317. package/src/tier-engine/index.ts +22 -0
  318. package/src/user/__tests__/seed-testing.integration.ts +127 -0
  319. package/src/user/__tests__/user.integration.ts +198 -0
  320. package/src/user/command-schemas.ts +15 -0
  321. package/src/user/constants.ts +23 -0
  322. package/src/user/feature.ts +32 -0
  323. package/src/user/handlers/create.write.ts +54 -0
  324. package/src/user/handlers/detail.query.ts +9 -0
  325. package/src/user/handlers/find-for-auth.query.ts +38 -0
  326. package/src/user/handlers/list.query.ts +8 -0
  327. package/src/user/handlers/me.query.ts +15 -0
  328. package/src/user/handlers/update.write.ts +54 -0
  329. package/src/user/index.ts +4 -0
  330. package/src/user/schema/index.ts +5 -0
  331. package/src/user/schema/user.ts +69 -0
  332. package/src/user/seeding.ts +93 -0
  333. package/src/user/testing.ts +5 -0
@@ -0,0 +1,283 @@
1
+ // Default-HTML-Renderer für die transactional Auth-Mails (Reset-Password
2
+ // + Verify-Email). Apps wiren die `sendResetEmail` / `sendVerificationEmail`
3
+ // callbacks im framework-config (siehe PasswordResetConfig im
4
+ // auth-routes.ts). Statt jede App selbst HTML zu schreiben, kann sie diese
5
+ // Renderer als one-liner nutzen:
6
+ //
7
+ // passwordReset: {
8
+ // sendResetEmail: ({ email, resetUrl, expiresAt }) =>
9
+ // mailSender.send({
10
+ // to: email,
11
+ // ...renderResetPasswordEmail({ resetUrl, expiresAt, locale: "de" }),
12
+ // }),
13
+ // }
14
+ //
15
+ // Apps die ihr eigenes Branding wollen, schreiben einen eigenen Renderer
16
+ // und mischen ihn in. Die Templates hier sind bewusst plain HTML mit
17
+ // inline-styling — kein CSS-Framework, kein bild-asset. Mail-Clients
18
+ // rendern das verlässlich, und der Operator kann das HTML im Mailer-Log
19
+ // problemlos lesen.
20
+ //
21
+ // Locale: de + en. Apps mit anderen Sprachen rendern selbst.
22
+
23
+ import { Temporal } from "temporal-polyfill";
24
+
25
+ export type AuthMailLocale = "de" | "en";
26
+
27
+ export type RenderResetPasswordEmailArgs = {
28
+ readonly resetUrl: string;
29
+ readonly expiresAt: string;
30
+ readonly locale?: AuthMailLocale;
31
+ /** Optional: App-Name fürs Subject + Header. Default "Account". */
32
+ readonly appName?: string;
33
+ };
34
+
35
+ export type RenderVerifyEmailArgs = {
36
+ readonly verificationUrl: string;
37
+ readonly expiresAt: string;
38
+ readonly locale?: AuthMailLocale;
39
+ readonly appName?: string;
40
+ };
41
+
42
+ export type RenderActivationEmailArgs = {
43
+ readonly activationUrl: string;
44
+ readonly expiresAt: string;
45
+ readonly locale?: AuthMailLocale;
46
+ readonly appName?: string;
47
+ };
48
+
49
+ export type RenderInviteEmailArgs = {
50
+ readonly inviteUrl: string;
51
+ readonly expiresAt: string;
52
+ readonly role: string;
53
+ readonly locale?: AuthMailLocale;
54
+ readonly appName?: string;
55
+ };
56
+
57
+ export type RenderedEmail = {
58
+ readonly subject: string;
59
+ readonly html: string;
60
+ };
61
+
62
+ const STRINGS = {
63
+ de: {
64
+ resetSubject: (app: string) => `${app} — Passwort zurücksetzen`,
65
+ resetGreeting: "Hallo,",
66
+ resetIntro: (app: string) =>
67
+ `du hast den Reset deines Passworts für ${app} angefordert. Klicke auf den folgenden Link, um ein neues Passwort zu setzen:`,
68
+ resetButton: "Passwort zurücksetzen",
69
+ resetExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
70
+ resetIgnore:
71
+ "Falls du keinen Reset angefordert hast, kannst du diese E-Mail einfach ignorieren — dein Passwort bleibt unverändert.",
72
+ verifySubject: (app: string) => `${app} — E-Mail bestätigen`,
73
+ verifyGreeting: "Willkommen,",
74
+ verifyIntro: (app: string) =>
75
+ `bitte bestätige deine E-Mail-Adresse für ${app}, um dein Konto zu aktivieren:`,
76
+ verifyButton: "E-Mail bestätigen",
77
+ verifyExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
78
+ verifyIgnore: "Falls du dieses Konto nicht angelegt hast, kannst du diese E-Mail ignorieren.",
79
+ activationSubject: (app: string) => `${app} — Account aktivieren`,
80
+ activationGreeting: "Willkommen,",
81
+ activationIntro: (app: string) =>
82
+ `klicke auf den folgenden Link, um deinen ${app}-Account zu aktivieren. Im nächsten Schritt setzt du dein Passwort:`,
83
+ activationButton: "Account aktivieren",
84
+ activationExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
85
+ activationIgnore:
86
+ "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren — es wird kein Account erstellt, solange du den Link nicht öffnest.",
87
+ inviteSubject: (app: string) => `${app} — Einladung zum Workspace`,
88
+ inviteGreeting: "Hallo,",
89
+ inviteIntro: (app: string, role: string) =>
90
+ `du wurdest zu einem ${app}-Workspace als ${role} eingeladen. Klicke auf den folgenden Link, um die Einladung anzunehmen:`,
91
+ inviteButton: "Einladung annehmen",
92
+ inviteExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
93
+ inviteIgnore:
94
+ "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.",
95
+ fallbackUrl: "Falls der Button nicht funktioniert, kopiere diesen Link in den Browser:",
96
+ },
97
+ en: {
98
+ resetSubject: (app: string) => `${app} — Reset your password`,
99
+ resetGreeting: "Hi,",
100
+ resetIntro: (app: string) =>
101
+ `you requested a password reset for ${app}. Click the link below to set a new password:`,
102
+ resetButton: "Reset password",
103
+ resetExpiry: (when: string) => `The link expires on ${when}.`,
104
+ resetIgnore:
105
+ "If you didn't request a reset, you can safely ignore this email — your password won't change.",
106
+ verifySubject: (app: string) => `${app} — Verify your email`,
107
+ verifyGreeting: "Welcome,",
108
+ verifyIntro: (app: string) =>
109
+ `please verify your email address for ${app} to activate your account:`,
110
+ verifyButton: "Verify email",
111
+ verifyExpiry: (when: string) => `The link expires on ${when}.`,
112
+ verifyIgnore: "If you didn't create this account, you can ignore this email.",
113
+ activationSubject: (app: string) => `${app} — Activate your account`,
114
+ activationGreeting: "Welcome,",
115
+ activationIntro: (app: string) =>
116
+ `click the link below to activate your ${app} account. The next step is choosing your password:`,
117
+ activationButton: "Activate account",
118
+ activationExpiry: (when: string) => `The link expires on ${when}.`,
119
+ activationIgnore:
120
+ "If you didn't sign up, you can ignore this email — no account is created until you open the link.",
121
+ inviteSubject: (app: string) => `${app} — Workspace invitation`,
122
+ inviteGreeting: "Hi,",
123
+ inviteIntro: (app: string, role: string) =>
124
+ `you've been invited to a ${app} workspace as ${role}. Click the link below to accept:`,
125
+ inviteButton: "Accept invitation",
126
+ inviteExpiry: (when: string) => `The link expires on ${when}.`,
127
+ inviteIgnore: "If you weren't expecting this invitation, you can ignore this email.",
128
+ fallbackUrl: "If the button doesn't work, copy this link into your browser:",
129
+ },
130
+ } as const;
131
+
132
+ // Shared shape für beide Token-Mails — heading/intro/button/expiry/ignore
133
+ // + button-Url + fallback-Url. renderResetPasswordEmail und renderVerifyEmail
134
+ // bauen den Spec aus den lokalisierten STRINGS und delegieren ans
135
+ // renderTokenEmail. Damit ist die Layout-Logik genau einmal definiert.
136
+ type TokenEmailSpec = {
137
+ readonly subject: string;
138
+ readonly greeting: string;
139
+ readonly intro: string;
140
+ readonly buttonLabel: string;
141
+ readonly buttonUrl: string;
142
+ readonly expiry: string;
143
+ readonly ignore: string;
144
+ readonly fallbackUrlLabel: string;
145
+ };
146
+
147
+ function renderTokenEmail(spec: TokenEmailSpec): RenderedEmail {
148
+ const bodyHtml = `
149
+ <tr><td>
150
+ <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(spec.greeting)}</p>
151
+ <p style="margin: 0 0 24px; font-size: 14px; line-height: 1.5;">${escapeHtml(spec.intro)}</p>
152
+ <p style="margin: 0 0 24px;">${renderButton({ url: spec.buttonUrl, label: spec.buttonLabel })}</p>
153
+ <p style="margin: 0 0 8px; font-size: 13px; color: #555;">${escapeHtml(spec.expiry)}</p>
154
+ <p style="margin: 0; font-size: 13px; color: #555;">${escapeHtml(spec.ignore)}</p>
155
+ ${renderFallbackUrl({ url: spec.buttonUrl, label: spec.fallbackUrlLabel })}
156
+ </td></tr>`;
157
+ return { subject: spec.subject, html: renderShell({ title: spec.subject, bodyHtml }) };
158
+ }
159
+
160
+ // Plain inline-styled HTML — funktioniert in Gmail/Outlook/Apple-Mail
161
+ // ohne dass wir Tailwind oder eine HTML-mail-Lib reinziehen müssen.
162
+ function renderShell(args: { title: string; bodyHtml: string }): string {
163
+ return `<!DOCTYPE html>
164
+ <html lang="en">
165
+ <head>
166
+ <meta charset="utf-8" />
167
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
168
+ <title>${escapeHtml(args.title)}</title>
169
+ </head>
170
+ <body style="margin: 0; padding: 0; background: #f7f7f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a;">
171
+ <table width="100%" cellpadding="0" cellspacing="0" style="padding: 24px 0;">
172
+ <tr>
173
+ <td align="center">
174
+ <table width="560" cellpadding="0" cellspacing="0" style="max-width: 560px; background: #ffffff; border-radius: 8px; padding: 32px;">
175
+ ${args.bodyHtml}
176
+ </table>
177
+ </td>
178
+ </tr>
179
+ </table>
180
+ </body>
181
+ </html>`;
182
+ }
183
+
184
+ function renderButton(args: { url: string; label: string }): string {
185
+ return `<a href="${escapeAttr(args.url)}" style="display: inline-block; background: #1a1a1a; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;">${escapeHtml(args.label)}</a>`;
186
+ }
187
+
188
+ function renderFallbackUrl(args: { url: string; label: string }): string {
189
+ return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${escapeAttr(args.url)}" style="color: #1a1a1a; word-break: break-all;">${escapeHtml(args.url)}</a></p>`;
190
+ }
191
+
192
+ export function renderResetPasswordEmail(args: RenderResetPasswordEmailArgs): RenderedEmail {
193
+ const locale = args.locale ?? "en";
194
+ const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
195
+ const t = STRINGS[locale];
196
+ return renderTokenEmail({
197
+ subject: t.resetSubject(appName),
198
+ greeting: t.resetGreeting,
199
+ intro: t.resetIntro(appName),
200
+ buttonLabel: t.resetButton,
201
+ buttonUrl: args.resetUrl,
202
+ expiry: t.resetExpiry(formatExpiry(args.expiresAt)),
203
+ ignore: t.resetIgnore,
204
+ fallbackUrlLabel: t.fallbackUrl,
205
+ });
206
+ }
207
+
208
+ export function renderVerifyEmail(args: RenderVerifyEmailArgs): RenderedEmail {
209
+ const locale = args.locale ?? "en";
210
+ const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
211
+ const t = STRINGS[locale];
212
+ return renderTokenEmail({
213
+ subject: t.verifySubject(appName),
214
+ greeting: t.verifyGreeting,
215
+ intro: t.verifyIntro(appName),
216
+ buttonLabel: t.verifyButton,
217
+ buttonUrl: args.verificationUrl,
218
+ expiry: t.verifyExpiry(formatExpiry(args.expiresAt)),
219
+ ignore: t.verifyIgnore,
220
+ fallbackUrlLabel: t.fallbackUrl,
221
+ });
222
+ }
223
+
224
+ export function renderActivationEmail(args: RenderActivationEmailArgs): RenderedEmail {
225
+ const locale = args.locale ?? "en";
226
+ const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
227
+ const t = STRINGS[locale];
228
+ return renderTokenEmail({
229
+ subject: t.activationSubject(appName),
230
+ greeting: t.activationGreeting,
231
+ intro: t.activationIntro(appName),
232
+ buttonLabel: t.activationButton,
233
+ buttonUrl: args.activationUrl,
234
+ expiry: t.activationExpiry(formatExpiry(args.expiresAt)),
235
+ ignore: t.activationIgnore,
236
+ fallbackUrlLabel: t.fallbackUrl,
237
+ });
238
+ }
239
+
240
+ export function renderInviteEmail(args: RenderInviteEmailArgs): RenderedEmail {
241
+ const locale = args.locale ?? "en";
242
+ const appName = args.appName ?? (locale === "de" ? "Workspace" : "Workspace");
243
+ const t = STRINGS[locale];
244
+ return renderTokenEmail({
245
+ subject: t.inviteSubject(appName),
246
+ greeting: t.inviteGreeting,
247
+ intro: t.inviteIntro(appName, args.role),
248
+ buttonLabel: t.inviteButton,
249
+ buttonUrl: args.inviteUrl,
250
+ expiry: t.inviteExpiry(formatExpiry(args.expiresAt)),
251
+ ignore: t.inviteIgnore,
252
+ fallbackUrlLabel: t.fallbackUrl,
253
+ });
254
+ }
255
+
256
+ // ISO-Timestamp aus dem Token-Handler ("2026-05-04T13:45:00.000Z") in
257
+ // "2026-05-04 13:45 UTC" rendern. Locale-unabhängig damit der Mail-
258
+ // Renderer keine locale-spezifischen Number-Formatter mitschleppt; UTC-
259
+ // Suffix damit der User unabhängig von seiner Tz sieht wann der Link
260
+ // abläuft. Bei un-parsbarem Input fällt's auf den raw-string zurück.
261
+ function formatExpiry(iso: string): string {
262
+ try {
263
+ const z = Temporal.Instant.from(iso).toZonedDateTimeISO("UTC");
264
+ return `${z.year}-${pad2(z.month)}-${pad2(z.day)} ${pad2(z.hour)}:${pad2(z.minute)} UTC`;
265
+ } catch {
266
+ return iso;
267
+ }
268
+ }
269
+
270
+ function pad2(n: number): string {
271
+ return String(n).padStart(2, "0");
272
+ }
273
+
274
+ function escapeHtml(s: string): string {
275
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
276
+ }
277
+ function escapeAttr(s: string): string {
278
+ return s
279
+ .replace(/&/g, "&amp;")
280
+ .replace(/"/g, "&quot;")
281
+ .replace(/</g, "&lt;")
282
+ .replace(/>/g, "&gt;");
283
+ }
@@ -0,0 +1,140 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { changePasswordWrite } from "./handlers/change-password.write";
3
+ import { createInviteAcceptHandler } from "./handlers/invite-accept.write";
4
+ import { createInviteAcceptWithLoginHandler } from "./handlers/invite-accept-with-login.write";
5
+ import {
6
+ createInviteCreateHandler,
7
+ type InviteCreateOptions,
8
+ } from "./handlers/invite-create.write";
9
+ import { createInviteSignupCompleteHandler } from "./handlers/invite-signup-complete.write";
10
+ import { createLoginHandler } from "./handlers/login.write";
11
+ import { logoutWrite } from "./handlers/logout.write";
12
+ import { createRequestEmailVerificationHandler } from "./handlers/request-email-verification.write";
13
+ import { createRequestPasswordResetHandler } from "./handlers/request-password-reset.write";
14
+ import { createResetPasswordHandler } from "./handlers/reset-password.write";
15
+ import { createSignupConfirmHandler } from "./handlers/signup-confirm.write";
16
+ import {
17
+ createSignupRequestHandler,
18
+ type SignupRequestOptions,
19
+ } from "./handlers/signup-request.write";
20
+ import { createVerifyEmailHandler } from "./handlers/verify-email.write";
21
+
22
+ // Opt-in configuration for the password-reset flow. When omitted the
23
+ // request-password-reset / reset-password handlers are not registered —
24
+ // the framework-level routes stay 404 and callers know the flow is off.
25
+ // Keeping this at the feature level (rather than via env) means the caller
26
+ // explicitly acknowledges that reset is wired and that they have a working
27
+ // sendResetEmail callback on the framework side.
28
+ export type PasswordResetOptions = {
29
+ readonly hmacSecret: string;
30
+ readonly tokenTtlMinutes?: number;
31
+ };
32
+
33
+ // Opt-in configuration for the email-verification flow. mode="strict"
34
+ // forces login to fail with email_not_verified when the flag is false;
35
+ // "off" registers the handlers without login-gating (useful during
36
+ // rollout so existing accounts keep working). Default: strict — if you
37
+ // wire verification at all, you probably want it enforced.
38
+ export type EmailVerificationOptions = {
39
+ readonly hmacSecret: string;
40
+ readonly tokenTtlMinutes?: number;
41
+ readonly mode?: "strict" | "off";
42
+ };
43
+
44
+ // Brute-force protection on the login handler. Omit for the defaults
45
+ // (5 failures → 15-minute lock). Set the dial knobs to override.
46
+ //
47
+ // Storage: Redis (keyed by userId). Without ctx.redis the handler skips
48
+ // lockout entirely — login still works, but brute-force protection falls
49
+ // back to the IP-rate-limiter. Counter is monotonic: only a successful
50
+ // login resets it, so after a lockout expires the next wrong password
51
+ // re-locks on attempt 1 (strict semantic — see lockout-store.ts for
52
+ // rationale).
53
+ export type AccountLockoutOptions = {
54
+ readonly maxFailedAttempts?: number;
55
+ readonly lockoutDurationMinutes?: number;
56
+ };
57
+
58
+ // Magic-Link Self-Signup. Wenn gesetzt, registriert das Feature die
59
+ // signup-request + signup-confirm-Handler. Der Token-Store (Redis)
60
+ // kommt aus ctx.redis — tokenTtlMinutes ist der einzige Knopf
61
+ // (Token-Material ist generateToken() = 256 Bit randomBytes, fest;
62
+ // Memory feedback_no_options_without_need: keine Knöpfe ohne Bedarf).
63
+ // Anders als reset/verify gibt's kein hmacSecret hier, weil der Token
64
+ // opaque random ist (Redis ist Source of Truth).
65
+ export type SignupOptions = SignupRequestOptions;
66
+
67
+ // Tenant-Invite Magic-Link. Wenn gesetzt, registriert das Feature die
68
+ // invite-create + invite-accept-Handler. Branch 2+3 (anon-flows) kommen
69
+ // als separate Handler in einem Folge-Schritt. tokenTtlMinutes Default
70
+ // 7 Tage (industry standard).
71
+ export type InviteOptions = InviteCreateOptions;
72
+
73
+ export type AuthEmailPasswordOptions = {
74
+ readonly passwordReset?: PasswordResetOptions;
75
+ readonly emailVerification?: EmailVerificationOptions;
76
+ readonly accountLockout?: AccountLockoutOptions;
77
+ readonly signup?: SignupOptions;
78
+ readonly invite?: InviteOptions;
79
+ };
80
+
81
+ // Auth feature — email+password login. Depends on the user feature for
82
+ // identity lookups (via ctx.queryAs) and on the tenant feature for
83
+ // membership resolution. No direct imports of foreign tables.
84
+ export function createAuthEmailPasswordFeature(
85
+ opts: AuthEmailPasswordOptions = {},
86
+ ): FeatureDefinition {
87
+ if (opts.passwordReset && !opts.passwordReset.hmacSecret) {
88
+ throw new Error(
89
+ "[auth-email-password] passwordReset.hmacSecret must be non-empty when passwordReset is configured",
90
+ );
91
+ }
92
+ if (opts.emailVerification && !opts.emailVerification.hmacSecret) {
93
+ throw new Error(
94
+ "[auth-email-password] emailVerification.hmacSecret must be non-empty when emailVerification is configured",
95
+ );
96
+ }
97
+
98
+ const strictVerification =
99
+ opts.emailVerification !== undefined && (opts.emailVerification.mode ?? "strict") === "strict";
100
+
101
+ return defineFeature("auth-email-password", (r) => {
102
+ r.requires("user");
103
+ r.requires("tenant");
104
+
105
+ const handlers = {
106
+ login: r.writeHandler(
107
+ createLoginHandler({
108
+ strictEmailVerification: strictVerification,
109
+ accountLockout: opts.accountLockout,
110
+ }),
111
+ ),
112
+ changePassword: r.writeHandler(changePasswordWrite),
113
+ logout: r.writeHandler(logoutWrite),
114
+ };
115
+
116
+ if (opts.passwordReset) {
117
+ r.writeHandler(createRequestPasswordResetHandler(opts.passwordReset));
118
+ r.writeHandler(createResetPasswordHandler(opts.passwordReset));
119
+ }
120
+
121
+ if (opts.emailVerification) {
122
+ r.writeHandler(createRequestEmailVerificationHandler(opts.emailVerification));
123
+ r.writeHandler(createVerifyEmailHandler(opts.emailVerification));
124
+ }
125
+
126
+ if (opts.signup) {
127
+ r.writeHandler(createSignupRequestHandler(opts.signup));
128
+ r.writeHandler(createSignupConfirmHandler());
129
+ }
130
+
131
+ if (opts.invite) {
132
+ r.writeHandler(createInviteCreateHandler(opts.invite));
133
+ r.writeHandler(createInviteAcceptHandler());
134
+ r.writeHandler(createInviteAcceptWithLoginHandler());
135
+ r.writeHandler(createInviteSignupCompleteHandler());
136
+ }
137
+
138
+ return { handlers };
139
+ });
140
+ }
@@ -0,0 +1,58 @@
1
+ import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { z } from "zod";
4
+ import { UserHandlers, UserQueries } from "../../user";
5
+ import { AuthErrors } from "../constants";
6
+ import { hashPassword, verifyPassword } from "../password-hashing";
7
+
8
+ function invalidCredentials() {
9
+ return writeFailure(
10
+ new UnprocessableError(AuthErrors.invalidCredentials, {
11
+ i18nKey: "auth.errors.invalidCredentials",
12
+ }),
13
+ );
14
+ }
15
+
16
+ // Change-password — authenticated. The user supplies their current password
17
+ // (re-auth) and the new one. The new hash is written via ctx.writeAs(system)
18
+ // against the user feature's update handler; field-access on passwordHash
19
+ // (privileged-only) lets the system identity through.
20
+ export const changePasswordWrite = defineWriteHandler({
21
+ name: "change-password",
22
+ schema: z.object({
23
+ oldPassword: z.string().min(1),
24
+ newPassword: z.string().min(8).max(200),
25
+ }),
26
+ access: { roles: access.authenticated },
27
+ handler: async (event, ctx) => {
28
+ const systemUser = createSystemUser(event.user.tenantId);
29
+
30
+ // Load self with passwordHash — only visible to the privileged caller.
31
+ const me = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
32
+ id: event.user.id,
33
+ })) as { id: number; passwordHash: string | null; version: number } | null;
34
+
35
+ if (!me?.passwordHash) {
36
+ return invalidCredentials();
37
+ }
38
+
39
+ const oldOk = await verifyPassword(me.passwordHash, event.payload.oldPassword);
40
+ if (!oldOk) {
41
+ return invalidCredentials();
42
+ }
43
+
44
+ const newHash = await hashPassword(event.payload.newPassword);
45
+
46
+ // Apply via user feature's update handler — writeAs(system) satisfies
47
+ // the privileged-only write rule on passwordHash. Pass the current version
48
+ // through so optimistic locking still applies end-to-end.
49
+ const writeRes = await ctx.writeAs(systemUser, UserHandlers.update, {
50
+ id: me.id,
51
+ version: me.version,
52
+ changes: { passwordHash: newHash },
53
+ });
54
+ if (!writeRes.isSuccess) return writeRes;
55
+
56
+ return { isSuccess: true, data: { kind: "password-changed" } };
57
+ },
58
+ });
@@ -0,0 +1,191 @@
1
+ // Shared state-change pipeline for the confirm side of out-of-band token
2
+ // flows (password-reset, email-verification). Both follow the same shape
3
+ // once the token is verified:
4
+ //
5
+ // 1. Redis check + burn (single-use enforcement)
6
+ // 2. Load user + deleted/missing/no-version guard
7
+ // 3. Optional idempotent short-circuit (verify-email when already done)
8
+ // 4. Resolve memberships → tenant-order for stream-matching
9
+ // 5. Try each tenant's stream with the handler-specific `changes`
10
+ // 6. Release the burn on ANY non-success path so a legit retry isn't
11
+ // locked out by a stale marker
12
+ //
13
+ // The top-level `runConfirmTokenFlow` orchestrates and owns the
14
+ // try/finally burn-release. Every branch that should NOT release the
15
+ // burn (success, already-done) flips `committed = true`; everything
16
+ // else — including future branches a maintainer adds — releases
17
+ // automatically.
18
+
19
+ import {
20
+ createSystemUser,
21
+ type HandlerContext,
22
+ type SessionUser,
23
+ SYSTEM_TENANT_ID,
24
+ type TenantId,
25
+ type WriteResult,
26
+ } from "@cosmicdrift/kumiko-framework/engine";
27
+ import {
28
+ InternalError,
29
+ type WriteFailure,
30
+ writeFailure,
31
+ } from "@cosmicdrift/kumiko-framework/errors";
32
+ import type Redis from "ioredis";
33
+ import { UserHandlers, UserQueries } from "../../user";
34
+ import type { AuthUserRow } from "../auth-user-row";
35
+ import { parseAuthUserRow } from "../auth-user-row";
36
+ import { orderTenantsByPreference } from "../stream-tenant";
37
+ import { burnToken, unburnToken } from "../token-burn-store";
38
+
39
+ export type ConfirmTokenFlowSpec<TSuccessData> = {
40
+ // Short purpose-tag used in the burn-store key. Must NOT overlap with
41
+ // other token flows — "reset" vs "verify" keeps cross-flow replay
42
+ // impossible at both layers (HMAC-purpose AND burn-purpose).
43
+ readonly purpose: string;
44
+ // Used verbatim in the 5xx body when ctx.redis is missing — the feature
45
+ // is misconfigured, not the caller's fault.
46
+ readonly redisRequiredMessage: string;
47
+ // Standard failure returned for every "token can't be consumed" path
48
+ // (bad state, missing memberships, every tenant rejected). The route
49
+ // layer returns 422 with a uniform code so the caller can't tell which
50
+ // branch fired.
51
+ readonly invalidToken: () => ReturnType<typeof writeFailure>;
52
+ // Handler-specific payload for user:update. Runs once per token — the
53
+ // result is shared across every tenant-stream attempt. Can be async
54
+ // (password-reset hashes here).
55
+ readonly buildChanges: (me: AuthUserRow) => Promise<Record<string, unknown>>;
56
+ // Returned verbatim on a successful write.
57
+ readonly successData: TSuccessData;
58
+ // Optional idempotent short-circuit. When `check(me)` is true, the flow
59
+ // skips the write entirely and returns `data` — but keeps the burn
60
+ // intact, because the token's job is done (state already matches what
61
+ // the write would have produced). A second click sees `already-used`.
62
+ readonly alreadyDone?: {
63
+ readonly check: (me: AuthUserRow) => boolean;
64
+ readonly data: TSuccessData;
65
+ };
66
+ };
67
+
68
+ export async function runConfirmTokenFlow<TSuccessData>(
69
+ ctx: HandlerContext,
70
+ userId: string,
71
+ expiresAtMs: number,
72
+ spec: ConfirmTokenFlowSpec<TSuccessData>,
73
+ ): Promise<WriteResult<TSuccessData>> {
74
+ if (!ctx.redis) {
75
+ return writeFailure(new InternalError({ message: spec.redisRequiredMessage }));
76
+ }
77
+ const redis: Redis = ctx.redis;
78
+
79
+ const burn = await burnToken(redis, spec.purpose, userId, expiresAtMs);
80
+ if (burn === "already-used") return spec.invalidToken();
81
+
82
+ let committed = false;
83
+ try {
84
+ // Cross-tenant queries run under a SYSTEM_TENANT-scoped identity;
85
+ // user-feature is r.systemScope so this bypasses the tenant filter.
86
+ const systemUser = createSystemUser(SYSTEM_TENANT_ID);
87
+
88
+ const me = await loadValidatedUser(ctx, systemUser, userId);
89
+ if (!me) return spec.invalidToken();
90
+
91
+ if (spec.alreadyDone?.check(me)) {
92
+ // Token job is done — keep the burn intact. A replay from another
93
+ // device lands cleanly on the already-used branch above.
94
+ committed = true;
95
+ return { isSuccess: true, data: spec.alreadyDone.data };
96
+ }
97
+
98
+ const tenantOrder = await resolveStreamTenants(ctx, systemUser, me);
99
+ if (tenantOrder.length === 0) return spec.invalidToken();
100
+
101
+ const changes = await spec.buildChanges(me);
102
+ const writeResult = await tryWriteAcrossTenants(ctx, me, tenantOrder, changes);
103
+ if (writeResult.isSuccess) {
104
+ committed = true;
105
+ return { isSuccess: true, data: spec.successData };
106
+ }
107
+ // `all_conflicts` = every tenant returned version_conflict → token-level
108
+ // failure. `hard_failure` = a real write error (DB down, access
109
+ // denied) that bubbles unchanged.
110
+ if (writeResult.reason === "all_conflicts") return spec.invalidToken();
111
+ return writeResult.failure;
112
+ } finally {
113
+ // committed===false covers EVERY failure path — including branches a
114
+ // future maintainer adds without reading this file. The original
115
+ // handlers had ~7 explicit unburn calls; any forgotten one would
116
+ // have locked the token. Flag pattern is robust-by-default.
117
+ if (!committed) {
118
+ await unburnToken(redis, spec.purpose, userId, expiresAtMs);
119
+ }
120
+ }
121
+ }
122
+
123
+ // --- Private helpers ------------------------------------------------------
124
+
125
+ // Fetches the user row via the privileged findForAuth query and validates
126
+ // it's usable for a write: not deleted, has a row.version (the version
127
+ // column is a findForAuth contract field — absence is a schema bug, but
128
+ // we still handle it gracefully rather than throwing past the burn).
129
+ // Return type narrows `version` to `number` so the write-callsite doesn't
130
+ // need a `?? 0` fallback — the guard lives here, not at every callsite.
131
+ async function loadValidatedUser(
132
+ ctx: HandlerContext,
133
+ systemUser: SessionUser,
134
+ userId: string,
135
+ ): Promise<(AuthUserRow & { version: number }) | null> {
136
+ const me = parseAuthUserRow(
137
+ await ctx.queryAs(systemUser, UserQueries.findForAuth, { id: userId }),
138
+ );
139
+ if (!me || me.isDeleted || me.version === undefined) return null;
140
+ return { ...me, version: me.version };
141
+ }
142
+
143
+ // Loads the user's memberships and returns a prioritised tenant list.
144
+ // Empty when the user has no memberships at all — the caller treats that
145
+ // as invalid_token (a user without memberships can't own a usable auth
146
+ // flow anyway, and a deterministic early-return is cleaner than
147
+ // discovering it at write time).
148
+ async function resolveStreamTenants(
149
+ ctx: HandlerContext,
150
+ systemUser: SessionUser,
151
+ me: AuthUserRow,
152
+ ): Promise<readonly TenantId[]> {
153
+ const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
154
+ userId: me.id,
155
+ })) as Array<{ tenantId: TenantId }>;
156
+ return orderTenantsByPreference(memberships, me.lastActiveTenantId);
157
+ }
158
+
159
+ // Discriminated result for the write-across-tenants loop.
160
+ // all_conflicts → every candidate rejected with version_conflict →
161
+ // token-level failure; caller returns invalidToken.
162
+ // hard_failure → a non-conflict error that should bubble unchanged
163
+ // (DB down, access denied, …); caller returns it as-is.
164
+ type TenantWriteResult =
165
+ | { isSuccess: true }
166
+ | { isSuccess: false; reason: "all_conflicts" }
167
+ | { isSuccess: false; reason: "hard_failure"; failure: WriteFailure };
168
+
169
+ // Attempts the update against each candidate stream. memberships-query
170
+ // has no deterministic ORDER BY, so the matching stream is discovered by
171
+ // attempt: version_conflict → try the next candidate, anything else →
172
+ // bubble immediately so ops sees the real failure class.
173
+ async function tryWriteAcrossTenants(
174
+ ctx: HandlerContext,
175
+ me: AuthUserRow & { version: number },
176
+ tenantOrder: readonly TenantId[],
177
+ changes: Record<string, unknown>,
178
+ ): Promise<TenantWriteResult> {
179
+ for (const tenantId of tenantOrder) {
180
+ const writeRes = await ctx.writeAs(createSystemUser(tenantId), UserHandlers.update, {
181
+ id: me.id,
182
+ version: me.version,
183
+ changes,
184
+ });
185
+ if (writeRes.isSuccess) return { isSuccess: true };
186
+ if (writeRes.error.code !== "version_conflict") {
187
+ return { isSuccess: false, reason: "hard_failure", failure: writeRes };
188
+ }
189
+ }
190
+ return { isSuccess: false, reason: "all_conflicts" };
191
+ }