@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,361 @@
|
|
|
1
|
+
import type { TextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
2
|
+
import {
|
|
3
|
+
createTextContentApi,
|
|
4
|
+
createTextContentFeature,
|
|
5
|
+
textBlockEntity,
|
|
6
|
+
} from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
7
|
+
import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/text-content/seeding";
|
|
8
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
|
+
import {
|
|
12
|
+
createEntityTable,
|
|
13
|
+
setupTestStack,
|
|
14
|
+
type TestStack,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
17
|
+
import { createLegalPagesFeature, runLegalPagesBootCheck } from "../feature";
|
|
18
|
+
import { renderMarkdownToHtml, wrapInLayout } from "../markdown";
|
|
19
|
+
|
|
20
|
+
let stack: TestStack;
|
|
21
|
+
let db: DbConnection;
|
|
22
|
+
|
|
23
|
+
const textFeature = createTextContentFeature();
|
|
24
|
+
const legalFeature = createLegalPagesFeature();
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
// legal-pages braucht zwei wirings:
|
|
28
|
+
// 1. anonymousAccess für die /legal/*-Routes (laufen ohne JWT)
|
|
29
|
+
// 2. extraContext.textContent damit der Boot-Check + interner
|
|
30
|
+
// Cross-Feature-Lookup ohne direct DB-Coupling funktioniert
|
|
31
|
+
stack = await setupTestStack({
|
|
32
|
+
features: [textFeature, legalFeature],
|
|
33
|
+
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
34
|
+
extraContext: ({ db }) => ({
|
|
35
|
+
textContent: createTextContentApi(db),
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
db = stack.db;
|
|
39
|
+
await createEntityTable(db, textBlockEntity);
|
|
40
|
+
await createEventsTable(db);
|
|
41
|
+
|
|
42
|
+
// Seed legal blocks für SYSTEM_TENANT in DE
|
|
43
|
+
await seedTextBlock(db, {
|
|
44
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
45
|
+
slug: "imprint",
|
|
46
|
+
lang: "de",
|
|
47
|
+
title: "Impressum",
|
|
48
|
+
body: "## Angaben gemäß § 5 TMG\n\n**Marc Frost**\n\nSlevogtstr. 10, Leipzig",
|
|
49
|
+
});
|
|
50
|
+
await seedTextBlock(db, {
|
|
51
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
52
|
+
slug: "privacy",
|
|
53
|
+
lang: "de",
|
|
54
|
+
title: "Datenschutzerklärung",
|
|
55
|
+
body: "## 1. Überblick\n\nWir verarbeiten **keine Tracking-Cookies**.",
|
|
56
|
+
});
|
|
57
|
+
await seedTextBlock(db, {
|
|
58
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
59
|
+
slug: "imprint",
|
|
60
|
+
lang: "en",
|
|
61
|
+
title: "Imprint",
|
|
62
|
+
body: "## Provider\n\n**Marc Frost**\n\nLeipzig, Germany",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
await stack.cleanup();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("legal-pages :: GET /legal/impressum", () => {
|
|
71
|
+
test("returns rendered HTML for DE imprint", async () => {
|
|
72
|
+
const res = await stack.app.request("/legal/impressum");
|
|
73
|
+
expect(res.status).toBe(200);
|
|
74
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
75
|
+
const body = await res.text();
|
|
76
|
+
expect(body).toContain("<title>Impressum</title>");
|
|
77
|
+
expect(body).toContain('lang="de"');
|
|
78
|
+
expect(body).toContain("Marc Frost");
|
|
79
|
+
expect(body).toContain("<h2>"); // markdown-rendered ## heading
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("legal-pages :: GET /legal/datenschutz", () => {
|
|
84
|
+
test("returns rendered HTML for DE privacy", async () => {
|
|
85
|
+
const res = await stack.app.request("/legal/datenschutz");
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
const body = await res.text();
|
|
88
|
+
expect(body).toContain("<title>Datenschutzerklärung</title>");
|
|
89
|
+
expect(body).toContain("Tracking-Cookies");
|
|
90
|
+
expect(body).toContain("<strong>"); // markdown bold
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("legal-pages :: GET /legal/imprint (EN)", () => {
|
|
95
|
+
test("returns rendered HTML for EN imprint", async () => {
|
|
96
|
+
const res = await stack.app.request("/legal/imprint");
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
const body = await res.text();
|
|
99
|
+
expect(body).toContain('lang="en"');
|
|
100
|
+
expect(body).toContain("Leipzig");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("legal-pages :: GET /legal/privacy (EN, not seeded)", () => {
|
|
105
|
+
test("returns 404 with helpful message when block missing", async () => {
|
|
106
|
+
const res = await stack.app.request("/legal/privacy");
|
|
107
|
+
expect(res.status).toBe(404);
|
|
108
|
+
const body = await res.text();
|
|
109
|
+
expect(body).toContain("Privacy Policy");
|
|
110
|
+
expect(body).toContain("Tenant-Admin");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("legal-pages :: edge-cases", () => {
|
|
115
|
+
test("Block existiert mit body=null → Route returnt 404 statt leerer HTML", async () => {
|
|
116
|
+
// seedTextBlock erlaubt body=null als legitimer Stub-State.
|
|
117
|
+
// Routes sollen das als "not configured" behandeln, NICHT als
|
|
118
|
+
// valides leeres Page rendern (würde DSGVO-pflichtige Page als
|
|
119
|
+
// existent vortäuschen).
|
|
120
|
+
await seedTextBlock(db, {
|
|
121
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
122
|
+
slug: "imprint",
|
|
123
|
+
lang: "fr",
|
|
124
|
+
title: "Mentions légales",
|
|
125
|
+
body: null,
|
|
126
|
+
});
|
|
127
|
+
// Keine /legal/imprint-fr-Route registriert (LEGAL_ROUTES ist
|
|
128
|
+
// de+en) — wir adden nicht extra. Stattdessen testen wir das
|
|
129
|
+
// Verhalten via direct getBlock-Lookup gegen einen leeren
|
|
130
|
+
// privacy-en Block (existiert noch nicht im stack-setup).
|
|
131
|
+
await seedTextBlock(db, {
|
|
132
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
133
|
+
slug: "privacy",
|
|
134
|
+
lang: "en",
|
|
135
|
+
title: "Privacy Policy",
|
|
136
|
+
body: null,
|
|
137
|
+
});
|
|
138
|
+
const res = await stack.app.request("/legal/privacy");
|
|
139
|
+
expect(res.status).toBe(404);
|
|
140
|
+
const body = await res.text();
|
|
141
|
+
expect(body).toContain("Tenant-Admin");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("Markdown-Body mit <script> wird NICHT escaped (dokumentiertes XSS-Verhalten, siehe README)", async () => {
|
|
145
|
+
// Bewusstes Verhalten: marked.parse rendered HTML 1:1, kein
|
|
146
|
+
// DOMPurify-Layer aktuell. Dokumentiert in legal-pages/README.md
|
|
147
|
+
// ('XSS — bewusst aktuell nicht gesichert'). Test pinnt das
|
|
148
|
+
// Verhalten — wenn es sich ändert (z.B. DOMPurify dazu), schlägt
|
|
149
|
+
// dieser Test fehl und der Wechsel ist dokumentiert.
|
|
150
|
+
await seedTextBlock(db, {
|
|
151
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
152
|
+
slug: "imprint",
|
|
153
|
+
lang: "de",
|
|
154
|
+
title: "Impressum",
|
|
155
|
+
body: "## XSS-Test\n\n<script>window.x=1</script>\n\nDanach.",
|
|
156
|
+
});
|
|
157
|
+
const res = await stack.app.request("/legal/impressum");
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
const html = await res.text();
|
|
160
|
+
// Aktuelles Verhalten: script-tag bleibt unescaped im Output
|
|
161
|
+
expect(html).toContain("<script>window.x=1</script>");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("legal-pages :: cache-control", () => {
|
|
166
|
+
test("sets public cache header for 5min", async () => {
|
|
167
|
+
const res = await stack.app.request("/legal/impressum");
|
|
168
|
+
expect(res.headers.get("cache-control")).toBe("public, max-age=300");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("markdown render helpers", () => {
|
|
173
|
+
test("renderMarkdownToHtml converts markdown to HTML", () => {
|
|
174
|
+
const html = renderMarkdownToHtml("# Title\n\n**bold**");
|
|
175
|
+
expect(html).toContain("<h1>");
|
|
176
|
+
expect(html).toContain("<strong>bold</strong>");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("wrapInLayout produces valid HTML5 with title + lang", () => {
|
|
180
|
+
const html = wrapInLayout({ title: "Test", bodyHtml: "<p>x</p>", lang: "de" });
|
|
181
|
+
expect(html).toContain("<!doctype html>");
|
|
182
|
+
expect(html).toContain('lang="de"');
|
|
183
|
+
expect(html).toContain("<title>Test</title>");
|
|
184
|
+
expect(html).toContain("<p>x</p>");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("wrapInLayout escapes title to prevent XSS", () => {
|
|
188
|
+
const html = wrapInLayout({
|
|
189
|
+
title: "<script>alert(1)</script>",
|
|
190
|
+
bodyHtml: "x",
|
|
191
|
+
lang: "en",
|
|
192
|
+
});
|
|
193
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
194
|
+
expect(html).toContain("<script>");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Boot-Check direkt (ohne dev-server-Job-Runner-Path) — verifiziert
|
|
199
|
+
// dass die Logik fehlende Blocks im SYSTEM_TENANT erkennt. Der eigentliche
|
|
200
|
+
// runOnBoot-Trigger lebt im JobRunner und wird in jobs-feature integration-
|
|
201
|
+
// tests separately exercised.
|
|
202
|
+
describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", () => {
|
|
203
|
+
test("legal-pages serven SYSTEM_TENANT-Texte auch wenn tenantResolver einen anderen Tenant zurückgibt", async () => {
|
|
204
|
+
// Simuliert publicstatus's Setup: host-basierter tenantResolver der
|
|
205
|
+
// tenant-subdomain → tenant-tenantId resolved. Ohne den X-Tenant-Fix
|
|
206
|
+
// würde /legal/impressum für tenant-x.example.com tenant-x's
|
|
207
|
+
// (leeren) imprint-Block abfragen → 404. Mit Fix immer SYSTEM_TENANT.
|
|
208
|
+
const otherTenantId = "22222222-2222-4222-8222-222222222222";
|
|
209
|
+
const hostScopedStack = await setupTestStack({
|
|
210
|
+
features: [createTextContentFeature(), createLegalPagesFeature()],
|
|
211
|
+
anonymousAccess: {
|
|
212
|
+
// Resolver gibt IMMER einen anderen Tenant zurück — wenn legal-
|
|
213
|
+
// pages den respektieren würde, wäre der DB-Lookup leer.
|
|
214
|
+
tenantResolver: () => otherTenantId,
|
|
215
|
+
tenantExists: async (id) => id === otherTenantId || id === SYSTEM_TENANT_ID,
|
|
216
|
+
},
|
|
217
|
+
extraContext: ({ db }) => ({
|
|
218
|
+
textContent: createTextContentApi(db),
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
await createEntityTable(hostScopedStack.db, textBlockEntity);
|
|
223
|
+
await createEventsTable(hostScopedStack.db);
|
|
224
|
+
|
|
225
|
+
// Block NUR im SYSTEM_TENANT seeden — NICHT im otherTenantId
|
|
226
|
+
await seedTextBlock(hostScopedStack.db, {
|
|
227
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
228
|
+
slug: "imprint",
|
|
229
|
+
lang: "de",
|
|
230
|
+
title: "System-Impressum",
|
|
231
|
+
body: "## Plattform\n\nMarc Frost",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const res = await hostScopedStack.app.request("/legal/impressum");
|
|
235
|
+
expect(res.status).toBe(200);
|
|
236
|
+
const body = await res.text();
|
|
237
|
+
expect(body).toContain("System-Impressum");
|
|
238
|
+
expect(body).toContain("Marc Frost");
|
|
239
|
+
} finally {
|
|
240
|
+
await hostScopedStack.cleanup();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
|
|
246
|
+
// Direkter Test der Boot-Check-Logik mit constructed ctx-Objects —
|
|
247
|
+
// keine JobRunner-Coupling, keine Test-Stacks. Das ist die echte
|
|
248
|
+
// Verhalten-Test-Surface; r.job() ist nur thin shell darum.
|
|
249
|
+
|
|
250
|
+
type Block = { slug: string; lang: string; title: string; body: string | null };
|
|
251
|
+
|
|
252
|
+
function fakeTextContent(blocks: readonly Block[]): {
|
|
253
|
+
api: TextContentApi;
|
|
254
|
+
calls: { tenantId: string; slug: string; lang: string }[];
|
|
255
|
+
} {
|
|
256
|
+
const calls: { tenantId: string; slug: string; lang: string }[] = [];
|
|
257
|
+
return {
|
|
258
|
+
calls,
|
|
259
|
+
api: {
|
|
260
|
+
getBlock: async ({ tenantId, slug, lang }) => {
|
|
261
|
+
calls.push({ tenantId, slug, lang });
|
|
262
|
+
const block = blocks.find((b) => b.slug === slug && b.lang === lang);
|
|
263
|
+
if (!block) return null;
|
|
264
|
+
return { ...block, updatedAt: new Date() };
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
test("alle Pflicht-Blocks vorhanden → log.info, kein throw", async () => {
|
|
271
|
+
const { api } = fakeTextContent([
|
|
272
|
+
{ slug: "imprint", lang: "de", title: "I", body: "body" },
|
|
273
|
+
{ slug: "privacy", lang: "de", title: "P", body: "body" },
|
|
274
|
+
]);
|
|
275
|
+
const infos: string[] = [];
|
|
276
|
+
const warns: string[] = [];
|
|
277
|
+
await expect(
|
|
278
|
+
runLegalPagesBootCheck({
|
|
279
|
+
textContent: api,
|
|
280
|
+
log: { info: (m) => infos.push(m), warn: (m) => warns.push(m) },
|
|
281
|
+
}),
|
|
282
|
+
).resolves.toBeUndefined();
|
|
283
|
+
expect(infos).toHaveLength(1);
|
|
284
|
+
expect(infos[0]).toContain("alle Pflicht-Blocks vorhanden");
|
|
285
|
+
expect(warns).toHaveLength(0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("missing blocks + NODE_ENV=production → throws mit slug-Liste", async () => {
|
|
289
|
+
const { api } = fakeTextContent([]);
|
|
290
|
+
const originalEnv = process.env["NODE_ENV"];
|
|
291
|
+
process.env["NODE_ENV"] = "production";
|
|
292
|
+
try {
|
|
293
|
+
await expect(runLegalPagesBootCheck({ textContent: api })).rejects.toThrow(
|
|
294
|
+
/Boot-Validation failed.*imprint\/de.*privacy\/de/s,
|
|
295
|
+
);
|
|
296
|
+
} finally {
|
|
297
|
+
if (originalEnv === undefined) delete process.env["NODE_ENV"];
|
|
298
|
+
else process.env["NODE_ENV"] = originalEnv;
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("missing blocks + NODE_ENV!=production → log.warn, kein throw", async () => {
|
|
303
|
+
const { api } = fakeTextContent([]);
|
|
304
|
+
const warns: string[] = [];
|
|
305
|
+
const originalEnv = process.env["NODE_ENV"];
|
|
306
|
+
process.env["NODE_ENV"] = "development";
|
|
307
|
+
try {
|
|
308
|
+
await expect(
|
|
309
|
+
runLegalPagesBootCheck({
|
|
310
|
+
textContent: api,
|
|
311
|
+
log: { warn: (m) => warns.push(m) },
|
|
312
|
+
}),
|
|
313
|
+
).resolves.toBeUndefined();
|
|
314
|
+
expect(warns).toHaveLength(1);
|
|
315
|
+
expect(warns[0]).toContain("missing 2 required text-block(s)");
|
|
316
|
+
expect(warns[0]).toContain("imprint/de");
|
|
317
|
+
expect(warns[0]).toContain("privacy/de");
|
|
318
|
+
} finally {
|
|
319
|
+
if (originalEnv === undefined) delete process.env["NODE_ENV"];
|
|
320
|
+
else process.env["NODE_ENV"] = originalEnv;
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("ctx ohne textContent → InternalError mit Wiring-Hinweis", async () => {
|
|
325
|
+
await expect(runLegalPagesBootCheck({})).rejects.toThrow(/textContent missing.*extraContext/s);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("Block existiert aber body ist null → wird als missing gezählt", async () => {
|
|
329
|
+
const { api } = fakeTextContent([
|
|
330
|
+
{ slug: "imprint", lang: "de", title: "I", body: null },
|
|
331
|
+
{ slug: "privacy", lang: "de", title: "P", body: "body" },
|
|
332
|
+
]);
|
|
333
|
+
const warns: string[] = [];
|
|
334
|
+
const originalEnv = process.env["NODE_ENV"];
|
|
335
|
+
process.env["NODE_ENV"] = "development";
|
|
336
|
+
try {
|
|
337
|
+
await runLegalPagesBootCheck({
|
|
338
|
+
textContent: api,
|
|
339
|
+
log: { warn: (m) => warns.push(m) },
|
|
340
|
+
});
|
|
341
|
+
expect(warns[0]).toContain("missing 1 required text-block(s)");
|
|
342
|
+
expect(warns[0]).toContain("imprint/de");
|
|
343
|
+
expect(warns[0]).not.toContain("privacy/de");
|
|
344
|
+
} finally {
|
|
345
|
+
if (originalEnv === undefined) delete process.env["NODE_ENV"];
|
|
346
|
+
else process.env["NODE_ENV"] = originalEnv;
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("alle Lookups erfolgen gegen SYSTEM_TENANT_ID (nie tenant-scoped)", async () => {
|
|
351
|
+
const { api, calls } = fakeTextContent([
|
|
352
|
+
{ slug: "imprint", lang: "de", title: "I", body: "x" },
|
|
353
|
+
{ slug: "privacy", lang: "de", title: "P", body: "x" },
|
|
354
|
+
]);
|
|
355
|
+
await runLegalPagesBootCheck({ textContent: api });
|
|
356
|
+
expect(calls).toHaveLength(2);
|
|
357
|
+
for (const call of calls) {
|
|
358
|
+
expect(call.tenantId).toBe(SYSTEM_TENANT_ID);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Feature name
|
|
2
|
+
export const LEGAL_PAGES_FEATURE = "legal-pages" as const;
|
|
3
|
+
|
|
4
|
+
// Required slugs that must exist as text-blocks for production-boot.
|
|
5
|
+
// Pro Sprache + Slug eine Pflicht-Kombo. Wer mehr Sprachen will, ergänzt
|
|
6
|
+
// die Liste — Boot-Check wird dynamisch aus der Liste generiert.
|
|
7
|
+
export const LEGAL_REQUIRED_BLOCKS = [
|
|
8
|
+
{ slug: "imprint", lang: "de" },
|
|
9
|
+
{ slug: "privacy", lang: "de" },
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
// Optionale Blocks die NICHT Boot-fail-relevant sind, aber die Routes
|
|
13
|
+
// servieren falls vorhanden. EN-Versionen sind in DACH-Apps oft nur
|
|
14
|
+
// "nice-to-have".
|
|
15
|
+
export const LEGAL_OPTIONAL_BLOCKS = [
|
|
16
|
+
{ slug: "imprint", lang: "en" },
|
|
17
|
+
{ slug: "privacy", lang: "en" },
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
// Public-Route-Mapping: URL-Path → (slug, lang). DE nutzt die deutschen
|
|
21
|
+
// Standard-Bezeichnungen, EN die englischen.
|
|
22
|
+
export const LEGAL_ROUTES = [
|
|
23
|
+
{ path: "/legal/impressum", slug: "imprint", lang: "de", titleFallback: "Impressum" },
|
|
24
|
+
{
|
|
25
|
+
path: "/legal/datenschutz",
|
|
26
|
+
slug: "privacy",
|
|
27
|
+
lang: "de",
|
|
28
|
+
titleFallback: "Datenschutzerklärung",
|
|
29
|
+
},
|
|
30
|
+
{ path: "/legal/imprint", slug: "imprint", lang: "en", titleFallback: "Imprint" },
|
|
31
|
+
{ path: "/legal/privacy", slug: "privacy", lang: "en", titleFallback: "Privacy Policy" },
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export const LegalPagesErrors = {
|
|
35
|
+
bootMissingBlock: "legal_pages_boot_missing_block",
|
|
36
|
+
} as const;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requireTextContent,
|
|
3
|
+
type TextContentApi,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
5
|
+
import {
|
|
6
|
+
defineFeature,
|
|
7
|
+
type FeatureDefinition,
|
|
8
|
+
SYSTEM_TENANT_ID,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES } from "./constants";
|
|
11
|
+
import { renderMarkdownToHtml, wrapInLayout } from "./markdown";
|
|
12
|
+
|
|
13
|
+
// QN-Konstante als dokumentierter Public-Contract des text-content-
|
|
14
|
+
// Features. Ein magic-string statt eines Code-Imports ist hier explizit
|
|
15
|
+
// gewollt: Cross-Feature-Calls gehen nur über stable Public-API
|
|
16
|
+
// (handler-name + payload-shape), nicht über interne Module-Refs. Wenn
|
|
17
|
+
// text-content's Handler-Name sich ändert, ist das ein semver-major
|
|
18
|
+
// und muss in beiden Features synchronisiert werden — gleiches Risiko
|
|
19
|
+
// wie bei jedem API-Endpunkt.
|
|
20
|
+
const TEXT_CONTENT_BY_SLUG_QN = "text-content:query:by-slug";
|
|
21
|
+
|
|
22
|
+
// Wire-Body-Shape von /api/query — das, was bySlugQuery returnt.
|
|
23
|
+
type ByslugQueryBody = {
|
|
24
|
+
data: { title: string; body: string | null } | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// legal-pages — Opt-in-Wrapper um text-content für DACH-Compliance.
|
|
28
|
+
// Liefert vier feste Public-HTML-Routes (/legal/impressum,
|
|
29
|
+
// /legal/datenschutz, /legal/imprint, /legal/privacy) mit
|
|
30
|
+
// Markdown→HTML-Rendering und einen Boot-Check der in Production hart
|
|
31
|
+
// fehlt wenn die DE-Pflicht-Blocks nicht geseedet sind.
|
|
32
|
+
//
|
|
33
|
+
// Cross-Feature-Decoupling:
|
|
34
|
+
// • Routes nutzen app.fetch zu "/api/query" mit dem QN-string
|
|
35
|
+
// `text-content:query:by-slug` — kein Code-Import von text-content
|
|
36
|
+
// • Boot-Check nutzt ctx.textContent (über extraContext) — symmetrisch
|
|
37
|
+
// zum config/tenant-Pattern
|
|
38
|
+
// • Single Type-Import (TextContentApi) bleibt — type-only verstößt
|
|
39
|
+
// nicht gegen Cross-Feature-Coupling
|
|
40
|
+
//
|
|
41
|
+
// Voraussetzungen für Production:
|
|
42
|
+
// • App-Bootstrap muss extraContext: { textContent: createTextContentApi(db) }
|
|
43
|
+
// setzen — sonst wirft Boot-Check beim Start
|
|
44
|
+
// • anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID } — sonst
|
|
45
|
+
// antworten die Routes mit 503
|
|
46
|
+
export type LegalPagesWrapLayout = (opts: {
|
|
47
|
+
readonly title: string;
|
|
48
|
+
readonly bodyHtml: string;
|
|
49
|
+
readonly lang: string;
|
|
50
|
+
}) => string;
|
|
51
|
+
|
|
52
|
+
export type LegalPagesOptions = {
|
|
53
|
+
/** Custom Layout-Wrapper für die /legal/*-Routes. Default: minimaler
|
|
54
|
+
* HTML-Skeleton aus markdown.ts (`wrapInLayout`). Apps die ihr eigenes
|
|
55
|
+
* Marketing-Layout (Header/Footer/Theme) auch um Legal-Body legen
|
|
56
|
+
* wollen, übergeben hier ihre Render-Function. */
|
|
57
|
+
readonly wrapLayout?: LegalPagesWrapLayout;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
|
|
61
|
+
const wrapLayout = opts.wrapLayout ?? wrapInLayout;
|
|
62
|
+
return defineFeature("legal-pages", (r) => {
|
|
63
|
+
r.requires("text-content");
|
|
64
|
+
|
|
65
|
+
// 4 Public-HTML-Routes
|
|
66
|
+
for (const route of LEGAL_ROUTES) {
|
|
67
|
+
r.httpRoute({
|
|
68
|
+
method: "GET",
|
|
69
|
+
path: route.path,
|
|
70
|
+
anonymous: true,
|
|
71
|
+
handler: async (c, { app }) => {
|
|
72
|
+
const url = new URL(c.req.url);
|
|
73
|
+
// Architektur: 1 App = X Tenants = 1 Impressum. Egal welche
|
|
74
|
+
// Subdomain der Visitor besucht (apex, admin.*, tenant-x.*) —
|
|
75
|
+
// legal-pages serven IMMER die SYSTEM_TENANT-Texte. Deshalb
|
|
76
|
+
// explizit X-Tenant-Header setzen statt host weiterreichen
|
|
77
|
+
// (sonst würde ein host-basierter anonymousAccess-Resolver
|
|
78
|
+
// die tenant-Subdomain auf tenant-tenantId resolven und
|
|
79
|
+
// tenant-x's leere imprint-Tabelle abfragen → 404).
|
|
80
|
+
const queryRes = await app.fetch(
|
|
81
|
+
new Request(`${url.origin}/api/query`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: {
|
|
84
|
+
"content-type": "application/json",
|
|
85
|
+
"X-Tenant": SYSTEM_TENANT_ID,
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
type: TEXT_CONTENT_BY_SLUG_QN,
|
|
89
|
+
payload: { slug: route.slug, lang: route.lang },
|
|
90
|
+
}),
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (!queryRes.ok) {
|
|
95
|
+
return c.text("legal page unavailable", 503);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const body: ByslugQueryBody = await queryRes.json();
|
|
99
|
+
const data = body.data;
|
|
100
|
+
if (!data?.body) {
|
|
101
|
+
return c.text(
|
|
102
|
+
`${route.titleFallback} not configured. Tenant-Admin must set this text-block.`,
|
|
103
|
+
404,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const html = wrapLayout({
|
|
108
|
+
title: data.title || route.titleFallback,
|
|
109
|
+
bodyHtml: renderMarkdownToHtml(data.body),
|
|
110
|
+
lang: route.lang,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return c.body(html, 200, {
|
|
114
|
+
"content-type": "text/html; charset=utf-8",
|
|
115
|
+
"cache-control": "public, max-age=300",
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Boot-Check via ctx.textContent (extraContext-Pattern, symmetrisch
|
|
122
|
+
// zu requireConfigResolver in config). App-Bootstrap muss textContent
|
|
123
|
+
// wired haben — der Helper gibt einen klaren Wiring-Hinweis wenn nicht.
|
|
124
|
+
//
|
|
125
|
+
// Body als named function extrahiert (`runLegalPagesBootCheck`) damit
|
|
126
|
+
// die Logik direkt unit-testbar ist statt nur indirekt über Routes.
|
|
127
|
+
// Pattern: thin job-shell ruft testable function — keine Test-Coupling
|
|
128
|
+
// zum JobRunner.
|
|
129
|
+
r.job(
|
|
130
|
+
"legal-pages-boot-check",
|
|
131
|
+
{
|
|
132
|
+
trigger: { manual: true },
|
|
133
|
+
runOnBoot: true,
|
|
134
|
+
runIn: "api",
|
|
135
|
+
},
|
|
136
|
+
async (_payload, ctx) => runLegalPagesBootCheck(ctx),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return {};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Minimal-shape für die Boot-Check-Logik — nur die Felder die der Check
|
|
144
|
+
// braucht. Akzeptiert HandlerContext + AppContext + jeden anderen
|
|
145
|
+
// Container der textContent + log mitbringt. Macht den Check direkt
|
|
146
|
+
// unit-testbar mit constructed ctx-Objects.
|
|
147
|
+
export type LegalPagesBootCheckCtx = {
|
|
148
|
+
readonly textContent?: TextContentApi;
|
|
149
|
+
readonly log?: {
|
|
150
|
+
readonly info?: (msg: string) => void;
|
|
151
|
+
readonly warn?: (msg: string) => void;
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Exportiert für direkte Tests. Wirft InternalError wenn ctx.textContent
|
|
156
|
+
// nicht gewired ist (Hinweis auf fehlenden extraContext). Wirft Error
|
|
157
|
+
// in NODE_ENV=production wenn Pflicht-Blocks fehlen, sonst log.warn.
|
|
158
|
+
// Logged log.info wenn alles vorhanden ist (kein silent-skip).
|
|
159
|
+
export async function runLegalPagesBootCheck(ctx: LegalPagesBootCheckCtx): Promise<void> {
|
|
160
|
+
const textContent: TextContentApi = requireTextContent(ctx, "legal-pages-boot-check");
|
|
161
|
+
const missing: { slug: string; lang: string }[] = [];
|
|
162
|
+
|
|
163
|
+
for (const required of LEGAL_REQUIRED_BLOCKS) {
|
|
164
|
+
const block = await textContent.getBlock({
|
|
165
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
166
|
+
slug: required.slug,
|
|
167
|
+
lang: required.lang,
|
|
168
|
+
});
|
|
169
|
+
if (!block?.body) {
|
|
170
|
+
missing.push({ slug: required.slug, lang: required.lang });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (missing.length === 0) {
|
|
175
|
+
ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
|
|
176
|
+
} else {
|
|
177
|
+
const message =
|
|
178
|
+
`legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
|
|
179
|
+
missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
|
|
180
|
+
". Seed via text-content:write:set or seedTextBlock helper.";
|
|
181
|
+
|
|
182
|
+
if (process.env["NODE_ENV"] === "production") {
|
|
183
|
+
throw new Error(`Boot-Validation failed: ${message}`);
|
|
184
|
+
}
|
|
185
|
+
ctx.log?.warn?.(message);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
LEGAL_OPTIONAL_BLOCKS,
|
|
3
|
+
LEGAL_PAGES_FEATURE,
|
|
4
|
+
LEGAL_REQUIRED_BLOCKS,
|
|
5
|
+
LEGAL_ROUTES,
|
|
6
|
+
LegalPagesErrors,
|
|
7
|
+
} from "./constants";
|
|
8
|
+
export {
|
|
9
|
+
createLegalPagesFeature,
|
|
10
|
+
type LegalPagesBootCheckCtx,
|
|
11
|
+
runLegalPagesBootCheck,
|
|
12
|
+
} from "./feature";
|
|
13
|
+
export { renderMarkdownToHtml, wrapInLayout } from "./markdown";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Marked } from "marked";
|
|
2
|
+
|
|
3
|
+
// Markdown→HTML mit eigener `marked`-Instance. GFM aus, breaks aus —
|
|
4
|
+
// Legal-Pages sind strukturiert genug dass GFM-Tables/Strikethrough/
|
|
5
|
+
// Task-Lists nicht nötig sind. Headers + Listen + Links + Code reichen.
|
|
6
|
+
//
|
|
7
|
+
// Instance statt globaler `marked.setOptions()` damit andere Features
|
|
8
|
+
// die `marked` als runtime-dep nutzen ihre eigenen Optionen behalten —
|
|
9
|
+
// modul-level side-effect auf shared library wäre brittle bei mehreren
|
|
10
|
+
// Konsumenten.
|
|
11
|
+
//
|
|
12
|
+
// XSS-Schutz: marked rendered tags 1:1, also kann ein böswilliger Text-
|
|
13
|
+
// Editor (TenantAdmin) <script>-Tags reinschreiben. Aktuell akzeptiert
|
|
14
|
+
// weil nur trusted Roles (TenantAdmin/SystemAdmin) Texte setzen können —
|
|
15
|
+
// bei einem Multi-Author-Setup müsste DOMPurify oder isomorphic-dompurify
|
|
16
|
+
// dazu. Dokumentiert in README, Phase-2-Hardening.
|
|
17
|
+
const markdownRenderer = new Marked({
|
|
18
|
+
gfm: false,
|
|
19
|
+
breaks: false,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export function renderMarkdownToHtml(markdown: string): string {
|
|
23
|
+
// @cast-boundary render-helper marked.parse return-type ist
|
|
24
|
+
// `string | Promise<string>` — mit `{ async: false }` runtime-garantiert
|
|
25
|
+
// sync (string). Cast nur API-Vertragsfix, kein Type-Loss.
|
|
26
|
+
return markdownRenderer.parse(markdown, { async: false }) as string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Layout-Wrapper für Legal-Pages — minimaler HTML-Skeleton mit Inline-
|
|
30
|
+
// CSS damit die Pages auch ohne App-Layout sauber aussehen. Apps die
|
|
31
|
+
// das in ihr eigenes Layout integrieren wollen, nutzen text-content's
|
|
32
|
+
// by-slug-query direkt und rendern selbst.
|
|
33
|
+
export function wrapInLayout(opts: { title: string; bodyHtml: string; lang: string }): string {
|
|
34
|
+
return `<!doctype html>
|
|
35
|
+
<html lang="${escapeHtmlAttr(opts.lang)}">
|
|
36
|
+
<head>
|
|
37
|
+
<meta charset="utf-8">
|
|
38
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
39
|
+
<title>${escapeHtml(opts.title)}</title>
|
|
40
|
+
<style>
|
|
41
|
+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px;
|
|
42
|
+
margin: 2rem auto; padding: 0 1rem; line-height: 1.6; color: #222; }
|
|
43
|
+
h1, h2, h3 { line-height: 1.2; margin-top: 2rem; }
|
|
44
|
+
h1 { font-size: 1.8rem; } h2 { font-size: 1.4rem; } h3 { font-size: 1.15rem; }
|
|
45
|
+
a { color: #0066cc; }
|
|
46
|
+
code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
|
47
|
+
hr { border: 0; border-top: 1px solid #ddd; margin: 2rem 0; }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<main>
|
|
52
|
+
${opts.bodyHtml}
|
|
53
|
+
</main>
|
|
54
|
+
</body>
|
|
55
|
+
</html>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function escapeHtml(s: string): string {
|
|
59
|
+
return s
|
|
60
|
+
.replace(/&/g, "&")
|
|
61
|
+
.replace(/</g, "<")
|
|
62
|
+
.replace(/>/g, ">")
|
|
63
|
+
.replace(/"/g, """)
|
|
64
|
+
.replace(/'/g, "'");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function escapeHtmlAttr(s: string): string {
|
|
68
|
+
return escapeHtml(s);
|
|
69
|
+
}
|