@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,203 @@
|
|
|
1
|
+
// Tenant-Invite Step 2 — Branch 2 (anon User mit existing email).
|
|
2
|
+
//
|
|
3
|
+
// Flow:
|
|
4
|
+
// 1. User (nicht eingeloggt) klickt Invite-Link → /invite/accept?token=...
|
|
5
|
+
// 2. Frontend zeigt Login-Form mit pre-filled email (von der Invitation-
|
|
6
|
+
// Page geliefert via separate Lookup-Query, oder vom User getippt)
|
|
7
|
+
// 3. User submitted email + password + token an diesen Handler
|
|
8
|
+
// 4. Server: login + accept in einem Schritt:
|
|
9
|
+
// a. Token → invitationId → invitation row
|
|
10
|
+
// b. Login-Check: Password gegen userTable für invitation.email
|
|
11
|
+
// c. Email-Match (vom User-Input) === invitation.email
|
|
12
|
+
// d. Membership-Add im invited Tenant
|
|
13
|
+
// e. Invitation → status=accepted, Token gelöscht
|
|
14
|
+
// 5. Response: SessionUser + tenantKey für Auto-Login (analog signup-confirm)
|
|
15
|
+
//
|
|
16
|
+
// Anders als signup-confirm: KEIN neuer Tenant entsteht, KEIN neuer
|
|
17
|
+
// User entsteht — beide existieren bereits. Magic ist die kombinierte
|
|
18
|
+
// Login+Accept-Operation in einem Roundtrip.
|
|
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 login-style password-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
|
+
import { verifyPassword } from "../password-hashing";
|
|
57
|
+
|
|
58
|
+
const InviteAcceptWithLoginSchema = z.object({
|
|
59
|
+
token: z.string().min(1),
|
|
60
|
+
email: z.email(),
|
|
61
|
+
password: z.string().min(8).max(200),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export type InviteAcceptWithLoginData = {
|
|
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 createInviteAcceptWithLoginHandler() {
|
|
86
|
+
return defineWriteHandler<
|
|
87
|
+
"invite-accept-with-login",
|
|
88
|
+
typeof InviteAcceptWithLoginSchema,
|
|
89
|
+
InviteAcceptWithLoginData
|
|
90
|
+
>({
|
|
91
|
+
name: "invite-accept-with-login",
|
|
92
|
+
schema: InviteAcceptWithLoginSchema,
|
|
93
|
+
access: { roles: ["all"] },
|
|
94
|
+
handler: async (event, ctx) => {
|
|
95
|
+
if (!ctx.redis) {
|
|
96
|
+
return writeFailure(
|
|
97
|
+
new InternalError({ message: "invite-accept-with-login 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
|
+
// Email-Match vom User-Input (nicht aus session — User ist anon)
|
|
123
|
+
if (event.payload.email.toLowerCase() !== invitationEmail) {
|
|
124
|
+
return writeFailure(
|
|
125
|
+
new UnprocessableError(AuthErrors.inviteEmailMismatch, {
|
|
126
|
+
i18nKey: "auth.errors.inviteEmailMismatch",
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Password-Check gegen userTable. Anti-enumeration: bei
|
|
132
|
+
// user-not-found ODER wrong-password collapsed beides auf
|
|
133
|
+
// invalidInviteToken (gleicher anti-enum-Trade-off wie reset).
|
|
134
|
+
const userRow = await fetchOne(ctx.db.raw, userTable, eq(userTable.email, invitationEmail));
|
|
135
|
+
if (!userRow?.["passwordHash"]) return invalidInviteToken();
|
|
136
|
+
const passwordValid = await verifyPassword(
|
|
137
|
+
userRow["passwordHash"] as string,
|
|
138
|
+
event.payload.password,
|
|
139
|
+
);
|
|
140
|
+
if (!passwordValid) return invalidInviteToken();
|
|
141
|
+
|
|
142
|
+
const userId = userRow["id"] as string;
|
|
143
|
+
|
|
144
|
+
// Already-Member-Check (idempotent)
|
|
145
|
+
const memberships = (await ctx.queryAs(
|
|
146
|
+
createSystemUser(invitationTenantId),
|
|
147
|
+
"tenant:query:memberships",
|
|
148
|
+
{ userId },
|
|
149
|
+
)) as Array<{ tenantId: string }>;
|
|
150
|
+
const alreadyMember = memberships.some((m) => m.tenantId === invitationTenantId);
|
|
151
|
+
|
|
152
|
+
// @cast-boundary db-runner — TenantDb.raw is DbRunner
|
|
153
|
+
const dbConn = ctx.db.raw as DbConnection;
|
|
154
|
+
|
|
155
|
+
if (!alreadyMember) {
|
|
156
|
+
await seedTenantMembership(dbConn, {
|
|
157
|
+
userId,
|
|
158
|
+
tenantId: invitationTenantId,
|
|
159
|
+
roles: [invitationRole],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Invitation → accepted: TenantDb für invitation-tenant.
|
|
164
|
+
const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
|
|
165
|
+
const updateResult = await invitationExecutor.update(
|
|
166
|
+
{
|
|
167
|
+
id: invitationId,
|
|
168
|
+
version: invitationVersion,
|
|
169
|
+
changes: { status: INVITATION_STATUS.accepted },
|
|
170
|
+
},
|
|
171
|
+
createSystemUser(invitationTenantId),
|
|
172
|
+
invitationTdb,
|
|
173
|
+
);
|
|
174
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
175
|
+
|
|
176
|
+
await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
|
|
177
|
+
|
|
178
|
+
// SessionUser für JWT-Mint im invited Tenant. Roles =
|
|
179
|
+
// [invitationRole] (Admin/Editor/User je nach invite).
|
|
180
|
+
const session: SessionUser = {
|
|
181
|
+
id: userId,
|
|
182
|
+
tenantId: invitationTenantId,
|
|
183
|
+
roles: [invitationRole],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
committed = true;
|
|
187
|
+
return {
|
|
188
|
+
isSuccess: true,
|
|
189
|
+
data: {
|
|
190
|
+
kind: "auth-session",
|
|
191
|
+
session,
|
|
192
|
+
tenantId: invitationTenantId,
|
|
193
|
+
role: invitationRole,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
} finally {
|
|
197
|
+
if (!committed && ctx.redis) {
|
|
198
|
+
await unburnInviteToken(ctx.redis, event.payload.token);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Tenant-Invite Step 2 — Branch 1 (logged-in user accepts).
|
|
2
|
+
//
|
|
3
|
+
// User ist eingeloggt (in irgendeinem Tenant), klickt Accept-Link.
|
|
4
|
+
// Server:
|
|
5
|
+
// 1. Token → invitationId (Redis)
|
|
6
|
+
// 2. Burn (single-use)
|
|
7
|
+
// 3. Invitation-Row aus DB
|
|
8
|
+
// 4. Email-Match: invitation.email === user.email (sonst inviteEmailMismatch)
|
|
9
|
+
// 5. Already-Member-Check: User schon Member im invited Tenant → no-op success
|
|
10
|
+
// 6. Membership-Add via system-dispatcher (TenantHandlers.addMember)
|
|
11
|
+
// 7. Invitation-Row → status=accepted
|
|
12
|
+
// 8. Redis-Keys löschen (Burn-Key bleibt für Replay-Schutz)
|
|
13
|
+
//
|
|
14
|
+
// Branch 1 ist der klassische "shared workspace bei eingeloggter
|
|
15
|
+
// Session"-Flow. Branch 2 (anon + existing email) und Branch 3 (anon +
|
|
16
|
+
// new email) kommen als separate Handler.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
createEventStoreExecutor,
|
|
20
|
+
createTenantDb,
|
|
21
|
+
type DbConnection,
|
|
22
|
+
fetchOne,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
24
|
+
import {
|
|
25
|
+
createSystemUser,
|
|
26
|
+
defineWriteHandler,
|
|
27
|
+
type TenantId,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
|
+
import {
|
|
30
|
+
InternalError,
|
|
31
|
+
UnprocessableError,
|
|
32
|
+
writeFailure,
|
|
33
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
34
|
+
import { eq } from "drizzle-orm";
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
// kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link), DB-row-owner ist tenant-feature
|
|
37
|
+
import {
|
|
38
|
+
INVITATION_STATUS,
|
|
39
|
+
tenantInvitationEntity,
|
|
40
|
+
tenantInvitationsTable,
|
|
41
|
+
} from "../../tenant/invitation-table";
|
|
42
|
+
// kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add (analog provisionSignupAccount)
|
|
43
|
+
import { seedTenantMembership } from "../../tenant/seeding";
|
|
44
|
+
// kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
|
|
45
|
+
import { userTable } from "../../user/schema/user";
|
|
46
|
+
import { AuthErrors } from "../constants";
|
|
47
|
+
import {
|
|
48
|
+
burnInviteToken,
|
|
49
|
+
deleteInviteToken,
|
|
50
|
+
getInvitationIdForToken,
|
|
51
|
+
unburnInviteToken,
|
|
52
|
+
} from "../invite-token-store";
|
|
53
|
+
|
|
54
|
+
const InviteAcceptSchema = z.object({
|
|
55
|
+
token: z.string().min(1),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export type InviteAcceptData = {
|
|
59
|
+
readonly kind: "invite-accepted";
|
|
60
|
+
readonly tenantId: TenantId;
|
|
61
|
+
readonly role: string;
|
|
62
|
+
readonly alreadyMember: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const invitationExecutor = createEventStoreExecutor(
|
|
66
|
+
tenantInvitationsTable,
|
|
67
|
+
tenantInvitationEntity,
|
|
68
|
+
{ entityName: "tenant-invitation" },
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
function invalidInviteToken() {
|
|
72
|
+
return writeFailure(
|
|
73
|
+
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
74
|
+
i18nKey: "auth.errors.invalidInviteToken",
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createInviteAcceptHandler() {
|
|
80
|
+
return defineWriteHandler<"invite-accept", typeof InviteAcceptSchema, InviteAcceptData>({
|
|
81
|
+
name: "invite-accept",
|
|
82
|
+
schema: InviteAcceptSchema,
|
|
83
|
+
// openToAll: any authenticated user (Branch 1). Branch 2+3 (anon)
|
|
84
|
+
// nutzen `roles: ["all"]` weil dort GUEST_USER mit ["all"]-role
|
|
85
|
+
// dispatched wird.
|
|
86
|
+
access: { openToAll: true },
|
|
87
|
+
handler: async (event, ctx) => {
|
|
88
|
+
if (!ctx.redis) {
|
|
89
|
+
return writeFailure(
|
|
90
|
+
new InternalError({ message: "invite-accept requires ctx.redis for token consumption" }),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const invitationId = await getInvitationIdForToken(ctx.redis, event.payload.token);
|
|
95
|
+
if (!invitationId) return invalidInviteToken();
|
|
96
|
+
|
|
97
|
+
const burn = await burnInviteToken(ctx.redis, event.payload.token);
|
|
98
|
+
if (burn === "already-used") return invalidInviteToken();
|
|
99
|
+
|
|
100
|
+
let committed = false;
|
|
101
|
+
try {
|
|
102
|
+
const invitation = await fetchOne(
|
|
103
|
+
ctx.db.raw,
|
|
104
|
+
tenantInvitationsTable,
|
|
105
|
+
eq(tenantInvitationsTable.id, invitationId),
|
|
106
|
+
);
|
|
107
|
+
if (!invitation || invitation["status"] !== INVITATION_STATUS.pending)
|
|
108
|
+
return invalidInviteToken();
|
|
109
|
+
|
|
110
|
+
const invitationTenantId = invitation["tenantId"] as TenantId;
|
|
111
|
+
const invitationEmail = invitation["email"] as string;
|
|
112
|
+
const invitationRole = invitation["role"] as string;
|
|
113
|
+
const invitationVersion = invitation["version"] as number;
|
|
114
|
+
|
|
115
|
+
// Email-Match: User muss mit der eingeladenen Email matchen.
|
|
116
|
+
// Sonst kann ein Angreifer mit Zugriff zur invitee-Mail seinen
|
|
117
|
+
// eigenen Account dem Tenant zuschlagen.
|
|
118
|
+
const userRow = await fetchOne(ctx.db.raw, userTable, eq(userTable.id, event.user.id));
|
|
119
|
+
if (!userRow || (userRow["email"] as string).toLowerCase() !== invitationEmail) {
|
|
120
|
+
return writeFailure(
|
|
121
|
+
new UnprocessableError(AuthErrors.inviteEmailMismatch, {
|
|
122
|
+
i18nKey: "auth.errors.inviteEmailMismatch",
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Already-Member-Check via memberships-query. Wenn der User schon
|
|
128
|
+
// im invited Tenant Member ist, kein Error — no-op + 200 mit
|
|
129
|
+
// alreadyMember=true (advisor-Constraint #4: idempotent).
|
|
130
|
+
const memberships = (await ctx.queryAs(
|
|
131
|
+
createSystemUser(invitationTenantId),
|
|
132
|
+
"tenant:query:memberships",
|
|
133
|
+
{ userId: event.user.id },
|
|
134
|
+
)) as Array<{ tenantId: string }>;
|
|
135
|
+
const alreadyMember = memberships.some((m) => m.tenantId === invitationTenantId);
|
|
136
|
+
|
|
137
|
+
// @cast-boundary db-runner — TenantDb.raw is DbRunner
|
|
138
|
+
const dbConn = ctx.db.raw as DbConnection;
|
|
139
|
+
|
|
140
|
+
if (!alreadyMember) {
|
|
141
|
+
// Membership-Add via seedTenantMembership-helper (event-store-
|
|
142
|
+
// executor pattern, gleich wie provisionSignupAccount). Nicht
|
|
143
|
+
// dispatcher.writeAs(addMember) weil addMember-Handler nur
|
|
144
|
+
// ["SystemAdmin"]-Role akzeptiert; createSystemUser produziert
|
|
145
|
+
// "system"-Role die NICHT matcht. Direkt-via-Executor bypassed
|
|
146
|
+
// den Access-Check für privilegierte Cross-Tenant-Operationen.
|
|
147
|
+
await seedTenantMembership(dbConn, {
|
|
148
|
+
userId: event.user.id,
|
|
149
|
+
tenantId: invitationTenantId,
|
|
150
|
+
roles: [invitationRole],
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Invitation-Status → accepted via event-store-executor.
|
|
155
|
+
// Tenant-scoping: ctx.db ist auf event.user.tenantId gescopt
|
|
156
|
+
// (= NICHT der invitation-tenant). Eigene TenantDb für den
|
|
157
|
+
// invitation-tenant bauen damit der executor die row findet.
|
|
158
|
+
const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
|
|
159
|
+
const updateResult = await invitationExecutor.update(
|
|
160
|
+
{
|
|
161
|
+
id: invitationId,
|
|
162
|
+
version: invitationVersion,
|
|
163
|
+
changes: { status: INVITATION_STATUS.accepted },
|
|
164
|
+
},
|
|
165
|
+
createSystemUser(invitationTenantId),
|
|
166
|
+
invitationTdb,
|
|
167
|
+
);
|
|
168
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
169
|
+
|
|
170
|
+
await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
|
|
171
|
+
|
|
172
|
+
committed = true;
|
|
173
|
+
return {
|
|
174
|
+
isSuccess: true,
|
|
175
|
+
data: {
|
|
176
|
+
kind: "invite-accepted",
|
|
177
|
+
tenantId: invitationTenantId,
|
|
178
|
+
role: invitationRole,
|
|
179
|
+
alreadyMember,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
} finally {
|
|
183
|
+
if (!committed && ctx.redis) {
|
|
184
|
+
await unburnInviteToken(ctx.redis, event.payload.token);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Tenant-Invite Step 1 (create).
|
|
2
|
+
//
|
|
3
|
+
// Admin invitet email → DB-Row entsteht via event-store-executor (oder
|
|
4
|
+
// wird re-used bei Re-Invite), Random-Token in Redis bidirektional,
|
|
5
|
+
// Route-Layer schickt Mail mit Activation-URL.
|
|
6
|
+
//
|
|
7
|
+
// Resend-Idempotenz: Re-Invite für gleiche (tenantId, email) während
|
|
8
|
+
// pending → existing row + token re-genutzt + TTL refresh + zweite Mail
|
|
9
|
+
// mit GLEICHEM Link. Bei status="cancelled" oder "accepted": existing
|
|
10
|
+
// row updated zurück auf status=pending + neuer token.
|
|
11
|
+
//
|
|
12
|
+
// Always-200 für unbekannten User: bei invitee-Email die nicht in users
|
|
13
|
+
// existiert wird trotzdem ein Invite erstellt — Branch-3-Accept-Flow
|
|
14
|
+
// erlaubt new-user-signup mit dem Token. Keine Enumeration durchs
|
|
15
|
+
// invite-create.
|
|
16
|
+
|
|
17
|
+
import { generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
18
|
+
import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
19
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
21
|
+
import { eq } from "drizzle-orm";
|
|
22
|
+
import { Temporal } from "temporal-polyfill";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
// kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
|
|
25
|
+
import {
|
|
26
|
+
INVITATION_STATUS,
|
|
27
|
+
tenantInvitationEntity,
|
|
28
|
+
tenantInvitationsTable,
|
|
29
|
+
} from "../../tenant/invitation-table";
|
|
30
|
+
import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
|
|
31
|
+
import { getTokenForInvitation, storeInviteToken } from "../invite-token-store";
|
|
32
|
+
|
|
33
|
+
const InviteCreateSchema = z.object({
|
|
34
|
+
email: z.email(),
|
|
35
|
+
role: z.string().min(1).max(50),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export type InviteCreateData = {
|
|
39
|
+
readonly kind: "invite-created";
|
|
40
|
+
readonly invitationId: string;
|
|
41
|
+
readonly tenantId: string;
|
|
42
|
+
readonly email: string;
|
|
43
|
+
readonly role: string;
|
|
44
|
+
readonly token: string;
|
|
45
|
+
readonly expiresAt: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type InviteCreateOptions = {
|
|
49
|
+
/** TTL für den Activation-Token. Default 7 Tage. */
|
|
50
|
+
readonly tokenTtlMinutes?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
|
|
54
|
+
entityName: "tenant-invitation",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
|
|
58
|
+
const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES;
|
|
59
|
+
const ttlSeconds = ttlMinutes * 60;
|
|
60
|
+
|
|
61
|
+
return defineWriteHandler<"invite-create", typeof InviteCreateSchema, InviteCreateData>({
|
|
62
|
+
name: "invite-create",
|
|
63
|
+
schema: InviteCreateSchema,
|
|
64
|
+
access: { roles: ["Admin"] },
|
|
65
|
+
handler: async (event, ctx) => {
|
|
66
|
+
if (!ctx.redis) {
|
|
67
|
+
return writeFailure(
|
|
68
|
+
new InternalError({ message: "invite-create requires ctx.redis for token store" }),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const email = event.payload.email.toLowerCase();
|
|
73
|
+
const tenantId = event.user.tenantId;
|
|
74
|
+
const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
|
|
75
|
+
|
|
76
|
+
// Existing row für (tenantId, email) — unique-index garantiert
|
|
77
|
+
// max. eine Row. Status egal (cancelled/accepted/expired/pending);
|
|
78
|
+
// wir setzen sie auf pending zurück und vergeben einen frischen
|
|
79
|
+
// Token wenn der bisherige nicht mehr lebt.
|
|
80
|
+
const existing = await fetchOne(
|
|
81
|
+
ctx.db.raw,
|
|
82
|
+
tenantInvitationsTable,
|
|
83
|
+
eq(tenantInvitationsTable.tenantId, tenantId),
|
|
84
|
+
eq(tenantInvitationsTable.email, email),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
let invitationId: string;
|
|
88
|
+
let token: string;
|
|
89
|
+
if (existing) {
|
|
90
|
+
invitationId = existing["id"] as string;
|
|
91
|
+
const existingVersion = existing["version"] as number;
|
|
92
|
+
// Resend-Idempotenz: Token aus Redis re-use wenn noch lebend.
|
|
93
|
+
// Sonst neuen mintinen (alter ist abgelaufen).
|
|
94
|
+
const existingToken = await getTokenForInvitation(ctx.redis, invitationId);
|
|
95
|
+
token = existingToken ?? generateToken();
|
|
96
|
+
|
|
97
|
+
const updateResult = await executor.update(
|
|
98
|
+
{
|
|
99
|
+
id: invitationId,
|
|
100
|
+
version: existingVersion,
|
|
101
|
+
changes: {
|
|
102
|
+
role: event.payload.role,
|
|
103
|
+
status: INVITATION_STATUS.pending,
|
|
104
|
+
invitedBy: event.user.id,
|
|
105
|
+
expiresAt,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
event.user,
|
|
109
|
+
ctx.db,
|
|
110
|
+
);
|
|
111
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
112
|
+
} else {
|
|
113
|
+
const createResult = await executor.create(
|
|
114
|
+
{
|
|
115
|
+
email,
|
|
116
|
+
role: event.payload.role,
|
|
117
|
+
status: INVITATION_STATUS.pending,
|
|
118
|
+
invitedBy: event.user.id,
|
|
119
|
+
expiresAt,
|
|
120
|
+
},
|
|
121
|
+
event.user,
|
|
122
|
+
ctx.db,
|
|
123
|
+
);
|
|
124
|
+
if (!createResult.isSuccess) return createResult;
|
|
125
|
+
invitationId = (createResult.data as { id: string }).id;
|
|
126
|
+
token = generateToken();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
isSuccess: true,
|
|
133
|
+
data: {
|
|
134
|
+
kind: "invite-created",
|
|
135
|
+
invitationId,
|
|
136
|
+
tenantId,
|
|
137
|
+
email,
|
|
138
|
+
role: event.payload.role,
|
|
139
|
+
token,
|
|
140
|
+
expiresAt: expiresAt.toString(),
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|