@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,19 @@
|
|
|
1
|
+
// Feature name
|
|
2
|
+
export const TEXT_CONTENT_FEATURE = "text-content" as const;
|
|
3
|
+
|
|
4
|
+
// Qualified write handler names (QN format: scope:type:name)
|
|
5
|
+
export const TextContentHandlers = {
|
|
6
|
+
set: "text-content:write:set",
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
// Qualified query handler names (QN format: scope:type:name)
|
|
10
|
+
export const TextContentQueries = {
|
|
11
|
+
bySlug: "text-content:query:by-slug",
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
// Error codes
|
|
15
|
+
export const TextContentErrors = {
|
|
16
|
+
notFound: "text_block_not_found",
|
|
17
|
+
invalidSlug: "invalid_slug",
|
|
18
|
+
invalidLang: "invalid_lang",
|
|
19
|
+
} as const;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { bySlugQuery } from "./handlers/by-slug.query";
|
|
3
|
+
import { setWrite } from "./handlers/set.write";
|
|
4
|
+
import { textBlockEntity } from "./table";
|
|
5
|
+
|
|
6
|
+
// text-content — generischer Container für statische Texte (Impressum,
|
|
7
|
+
// Datenschutz, FAQ, About, ToS, Marketing-Snippets). Pro
|
|
8
|
+
// (tenantId, slug, lang) genau ein Block. Inhalt ist Markdown — die
|
|
9
|
+
// Konvertierung zu HTML übernehmen Consumer-Features wie legal-pages
|
|
10
|
+
// (das opt-in obendrauf-Feature für Compliance-Pages).
|
|
11
|
+
//
|
|
12
|
+
// Opt-in: wer keine statischen Texte braucht (interne Tools), aktiviert
|
|
13
|
+
// das Feature gar nicht. Wer es aktiviert, hat sofort CRUD + by-slug-
|
|
14
|
+
// query — Routes/Render kommen pro Use-Case (legal-pages, etc.).
|
|
15
|
+
export function createTextContentFeature(): FeatureDefinition {
|
|
16
|
+
return defineFeature("text-content", (r) => {
|
|
17
|
+
r.entity("text-block", textBlockEntity);
|
|
18
|
+
|
|
19
|
+
const handlers = {
|
|
20
|
+
set: r.writeHandler(setWrite),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const queries = {
|
|
24
|
+
bySlug: r.queryHandler(bySlugQuery),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return { handlers, queries };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { type TextBlockRow, textBlocksTable } from "../table";
|
|
7
|
+
|
|
8
|
+
// Public-Read by (tenantId, slug, lang). Anonymous: muss `anonymous`
|
|
9
|
+
// in roles enthalten — openToAll alleine ist auth-only (Regression-
|
|
10
|
+
// Guard). Tenant-Scope kommt default aus query.user.tenantId (anonymous-
|
|
11
|
+
// context setzt SYSTEM_TENANT_ID oder Host-resolved-tenant je nach App-
|
|
12
|
+
// Setup). Optional `tenantIdOverride` (SystemAdmin-only) erlaubt cross-
|
|
13
|
+
// tenant Read — symmetrisch zum set-handler. Use-case: Edit-UI lädt den
|
|
14
|
+
// SYSTEM_TENANT-Block für SystemAdmin der nicht direkt drauf member ist.
|
|
15
|
+
export const bySlugQuery = defineQueryHandler({
|
|
16
|
+
name: "by-slug",
|
|
17
|
+
schema: z.object({
|
|
18
|
+
slug: z.string().min(1).max(64),
|
|
19
|
+
lang: z.string().min(2).max(8),
|
|
20
|
+
/** Optional cross-tenant read — nur für SystemAdmin. Siehe
|
|
21
|
+
* set.write.ts für die symmetrische write-side. */
|
|
22
|
+
tenantIdOverride: z.string().min(1).optional(),
|
|
23
|
+
}),
|
|
24
|
+
// Public-Read: muss explizit `anonymous` enthalten damit no-JWT-
|
|
25
|
+
// Visitors auf Marketing-/Legal-Pages den Text sehen. openToAll
|
|
26
|
+
// alleine ist auth-only (Regression-Guard) — siehe
|
|
27
|
+
// docs/plans/datenschutz/legal-artifacts.md.
|
|
28
|
+
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
29
|
+
handler: async (query, ctx) => {
|
|
30
|
+
const override = query.payload.tenantIdOverride;
|
|
31
|
+
if (override !== undefined && !query.user.roles.includes("SystemAdmin")) {
|
|
32
|
+
throw new AccessDeniedError({
|
|
33
|
+
i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
|
|
34
|
+
details: { reason: "tenant_override_requires_system_admin" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const tenantId = override ?? query.user.tenantId;
|
|
38
|
+
const row = await fetchOne<TextBlockRow>(
|
|
39
|
+
ctx.db,
|
|
40
|
+
textBlocksTable,
|
|
41
|
+
eq(textBlocksTable["tenantId"], tenantId),
|
|
42
|
+
eq(textBlocksTable["slug"], query.payload.slug),
|
|
43
|
+
eq(textBlocksTable["lang"], query.payload.lang),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (!row) return null;
|
|
47
|
+
return {
|
|
48
|
+
slug: row.slug,
|
|
49
|
+
lang: row.lang,
|
|
50
|
+
title: row.title,
|
|
51
|
+
body: row.body,
|
|
52
|
+
updatedAt: row.updatedAt,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
|
|
7
|
+
|
|
8
|
+
const slugSchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1)
|
|
11
|
+
.max(64)
|
|
12
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
|
|
13
|
+
|
|
14
|
+
const langSchema = z
|
|
15
|
+
.string()
|
|
16
|
+
.min(2)
|
|
17
|
+
.max(8)
|
|
18
|
+
.regex(/^[a-z]{2}(-[a-z]{2})?$/i, "lang must be ISO 639-1 (e.g. de, en, en-us)");
|
|
19
|
+
|
|
20
|
+
const executor = createEventStoreExecutor(textBlocksTable, textBlockEntity, {
|
|
21
|
+
entityName: "text-block",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Upsert handler — eine Operation pro (tenantId, slug, lang). Bei
|
|
25
|
+
// existierender Row → update, sonst → create. Tenant-Scope kommt
|
|
26
|
+
// default aus event.user. Tenant-Admins setzen Texte für ihren
|
|
27
|
+
// eigenen Tenant; Plattform-Sysadmins können via optional
|
|
28
|
+
// `tenantIdOverride` für einen anderen Tenant schreiben (typisch:
|
|
29
|
+
// SYSTEM_TENANT_ID für legal-pages-content den die ganze Plattform
|
|
30
|
+
// teilt). Override ist SystemAdmin-only — TenantAdmin's Override-
|
|
31
|
+
// Versuch → 403.
|
|
32
|
+
export const setWrite = defineWriteHandler({
|
|
33
|
+
name: "set",
|
|
34
|
+
schema: z.object({
|
|
35
|
+
slug: slugSchema,
|
|
36
|
+
lang: langSchema,
|
|
37
|
+
title: z.string().min(1).max(200),
|
|
38
|
+
body: z.string().max(100_000).nullable(),
|
|
39
|
+
/** Optional cross-tenant write — nur für SystemAdmin. Typischer
|
|
40
|
+
* use-case: legal-pages-Edit-UI lässt SystemAdmin auf
|
|
41
|
+
* SYSTEM_TENANT_ID schreiben (sonst landet der text auf seinem
|
|
42
|
+
* eigenen platform-tenant und legal-pages-routes lesen ihn
|
|
43
|
+
* nicht). TenantAdmin's Versuch → ForbiddenError. */
|
|
44
|
+
tenantIdOverride: z.string().min(1).optional(),
|
|
45
|
+
}),
|
|
46
|
+
// SystemAdmin ist eine GLOBALE Rolle (users.roles), TenantAdmin pro
|
|
47
|
+
// tenant-membership. SystemAdmin braucht beide Pfade explizit weil
|
|
48
|
+
// er nicht implicit TenantAdmin auf jedem Tenant ist (siehe
|
|
49
|
+
// project_global_roles_sysadmin memory). Ohne SystemAdmin könnte
|
|
50
|
+
// niemand SYSTEM_TENANT-Texte setzen — nur via Test-Helper.
|
|
51
|
+
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
52
|
+
handler: async (event, ctx) => {
|
|
53
|
+
const db = ctx.db;
|
|
54
|
+
const override = event.payload.tenantIdOverride;
|
|
55
|
+
if (override !== undefined && !event.user.roles.includes("SystemAdmin")) {
|
|
56
|
+
return writeFailure(
|
|
57
|
+
new AccessDeniedError({
|
|
58
|
+
i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
|
|
59
|
+
details: { reason: "tenant_override_requires_system_admin" },
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const tenantId = override ?? event.user.tenantId;
|
|
64
|
+
// Bei tenantIdOverride muss auch der user-context auf den ziel-tenant
|
|
65
|
+
// umgestellt werden, sonst läuft der event-store-Lookup
|
|
66
|
+
// (getStreamVersion) gegen user.tenantId statt tenantId — und findet
|
|
67
|
+
// den stream nicht → version_conflict obwohl die projection-row da ist.
|
|
68
|
+
// Symmetrisch zu seedTextBlock, das TestUsers.systemAdmin (tenantId =
|
|
69
|
+
// SYSTEM_TENANT) als by verwendet.
|
|
70
|
+
const executorUser =
|
|
71
|
+
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user;
|
|
72
|
+
|
|
73
|
+
const existing = await fetchOne<TextBlockRow>(
|
|
74
|
+
db,
|
|
75
|
+
textBlocksTable,
|
|
76
|
+
eq(textBlocksTable["tenantId"], tenantId),
|
|
77
|
+
eq(textBlocksTable["slug"], event.payload.slug),
|
|
78
|
+
eq(textBlocksTable["lang"], event.payload.lang),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (existing) {
|
|
82
|
+
const result = await executor.update(
|
|
83
|
+
{
|
|
84
|
+
id: existing.id,
|
|
85
|
+
version: existing.version,
|
|
86
|
+
changes: {
|
|
87
|
+
title: event.payload.title,
|
|
88
|
+
body: event.payload.body,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
executorUser,
|
|
92
|
+
db,
|
|
93
|
+
);
|
|
94
|
+
if (!result.isSuccess) return result;
|
|
95
|
+
return {
|
|
96
|
+
isSuccess: true as const,
|
|
97
|
+
data: { slug: event.payload.slug, lang: event.payload.lang, isNew: false },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = await executor.create(
|
|
102
|
+
{
|
|
103
|
+
slug: event.payload.slug,
|
|
104
|
+
lang: event.payload.lang,
|
|
105
|
+
title: event.payload.title,
|
|
106
|
+
body: event.payload.body,
|
|
107
|
+
tenantId,
|
|
108
|
+
},
|
|
109
|
+
executorUser,
|
|
110
|
+
db,
|
|
111
|
+
);
|
|
112
|
+
if (!result.isSuccess) return result;
|
|
113
|
+
return {
|
|
114
|
+
isSuccess: true as const,
|
|
115
|
+
data: { slug: event.payload.slug, lang: event.payload.lang, isNew: true },
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createTextContentApi,
|
|
3
|
+
requireTextContent,
|
|
4
|
+
type TextBlock,
|
|
5
|
+
type TextContentApi,
|
|
6
|
+
} from "./api";
|
|
7
|
+
export {
|
|
8
|
+
TEXT_CONTENT_FEATURE,
|
|
9
|
+
TextContentErrors,
|
|
10
|
+
TextContentHandlers,
|
|
11
|
+
TextContentQueries,
|
|
12
|
+
} from "./constants";
|
|
13
|
+
export { createTextContentFeature } from "./feature";
|
|
14
|
+
export { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Test-Helper für text-content. Legt einen TextBlock direkt über den
|
|
2
|
+
// Event-Store-Executor an — gleicher Pfad wie der echte set-Handler,
|
|
3
|
+
// aber ohne Access-Check. Idempotent: zweiter Call mit gleichem
|
|
4
|
+
// (tenantId, slug, lang) updated den existing Block.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createEventStoreExecutor,
|
|
8
|
+
createTenantDb,
|
|
9
|
+
type DbConnection,
|
|
10
|
+
fetchOne,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { eq } from "drizzle-orm";
|
|
15
|
+
import { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
|
|
16
|
+
|
|
17
|
+
const executor = createEventStoreExecutor(textBlocksTable, textBlockEntity, {
|
|
18
|
+
entityName: "text-block",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type SeedTextBlockOptions = {
|
|
22
|
+
readonly tenantId: TenantId;
|
|
23
|
+
readonly slug: string;
|
|
24
|
+
readonly lang: string;
|
|
25
|
+
readonly title: string;
|
|
26
|
+
readonly body?: string | null;
|
|
27
|
+
readonly by?: SessionUser;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function seedTextBlock(
|
|
31
|
+
db: DbConnection,
|
|
32
|
+
opts: SeedTextBlockOptions,
|
|
33
|
+
): Promise<{ id: string | number }> {
|
|
34
|
+
// Default-user muss user.tenantId === opts.tenantId haben, sonst
|
|
35
|
+
// landet der event-store-stream im user.tenantId-bucket aber die
|
|
36
|
+
// projection-row im opts.tenantId-bucket. Spätere echte writes via
|
|
37
|
+
// set-handler (mit korrektem tenant-context) finden den stream
|
|
38
|
+
// nicht → version_conflict. TestUsers.systemAdmin ist hardcoded
|
|
39
|
+
// testTenantId(1), nicht opts.tenantId — explizit überschreiben.
|
|
40
|
+
const by = opts.by ?? { ...TestUsers.systemAdmin, tenantId: opts.tenantId };
|
|
41
|
+
// executor.create erwartet TenantDb — wrapping nötig damit die runtime-
|
|
42
|
+
// checks (tenant-scope-validation) greifen.
|
|
43
|
+
const tdb = createTenantDb(db, opts.tenantId, "system");
|
|
44
|
+
|
|
45
|
+
const existing = await fetchOne<TextBlockRow>(
|
|
46
|
+
db,
|
|
47
|
+
textBlocksTable,
|
|
48
|
+
eq(textBlocksTable["tenantId"], opts.tenantId),
|
|
49
|
+
eq(textBlocksTable["slug"], opts.slug),
|
|
50
|
+
eq(textBlocksTable["lang"], opts.lang),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (existing) {
|
|
54
|
+
const result = await executor.update(
|
|
55
|
+
{
|
|
56
|
+
id: existing.id,
|
|
57
|
+
version: existing.version,
|
|
58
|
+
changes: { title: opts.title, body: opts.body ?? null },
|
|
59
|
+
},
|
|
60
|
+
by,
|
|
61
|
+
tdb,
|
|
62
|
+
);
|
|
63
|
+
if (!result.isSuccess) {
|
|
64
|
+
throw new Error(`seedTextBlock update failed: ${JSON.stringify(result)}`);
|
|
65
|
+
}
|
|
66
|
+
return { id: existing.id };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result = await executor.create(
|
|
70
|
+
{
|
|
71
|
+
slug: opts.slug,
|
|
72
|
+
lang: opts.lang,
|
|
73
|
+
title: opts.title,
|
|
74
|
+
body: opts.body ?? null,
|
|
75
|
+
tenantId: opts.tenantId,
|
|
76
|
+
},
|
|
77
|
+
by,
|
|
78
|
+
tdb,
|
|
79
|
+
);
|
|
80
|
+
if (!result.isSuccess) {
|
|
81
|
+
throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
|
|
82
|
+
}
|
|
83
|
+
// @cast-boundary db-row executor.create result.data ist Drizzle-row
|
|
84
|
+
// (Record<string, unknown>), projected nach INSERT/RETURNING auf
|
|
85
|
+
// TextBlockRow. Runtime-narrowing in der nächsten Zeile.
|
|
86
|
+
const data = result.data as Partial<TextBlockRow>;
|
|
87
|
+
if (data.id === undefined) {
|
|
88
|
+
throw new Error("seedTextBlock: executor.create did not return an id");
|
|
89
|
+
}
|
|
90
|
+
return { id: data.id };
|
|
91
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
|
|
4
|
+
// TextBlock — generischer Container für statische Texte (legal pages,
|
|
5
|
+
// FAQ, About, ToS, Marketing-Snippets). Pro (tenantId, slug, lang) genau
|
|
6
|
+
// eine Row. SYSTEM_TENANT_ID für app-weite Texte (Impressum etc.), sonst
|
|
7
|
+
// Tenant-eigene Texte.
|
|
8
|
+
//
|
|
9
|
+
// Inhaltsformat ist Markdown (App-Renderer entscheidet Markdown→HTML).
|
|
10
|
+
// Body bleibt nullable damit ein leerer Block existieren kann (z.B.
|
|
11
|
+
// während Tenant-Onboarding bevor der Admin den finalen Text schreibt).
|
|
12
|
+
export const textBlockEntity = createEntity({
|
|
13
|
+
table: "read_text_blocks",
|
|
14
|
+
fields: {
|
|
15
|
+
slug: createTextField({ required: true }),
|
|
16
|
+
lang: createTextField({ required: true }),
|
|
17
|
+
title: createTextField({ required: true }),
|
|
18
|
+
body: createTextField({}),
|
|
19
|
+
},
|
|
20
|
+
indexes: [
|
|
21
|
+
{ unique: true, columns: ["tenantId", "slug", "lang"], name: "read_text_blocks_unique" },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const textBlocksTable = buildDrizzleTable("text-block", textBlockEntity);
|
|
26
|
+
|
|
27
|
+
// Concrete Row-Type — single-source dafür dass die unknown-Werte die
|
|
28
|
+
// Drizzle aus `Record<string, unknown>` liefert genau einmal benannt
|
|
29
|
+
// werden (statt 6× `row["x"] as Y` Casts in Handlern + Seeding).
|
|
30
|
+
// Kommt aus `entity.fields` + Standard-Spalten (id, version, tenantId,
|
|
31
|
+
// createdAt, updatedAt, createdBy, updatedBy) die buildBaseColumns
|
|
32
|
+
// erzwingt.
|
|
33
|
+
export type TextBlockRow = {
|
|
34
|
+
readonly id: string | number;
|
|
35
|
+
readonly version: number;
|
|
36
|
+
readonly tenantId: string;
|
|
37
|
+
readonly slug: string;
|
|
38
|
+
readonly lang: string;
|
|
39
|
+
readonly title: string;
|
|
40
|
+
readonly body: string | null;
|
|
41
|
+
readonly createdAt: Date;
|
|
42
|
+
readonly updatedAt: Date;
|
|
43
|
+
readonly createdBy: string;
|
|
44
|
+
readonly updatedBy: string;
|
|
45
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { type AddOnMap, composeApp, type TierMap } from "../compose-app";
|
|
4
|
+
|
|
5
|
+
// --- App-spezifischer Cap-Shape (typed, kein Record<string, unknown>) ---
|
|
6
|
+
|
|
7
|
+
type AppCaps = {
|
|
8
|
+
readonly apps: number;
|
|
9
|
+
readonly mailsPerMonth: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// --- Test fixtures ---
|
|
13
|
+
|
|
14
|
+
const baseFeature = defineFeature("auth", () => {});
|
|
15
|
+
const tenantF = defineFeature("tenant", () => {});
|
|
16
|
+
const designerF = defineFeature("designer", () => {});
|
|
17
|
+
const aiPatchF = defineFeature("ai-patch", () => {});
|
|
18
|
+
const aiConvF = defineFeature("ai-conversation", () => {});
|
|
19
|
+
const byokEncF = defineFeature("byok-encryption", () => {});
|
|
20
|
+
const dedicatedF = defineFeature("dedicated-stack", () => {});
|
|
21
|
+
|
|
22
|
+
const featureRegistry: Record<string, FeatureDefinition> = {
|
|
23
|
+
designer: designerF,
|
|
24
|
+
"ai-patch": aiPatchF,
|
|
25
|
+
"ai-conversation": aiConvF,
|
|
26
|
+
"byok-encryption": byokEncF,
|
|
27
|
+
"dedicated-stack": dedicatedF,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const tierMap: TierMap<AppCaps> = {
|
|
31
|
+
free: { features: [], caps: { apps: 1, mailsPerMonth: 1000 } },
|
|
32
|
+
pro: { features: ["designer", "ai-patch"], caps: { apps: 5, mailsPerMonth: 10_000 } },
|
|
33
|
+
business: {
|
|
34
|
+
features: ["designer", "ai-patch", "ai-conversation"],
|
|
35
|
+
caps: { apps: 20, mailsPerMonth: 50_000 },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const addOnMap: AddOnMap<AppCaps> = {
|
|
40
|
+
"byok-encryption": { features: ["byok-encryption"] },
|
|
41
|
+
"dedicated-stack": {
|
|
42
|
+
features: ["dedicated-stack"],
|
|
43
|
+
capOverrides: { mailsPerMonth: 100_000 },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// --- Tests ---
|
|
48
|
+
|
|
49
|
+
describe("composeApp", () => {
|
|
50
|
+
test("Free tier mounts only base features", () => {
|
|
51
|
+
const result = composeApp<AppCaps>({
|
|
52
|
+
base: [baseFeature, tenantF],
|
|
53
|
+
featureRegistry,
|
|
54
|
+
tierMap,
|
|
55
|
+
addOnMap,
|
|
56
|
+
tier: "free",
|
|
57
|
+
addOns: [],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(result.features.map((f) => f.name)).toEqual(["auth", "tenant"]);
|
|
61
|
+
// Typed caps — `result.caps.apps` is `number`, not `unknown`.
|
|
62
|
+
expect(result.caps.apps).toBe(1);
|
|
63
|
+
expect(result.caps.mailsPerMonth).toBe(1000);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("Pro tier adds Designer + ai-patch", () => {
|
|
67
|
+
const result = composeApp<AppCaps>({
|
|
68
|
+
base: [baseFeature, tenantF],
|
|
69
|
+
featureRegistry,
|
|
70
|
+
tierMap,
|
|
71
|
+
addOnMap,
|
|
72
|
+
tier: "pro",
|
|
73
|
+
addOns: [],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result.features.map((f) => f.name)).toEqual(["auth", "tenant", "designer", "ai-patch"]);
|
|
77
|
+
expect(result.caps).toEqual({ apps: 5, mailsPerMonth: 10_000 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("Add-On adds its features on top of tier", () => {
|
|
81
|
+
const result = composeApp<AppCaps>({
|
|
82
|
+
base: [baseFeature],
|
|
83
|
+
featureRegistry,
|
|
84
|
+
tierMap,
|
|
85
|
+
addOnMap,
|
|
86
|
+
tier: "pro",
|
|
87
|
+
addOns: ["byok-encryption"],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(result.features.map((f) => f.name)).toEqual([
|
|
91
|
+
"auth",
|
|
92
|
+
"designer",
|
|
93
|
+
"ai-patch",
|
|
94
|
+
"byok-encryption",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("Add-On capOverrides win over tier caps", () => {
|
|
99
|
+
const result = composeApp<AppCaps>({
|
|
100
|
+
base: [baseFeature],
|
|
101
|
+
featureRegistry,
|
|
102
|
+
tierMap,
|
|
103
|
+
addOnMap,
|
|
104
|
+
tier: "pro",
|
|
105
|
+
addOns: ["dedicated-stack"],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(result.caps).toEqual({
|
|
109
|
+
apps: 5, // from pro
|
|
110
|
+
mailsPerMonth: 100_000, // overridden by dedicated-stack
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("dedupe — feature listed in tier and add-on mounts only once", () => {
|
|
115
|
+
// Set up an add-on that re-lists ai-patch (which Pro already has).
|
|
116
|
+
const overlapAddOnMap: AddOnMap<AppCaps> = {
|
|
117
|
+
...addOnMap,
|
|
118
|
+
"ai-power-pack": { features: ["ai-patch", "ai-conversation"] },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const result = composeApp<AppCaps>({
|
|
122
|
+
base: [],
|
|
123
|
+
featureRegistry,
|
|
124
|
+
tierMap,
|
|
125
|
+
addOnMap: overlapAddOnMap,
|
|
126
|
+
tier: "pro",
|
|
127
|
+
addOns: ["ai-power-pack"],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ai-patch only mounts once, ai-conversation mounts as add-on extension.
|
|
131
|
+
const names = result.features.map((f) => f.name);
|
|
132
|
+
expect(names).toEqual(["designer", "ai-patch", "ai-conversation"]);
|
|
133
|
+
expect(names.filter((n) => n === "ai-patch")).toHaveLength(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("unknown tier throws with helpful message", () => {
|
|
137
|
+
expect(() =>
|
|
138
|
+
composeApp<AppCaps>({
|
|
139
|
+
base: [],
|
|
140
|
+
featureRegistry,
|
|
141
|
+
tierMap,
|
|
142
|
+
addOnMap,
|
|
143
|
+
tier: "platinum",
|
|
144
|
+
addOns: [],
|
|
145
|
+
}),
|
|
146
|
+
).toThrow(/unknown tier "platinum"/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("unknown add-on throws with helpful message", () => {
|
|
150
|
+
expect(() =>
|
|
151
|
+
composeApp<AppCaps>({
|
|
152
|
+
base: [],
|
|
153
|
+
featureRegistry,
|
|
154
|
+
tierMap,
|
|
155
|
+
addOnMap,
|
|
156
|
+
tier: "free",
|
|
157
|
+
addOns: ["unicorn-mode"],
|
|
158
|
+
}),
|
|
159
|
+
).toThrow(/unknown add-on "unicorn-mode"/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("tier referencing unknown feature throws", () => {
|
|
163
|
+
const brokenTierMap: TierMap<AppCaps> = {
|
|
164
|
+
...tierMap,
|
|
165
|
+
"broken-tier": {
|
|
166
|
+
features: ["does-not-exist"],
|
|
167
|
+
caps: { apps: 0, mailsPerMonth: 0 },
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
expect(() =>
|
|
172
|
+
composeApp<AppCaps>({
|
|
173
|
+
base: [],
|
|
174
|
+
featureRegistry,
|
|
175
|
+
tierMap: brokenTierMap,
|
|
176
|
+
addOnMap,
|
|
177
|
+
tier: "broken-tier",
|
|
178
|
+
addOns: [],
|
|
179
|
+
}),
|
|
180
|
+
).toThrow(/unknown feature "does-not-exist"/);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { tierAssignmentAggregateId } from "../aggregate-id";
|
|
3
|
+
import { TIER_ENGINE_FEATURE, TierEngineHandlers, TierEngineQueries } from "../constants";
|
|
4
|
+
import { tierEngineFeature } from "../feature";
|
|
5
|
+
|
|
6
|
+
// Drift-Pin-Tests — diese Werte sind Cross-File-Contracts, ein Wechsel
|
|
7
|
+
// muss bewusst geschehen und die anderen Stellen mitziehen. Wenn diese
|
|
8
|
+
// Tests rot werden: stop, denk nach, sync alle Stellen.
|
|
9
|
+
|
|
10
|
+
describe("tier-engine drift pins", () => {
|
|
11
|
+
test("TIER_ENGINE_FEATURE matches the registered feature-name", () => {
|
|
12
|
+
expect(tierEngineFeature.name).toBe(TIER_ENGINE_FEATURE);
|
|
13
|
+
expect(tierEngineFeature.name).toBe("tier-engine");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("Handler-QNs follow the scope:type:name convention with feature-prefix", () => {
|
|
17
|
+
expect(TierEngineHandlers.create).toBe("tier-engine:write:tier-assignment:create");
|
|
18
|
+
expect(TierEngineHandlers.update).toBe("tier-engine:write:tier-assignment:update");
|
|
19
|
+
expect(TierEngineQueries.list).toBe("tier-engine:query:tier-assignment:list");
|
|
20
|
+
expect(TierEngineQueries.getActiveTier).toBe("tier-engine:query:get-active-tier");
|
|
21
|
+
|
|
22
|
+
// Every QN must start with the feature-name as scope.
|
|
23
|
+
for (const qn of [...Object.values(TierEngineHandlers), ...Object.values(TierEngineQueries)]) {
|
|
24
|
+
expect(qn.startsWith(`${TIER_ENGINE_FEATURE}:`)).toBe(true);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("tier-assignment aggregate-id namespace is stable across boots", () => {
|
|
29
|
+
// The namespace UUID is in stone — changing it re-keys every existing
|
|
30
|
+
// aggregate-stream and breaks event-replay + projection-rebuild +
|
|
31
|
+
// audit-trail. If this test fails: revert the namespace, do not adjust
|
|
32
|
+
// the test.
|
|
33
|
+
const id1 = tierAssignmentAggregateId("00000000-0000-4000-8000-000000000001");
|
|
34
|
+
const id2 = tierAssignmentAggregateId("00000000-0000-4000-8000-000000000001");
|
|
35
|
+
const id3 = tierAssignmentAggregateId("00000000-0000-4000-8000-000000000002");
|
|
36
|
+
|
|
37
|
+
expect(id1).toBe(id2); // same input → same output (deterministic)
|
|
38
|
+
expect(id1).not.toBe(id3); // different input → different output
|
|
39
|
+
// Pin the actual value — drift-detector for the namespace constant.
|
|
40
|
+
expect(id1).toBe("4d7b6b9b-5257-56f7-b668-5d0b92dbd4dc");
|
|
41
|
+
});
|
|
42
|
+
});
|