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