@cosmicdrift/kumiko-bundled-features 0.174.0 → 0.176.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.
Files changed (80) hide show
  1. package/package.json +8 -10
  2. package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
  3. package/src/auth-email-password/__tests__/login-gates.test.ts +12 -6
  4. package/src/auth-email-password/seeding.ts +4 -2
  5. package/src/auth-mfa/__tests__/token-secrets.test.ts +19 -0
  6. package/src/auth-mfa/handlers/reencrypt.job.ts +11 -20
  7. package/src/auth-mfa/token-secrets.ts +16 -14
  8. package/src/billing-foundation/subscription-tier-sync.ts +8 -1
  9. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +98 -0
  10. package/src/cap-counter/stock-cap-guard.ts +1 -1
  11. package/src/config/handlers/reencrypt.job.ts +7 -18
  12. package/src/delivery/delivery-service.ts +1 -1
  13. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +47 -11
  14. package/src/document-ingest-foundation/__tests__/feature.test.ts +6 -2
  15. package/src/document-ingest-foundation/events.ts +15 -0
  16. package/src/document-ingest-foundation/feature.ts +27 -2
  17. package/src/inbound-mail-foundation/watch-supervisor.ts +11 -2
  18. package/src/jobs/handlers/catalog.query.ts +1 -4
  19. package/src/jobs/handlers/trigger.write.ts +2 -1
  20. package/src/jobs/is-manual-trigger.ts +5 -0
  21. package/src/legal-pages/README.md +26 -25
  22. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
  23. package/src/legal-pages/feature.ts +40 -31
  24. package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
  25. package/src/legal-pages/web/client-plugin.ts +3 -3
  26. package/src/managed-pages/screens/branding-screen.ts +1 -1
  27. package/src/presets/dsgvo-self-service.ts +10 -10
  28. package/src/seo/__tests__/robots-txt.test.ts +21 -0
  29. package/src/seo/__tests__/seo.integration.test.ts +13 -13
  30. package/src/seo/robots-txt.ts +21 -1
  31. package/src/sessions/handlers/revoke-all-for-user.write.ts +10 -1
  32. package/src/shared/classify-stored-envelope.ts +21 -0
  33. package/src/shared/index.ts +4 -0
  34. package/src/template-resolver/README.md +30 -1
  35. package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
  36. package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
  37. package/src/template-resolver/api.ts +11 -7
  38. package/src/template-resolver/constants.ts +22 -0
  39. package/src/template-resolver/feature.ts +16 -1
  40. package/src/template-resolver/handlers/by-slug.query.ts +54 -0
  41. package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
  42. package/src/template-resolver/handlers/set.write.ts +110 -0
  43. package/src/template-resolver/handlers/shared.ts +13 -2
  44. package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
  45. package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
  46. package/src/template-resolver/index.ts +7 -0
  47. package/src/template-resolver/qualified-names.ts +3 -0
  48. package/src/template-resolver/seeding.ts +126 -2
  49. package/src/template-resolver/table.ts +11 -2
  50. package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
  51. package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
  52. package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
  53. package/src/template-resolver/web/client-plugin.tsx +352 -0
  54. package/src/template-resolver/web/index.ts +8 -0
  55. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +9 -0
  56. package/src/tenant-settings/tenant-defaults.ts +3 -0
  57. package/src/tier-engine/compose-app.ts +2 -13
  58. package/src/user/__tests__/seed-testing.integration.test.ts +25 -0
  59. package/src/user/seeding.ts +25 -1
  60. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +1 -1
  61. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +2 -2
  62. package/src/user-data-rights/__tests__/request-deletion-url.test.ts +11 -8
  63. package/src/user-data-rights/feature.ts +3 -2
  64. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +8 -6
  65. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +5 -5
  66. package/src/user-data-rights/web/confirm-deletion-screen.tsx +3 -2
  67. package/src/text-content/README.md +0 -197
  68. package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
  69. package/src/text-content/api.ts +0 -86
  70. package/src/text-content/changes.json +0 -14
  71. package/src/text-content/constants.ts +0 -21
  72. package/src/text-content/feature.ts +0 -53
  73. package/src/text-content/handlers/by-slug.query.ts +0 -55
  74. package/src/text-content/handlers/by-tenant.query.ts +0 -60
  75. package/src/text-content/handlers/set.write.ts +0 -141
  76. package/src/text-content/index.ts +0 -14
  77. package/src/text-content/seeding.ts +0 -139
  78. package/src/text-content/table.ts +0 -53
  79. package/src/text-content/web/client-plugin.tsx +0 -391
  80. package/src/text-content/web/index.ts +0 -8
@@ -69,7 +69,31 @@ export async function seedUser(
69
69
  const existing = await fetchOne(db, userTable, { email: options.email });
70
70
  // @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
71
71
  // liefert die Projection-Row als Record<string, unknown>.
72
- if (existing) return { id: existing["id"] as string };
72
+ if (existing) {
73
+ const id = existing["id"] as string;
74
+ // Reconcile emailVerified on an already-seeded row: a persistent dev DB
75
+ // (or a re-run bootstrap) can carry a User seeded before this flag
76
+ // existed or before it flipped to true — without this, "seed with
77
+ // emailVerified: true" only ever takes effect on the very first insert
78
+ // and the flag silently does nothing on every re-run after (#1687).
79
+ // Goes through the executor (a real `.updated` event), never a direct
80
+ // write.
81
+ if (options.emailVerified === true && existing["emailVerified"] !== true) {
82
+ const result = await userExecutor.update(
83
+ { id, version: existing["version"] as number, changes: { emailVerified: true } },
84
+ by,
85
+ tdb,
86
+ );
87
+ // version_conflict = a concurrent write already changed the row —
88
+ // fine for a seed helper, don't fail the whole seed run over it.
89
+ if (!result.isSuccess && result.error.code !== "version_conflict") {
90
+ throw new Error(
91
+ `seedUser emailVerified reconcile failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
92
+ );
93
+ }
94
+ }
95
+ return { id };
96
+ }
73
97
 
74
98
  const result = await userExecutor.create(
75
99
  {
@@ -103,7 +103,7 @@ afterEach(() => {
103
103
 
104
104
  function tokenFromLastVerifyCall(): string {
105
105
  const url = new URL(verifyCalls[0]?.verifyUrl ?? "");
106
- return url.searchParams.get("token") ?? "";
106
+ return new URLSearchParams(url.hash.slice(1)).get("token") ?? "";
107
107
  }
108
108
 
109
109
  describe("anonymous deletion flow with active KMS", () => {
@@ -95,7 +95,7 @@ async function seedAlice(status: string = USER_STATUS.Active, email: string = AL
95
95
 
96
96
  function tokenFromLastVerifyCall(): string {
97
97
  const url = new URL(verifyCalls[0]?.verifyUrl ?? "");
98
- return url.searchParams.get("token") ?? "";
98
+ return new URLSearchParams(url.hash.slice(1)).get("token") ?? "";
99
99
  }
100
100
 
101
101
  async function statusOf(): Promise<string | undefined> {
@@ -118,7 +118,7 @@ describe("anonymous deletion flow", () => {
118
118
 
119
119
  expect(verifyCalls).toHaveLength(1);
120
120
  expect(verifyCalls[0]?.email).toBe(ALICE_EMAIL);
121
- expect(verifyCalls[0]?.verifyUrl.startsWith(`${VERIFY_URL}?token=`)).toBe(true);
121
+ expect(verifyCalls[0]?.verifyUrl.startsWith(`${VERIFY_URL}#token=`)).toBe(true);
122
122
  expect(tokenFromLastVerifyCall().length).toBeGreaterThan(0);
123
123
  // Status noch NICHT geflipt — erst confirm startet die Grace-Period.
124
124
  expect(await statusOf()).toBe(USER_STATUS.Active);
@@ -2,20 +2,23 @@ import { describe, expect, test } from "bun:test";
2
2
  import { buildDeletionVerifyUrl } from "../handlers/request-deletion-by-email.write";
3
3
 
4
4
  describe("buildDeletionVerifyUrl", () => {
5
- test("appends ?token to a plain base URL", () => {
6
- expect(buildDeletionVerifyUrl("https://app.example.com/delete/confirm", "tok-123")).toBe(
7
- "https://app.example.com/delete/confirm?token=tok-123",
8
- );
5
+ // fw#1554: the token goes in the URL fragment, not a query param —
6
+ // fragments never leave the browser, so they never land in proxy/access
7
+ // logs (unlike ?token=, which does).
8
+ test("puts the token in the URL fragment, not a query param", () => {
9
+ const url = buildDeletionVerifyUrl("https://app.example.com/delete/confirm", "tok-123");
10
+ expect(url).toBe("https://app.example.com/delete/confirm#token=tok-123");
11
+ expect(new URL(url).search).toBe("");
9
12
  });
10
13
 
11
- test("appends &token when the base already carries query params (not a second ?)", () => {
14
+ test("preserves an existing query param — only the token is a fragment", () => {
12
15
  const url = buildDeletionVerifyUrl("https://app.example.com/confirm?lang=de", "tok-123");
13
- expect(url).toBe("https://app.example.com/confirm?lang=de&token=tok-123");
14
- expect(url.match(/\?/g)).toHaveLength(1);
16
+ expect(url).toBe("https://app.example.com/confirm?lang=de#token=tok-123");
15
17
  });
16
18
 
17
19
  test("URL-encodes a token with reserved characters", () => {
18
20
  const url = new URL(buildDeletionVerifyUrl("https://app.example.com/c", "a b&c=d"));
19
- expect(url.searchParams.get("token")).toBe("a b&c=d");
21
+ const params = new URLSearchParams(url.hash.slice(1));
22
+ expect(params.get("token")).toBe("a b&c=d");
20
23
  });
21
24
  });
@@ -112,8 +112,9 @@ export type UserDataRightsOptions = {
112
112
  * by-token weist generisch ab). */
113
113
  readonly deletionTokenSecret?: string;
114
114
  /** Basis-URL des Apex-Confirm-Screens, z.B.
115
- * "https://app.example.com/delete-account/confirm". Der Handler hängt
116
- * `?token=<token>` an. Required wenn deletionTokenSecret gesetzt. */
115
+ * "https://app.example.com/delete-account/confirm". Der Handler hängt das
116
+ * Token als URL-Fragment an (`#token=<token>`, fw#1554). Required wenn
117
+ * deletionTokenSecret gesetzt. */
117
118
  readonly deletionVerifyUrl?: string;
118
119
  /** Versand des Verify-Magic-Links (Schritt 1 des anonymen Flows).
119
120
  * Best-effort, app-author-wired. MUSS non-blocking sein (enqueue, z.B.
@@ -30,18 +30,20 @@ export type RequestDeletionByEmailOptions = {
30
30
  * deaktiviert (Handler antwortet still mit success, kein Link). */
31
31
  readonly deletionTokenSecret?: string;
32
32
  /** Basis-URL des Apex-Confirm-Screens, z.B.
33
- * "https://app.example.com/delete-account/confirm". Der Handler hängt
34
- * `?token=<token>` an. Ohne URL kein Link. */
33
+ * "https://app.example.com/delete-account/confirm". Der Handler hängt das
34
+ * Token als URL-Fragment an (`#token=<token>`, fw#1554). Ohne URL kein Link. */
35
35
  readonly deletionVerifyUrl?: string;
36
36
  readonly sendDeletionVerificationEmail?: SendDeletionVerificationEmailFn;
37
37
  };
38
38
 
39
- // URL-safe append: handles a base URL that already carries query params
40
- // (`?lang=de` → `?lang=de&token=…`) instead of producing an invalid
41
- // `?lang=de?token=…`. searchParams.set encodes the token.
39
+ // Token goes in the URL fragment, not a query param — fragments never leave
40
+ // the browser (not sent to the server, so they never land in proxy/access
41
+ // logs, unlike `?token=`). Same convention as the export-download link
42
+ // (feature.ts, issue #1271). Preserves any existing query params on `base`
43
+ // (`?lang=de` stays a query param; only the token is a fragment).
42
44
  export function buildDeletionVerifyUrl(base: string, token: string): string {
43
45
  const url = new URL(base);
44
- url.searchParams.set("token", token);
46
+ url.hash = `token=${encodeURIComponent(token)}`;
45
47
  return url.toString();
46
48
  }
47
49
 
@@ -79,15 +79,15 @@ describe("RequestAccountDeletionScreen", () => {
79
79
  });
80
80
 
81
81
  describe("ConfirmAccountDeletionScreen", () => {
82
- test("ohne ?token → missingToken, kein Confirm-Button", () => {
82
+ test("ohne #token → missingToken, kein Confirm-Button", () => {
83
83
  window.history.replaceState({}, "", "/delete-account/confirm");
84
84
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, []));
85
85
  expect(ui.getByText(/Kein Token/)).toBeTruthy();
86
86
  expect(ui.queryByRole("button")).toBeNull();
87
87
  });
88
88
 
89
- test("mit ?token → Confirm dispatcht confirm-deletion-by-token + Success", async () => {
90
- window.history.replaceState({}, "", "/delete-account/confirm?token=tok-123");
89
+ test("mit #token → Confirm dispatcht confirm-deletion-by-token + Success", async () => {
90
+ window.history.replaceState({}, "", "/delete-account/confirm#token=tok-123");
91
91
  const calls: WriteCall[] = [];
92
92
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, calls));
93
93
  fireEvent.click(ui.getByRole("button"));
@@ -98,7 +98,7 @@ describe("ConfirmAccountDeletionScreen", () => {
98
98
  });
99
99
 
100
100
  test("write-Failure → invalidToken-Banner, kein Success", async () => {
101
- window.history.replaceState({}, "", "/delete-account/confirm?token=bad");
101
+ window.history.replaceState({}, "", "/delete-account/confirm#token=bad");
102
102
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(false, []));
103
103
  fireEvent.click(ui.getByRole("button"));
104
104
  await waitFor(() => expect(ui.getByText(/ungültig oder abgelaufen/)).toBeTruthy());
@@ -106,7 +106,7 @@ describe("ConfirmAccountDeletionScreen", () => {
106
106
  });
107
107
 
108
108
  test("write wirft → generischer Error-Banner, NICHT invalidToken", async () => {
109
- window.history.replaceState({}, "", "/delete-account/confirm?token=tok-123");
109
+ window.history.replaceState({}, "", "/delete-account/confirm#token=tok-123");
110
110
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeThrowingDispatcher());
111
111
  fireEvent.click(ui.getByRole("button"));
112
112
  await waitFor(() => expect(ui.getByText(/schief gegangen/)).toBeTruthy());
@@ -1,6 +1,7 @@
1
1
  // @runtime client
2
2
  // ConfirmAccountDeletionScreen — anonymer Apex-Screen Schritt 2. Liest das
3
- // `?token` aus der Verify-Link-URL und dispatcht beim Bestätigen
3
+ // Token aus dem URL-Fragment (`#token=…`, nie an den Server gesendet — landet
4
+ // so nicht in Proxy-/Access-Logs, fw#1554) und dispatcht beim Bestätigen
4
5
  // user-data-rights:write:confirm-deletion-by-token → startet die Grace-Period.
5
6
  //
6
7
  // App mountet den Screen unter der deletionVerifyUrl-Route (z.B.
@@ -15,7 +16,7 @@ type Phase = "idle" | "submitting" | "success" | "missing" | "invalid" | "error"
15
16
 
16
17
  function readToken(): string {
17
18
  if (typeof window === "undefined") return "";
18
- return new URLSearchParams(window.location.search).get("token") ?? "";
19
+ return new URLSearchParams(window.location.hash.slice(1)).get("token") ?? "";
19
20
  }
20
21
 
21
22
  export type ConfirmAccountDeletionScreenProps = {
@@ -1,197 +0,0 @@
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 schema migration:
26
-
27
- ```bash
28
- # In the app workspace (legacy drizzle.config.ts apps):
29
- bun kumiko migrate generate # detects the new r.entity("text-block")
30
- bun kumiko migrate apply # pre-deploy step in prod
31
-
32
- # New apps (kumiko/schema.ts):
33
- bun kumiko schema generate text-content
34
- bun kumiko schema apply
35
- ```
36
-
37
- The boot gate (`runProdApp`) checks hard: missing table = `SchemaDriftError`,
38
- container exits. No auto-heal in production. See
39
- [docs/plans/architecture/migrations.md](../../../../docs/plans/architecture/migrations.md).
40
-
41
- In integration tests (`bun test`) it's enough to do:
42
-
43
- ```typescript
44
- import { unsafeCreateEntityTable } from "@cosmicdrift/kumiko-framework/stack";
45
- import { textBlockEntity } from "@cosmicdrift/kumiko-bundled-features/text-content";
46
-
47
- await unsafeCreateEntityTable(stack.db, textBlockEntity);
48
- ```
49
-
50
- The `unsafe` prefix is intentional — it bypasses the projection
51
- registry and is reserved for test setup and framework-internals. Apps
52
- declare data via `r.entity(...)` everywhere else.
53
-
54
- ## Use cases
55
-
56
- text-content is generic — anything that's static Markdown text per
57
- `(tenantId, slug, lang)` fits. Examples from real life:
58
-
59
- | Slug example | Use case | Tenant scope |
60
- |---|---|---|
61
- | `imprint`, `privacy` | Imprint, privacy (DACH) | SYSTEM_TENANT_ID (app-wide) |
62
- | `terms-of-service`, `eula` | Terms of service | SYSTEM_TENANT_ID or tenant-owned |
63
- | `faq-billing`, `faq-onboarding`, `faq-troubleshooting` | FAQ sections | SYSTEM_TENANT_ID |
64
- | `about-team`, `about-mission` | About pages | SYSTEM_TENANT_ID |
65
- | `help-shortcuts`, `help-search` | In-app help texts | SYSTEM_TENANT_ID |
66
- | `welcome-email-body`, `password-reset-body` | Email templates (Markdown body) | SYSTEM_TENANT_ID or tenant branding |
67
- | `marketing-pricing-cta`, `marketing-feature-list` | Marketing snippets for landing pages | SYSTEM_TENANT_ID |
68
- | `tenant-welcome-message` | Tenant-specific text | TenantId (each tenant maintains their own) |
69
-
70
- Convention for slugs: `kebab-case`, hierarchy via `area-topic`
71
- (e.g. `faq-billing` rather than `billing-faq` so list aggregation by
72
- prefix is straightforward).
73
-
74
- ---
75
-
76
- ## API
77
-
78
- ### `text-content:write:set` — upsert per block
79
-
80
- The tenant admin writes a block. Idempotent: if a block already exists
81
- for `(tenantId, slug, lang)`, it's updated.
82
-
83
- ```typescript
84
- import { TextContentHandlers } from "@cosmicdrift/kumiko-bundled-features/text-content";
85
-
86
- await stack.http.writeOk(TextContentHandlers.set, {
87
- slug: "imprint",
88
- lang: "de",
89
- title: "Impressum",
90
- body: "## Angaben gemäß § 5 TMG\n\nMarc Frost ...",
91
- }, tenantAdmin);
92
- ```
93
-
94
- **Validation:**
95
- - `slug` — kebab-case (`/^[a-z0-9][a-z0-9-]*$/`), max 64 chars
96
- - `lang` — ISO 639-1 (`de`, `en`, `en-us`, ...)
97
- - `title` — 1-200 chars
98
- - `body` — Markdown, max 100000 chars, nullable
99
-
100
- **Access:** `roles: ["TenantAdmin"]`. Tenant scope comes automatically
101
- from `event.user.tenantId`. Platform admins (SystemTenant) set texts
102
- through the SystemAdmin role in SYSTEM_TENANT_ID.
103
-
104
- ### `text-content:query:by-slug` — public read
105
-
106
- Anonymous-capable (`roles: ["anonymous", "User", "TenantAdmin",
107
- "SystemAdmin"]`) — visitors on marketing/legal pages should see texts
108
- without a login.
109
-
110
- ```typescript
111
- import { TextContentQueries } from "@cosmicdrift/kumiko-bundled-features/text-content";
112
-
113
- const block = await stack.http.queryOk(TextContentQueries.bySlug, {
114
- slug: "imprint",
115
- lang: "de",
116
- }, anyUser);
117
- // → { slug, lang, title, body, updatedAt } | null
118
- ```
119
-
120
- **Tenant scope:** comes from `query.user.tenantId`. For anonymous
121
- requests the server must configure `anonymousAccess` with a
122
- `defaultTenantId` or `tenantResolver`.
123
-
124
- ---
125
-
126
- ## Test helper
127
-
128
- ```typescript
129
- import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/text-content/seeding";
130
-
131
- await seedTextBlock(db, {
132
- tenantId: SYSTEM_TENANT_ID,
133
- slug: "imprint",
134
- lang: "de",
135
- title: "Impressum",
136
- body: "...",
137
- });
138
- ```
139
-
140
- Idempotent: a second call updates the block.
141
-
142
- ---
143
-
144
- ## Cross-feature API (for consuming features)
145
-
146
- When another feature (e.g. `legal-pages`) wants to read text blocks
147
- **without a direct code import**, there's an extraContext API:
148
-
149
- ```typescript
150
- import { createTextContentApi, requireTextContent } from "@cosmicdrift/kumiko-bundled-features/text-content";
151
-
152
- // 1. App bootstrap wires the API:
153
- runProdApp({
154
- features: [createTextContentFeature(), createLegalPagesFeature(), /* ... */],
155
- extraContext: ({ db }) => ({
156
- textContent: createTextContentApi(db),
157
- }),
158
- });
159
-
160
- // 2. In the consumer feature (e.g. legal-pages handler / boot job):
161
- const textContent = requireTextContent(ctx, "my-handler");
162
- const block = await textContent.getBlock({
163
- tenantId: SYSTEM_TENANT_ID,
164
- slug: "imprint",
165
- lang: "de",
166
- });
167
- ```
168
-
169
- Pattern is symmetrical to `config` ↔ `tenant`: `text-content` only
170
- exports the type + factory, consuming features only import the type.
171
- This means text-content can be refactored freely without breaking
172
- other features — the contract is the `TextContentApi` interface.
173
-
174
- ## Combining with `legal-pages`
175
-
176
- `legal-pages` is an opt-in wrapper that registers four fixed
177
- convenience routes (`/legal/impressum`, `/legal/datenschutz`,
178
- `/legal/imprint`, `/legal/privacy`) and renders Markdown→HTML. See
179
- [../legal-pages/README.md](../legal-pages/README.md).
180
-
181
- ---
182
-
183
- ## Architecture
184
-
185
- - **Single source of truth:** `textBlockEntity` in `table.ts`.
186
- The Drizzle table is derived via `buildEntityTable("text-block",
187
- textBlockEntity)`, the unique index on `(tenantId, slug, lang)` is
188
- declared via `entity.indexes`.
189
- - **Event-sourced:** the write path goes through
190
- `createEventStoreExecutor` — `text-block.created` and
191
- `text-block.updated` land in the event stream, the projection row in
192
- the same TX. Subscribers (audit, search) receive the events.
193
- - **Storage:** one block per `(tenantId, slug, lang)`. SYSTEM_TENANT_ID
194
- for app-wide texts, regular TenantId for tenant-owned ones.
195
-
196
- Cross-refs: [../../docs/plans/datenschutz/](../../../../docs/plans/datenschutz/)
197
- for the bigger privacy plan picture.