@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,97 @@
1
+ // ASP.NET Core Identity V3 password-hash verifier.
2
+ //
3
+ // Why this lives in Kumiko: legacy migrations from .NET stacks (BMC: 22k
4
+ // users with Identity-V3 passwordHash) need login to keep working without
5
+ // forcing every user through a password-reset flow. New hashes are still
6
+ // argon2 — Identity-V3 is verify-only.
7
+ //
8
+ // Format specification (from ASP.NET Core Identity source —
9
+ // `Microsoft.AspNetCore.Identity.PasswordHasher`, `IdentityV3` mode):
10
+ //
11
+ // Byte 0: format marker (0x01 = V3)
12
+ // Bytes 1..4: PRF as uint32 big-endian
13
+ // 1 = HMACSHA256
14
+ // 2 = HMACSHA512
15
+ // (BMC uses 1; we accept both since the format does)
16
+ // Bytes 5..8: iteration count as uint32 big-endian
17
+ // Bytes 9..12: salt length in bytes as uint32 big-endian
18
+ // Bytes 13..: salt + derived subkey, concatenated
19
+ //
20
+ // The whole blob is base64-encoded. Typical BMC hash starts with
21
+ // "AQAAAAEAACcQ..." which decodes to:
22
+ // 0x01 (V3) | 0x00000001 (HMACSHA256) | 0x00002710 (10000 iter) | …
23
+ //
24
+ // We never produce these — `hashPassword()` (argon2id) is the canonical
25
+ // path. After a successful Identity-V3 login the application can re-hash
26
+ // the password into argon2 on the next change-password event; that's
27
+ // out-of-scope here.
28
+
29
+ import { pbkdf2Sync, timingSafeEqual } from "node:crypto";
30
+
31
+ const FORMAT_MARKER_V3 = 0x01;
32
+ const HEADER_LENGTH = 13; // 1 (format) + 4 (PRF) + 4 (iter) + 4 (saltLen)
33
+
34
+ const PRF_HMAC_SHA256 = 1;
35
+ const PRF_HMAC_SHA512 = 2;
36
+
37
+ // Quick sniff so the caller can route between argon2 and Identity-V3 without
38
+ // throwing parse errors on every login. Only checks the format marker; the
39
+ // full structural validation happens in `verifyIdentityV3Hash`.
40
+ export function isIdentityV3Hash(hashB64: string): boolean {
41
+ const bytes = decodeBase64(hashB64);
42
+ if (bytes === null) return false;
43
+ return bytes.length >= HEADER_LENGTH && bytes[0] === FORMAT_MARKER_V3;
44
+ }
45
+
46
+ // Returns true on match. False on any mismatch — wrong password, malformed
47
+ // hash, unsupported PRF, garbled length fields. Never throws (mirrors
48
+ // `verifyPassword`'s contract — auth handlers don't want exceptions on
49
+ // pathological stored data, just a clean "no").
50
+ export function verifyIdentityV3Hash(password: string, hashB64: string): boolean {
51
+ const bytes = decodeBase64(hashB64);
52
+ if (bytes === null) return false;
53
+ if (bytes.length < HEADER_LENGTH) return false;
54
+ if (bytes[0] !== FORMAT_MARKER_V3) return false;
55
+
56
+ const prf = bytes.readUInt32BE(1);
57
+ const iterations = bytes.readUInt32BE(5);
58
+ const saltLength = bytes.readUInt32BE(9);
59
+
60
+ // Defensive: ASP.NET writes 16-byte salts, but the format technically
61
+ // allows any length. We accept what's encoded but bail if the blob is
62
+ // truncated mid-salt.
63
+ if (saltLength === 0) return false;
64
+ if (bytes.length <= HEADER_LENGTH + saltLength) return false; // need ≥1 subkey byte
65
+
66
+ const salt = bytes.subarray(HEADER_LENGTH, HEADER_LENGTH + saltLength);
67
+ const subkey = bytes.subarray(HEADER_LENGTH + saltLength);
68
+
69
+ const algorithm = prfToNodeAlgorithm(prf);
70
+ if (algorithm === null) return false;
71
+
72
+ let derived: Buffer;
73
+ try {
74
+ derived = pbkdf2Sync(password, salt, iterations, subkey.length, algorithm);
75
+ } catch {
76
+ return false;
77
+ }
78
+
79
+ if (derived.length !== subkey.length) return false;
80
+ return timingSafeEqual(derived, subkey);
81
+ }
82
+
83
+ function prfToNodeAlgorithm(prf: number): "sha256" | "sha512" | null {
84
+ if (prf === PRF_HMAC_SHA256) return "sha256";
85
+ if (prf === PRF_HMAC_SHA512) return "sha512";
86
+ return null;
87
+ }
88
+
89
+ function decodeBase64(b64: string): Buffer | null {
90
+ // Lenient decode: Buffer.from strips whitespace and ignores trailing garbage,
91
+ // which is what we want for hashes pulled out of CSV exports / legacy DBs
92
+ // that might carry stray CR/LF.
93
+ if (typeof b64 !== "string" || b64.length === 0) return null;
94
+ const buf = Buffer.from(b64, "base64");
95
+ if (buf.length === 0) return null;
96
+ return buf;
97
+ }
@@ -0,0 +1,35 @@
1
+ export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
2
+ // Default-HTML-Renderer für die Reset-Password + Verify-Email Mails.
3
+ // Apps wiren die `sendResetEmail` / `sendVerificationEmail` callbacks
4
+ // im framework-config — diese Renderer können als one-liner genutzt
5
+ // werden, oder die App schreibt einen eigenen Renderer für Branding.
6
+ export type {
7
+ AuthMailLocale,
8
+ RenderActivationEmailArgs,
9
+ RenderedEmail,
10
+ RenderInviteEmailArgs,
11
+ RenderResetPasswordEmailArgs,
12
+ RenderVerifyEmailArgs,
13
+ } from "./email-templates";
14
+ export {
15
+ renderActivationEmail,
16
+ renderInviteEmail,
17
+ renderResetPasswordEmail,
18
+ renderVerifyEmail,
19
+ } from "./email-templates";
20
+ export type {
21
+ AccountLockoutOptions,
22
+ AuthEmailPasswordOptions,
23
+ EmailVerificationOptions,
24
+ InviteOptions,
25
+ PasswordResetOptions,
26
+ SignupOptions,
27
+ } from "./feature";
28
+ export { createAuthEmailPasswordFeature } from "./feature";
29
+ export { hashPassword, verifyPassword } from "./password-hashing";
30
+ // Generic HMAC-signed single-purpose token helpers. Re-exported damit
31
+ // app-spezifische out-of-band-Flows (subscriber-confirm, magic-links,
32
+ // invite-tokens) denselben battle-tested signer/verifier nutzen können
33
+ // statt eigene HMAC-Logik zu duplizieren. Purpose-string diskriminiert
34
+ // Cross-Replay zwischen Flows.
35
+ export { signToken, TokenPurpose, type VerifyResult, verifyToken } from "./signed-token";
@@ -0,0 +1,92 @@
1
+ // Redis-backed Token-Store für Tenant-Invite-Magic-Link-Flow.
2
+ //
3
+ // Subject ist die Invitation-Row-ID (DB-row owner: tenant-feature). Wir
4
+ // mappen Token → invitationId in Redis und nutzen den Token als opaque
5
+ // random string aus generateToken (256 bit base64url, randomBytes).
6
+ //
7
+ // Anders als signup-token-store mappen wir hier NICHT bidirektional
8
+ // — Resend-Idempotenz lebt auf der Invitation-Row-Ebene (Admin invitet
9
+ // dieselbe email zweimal → existing row + token wird re-genutzt; das
10
+ // invite-create-handler holt den existing token aus Redis via
11
+ // invitationId-Lookup auf einem zweiten Key).
12
+ //
13
+ // Bidirektional ist trotzdem nützlich für Cancel: Admin cancelt → row.id
14
+ // bekannt, ich brauche den token um Redis-Key zu löschen. Daher: zweiter
15
+ // Key invite:by-id:<invitationId> → token. Cancel löscht beide.
16
+ //
17
+ // Bug-Pattern: TTL liegt nur in Redis. DB-row.expiresAt ist UI-Anzeige.
18
+ // Bei expired-token: invite-accept findet den Token nicht → invalid-
19
+ // invite-token. DB-row bleibt mit status="pending" — Cleanup-Job
20
+ // markiert sie zu "expired" (separater Concern, kommt im U.3-Cleanup).
21
+ //
22
+ // Keine Kollision mit signup/reset/verify-Tokens: alle Invite-Keys haben
23
+ // `invite:`-Prefix.
24
+
25
+ import type Redis from "ioredis";
26
+
27
+ const TOKEN_KEY_PREFIX = "invite:by-token:";
28
+ const ID_KEY_PREFIX = "invite:by-id:";
29
+ const BURN_KEY_PREFIX = "invite:burn:";
30
+
31
+ function tokenKey(token: string): string {
32
+ return `${TOKEN_KEY_PREFIX}${token}`;
33
+ }
34
+ function idKey(invitationId: string): string {
35
+ return `${ID_KEY_PREFIX}${invitationId}`;
36
+ }
37
+ function burnKey(token: string): string {
38
+ return `${BURN_KEY_PREFIX}${token}`;
39
+ }
40
+
41
+ /** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
42
+ * Idempotent — re-write derselben Token-Invitation-Kombi ist OK
43
+ * (refresh TTL für Resend). */
44
+ export async function storeInviteToken(
45
+ redis: Redis,
46
+ args: { invitationId: string; token: string; ttlSeconds: number },
47
+ ): Promise<void> {
48
+ await Promise.all([
49
+ redis.set(tokenKey(args.token), args.invitationId, "EX", args.ttlSeconds),
50
+ redis.set(idKey(args.invitationId), args.token, "EX", args.ttlSeconds),
51
+ ]);
52
+ }
53
+
54
+ /** Lookup: invitationId für Token. Null wenn Token nicht (mehr) existiert
55
+ * (abgelaufen, schon konsumiert, oder ungültig). */
56
+ export async function getInvitationIdForToken(redis: Redis, token: string): Promise<string | null> {
57
+ return redis.get(tokenKey(token));
58
+ }
59
+
60
+ /** Lookup: Existierender Token für eine invitationId — für Resend-
61
+ * Idempotenz (Admin invitet dieselbe email zweimal → re-use token). */
62
+ export async function getTokenForInvitation(
63
+ redis: Redis,
64
+ invitationId: string,
65
+ ): Promise<string | null> {
66
+ return redis.get(idKey(invitationId));
67
+ }
68
+
69
+ /** Single-Use-Burn. Wenn zwei Tabs gleichzeitig den Accept-Link klicken,
70
+ * gewinnt der erste, der zweite kriegt "already-used". TTL = 1h. */
71
+ export async function burnInviteToken(
72
+ redis: Redis,
73
+ token: string,
74
+ ): Promise<"burned" | "already-used"> {
75
+ const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
76
+ return result === "OK" ? "burned" : "already-used";
77
+ }
78
+
79
+ /** Cleanup nach erfolgreichem Accept ODER Cancel — beide Lookup-Keys
80
+ * löschen. Burn-Key bleibt für die restliche Burn-TTL als Replay-Schutz. */
81
+ export async function deleteInviteToken(
82
+ redis: Redis,
83
+ args: { invitationId: string; token: string },
84
+ ): Promise<void> {
85
+ await Promise.all([redis.del(tokenKey(args.token)), redis.del(idKey(args.invitationId))]);
86
+ }
87
+
88
+ /** Burn-Release für Failed-Accept-Pfade (DB-Error etc.) damit ein
89
+ * legitimer Retry nicht durch einen stale Burn-Marker geblockt wird. */
90
+ export async function unburnInviteToken(redis: Redis, token: string): Promise<void> {
91
+ await redis.del(burnKey(token));
92
+ }
@@ -0,0 +1,118 @@
1
+ // Redis-backed account-lockout state for the login handler.
2
+ //
3
+ // Why Redis, not DB? The login handler returns WriteFailure on bad
4
+ // credentials — the dispatcher rolls back the whole transaction, which
5
+ // would wipe a DB-based counter-update alongside the "invalid credentials"
6
+ // response. Redis operations run outside the DB tx and survive the rollback.
7
+ // Consistent with token-burn-store.ts, which uses Redis for the same reason
8
+ // (state that must persist regardless of the handler's WriteResult).
9
+ //
10
+ // Persistence note: in prod, configure Redis with AOF or RDB so lockout
11
+ // state survives Redis restart. Without persistence, a restart resets every
12
+ // active counter — an attacker could exploit the gap, though the IP-level
13
+ // rate-limiter (framework rate-limit) is the parallel defense for that
14
+ // case anyway.
15
+
16
+ import type Redis from "ioredis";
17
+
18
+ export type LockoutState = {
19
+ readonly failureCount: number;
20
+ // Epoch milliseconds when the account auto-unlocks. null while the
21
+ // counter is still below threshold.
22
+ readonly lockedUntil: number | null;
23
+ };
24
+
25
+ // Two keys per user so each can carry its own TTL:
26
+ // - count-key: 24h, carries the streak. Monotonic — once threshold is
27
+ // crossed it STAYS crossed until a successful login clears it.
28
+ // - until-key: exactly the lockout duration, auto-expires when the lock
29
+ // ends (Redis TTL replaces a "timer" that would otherwise need a job).
30
+ //
31
+ // Consequence of the monotonic counter: once a user has been locked, the
32
+ // NEXT wrong password after the lock expires re-locks immediately — the
33
+ // INCR still returns a value ≥ threshold, so the SET NX re-arms the lock.
34
+ // A successful login is the only way to reset the streak. Intentional:
35
+ // brute-force resistance favours strictness over UX, and a legitimate user
36
+ // who hit the lock once can just log in correctly to clear it.
37
+ const COUNT_KEY_PREFIX = "kumiko:auth:lockout:count:";
38
+ const UNTIL_KEY_PREFIX = "kumiko:auth:lockout:until:";
39
+
40
+ function countKey(userId: string): string {
41
+ return `${COUNT_KEY_PREFIX}${userId}`;
42
+ }
43
+ function untilKey(userId: string): string {
44
+ return `${UNTIL_KEY_PREFIX}${userId}`;
45
+ }
46
+
47
+ export async function getLockoutState(redis: Redis, userId: string): Promise<LockoutState | null> {
48
+ const [countRaw, untilRaw] = await redis.mget(countKey(userId), untilKey(userId));
49
+ if (countRaw === null) return null;
50
+ const failureCount = Number(countRaw);
51
+ if (!Number.isFinite(failureCount)) return null;
52
+ const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
53
+ return {
54
+ failureCount,
55
+ lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
56
+ };
57
+ }
58
+
59
+ // Race-free: INCR is atomic at the Redis level, so N concurrent wrong-
60
+ // password attempts produce exactly N increments — no GET-SET window to
61
+ // lose an increment through. The NX on the until-key likewise guarantees
62
+ // only one attempt out of a concurrent batch sets the lock timestamp;
63
+ // subsequent concurrent attempts find the key already set and leave it
64
+ // alone, so the lock window stays anchored to the first-to-cross, not
65
+ // the last.
66
+ export async function recordFailedAttempt(
67
+ redis: Redis,
68
+ userId: string,
69
+ maxFailedAttempts: number,
70
+ lockoutDurationMinutes: number,
71
+ ): Promise<LockoutState> {
72
+ const lockDurationMs = lockoutDurationMinutes * 60 * 1000;
73
+ // TTL on the count-key: 24h covers "I fat-fingered yesterday". The
74
+ // lockout duration is on the until-key; the count-key outlives it so an
75
+ // expired lock leaves a counter ≥ threshold — that's what makes the next
76
+ // miss immediately re-lock (strict-semantic; see the type-comment above).
77
+ const ttlSec = Math.max(lockoutDurationMinutes * 60, 24 * 3600);
78
+
79
+ const count = await redis.incr(countKey(userId));
80
+ if (count === 1) {
81
+ // First failure → set the TTL. INCR doesn't set one; a counter without
82
+ // TTL would leak forever for users that never return.
83
+ await redis.expire(countKey(userId), ttlSec);
84
+ }
85
+
86
+ let lockedUntil: number | null = null;
87
+ if (count >= maxFailedAttempts) {
88
+ const computedUntil = Date.now() + lockDurationMs;
89
+ // NX: only set if no lock is currently armed. A second concurrent attempt
90
+ // arriving after the first crossed the threshold must NOT reset the
91
+ // timer — the lock window should align with the attempt that crossed,
92
+ // not the one that happened a millisecond later.
93
+ const setOk = await redis.set(
94
+ untilKey(userId),
95
+ String(computedUntil),
96
+ "PX",
97
+ lockDurationMs,
98
+ "NX",
99
+ );
100
+ if (setOk === "OK") {
101
+ lockedUntil = computedUntil;
102
+ } else {
103
+ // Another concurrent attempt already locked — read the authoritative
104
+ // timestamp so the returned state matches what a follow-up
105
+ // getLockoutState would see.
106
+ const existing = await redis.get(untilKey(userId));
107
+ lockedUntil = existing !== null ? Number(existing) : null;
108
+ }
109
+ }
110
+
111
+ return { failureCount: count, lockedUntil };
112
+ }
113
+
114
+ // Called on successful login. Idempotent — deleting missing keys is a no-op.
115
+ // The ONLY path that resets the counter; intentional.
116
+ export async function clearLockoutState(redis: Redis, userId: string): Promise<void> {
117
+ await redis.del(countKey(userId), untilKey(userId));
118
+ }
@@ -0,0 +1,43 @@
1
+ import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";
2
+ import { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
3
+
4
+ // OWASP-recommended argon2id parameters (2024 guidance):
5
+ // memoryCost: 19 MiB, timeCost: 2, parallelism: 1
6
+ // These strike a balance between login latency (~20ms on typical hardware)
7
+ // and brute-force resistance. If hashing becomes a bottleneck, tune memoryCost
8
+ // before parallelism — memory hardness is what defeats GPU attacks.
9
+ //
10
+ // algorithm: 2 = Argon2id (best of argon2i + argon2d).
11
+ // We inline the numeric value instead of importing Algorithm because the
12
+ // @node-rs/argon2 enum is const and breaks verbatimModuleSyntax imports.
13
+ const HASH_OPTIONS = {
14
+ algorithm: 2,
15
+ memoryCost: 19456,
16
+ timeCost: 2,
17
+ parallelism: 1,
18
+ } as const;
19
+
20
+ export async function hashPassword(password: string): Promise<string> {
21
+ return argonHash(password, HASH_OPTIONS);
22
+ }
23
+
24
+ // Returns true if the password matches. Never throws on wrong passwords —
25
+ // only on malformed hash strings (which would be a bug, not a login attempt).
26
+ //
27
+ // Two verifier paths:
28
+ // - argon2id (default, what `hashPassword` produces)
29
+ // - ASP.NET Core Identity V3 (verify-only, for legacy migrations from .NET
30
+ // stacks). Sniffed via the format marker; on a successful match the
31
+ // application can rehash to argon2 at the next password-change event.
32
+ export async function verifyPassword(hashString: string, password: string): Promise<boolean> {
33
+ if (isIdentityV3Hash(hashString)) {
34
+ return verifyIdentityV3Hash(password, hashString);
35
+ }
36
+ try {
37
+ return await argonVerify(hashString, password);
38
+ } catch {
39
+ // argon2 throws on unparseable hash — treat as mismatch rather than 500
40
+ // to avoid revealing which accounts have corrupted stored hashes.
41
+ return false;
42
+ }
43
+ }
@@ -0,0 +1,28 @@
1
+ // Thin wrapper around signed-token.ts pinning the purpose to "reset".
2
+ // Handlers keep their terse API (signResetToken / verifyResetToken) while
3
+ // the shared HMAC logic lives in one place. verification-token.ts mirrors
4
+ // this pattern with purpose="verify".
5
+
6
+ import type { Temporal } from "temporal-polyfill";
7
+ import { signToken, TokenPurpose, verifyToken } from "./signed-token";
8
+
9
+ export type VerifyResult =
10
+ | { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
11
+ | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
12
+
13
+ export function signResetToken(
14
+ userId: string,
15
+ ttlMinutes: number,
16
+ secret: string,
17
+ now?: Temporal.Instant,
18
+ ): { token: string; expiresAt: Temporal.Instant } {
19
+ return signToken(userId, TokenPurpose.passwordReset, ttlMinutes, secret, now);
20
+ }
21
+
22
+ export function verifyResetToken(
23
+ token: string,
24
+ secret: string,
25
+ now?: Temporal.Instant,
26
+ ): VerifyResult {
27
+ return verifyToken(token, TokenPurpose.passwordReset, secret, now);
28
+ }
@@ -0,0 +1,183 @@
1
+ // Stable seeding helpers fürs auth-email-password-Feature. Liegen unter
2
+ // `/seeding` (nicht `/testing`) damit der Vertrag klar ist: hier ist
3
+ // non-test code der bei jedem Dev-Boot + jedem Integration-Test läuft.
4
+ // Test-spezifische Variationen (account-locked-Setup, expired-token,
5
+ // race-conditions) werden NICHT als Knöpfe an diesen Helpers angebaut —
6
+ // sie kommen als neue Funktionen daneben oder inline ins Test-File.
7
+ //
8
+ // Bündelt drei Schritte in einem Aufruf:
9
+ // 1. argon2-Hash des Plain-Passworts
10
+ // 2. seedUser() aus user/seeding
11
+ // 3. seedTenant + seedTenantMembership aus tenant/seeding
12
+ // Damit Sample-Server und Tests keine drei sub-paths zusammensammeln
13
+ // müssen.
14
+
15
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
16
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
18
+ // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
19
+ import { seedTenant, seedTenantMembership } from "../tenant/seeding";
20
+ // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
21
+ import { seedUser } from "../user/seeding";
22
+ import { hashPassword } from "./password-hashing";
23
+
24
+ // Re-export für ergonomische Single-Import-Site in tests/seed-scripts.
25
+ // Das Auth-Feature ist der natürliche Aufrufer für "seed admin user mit
26
+ // password + tenant + membership" — wer das nutzt soll nicht aus drei
27
+ // verschiedenen sub-paths zusammensammeln müssen.
28
+ // kumiko-lint-ignore cross-feature-import re-export of test-helpers
29
+ export { seedTenant, seedTenantMembership } from "../tenant/seeding";
30
+ // kumiko-lint-ignore cross-feature-import re-export of test-helpers
31
+ export { seedUser } from "../user/seeding";
32
+
33
+ export type SeedUserWithPasswordOptions = {
34
+ readonly email: string;
35
+ readonly password: string;
36
+ readonly displayName: string;
37
+ readonly locale?: string;
38
+ /** Globale Rollen — siehe SeedUserOptions.roles. */
39
+ readonly roles?: readonly string[];
40
+ /** Initial-emailVerified-Flag. Default false (Verify-Flow läuft).
41
+ * Magic-Link-Signup setzt true weil der Mail-Klick die Email-
42
+ * Ownership beweist. Siehe SeedUserOptions.emailVerified. */
43
+ readonly emailVerified?: boolean;
44
+ readonly by?: SessionUser;
45
+ };
46
+
47
+ /**
48
+ * Seed a user mit Plain-Password (wird vor dem Insert mit argon2
49
+ * gehasht). Liefert userId, idempotent über email.
50
+ */
51
+ export async function seedUserWithPassword(
52
+ db: DbConnection,
53
+ options: SeedUserWithPasswordOptions,
54
+ ): Promise<string> {
55
+ const passwordHash = await hashPassword(options.password);
56
+ return seedUser(db, {
57
+ email: options.email,
58
+ displayName: options.displayName,
59
+ passwordHash,
60
+ ...(options.locale !== undefined && { locale: options.locale }),
61
+ ...(options.roles !== undefined && { roles: options.roles }),
62
+ ...(options.emailVerified !== undefined && { emailVerified: options.emailVerified }),
63
+ ...(options.by !== undefined && { by: options.by }),
64
+ });
65
+ }
66
+
67
+ /** Provisioning-Helper für Self-Signup-Confirm. Legt einen frischen
68
+ * Tenant + Admin-User + Membership in einem Rutsch an — verwendet die
69
+ * bestehende Event-Store-Pipeline (wie seedAdmin) und ist daher
70
+ * konsistent mit dem regulären create-Pfad: events werden geschrieben,
71
+ * Projections sind populated, MSPs/Audit sehen die neuen Rows.
72
+ *
73
+ * Naming-Hinweis: nutzt intern `seedTenant` / `seedUser*` —
74
+ * diese Helpers sind production-grade (event-store-pipeline), das "seed"
75
+ * im Namen ist historisch (zuerst für Tests + Bootstrap gebaut, dann
76
+ * als General-Purpose-Helper exportiert). Rename `seed*` → `provision*`
77
+ * ist als dedizierter Cleanup-PR geplant — disproportional zum Wert
78
+ * innerhalb dieses Sprints, weil alle existing tests berührt würden.
79
+ *
80
+ * Atomicity: läuft inside einer Drizzle-Tx wenn der Caller das angibt
81
+ * (db.transaction(tx => provisionSignupAccount(tx, ...)) — die seed-
82
+ * helpers nehmen DbConnection|DbTx strukturell. Bei pure DbConnection
83
+ * sind die 3 writes nicht atomic; bei Failure zwischen Schritten kann
84
+ * ein orphan-Tenant zurückbleiben (Tenant ohne User → unused row;
85
+ * User ohne Membership → "no_membership" beim ersten Login).
86
+ *
87
+ * Nicht idempotent: ein zweiter Aufruf für dieselbe Email wirft (über
88
+ * seedTenant + seedUser deren idempotenz-Check sich an key/email
89
+ * orientiert; bei collidierenden tenantKey ist der Caller
90
+ * verantwortlich, einen freien zu finden — siehe generateUniqueName). */
91
+ /** Default-Roles für den Self-Signup-Admin. Geteilt zwischen
92
+ * provisionSignupAccount (DB-write) und signup-confirm-handler
93
+ * (SessionUser-Konstruktion für JWT-Mint) — sonst hätten zwei
94
+ * Stellen unabhängig "Admin" hardcoded und würden bei einem
95
+ * Refactor zu role-mismatch zwischen DB und Session leiden. */
96
+ export const INITIAL_SIGNUP_ROLES = ["Admin"] as const;
97
+
98
+ export type ProvisionSignupAccountOptions = {
99
+ readonly email: string;
100
+ readonly password: string;
101
+ readonly displayName: string;
102
+ readonly tenantKey: string;
103
+ readonly tenantName: string;
104
+ readonly tenantId: TenantId;
105
+ readonly memberRoles?: readonly string[];
106
+ };
107
+
108
+ export async function provisionSignupAccount(
109
+ db: DbConnection,
110
+ options: ProvisionSignupAccountOptions,
111
+ ): Promise<{ readonly userId: string; readonly tenantId: TenantId }> {
112
+ await seedTenant(db, {
113
+ id: options.tenantId,
114
+ key: options.tenantKey,
115
+ name: options.tenantName,
116
+ });
117
+ const userId = await seedUserWithPassword(db, {
118
+ email: options.email,
119
+ password: options.password,
120
+ displayName: options.displayName,
121
+ emailVerified: true,
122
+ });
123
+ await seedTenantMembership(db, {
124
+ userId,
125
+ tenantId: options.tenantId,
126
+ roles: options.memberRoles ?? INITIAL_SIGNUP_ROLES,
127
+ });
128
+ return { userId, tenantId: options.tenantId };
129
+ }
130
+
131
+ export type SeedAdminOptions = {
132
+ readonly email: string;
133
+ readonly password: string;
134
+ readonly displayName: string;
135
+ /** Tenants, in die der Admin als Mitglied eingetragen wird. Pro
136
+ * Tenant kann eine eigene Rollenliste gesetzt werden — hilft beim
137
+ * Sample-TenantSwitcher der pro Tenant unterschiedliche
138
+ * Rollen-Listen zeigt. */
139
+ readonly memberships: ReadonlyArray<{
140
+ readonly tenantId: TenantId;
141
+ readonly tenantKey: string;
142
+ readonly tenantName: string;
143
+ readonly roles: readonly string[];
144
+ }>;
145
+ /** Globale Rollen die in users.roles landen — tenant-unabhängig.
146
+ * Login-Handler mergt sie in jede Session parallel zu den tenant-
147
+ * membership-Rollen. Typischer use-case: `["SystemAdmin"]` für
148
+ * einen Plattform-Operator. Default: leer. */
149
+ readonly globalRoles?: readonly string[];
150
+ readonly by?: SessionUser;
151
+ };
152
+
153
+ /**
154
+ * Seed-Convenience für Sample-Server: Admin-User mit gehashtem
155
+ * Password + N Tenants + N Memberships. Alles idempotent (Re-Run im
156
+ * persistent-DB-Modus läuft durch). Liefert die userId zurück.
157
+ */
158
+ export async function seedAdmin(db: DbConnection, options: SeedAdminOptions): Promise<string> {
159
+ const by = options.by ?? TestUsers.systemAdmin;
160
+
161
+ for (const m of options.memberships) {
162
+ await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
163
+ }
164
+
165
+ const userId = await seedUserWithPassword(db, {
166
+ email: options.email,
167
+ password: options.password,
168
+ displayName: options.displayName,
169
+ ...(options.globalRoles !== undefined && { roles: options.globalRoles }),
170
+ by,
171
+ });
172
+
173
+ for (const m of options.memberships) {
174
+ await seedTenantMembership(db, {
175
+ userId,
176
+ tenantId: m.tenantId,
177
+ roles: m.roles,
178
+ by,
179
+ });
180
+ }
181
+
182
+ return userId;
183
+ }