@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.
- package/package.json +8 -10
- package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
- package/src/auth-email-password/__tests__/login-gates.test.ts +12 -6
- package/src/auth-email-password/seeding.ts +4 -2
- package/src/auth-mfa/__tests__/token-secrets.test.ts +19 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +11 -20
- package/src/auth-mfa/token-secrets.ts +16 -14
- package/src/billing-foundation/subscription-tier-sync.ts +8 -1
- package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +98 -0
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/config/handlers/reencrypt.job.ts +7 -18
- package/src/delivery/delivery-service.ts +1 -1
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +47 -11
- package/src/document-ingest-foundation/__tests__/feature.test.ts +6 -2
- package/src/document-ingest-foundation/events.ts +15 -0
- package/src/document-ingest-foundation/feature.ts +27 -2
- package/src/inbound-mail-foundation/watch-supervisor.ts +11 -2
- package/src/jobs/handlers/catalog.query.ts +1 -4
- package/src/jobs/handlers/trigger.write.ts +2 -1
- package/src/jobs/is-manual-trigger.ts +5 -0
- package/src/legal-pages/README.md +26 -25
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
- package/src/legal-pages/feature.ts +40 -31
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
- package/src/legal-pages/web/client-plugin.ts +3 -3
- package/src/managed-pages/screens/branding-screen.ts +1 -1
- package/src/presets/dsgvo-self-service.ts +10 -10
- package/src/seo/__tests__/robots-txt.test.ts +21 -0
- package/src/seo/__tests__/seo.integration.test.ts +13 -13
- package/src/seo/robots-txt.ts +21 -1
- package/src/sessions/handlers/revoke-all-for-user.write.ts +10 -1
- package/src/shared/classify-stored-envelope.ts +21 -0
- package/src/shared/index.ts +4 -0
- package/src/template-resolver/README.md +30 -1
- package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
- package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
- package/src/template-resolver/api.ts +11 -7
- package/src/template-resolver/constants.ts +22 -0
- package/src/template-resolver/feature.ts +16 -1
- package/src/template-resolver/handlers/by-slug.query.ts +54 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
- package/src/template-resolver/handlers/set.write.ts +110 -0
- package/src/template-resolver/handlers/shared.ts +13 -2
- package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
- package/src/template-resolver/index.ts +7 -0
- package/src/template-resolver/qualified-names.ts +3 -0
- package/src/template-resolver/seeding.ts +126 -2
- package/src/template-resolver/table.ts +11 -2
- package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
- package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
- package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
- package/src/template-resolver/web/client-plugin.tsx +352 -0
- package/src/template-resolver/web/index.ts +8 -0
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +9 -0
- package/src/tenant-settings/tenant-defaults.ts +3 -0
- package/src/tier-engine/compose-app.ts +2 -13
- package/src/user/__tests__/seed-testing.integration.test.ts +25 -0
- package/src/user/seeding.ts +25 -1
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/request-deletion-url.test.ts +11 -8
- package/src/user-data-rights/feature.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +8 -6
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +5 -5
- package/src/user-data-rights/web/confirm-deletion-screen.tsx +3 -2
- package/src/text-content/README.md +0 -197
- package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
- package/src/text-content/api.ts +0 -86
- package/src/text-content/changes.json +0 -14
- package/src/text-content/constants.ts +0 -21
- package/src/text-content/feature.ts +0 -53
- package/src/text-content/handlers/by-slug.query.ts +0 -55
- package/src/text-content/handlers/by-tenant.query.ts +0 -60
- package/src/text-content/handlers/set.write.ts +0 -141
- package/src/text-content/index.ts +0 -14
- package/src/text-content/seeding.ts +0 -139
- package/src/text-content/table.ts +0 -53
- package/src/text-content/web/client-plugin.tsx +0 -391
- package/src/text-content/web/index.ts +0 -8
package/src/user/seeding.ts
CHANGED
|
@@ -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)
|
|
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.
|
|
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.
|
|
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}
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
40
|
-
// (
|
|
41
|
-
// `?
|
|
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.
|
|
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
|
|
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
|
|
90
|
-
window.history.replaceState({}, "", "/delete-account/confirm
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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.
|