@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,127 @@
|
|
|
1
|
+
// Tests für seedUser. Vier Invarianten:
|
|
2
|
+
// 1. Projection-Row landet mit email/displayName/passwordHash
|
|
3
|
+
// 2. Event `user.created` landet auf dem Aggregate-Stream
|
|
4
|
+
// 3. Idempotenz über `email` — zweiter Call liefert dieselbe userId
|
|
5
|
+
// ohne neuen Insert/Event
|
|
6
|
+
// 4. `passwordHash`-Field ist optional (User ohne Passwort, z.B. SSO-
|
|
7
|
+
// Federation, soll auch funktionieren)
|
|
8
|
+
|
|
9
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
|
+
import {
|
|
11
|
+
createEntityTable,
|
|
12
|
+
pushTables,
|
|
13
|
+
setupTestStack,
|
|
14
|
+
type TestStack,
|
|
15
|
+
TestUsers,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
17
|
+
import { eq } from "drizzle-orm";
|
|
18
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
19
|
+
import { createConfigFeature } from "../../config/feature";
|
|
20
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
21
|
+
import { configValuesTable } from "../../config/table";
|
|
22
|
+
import { createUserFeature } from "../feature";
|
|
23
|
+
import { userEntity, userTable } from "../schema/user";
|
|
24
|
+
import { seedUser } from "../seeding";
|
|
25
|
+
|
|
26
|
+
let stack: TestStack;
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
const resolver = createConfigResolver();
|
|
30
|
+
stack = await setupTestStack({
|
|
31
|
+
features: [createConfigFeature(), createUserFeature()],
|
|
32
|
+
extraContext: { configResolver: resolver },
|
|
33
|
+
});
|
|
34
|
+
await createEntityTable(stack.db, userEntity);
|
|
35
|
+
await pushTables(stack.db, { configValuesTable });
|
|
36
|
+
await createEventsTable(stack.db);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
await stack.cleanup();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
await stack.db.delete(userTable);
|
|
45
|
+
await stack.db.delete(eventsTable);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("seedUser", () => {
|
|
49
|
+
test("schreibt Projection-Row mit email/displayName/passwordHash", async () => {
|
|
50
|
+
const userId = await seedUser(stack.db, {
|
|
51
|
+
email: "alice@example.com",
|
|
52
|
+
displayName: "Alice",
|
|
53
|
+
passwordHash: "$argon2id$test-hash",
|
|
54
|
+
});
|
|
55
|
+
expect(userId).toMatch(/^[0-9a-f-]{36}$/);
|
|
56
|
+
|
|
57
|
+
const rows = await stack.db
|
|
58
|
+
.select()
|
|
59
|
+
.from(userTable)
|
|
60
|
+
.where(eq(userTable["email"], "alice@example.com"));
|
|
61
|
+
expect(rows).toHaveLength(1);
|
|
62
|
+
expect(rows[0]?.["email"]).toBe("alice@example.com");
|
|
63
|
+
expect(rows[0]?.["displayName"]).toBe("Alice");
|
|
64
|
+
expect(rows[0]?.["passwordHash"]).toBe("$argon2id$test-hash");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("emittiert user.created-Event auf den Aggregate-Stream", async () => {
|
|
68
|
+
const userId = await seedUser(stack.db, {
|
|
69
|
+
email: "bob@example.com",
|
|
70
|
+
displayName: "Bob",
|
|
71
|
+
});
|
|
72
|
+
const events = await stack.db
|
|
73
|
+
.select()
|
|
74
|
+
.from(eventsTable)
|
|
75
|
+
.where(eq(eventsTable.aggregateType, "user"));
|
|
76
|
+
const created = events.filter((e) => e.type === "user.created");
|
|
77
|
+
expect(created).toHaveLength(1);
|
|
78
|
+
expect(created[0]?.aggregateId).toBe(userId);
|
|
79
|
+
const payload = created[0]?.payload as { email: string; displayName: string };
|
|
80
|
+
expect(payload.email).toBe("bob@example.com");
|
|
81
|
+
expect(payload.displayName).toBe("Bob");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("idempotent über email — zweiter Call liefert dieselbe userId, kein zweites Event", async () => {
|
|
85
|
+
const first = await seedUser(stack.db, {
|
|
86
|
+
email: "carol@example.com",
|
|
87
|
+
displayName: "Carol",
|
|
88
|
+
});
|
|
89
|
+
const second = await seedUser(stack.db, {
|
|
90
|
+
email: "carol@example.com",
|
|
91
|
+
displayName: "Carol Updated",
|
|
92
|
+
});
|
|
93
|
+
expect(second).toBe(first);
|
|
94
|
+
|
|
95
|
+
const rows = await stack.db
|
|
96
|
+
.select()
|
|
97
|
+
.from(userTable)
|
|
98
|
+
.where(eq(userTable["email"], "carol@example.com"));
|
|
99
|
+
expect(rows).toHaveLength(1);
|
|
100
|
+
// Original-displayName bleibt — zweiter Call wurde geskippt, kein update.
|
|
101
|
+
expect(rows[0]?.["displayName"]).toBe("Carol");
|
|
102
|
+
|
|
103
|
+
const created = await stack.db
|
|
104
|
+
.select()
|
|
105
|
+
.from(eventsTable)
|
|
106
|
+
.where(eq(eventsTable.aggregateType, "user"));
|
|
107
|
+
expect(created.filter((e) => e.type === "user.created")).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("passwordHash optional — User ohne Hash anlegbar (z.B. SSO-Federation)", async () => {
|
|
111
|
+
const userId = await seedUser(stack.db, {
|
|
112
|
+
email: "dave@example.com",
|
|
113
|
+
displayName: "Dave",
|
|
114
|
+
});
|
|
115
|
+
const [row] = await stack.db.select().from(userTable).where(eq(userTable["id"], userId));
|
|
116
|
+
expect(row?.["passwordHash"]).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("default `by` ist TestUsers.systemAdmin (für audit-trail)", async () => {
|
|
120
|
+
const userId = await seedUser(stack.db, {
|
|
121
|
+
email: "eve@example.com",
|
|
122
|
+
displayName: "Eve",
|
|
123
|
+
});
|
|
124
|
+
const [row] = await stack.db.select().from(userTable).where(eq(userTable["id"], userId));
|
|
125
|
+
expect(row?.["insertedById"]).toBe(TestUsers.systemAdmin.id);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEntityTable,
|
|
3
|
+
createTestUser,
|
|
4
|
+
setupTestStack,
|
|
5
|
+
type TestStack,
|
|
6
|
+
TestUsers,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
8
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
10
|
+
import { UserErrors, UserHandlers, UserQueries } from "../constants";
|
|
11
|
+
import { createUserFeature } from "../feature";
|
|
12
|
+
import { userEntity, userTable } from "../schema/user";
|
|
13
|
+
|
|
14
|
+
let stack: TestStack;
|
|
15
|
+
|
|
16
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
17
|
+
const userFeature = createUserFeature();
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
stack = await setupTestStack({ features: [userFeature] });
|
|
21
|
+
await createEntityTable(stack.db, userEntity);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(async () => {
|
|
25
|
+
await stack.cleanup();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
await stack.db.delete(userTable);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Helper: create a user as SystemAdmin and return its id.
|
|
33
|
+
async function seedUser(overrides: {
|
|
34
|
+
email: string;
|
|
35
|
+
displayName: string;
|
|
36
|
+
passwordHash?: string;
|
|
37
|
+
}): Promise<{ id: number }> {
|
|
38
|
+
const res = await stack.http.writeOk<{ id: number }>(
|
|
39
|
+
UserHandlers.create,
|
|
40
|
+
{
|
|
41
|
+
passwordHash: "seeded-hash",
|
|
42
|
+
...overrides,
|
|
43
|
+
},
|
|
44
|
+
systemAdmin,
|
|
45
|
+
);
|
|
46
|
+
return { id: res.id };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Scenario 1: SystemAdmin creates user, me query returns correct data ---
|
|
50
|
+
|
|
51
|
+
describe("scenario 1: create + me", () => {
|
|
52
|
+
test("SystemAdmin creates a user, user sees their own profile via me", async () => {
|
|
53
|
+
const created = await seedUser({
|
|
54
|
+
email: "marc@example.com",
|
|
55
|
+
displayName: "Marc",
|
|
56
|
+
passwordHash: "secret-hash",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const signedIn = createTestUser({ id: created.id, roles: ["User"] });
|
|
60
|
+
const me = await stack.http.queryOk<Record<string, unknown>>(UserQueries.me, {}, signedIn);
|
|
61
|
+
|
|
62
|
+
expect(me).toMatchObject({
|
|
63
|
+
id: created.id,
|
|
64
|
+
email: "marc@example.com",
|
|
65
|
+
displayName: "Marc",
|
|
66
|
+
locale: "de", // comes from the entity's default — client didn't send it
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("normal user cannot create another user", async () => {
|
|
71
|
+
const normal = createTestUser({ id: 42, roles: ["User"] });
|
|
72
|
+
const error = await stack.http.writeErr(
|
|
73
|
+
UserHandlers.create,
|
|
74
|
+
{ email: "evil@example.com", displayName: "Evil" },
|
|
75
|
+
normal,
|
|
76
|
+
);
|
|
77
|
+
expectErrorIncludes(error, "access_denied");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("duplicate email is rejected", async () => {
|
|
81
|
+
await seedUser({ email: "dup@example.com", displayName: "First" });
|
|
82
|
+
const error = await stack.http.writeErr(
|
|
83
|
+
UserHandlers.create,
|
|
84
|
+
{ email: "dup@example.com", displayName: "Second" },
|
|
85
|
+
systemAdmin,
|
|
86
|
+
);
|
|
87
|
+
expectErrorIncludes(error, UserErrors.emailAlreadyExists);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// --- Scenario 2: field-level read access hides passwordHash ---
|
|
92
|
+
|
|
93
|
+
describe("scenario 2: field-level read access", () => {
|
|
94
|
+
test("user profile does not expose passwordHash via me", async () => {
|
|
95
|
+
const created = await seedUser({
|
|
96
|
+
email: "secret@example.com",
|
|
97
|
+
displayName: "Secret",
|
|
98
|
+
passwordHash: "must-stay-hidden",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const signedIn = createTestUser({ id: created.id, roles: ["User"] });
|
|
102
|
+
const me = await stack.http.queryOk<Record<string, unknown>>(UserQueries.me, {}, signedIn);
|
|
103
|
+
|
|
104
|
+
expect(me).not.toHaveProperty("passwordHash");
|
|
105
|
+
// Sanity: the value is actually stored, just hidden from this role
|
|
106
|
+
const [row] = await stack.db.select().from(userTable);
|
|
107
|
+
expect((row as { passwordHash: string }).passwordHash).toBe("must-stay-hidden");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// --- Scenario 3: user edits own profile, email/passwordHash are system-locked ---
|
|
112
|
+
|
|
113
|
+
describe("scenario 3: self-update + field-level write access", () => {
|
|
114
|
+
test("user can change their own displayName + locale", async () => {
|
|
115
|
+
const created = await seedUser({ email: "editor@example.com", displayName: "Before" });
|
|
116
|
+
const signedIn = createTestUser({ id: created.id, roles: ["User"] });
|
|
117
|
+
|
|
118
|
+
await stack.http.writeOk(
|
|
119
|
+
UserHandlers.update,
|
|
120
|
+
{ id: created.id, changes: { displayName: "After", locale: "en" }, version: 1 },
|
|
121
|
+
signedIn,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const me = await stack.http.queryOk<Record<string, unknown>>(UserQueries.me, {}, signedIn);
|
|
125
|
+
expect(me).toMatchObject({ displayName: "After", locale: "en" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("user cannot change their own email (field-level write-locked to system)", async () => {
|
|
129
|
+
const created = await seedUser({ email: "locked@example.com", displayName: "Locked" });
|
|
130
|
+
const signedIn = createTestUser({ id: created.id, roles: ["User"] });
|
|
131
|
+
|
|
132
|
+
const error = await stack.http.writeErr(
|
|
133
|
+
UserHandlers.update,
|
|
134
|
+
{ id: created.id, changes: { email: "changed@example.com" }, version: 1 },
|
|
135
|
+
signedIn,
|
|
136
|
+
);
|
|
137
|
+
expectErrorIncludes(error, "field_access_denied");
|
|
138
|
+
|
|
139
|
+
// Email is unchanged in the DB
|
|
140
|
+
const [row] = await stack.db.select().from(userTable);
|
|
141
|
+
expect((row as { email: string }).email).toBe("locked@example.com");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("user cannot update someone else's profile", async () => {
|
|
145
|
+
const victim = await seedUser({ email: "victim@example.com", displayName: "Victim" });
|
|
146
|
+
const attacker = createTestUser({ id: victim.id + 1000, roles: ["User"] });
|
|
147
|
+
|
|
148
|
+
const error = await stack.http.writeErr(
|
|
149
|
+
UserHandlers.update,
|
|
150
|
+
{ id: victim.id, changes: { displayName: "Pwned" }, version: 1 },
|
|
151
|
+
attacker,
|
|
152
|
+
);
|
|
153
|
+
expectErrorIncludes(error, UserErrors.cannotEditOtherUser);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// --- Scenario 4: detail + list are SystemAdmin-only ---
|
|
158
|
+
|
|
159
|
+
describe("scenario 4: detail + list access", () => {
|
|
160
|
+
test("SystemAdmin can fetch any user via detail", async () => {
|
|
161
|
+
const target = await seedUser({ email: "target@example.com", displayName: "Target" });
|
|
162
|
+
|
|
163
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
164
|
+
UserQueries.detail,
|
|
165
|
+
{ id: target.id },
|
|
166
|
+
systemAdmin,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(detail).toMatchObject({ id: target.id, email: "target@example.com" });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("tenant Admin cannot fetch arbitrary users (role leak guard)", async () => {
|
|
173
|
+
const target = await seedUser({ email: "other@example.com", displayName: "Other" });
|
|
174
|
+
const tenantAdmin = createTestUser({ id: 9999, roles: ["Admin"] });
|
|
175
|
+
|
|
176
|
+
const res = await stack.http.query(UserQueries.detail, { id: target.id }, tenantAdmin);
|
|
177
|
+
expect(res.status).toBe(403);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("list returns users (SystemAdmin only)", async () => {
|
|
181
|
+
await seedUser({ email: "a@example.com", displayName: "A" });
|
|
182
|
+
await seedUser({ email: "b@example.com", displayName: "B" });
|
|
183
|
+
|
|
184
|
+
const result = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
185
|
+
UserQueries.list,
|
|
186
|
+
{},
|
|
187
|
+
systemAdmin,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(result.rows.length).toBeGreaterThanOrEqual(2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("normal user cannot list", async () => {
|
|
194
|
+
const signedIn = createTestUser({ id: 2000, roles: ["User"] });
|
|
195
|
+
const res = await stack.http.query(UserQueries.list, {}, signedIn);
|
|
196
|
+
expect(res.status).toBe(403);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Command-input schemas for the user write handlers, re-exposed for external
|
|
2
|
+
// consumers — primarily migration mappers that need to write events directly
|
|
3
|
+
// into the core `user` stream via `eventStore.appendRaw` (Marten-bypass) and
|
|
4
|
+
// must validate their payloads against the exact handler contract.
|
|
5
|
+
//
|
|
6
|
+
// See `tenant/command-schemas.ts` for the same pattern + the schema-vs-event-
|
|
7
|
+
// payload caveat (strip-id, defaults, sensitive, compound-type flattening).
|
|
8
|
+
|
|
9
|
+
import { createWrite } from "./handlers/create.write";
|
|
10
|
+
import { updateWrite } from "./handlers/update.write";
|
|
11
|
+
|
|
12
|
+
export const UserCommandSchemas = {
|
|
13
|
+
create: createWrite.schema,
|
|
14
|
+
update: updateWrite.schema,
|
|
15
|
+
} as const;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Feature name
|
|
2
|
+
export const USER_FEATURE = "user" as const;
|
|
3
|
+
|
|
4
|
+
// Qualified write handler names. Handlers carry the "user:" entity prefix so
|
|
5
|
+
// field-level access rules (passwordHash system-only etc.) are wired up.
|
|
6
|
+
export const UserHandlers = {
|
|
7
|
+
create: "user:write:user:create",
|
|
8
|
+
update: "user:write:user:update",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
// Qualified query handler names
|
|
12
|
+
export const UserQueries = {
|
|
13
|
+
me: "user:query:user:me",
|
|
14
|
+
detail: "user:query:user:detail",
|
|
15
|
+
list: "user:query:user:list",
|
|
16
|
+
findForAuth: "user:query:user:find-for-auth",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
// Error codes
|
|
20
|
+
export const UserErrors = {
|
|
21
|
+
emailAlreadyExists: "email_already_exists",
|
|
22
|
+
cannotEditOtherUser: "cannot_edit_other_user",
|
|
23
|
+
} as const;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { createWrite } from "./handlers/create.write";
|
|
3
|
+
import { detailQuery } from "./handlers/detail.query";
|
|
4
|
+
import { findForAuthQuery } from "./handlers/find-for-auth.query";
|
|
5
|
+
import { listQuery } from "./handlers/list.query";
|
|
6
|
+
import { meQuery } from "./handlers/me.query";
|
|
7
|
+
import { updateWrite } from "./handlers/update.write";
|
|
8
|
+
import { userEntity } from "./schema/user";
|
|
9
|
+
|
|
10
|
+
// The user feature holds the cross-tenant user identity. `systemScope()` means
|
|
11
|
+
// queries and writes bypass the tenant filter — a user exists above any tenant.
|
|
12
|
+
// Membership + tenant-specific roles live in the tenant feature.
|
|
13
|
+
export function createUserFeature(): FeatureDefinition {
|
|
14
|
+
return defineFeature("user", (r) => {
|
|
15
|
+
r.systemScope();
|
|
16
|
+
r.entity("user", userEntity);
|
|
17
|
+
|
|
18
|
+
const handlers = {
|
|
19
|
+
create: r.writeHandler(createWrite),
|
|
20
|
+
update: r.writeHandler(updateWrite),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const queries = {
|
|
24
|
+
me: r.queryHandler(meQuery),
|
|
25
|
+
detail: r.queryHandler(detailQuery),
|
|
26
|
+
list: r.queryHandler(listQuery),
|
|
27
|
+
findForAuth: r.queryHandler(findForAuthQuery),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return { handlers, queries };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { UserErrors } from "../constants";
|
|
7
|
+
import { userEntity, userTable } from "../schema/user";
|
|
8
|
+
|
|
9
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
10
|
+
|
|
11
|
+
// Only the Auth features (running as SYSTEM) or a SystemAdmin may create users.
|
|
12
|
+
//
|
|
13
|
+
// Email uniqueness is checked via a pre-flight query — the framework has no
|
|
14
|
+
// `unique:` field flag yet. This check is race-prone: two concurrent requests
|
|
15
|
+
// can both see "no duplicate" and both insert. Acceptable MVP behavior since
|
|
16
|
+
// user creation is low-frequency and gated by privileged roles; the DB will
|
|
17
|
+
// still surface a pg unique violation once we add the constraint.
|
|
18
|
+
// TODO: replace with a real `unique:` field flag + DB constraint.
|
|
19
|
+
export const createWrite = defineWriteHandler({
|
|
20
|
+
name: "user:create",
|
|
21
|
+
schema: z.object({
|
|
22
|
+
email: z.email(),
|
|
23
|
+
passwordHash: z.string().optional(),
|
|
24
|
+
displayName: z.string().min(1).max(100),
|
|
25
|
+
locale: z.string().min(2).max(10).optional(),
|
|
26
|
+
// Globale Rollen — JSON-encoded string[]. Optional weil der Default
|
|
27
|
+
// im Entity-Schema "[]" ist; setzen wenn man einen SystemAdmin (oder
|
|
28
|
+
// andere globale Rollen) anlegt. Field-Access (write: privileged) auf
|
|
29
|
+
// der Entity ist die letzte Hand: wer auch immer create dispatcht ist
|
|
30
|
+
// schon privileged (system/SystemAdmin), aber das Field-Guard läuft
|
|
31
|
+
// trotzdem als defense-in-depth.
|
|
32
|
+
roles: z.string().optional(),
|
|
33
|
+
}),
|
|
34
|
+
access: { roles: ["system", "SystemAdmin"] },
|
|
35
|
+
handler: async (event, ctx) => {
|
|
36
|
+
const existing = await ctx.db
|
|
37
|
+
.select({ id: userTable["id"] })
|
|
38
|
+
.from(userTable)
|
|
39
|
+
.where(eq(userTable["email"], event.payload.email))
|
|
40
|
+
.limit(1);
|
|
41
|
+
|
|
42
|
+
if (existing.length > 0) {
|
|
43
|
+
return writeFailure(
|
|
44
|
+
new ConflictError({
|
|
45
|
+
message: "email already exists",
|
|
46
|
+
i18nKey: "user.errors.emailAlreadyExists",
|
|
47
|
+
details: { reason: UserErrors.emailAlreadyExists, field: "email" },
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return crud.create(event.payload, event.user, ctx.db);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { access, defineEntityDetailHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { userEntity } from "../schema/user";
|
|
3
|
+
|
|
4
|
+
// Only SystemAdmins can read arbitrary users. Tenant-level "Admin" does NOT
|
|
5
|
+
// grant this — the user feature is tenant-agnostic, and an Admin's scope is
|
|
6
|
+
// bound to their own tenant's memberships (served by the tenant feature).
|
|
7
|
+
export const detailQuery = defineEntityDetailHandler("user", userEntity, {
|
|
8
|
+
access: { roles: access.systemAdmin },
|
|
9
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { userTable } from "../schema/user";
|
|
6
|
+
|
|
7
|
+
// Privileged auth lookup: returns the full user row — including passwordHash —
|
|
8
|
+
// by email OR id (exactly one, enforced by the schema). Used by the auth
|
|
9
|
+
// features via ctx.queryAs(systemUser, ...).
|
|
10
|
+
//
|
|
11
|
+
// Field-level read rules allow passwordHash for the "privileged" role set,
|
|
12
|
+
// so system callers see everything; any other caller is filtered even if
|
|
13
|
+
// they somehow reach this handler. Access is also restricted to privileged
|
|
14
|
+
// — regular users or tenant admins cannot call this at all.
|
|
15
|
+
export const findForAuthQuery = defineQueryHandler({
|
|
16
|
+
name: "user:find-for-auth",
|
|
17
|
+
schema: z
|
|
18
|
+
.object({
|
|
19
|
+
email: z.email().optional(),
|
|
20
|
+
id: z.uuid().optional(),
|
|
21
|
+
})
|
|
22
|
+
.refine(
|
|
23
|
+
// XOR: exactly one must be set. Neither or both is a caller bug, not an
|
|
24
|
+
// ambiguous lookup.
|
|
25
|
+
(v) => (v.email !== undefined) !== (v.id !== undefined),
|
|
26
|
+
{ message: "exactly one of email or id must be set" },
|
|
27
|
+
),
|
|
28
|
+
access: { roles: access.privileged },
|
|
29
|
+
handler: async (query, ctx) => {
|
|
30
|
+
const condition =
|
|
31
|
+
query.payload.email !== undefined
|
|
32
|
+
? eq(userTable["email"], query.payload.email)
|
|
33
|
+
: eq(userTable["id"], query.payload.id as string);
|
|
34
|
+
|
|
35
|
+
const [row] = await ctx.db.select().from(userTable).where(condition).limit(1);
|
|
36
|
+
return (row as DbRow) ?? null;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { access, defineEntityListHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { userEntity } from "../schema/user";
|
|
3
|
+
|
|
4
|
+
// System-wide user listing is SystemAdmin-only. Tenant admins list their
|
|
5
|
+
// members via the tenant feature (which scopes by membership, not globally).
|
|
6
|
+
export const listQuery = defineEntityListHandler("user", userEntity, {
|
|
7
|
+
access: { roles: access.systemAdmin },
|
|
8
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { userEntity, userTable } from "../schema/user";
|
|
5
|
+
|
|
6
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
7
|
+
|
|
8
|
+
// Returns the currently signed-in user's profile. Field-level read access
|
|
9
|
+
// strips out the passwordHash automatically (configured on the entity).
|
|
10
|
+
export const meQuery = defineQueryHandler({
|
|
11
|
+
name: "user:me",
|
|
12
|
+
schema: z.object({}),
|
|
13
|
+
access: { openToAll: true },
|
|
14
|
+
handler: async (query, ctx) => crud.detail({ id: query.user.id }, query.user, ctx.db),
|
|
15
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { access, defineWriteHandler, hasAccess } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { UserErrors } from "../constants";
|
|
6
|
+
import { userEntity, userTable } from "../schema/user";
|
|
7
|
+
|
|
8
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
9
|
+
|
|
10
|
+
// Users can update their OWN profile; SystemAdmin/system can update anyone.
|
|
11
|
+
// Handler-level access is openToAll — the row guard below is the actual gate,
|
|
12
|
+
// and field-level access (passwordHash/email write-locked to "privileged")
|
|
13
|
+
// stops any write that shouldn't touch an identity column.
|
|
14
|
+
export const updateWrite = defineWriteHandler({
|
|
15
|
+
name: "user:update",
|
|
16
|
+
schema: z.object({
|
|
17
|
+
id: z.uuid(),
|
|
18
|
+
// Clients must send the version they read. The CrudExecutor rejects
|
|
19
|
+
// missing versions with version_conflict — see optimistic-locking in
|
|
20
|
+
// crud-executor.ts.
|
|
21
|
+
version: z.number(),
|
|
22
|
+
changes: z.object({
|
|
23
|
+
displayName: z.string().min(1).max(100).optional(),
|
|
24
|
+
locale: z.string().min(2).max(10).optional(),
|
|
25
|
+
email: z.email().optional(),
|
|
26
|
+
passwordHash: z.string().optional(),
|
|
27
|
+
lastActiveTenantId: z.string().optional(),
|
|
28
|
+
emailVerified: z.boolean().optional(),
|
|
29
|
+
// Globale Rollen — JSON-encoded string[]. Field-level write-access
|
|
30
|
+
// ist privileged (siehe userEntity.roles), d.h. ein non-privileged
|
|
31
|
+
// Caller sieht hier zwar einen 200, aber das Field-Guard im
|
|
32
|
+
// executor blockt die Spalte vorm Schreiben (silent strip). Schema
|
|
33
|
+
// akzeptiert das field damit der SystemAdmin-Pfad explizit
|
|
34
|
+
// existiert; der Privilege-Escalation-Schutz greift im
|
|
35
|
+
// FieldAccessFilter, nicht im Schema.
|
|
36
|
+
roles: z.string().optional(),
|
|
37
|
+
}),
|
|
38
|
+
}),
|
|
39
|
+
access: { openToAll: true },
|
|
40
|
+
handler: async (event, ctx) => {
|
|
41
|
+
const isSelf = event.payload.id === event.user.id;
|
|
42
|
+
const isPrivileged = hasAccess(event.user, { roles: access.privileged });
|
|
43
|
+
if (!isSelf && !isPrivileged) {
|
|
44
|
+
return writeFailure(
|
|
45
|
+
new AccessDeniedError({
|
|
46
|
+
message: "cannot edit other user",
|
|
47
|
+
i18nKey: "user.errors.cannotEditOtherUser",
|
|
48
|
+
details: { reason: UserErrors.cannotEditOtherUser, targetUserId: event.payload.id },
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return crud.update(event.payload, event.user, ctx.db);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
createBooleanField,
|
|
5
|
+
createEntity,
|
|
6
|
+
createTextField,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
|
|
9
|
+
// User entity — tenant-agnostic. A single user can belong to multiple tenants
|
|
10
|
+
// via tenantMemberships. No tenantId column on this table.
|
|
11
|
+
export const userEntity = createEntity({
|
|
12
|
+
table: "read_users",
|
|
13
|
+
softDelete: true,
|
|
14
|
+
fields: {
|
|
15
|
+
// Identity — anyone who can see the user can read the email, but only
|
|
16
|
+
// privileged roles (SYSTEM auth code, SystemAdmin) may change it.
|
|
17
|
+
email: createTextField({
|
|
18
|
+
required: true,
|
|
19
|
+
format: "email",
|
|
20
|
+
maxLength: 320,
|
|
21
|
+
access: { write: access.privileged },
|
|
22
|
+
}),
|
|
23
|
+
|
|
24
|
+
// Password material: only SYSTEM/SystemAdmin can read or write it.
|
|
25
|
+
// auth-email-password reads it during login, writes it during registration
|
|
26
|
+
// and password changes. Stripped from ordinary responses via read-access.
|
|
27
|
+
passwordHash: createTextField({
|
|
28
|
+
maxLength: 255,
|
|
29
|
+
access: { read: access.privileged, write: access.privileged },
|
|
30
|
+
}),
|
|
31
|
+
|
|
32
|
+
// Profile — user-editable
|
|
33
|
+
displayName: createTextField({ required: true, maxLength: 100, searchable: true }),
|
|
34
|
+
locale: createTextField({ maxLength: 10, default: "de" }),
|
|
35
|
+
|
|
36
|
+
// Which tenant should this user land in on next login. Set by the login
|
|
37
|
+
// handler (SYSTEM), read by the login flow + UI for deep-linking.
|
|
38
|
+
// UUID string matching tenants.id; createTextField stores it as text.
|
|
39
|
+
lastActiveTenantId: createTextField({
|
|
40
|
+
maxLength: 36,
|
|
41
|
+
access: { write: access.privileged },
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
// Email-verification flag — flipped to true by the verify-email handler
|
|
45
|
+
// after an HMAC-signed token roundtrip. Readable by anyone who can see
|
|
46
|
+
// the user row; writable only by privileged (system) callers so a user
|
|
47
|
+
// can't self-mark themselves verified. Login can be config-gated to
|
|
48
|
+
// refuse a session while this is false (strict mode).
|
|
49
|
+
emailVerified: createBooleanField({
|
|
50
|
+
default: false,
|
|
51
|
+
access: { write: access.privileged },
|
|
52
|
+
}),
|
|
53
|
+
|
|
54
|
+
// Globale Rollen — parallel zu tenantMemberships.roles. JSON-encoded
|
|
55
|
+
// string[]; parseRoles() deserialisiert beim Read. Login-Handler mergt
|
|
56
|
+
// diese Rollen mit den tenant-membership-roles in die Session — so
|
|
57
|
+
// sind sie tenant-unabhängig (z.B. SystemAdmin, BillingAdmin). Default
|
|
58
|
+
// "[]" damit die Session-Roles-Merge keinen NULL-Branch braucht.
|
|
59
|
+
// Schreibrecht privileged: ein User darf sich nicht selbst zum
|
|
60
|
+
// SystemAdmin machen.
|
|
61
|
+
roles: createTextField({
|
|
62
|
+
required: true,
|
|
63
|
+
default: "[]",
|
|
64
|
+
access: { write: access.privileged },
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const userTable = buildDrizzleTable("user", userEntity);
|