@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.
- package/package.json +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- 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
|
+
}
|