@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,190 @@
|
|
|
1
|
+
# text-content
|
|
2
|
+
|
|
3
|
+
Generic Markdown text container — exactly one block per
|
|
4
|
+
`(tenantId, slug, lang)`. Use cases: imprint, privacy policy, FAQ,
|
|
5
|
+
about, ToS, marketing snippets. Foundation for
|
|
6
|
+
[`legal-pages`](../legal-pages/), but also usable standalone.
|
|
7
|
+
|
|
8
|
+
**Opt-in.** If you don't need static texts (internal tools, pure API
|
|
9
|
+
apps), simply don't activate the feature.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { createTextContentFeature } from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
17
|
+
|
|
18
|
+
runProdApp({
|
|
19
|
+
features: [createTextContentFeature(), /* ... */],
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Production table setup
|
|
24
|
+
|
|
25
|
+
Each app creates the `read_text_blocks` table via a Drizzle migration:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# In the app workspace (e.g. samples/showcases/myapp):
|
|
29
|
+
yarn kumiko migrate generate # detects the new r.entity("text-block")
|
|
30
|
+
# → drizzle migration in the drizzle/ folder
|
|
31
|
+
yarn kumiko migrate apply # apply (pre-deploy step in prod)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The boot gate (`runProdApp`) checks hard: missing table = `SchemaDriftError`,
|
|
35
|
+
container exits. No auto-heal in production. See
|
|
36
|
+
[docs/plans/architecture/migrations.md](../../../../docs/plans/architecture/migrations.md).
|
|
37
|
+
|
|
38
|
+
In integration tests (vitest) it's enough to do:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { createEntityTable } from "@cosmicdrift/kumiko-framework/stack";
|
|
42
|
+
import { textBlockEntity } from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
43
|
+
|
|
44
|
+
await createEntityTable(stack.db, textBlockEntity);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Use cases
|
|
48
|
+
|
|
49
|
+
text-content is generic — anything that's static Markdown text per
|
|
50
|
+
`(tenantId, slug, lang)` fits. Examples from real life:
|
|
51
|
+
|
|
52
|
+
| Slug example | Use case | Tenant scope |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `imprint`, `privacy` | Imprint, privacy (DACH) | SYSTEM_TENANT_ID (app-wide) |
|
|
55
|
+
| `terms-of-service`, `eula` | Terms of service | SYSTEM_TENANT_ID or tenant-owned |
|
|
56
|
+
| `faq-billing`, `faq-onboarding`, `faq-troubleshooting` | FAQ sections | SYSTEM_TENANT_ID |
|
|
57
|
+
| `about-team`, `about-mission` | About pages | SYSTEM_TENANT_ID |
|
|
58
|
+
| `help-shortcuts`, `help-search` | In-app help texts | SYSTEM_TENANT_ID |
|
|
59
|
+
| `welcome-email-body`, `password-reset-body` | Email templates (Markdown body) | SYSTEM_TENANT_ID or tenant branding |
|
|
60
|
+
| `marketing-pricing-cta`, `marketing-feature-list` | Marketing snippets for landing pages | SYSTEM_TENANT_ID |
|
|
61
|
+
| `tenant-welcome-message` | Tenant-specific text | TenantId (each tenant maintains their own) |
|
|
62
|
+
|
|
63
|
+
Convention for slugs: `kebab-case`, hierarchy via `area-topic`
|
|
64
|
+
(e.g. `faq-billing` rather than `billing-faq` so list aggregation by
|
|
65
|
+
prefix is straightforward).
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
### `text-content:write:set` — upsert per block
|
|
72
|
+
|
|
73
|
+
The tenant admin writes a block. Idempotent: if a block already exists
|
|
74
|
+
for `(tenantId, slug, lang)`, it's updated.
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { TextContentHandlers } from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
78
|
+
|
|
79
|
+
await stack.http.writeOk(TextContentHandlers.set, {
|
|
80
|
+
slug: "imprint",
|
|
81
|
+
lang: "de",
|
|
82
|
+
title: "Impressum",
|
|
83
|
+
body: "## Angaben gemäß § 5 TMG\n\nMarc Frost ...",
|
|
84
|
+
}, tenantAdmin);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Validation:**
|
|
88
|
+
- `slug` — kebab-case (`/^[a-z0-9][a-z0-9-]*$/`), max 64 chars
|
|
89
|
+
- `lang` — ISO 639-1 (`de`, `en`, `en-us`, ...)
|
|
90
|
+
- `title` — 1-200 chars
|
|
91
|
+
- `body` — Markdown, max 100000 chars, nullable
|
|
92
|
+
|
|
93
|
+
**Access:** `roles: ["TenantAdmin"]`. Tenant scope comes automatically
|
|
94
|
+
from `event.user.tenantId`. Platform admins (SystemTenant) set texts
|
|
95
|
+
through the SystemAdmin role in SYSTEM_TENANT_ID.
|
|
96
|
+
|
|
97
|
+
### `text-content:query:by-slug` — public read
|
|
98
|
+
|
|
99
|
+
Anonymous-capable (`roles: ["anonymous", "User", "TenantAdmin",
|
|
100
|
+
"SystemAdmin"]`) — visitors on marketing/legal pages should see texts
|
|
101
|
+
without a login.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { TextContentQueries } from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
105
|
+
|
|
106
|
+
const block = await stack.http.queryOk(TextContentQueries.bySlug, {
|
|
107
|
+
slug: "imprint",
|
|
108
|
+
lang: "de",
|
|
109
|
+
}, anyUser);
|
|
110
|
+
// → { slug, lang, title, body, updatedAt } | null
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Tenant scope:** comes from `query.user.tenantId`. For anonymous
|
|
114
|
+
requests the server must configure `anonymousAccess` with a
|
|
115
|
+
`defaultTenantId` or `tenantResolver`.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Test helper
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/text-content/seeding";
|
|
123
|
+
|
|
124
|
+
await seedTextBlock(db, {
|
|
125
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
126
|
+
slug: "imprint",
|
|
127
|
+
lang: "de",
|
|
128
|
+
title: "Impressum",
|
|
129
|
+
body: "...",
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Idempotent: a second call updates the block.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Cross-feature API (for consuming features)
|
|
138
|
+
|
|
139
|
+
When another feature (e.g. `legal-pages`) wants to read text blocks
|
|
140
|
+
**without a direct code import**, there's an extraContext API:
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { createTextContentApi, requireTextContent } from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
144
|
+
|
|
145
|
+
// 1. App bootstrap wires the API:
|
|
146
|
+
runProdApp({
|
|
147
|
+
features: [createTextContentFeature(), createLegalPagesFeature(), /* ... */],
|
|
148
|
+
extraContext: ({ db }) => ({
|
|
149
|
+
textContent: createTextContentApi(db),
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// 2. In the consumer feature (e.g. legal-pages handler / boot job):
|
|
154
|
+
const textContent = requireTextContent(ctx, "my-handler");
|
|
155
|
+
const block = await textContent.getBlock({
|
|
156
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
157
|
+
slug: "imprint",
|
|
158
|
+
lang: "de",
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Pattern is symmetrical to `config` ↔ `tenant`: `text-content` only
|
|
163
|
+
exports the type + factory, consuming features only import the type.
|
|
164
|
+
This means text-content can be refactored freely without breaking
|
|
165
|
+
other features — the contract is the `TextContentApi` interface.
|
|
166
|
+
|
|
167
|
+
## Combining with `legal-pages`
|
|
168
|
+
|
|
169
|
+
`legal-pages` is an opt-in wrapper that registers four fixed
|
|
170
|
+
convenience routes (`/legal/impressum`, `/legal/datenschutz`,
|
|
171
|
+
`/legal/imprint`, `/legal/privacy`) and renders Markdown→HTML. See
|
|
172
|
+
[../legal-pages/README.md](../legal-pages/README.md).
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Architecture
|
|
177
|
+
|
|
178
|
+
- **Single source of truth:** `textBlockEntity` in `table.ts`.
|
|
179
|
+
The Drizzle table is derived via `buildDrizzleTable("text-block",
|
|
180
|
+
textBlockEntity)`, the unique index on `(tenantId, slug, lang)` is
|
|
181
|
+
declared via `entity.indexes`.
|
|
182
|
+
- **Event-sourced:** the write path goes through
|
|
183
|
+
`createEventStoreExecutor` — `text-block.created` and
|
|
184
|
+
`text-block.updated` land in the event stream, the projection row in
|
|
185
|
+
the same TX. Subscribers (audit, search) receive the events.
|
|
186
|
+
- **Storage:** one block per `(tenantId, slug, lang)`. SYSTEM_TENANT_ID
|
|
187
|
+
for app-wide texts, regular TenantId for tenant-owned ones.
|
|
188
|
+
|
|
189
|
+
Cross-refs: [../../docs/plans/datenschutz/](../../../../docs/plans/datenschutz/)
|
|
190
|
+
for the bigger privacy plan picture.
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
|
+
import {
|
|
4
|
+
createEntityTable,
|
|
5
|
+
createTestUser,
|
|
6
|
+
setupTestStack,
|
|
7
|
+
type TestStack,
|
|
8
|
+
TestUsers,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
10
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
12
|
+
import { TextContentHandlers, TextContentQueries } from "../constants";
|
|
13
|
+
import { createTextContentFeature } from "../feature";
|
|
14
|
+
import { seedTextBlock } from "../seeding";
|
|
15
|
+
import { textBlockEntity } from "../table";
|
|
16
|
+
|
|
17
|
+
let stack: TestStack;
|
|
18
|
+
let db: DbConnection;
|
|
19
|
+
|
|
20
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
21
|
+
const tenantAdmin = createTestUser({ id: 2, roles: ["TenantAdmin"] });
|
|
22
|
+
const normalUser = createTestUser({ id: 3 });
|
|
23
|
+
|
|
24
|
+
const feature = createTextContentFeature();
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
stack = await setupTestStack({ features: [feature] });
|
|
28
|
+
db = stack.db;
|
|
29
|
+
await createEntityTable(db, textBlockEntity);
|
|
30
|
+
await createEventsTable(db);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(async () => {
|
|
34
|
+
await stack.cleanup();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("text-content :: write", () => {
|
|
38
|
+
test("TenantAdmin can create a text block", async () => {
|
|
39
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
40
|
+
TextContentHandlers.set,
|
|
41
|
+
{
|
|
42
|
+
slug: "imprint",
|
|
43
|
+
lang: "de",
|
|
44
|
+
title: "Impressum",
|
|
45
|
+
body: "## Angaben gemäß § 5 TMG\n\nMarc Frost",
|
|
46
|
+
},
|
|
47
|
+
tenantAdmin,
|
|
48
|
+
);
|
|
49
|
+
expect(result).toMatchObject({ slug: "imprint", lang: "de", isNew: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("set is idempotent — second call updates existing block", async () => {
|
|
53
|
+
await stack.http.writeOk(
|
|
54
|
+
TextContentHandlers.set,
|
|
55
|
+
{ slug: "privacy", lang: "de", title: "Datenschutz v1", body: "alt" },
|
|
56
|
+
tenantAdmin,
|
|
57
|
+
);
|
|
58
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
59
|
+
TextContentHandlers.set,
|
|
60
|
+
{ slug: "privacy", lang: "de", title: "Datenschutz v2", body: "neu" },
|
|
61
|
+
tenantAdmin,
|
|
62
|
+
);
|
|
63
|
+
expect(result).toMatchObject({ slug: "privacy", isNew: false });
|
|
64
|
+
|
|
65
|
+
const fetched = await stack.http.queryOk<Record<string, unknown>>(
|
|
66
|
+
TextContentQueries.bySlug,
|
|
67
|
+
{ slug: "privacy", lang: "de" },
|
|
68
|
+
tenantAdmin,
|
|
69
|
+
);
|
|
70
|
+
expect(fetched).toMatchObject({ title: "Datenschutz v2", body: "neu" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("SystemAdmin can create text blocks for SYSTEM_TENANT (without TenantAdmin role)", async () => {
|
|
74
|
+
// SystemAdmin ist global, hat KEIN implicit TenantAdmin auf seiner
|
|
75
|
+
// membership. Das Set-Handler-ACL muss SystemAdmin explizit erlauben
|
|
76
|
+
// sonst kann niemand Plattform-Texte (z.B. Impressum) setzen.
|
|
77
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
78
|
+
TextContentHandlers.set,
|
|
79
|
+
{
|
|
80
|
+
slug: "system-imprint-write",
|
|
81
|
+
lang: "de",
|
|
82
|
+
title: "System-Impressum",
|
|
83
|
+
body: "## Plattform\n\nMarc",
|
|
84
|
+
},
|
|
85
|
+
systemAdmin,
|
|
86
|
+
);
|
|
87
|
+
expect(result).toMatchObject({ slug: "system-imprint-write", isNew: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("normal User cannot create text blocks (access denied)", async () => {
|
|
91
|
+
const error = await stack.http.writeErr(
|
|
92
|
+
TextContentHandlers.set,
|
|
93
|
+
{ slug: "about", lang: "de", title: "Über", body: null },
|
|
94
|
+
normalUser,
|
|
95
|
+
);
|
|
96
|
+
expectErrorIncludes(error, "access_denied");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("invalid slug rejected by schema validation", async () => {
|
|
100
|
+
const error = await stack.http.writeErr(
|
|
101
|
+
TextContentHandlers.set,
|
|
102
|
+
{ slug: "Invalid Slug!", lang: "de", title: "x", body: null },
|
|
103
|
+
tenantAdmin,
|
|
104
|
+
);
|
|
105
|
+
expectErrorIncludes(error, "validation_error");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("SystemAdmin can write with tenantIdOverride to a different tenant (legal-pages use-case)", async () => {
|
|
109
|
+
// Use-case: Plattform-App's Edit-UI lädt SystemAdmin der NICHT
|
|
110
|
+
// member auf SYSTEM_TENANT ist + lässt ihn dort schreiben.
|
|
111
|
+
// Ohne override würde der text auf systemAdmin.tenantId landen
|
|
112
|
+
// statt SYSTEM_TENANT — legal-pages-routes lesen ihn dann nie.
|
|
113
|
+
const targetTenant = createTestUser({ id: 99 }).tenantId;
|
|
114
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
115
|
+
TextContentHandlers.set,
|
|
116
|
+
{
|
|
117
|
+
slug: "override-target",
|
|
118
|
+
lang: "de",
|
|
119
|
+
title: "Override-Test",
|
|
120
|
+
body: "via tenantIdOverride",
|
|
121
|
+
tenantIdOverride: targetTenant,
|
|
122
|
+
},
|
|
123
|
+
systemAdmin,
|
|
124
|
+
);
|
|
125
|
+
expect(result).toMatchObject({ slug: "override-target", isNew: true });
|
|
126
|
+
|
|
127
|
+
// Beweis: text landed auf TARGET-tenant, nicht auf systemAdmin's
|
|
128
|
+
// eigenem tenant. Read mit denselben override returnt den block.
|
|
129
|
+
const read = await stack.http.queryOk<Record<string, unknown>>(
|
|
130
|
+
TextContentQueries.bySlug,
|
|
131
|
+
{ slug: "override-target", lang: "de", tenantIdOverride: targetTenant },
|
|
132
|
+
systemAdmin,
|
|
133
|
+
);
|
|
134
|
+
expect(read).toMatchObject({ slug: "override-target", title: "Override-Test" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("SystemAdmin can UPDATE with tenantIdOverride (regression: stream-lookup must use override-tenantId, not user.tenantId)", async () => {
|
|
138
|
+
// Regression-Guard für 2026-05-04: bei tenantIdOverride MUSS auch der
|
|
139
|
+
// user-context für den event-store-executor remapped werden — sonst
|
|
140
|
+
// landet append() auf user.tenantId aber getStreamVersion (auf
|
|
141
|
+
// update) sucht ebenfalls auf user.tenantId, findet aber NUR den
|
|
142
|
+
// stream auf override-tenantId aus dem ersten write → version_conflict
|
|
143
|
+
// obwohl die projection-row da ist. Test der NUR create+override
|
|
144
|
+
// hatte den Bug nicht gefangen weil append=create ohne stream-lookup.
|
|
145
|
+
const targetTenant = createTestUser({ id: 77 }).tenantId;
|
|
146
|
+
|
|
147
|
+
// Schritt 1: create mit override.
|
|
148
|
+
await stack.http.writeOk<Record<string, unknown>>(
|
|
149
|
+
TextContentHandlers.set,
|
|
150
|
+
{
|
|
151
|
+
slug: "update-target",
|
|
152
|
+
lang: "de",
|
|
153
|
+
title: "v1",
|
|
154
|
+
body: "first",
|
|
155
|
+
tenantIdOverride: targetTenant,
|
|
156
|
+
},
|
|
157
|
+
systemAdmin,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Schritt 2: UPDATE mit override (selbe slug+lang+target). Vor dem
|
|
161
|
+
// Fix: version_conflict. Nach dem Fix: clean update.
|
|
162
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
163
|
+
TextContentHandlers.set,
|
|
164
|
+
{
|
|
165
|
+
slug: "update-target",
|
|
166
|
+
lang: "de",
|
|
167
|
+
title: "v2",
|
|
168
|
+
body: "updated",
|
|
169
|
+
tenantIdOverride: targetTenant,
|
|
170
|
+
},
|
|
171
|
+
systemAdmin,
|
|
172
|
+
);
|
|
173
|
+
expect(result).toMatchObject({ slug: "update-target", isNew: false });
|
|
174
|
+
|
|
175
|
+
// Beweis: read returnt den UPDATED content auf TARGET-tenant.
|
|
176
|
+
const read = await stack.http.queryOk<Record<string, unknown>>(
|
|
177
|
+
TextContentQueries.bySlug,
|
|
178
|
+
{ slug: "update-target", lang: "de", tenantIdOverride: targetTenant },
|
|
179
|
+
systemAdmin,
|
|
180
|
+
);
|
|
181
|
+
expect(read).toMatchObject({ slug: "update-target", title: "v2", body: "updated" });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("TenantAdmin's tenantIdOverride attempt → 403 access_denied", async () => {
|
|
185
|
+
// Defense-in-Depth: override ist SystemAdmin-only. TenantAdmin
|
|
186
|
+
// darf NICHT auf andere tenants schreiben — sonst könnte ein
|
|
187
|
+
// Tenant-Admin von Tenant-A einfach Tenant-B's Impressum überschreiben.
|
|
188
|
+
const otherTenant = createTestUser({ id: 88 }).tenantId;
|
|
189
|
+
const error = await stack.http.writeErr(
|
|
190
|
+
TextContentHandlers.set,
|
|
191
|
+
{
|
|
192
|
+
slug: "evil-override",
|
|
193
|
+
lang: "de",
|
|
194
|
+
title: "evil",
|
|
195
|
+
body: null,
|
|
196
|
+
tenantIdOverride: otherTenant,
|
|
197
|
+
},
|
|
198
|
+
tenantAdmin,
|
|
199
|
+
);
|
|
200
|
+
expectErrorIncludes(error, "access_denied");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("invalid lang rejected by schema validation", async () => {
|
|
204
|
+
const error = await stack.http.writeErr(
|
|
205
|
+
TextContentHandlers.set,
|
|
206
|
+
{ slug: "ok", lang: "DEUTSCH", title: "x", body: null },
|
|
207
|
+
tenantAdmin,
|
|
208
|
+
);
|
|
209
|
+
expectErrorIncludes(error, "validation_error");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("text-content :: query (openToAll)", () => {
|
|
214
|
+
test("by-slug returns existing block for matching tenant/lang", async () => {
|
|
215
|
+
await seedTextBlock(db, {
|
|
216
|
+
tenantId: tenantAdmin.tenantId,
|
|
217
|
+
slug: "about",
|
|
218
|
+
lang: "de",
|
|
219
|
+
title: "Über uns",
|
|
220
|
+
body: "Wir sind ein Team.",
|
|
221
|
+
});
|
|
222
|
+
const result = await stack.http.queryOk<Record<string, unknown>>(
|
|
223
|
+
TextContentQueries.bySlug,
|
|
224
|
+
{ slug: "about", lang: "de" },
|
|
225
|
+
tenantAdmin,
|
|
226
|
+
);
|
|
227
|
+
expect(result).toMatchObject({
|
|
228
|
+
slug: "about",
|
|
229
|
+
lang: "de",
|
|
230
|
+
title: "Über uns",
|
|
231
|
+
body: "Wir sind ein Team.",
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("by-slug returns null for missing block", async () => {
|
|
236
|
+
const result = await stack.http.queryOk<Record<string, unknown> | null>(
|
|
237
|
+
TextContentQueries.bySlug,
|
|
238
|
+
{ slug: "does-not-exist", lang: "de" },
|
|
239
|
+
tenantAdmin,
|
|
240
|
+
);
|
|
241
|
+
expect(result).toBeFalsy();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("by-slug isolates by tenant — other tenant's block invisible", async () => {
|
|
245
|
+
const otherTenant = createTestUser({
|
|
246
|
+
id: 99,
|
|
247
|
+
tenantId: "11111111-1111-4111-8111-111111111111",
|
|
248
|
+
});
|
|
249
|
+
await seedTextBlock(db, {
|
|
250
|
+
tenantId: tenantAdmin.tenantId,
|
|
251
|
+
slug: "tenant-only",
|
|
252
|
+
lang: "de",
|
|
253
|
+
title: "Tenant-A only",
|
|
254
|
+
});
|
|
255
|
+
const result = await stack.http.queryOk<Record<string, unknown> | null>(
|
|
256
|
+
TextContentQueries.bySlug,
|
|
257
|
+
{ slug: "tenant-only", lang: "de" },
|
|
258
|
+
otherTenant,
|
|
259
|
+
);
|
|
260
|
+
// null oder undefined je nach pipeline-shape — beides bedeutet "nicht gefunden"
|
|
261
|
+
expect(result).toBeFalsy();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("by-slug works for SystemAdmin scoped to system tenant", async () => {
|
|
265
|
+
await seedTextBlock(db, {
|
|
266
|
+
tenantId: systemAdmin.tenantId,
|
|
267
|
+
slug: "system-imprint",
|
|
268
|
+
lang: "de",
|
|
269
|
+
title: "System-Impressum",
|
|
270
|
+
body: "Plattform-Betreiber",
|
|
271
|
+
});
|
|
272
|
+
const result = await stack.http.queryOk<Record<string, unknown>>(
|
|
273
|
+
TextContentQueries.bySlug,
|
|
274
|
+
{ slug: "system-imprint", lang: "de" },
|
|
275
|
+
systemAdmin,
|
|
276
|
+
);
|
|
277
|
+
expect(result).toMatchObject({ title: "System-Impressum" });
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("text-content :: edge-cases", () => {
|
|
282
|
+
test("body=null roundtrip — set + query liefert null body zurück", async () => {
|
|
283
|
+
// Sinnvoller Use-Case: Tenant-Admin legt einen leeren Block als
|
|
284
|
+
// Stub an (z.B. während Onboarding) und befüllt ihn später.
|
|
285
|
+
await stack.http.writeOk<Record<string, unknown>>(
|
|
286
|
+
TextContentHandlers.set,
|
|
287
|
+
{ slug: "stub-page", lang: "de", title: "Wird noch gefüllt", body: null },
|
|
288
|
+
tenantAdmin,
|
|
289
|
+
);
|
|
290
|
+
const fetched = await stack.http.queryOk<Record<string, unknown>>(
|
|
291
|
+
TextContentQueries.bySlug,
|
|
292
|
+
{ slug: "stub-page", lang: "de" },
|
|
293
|
+
tenantAdmin,
|
|
294
|
+
);
|
|
295
|
+
expect(fetched).toMatchObject({ title: "Wird noch gefüllt", body: null });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("body=null kann via update auf string gesetzt werden", async () => {
|
|
299
|
+
await stack.http.writeOk(
|
|
300
|
+
TextContentHandlers.set,
|
|
301
|
+
{ slug: "later-filled", lang: "de", title: "Stub", body: null },
|
|
302
|
+
tenantAdmin,
|
|
303
|
+
);
|
|
304
|
+
await stack.http.writeOk(
|
|
305
|
+
TextContentHandlers.set,
|
|
306
|
+
{ slug: "later-filled", lang: "de", title: "Stub", body: "Inhalt" },
|
|
307
|
+
tenantAdmin,
|
|
308
|
+
);
|
|
309
|
+
const fetched = await stack.http.queryOk<Record<string, unknown>>(
|
|
310
|
+
TextContentQueries.bySlug,
|
|
311
|
+
{ slug: "later-filled", lang: "de" },
|
|
312
|
+
tenantAdmin,
|
|
313
|
+
);
|
|
314
|
+
expect(fetched!["body"]).toBe("Inhalt");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("body knapp unter max-length (100k Zeichen) wird akzeptiert", async () => {
|
|
318
|
+
const justBelowMax = "a".repeat(100_000);
|
|
319
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
320
|
+
TextContentHandlers.set,
|
|
321
|
+
{ slug: "max-length-ok", lang: "de", title: "Max", body: justBelowMax },
|
|
322
|
+
tenantAdmin,
|
|
323
|
+
);
|
|
324
|
+
expect(result).toMatchObject({ slug: "max-length-ok", isNew: true });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("body über max-length (100k+1 Zeichen) → validation_error", async () => {
|
|
328
|
+
const overLimit = "a".repeat(100_001);
|
|
329
|
+
const error = await stack.http.writeErr(
|
|
330
|
+
TextContentHandlers.set,
|
|
331
|
+
{ slug: "max-length-fail", lang: "de", title: "Over", body: overLimit },
|
|
332
|
+
tenantAdmin,
|
|
333
|
+
);
|
|
334
|
+
expectErrorIncludes(error, "validation_error");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("body mit XSS-Payload wird unverändert gespeichert (Markdown-Renderer ist verantwortlich für Escaping)", async () => {
|
|
338
|
+
// Dokumentiertes Verhalten: text-content speichert Markdown 1:1.
|
|
339
|
+
// Konsumenten (z.B. legal-pages mit `marked`) müssen entscheiden ob
|
|
340
|
+
// sie sanitizen — siehe legal-pages/README.md XSS-Sektion.
|
|
341
|
+
const xssPayload = "## Title\n\n<script>alert('xss')</script>\n\nText.";
|
|
342
|
+
await stack.http.writeOk(
|
|
343
|
+
TextContentHandlers.set,
|
|
344
|
+
{ slug: "xss-test", lang: "de", title: "XSS", body: xssPayload },
|
|
345
|
+
tenantAdmin,
|
|
346
|
+
);
|
|
347
|
+
const fetched = await stack.http.queryOk<Record<string, unknown>>(
|
|
348
|
+
TextContentQueries.bySlug,
|
|
349
|
+
{ slug: "xss-test", lang: "de" },
|
|
350
|
+
tenantAdmin,
|
|
351
|
+
);
|
|
352
|
+
// Roundtrip: Body bleibt exakt was reingeschrieben wurde
|
|
353
|
+
expect(fetched!["body"]).toBe(xssPayload);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("concurrent set auf gleichen (tenantId, slug, lang) — mindestens einer succeed", async () => {
|
|
357
|
+
// Race-Test: Zwei TenantAdmins (oder selber Admin von zwei Tabs)
|
|
358
|
+
// setzen gleichzeitig. fetchOne+update ist nicht atomar — wenn
|
|
359
|
+
// beide das selbe `existing` finden und beide updaten wollen,
|
|
360
|
+
// greift Optimistic-Locking via version-check im Executor.
|
|
361
|
+
// Erwartung: einer succeed, einer kann version_conflict werfen
|
|
362
|
+
// (oder beide succeed wenn sequenziell genug). Mindestens einer
|
|
363
|
+
// muss durchlaufen, sonst ist der Race-Pfad kaputt.
|
|
364
|
+
await stack.http.writeOk(
|
|
365
|
+
TextContentHandlers.set,
|
|
366
|
+
{ slug: "race-test", lang: "de", title: "Initial", body: "v1" },
|
|
367
|
+
tenantAdmin,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const results = await Promise.allSettled([
|
|
371
|
+
stack.http.writeOk(
|
|
372
|
+
TextContentHandlers.set,
|
|
373
|
+
{ slug: "race-test", lang: "de", title: "A", body: "from-a" },
|
|
374
|
+
tenantAdmin,
|
|
375
|
+
),
|
|
376
|
+
stack.http.writeOk(
|
|
377
|
+
TextContentHandlers.set,
|
|
378
|
+
{ slug: "race-test", lang: "de", title: "B", body: "from-b" },
|
|
379
|
+
tenantAdmin,
|
|
380
|
+
),
|
|
381
|
+
]);
|
|
382
|
+
const succeeded = results.filter((r) => r.status === "fulfilled").length;
|
|
383
|
+
expect(succeeded).toBeGreaterThanOrEqual(1);
|
|
384
|
+
|
|
385
|
+
// Egal welcher gewinnt — die Row ist nach beiden Aufrufen konsistent
|
|
386
|
+
// mit einem der beiden Werte (kein partial state).
|
|
387
|
+
const fetched = await stack.http.queryOk<Record<string, unknown>>(
|
|
388
|
+
TextContentQueries.bySlug,
|
|
389
|
+
{ slug: "race-test", lang: "de" },
|
|
390
|
+
tenantAdmin,
|
|
391
|
+
);
|
|
392
|
+
const finalBody = fetched!["body"];
|
|
393
|
+
expect(["from-a", "from-b", "v1"]).toContain(finalBody);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe("text-content :: seedTextBlock", () => {
|
|
398
|
+
test("seedTextBlock is idempotent", async () => {
|
|
399
|
+
const a = await seedTextBlock(db, {
|
|
400
|
+
tenantId: tenantAdmin.tenantId,
|
|
401
|
+
slug: "seed-test",
|
|
402
|
+
lang: "de",
|
|
403
|
+
title: "v1",
|
|
404
|
+
body: "alt",
|
|
405
|
+
});
|
|
406
|
+
const b = await seedTextBlock(db, {
|
|
407
|
+
tenantId: tenantAdmin.tenantId,
|
|
408
|
+
slug: "seed-test",
|
|
409
|
+
lang: "de",
|
|
410
|
+
title: "v2",
|
|
411
|
+
body: "neu",
|
|
412
|
+
});
|
|
413
|
+
expect(a.id).toBe(b.id);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// TextContentApi — die Cross-Feature-Schnittstelle des text-content-
|
|
2
|
+
// Features. Andere Features (z.B. legal-pages) importieren NUR den Type
|
|
3
|
+
// hier und holen die Implementation runtime aus ctx.textContent.
|
|
4
|
+
//
|
|
5
|
+
// Pattern symmetrisch zu config: das Feature exportiert API-Type +
|
|
6
|
+
// Factory, App-Bootstrap setzt die Instance via extraContext, consuming-
|
|
7
|
+
// Features nutzen sie via require-Helper aus dem HandlerContext. So
|
|
8
|
+
// bleiben Features durch Refactorings entkoppelt — wer textBlocksTable
|
|
9
|
+
// umzieht oder die Query-Signatur ändert, muss nur die Factory anpassen.
|
|
10
|
+
|
|
11
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
15
|
+
import { eq } from "drizzle-orm";
|
|
16
|
+
import { type TextBlockRow, textBlocksTable } from "./table";
|
|
17
|
+
|
|
18
|
+
export type TextBlock = {
|
|
19
|
+
readonly slug: string;
|
|
20
|
+
readonly lang: string;
|
|
21
|
+
readonly title: string;
|
|
22
|
+
readonly body: string | null;
|
|
23
|
+
readonly updatedAt: Date;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type TextContentApi = {
|
|
27
|
+
/**
|
|
28
|
+
* Lookup eines TextBlocks by (tenantId, slug, lang). Null wenn nicht
|
|
29
|
+
* existiert. Tenant-Scope wird vom Caller mitgegeben — kein implicit
|
|
30
|
+
* tenantId aus Session, weil die API auch von Boot-Jobs ohne
|
|
31
|
+
* Session-User aufgerufen wird (siehe legal-pages bootCheck).
|
|
32
|
+
*/
|
|
33
|
+
readonly getBlock: (args: {
|
|
34
|
+
tenantId: TenantId;
|
|
35
|
+
slug: string;
|
|
36
|
+
lang: string;
|
|
37
|
+
}) => Promise<TextBlock | null>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function createTextContentApi(db: DbConnection): TextContentApi {
|
|
41
|
+
return {
|
|
42
|
+
getBlock: async ({ tenantId, slug, lang }) => {
|
|
43
|
+
const row = await fetchOne<TextBlockRow>(
|
|
44
|
+
db,
|
|
45
|
+
textBlocksTable,
|
|
46
|
+
eq(textBlocksTable["tenantId"], tenantId),
|
|
47
|
+
eq(textBlocksTable["slug"], slug),
|
|
48
|
+
eq(textBlocksTable["lang"], lang),
|
|
49
|
+
);
|
|
50
|
+
if (!row) return null;
|
|
51
|
+
return {
|
|
52
|
+
slug: row.slug,
|
|
53
|
+
lang: row.lang,
|
|
54
|
+
title: row.title,
|
|
55
|
+
body: row.body,
|
|
56
|
+
updatedAt: row.updatedAt,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Single point of truth für "dieser Handler braucht text-content".
|
|
63
|
+
// Wirft InternalError mit Wiring-Hinweis statt bare Error — so liest
|
|
64
|
+
// die Debug-Session die exakte Boot-Lücke ("text-content feature not
|
|
65
|
+
// wired into AppContext") statt eines generischen undefined-bugs.
|
|
66
|
+
//
|
|
67
|
+
// Pattern symmetrisch zu requireConfigResolver/requireConfigEncryption.
|
|
68
|
+
// Akzeptiert HandlerContext + AppContext (Job-Context) — beide haben
|
|
69
|
+
// SharedContextFields als Basis. Das narrowing geschieht via shape-check
|
|
70
|
+
// auf das optionale `textContent`-Feld (kein Type-Lookup ins framework).
|
|
71
|
+
export function requireTextContent(
|
|
72
|
+
ctx: { readonly textContent?: TextContentApi } | object,
|
|
73
|
+
callerName: string,
|
|
74
|
+
): TextContentApi {
|
|
75
|
+
// @cast-boundary engine-bridge ctx ist Framework-Container (HandlerContext
|
|
76
|
+
// | AppContext), textContent kommt per extraContext aus dem App-Bootstrap.
|
|
77
|
+
const api = (ctx as { textContent?: TextContentApi }).textContent;
|
|
78
|
+
if (!api) {
|
|
79
|
+
throw new InternalError({
|
|
80
|
+
message:
|
|
81
|
+
`[${callerName}] ctx.textContent missing — App-Bootstrap muss ` +
|
|
82
|
+
`extraContext: { textContent: createTextContentApi(db) } setzen ` +
|
|
83
|
+
`(siehe text-content/README.md).`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return api;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Re-export für Test-Helper die selbst eine Session-User-scoped Variante
|
|
90
|
+
// brauchen — der Standard-Use-Case (Routes/Boot-Jobs) gibt tenantId
|
|
91
|
+
// explizit mit, deshalb ist getBlock session-agnostisch.
|
|
92
|
+
export type { SessionUser };
|