@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,101 @@
|
|
|
1
|
+
// Tests the `sessionStrictMode` flag on AuthRoutesConfig. When enabled, a
|
|
2
|
+
// JWT that arrives WITHOUT a `jti` is rejected at the middleware — useful
|
|
3
|
+
// after a rolling deploy has been emitting sids longer than the JWT TTL,
|
|
4
|
+
// so legacy stateless tokens are expected to have expired. Default false
|
|
5
|
+
// keeps pre-upgrade tokens working; this suite flips it on and asserts.
|
|
6
|
+
|
|
7
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import {
|
|
9
|
+
setupTestStack,
|
|
10
|
+
type TestStack,
|
|
11
|
+
TestUsers,
|
|
12
|
+
testTenantId,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
15
|
+
import { createConfigFeature } from "../../config";
|
|
16
|
+
import { createTenantFeature } from "../../tenant";
|
|
17
|
+
import { createUserFeature } from "../../user";
|
|
18
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
19
|
+
|
|
20
|
+
let stack: TestStack;
|
|
21
|
+
const TENANT: TenantId = testTenantId(1);
|
|
22
|
+
const userId = TestUsers.systemAdmin.id;
|
|
23
|
+
|
|
24
|
+
// Stub checker that always accepts. The strictMode branch runs BEFORE the
|
|
25
|
+
// checker is even consulted (no jti → nothing to check), so the stub never
|
|
26
|
+
// fires in the strict-mode path. It's present to satisfy the framework's
|
|
27
|
+
// "you wired sessionChecker, so we'll run it if we have an sid" contract.
|
|
28
|
+
// Accepts the full AuthSessionChecker signature (sid + expectedUserId)
|
|
29
|
+
// even though it doesn't use the args.
|
|
30
|
+
async function stubChecker(_sid: string, _expectedUserId: string): Promise<"live"> {
|
|
31
|
+
return "live";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
stack = await setupTestStack({
|
|
36
|
+
features: [
|
|
37
|
+
createConfigFeature(),
|
|
38
|
+
createUserFeature(),
|
|
39
|
+
createTenantFeature(),
|
|
40
|
+
createAuthEmailPasswordFeature(),
|
|
41
|
+
],
|
|
42
|
+
authConfig: {
|
|
43
|
+
membershipQuery: "tenant:query:memberships",
|
|
44
|
+
sessionChecker: stubChecker,
|
|
45
|
+
sessionStrictMode: true,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await stack.cleanup();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("sessionStrictMode: sidless JWTs are rejected", () => {
|
|
55
|
+
test("JWT without jti → 401 with reason=no_sid", async () => {
|
|
56
|
+
// Hand-signed JWT that carries id + tenantId + roles but NO jti. The
|
|
57
|
+
// standard testing request-helper signs JWTs the same way on user
|
|
58
|
+
// arguments without a sid field.
|
|
59
|
+
const token = await stack.jwt.sign({ id: userId, tenantId: TENANT, roles: ["SystemAdmin"] });
|
|
60
|
+
|
|
61
|
+
const res = await stack.http.raw(
|
|
62
|
+
"POST",
|
|
63
|
+
"/api/query",
|
|
64
|
+
{ type: "user:query:user:me", payload: {} },
|
|
65
|
+
{ Authorization: `Bearer ${token}` },
|
|
66
|
+
);
|
|
67
|
+
expect(res.status).toBe(401);
|
|
68
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
69
|
+
expect(body.error?.details?.reason).toBe("no_sid");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("JWT WITH jti passes the middleware gate (stubChecker returns 'live')", async () => {
|
|
73
|
+
const token = await stack.jwt.sign({
|
|
74
|
+
id: userId,
|
|
75
|
+
tenantId: TENANT,
|
|
76
|
+
roles: ["SystemAdmin"],
|
|
77
|
+
sid: "aaaa1111-bbbb-2222-cccc-3333dddd4444",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Hit /health — it's in PUBLIC_API_PATHS and bypasses auth entirely,
|
|
81
|
+
// so a success there tells us nothing. Instead send to a known handler
|
|
82
|
+
// and just assert the middleware didn't turn it into a 401. The
|
|
83
|
+
// minimal stack has no user-table; the me-query would 500 on its SQL
|
|
84
|
+
// call, which is fine for this test (we're specifically NOT making
|
|
85
|
+
// statements about the handler behaviour). 401 means "middleware
|
|
86
|
+
// blocked us", which is exactly the bug this suite catches.
|
|
87
|
+
const res = await stack.http.raw(
|
|
88
|
+
"POST",
|
|
89
|
+
"/api/query",
|
|
90
|
+
{ type: "user:query:user:me", payload: {} },
|
|
91
|
+
{ Authorization: `Bearer ${token}` },
|
|
92
|
+
);
|
|
93
|
+
expect(res.status).not.toBe(401);
|
|
94
|
+
// Narrow the "not 401" to exclude other 4xx middleware errors too.
|
|
95
|
+
// A 403 from access-layer or a 400 from shape-validation wouldn't
|
|
96
|
+
// come from sessionStrictMode, but either would be a different code
|
|
97
|
+
// path than the one we're testing — flag them if they surface.
|
|
98
|
+
expect(res.status).not.toBe(403);
|
|
99
|
+
expect(res.status).not.toBe(400);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Temporal } from "temporal-polyfill";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { signToken, TokenPurpose, verifyToken } from "../signed-token";
|
|
4
|
+
|
|
5
|
+
const SECRET = "test-hmac-secret-32-bytes-minimum!!";
|
|
6
|
+
const USER_ID = "11111111-1111-4111-8111-111111111111";
|
|
7
|
+
|
|
8
|
+
describe("signed-token", () => {
|
|
9
|
+
test("round-trip: sign → verify (matching purpose) → userId", () => {
|
|
10
|
+
const { token } = signToken(USER_ID, TokenPurpose.passwordReset, 15, SECRET);
|
|
11
|
+
const result = verifyToken(token, TokenPurpose.passwordReset, SECRET);
|
|
12
|
+
expect(result.ok).toBe(true);
|
|
13
|
+
if (result.ok) expect(result.userId).toBe(USER_ID);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("cross-purpose replay is rejected (reset token on verify endpoint)", () => {
|
|
17
|
+
const { token } = signToken(USER_ID, TokenPurpose.passwordReset, 15, SECRET);
|
|
18
|
+
const result = verifyToken(token, TokenPurpose.emailVerification, SECRET);
|
|
19
|
+
expect(result).toEqual({ ok: false, reason: "bad_signature" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("tampered signature → bad_signature", () => {
|
|
23
|
+
const { token } = signToken(USER_ID, TokenPurpose.passwordReset, 15, SECRET);
|
|
24
|
+
const tampered = `${token.slice(0, -3)}XXX`;
|
|
25
|
+
const result = verifyToken(tampered, TokenPurpose.passwordReset, SECRET);
|
|
26
|
+
expect(result).toEqual({ ok: false, reason: "bad_signature" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("different secret → bad_signature", () => {
|
|
30
|
+
const { token } = signToken(USER_ID, TokenPurpose.passwordReset, 15, SECRET);
|
|
31
|
+
const result = verifyToken(
|
|
32
|
+
token,
|
|
33
|
+
TokenPurpose.passwordReset,
|
|
34
|
+
"other-secret-not-the-same-one!!!!",
|
|
35
|
+
);
|
|
36
|
+
expect(result).toEqual({ ok: false, reason: "bad_signature" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("expired → expired", () => {
|
|
40
|
+
const t0 = Temporal.Instant.fromEpochMilliseconds(1_700_000_000_000);
|
|
41
|
+
const laterThanTtl = t0.add({ minutes: 16 });
|
|
42
|
+
const { token } = signToken(USER_ID, TokenPurpose.passwordReset, 15, SECRET, t0);
|
|
43
|
+
const result = verifyToken(token, TokenPurpose.passwordReset, SECRET, laterThanTtl);
|
|
44
|
+
expect(result).toEqual({ ok: false, reason: "expired" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("malformed: wrong part count", () => {
|
|
48
|
+
expect(verifyToken("not-a-token", "reset", SECRET)).toEqual({
|
|
49
|
+
ok: false,
|
|
50
|
+
reason: "malformed",
|
|
51
|
+
});
|
|
52
|
+
expect(verifyToken("a.b", "reset", SECRET)).toEqual({
|
|
53
|
+
ok: false,
|
|
54
|
+
reason: "malformed",
|
|
55
|
+
});
|
|
56
|
+
expect(verifyToken("a.b.c.d", "reset", SECRET)).toEqual({
|
|
57
|
+
ok: false,
|
|
58
|
+
reason: "malformed",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("malformed: non-numeric expiry", () => {
|
|
63
|
+
const result = verifyToken(`${USER_ID}.not-a-number.sig`, "reset", SECRET);
|
|
64
|
+
expect(result).toEqual({ ok: false, reason: "malformed" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("empty parts count as malformed", () => {
|
|
68
|
+
const result = verifyToken("..", "reset", SECRET);
|
|
69
|
+
expect(result).toEqual({ ok: false, reason: "malformed" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("expiresAt reflects configured TTL", () => {
|
|
73
|
+
const t0 = Temporal.Instant.fromEpochMilliseconds(1_700_000_000_000);
|
|
74
|
+
const { expiresAt } = signToken(USER_ID, "reset", 30, SECRET, t0);
|
|
75
|
+
const diff = expiresAt.since(t0).total({ unit: "minutes" });
|
|
76
|
+
expect(diff).toBe(30);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Magic-Link-Self-Signup Full-Stack Integration-Test. Spec ist der
|
|
2
|
+
// Test selbst (advisor-Empfehlung). Geht durch HTTP, weil
|
|
3
|
+
// stack.dispatcher nicht exposed ist und die Routes ohnehin der
|
|
4
|
+
// reale User-Pfad sind.
|
|
5
|
+
//
|
|
6
|
+
// Pinst:
|
|
7
|
+
// 1. POST signup-request mit valid email → 200, Mail captured durch
|
|
8
|
+
// sendActivationEmail-callback (echte route + signup-feature).
|
|
9
|
+
// 2. Resend-Idempotenz: zweiter Request für selbe email → gleicher
|
|
10
|
+
// Token in Mail (existing token in Redis wird re-genutzt).
|
|
11
|
+
// 3. POST signup-confirm mit captured Token + Password → 200, Cookies
|
|
12
|
+
// gesetzt (kumiko_auth + kumiko_csrf), Body mit user + tenantKey,
|
|
13
|
+
// DB hat user (emailVerified=true) + tenant + Admin-membership.
|
|
14
|
+
// 4. POST /api/auth/login mit demselben Password → 200 (Authority-
|
|
15
|
+
// Beweis: tenant + user + membership wirklich da, Auto-Login
|
|
16
|
+
// könnte stattdessen den JWT aus signup-confirm verwenden, aber
|
|
17
|
+
// dieser zweite Login schließt aus dass die signup-confirm-
|
|
18
|
+
// pipeline irgendetwas verschluckt hat).
|
|
19
|
+
// 5. Replay: zweiter signup-confirm mit gleichem Token → 422
|
|
20
|
+
// invalid_signup_token (single-use burn).
|
|
21
|
+
// 6. Token-not-found / abgelaufen → 422 invalid_signup_token
|
|
22
|
+
// (uniformer Code, kein Enumeration-leak).
|
|
23
|
+
// 7. Sequential Signups → unique tenantKey-Slugs (TOCTOU-Schutz
|
|
24
|
+
// via DB-unique-index + generateUniqueName-isAvailable-check
|
|
25
|
+
// zusammen).
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
createEntityTable,
|
|
29
|
+
pushTables,
|
|
30
|
+
setupTestStack,
|
|
31
|
+
type TestStack,
|
|
32
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
33
|
+
import { eq } from "drizzle-orm";
|
|
34
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
35
|
+
import { createConfigFeature } from "../../config";
|
|
36
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
37
|
+
import { configValuesTable } from "../../config/table";
|
|
38
|
+
import { createTenantFeature } from "../../tenant";
|
|
39
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
40
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
41
|
+
import { createUserFeature } from "../../user/feature";
|
|
42
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
43
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
44
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
45
|
+
|
|
46
|
+
const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
|
|
47
|
+
const capturedActivationEmails: Array<{
|
|
48
|
+
email: string;
|
|
49
|
+
activationUrl: string;
|
|
50
|
+
expiresAt: string;
|
|
51
|
+
}> = [];
|
|
52
|
+
|
|
53
|
+
let stack: TestStack;
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
stack = await setupTestStack({
|
|
57
|
+
features: [
|
|
58
|
+
createConfigFeature(),
|
|
59
|
+
createUserFeature(),
|
|
60
|
+
createTenantFeature(),
|
|
61
|
+
createAuthEmailPasswordFeature({
|
|
62
|
+
signup: { tokenTtlMinutes: 60 },
|
|
63
|
+
}),
|
|
64
|
+
],
|
|
65
|
+
extraContext: { configResolver: createConfigResolver() },
|
|
66
|
+
authConfig: {
|
|
67
|
+
membershipQuery: "tenant:query:memberships",
|
|
68
|
+
loginHandler: AuthHandlers.login,
|
|
69
|
+
signup: {
|
|
70
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
71
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
72
|
+
appActivationUrl: APP_ACTIVATION_URL,
|
|
73
|
+
sendActivationEmail: async (args) => {
|
|
74
|
+
capturedActivationEmails.push(args);
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await createEntityTable(stack.db, userEntity);
|
|
81
|
+
// tenant-entity hat den unique-constraint auf .key (siehe
|
|
82
|
+
// tenant.schema.indexes). createEntityTable baut das via
|
|
83
|
+
// buildDrizzleTable nach — pinst den TOCTOU-Schutz für signup-confirm.
|
|
84
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
85
|
+
await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterAll(async () => {
|
|
89
|
+
await stack.cleanup();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
await stack.db.delete(userTable);
|
|
94
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
95
|
+
await stack.db.delete(tenantTable);
|
|
96
|
+
capturedActivationEmails.length = 0;
|
|
97
|
+
// Redis-cleanup damit Resend-Tests keine state-leaks haben.
|
|
98
|
+
const allKeys = await stack.redis.redis.keys("signup:*");
|
|
99
|
+
if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
async function postSignupRequest(email: string): Promise<Response> {
|
|
103
|
+
return stack.http.raw("POST", "/api/auth/signup-request", { email });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function postSignupConfirm(token: string, password: string): Promise<Response> {
|
|
107
|
+
return stack.http.raw("POST", "/api/auth/signup-confirm", { token, password });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function postLogin(email: string, password: string): Promise<Response> {
|
|
111
|
+
return stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function extractTokenFromUrl(url: string): string {
|
|
115
|
+
const match = url.match(/[?&]token=([^&]+)/);
|
|
116
|
+
if (!match?.[1]) throw new Error(`No token in url: ${url}`);
|
|
117
|
+
return decodeURIComponent(match[1]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("POST /api/auth/signup-request", () => {
|
|
121
|
+
test("known email → 200, mail captured mit activation-url", async () => {
|
|
122
|
+
const res = await postSignupRequest("alice@example.com");
|
|
123
|
+
expect(res.status).toBe(200);
|
|
124
|
+
expect(await res.json()).toEqual({ isSuccess: true });
|
|
125
|
+
expect(capturedActivationEmails).toHaveLength(1);
|
|
126
|
+
const [captured] = capturedActivationEmails;
|
|
127
|
+
if (!captured) throw new Error("no captured email");
|
|
128
|
+
expect(captured.email).toBe("alice@example.com");
|
|
129
|
+
expect(captured.activationUrl.startsWith(`${APP_ACTIVATION_URL}?token=`)).toBe(true);
|
|
130
|
+
expect(typeof captured.expiresAt).toBe("string");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("Resend: zweiter Request für selbe email → gleicher token in Mail", async () => {
|
|
134
|
+
await postSignupRequest("resend@example.com");
|
|
135
|
+
await postSignupRequest("resend@example.com");
|
|
136
|
+
|
|
137
|
+
expect(capturedActivationEmails).toHaveLength(2);
|
|
138
|
+
const [first, second] = capturedActivationEmails;
|
|
139
|
+
if (!first || !second) throw new Error("missing emails");
|
|
140
|
+
expect(extractTokenFromUrl(second.activationUrl)).toBe(
|
|
141
|
+
extractTokenFromUrl(first.activationUrl),
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("malformed body → 200 (silent success, anti-enumeration)", async () => {
|
|
146
|
+
const res = await stack.http.raw("POST", "/api/auth/signup-request", { wrong: "shape" });
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
expect(capturedActivationEmails).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("POST /api/auth/signup-confirm", () => {
|
|
153
|
+
async function requestSignup(email: string): Promise<string> {
|
|
154
|
+
capturedActivationEmails.length = 0;
|
|
155
|
+
const res = await postSignupRequest(email);
|
|
156
|
+
expect(res.status).toBe(200);
|
|
157
|
+
const captured = capturedActivationEmails[0];
|
|
158
|
+
if (!captured) throw new Error("signup-request fixture didn't capture mail");
|
|
159
|
+
return extractTokenFromUrl(captured.activationUrl);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
test("voller Roundtrip: confirm legt user + tenant + Admin-Membership an, Cookies + Login funktioniert", async () => {
|
|
163
|
+
const email = "bob@example.com";
|
|
164
|
+
const password = "fresh-secure-pw-1234";
|
|
165
|
+
const token = await requestSignup(email);
|
|
166
|
+
|
|
167
|
+
const confirmRes = await postSignupConfirm(token, password);
|
|
168
|
+
expect(confirmRes.status).toBe(200);
|
|
169
|
+
const body = (await confirmRes.json()) as {
|
|
170
|
+
isSuccess: boolean;
|
|
171
|
+
token?: string;
|
|
172
|
+
user?: { id: string; tenantId: string; roles: string[] };
|
|
173
|
+
tenantKey?: string;
|
|
174
|
+
};
|
|
175
|
+
expect(body.isSuccess).toBe(true);
|
|
176
|
+
expect(body.token).toBeTruthy();
|
|
177
|
+
expect(body.user?.id).toBeTruthy();
|
|
178
|
+
expect(body.user?.tenantId).toBeTruthy();
|
|
179
|
+
expect(body.user?.roles).toContain("Admin");
|
|
180
|
+
expect(body.tenantKey).toBeTruthy();
|
|
181
|
+
|
|
182
|
+
// Cookies gesetzt (auth + csrf)
|
|
183
|
+
const setCookies = confirmRes.headers.get("set-cookie") ?? "";
|
|
184
|
+
expect(setCookies).toContain("kumiko_auth=");
|
|
185
|
+
expect(setCookies).toContain("kumiko_csrf=");
|
|
186
|
+
|
|
187
|
+
// DB-State pinst
|
|
188
|
+
const userRows = await stack.db.select().from(userTable).where(eq(userTable.email, email));
|
|
189
|
+
expect(userRows).toHaveLength(1);
|
|
190
|
+
expect(userRows[0]?.["emailVerified"]).toBe(true);
|
|
191
|
+
expect(userRows[0]?.["passwordHash"]).toBeTruthy();
|
|
192
|
+
|
|
193
|
+
const tenantRows = await stack.db
|
|
194
|
+
.select()
|
|
195
|
+
.from(tenantTable)
|
|
196
|
+
.where(eq(tenantTable.id, body.user?.tenantId ?? ""));
|
|
197
|
+
expect(tenantRows).toHaveLength(1);
|
|
198
|
+
expect(tenantRows[0]?.["key"]).toBe(body.tenantKey);
|
|
199
|
+
|
|
200
|
+
const memberships = await stack.db
|
|
201
|
+
.select()
|
|
202
|
+
.from(tenantMembershipsTable)
|
|
203
|
+
.where(eq(tenantMembershipsTable.userId, body.user?.id ?? ""));
|
|
204
|
+
expect(memberships).toHaveLength(1);
|
|
205
|
+
const rolesRaw = memberships[0]?.["roles"];
|
|
206
|
+
if (typeof rolesRaw === "string") {
|
|
207
|
+
expect(JSON.parse(rolesRaw) as string[]).toContain("Admin");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Authority-Beweis: Login mit dem gesetzten Password funktioniert.
|
|
211
|
+
const loginRes = await postLogin(email, password);
|
|
212
|
+
expect(loginRes.status).toBe(200);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("Single-Use-Burn: zweiter confirm mit gleichem Token → 422 invalid_signup_token", async () => {
|
|
216
|
+
const email = "burn@example.com";
|
|
217
|
+
const password = "burn-test-pw-1234";
|
|
218
|
+
const token = await requestSignup(email);
|
|
219
|
+
|
|
220
|
+
const first = await postSignupConfirm(token, password);
|
|
221
|
+
expect(first.status).toBe(200);
|
|
222
|
+
|
|
223
|
+
const second = await postSignupConfirm(token, "another-pw-9876");
|
|
224
|
+
expect(second.status).toBe(422);
|
|
225
|
+
const body = (await second.json()) as {
|
|
226
|
+
error?: { details?: { reason?: string } };
|
|
227
|
+
};
|
|
228
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidSignupToken);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("unbekannter Token → 422 invalid_signup_token (Anti-Enumeration)", async () => {
|
|
232
|
+
const res = await postSignupConfirm("nonexistent-token-xxxxxxxxxx", "any-pw-1234");
|
|
233
|
+
expect(res.status).toBe(422);
|
|
234
|
+
const body = (await res.json()) as {
|
|
235
|
+
error?: { details?: { reason?: string } };
|
|
236
|
+
};
|
|
237
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidSignupToken);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("zu kurzes Password → 400 invalid_body (Schema-Reject vor dispatcher)", async () => {
|
|
241
|
+
const email = "short@example.com";
|
|
242
|
+
const token = await requestSignup(email);
|
|
243
|
+
const res = await postSignupConfirm(token, "tiny");
|
|
244
|
+
expect(res.status).toBe(400);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("mehrere sequentielle Signups → unterschiedliche tenant.key-Slugs", async () => {
|
|
248
|
+
const keys: string[] = [];
|
|
249
|
+
for (let i = 0; i < 3; i++) {
|
|
250
|
+
const email = `multi-${i}@example.com`;
|
|
251
|
+
const token = await requestSignup(email);
|
|
252
|
+
const confirmRes = await postSignupConfirm(token, `multi-pw-${i}-1234`);
|
|
253
|
+
expect(confirmRes.status).toBe(200);
|
|
254
|
+
const body = (await confirmRes.json()) as { tenantKey: string };
|
|
255
|
+
keys.push(body.tenantKey);
|
|
256
|
+
}
|
|
257
|
+
expect(new Set(keys).size).toBe(3);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Narrow cross-feature query results (ctx.queryAs → Promise<unknown>) into
|
|
2
|
+
// the shape the auth handlers actually read. Replaces a bare
|
|
3
|
+
// `as AuthUserRow | null` at the system boundary — the coding standard
|
|
4
|
+
// requires a TypeGuard in place of unchecked casts from unknown.
|
|
5
|
+
|
|
6
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// Fields findForAuth returns. `version` is present for updates, `email` +
|
|
9
|
+
// `passwordHash` only for login/reset/verification lookups. Every field
|
|
10
|
+
// except `id` is optional because different call-sites read different
|
|
11
|
+
// subsets and the projection may add nullable columns later.
|
|
12
|
+
export type AuthUserRow = {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly email?: string;
|
|
15
|
+
readonly version?: number;
|
|
16
|
+
readonly passwordHash?: string | null;
|
|
17
|
+
readonly isDeleted?: boolean | null;
|
|
18
|
+
readonly emailVerified?: boolean | null;
|
|
19
|
+
readonly lastActiveTenantId?: TenantId | string | null;
|
|
20
|
+
// JSON-encoded string[] — globale Rollen die parallel zu tenant-membership-
|
|
21
|
+
// roles gelten (z.B. SystemAdmin, BillingAdmin). Caller deserialisiert via
|
|
22
|
+
// parseRoles() vor dem Merge in die Session.
|
|
23
|
+
readonly roles?: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Returns the narrowed row or null — mirrors findForAuth's contract where
|
|
27
|
+
// "not found" is a legitimate outcome (unknown email, unknown id). Throws
|
|
28
|
+
// NEVER — a malformed row is treated as not-found so enumeration surfaces
|
|
29
|
+
// stay consistent across "user doesn't exist" and "DB gave back junk".
|
|
30
|
+
export function parseAuthUserRow(raw: unknown): AuthUserRow | null {
|
|
31
|
+
if (raw === null || raw === undefined) return null;
|
|
32
|
+
if (typeof raw !== "object") return null;
|
|
33
|
+
const obj = raw as Record<string, unknown>; // @cast-boundary db-row
|
|
34
|
+
if (typeof obj["id"] !== "string") return null;
|
|
35
|
+
// Deliberate boundary-cast: id is verified; the remaining optional
|
|
36
|
+
// fields (email, version, isDeleted, …) are declared optional on
|
|
37
|
+
// AuthUserRow so a missing property at the callsite surfaces as
|
|
38
|
+
// `undefined` on read, not a runtime exception. Explicit validation
|
|
39
|
+
// of each column would duplicate findForAuth's schema and rot with it.
|
|
40
|
+
return obj as unknown as AuthUserRow;
|
|
41
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Pure string-Konstanten — keine DB/Node-builtins. Mit `@runtime client`
|
|
3
|
+
// markiert damit auch Browser-Code (Members-Screen etc.) sie importieren
|
|
4
|
+
// kann ohne dass die runtime-isolation-Guard schreit. Runtime darf
|
|
5
|
+
// "client"-Files importieren (siehe RUNTIME_RULES), also bleibt auch
|
|
6
|
+
// der server-side Zugriff (handlers, dispatcher) erhalten.
|
|
7
|
+
export const AUTH_EMAIL_PASSWORD_FEATURE = "auth-email-password" as const;
|
|
8
|
+
|
|
9
|
+
// Qualified handler names. Non-CRUD handlers, no entity prefix.
|
|
10
|
+
export const AuthHandlers = {
|
|
11
|
+
login: "auth-email-password:write:login",
|
|
12
|
+
logout: "auth-email-password:write:logout",
|
|
13
|
+
changePassword: "auth-email-password:write:change-password",
|
|
14
|
+
requestPasswordReset: "auth-email-password:write:request-password-reset",
|
|
15
|
+
resetPassword: "auth-email-password:write:reset-password",
|
|
16
|
+
requestEmailVerification: "auth-email-password:write:request-email-verification",
|
|
17
|
+
verifyEmail: "auth-email-password:write:verify-email",
|
|
18
|
+
// Magic-Link Self-Signup (Pre-Activation-Token-Pattern). request mintet
|
|
19
|
+
// einen opaken Random-Token, speichert ihn bidirektional in Redis und
|
|
20
|
+
// sendet eine Aktivierungs-Mail. confirm löst den Token ein und legt
|
|
21
|
+
// user + tenant + Admin-Membership atomar an. emailVerified=true ab
|
|
22
|
+
// Sekunde 0 — der Klick auf den Mail-Link IST der Beweis.
|
|
23
|
+
signupRequest: "auth-email-password:write:signup-request",
|
|
24
|
+
signupConfirm: "auth-email-password:write:signup-confirm",
|
|
25
|
+
// Tenant-Invite Magic-Link (Admin lädt User in existing Tenant ein).
|
|
26
|
+
// Drei separate accept-Endpoints für klare Branch-Separation:
|
|
27
|
+
// inviteCreate: Admin → POST email + role
|
|
28
|
+
// inviteAccept: logged-in User → POST token (membership-add)
|
|
29
|
+
// inviteAcceptWithLogin: anon User mit existing email → POST token + email + password
|
|
30
|
+
// inviteSignupComplete: anon User mit neuer email → POST token + password
|
|
31
|
+
// inviteCancel: Admin cancelt pending invite
|
|
32
|
+
inviteCreate: "auth-email-password:write:invite-create",
|
|
33
|
+
inviteAccept: "auth-email-password:write:invite-accept",
|
|
34
|
+
inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
|
|
35
|
+
inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
|
|
36
|
+
inviteCancel: "auth-email-password:write:invite-cancel",
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
// Error codes — kept intentionally generic so clients can't distinguish
|
|
40
|
+
// "email doesn't exist" from "password wrong". Both surface as invalid_credentials.
|
|
41
|
+
// Soft-deleted users also collapse into invalid_credentials to avoid enumeration.
|
|
42
|
+
export const AuthErrors = {
|
|
43
|
+
invalidCredentials: "invalid_credentials",
|
|
44
|
+
noMembership: "no_membership",
|
|
45
|
+
// Reset-flow: the route maps every reset-token verify failure (malformed,
|
|
46
|
+
// bad signature, expired) to this single code so a probing client can't
|
|
47
|
+
// learn whether a token was tampered with or just stale.
|
|
48
|
+
invalidResetToken: "invalid_reset_token",
|
|
49
|
+
resetNotConfigured: "password_reset_not_configured",
|
|
50
|
+
// Verification-flow: mirrors the reset-token handling. The login path
|
|
51
|
+
// uses `emailNotVerified` which IS a deliberate enumeration leak —
|
|
52
|
+
// UX benefit (explicit "check your email") outweighs the marginal
|
|
53
|
+
// signal ("this email exists in our system"). Signup already surfaces
|
|
54
|
+
// that.
|
|
55
|
+
invalidVerificationToken: "invalid_verification_token",
|
|
56
|
+
verificationNotConfigured: "email_verification_not_configured",
|
|
57
|
+
emailNotVerified: "email_not_verified",
|
|
58
|
+
// Self-Signup: alle confirm-Failures (unbekannter Token, schon
|
|
59
|
+
// konsumiert, abgelaufen) collapsen auf diesen Code — gleicher
|
|
60
|
+
// anti-enumeration-Trade-off wie reset/verify.
|
|
61
|
+
invalidSignupToken: "invalid_signup_token",
|
|
62
|
+
signupNotConfigured: "signup_not_configured",
|
|
63
|
+
// Invite-Flow: alle Token-Failures collapsen auf invalidInviteToken
|
|
64
|
+
// (anti-enumeration). emailMismatch wenn der invitee versucht den
|
|
65
|
+
// Link mit einer anderen Email zu accepten als die eingeladene.
|
|
66
|
+
invalidInviteToken: "invalid_invite_token",
|
|
67
|
+
inviteEmailMismatch: "invite_email_mismatch",
|
|
68
|
+
inviteAlreadyMember: "invite_already_member",
|
|
69
|
+
// Account-lockout: login refuses with this code when the user's streak of
|
|
70
|
+
// failed attempts has crossed the configured threshold. The error detail
|
|
71
|
+
// carries `retryAfterSeconds` so the UI can show a countdown. Returning a
|
|
72
|
+
// distinct code (rather than hiding it inside invalid_credentials) is a
|
|
73
|
+
// deliberate enumeration trade-off: the lockout event itself is already
|
|
74
|
+
// observable to the attacker, and legit users benefit from a clear signal.
|
|
75
|
+
accountLocked: "account_locked",
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
// Account-lockout defaults — overridable via
|
|
79
|
+
// AuthEmailPasswordOptions.accountLockout on the feature. Defaults track the
|
|
80
|
+
// industry norm (NIST 800-63B) for password-only logins: a small streak
|
|
81
|
+
// threshold, a short cooldown.
|
|
82
|
+
export const AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS = 5;
|
|
83
|
+
export const AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES = 15;
|
|
84
|
+
|
|
85
|
+
export const AUTH_RESET_DEFAULT_TTL_MINUTES = 15;
|
|
86
|
+
// Verification tokens live longer by default because the user may not be
|
|
87
|
+
// at their computer the moment they sign up — 24h covers "verify after
|
|
88
|
+
// I've got home from work". The HMAC-signed token is still single-use
|
|
89
|
+
// because flipping emailVerified=true is an idempotent state change:
|
|
90
|
+
// replaying the same token re-sets the same flag.
|
|
91
|
+
export const AUTH_VERIFY_DEFAULT_TTL_MINUTES = 24 * 60;
|
|
92
|
+
|
|
93
|
+
// Self-Signup: 24h Default. Lang genug damit User nicht denken muss
|
|
94
|
+
// "schnell aktivieren" — ein Mail-Link der morgen früh noch geht ist
|
|
95
|
+
// User-Friendly. Kürzere TTLs werfen Resend-Spam weil User vergessen.
|
|
96
|
+
export const AUTH_SIGNUP_DEFAULT_TTL_MINUTES = 24 * 60;
|
|
97
|
+
|
|
98
|
+
// Tenant-Invite: 7 Tage Default. Industry-Standard (GitHub, Linear,
|
|
99
|
+
// Slack); invitees brauchen oft länger zum Reagieren als bei Self-
|
|
100
|
+
// Signup wo die User-Intention frisch ist.
|
|
101
|
+
export const AUTH_INVITE_DEFAULT_TTL_MINUTES = 7 * 24 * 60;
|