@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,192 @@
|
|
|
1
|
+
// Tenant-Invite Step 2 — Branch 3 (anon User, Email NICHT registriert).
|
|
2
|
+
//
|
|
3
|
+
// Anders als signup-confirm: KEIN neuer Tenant entsteht. Der Tenant
|
|
4
|
+
// existiert schon (im invitation-row), wir legen NUR User+Membership an.
|
|
5
|
+
//
|
|
6
|
+
// Flow:
|
|
7
|
+
// 1. User klickt Invite-Link → /invite/signup?token=...
|
|
8
|
+
// 2. Frontend zeigt Password-Form (email kommt aus invitation, kein
|
|
9
|
+
// User-Input damit kein Email-Mismatch möglich)
|
|
10
|
+
// 3. User submitted password + token
|
|
11
|
+
// 4. Server:
|
|
12
|
+
// a. Token → invitationId → invitation row
|
|
13
|
+
// b. User-Existence-Check: invitation.email darf NICHT in userTable
|
|
14
|
+
// existieren (sonst soll Branch 2 oder Branch 1 genutzt werden)
|
|
15
|
+
// c. Create user (emailVerified=true wegen Magic-Link)
|
|
16
|
+
// d. Add membership im invited Tenant
|
|
17
|
+
// e. Invitation → accepted, Token gelöscht
|
|
18
|
+
// 5. Response: SessionUser + tenantId für Auto-Login
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
createEventStoreExecutor,
|
|
22
|
+
createTenantDb,
|
|
23
|
+
type DbConnection,
|
|
24
|
+
fetchOne,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
+
import {
|
|
27
|
+
createSystemUser,
|
|
28
|
+
defineWriteHandler,
|
|
29
|
+
type SessionUser,
|
|
30
|
+
type TenantId,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
+
import {
|
|
33
|
+
InternalError,
|
|
34
|
+
UnprocessableError,
|
|
35
|
+
writeFailure,
|
|
36
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
37
|
+
import { eq } from "drizzle-orm";
|
|
38
|
+
import { z } from "zod";
|
|
39
|
+
// kumiko-lint-ignore cross-feature-import invite-flow
|
|
40
|
+
import {
|
|
41
|
+
INVITATION_STATUS,
|
|
42
|
+
tenantInvitationEntity,
|
|
43
|
+
tenantInvitationsTable,
|
|
44
|
+
} from "../../tenant/invitation-table";
|
|
45
|
+
// kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
|
|
46
|
+
import { seedTenantMembership } from "../../tenant/seeding";
|
|
47
|
+
// kumiko-lint-ignore cross-feature-import existence-check
|
|
48
|
+
import { userTable } from "../../user/schema/user";
|
|
49
|
+
import { AuthErrors } from "../constants";
|
|
50
|
+
import {
|
|
51
|
+
burnInviteToken,
|
|
52
|
+
deleteInviteToken,
|
|
53
|
+
getInvitationIdForToken,
|
|
54
|
+
unburnInviteToken,
|
|
55
|
+
} from "../invite-token-store";
|
|
56
|
+
// kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
|
|
57
|
+
import { seedUserWithPassword } from "../seeding";
|
|
58
|
+
|
|
59
|
+
const InviteSignupCompleteSchema = z.object({
|
|
60
|
+
token: z.string().min(1),
|
|
61
|
+
password: z.string().min(8).max(200),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export type InviteSignupCompleteData = {
|
|
65
|
+
readonly kind: "auth-session";
|
|
66
|
+
readonly session: SessionUser;
|
|
67
|
+
readonly tenantId: TenantId;
|
|
68
|
+
readonly role: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const invitationExecutor = createEventStoreExecutor(
|
|
72
|
+
tenantInvitationsTable,
|
|
73
|
+
tenantInvitationEntity,
|
|
74
|
+
{ entityName: "tenant-invitation" },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
function invalidInviteToken() {
|
|
78
|
+
return writeFailure(
|
|
79
|
+
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
80
|
+
i18nKey: "auth.errors.invalidInviteToken",
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createInviteSignupCompleteHandler() {
|
|
86
|
+
return defineWriteHandler<
|
|
87
|
+
"invite-signup-complete",
|
|
88
|
+
typeof InviteSignupCompleteSchema,
|
|
89
|
+
InviteSignupCompleteData
|
|
90
|
+
>({
|
|
91
|
+
name: "invite-signup-complete",
|
|
92
|
+
schema: InviteSignupCompleteSchema,
|
|
93
|
+
access: { roles: ["all"] },
|
|
94
|
+
handler: async (event, ctx) => {
|
|
95
|
+
if (!ctx.redis) {
|
|
96
|
+
return writeFailure(
|
|
97
|
+
new InternalError({ message: "invite-signup-complete requires ctx.redis" }),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const invitationId = await getInvitationIdForToken(ctx.redis, event.payload.token);
|
|
102
|
+
if (!invitationId) return invalidInviteToken();
|
|
103
|
+
|
|
104
|
+
const burn = await burnInviteToken(ctx.redis, event.payload.token);
|
|
105
|
+
if (burn === "already-used") return invalidInviteToken();
|
|
106
|
+
|
|
107
|
+
let committed = false;
|
|
108
|
+
try {
|
|
109
|
+
const invitation = await fetchOne(
|
|
110
|
+
ctx.db.raw,
|
|
111
|
+
tenantInvitationsTable,
|
|
112
|
+
eq(tenantInvitationsTable.id, invitationId),
|
|
113
|
+
);
|
|
114
|
+
if (!invitation || invitation["status"] !== INVITATION_STATUS.pending)
|
|
115
|
+
return invalidInviteToken();
|
|
116
|
+
|
|
117
|
+
const invitationTenantId = invitation["tenantId"] as TenantId;
|
|
118
|
+
const invitationEmail = invitation["email"] as string;
|
|
119
|
+
const invitationRole = invitation["role"] as string;
|
|
120
|
+
const invitationVersion = invitation["version"] as number;
|
|
121
|
+
|
|
122
|
+
// User-Not-Exists-Check: wenn die Email schon registriert ist,
|
|
123
|
+
// muss der User Branch 2 (acceptWithLogin) nutzen. Hier ist
|
|
124
|
+
// explizit "neue Email" — sonst hätten wir zwei Wege ein
|
|
125
|
+
// Password zu setzen für denselben User.
|
|
126
|
+
const existingUser = await fetchOne(
|
|
127
|
+
ctx.db.raw,
|
|
128
|
+
userTable,
|
|
129
|
+
eq(userTable.email, invitationEmail),
|
|
130
|
+
);
|
|
131
|
+
if (existingUser) return invalidInviteToken();
|
|
132
|
+
|
|
133
|
+
// User anlegen via seedUserWithPassword (gleiches Pattern wie
|
|
134
|
+
// signup-confirm), emailVerified=true wegen Magic-Link.
|
|
135
|
+
// @cast-boundary db-runner — TenantDb.raw is DbRunner; seed-helpers
|
|
136
|
+
// operate on plain drizzle-API which both shapes expose identically.
|
|
137
|
+
const dbConn = ctx.db.raw as DbConnection;
|
|
138
|
+
const userId = await seedUserWithPassword(dbConn, {
|
|
139
|
+
email: invitationEmail,
|
|
140
|
+
password: event.payload.password,
|
|
141
|
+
displayName: invitationEmail.split("@")[0] ?? invitationEmail,
|
|
142
|
+
emailVerified: true,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Membership-Add via seed-helper (gleiches Pattern wie
|
|
146
|
+
// provisionSignupAccount — bypassed addMember access-check
|
|
147
|
+
// weil createSystemUser nicht ["SystemAdmin"] matcht).
|
|
148
|
+
await seedTenantMembership(dbConn, {
|
|
149
|
+
userId,
|
|
150
|
+
tenantId: invitationTenantId,
|
|
151
|
+
roles: [invitationRole],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Invitation → accepted: TenantDb für invitation-tenant
|
|
155
|
+
const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
|
|
156
|
+
const updateResult = await invitationExecutor.update(
|
|
157
|
+
{
|
|
158
|
+
id: invitationId,
|
|
159
|
+
version: invitationVersion,
|
|
160
|
+
changes: { status: INVITATION_STATUS.accepted },
|
|
161
|
+
},
|
|
162
|
+
createSystemUser(invitationTenantId),
|
|
163
|
+
invitationTdb,
|
|
164
|
+
);
|
|
165
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
166
|
+
|
|
167
|
+
await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
|
|
168
|
+
|
|
169
|
+
const session: SessionUser = {
|
|
170
|
+
id: userId,
|
|
171
|
+
tenantId: invitationTenantId,
|
|
172
|
+
roles: [invitationRole],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
committed = true;
|
|
176
|
+
return {
|
|
177
|
+
isSuccess: true,
|
|
178
|
+
data: {
|
|
179
|
+
kind: "auth-session",
|
|
180
|
+
session,
|
|
181
|
+
tenantId: invitationTenantId,
|
|
182
|
+
role: invitationRole,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
} finally {
|
|
186
|
+
if (!committed && ctx.redis) {
|
|
187
|
+
await unburnInviteToken(ctx.redis, event.payload.token);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSystemUser,
|
|
3
|
+
defineWriteHandler,
|
|
4
|
+
type SessionUser,
|
|
5
|
+
type TenantId,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { UserQueries } from "../../user";
|
|
11
|
+
import { parseAuthUserRow } from "../auth-user-row";
|
|
12
|
+
import {
|
|
13
|
+
AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
|
|
14
|
+
AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
|
|
15
|
+
AuthErrors,
|
|
16
|
+
} from "../constants";
|
|
17
|
+
import { clearLockoutState, getLockoutState, recordFailedAttempt } from "../lockout-store";
|
|
18
|
+
import { verifyPassword } from "../password-hashing";
|
|
19
|
+
|
|
20
|
+
function invalidCredentials() {
|
|
21
|
+
return writeFailure(
|
|
22
|
+
new UnprocessableError(AuthErrors.invalidCredentials, {
|
|
23
|
+
i18nKey: "auth.errors.invalidCredentials",
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function noMembership() {
|
|
29
|
+
return writeFailure(
|
|
30
|
+
new UnprocessableError(AuthErrors.noMembership, {
|
|
31
|
+
i18nKey: "auth.errors.noMembership",
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function emailNotVerified() {
|
|
37
|
+
return writeFailure(
|
|
38
|
+
new UnprocessableError(AuthErrors.emailNotVerified, {
|
|
39
|
+
i18nKey: "auth.errors.emailNotVerified",
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function accountLocked(retryAfterSeconds: number) {
|
|
45
|
+
return writeFailure(
|
|
46
|
+
new UnprocessableError(AuthErrors.accountLocked, {
|
|
47
|
+
i18nKey: "auth.errors.accountLocked",
|
|
48
|
+
// Seconds until auto-unlock — UI renders a countdown, clients can
|
|
49
|
+
// schedule a retry. Rounded up so the UI never shows 0 while the
|
|
50
|
+
// lock is still active.
|
|
51
|
+
details: { retryAfterSeconds },
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type LoginHandlerOptions = {
|
|
57
|
+
// When true, a valid (email + password) login fails with email_not_verified
|
|
58
|
+
// if the user row's emailVerified flag is false. Enumeration-leak is
|
|
59
|
+
// accepted: UX benefit ("check your email") outweighs the marginal
|
|
60
|
+
// signal since signup already surfaces the same fact.
|
|
61
|
+
readonly strictEmailVerification?: boolean;
|
|
62
|
+
// Brute-force protection: after N wrong-password attempts the account
|
|
63
|
+
// locks for the configured duration. State lives in Redis (see
|
|
64
|
+
// lockout-store.ts) — if ctx.redis is unset, lockout is skipped and the
|
|
65
|
+
// handler falls back to classic invalid-credentials. Counter is monotonic
|
|
66
|
+
// and only resets on a successful login, so a re-lock after the cooldown
|
|
67
|
+
// happens on the FIRST miss, not the Nth (strict semantic — favours
|
|
68
|
+
// brute-force resistance over UX).
|
|
69
|
+
readonly accountLockout?: {
|
|
70
|
+
readonly maxFailedAttempts?: number;
|
|
71
|
+
readonly lockoutDurationMinutes?: number;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const SYSTEM_USER_ID = "00000000-0000-4000-8000-000000000000";
|
|
76
|
+
|
|
77
|
+
// Login — unauthenticated entry point. The route is wired public (no JWT
|
|
78
|
+
// middleware), synthesising a guest SessionUser for the handler's access
|
|
79
|
+
// check. Everything inside the handler goes through ctx.queryAs(system, ...)
|
|
80
|
+
// so the user feature stays the single owner of its table.
|
|
81
|
+
export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
82
|
+
const strictVerification = opts.strictEmailVerification === true;
|
|
83
|
+
const maxFailedAttempts =
|
|
84
|
+
opts.accountLockout?.maxFailedAttempts ?? AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS;
|
|
85
|
+
const lockoutDurationMinutes =
|
|
86
|
+
opts.accountLockout?.lockoutDurationMinutes ?? AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES;
|
|
87
|
+
|
|
88
|
+
return defineWriteHandler({
|
|
89
|
+
name: "login",
|
|
90
|
+
schema: z.object({
|
|
91
|
+
email: z.email(),
|
|
92
|
+
password: z.string().min(1),
|
|
93
|
+
}),
|
|
94
|
+
access: { roles: ["all"] },
|
|
95
|
+
handler: async (event, ctx) => {
|
|
96
|
+
const systemUser = createSystemUser(SYSTEM_USER_ID);
|
|
97
|
+
|
|
98
|
+
const found = parseAuthUserRow(
|
|
99
|
+
await ctx.queryAs(systemUser, UserQueries.findForAuth, {
|
|
100
|
+
email: event.payload.email,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Uniform response on any credential mismatch (no user, wrong password,
|
|
105
|
+
// soft-deleted user) — prevents email enumeration.
|
|
106
|
+
if (!found?.passwordHash || found.isDeleted) {
|
|
107
|
+
return invalidCredentials();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Lockout gate — runs BEFORE password verification so a locked account
|
|
111
|
+
// can't be bruteforce-probed for passwords (and also can't be probed
|
|
112
|
+
// for a timing-oracle on the bcrypt verify). If Redis isn't wired,
|
|
113
|
+
// lockout is silently skipped — login still works, brute-force
|
|
114
|
+
// protection just degrades to the IP-rate-limiter at the edge.
|
|
115
|
+
if (ctx.redis) {
|
|
116
|
+
const state = await getLockoutState(ctx.redis, found.id);
|
|
117
|
+
if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
if (state.lockedUntil > now) {
|
|
120
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
|
|
121
|
+
return accountLocked(retryAfterSeconds);
|
|
122
|
+
}
|
|
123
|
+
// lockedUntil in the past — shouldn't normally happen because the
|
|
124
|
+
// Redis TTL on the until-key expires the key at the same moment
|
|
125
|
+
// as the value. Clock skew / replication lag could surface this;
|
|
126
|
+
// fall through to password verification. The counter is NOT
|
|
127
|
+
// reset — next miss re-locks immediately (strict-semantic, see
|
|
128
|
+
// lockout-store.ts).
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const passwordOk = await verifyPassword(found.passwordHash, event.payload.password);
|
|
133
|
+
if (!passwordOk) {
|
|
134
|
+
if (ctx.redis) {
|
|
135
|
+
await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
|
|
136
|
+
}
|
|
137
|
+
return invalidCredentials();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Strict verification gate — runs AFTER password check so an attacker
|
|
141
|
+
// probing "email_not_verified" needs valid credentials first. The
|
|
142
|
+
// remaining enumeration surface is "valid-cred + unverified" → accepted
|
|
143
|
+
// leak because the signup flow already told the user "check your email".
|
|
144
|
+
if (strictVerification && found.emailVerified !== true) {
|
|
145
|
+
return emailNotVerified();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Resolve tenant + roles via the tenant feature's memberships query.
|
|
149
|
+
// Returns [] if the user has no memberships — MVP: no login without an
|
|
150
|
+
// invitation, so we refuse with a dedicated error.
|
|
151
|
+
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
152
|
+
userId: found.id,
|
|
153
|
+
})) as Array<{ tenantId: TenantId; roles: readonly string[] }>;
|
|
154
|
+
|
|
155
|
+
if (memberships.length === 0) {
|
|
156
|
+
return noMembership();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const preferred =
|
|
160
|
+
found.lastActiveTenantId !== null && found.lastActiveTenantId !== undefined
|
|
161
|
+
? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
|
|
162
|
+
: undefined;
|
|
163
|
+
const chosen = preferred ?? memberships[0];
|
|
164
|
+
if (!chosen) {
|
|
165
|
+
return noMembership();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Clear the lockout state on success. DEL is idempotent, so no need
|
|
169
|
+
// to gate on "was there a counter?" — skipping the Redis round-trip
|
|
170
|
+
// entirely for users who never failed a login would optimise the hot
|
|
171
|
+
// path, but the call is microseconds and the branch isn't free either.
|
|
172
|
+
if (ctx.redis) {
|
|
173
|
+
await clearLockoutState(ctx.redis, found.id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Globale Rollen aus user.roles + tenant-membership-roles mergen.
|
|
177
|
+
// Globale Rollen (SystemAdmin etc.) bleiben so über alle tenants
|
|
178
|
+
// gleich; tenant-spezifische Rollen (Admin, User) kommen aus der
|
|
179
|
+
// membership. Dedupe via Set damit eine Rolle die in beiden Quellen
|
|
180
|
+
// steht nicht doppelt im Session-Roles landet.
|
|
181
|
+
const globalRoles = parseRoles(found.roles ?? null);
|
|
182
|
+
const mergedRoles = Array.from(new Set([...globalRoles, ...chosen.roles]));
|
|
183
|
+
const baseSession: SessionUser = {
|
|
184
|
+
id: found.id,
|
|
185
|
+
tenantId: chosen.tenantId,
|
|
186
|
+
roles: mergedRoles,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Features can contribute identity facts (team IDs, feature flags, ...)
|
|
190
|
+
// via r.authClaims(). ctx.resolveAuthClaims is a thin pass-through to
|
|
191
|
+
// dispatcher.resolveAuthClaims — same impl also used by the switch-tenant
|
|
192
|
+
// route, so login + tenant-switch stay in sync.
|
|
193
|
+
//
|
|
194
|
+
// Best-effort: if no feature registered a hook, we get an empty record
|
|
195
|
+
// back and simply omit the `claims` field from the session (keeps the
|
|
196
|
+
// shape clean for the JWT layer, which already spreads claims
|
|
197
|
+
// conditionally based on presence).
|
|
198
|
+
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
199
|
+
const session: SessionUser =
|
|
200
|
+
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
isSuccess: true,
|
|
204
|
+
data: { kind: "auth-session", session },
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
// Logout — JWT is stateless, so server-side we only return OK. A future
|
|
5
|
+
// revocation list / session table can land here without changing the
|
|
6
|
+
// route or client API. Keeping the handler makes the API shape stable.
|
|
7
|
+
export const logoutWrite = defineWriteHandler({
|
|
8
|
+
name: "logout",
|
|
9
|
+
schema: z.object({}),
|
|
10
|
+
access: { roles: access.authenticated },
|
|
11
|
+
handler: async () => ({ isSuccess: true, data: { kind: "logged-out" } }),
|
|
12
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { AUTH_VERIFY_DEFAULT_TTL_MINUTES, AuthErrors } from "../constants";
|
|
2
|
+
import { signVerificationToken } from "../verification-token";
|
|
3
|
+
import {
|
|
4
|
+
createTokenRequestHandler,
|
|
5
|
+
type TokenRequestData,
|
|
6
|
+
type TokenRequestOptions,
|
|
7
|
+
} from "./token-request-handler";
|
|
8
|
+
|
|
9
|
+
export type RequestEmailVerificationOptions = TokenRequestOptions;
|
|
10
|
+
|
|
11
|
+
export type RequestVerificationData = TokenRequestData<"verification-requested">;
|
|
12
|
+
|
|
13
|
+
export function createRequestEmailVerificationHandler(opts: RequestEmailVerificationOptions) {
|
|
14
|
+
return createTokenRequestHandler(
|
|
15
|
+
{
|
|
16
|
+
handlerName: "request-email-verification",
|
|
17
|
+
successKind: "verification-requested",
|
|
18
|
+
defaultTtlMinutes: AUTH_VERIFY_DEFAULT_TTL_MINUTES,
|
|
19
|
+
sign: signVerificationToken,
|
|
20
|
+
notConfiguredError: AuthErrors.verificationNotConfigured,
|
|
21
|
+
notConfiguredI18nKey: "auth.errors.verificationNotConfigured",
|
|
22
|
+
// Silent no-op for already-verified users. Flipped-together with
|
|
23
|
+
// unknown/deleted to keep the enumeration surface symmetric — the
|
|
24
|
+
// caller sees the same 200 regardless of whether a token was minted.
|
|
25
|
+
extraSilentSkip: (user) => user.emailVerified === true,
|
|
26
|
+
},
|
|
27
|
+
opts,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AUTH_RESET_DEFAULT_TTL_MINUTES, AuthErrors } from "../constants";
|
|
2
|
+
import { signResetToken } from "../reset-token";
|
|
3
|
+
import {
|
|
4
|
+
createTokenRequestHandler,
|
|
5
|
+
type TokenRequestData,
|
|
6
|
+
type TokenRequestOptions,
|
|
7
|
+
} from "./token-request-handler";
|
|
8
|
+
|
|
9
|
+
export type RequestPasswordResetOptions = TokenRequestOptions;
|
|
10
|
+
|
|
11
|
+
// Public shape re-exported for callers that build custom routes on top of
|
|
12
|
+
// the dispatcher (bypassing the framework's auth-routes).
|
|
13
|
+
export type RequestResetData = TokenRequestData<"reset-requested">;
|
|
14
|
+
|
|
15
|
+
export function createRequestPasswordResetHandler(opts: RequestPasswordResetOptions) {
|
|
16
|
+
return createTokenRequestHandler(
|
|
17
|
+
{
|
|
18
|
+
handlerName: "request-password-reset",
|
|
19
|
+
successKind: "reset-requested",
|
|
20
|
+
defaultTtlMinutes: AUTH_RESET_DEFAULT_TTL_MINUTES,
|
|
21
|
+
sign: signResetToken,
|
|
22
|
+
notConfiguredError: AuthErrors.resetNotConfigured,
|
|
23
|
+
notConfiguredI18nKey: "auth.errors.resetNotConfigured",
|
|
24
|
+
// Password-reset has no extra skip condition — every existing,
|
|
25
|
+
// non-deleted user can initiate a reset regardless of verification
|
|
26
|
+
// state. The sessions feature handles the post-change revocation.
|
|
27
|
+
extraSilentSkip: () => false,
|
|
28
|
+
},
|
|
29
|
+
opts,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { AuthErrors } from "../constants";
|
|
5
|
+
import { hashPassword } from "../password-hashing";
|
|
6
|
+
import { verifyResetToken } from "../reset-token";
|
|
7
|
+
import { runConfirmTokenFlow } from "./confirm-token-flow";
|
|
8
|
+
|
|
9
|
+
export type ResetPasswordOptions = {
|
|
10
|
+
readonly hmacSecret: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function invalidToken() {
|
|
14
|
+
return writeFailure(
|
|
15
|
+
new UnprocessableError(AuthErrors.invalidResetToken, {
|
|
16
|
+
i18nKey: "auth.errors.invalidResetToken",
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Confirm step of the reset flow. Token-verify happens inline; the
|
|
22
|
+
// post-verify pipeline (burn, load user, memberships, try-all-tenants,
|
|
23
|
+
// burn-release-on-failure) lives in confirm-token-flow to stay in sync
|
|
24
|
+
// with verify-email. Session revocation on password change is wired
|
|
25
|
+
// cross-feature via the sessions feature's r.entityHook("postSave",
|
|
26
|
+
// "user", ...) — no explicit revoke call needed here.
|
|
27
|
+
export function createResetPasswordHandler(opts: ResetPasswordOptions) {
|
|
28
|
+
return defineWriteHandler({
|
|
29
|
+
name: "reset-password",
|
|
30
|
+
schema: z.object({
|
|
31
|
+
token: z.string().min(1),
|
|
32
|
+
newPassword: z.string().min(8).max(200),
|
|
33
|
+
}),
|
|
34
|
+
access: { roles: ["all"] },
|
|
35
|
+
handler: async (event, ctx) => {
|
|
36
|
+
if (!opts.hmacSecret) {
|
|
37
|
+
return writeFailure(
|
|
38
|
+
new UnprocessableError(AuthErrors.resetNotConfigured, {
|
|
39
|
+
i18nKey: "auth.errors.resetNotConfigured",
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// All verify failures (malformed / bad_signature / expired) fold into
|
|
45
|
+
// the same invalid_reset_token error — a probing caller can't
|
|
46
|
+
// distinguish tampered from stale from random-string.
|
|
47
|
+
const verify = verifyResetToken(event.payload.token, opts.hmacSecret);
|
|
48
|
+
if (!verify.ok) return invalidToken();
|
|
49
|
+
|
|
50
|
+
return runConfirmTokenFlow(ctx, verify.userId, verify.expiresAtMs, {
|
|
51
|
+
purpose: "reset",
|
|
52
|
+
redisRequiredMessage: "password-reset requires ctx.redis to enforce token single-use",
|
|
53
|
+
invalidToken,
|
|
54
|
+
buildChanges: async () => ({
|
|
55
|
+
passwordHash: await hashPassword(event.payload.newPassword),
|
|
56
|
+
}),
|
|
57
|
+
successData: { kind: "password-reset" as const },
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|