@cosmicdrift/kumiko-bundled-features 0.174.1 → 0.176.1

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 (51) 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__/invite-flow.integration.test.ts +31 -0
  4. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -2
  5. package/src/auth-mfa/__tests__/verify.integration.test.ts +64 -7
  6. package/src/auth-mfa/handlers/verify.write.ts +8 -3
  7. package/src/legal-pages/README.md +26 -25
  8. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
  9. package/src/legal-pages/feature.ts +40 -31
  10. package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
  11. package/src/legal-pages/web/client-plugin.ts +3 -3
  12. package/src/seo/__tests__/robots-txt.test.ts +21 -0
  13. package/src/seo/__tests__/seo.integration.test.ts +13 -13
  14. package/src/seo/robots-txt.ts +21 -1
  15. package/src/shared/index.ts +1 -0
  16. package/src/shared/session-timezone-field.ts +7 -0
  17. package/src/template-resolver/README.md +30 -1
  18. package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
  19. package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
  20. package/src/template-resolver/api.ts +11 -7
  21. package/src/template-resolver/constants.ts +22 -0
  22. package/src/template-resolver/feature.ts +16 -1
  23. package/src/template-resolver/handlers/by-slug.query.ts +54 -0
  24. package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
  25. package/src/template-resolver/handlers/set.write.ts +110 -0
  26. package/src/template-resolver/handlers/shared.ts +13 -2
  27. package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
  28. package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
  29. package/src/template-resolver/index.ts +7 -0
  30. package/src/template-resolver/qualified-names.ts +3 -0
  31. package/src/template-resolver/seeding.ts +126 -2
  32. package/src/template-resolver/table.ts +11 -2
  33. package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
  34. package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
  35. package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
  36. package/src/template-resolver/web/client-plugin.tsx +352 -0
  37. package/src/template-resolver/web/index.ts +8 -0
  38. package/src/text-content/README.md +0 -197
  39. package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
  40. package/src/text-content/api.ts +0 -86
  41. package/src/text-content/changes.json +0 -14
  42. package/src/text-content/constants.ts +0 -21
  43. package/src/text-content/feature.ts +0 -53
  44. package/src/text-content/handlers/by-slug.query.ts +0 -55
  45. package/src/text-content/handlers/by-tenant.query.ts +0 -60
  46. package/src/text-content/handlers/set.write.ts +0 -141
  47. package/src/text-content/index.ts +0 -14
  48. package/src/text-content/seeding.ts +0 -139
  49. package/src/text-content/table.ts +0 -53
  50. package/src/text-content/web/client-plugin.tsx +0 -391
  51. package/src/text-content/web/index.ts +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.174.1",
3
+ "version": "0.176.1",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -104,12 +104,10 @@
104
104
  "./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
105
105
  "./feature-toggles": "./src/feature-toggles/index.ts",
106
106
  "./feature-toggles/web": "./src/feature-toggles/web/index.ts",
107
- "./text-content": "./src/text-content/index.ts",
108
- "./text-content/seeding": "./src/text-content/seeding.ts",
109
- "./text-content/web": "./src/text-content/web/index.ts",
110
107
  "./template-resolver": "./src/template-resolver/index.ts",
111
108
  "./template-resolver/seeding": "./src/template-resolver/seeding.ts",
112
109
  "./template-resolver/testing": "./src/template-resolver/testing.ts",
110
+ "./template-resolver/web": "./src/template-resolver/web/index.ts",
113
111
  "./renderer-foundation": "./src/renderer-foundation/index.ts",
114
112
  "./legal-pages": "./src/legal-pages/index.ts",
115
113
  "./legal-pages/web": "./src/legal-pages/web/index.ts",
@@ -122,12 +120,12 @@
122
120
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
123
121
  },
124
122
  "dependencies": {
125
- "@cosmicdrift/kumiko-dispatcher-live": "0.174.1",
126
- "@cosmicdrift/kumiko-framework": "0.174.1",
127
- "@cosmicdrift/kumiko-headless": "0.174.1",
128
- "@cosmicdrift/kumiko-renderer": "0.174.1",
129
- "@cosmicdrift/kumiko-renderer-web": "0.174.1",
130
- "@cosmicdrift/kumiko-types": "0.174.1",
123
+ "@cosmicdrift/kumiko-dispatcher-live": "0.176.1",
124
+ "@cosmicdrift/kumiko-framework": "0.176.1",
125
+ "@cosmicdrift/kumiko-headless": "0.176.1",
126
+ "@cosmicdrift/kumiko-renderer": "0.176.1",
127
+ "@cosmicdrift/kumiko-renderer-web": "0.176.1",
128
+ "@cosmicdrift/kumiko-types": "0.176.1",
131
129
  "@mollie/api-client": "^4.5.0",
132
130
  "imapflow": "^1.3.3",
133
131
  "mailparser": "^3.9.8",
@@ -17,17 +17,21 @@ import {
17
17
  tenantComplianceProfileTable,
18
18
  } from "../compliance-profiles/schema/profile-selection";
19
19
  import { seedComplianceProfile } from "../compliance-profiles/seeding";
20
- import { createTextContentFeature } from "../text-content/feature";
21
- import { seedTextBlock } from "../text-content/seeding";
22
- import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../text-content/table";
20
+ import { createTemplateResolverFeature } from "../template-resolver/feature";
21
+ import { seedTextBlock } from "../template-resolver/seeding";
22
+ import {
23
+ type TemplateResourceRow,
24
+ templateResourceEntity,
25
+ templateResourcesTable,
26
+ } from "../template-resolver/table";
23
27
 
24
28
  let stack: TestStack;
25
29
 
26
30
  beforeAll(async () => {
27
31
  stack = await setupTestStack({
28
- features: [createTextContentFeature(), createComplianceProfilesFeature()],
32
+ features: [createTemplateResolverFeature(), createComplianceProfilesFeature()],
29
33
  });
30
- await unsafeCreateEntityTable(stack.db, textBlockEntity);
34
+ await unsafeCreateEntityTable(stack.db, templateResourceEntity);
31
35
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
32
36
  await createEventsTable(stack.db);
33
37
  });
@@ -43,32 +47,32 @@ describe("boot-seed contract", () => {
43
47
  await seedTextBlock(stack.db, {
44
48
  tenantId,
45
49
  slug: "imprint",
46
- lang: "de",
50
+ locale: "de",
47
51
  title: "Impressum",
48
- body: "seed body",
52
+ content: "seed body",
49
53
  });
50
54
  await seedTextBlock(stack.db, {
51
55
  tenantId,
52
56
  slug: "imprint",
53
- lang: "de",
57
+ locale: "de",
54
58
  title: "Impressum (edited)",
55
- body: "admin body",
59
+ content: "admin body",
56
60
  ifExists: "update",
57
61
  });
58
62
  await seedTextBlock(stack.db, {
59
63
  tenantId,
60
64
  slug: "imprint",
61
- lang: "de",
65
+ locale: "de",
62
66
  title: "Impressum",
63
- body: "seed body",
67
+ content: "seed body",
64
68
  });
65
69
 
66
- const row = await fetchOne<TextBlockRow>(stack.db, textBlocksTable, {
70
+ const row = await fetchOne<TemplateResourceRow>(stack.db, templateResourcesTable, {
67
71
  tenantId,
68
72
  slug: "imprint",
69
- lang: "de",
73
+ locale: "de",
70
74
  });
71
- expect(row).toMatchObject({ title: "Impressum (edited)", body: "admin body", version: 2 });
75
+ expect(row).toMatchObject({ title: "Impressum (edited)", content: "admin body", version: 2 });
72
76
 
73
77
  const events = await selectMany(stack.db, eventsTable, { aggregateId: String(row!.id) });
74
78
  expect(events).toHaveLength(2);
@@ -351,6 +351,37 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
351
351
  expect(memberships).toHaveLength(2);
352
352
  });
353
353
 
354
+ test("Bob accepts with a timezone → JWT retains the timezone", async () => {
355
+ await asRawClient(stack.db).unsafe(
356
+ `UPDATE "${userTable.tableName}" SET timezone = $1 WHERE id = $2`,
357
+ ["Europe/Berlin", bobId],
358
+ );
359
+ try {
360
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
361
+
362
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
363
+ token,
364
+ email: BOB_EMAIL,
365
+ password: BOB_PASSWORD,
366
+ });
367
+ expect(res.status).toBe(200);
368
+ const body = (await res.json()) as { token?: string };
369
+ expect(body.token).toBeTypeOf("string");
370
+ if (!body.token) throw new Error("invite acceptance did not return a token");
371
+
372
+ const payload = await stack.jwt.verify(body.token);
373
+ expect(payload.timezone).toBe("Europe/Berlin");
374
+ } finally {
375
+ // Bob is shared across this describe block (other tests rely on his
376
+ // membership state persisting) — restore his timezone so later tests
377
+ // don't silently inherit it.
378
+ await asRawClient(stack.db).unsafe(
379
+ `UPDATE "${userTable.tableName}" SET timezone = NULL WHERE id = $1`,
380
+ [bobId],
381
+ );
382
+ }
383
+ });
384
+
354
385
  test("Wrong password → 422 invalid_invite_token (anti-enum)", async () => {
355
386
  const token = await inviteEmail(BOB_EMAIL, "Editor");
356
387
  const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
@@ -28,7 +28,7 @@ import {
28
28
  } from "@cosmicdrift/kumiko-framework/engine";
29
29
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
30
30
  import { z } from "zod";
31
- import { decryptStoredPii, verifyPassword } from "../../shared";
31
+ import { decryptStoredPii, sessionTimezoneField, verifyPassword } from "../../shared";
32
32
  // kumiko-lint-ignore cross-feature-import invite-flow
33
33
  import {
34
34
  INVITATION_STATUS,
@@ -96,7 +96,11 @@ export function createInviteAcceptWithLoginHandler() {
96
96
  readonly role: string;
97
97
  readonly version: number;
98
98
  };
99
- type UserAuthRow = { readonly id: string; readonly passwordHash: string | null };
99
+ type UserAuthRow = {
100
+ readonly id: string;
101
+ readonly passwordHash: string | null;
102
+ readonly timezone?: string | null;
103
+ };
100
104
 
101
105
  let committed = false;
102
106
  try {
@@ -173,6 +177,7 @@ export function createInviteAcceptWithLoginHandler() {
173
177
  // buildSessionRoles calls stripForbiddenMembershipRoles internally —
174
178
  // a reserved role on the invitation itself must never reach the session.
175
179
  roles: buildSessionRoles([], [invitationRole]),
180
+ ...sessionTimezoneField(userRow.timezone),
176
181
  };
177
182
 
178
183
  committed = true;
@@ -14,9 +14,12 @@ import {
14
14
  expectErrorIncludes,
15
15
  seedRow,
16
16
  } from "@cosmicdrift/kumiko-framework/testing";
17
+ import { AuthHandlers as AuthEmailPasswordHandlers } from "../../auth-email-password/constants";
18
+ import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
17
19
  import { createConfigFeature } from "../../config";
18
20
  import { createConfigResolver } from "../../config/resolver";
19
21
  import { configValuesTable } from "../../config/table";
22
+ import { hashPassword } from "../../shared";
20
23
  import { createTenantFeature } from "../../tenant";
21
24
  import { tenantMembershipsTable } from "../../tenant/membership-table";
22
25
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -25,7 +28,7 @@ import { createUserFeature } from "../../user/feature";
25
28
  import { userEntity, userTable } from "../../user/schema/user";
26
29
  import { base32Decode } from "../base32";
27
30
  import { AuthMfaHandlers } from "../constants";
28
- import { createAuthMfaFeature } from "../feature";
31
+ import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
29
32
  import { signMfaChallengeToken } from "../mfa-challenge-token";
30
33
  import { userMfaEntity } from "../schema/user-mfa";
31
34
  import { currentTotpCode } from "../totp";
@@ -49,18 +52,26 @@ beforeAll(async () => {
49
52
  const encryption = createTestEnvelopeCipher();
50
53
  configureEntityFieldEncryption(encryption);
51
54
  const resolver = createConfigResolver({ cipher: encryption });
55
+ const authMfaFeature = createAuthMfaFeature({
56
+ setupTokenSecret: SETUP_TOKEN_SECRET,
57
+ issuer: "Kumiko Test",
58
+ challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
59
+ });
52
60
  stack = await setupTestStack({
53
61
  features: [
54
62
  createConfigFeature(),
55
63
  createUserFeature(),
56
64
  createTenantFeature(),
57
- createAuthMfaFeature({
58
- setupTokenSecret: SETUP_TOKEN_SECRET,
59
- issuer: "Kumiko Test",
60
- challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
65
+ authMfaFeature,
66
+ createAuthEmailPasswordFeature({
67
+ mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
61
68
  }),
62
69
  ],
63
70
  extraContext: { configResolver: resolver, configEncryption: encryption },
71
+ authConfig: {
72
+ membershipQuery: "tenant:query:memberships",
73
+ loginHandler: AuthEmailPasswordHandlers.login,
74
+ },
64
75
  });
65
76
  await unsafeCreateEntityTable(stack.db, userEntity);
66
77
  await unsafeCreateEntityTable(stack.db, tenantEntity);
@@ -76,8 +87,10 @@ async function enableMfaFor(idSeed: number): Promise<{
76
87
  user: ReturnType<typeof createTestUser>;
77
88
  secret: Buffer;
78
89
  recoveryCodes: string[];
90
+ password: string;
79
91
  }> {
80
92
  const user = createTestUser({ id: idSeed, roles: ["User"] });
93
+ const password = `password-${idSeed}-long-enough`;
81
94
  const start = await stack.http.writeOk<{
82
95
  setupToken: string;
83
96
  otpauthUri: string;
@@ -103,14 +116,14 @@ async function enableMfaFor(idSeed: number): Promise<{
103
116
  id: user.id,
104
117
  tenantId: user.tenantId,
105
118
  email: `user-${user.id}@example.com`,
106
- passwordHash: "h",
119
+ passwordHash: await hashPassword(password),
107
120
  displayName: `Test User ${idSeed}`,
108
121
  locale: "de",
109
122
  emailVerified: true,
110
123
  roles: "[]",
111
124
  status: USER_STATUS.Active,
112
125
  });
113
- return { user, secret, recoveryCodes: start.recoveryCodes };
126
+ return { user, secret, recoveryCodes: start.recoveryCodes, password };
114
127
  }
115
128
 
116
129
  function challengeFor(userId: string, tenantId: TenantId): string {
@@ -131,6 +144,50 @@ describe("mfa verify — completes a two-step login", () => {
131
144
  expect(res.session.tenantId).toBe(user.tenantId);
132
145
  });
133
146
 
147
+ test("a password login followed by MFA verify retains the user's timezone", async () => {
148
+ const { user, password, secret } = await enableMfaFor(9);
149
+ await asRawClient(stack.db).unsafe(
150
+ `UPDATE "${userTable.tableName}" SET timezone = $1 WHERE id = $2`,
151
+ ["Europe/Berlin", user.id],
152
+ );
153
+
154
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
155
+ email: `user-${user.id}@example.com`,
156
+ password,
157
+ });
158
+ expect(loginRes.status).toBe(200);
159
+ const login = (await loginRes.json()) as { challengeToken?: string };
160
+ expect(login.challengeToken).toBeTypeOf("string");
161
+ if (!login.challengeToken) throw new Error("MFA login did not return a challenge token");
162
+
163
+ const verified = await stack.http.writeOk<{ session: SessionUser }>(
164
+ AuthMfaHandlers.verify,
165
+ { challengeToken: login.challengeToken, code: currentTotpCode(secret) },
166
+ GUEST,
167
+ );
168
+ expect(verified.session.timezone).toBe("Europe/Berlin");
169
+ });
170
+
171
+ test("a password login followed by MFA verify omits an unset timezone", async () => {
172
+ const { user, password, secret } = await enableMfaFor(10);
173
+
174
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
175
+ email: `user-${user.id}@example.com`,
176
+ password,
177
+ });
178
+ expect(loginRes.status).toBe(200);
179
+ const login = (await loginRes.json()) as { challengeToken?: string };
180
+ expect(login.challengeToken).toBeTypeOf("string");
181
+ if (!login.challengeToken) throw new Error("MFA login did not return a challenge token");
182
+
183
+ const verified = await stack.http.writeOk<{ session: SessionUser }>(
184
+ AuthMfaHandlers.verify,
185
+ { challengeToken: login.challengeToken, code: currentTotpCode(secret) },
186
+ GUEST,
187
+ );
188
+ expect(verified.session.timezone).toBeUndefined();
189
+ });
190
+
134
191
  test("a wrong TOTP code is rejected", async () => {
135
192
  const { user } = await enableMfaFor(2);
136
193
  const challengeToken = challengeFor(user.id, user.tenantId);
@@ -8,7 +8,7 @@ import {
8
8
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
9
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
10
10
  import { z } from "zod";
11
- import { burnToken } from "../../shared";
11
+ import { burnToken, sessionTimezoneField } from "../../shared";
12
12
  import { USER_STATUS, UserQueries } from "../../user";
13
13
  import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
14
14
  import { findUserMfaRow } from "../db/queries";
@@ -135,7 +135,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
135
135
  const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
136
136
  const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
137
137
  id: userId,
138
- })) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
138
+ })) as { roles?: string | null; status?: string; timezone?: string | null } | null; // @cast-boundary engine-payload
139
139
 
140
140
  // Re-check status + membership the way login.write.ts does after its
141
141
  // password check — the challenge token only proves "password was
@@ -168,7 +168,12 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
168
168
  // read-time backstop against a rebuild-resurrected role.
169
169
  const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
170
170
 
171
- const baseSession: SessionUser = { id: userId, tenantId, roles: mergedRoles };
171
+ const baseSession: SessionUser = {
172
+ id: userId,
173
+ tenantId,
174
+ roles: mergedRoles,
175
+ ...sessionTimezoneField(userRow?.timezone),
176
+ };
172
177
  const claims = await ctx.resolveAuthClaims(baseSession);
173
178
  const session: SessionUser =
174
179
  Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
@@ -1,6 +1,6 @@
1
1
  # legal-pages
2
2
 
3
- Opt-in wrapper around [`text-content`](../text-content/) for
3
+ Opt-in wrapper around [`template-resolver`](../template-resolver/) text-blocks for
4
4
  DACH compliance. Ships four fixed public HTML routes
5
5
  (`/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`,
6
6
  `/legal/privacy`) with Markdown→HTML rendering and a boot check that
@@ -17,25 +17,25 @@ feature.
17
17
  ```typescript
18
18
  import { createLegalPagesFeature } from "@cosmicdrift/kumiko-bundled-features/legal-pages";
19
19
  import {
20
- createTextContentApi,
21
- createTextContentFeature,
22
- } from "@cosmicdrift/kumiko-bundled-features/text-content";
20
+ createTemplateResolverApi,
21
+ createTemplateResolverFeature,
22
+ } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
23
23
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
24
24
 
25
25
  runProdApp({
26
26
  features: [
27
- createTextContentFeature(), // legal-pages requires text-content
27
+ createTemplateResolverFeature(), // legal-pages requires template-resolver
28
28
  createLegalPagesFeature(),
29
29
  /* ... */
30
30
  ],
31
31
  // Two wirings are required:
32
32
  // 1. anonymousAccess for /legal/* routes (run without a JWT)
33
- // 2. extraContext.textContent for the boot check (cross-feature
34
- // decoupling — legal-pages imports no code from text-content,
33
+ // 2. extraContext.templateResolver for the boot check (cross-feature
34
+ // decoupling — legal-pages imports no code from template-resolver,
35
35
  // only uses the API via ctx)
36
36
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
37
37
  extraContext: ({ db }) => ({
38
- textContent: createTextContentApi(db),
38
+ templateResolver: createTemplateResolverApi(db),
39
39
  }),
40
40
  });
41
41
  ```
@@ -44,19 +44,20 @@ runProdApp({
44
44
 
45
45
  ### Production table setup
46
46
 
47
- legal-pages doesn't have its own table — it uses text-content's
48
- `read_text_blocks`. Table setup therefore goes through text-content:
47
+ legal-pages doesn't have its own table — it stores its blocks as
48
+ `kind: "text-block"` rows in template-resolver's `read_template_resources`.
49
+ Table setup therefore goes through template-resolver:
49
50
 
50
51
  ```bash
51
- bun kumiko migrate generate # text-block entity is detected
52
- bun kumiko migrate apply
52
+ bun kumiko schema generate <name> # template-resource entity is detected
53
+ bun kumiko schema apply
53
54
  ```
54
55
 
55
- See [text-content/README.md](../text-content/README.md#production-table-setup).
56
+ See [template-resolver/README.md](../template-resolver/README.md).
56
57
 
57
58
  ## Routes
58
59
 
59
- | Path | Slug + lang | Title fallback (when block empty) |
60
+ | Path | Slug + locale | Title fallback (when block empty) |
60
61
  |---|---|---|
61
62
  | `GET /legal/impressum` | `imprint` / `de` | "Impressum" |
62
63
  | `GET /legal/datenschutz` | `privacy` / `de` | "Datenschutzerklärung" |
@@ -64,12 +65,12 @@ See [text-content/README.md](../text-content/README.md#production-table-setup).
64
65
  | `GET /legal/privacy` | `privacy` / `en` | "Privacy Policy" |
65
66
 
66
67
  Response:
67
- - `200 text/html` — block exists + has body. Cache header `public, max-age=300`.
68
+ - `200 text/html` — block exists + has content. Cache header `public, max-age=300`.
68
69
  - `404 text/plain` — block missing. Hint: "Tenant admin must set this text block".
69
70
  - `503 text/plain` — `app.fetch` to `/api/query` failed (anonymousAccess missing?).
70
71
 
71
72
  Layout: a minimal HTML5 skeleton with inline CSS — apps that want to
72
- integrate into their own layout use `text-content:query:by-slug`
73
+ integrate into their own layout use `template-resolver:query:by-slug`
73
74
  directly and render themselves.
74
75
 
75
76
  ---
@@ -79,7 +80,7 @@ directly and render themselves.
79
80
  `r.job` with `runOnBoot: true` checks at app start whether the DE
80
81
  required blocks exist in SYSTEM_TENANT:
81
82
 
82
- | Slug + lang | What happens when missing |
83
+ | Slug + locale | What happens when missing |
83
84
  |---|---|
84
85
  | `imprint` / `de` | **Production:** `throw new Error(...)` blocks app start. **Dev:** `ctx.log.warn(...)` |
85
86
  | `privacy` / `de` | as above |
@@ -104,18 +105,18 @@ await fetch("/api/write", {
104
105
  method: "POST",
105
106
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` },
106
107
  body: JSON.stringify({
107
- type: "text-content:write:set",
108
+ type: "template-resolver:write:set",
108
109
  payload: {
109
110
  slug: "imprint",
110
- lang: "de",
111
+ locale: "de",
111
112
  title: "Impressum",
112
- body: "## Angaben gemäß § 5 TMG\n\n...",
113
+ content: "## Angaben gemäß § 5 TMG\n\n...",
113
114
  },
114
115
  }),
115
116
  });
116
117
  ```
117
118
 
118
- → Idempotent: a second call with the same `(slug, lang)` updates the block.
119
+ → Idempotent: a second call with the same `(slug, locale)` updates the block.
119
120
  ACL: `roles: ["TenantAdmin", "SystemAdmin"]` — SystemAdmin (a global
120
121
  role) may set SYSTEM_TENANT texts, TenantAdmin only tenant-owned ones.
121
122
 
@@ -128,15 +129,15 @@ instant visibility, you can help things along with a CDN purge.
128
129
  On first app boot or via migration:
129
130
 
130
131
  ```typescript
131
- import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/text-content/seeding";
132
+ import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
132
133
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
133
134
 
134
135
  await seedTextBlock(db, {
135
136
  tenantId: SYSTEM_TENANT_ID,
136
137
  slug: "imprint",
137
- lang: "de",
138
+ locale: "de",
138
139
  title: "Impressum",
139
- body: `## Angaben gemäß § 5 TMG
140
+ content: `## Angaben gemäß § 5 TMG
140
141
 
141
142
  **Marc Frost**
142
143
 
@@ -181,7 +182,7 @@ no DOMPurify dependency needed:
181
182
  Kumiko app share the SYSTEM_TENANT version of the legal pages. If you
182
183
  need per-tenant imprints (rare — typical case: the platform operator
183
184
  is the responsible party, not the tenant customer), call
184
- text-content's by-slug query directly with a tenant-specific TenantId
185
+ the by-slug query directly with a tenant-specific TenantId
185
186
  and put your own routes in front.
186
187
 
187
188
  ---