@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,21 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
5
|
+
|
|
6
|
+
const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
7
|
+
|
|
8
|
+
// Optional `id`: SystemAdmin-only handler — legitimer Pfad für Seeds und
|
|
9
|
+
// externe Provisionierung (SCIM, IdP-Sync, Migration aus bestehenden Systemen),
|
|
10
|
+
// wo der Tenant mit einer vom Caller gewählten UUID angelegt werden muss.
|
|
11
|
+
// Wenn nicht gesetzt, Postgres vergibt via gen_random_uuid() eine neue UUID.
|
|
12
|
+
export const createWrite = defineWriteHandler({
|
|
13
|
+
name: "create",
|
|
14
|
+
schema: z.object({
|
|
15
|
+
id: z.uuid().optional(),
|
|
16
|
+
key: z.string().min(1).max(50),
|
|
17
|
+
name: z.string().min(1).max(200),
|
|
18
|
+
}),
|
|
19
|
+
access: { roles: ["SystemAdmin"] },
|
|
20
|
+
handler: async (event, ctx) => crud.create(event.payload, event.user, ctx.db),
|
|
21
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
5
|
+
|
|
6
|
+
const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
7
|
+
|
|
8
|
+
export const disableWrite = defineWriteHandler({
|
|
9
|
+
name: "disable",
|
|
10
|
+
schema: z.object({ id: z.uuid() }),
|
|
11
|
+
access: { roles: ["SystemAdmin"] },
|
|
12
|
+
// Admin flip: last-writer-wins is fine. SystemAdmin is the only caller and
|
|
13
|
+
// there's no meaningful concurrent-edit race on this single boolean.
|
|
14
|
+
handler: async (event, ctx) =>
|
|
15
|
+
crud.update({ id: event.payload.id, changes: { isEnabled: false } }, event.user, ctx.db, {
|
|
16
|
+
skipOptimisticLock: true,
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table";
|
|
5
|
+
|
|
6
|
+
// Pending-Invitations-Liste für den aktuellen Tenant. Admin-only.
|
|
7
|
+
// Filter: status="pending" — accepted/cancelled/expired sind für die
|
|
8
|
+
// UI uninteressant (UI zeigt nur "ausstehende Einladungen"; Audit-Log
|
|
9
|
+
// für historische gehört in ein separates Audit-Feature).
|
|
10
|
+
//
|
|
11
|
+
// SQL-side filter (vorher JS-side .filter): bei Tenants mit vielen
|
|
12
|
+
// historischen invitations lädt die Query sonst alle Rows in den
|
|
13
|
+
// Node-process um die meisten wegzuwerfen — DB indexed das auf den
|
|
14
|
+
// (tenantId, …)-key, JS-filter ist redundant.
|
|
15
|
+
export const invitationsQuery = defineQueryHandler({
|
|
16
|
+
name: "invitations",
|
|
17
|
+
schema: z.object({}),
|
|
18
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
19
|
+
handler: async (query, ctx) => {
|
|
20
|
+
const rows = await ctx.db
|
|
21
|
+
?.select()
|
|
22
|
+
.from(tenantInvitationsTable)
|
|
23
|
+
.where(
|
|
24
|
+
and(
|
|
25
|
+
eq(tenantInvitationsTable.tenantId, query.user.tenantId),
|
|
26
|
+
eq(tenantInvitationsTable.status, INVITATION_STATUS.pending),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
return rows ?? [];
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
5
|
+
|
|
6
|
+
const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
7
|
+
|
|
8
|
+
export const listQuery = defineQueryHandler({
|
|
9
|
+
name: "list",
|
|
10
|
+
schema: z.object({
|
|
11
|
+
cursor: z.string().optional(),
|
|
12
|
+
limit: z.number().optional(),
|
|
13
|
+
search: z.string().optional(),
|
|
14
|
+
}),
|
|
15
|
+
access: { roles: ["SystemAdmin"] },
|
|
16
|
+
handler: async (query, ctx) => crud.list(query.payload, query.user, ctx.db),
|
|
17
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { tenantTable } from "../schema/tenant";
|
|
6
|
+
|
|
7
|
+
// Direct query — query-handlers haben keinen tenant-crud-Handle. Direct-select
|
|
8
|
+
// ist trivial: WHERE id = tenantId (beides UUID). Kein CRUD-Detour nötig.
|
|
9
|
+
export const meQuery = defineQueryHandler({
|
|
10
|
+
name: "me",
|
|
11
|
+
schema: z.object({}),
|
|
12
|
+
access: { openToAll: true },
|
|
13
|
+
handler: async (query, ctx) => {
|
|
14
|
+
const row = await fetchOne(ctx.db, tenantTable, eq(tenantTable["id"], query.user.tenantId));
|
|
15
|
+
return row ?? null;
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { tenantMembershipsTable } from "../membership-table";
|
|
6
|
+
|
|
7
|
+
export const membersQuery = defineQueryHandler({
|
|
8
|
+
name: "members",
|
|
9
|
+
schema: z.object({}),
|
|
10
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
11
|
+
handler: async (query, ctx) => {
|
|
12
|
+
const rows = await ctx.db
|
|
13
|
+
?.select()
|
|
14
|
+
.from(tenantMembershipsTable)
|
|
15
|
+
.where(eq(tenantMembershipsTable.tenantId, query.user.tenantId));
|
|
16
|
+
|
|
17
|
+
return rows.map((row) => ({
|
|
18
|
+
...row,
|
|
19
|
+
roles: parseRoles(row["roles"]),
|
|
20
|
+
}));
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { tenantMembershipsTable } from "../membership-table";
|
|
6
|
+
|
|
7
|
+
export const membershipsQuery = defineQueryHandler({
|
|
8
|
+
name: "memberships",
|
|
9
|
+
schema: z.object({ userId: z.string() }),
|
|
10
|
+
// Called via ctx.queryAs(systemUser, ...) during login/switch-tenant, or
|
|
11
|
+
// directly by tenant admins managing memberships in the admin UI.
|
|
12
|
+
access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
|
|
13
|
+
handler: async (query, ctx) => {
|
|
14
|
+
const rows = await ctx.db
|
|
15
|
+
?.select()
|
|
16
|
+
.from(tenantMembershipsTable)
|
|
17
|
+
.where(eq(tenantMembershipsTable.userId, query.payload.userId));
|
|
18
|
+
|
|
19
|
+
return rows.map((row) => ({
|
|
20
|
+
...row,
|
|
21
|
+
roles: parseRoles(row["roles"]),
|
|
22
|
+
}));
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createEventStoreExecutor, type DbRow, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler, withResponseData } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
|
|
7
|
+
|
|
8
|
+
const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
|
|
9
|
+
entityName: "tenant-membership",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const removeMemberWrite = defineWriteHandler({
|
|
13
|
+
name: "removeMember",
|
|
14
|
+
schema: z.object({ userId: z.string(), tenantId: z.string() }),
|
|
15
|
+
access: { roles: ["SystemAdmin"] },
|
|
16
|
+
handler: async (event, ctx) => {
|
|
17
|
+
const db = ctx.db;
|
|
18
|
+
const existing = await fetchOne(
|
|
19
|
+
db,
|
|
20
|
+
tenantMembershipsTable,
|
|
21
|
+
eq(tenantMembershipsTable.userId, event.payload.userId),
|
|
22
|
+
eq(tenantMembershipsTable.tenantId, event.payload.tenantId),
|
|
23
|
+
);
|
|
24
|
+
if (!existing) {
|
|
25
|
+
return writeFailure(
|
|
26
|
+
new NotFoundError("membership", undefined, {
|
|
27
|
+
i18nKey: "tenant.errors.membershipNotFound",
|
|
28
|
+
i18nParams: { userId: event.payload.userId, tenantId: event.payload.tenantId },
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = await executor.delete(
|
|
34
|
+
{ id: (existing as DbRow)["id"] as string },
|
|
35
|
+
event.user,
|
|
36
|
+
db,
|
|
37
|
+
);
|
|
38
|
+
return withResponseData(result, event.payload);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { tenantMembershipsTable } from "../membership-table";
|
|
5
|
+
|
|
6
|
+
// Cross-feature query: resolve user IDs by tenantId or userId.
|
|
7
|
+
// Other features (delivery, jobs, etc.) use this to get user lists
|
|
8
|
+
// without knowing about membership internals.
|
|
9
|
+
//
|
|
10
|
+
// Examples:
|
|
11
|
+
// { tenantId: 1 } → all user IDs in tenant 1
|
|
12
|
+
// { userId: 5 } → [5] if member of any tenant, [] if not
|
|
13
|
+
export const resolveUserIdsQuery = defineQueryHandler({
|
|
14
|
+
name: "resolveUserIds",
|
|
15
|
+
schema: z.object({
|
|
16
|
+
tenantId: z.string().optional(),
|
|
17
|
+
userId: z.string().optional(),
|
|
18
|
+
}),
|
|
19
|
+
// System-internal: invoked by other features (delivery, jobs) through queryAs(systemUser, ...).
|
|
20
|
+
// Never called directly by an end-user request.
|
|
21
|
+
access: { roles: [SYSTEM_ROLE] },
|
|
22
|
+
handler: async (query, ctx) => {
|
|
23
|
+
const { tenantId, userId } = query.payload;
|
|
24
|
+
|
|
25
|
+
if (tenantId !== undefined) {
|
|
26
|
+
const rows = await ctx.db
|
|
27
|
+
.select({ userId: tenantMembershipsTable.userId })
|
|
28
|
+
.from(tenantMembershipsTable)
|
|
29
|
+
.where(eq(tenantMembershipsTable.tenantId, tenantId));
|
|
30
|
+
return rows.map((r) => r["userId"] as number);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (userId !== undefined) {
|
|
34
|
+
const rows = await ctx.db
|
|
35
|
+
.select({ userId: tenantMembershipsTable.userId })
|
|
36
|
+
.from(tenantMembershipsTable)
|
|
37
|
+
.where(eq(tenantMembershipsTable.userId, userId));
|
|
38
|
+
return rows.length > 0 ? [userId] : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return [];
|
|
42
|
+
},
|
|
43
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createEventStoreExecutor, type DbRow, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler, withResponseData } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
|
|
7
|
+
|
|
8
|
+
const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
|
|
9
|
+
entityName: "tenant-membership",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const updateMemberRolesWrite = defineWriteHandler({
|
|
13
|
+
name: "updateMemberRoles",
|
|
14
|
+
schema: z.object({
|
|
15
|
+
userId: z.string(),
|
|
16
|
+
tenantId: z.string(),
|
|
17
|
+
roles: z.array(z.string()).min(1),
|
|
18
|
+
}),
|
|
19
|
+
access: { roles: ["SystemAdmin"] },
|
|
20
|
+
handler: async (event, ctx) => {
|
|
21
|
+
const db = ctx.db;
|
|
22
|
+
const existing = await fetchOne(
|
|
23
|
+
db,
|
|
24
|
+
tenantMembershipsTable,
|
|
25
|
+
eq(tenantMembershipsTable.userId, event.payload.userId),
|
|
26
|
+
eq(tenantMembershipsTable.tenantId, event.payload.tenantId),
|
|
27
|
+
);
|
|
28
|
+
if (!existing) {
|
|
29
|
+
return writeFailure(
|
|
30
|
+
new NotFoundError("membership", undefined, {
|
|
31
|
+
i18nKey: "tenant.errors.membershipNotFound",
|
|
32
|
+
i18nParams: { userId: event.payload.userId, tenantId: event.payload.tenantId },
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// fetchOne already gave us the stream version — hand it to the executor
|
|
38
|
+
// instead of skipping the lock. Race window (another SystemAdmin writing
|
|
39
|
+
// between this read and append) surfaces as version_conflict rather than
|
|
40
|
+
// silent overwrite. Per-membership parallelism is rare; if it happens,
|
|
41
|
+
// the client retries on the error.
|
|
42
|
+
const row = existing as DbRow;
|
|
43
|
+
const result = await executor.update(
|
|
44
|
+
{
|
|
45
|
+
id: row["id"] as string,
|
|
46
|
+
version: row["version"] as number,
|
|
47
|
+
changes: { roles: JSON.stringify(event.payload.roles) },
|
|
48
|
+
},
|
|
49
|
+
event.user,
|
|
50
|
+
db,
|
|
51
|
+
);
|
|
52
|
+
return withResponseData(result, event.payload);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
5
|
+
|
|
6
|
+
const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
7
|
+
|
|
8
|
+
export const updateWrite = defineWriteHandler({
|
|
9
|
+
name: "update",
|
|
10
|
+
schema: z.object({
|
|
11
|
+
id: z.uuid(),
|
|
12
|
+
// Clients must send the version they read. The CrudExecutor rejects
|
|
13
|
+
// missing versions with version_conflict — see the optimistic-locking
|
|
14
|
+
// design note in crud-executor.ts.
|
|
15
|
+
version: z.number(),
|
|
16
|
+
changes: z.object({ name: z.string().min(1).max(200).optional() }),
|
|
17
|
+
}),
|
|
18
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
19
|
+
handler: async (event, ctx) => crud.update(event.payload, event.user, ctx.db),
|
|
20
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { TenantCommandSchemas } from "./command-schemas";
|
|
2
|
+
export { TENANT_FEATURE, TenantErrors, TenantHandlers, TenantQueries } from "./constants";
|
|
3
|
+
export { createTenantFeature } from "./feature";
|
|
4
|
+
export type { InvitationStatus } from "./invitation-table";
|
|
5
|
+
export {
|
|
6
|
+
INVITATION_STATUS,
|
|
7
|
+
INVITATION_STATUSES,
|
|
8
|
+
tenantInvitationEntity,
|
|
9
|
+
tenantInvitationsTable,
|
|
10
|
+
} from "./invitation-table";
|
|
11
|
+
export { tenantMembershipsTable } from "./membership-table";
|
|
12
|
+
export { tenantEntity, tenantTable } from "./schema/tenant";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Tenant-Invitations: Pre-Membership-Records mit Magic-Link-Token-Flow.
|
|
2
|
+
//
|
|
3
|
+
// Lifecycle:
|
|
4
|
+
// 1. Admin invitet email → DB-Row entsteht mit status="pending",
|
|
5
|
+
// Random-Token in Redis (signup-style bidirektional)
|
|
6
|
+
// 2. Mail an die invited Email mit Activation-URL
|
|
7
|
+
// 3. Klick auf Link → 3 Branches:
|
|
8
|
+
// a) Eingeloggt + Email matched session-user → Membership-Add
|
|
9
|
+
// b) Anonymous + Email existiert in users → Login → Auto-Accept
|
|
10
|
+
// c) Anonymous + Email neu → Password setzen → user+membership entstehen
|
|
11
|
+
// 4. Bei Erfolg: status="accepted", token aus Redis gelöscht (single-use-burn)
|
|
12
|
+
// 5. Bei Cancel durch Admin: status="cancelled", token aus Redis gelöscht
|
|
13
|
+
// 6. Bei TTL-Ablauf: Redis räumt Token, DB-Row bleibt mit status="pending"
|
|
14
|
+
// (Cleanup-Job marked sie als "expired" — separater Concern)
|
|
15
|
+
//
|
|
16
|
+
// Single-Truth für expiry: Redis-TTL. DB-row.expiresAt ist nur UI-
|
|
17
|
+
// Anzeige ("läuft in 6 Tagen ab"). Bei Lookup: pending in DB + token
|
|
18
|
+
// nicht mehr in Redis → effectively expired, accept schlägt fehl mit
|
|
19
|
+
// invalid-token.
|
|
20
|
+
//
|
|
21
|
+
// Idempotenz: zweiter invite für gleiche (tenantId, email) während
|
|
22
|
+
// pending → re-use existing row + refresh Redis-token + send mail
|
|
23
|
+
// (analog zu signup-Resend).
|
|
24
|
+
|
|
25
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
+
import {
|
|
27
|
+
createEntity,
|
|
28
|
+
createSelectField,
|
|
29
|
+
createTextField,
|
|
30
|
+
createTimestampField,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
+
|
|
33
|
+
// Status-const-Object damit Handler-Code keine Magic-Strings nutzt.
|
|
34
|
+
// Bei rename (z.B. "cancelled" → "revoked") fällt jeder caller auf
|
|
35
|
+
// einmal auf statt verstreut über 5 Stellen.
|
|
36
|
+
export const INVITATION_STATUS = {
|
|
37
|
+
pending: "pending",
|
|
38
|
+
accepted: "accepted",
|
|
39
|
+
cancelled: "cancelled",
|
|
40
|
+
expired: "expired",
|
|
41
|
+
} as const;
|
|
42
|
+
export type InvitationStatus = (typeof INVITATION_STATUS)[keyof typeof INVITATION_STATUS];
|
|
43
|
+
|
|
44
|
+
// Order MUSS bit-identisch zur DB-Migration sein. Object.values
|
|
45
|
+
// bewahrt insertion-order (JS-spec-stable für string-keys). Wenn
|
|
46
|
+
// jemand INVITATION_STATUS reordnet, generiert drizzle-kit eine
|
|
47
|
+
// neue Migration. Hardcoded-Tuple zur Sicherheit gegen versehentliches
|
|
48
|
+
// Refactoring der Object-Keys.
|
|
49
|
+
export const INVITATION_STATUSES = [
|
|
50
|
+
INVITATION_STATUS.pending,
|
|
51
|
+
INVITATION_STATUS.accepted,
|
|
52
|
+
INVITATION_STATUS.cancelled,
|
|
53
|
+
INVITATION_STATUS.expired,
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export const tenantInvitationEntity = createEntity({
|
|
57
|
+
table: "read_tenant_invitations",
|
|
58
|
+
fields: {
|
|
59
|
+
// Eingeladene Email — case-insensitive normalisiert beim Insert.
|
|
60
|
+
email: createTextField({ required: true, maxLength: 320 }),
|
|
61
|
+
// Membership-Rolle die dem User nach Accept gegeben wird. Default
|
|
62
|
+
// im handler ist "Admin" (Co-Admin-Pattern für kleine Teams).
|
|
63
|
+
role: createTextField({ required: true, maxLength: 50 }),
|
|
64
|
+
// Lifecycle-State. Default "pending"; transitions:
|
|
65
|
+
// pending → accepted | cancelled | expired
|
|
66
|
+
status: createSelectField({
|
|
67
|
+
options: INVITATION_STATUSES,
|
|
68
|
+
required: true,
|
|
69
|
+
default: "pending",
|
|
70
|
+
}),
|
|
71
|
+
// userId des einladenden Admins (für Audit-Trail "wer hat eingeladen").
|
|
72
|
+
invitedBy: createTextField({ required: true }),
|
|
73
|
+
// UI-Anzeige — Wahrheit liegt in Redis-TTL.
|
|
74
|
+
expiresAt: createTimestampField({ required: true }),
|
|
75
|
+
},
|
|
76
|
+
// Eine Invitation-Row pro (tenantId, email). Bei Re-Invite (Admin
|
|
77
|
+
// invitet zweite Mal nach Cancel/Accept) wird die existing row
|
|
78
|
+
// updated: status pending → cancelled → pending zurück, expiresAt
|
|
79
|
+
// refreshed. Verhindert Token-Doppel-Gabe + macht Resend-Idempotenz
|
|
80
|
+
// im handler trivial.
|
|
81
|
+
indexes: [
|
|
82
|
+
{
|
|
83
|
+
unique: true,
|
|
84
|
+
columns: ["tenantId", "email"],
|
|
85
|
+
name: "read_tenant_invitations_tenant_email_unique",
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const tenantInvitationsTable = buildDrizzleTable(
|
|
91
|
+
"tenant-invitation",
|
|
92
|
+
tenantInvitationEntity,
|
|
93
|
+
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
|
|
4
|
+
// Membership is event-sourced. Each (userId, tenantId) pair is its own
|
|
5
|
+
// aggregate stream — lifecycle events `tenantMembership.created /
|
|
6
|
+
// .updated / .deleted` flow through createEventStoreExecutor, which writes
|
|
7
|
+
// the stream + this projection in one TX. Queries read straight from the
|
|
8
|
+
// projection.
|
|
9
|
+
//
|
|
10
|
+
// UUID PK is mandatory for the event-store (aggregateId is uuid). The
|
|
11
|
+
// unique index on (userId, tenantId) stays — it was the effective PK under
|
|
12
|
+
// the old serial-id design and keeps duplicate-write protection at the
|
|
13
|
+
// database level independent of the handler lookup.
|
|
14
|
+
//
|
|
15
|
+
// Single-Source-of-Truth: `tenantMembershipEntity`. Die DB-Tabelle wird
|
|
16
|
+
// aus der EntityDefinition über buildDrizzleTable abgeleitet, der
|
|
17
|
+
// unique-Index ist via entity.indexes deklariert.
|
|
18
|
+
export const tenantMembershipEntity = createEntity({
|
|
19
|
+
table: "read_tenant_memberships",
|
|
20
|
+
fields: {
|
|
21
|
+
userId: createTextField({ required: true }),
|
|
22
|
+
// JSON-encoded string[] — parseRoles() deserializes at read time.
|
|
23
|
+
// Mirrors how roles were stored under the pre-ES row model so the
|
|
24
|
+
// read-side stays byte-compatible and no MSP/consumer needs rewrites.
|
|
25
|
+
roles: createTextField({ required: true }),
|
|
26
|
+
},
|
|
27
|
+
indexes: [
|
|
28
|
+
{ unique: true, columns: ["userId", "tenantId"], name: "read_tenant_memberships_unique" },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const tenantMembershipsTable = buildDrizzleTable(
|
|
33
|
+
"tenant-membership",
|
|
34
|
+
tenantMembershipEntity,
|
|
35
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createBooleanField,
|
|
4
|
+
createEntity,
|
|
5
|
+
createTextField,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
export const tenantEntity = createEntity({
|
|
9
|
+
table: "read_tenants",
|
|
10
|
+
// tenant.id IS the tenantId-value that every other table references as FK.
|
|
11
|
+
// Alle tenantId-Spalten sind UUID (Migration 2026-04-16) → tenant.id muss
|
|
12
|
+
// UUID sein, sonst findet der tenants-Lookup nie. Default gen_random_uuid().
|
|
13
|
+
fields: {
|
|
14
|
+
key: createTextField({ required: true, maxLength: 50 }),
|
|
15
|
+
name: createTextField({ required: true, maxLength: 200, searchable: true }),
|
|
16
|
+
isEnabled: createBooleanField({ default: true }),
|
|
17
|
+
},
|
|
18
|
+
// tenant.key wird in Admin-URLs verwendet (`admin.<host>/<key>/...`) und
|
|
19
|
+
// muss eindeutig sein. Ohne unique-constraint hätte ein konkurrenter
|
|
20
|
+
// Self-Signup-Confirm einen TOCTOU-Race zwischen generateUniqueName-
|
|
21
|
+
// isAvailable-check und insert: zwei Tabs könnten sequentiell denselben
|
|
22
|
+
// Slug claimen, beide commits durch, der dritte User landet auf einem
|
|
23
|
+
// shared admin-URL-prefix.
|
|
24
|
+
indexes: [{ unique: true, columns: ["key"] }],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const tenantTable = buildDrizzleTable("tenant", tenantEntity);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Testing helpers for the tenant feature. `seedTenantMembership` replaces
|
|
2
|
+
// the pre-ES pattern of `db.insert(tenantMembershipsTable).values({...})`
|
|
3
|
+
// in test fixtures — a direct-write bypasses the event-store executor, so
|
|
4
|
+
// seeded memberships have no stream, no `.created` event, and projections
|
|
5
|
+
// that consume membership events stay empty.
|
|
6
|
+
//
|
|
7
|
+
// The helper runs through the executor (same TX-semantics as the
|
|
8
|
+
// add-member handler), which means fixtures are event-sourced end-to-end:
|
|
9
|
+
// - events table gets a `tenantMembership.created` row
|
|
10
|
+
// - projection row (tenant_memberships) is written in the same TX
|
|
11
|
+
// - consumers (MSPs, audit) see the event just like a real call would
|
|
12
|
+
//
|
|
13
|
+
// Why this lives in bundled-features/tenant/testing rather than
|
|
14
|
+
// framework/testing: the helper closes over `tenantMembershipEntity` +
|
|
15
|
+
// `tenantMembershipsTable`, both owned by this feature. framework/testing
|
|
16
|
+
// stays shape-independent.
|
|
17
|
+
//
|
|
18
|
+
// Why not "just call the addMember handler via stack.http.writeOk":
|
|
19
|
+
// 1. Handler requires SystemAdmin — test fixtures often seed OTHER users
|
|
20
|
+
// before any admin exists, so the handler would 403.
|
|
21
|
+
// 2. Handler goes through HTTP → JWT mint → dispatcher. Overhead for
|
|
22
|
+
// fixture state-setup that the test doesn't exercise.
|
|
23
|
+
// The executor path skips access-checks by design (no HTTP, no JWT — this
|
|
24
|
+
// IS a test fixture, not a user request) while still producing the
|
|
25
|
+
// correct event + projection.
|
|
26
|
+
//
|
|
27
|
+
// Idempotent: calling twice for the same (userId, tenantId) is a no-op on
|
|
28
|
+
// the second call. Test fixtures that seed the same membership across
|
|
29
|
+
// `beforeEach` runs don't need explicit cleanup. A real `addMember` handler
|
|
30
|
+
// returns ConflictError on duplicates — that's the user-facing contract.
|
|
31
|
+
// Fixture-seeding prioritises "make the state exist" over "detect duplicate
|
|
32
|
+
// seeding", which is usually a test-author bug we don't need to surface.
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
createEventStoreExecutor,
|
|
36
|
+
createTenantDb,
|
|
37
|
+
type DbConnection,
|
|
38
|
+
fetchOne,
|
|
39
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
40
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
41
|
+
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
42
|
+
import { eq } from "drizzle-orm";
|
|
43
|
+
import { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table";
|
|
44
|
+
import { tenantEntity, tenantTable } from "./schema/tenant";
|
|
45
|
+
|
|
46
|
+
const tenantExecutor = createEventStoreExecutor(tenantTable, tenantEntity, {
|
|
47
|
+
entityName: "tenant",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
|
|
51
|
+
entityName: "tenant-membership",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export type SeedTenantMembershipOptions = {
|
|
55
|
+
readonly userId: string;
|
|
56
|
+
readonly tenantId: TenantId;
|
|
57
|
+
readonly roles: readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* SessionUser to bill the event against (goes into event.metadata.userId +
|
|
60
|
+
* the projection's inserted_by_id column). Defaults to TestUsers.systemAdmin
|
|
61
|
+
* — mirrors the real call-path, where add-member is SystemAdmin-only.
|
|
62
|
+
*/
|
|
63
|
+
readonly by?: SessionUser;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type SeedTenantOptions = {
|
|
67
|
+
/** Stable UUID — required for fixtures so the FE/BE können dieselbe ID
|
|
68
|
+
* hardcoden (Sample-Switcher zeigt den Tenant beim Namen, der Test
|
|
69
|
+
* prüft Memberships gegen exakt diese ID). Ohne ID müsste der Caller
|
|
70
|
+
* den lookup-by-key extra machen. */
|
|
71
|
+
readonly id: TenantId;
|
|
72
|
+
/** URL-/Slug-Form (z.B. "dev", "acme"). Indexed unique in der DB. */
|
|
73
|
+
readonly key: string;
|
|
74
|
+
/** Human-readable label (im Switcher angezeigt). */
|
|
75
|
+
readonly name: string;
|
|
76
|
+
readonly by?: SessionUser;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Seed a tenant through the event-store executor. Idempotent: a second
|
|
81
|
+
* call for the same `id` is a no-op. Same TX-semantics as the real
|
|
82
|
+
* `TenantHandlers.create`, minus the SystemAdmin-access-check and minus
|
|
83
|
+
* ConflictError-on-duplicate.
|
|
84
|
+
*/
|
|
85
|
+
export async function seedTenant(db: DbConnection, options: SeedTenantOptions): Promise<TenantId> {
|
|
86
|
+
const by = options.by ?? TestUsers.systemAdmin;
|
|
87
|
+
// executor.create erwartet eine TenantDb (mit .insert()-API), nicht
|
|
88
|
+
// die rohe DbConnection. Auch wenn das Tenant-Aggregat selbst NICHT
|
|
89
|
+
// tenant-scoped ist, braucht der Wrap-Layer für die runtime-API zu
|
|
90
|
+
// existieren. by.tenantId reicht — keine Override-Semantik wie bei
|
|
91
|
+
// seedTenantMembership nötig.
|
|
92
|
+
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
93
|
+
|
|
94
|
+
const existing = await fetchOne(db, tenantTable, eq(tenantTable["id"], options.id));
|
|
95
|
+
if (existing) return options.id;
|
|
96
|
+
|
|
97
|
+
const result = await tenantExecutor.create(
|
|
98
|
+
{ id: options.id, key: options.key, name: options.name },
|
|
99
|
+
by,
|
|
100
|
+
tdb,
|
|
101
|
+
);
|
|
102
|
+
if (!result.isSuccess) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`seedTenant failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return options.id;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Seed a tenant membership through the event-store executor. Writes
|
|
112
|
+
* both a `tenantMembership.created` event and the corresponding
|
|
113
|
+
* projection row in one transaction — identical effect to
|
|
114
|
+
* `TenantHandlers.addMember`, minus the access-check and minus the
|
|
115
|
+
* ConflictError on duplicates (duplicate calls no-op).
|
|
116
|
+
*/
|
|
117
|
+
export async function seedTenantMembership(
|
|
118
|
+
db: DbConnection,
|
|
119
|
+
options: SeedTenantMembershipOptions,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const by = options.by ?? TestUsers.systemAdmin;
|
|
122
|
+
// Wrap into a system-scoped TenantDb so the insert respects the tenant-
|
|
123
|
+
// override (we write into options.tenantId, which may differ from by.tenantId).
|
|
124
|
+
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
125
|
+
|
|
126
|
+
// Idempotency: duplicate seeds are common across beforeEach-resets where
|
|
127
|
+
// only certain tables get truncated. A plain executor.create would trip
|
|
128
|
+
// the (user_id, tenant_id) unique index; the fixture call-site would then
|
|
129
|
+
// have to juggle try/catch. Lookup-first keeps call-sites clean.
|
|
130
|
+
const existing = await fetchOne(
|
|
131
|
+
db,
|
|
132
|
+
tenantMembershipsTable,
|
|
133
|
+
eq(tenantMembershipsTable.userId, options.userId),
|
|
134
|
+
eq(tenantMembershipsTable.tenantId, options.tenantId),
|
|
135
|
+
);
|
|
136
|
+
// skip: idempotent no-op — duplicate seed is expected across beforeEach-
|
|
137
|
+
// resets that don't truncate this table. Cheaper than try/catch on the
|
|
138
|
+
// unique-index, and documented in the function JSDoc above.
|
|
139
|
+
if (existing) return;
|
|
140
|
+
|
|
141
|
+
const result = await executor.create(
|
|
142
|
+
{
|
|
143
|
+
userId: options.userId,
|
|
144
|
+
tenantId: options.tenantId,
|
|
145
|
+
roles: JSON.stringify(options.roles),
|
|
146
|
+
},
|
|
147
|
+
by,
|
|
148
|
+
tdb,
|
|
149
|
+
);
|
|
150
|
+
if (!result.isSuccess) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`seedTenantMembership failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// /testing re-exportiert /seeding. Ehemalige Heimat der seed-Helpers,
|
|
2
|
+
// jetzt nur noch Aggregation. Die Helpers leben in `/seeding` weil sie
|
|
3
|
+
// genauso vom Dev-Server-Bootstrap (runDevApp) konsumiert werden — nicht
|
|
4
|
+
// nur von Tests. Vertrag der Helpers ist stabil, test-spezifische
|
|
5
|
+
// Knöpfe gehören NICHT hier rein (würde dev-boots brechen wenn jemand
|
|
6
|
+
// einen lockout-test-Knopf einbaut).
|
|
7
|
+
|
|
8
|
+
export * from "./seeding";
|