@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
@@ -1,7 +1,8 @@
1
1
  import {
2
- requireTextContent,
3
- type TextContentApi,
4
- } from "@cosmicdrift/kumiko-bundled-features/text-content";
2
+ requireTemplateResolver,
3
+ TEXT_BLOCK_KIND,
4
+ type TemplateResolverApi,
5
+ } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
5
6
  import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
6
7
  import {
7
8
  defineFeature,
@@ -12,22 +13,26 @@ import { cachedSecurePageResponse } from "../page-render";
12
13
  import { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES } from "./constants";
13
14
  import { renderMarkdownToHtml, wrapInLayout } from "./markdown";
14
15
 
15
- // QN-Konstante als dokumentierter Public-Contract des text-content-
16
+ // QN-Konstante als dokumentierter Public-Contract des template-resolver-
16
17
  // Features. Ein magic-string statt eines Code-Imports ist hier explizit
17
18
  // gewollt: Cross-Feature-Calls gehen nur über stable Public-API
18
19
  // (handler-name + payload-shape), nicht über interne Module-Refs. Wenn
19
- // text-content's Handler-Name sich ändert, ist das ein semver-major
20
+ // template-resolver's Handler-Name sich ändert, ist das ein semver-major
20
21
  // und muss in beiden Features synchronisiert werden — gleiches Risiko
21
22
  // wie bei jedem API-Endpunkt.
22
- const TEXT_CONTENT_BY_SLUG_QN = "text-content:query:by-slug";
23
+ const TEXT_BLOCK_BY_SLUG_QN = "template-resolver:query:by-slug";
23
24
 
24
- // Return-Shape von bySlugQuery (text-content/handlers/by-slug.query.ts).
25
- type TextBlockQueryResult = { title: string; body: string | null; updatedAt: string } | null;
25
+ // Return shape of bySlugQuery (template-resolver/handlers/by-slug.query.ts).
26
+ type TextBlockQueryResult = {
27
+ title: string | null;
28
+ content: string | null;
29
+ updatedAt: string;
30
+ } | null;
26
31
 
27
32
  // 60s-shared-cache saves the origin-revalidate roundtrip; legal-content edits are live within 60s.
28
33
  const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
29
34
 
30
- // legal-pages — Opt-in-Wrapper um text-content für DACH-Compliance.
35
+ // legal-pages — Opt-in-Wrapper um template-resolver (kind text-block) für DACH-Compliance.
31
36
  // Liefert vier feste Public-HTML-Routes (/legal/impressum,
32
37
  // /legal/datenschutz, /legal/imprint, /legal/privacy) mit
33
38
  // Markdown→HTML-Rendering und einen Boot-Check der in Production hart
@@ -35,14 +40,14 @@ const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
35
40
  //
36
41
  // Cross-Feature-Decoupling:
37
42
  // • Routes nutzen app.fetch zu "/api/query" mit dem QN-string
38
- // `text-content:query:by-slug` — kein Code-Import von text-content
39
- // • Boot-Check nutzt ctx.textContent (über extraContext) — symmetrisch
43
+ // `template-resolver:query:by-slug` — kein Code-Import von template-resolver
44
+ // • Boot-Check nutzt ctx.templateResolver (über extraContext) — symmetrisch
40
45
  // zum config/tenant-Pattern
41
- // • Single Type-Import (TextContentApi) bleibt — type-only verstößt
46
+ // • Single Type-Import (TemplateResolverApi) bleibt — type-only verstößt
42
47
  // nicht gegen Cross-Feature-Coupling
43
48
  //
44
49
  // Voraussetzungen für Production:
45
- // • App-Bootstrap muss extraContext: { textContent: createTextContentApi(db) }
50
+ // • App-Bootstrap muss extraContext: { templateResolver: createTemplateResolverApi(db) }
46
51
  // setzen — sonst wirft Boot-Check beim Start
47
52
  // • anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID } — sonst
48
53
  // antworten die Routes mit 503
@@ -65,14 +70,14 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
65
70
  const wrapLayout = opts.wrapLayout ?? wrapInLayout;
66
71
  return defineFeature("legal-pages", (r) => {
67
72
  r.describe(
68
- "Opt-in wrapper around `text-content` that registers four public HTML routes (`/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`, `/legal/privacy`) with Markdown-to-HTML rendering and a boot-time job that hard-fails in production when the required DE blocks (`imprint/de`, `privacy/de`) are not seeded in `SYSTEM_TENANT`. Requires `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` and `extraContext.textContent` to be wired at app bootstrap; for per-tenant imprints or a custom layout call `text-content:query:by-slug` directly.",
73
+ "Opt-in wrapper around `template-resolver` text-blocks that registers four public HTML routes (`/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`, `/legal/privacy`) with Markdown-to-HTML rendering and a boot-time job that hard-fails in production when the required DE blocks (`imprint/de`, `privacy/de`) are not seeded in `SYSTEM_TENANT`. Requires `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` and `extraContext.templateResolver` to be wired at app bootstrap; for per-tenant imprints or a custom layout call `template-resolver:query:by-slug` directly.",
69
74
  );
70
75
  r.uiHints({
71
76
  displayLabel: "Legal Pages",
72
77
  category: "content",
73
78
  recommended: false,
74
79
  });
75
- r.requires("text-content");
80
+ r.requires("template-resolver");
76
81
 
77
82
  // 4 Public-HTML-Routes
78
83
  for (const route of LEGAL_ROUTES) {
@@ -92,19 +97,19 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
92
97
  try {
93
98
  // @cast-boundary engine-payload — dispatcher.query returnt
94
99
  // unknown; Shape ist durch bySlugQuery's Handler-Return-Type
95
- // (siehe text-content/handlers/by-slug.query.ts) vertraut.
100
+ // (siehe template-resolver/handlers/by-slug.query.ts) vertraut.
96
101
  data = (await systemQuery(
97
- TEXT_CONTENT_BY_SLUG_QN,
98
- { slug: route.slug, lang: route.lang },
102
+ TEXT_BLOCK_BY_SLUG_QN,
103
+ { slug: route.slug, locale: route.lang },
99
104
  SYSTEM_TENANT_ID,
100
105
  )) as TextBlockQueryResult;
101
106
  } catch (err) {
102
107
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback, no logger wired into HttpRouteHandlerDeps
103
- console.error(`legal-pages: text-content query failed for slug="${route.slug}"`, err);
108
+ console.error(`legal-pages: text-block query failed for slug="${route.slug}"`, err);
104
109
  return c.text("legal page unavailable", 503);
105
110
  }
106
111
 
107
- if (!data?.body) {
112
+ if (!data?.content) {
108
113
  return c.text(
109
114
  `${route.titleFallback} not configured. Tenant-Admin must set this text-block.`,
110
115
  404,
@@ -134,7 +139,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
134
139
 
135
140
  const html = wrapLayout({
136
141
  title: data.title || route.titleFallback,
137
- bodyHtml: renderMarkdownToHtml(data.body),
142
+ bodyHtml: renderMarkdownToHtml(data.content),
138
143
  lang: route.lang,
139
144
  slug: route.slug,
140
145
  });
@@ -149,8 +154,8 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
149
154
  });
150
155
  }
151
156
 
152
- // Boot-Check via ctx.textContent (extraContext-Pattern, symmetrisch
153
- // zu requireConfigResolver in config). App-Bootstrap muss textContent
157
+ // Boot-Check via ctx.templateResolver (extraContext-Pattern, symmetrisch
158
+ // zu requireConfigResolver in config). App-Bootstrap muss templateResolver
154
159
  // wired haben — der Helper gibt einen klaren Wiring-Hinweis wenn nicht.
155
160
  //
156
161
  // Body als named function extrahiert (`runLegalPagesBootCheck`) damit
@@ -173,31 +178,35 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
173
178
 
174
179
  // Minimal-shape für die Boot-Check-Logik — nur die Felder die der Check
175
180
  // braucht. Akzeptiert HandlerContext + AppContext + jeden anderen
176
- // Container der textContent + log mitbringt. Macht den Check direkt
181
+ // Container der templateResolver + log mitbringt. Macht den Check direkt
177
182
  // unit-testbar mit constructed ctx-Objects.
178
183
  export type LegalPagesBootCheckCtx = {
179
- readonly textContent?: TextContentApi;
184
+ readonly templateResolver?: TemplateResolverApi;
180
185
  readonly log?: {
181
186
  readonly info?: (msg: string) => void;
182
187
  readonly warn?: (msg: string) => void;
183
188
  };
184
189
  };
185
190
 
186
- // Exportiert für direkte Tests. Wirft InternalError wenn ctx.textContent
191
+ // Exportiert für direkte Tests. Wirft InternalError wenn ctx.templateResolver
187
192
  // nicht gewired ist (Hinweis auf fehlenden extraContext). Wirft Error
188
193
  // in NODE_ENV=production wenn Pflicht-Blocks fehlen, sonst log.warn.
189
194
  // Logged log.info wenn alles vorhanden ist (kein silent-skip).
190
195
  export async function runLegalPagesBootCheck(ctx: LegalPagesBootCheckCtx): Promise<void> {
191
- const textContent: TextContentApi = requireTextContent(ctx, "legal-pages-boot-check");
196
+ const templateResolver: TemplateResolverApi = requireTemplateResolver(
197
+ ctx,
198
+ "legal-pages-boot-check",
199
+ );
192
200
  const missing: { slug: string; lang: string }[] = [];
193
201
 
194
202
  for (const required of LEGAL_REQUIRED_BLOCKS) {
195
- const block = await textContent.getBlock({
203
+ const block = await templateResolver.findExact({
196
204
  tenantId: SYSTEM_TENANT_ID,
197
205
  slug: required.slug,
198
- lang: required.lang,
206
+ kind: TEXT_BLOCK_KIND,
207
+ locale: required.lang,
199
208
  });
200
- if (!block?.body) {
209
+ if (!block?.content) {
201
210
  missing.push({ slug: required.slug, lang: required.lang });
202
211
  }
203
212
  }
@@ -208,7 +217,7 @@ export async function runLegalPagesBootCheck(ctx: LegalPagesBootCheckCtx): Promi
208
217
  const message =
209
218
  `legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
210
219
  missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
211
- ". Seed via text-content:write:set or seedTextBlock helper.";
220
+ ". Seed via template-resolver:write:set or the seedTextBlock helper.";
212
221
 
213
222
  if (process.env["NODE_ENV"] === "production") {
214
223
  throw new Error(`Boot-Validation failed: ${message}`);
@@ -43,11 +43,11 @@ describe("legalPagesClient", () => {
43
43
  expect(emitted.length).toBeGreaterThan(0);
44
44
 
45
45
  // Jeder Top-Level-Knoten ist ein slug-Folder mit lang-Leaves, die per
46
- // Cross-Link auf text-content:edit zeigen.
46
+ // Cross-Link auf template-resolver:edit zeigen.
47
47
  const folder = emitted[0];
48
48
  expect(Array.isArray(folder?.children)).toBe(true);
49
49
  const langLeaf = Array.isArray(folder?.children) ? folder?.children[0] : undefined;
50
- expect(langLeaf?.target?.featureId).toBe("text-content");
50
+ expect(langLeaf?.target?.featureId).toBe("template-resolver");
51
51
  expect(langLeaf?.target?.action).toBe("edit");
52
52
  });
53
53
  });
@@ -33,7 +33,7 @@ const treeProvider: TreeChildrenSubscribe = () => (emit) => {
33
33
  // Slug ist der Übersetzungs-Anker — User pflegt DE+EN-Versionen
34
34
  // desselben Inhalts zusammen statt nach Sprache zu gruppieren.
35
35
  // Sub-Items sind reine Sprach-Leaves; Label = Sprache, target zeigt
36
- // auf text-content:edit mit slug+lang.
36
+ // auf template-resolver:edit mit slug+locale.
37
37
 
38
38
  // Group all blocks by slug, collect set of langs per slug.
39
39
  const bySlug = new Map<string, string[]>();
@@ -50,9 +50,9 @@ const treeProvider: TreeChildrenSubscribe = () => (emit) => {
50
50
  const langLeaves: TreeNode[] = langs.sort().map((lang) => ({
51
51
  label: lang,
52
52
  target: {
53
- featureId: "text-content",
53
+ featureId: "template-resolver",
54
54
  action: "edit",
55
- args: { slug, lang },
55
+ args: { slug, locale: lang },
56
56
  },
57
57
  }));
58
58
  slugFolders.push({
@@ -42,7 +42,7 @@ export function createBrandingSettingsScreen(opts: {
42
42
  description: createTextField({
43
43
  maxLength: 500,
44
44
  multiline: { rows: 3 },
45
- allowPlaintext: "tenant branding copy, business content not personal data",
45
+ allowPlaintext: "is-business-data",
46
46
  }),
47
47
  siteUrl: createTextField({ maxLength: 2000, format: "url" }),
48
48
  accentColor: createTextField({ maxLength: 9 }),
@@ -6,19 +6,19 @@ import { createUserDataRightsFeature, type UserDataRightsOptions } from "../user
6
6
  import { createUserProfileFeature } from "../user-profile";
7
7
 
8
8
  export type DsgvoSelfServiceOptions = {
9
- /** Durchgereicht an createUserDataRightsFeature — Export-/Deletion-Mail-
10
- * Callbacks + Apex-Deletion-HMAC. Default {} (no-op Mail-Side). */
9
+ /** Passed through to createUserDataRightsFeature — export/deletion mail
10
+ * callbacks + Apex deletion HMAC. Default {} (no-op mail side). */
11
11
  readonly userDataRights?: UserDataRightsOptions;
12
12
  };
13
13
 
14
- // DSGVO- + Account-Self-Service-Kette, die jede Kumiko-SaaS-App mountet
15
- // (Privacy-Center, Account-Löschung Art. 17, Export Art. 20, Sessions).
16
- // Order is load-bearing (Require-Order): user-data-rights braucht
17
- // data-retention + compliance-profiles + sessions, user-profile braucht
18
- // user-data-rights. Genau diese Order stand bisher in jeder App handkopiert
19
- // mit Erklär-Kommentar. text-content + legal-pages bleiben bewusst draußen —
20
- // legal-pages hat ein app-spezifisches wrapLayout, text-content ist
21
- // standalone Foundation; beide spreaded die App selbst dazu.
14
+ // DSGVO + account self-service chain every Kumiko SaaS app mounts (privacy
15
+ // center, account deletion Art. 17, export Art. 20, sessions). Order is
16
+ // load-bearing (require-order): user-data-rights needs data-retention +
17
+ // compliance-profiles + sessions, user-profile needs user-data-rights. This
18
+ // exact order used to be hand-copied into every app with an explainer
19
+ // comment. text-content + legal-pages stay out deliberately — legal-pages
20
+ // has an app-specific wrapLayout, text-content is a standalone foundation;
21
+ // both are spread in by the app itself.
22
22
  export function dsgvoSelfServiceFeatures(opts: DsgvoSelfServiceOptions = {}): FeatureDefinition[] {
23
23
  return [
24
24
  createDataRetentionFeature(),
@@ -15,4 +15,25 @@ describe("buildRobotsTxt", () => {
15
15
  "User-agent: *\nDisallow:\nSitemap: https://acme.test/sitemap.xml\n",
16
16
  );
17
17
  });
18
+
19
+ test("aiCrawlers: true + allow: true → explicit Allow block per AI bot", () => {
20
+ expect(buildRobotsTxt({ allow: true, aiCrawlers: true })).toBe(
21
+ "User-agent: *\nDisallow:\n" +
22
+ "User-agent: GPTBot\nAllow: /\n" +
23
+ "User-agent: ClaudeBot\nAllow: /\n" +
24
+ "User-agent: anthropic-ai\nAllow: /\n" +
25
+ "User-agent: PerplexityBot\nAllow: /\n" +
26
+ "User-agent: Google-Extended\nAllow: /\n",
27
+ );
28
+ });
29
+
30
+ test("aiCrawlers: true + allow: false → no per-bot block (blanket Disallow already covers it)", () => {
31
+ expect(buildRobotsTxt({ allow: false, aiCrawlers: true })).toBe("User-agent: *\nDisallow: /\n");
32
+ });
33
+
34
+ test("contentSignal appends a Content-Signal line", () => {
35
+ expect(
36
+ buildRobotsTxt({ allow: true, contentSignal: "ai-train=no, search=yes, ai-input=yes" }),
37
+ ).toBe("User-agent: *\nDisallow:\nContent-Signal: ai-train=no, search=yes, ai-input=yes\n");
38
+ });
18
39
  });
@@ -6,11 +6,11 @@ import {
6
6
  } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
7
7
  import { seedPage } from "@cosmicdrift/kumiko-bundled-features/managed-pages/seeding";
8
8
  import {
9
- createTextContentApi,
10
- createTextContentFeature,
11
- textBlockEntity,
12
- } from "@cosmicdrift/kumiko-bundled-features/text-content";
13
- import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/text-content/seeding";
9
+ createTemplateResolverApi,
10
+ createTemplateResolverFeature,
11
+ templateResourceEntity,
12
+ } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
13
+ import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
14
14
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
15
15
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
16
  import {
@@ -42,39 +42,39 @@ const managed = createManagedPagesFeature({
42
42
  });
43
43
  const legal = createLegalPagesFeature();
44
44
  const configFeature = createConfigFeature();
45
- const textContent = createTextContentFeature();
45
+ const templateResolver = createTemplateResolverFeature();
46
46
 
47
47
  beforeAll(async () => {
48
48
  const resolver = createConfigResolver();
49
49
  stack = await setupTestStack({
50
- features: [configFeature, textContent, managed, legal, seo],
50
+ features: [configFeature, templateResolver, managed, legal, seo],
51
51
  anonymousAccess: {
52
52
  tenantExists: async (id) => id === TENANT_A || id === SYSTEM_TENANT_ID,
53
53
  },
54
54
  extraContext: ({ registry, db }) => ({
55
55
  configResolver: resolver,
56
56
  _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
57
- textContent: createTextContentApi(db),
57
+ templateResolver: createTemplateResolverApi(db),
58
58
  }),
59
59
  });
60
60
  await unsafeCreateEntityTable(stack.db, pageEntity);
61
- await unsafeCreateEntityTable(stack.db, textBlockEntity);
61
+ await unsafeCreateEntityTable(stack.db, templateResourceEntity);
62
62
  await unsafePushTables(stack.db, { configValuesTable });
63
63
  await createEventsTable(stack.db);
64
64
 
65
65
  await seedTextBlock(stack.db, {
66
66
  tenantId: SYSTEM_TENANT_ID,
67
67
  slug: "imprint",
68
- lang: "de",
68
+ locale: "de",
69
69
  title: "Impressum",
70
- body: "## Test\n\nAcme",
70
+ content: "## Test\n\nAcme",
71
71
  });
72
72
  await seedTextBlock(stack.db, {
73
73
  tenantId: SYSTEM_TENANT_ID,
74
74
  slug: "privacy",
75
- lang: "de",
75
+ locale: "de",
76
76
  title: "Datenschutz",
77
- body: "## Test\n\nAcme",
77
+ content: "## Test\n\nAcme",
78
78
  });
79
79
 
80
80
  await seedPage(stack.db, {
@@ -1,13 +1,33 @@
1
1
  export type RobotsPolicy = {
2
2
  readonly allow: boolean;
3
3
  readonly sitemapUrl?: string;
4
+ /** Emit explicit Allow rules for known AI crawlers (GPTBot, ClaudeBot,
5
+ * anthropic-ai, PerplexityBot, Google-Extended). No-op when allow:false —
6
+ * a blanket Disallow already covers every bot. */
7
+ readonly aiCrawlers?: boolean;
8
+ /** Raw value for the Content-Signal directive (contentsignals.org), e.g.
9
+ * "ai-train=no, search=yes, ai-input=yes". Emitted verbatim. */
10
+ readonly contentSignal?: string;
4
11
  };
5
12
 
13
+ const AI_CRAWLER_USER_AGENTS = [
14
+ "GPTBot",
15
+ "ClaudeBot",
16
+ "anthropic-ai",
17
+ "PerplexityBot",
18
+ "Google-Extended",
19
+ ] as const;
20
+
6
21
  // Only used when an app opts into GET /robots.txt (SeoOptions.robotsPolicy) —
7
22
  // the common case (no per-host logic) is already covered by dev-server's
8
23
  // static public/robots.txt passthrough.
9
24
  export function buildRobotsTxt(policy: RobotsPolicy): string {
10
25
  const rule = policy.allow ? "Disallow:" : "Disallow: /";
26
+ const aiCrawlerBlocks =
27
+ policy.aiCrawlers && policy.allow
28
+ ? AI_CRAWLER_USER_AGENTS.map((ua) => `\nUser-agent: ${ua}\nAllow: /`).join("")
29
+ : "";
30
+ const contentSignal = policy.contentSignal ? `\nContent-Signal: ${policy.contentSignal}` : "";
11
31
  const sitemap = policy.sitemapUrl ? `\nSitemap: ${policy.sitemapUrl}` : "";
12
- return `User-agent: *\n${rule}${sitemap}\n`;
32
+ return `User-agent: *\n${rule}${aiCrawlerBlocks}${contentSignal}${sitemap}\n`;
13
33
  }
@@ -1,6 +1,7 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import { access, defineWriteHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
5
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
5
6
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
6
7
  import { Temporal } from "temporal-polyfill";
@@ -55,6 +56,14 @@ export const revokeAllForUserWrite = defineWriteHandler({
55
56
  // parse explicitly before the raw append so a shape drift fails loudly
56
57
  // here instead of landing unvalidated on the events table.
57
58
  if (updated.length > 0) {
59
+ const eventDef = ctx.registry.getEvent(SESSION_REVOKED_EVENT_QN);
60
+ if (!eventDef) {
61
+ return writeFailure(
62
+ new UnprocessableError("session_revoked_event_not_registered", {
63
+ details: { eventQn: SESSION_REVOKED_EVENT_QN },
64
+ }),
65
+ );
66
+ }
58
67
  const payload = sessionRevokedSchema.parse({
59
68
  userId: event.payload.userId,
60
69
  sessionIds: updated.map((row) => row.id),
@@ -66,7 +75,7 @@ export const revokeAllForUserWrite = defineWriteHandler({
66
75
  tenantId: SYSTEM_TENANT_ID,
67
76
  expectedVersion: 0,
68
77
  type: SESSION_REVOKED_EVENT_QN,
69
- eventVersion: ctx.registry.getEvent(SESSION_REVOKED_EVENT_QN)?.version ?? 1,
78
+ eventVersion: eventDef.version,
70
79
  payload,
71
80
  metadata: {
72
81
  userId: event.user.id,
@@ -0,0 +1,21 @@
1
+ import { isStoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
2
+
3
+ export type StoredEnvelopeClassification = "rotate" | "current" | "unrecognized";
4
+
5
+ // Shared by every KEK-rotation job (config, auth-mfa, ...): a row whose value
6
+ // isn't a current-cipher envelope (malformed JSON, or any pre-envelope
7
+ // format) is never a supported re-encrypt input — classify it "unrecognized"
8
+ // so callers fail it loudly instead of handing it to cipher.decrypt.
9
+ export function classifyStoredEnvelope(
10
+ value: string,
11
+ targetVersion: number,
12
+ ): StoredEnvelopeClassification {
13
+ let parsed: unknown;
14
+ try {
15
+ parsed = JSON.parse(value);
16
+ } catch {
17
+ return "unrecognized";
18
+ }
19
+ if (!isStoredEnvelope(parsed)) return "unrecognized";
20
+ return parsed.kekVersion === targetVersion ? "current" : "rotate";
21
+ }
@@ -5,6 +5,10 @@ export {
5
5
  type MigrationRowOutcome,
6
6
  runChunkedMigration,
7
7
  } from "./chunked-entity-migration";
8
+ export {
9
+ classifyStoredEnvelope,
10
+ type StoredEnvelopeClassification,
11
+ } from "./classify-stored-envelope";
8
12
  export { decryptStoredPii } from "./decrypt-stored-pii";
9
13
  export { encryptForDirectWrite } from "./encrypt-for-direct-write";
10
14
  export { entitiesOf } from "./entities-of";
@@ -1,6 +1,6 @@
1
1
  # template-resolver
2
2
 
3
- Strukturierter Template-Storage mit Tenant-Override-Hierarchie, Locale-Fallback und Resource-Linking via `file-foundation`.
3
+ Strukturierter Content-Storage mit Tenant-Override-Hierarchie, Locale-Fallback und Resource-Linking via `file-foundation`. Eine Entity (`template-resource`) für alle Content-Arten — Render-Templates (`mail-html`, `document-pdf`, `notification`, `image-snapshot`), Markdown-Textblöcke (`text-block`, ex `text-content`-Feature) und AI-Prompts (`ai-prompt`).
4
4
 
5
5
  **Plan-Doc:** [`kumiko-platform/docs/plans/features/template-resolver.md`](../../../../../../kumiko-platform/docs/plans/features/template-resolver.md)
6
6
 
@@ -120,6 +120,35 @@ describe("my-mail-renderer :: template-resolver conformance", () => {
120
120
 
121
121
  The harness checks `TemplateNotFoundError` propagation, locale-fallback, and (when `resolveResources` is provided) missing resource keys.
122
122
 
123
+ ## Text-Blöcke (`kind: "text-block"`)
124
+
125
+ Marketing-, FAQ- und Legal-Texte laufen über denselben Store, aber über einen
126
+ eigenen Write-Pfad: `set.write` trägt `title` + `folder` (Content-Tree) und
127
+ kennt keinen draft-Status — Speichern veröffentlicht.
128
+
129
+ | Handler | QN | Wer | Was |
130
+ |---|---|---|---|
131
+ | `TemplateResolverHandlers.set` | `template-resolver:write:set` | TenantAdmin + SystemAdmin (via `tenantIdOverride` auch auf `SYSTEM_TENANT_ID`) | Upsert eines Text-Blocks pro `(tenantId, slug, locale)` |
132
+ | `TemplateResolverQueries.bySlug` | `template-resolver:query:by-slug` | anonymous + User + Admins | Ein Text-Block — der Public-Read für Landing-/Legal-Pages |
133
+ | `TemplateResolverQueries.byTenant` | `template-resolver:query:by-tenant` | anonymous + User + Admins | Alle Text-Blöcke eines Tenants für den Content-Tree |
134
+
135
+ Die beiden Queries sind fest auf `kind: "text-block"` verdrahtet: Mail-Templates
136
+ und AI-Prompts liegen in derselben Tabelle und dürfen nicht über den anonymen
137
+ Pfad rausfallen.
138
+
139
+ Seed-Helper: `seedTextBlock` / `seedLegalContentFromJson` aus
140
+ `@cosmicdrift/kumiko-bundled-features/template-resolver/seeding`.
141
+
142
+ Content-Tree + Editor (Desktop-Web):
143
+
144
+ ```typescript
145
+ import { textBlocksClient } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
146
+
147
+ createKumikoApp({
148
+ clientFeatures: [textBlocksClient({ navId: "myapp:nav:content", tenantId: SYSTEM_TENANT_ID })],
149
+ });
150
+ ```
151
+
123
152
  ## Out-of-Scope
124
153
 
125
154
  - Rendering (Markdown/MJML → HTML/PDF) — siehe `renderer-foundation`
@@ -7,9 +7,10 @@ import {
7
7
  type TestStack,
8
8
  unsafeCreateEntityTable,
9
9
  } from "@cosmicdrift/kumiko-framework/stack";
10
- import { createTextContentFeature } from "../feature";
10
+ import { TEXT_BLOCK_KIND } from "../constants";
11
+ import { createTemplateResolverFeature } from "../feature";
11
12
  import { type LegalContentBlock, seedLegalContentFromJson } from "../seeding";
12
- import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
13
+ import { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "../table";
13
14
 
14
15
  // Pins seedLegalContentFromJson: seeds into SYSTEM_TENANT_ID by default and
15
16
  // re-seeds with ifExists:"update" so a changed template body lands on an
@@ -19,9 +20,9 @@ let stack: TestStack;
19
20
  let db: DbConnection;
20
21
 
21
22
  beforeAll(async () => {
22
- stack = await setupTestStack({ features: [createTextContentFeature()] });
23
+ stack = await setupTestStack({ features: [createTemplateResolverFeature()] });
23
24
  db = stack.db;
24
- await unsafeCreateEntityTable(db, textBlockEntity);
25
+ await unsafeCreateEntityTable(db, templateResourceEntity);
25
26
  await createEventsTable(db);
26
27
  });
27
28
 
@@ -29,60 +30,70 @@ afterAll(async () => {
29
30
  await stack.cleanup();
30
31
  });
31
32
 
32
- function read(slug: string, lang: string) {
33
- return fetchOne<TextBlockRow>(db, textBlocksTable, { tenantId: SYSTEM_TENANT_ID, slug, lang });
33
+ function read(slug: string, locale: string) {
34
+ return fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
35
+ tenantId: SYSTEM_TENANT_ID,
36
+ slug,
37
+ kind: TEXT_BLOCK_KIND,
38
+ locale,
39
+ });
34
40
  }
35
41
 
36
42
  describe("seedLegalContentFromJson", () => {
37
43
  test("seeds all blocks into SYSTEM_TENANT_ID as SYSTEM_USER", async () => {
38
44
  const blocks: LegalContentBlock[] = [
39
- { slug: "imprint", lang: "de", title: "Impressum", body: "Marc Frost" },
40
- { slug: "imprint", lang: "en", title: "Imprint", body: "Marc Frost" },
45
+ { slug: "imprint", locale: "de", title: "Impressum", content: "Marc Frost" },
46
+ { slug: "imprint", locale: "en", title: "Imprint", content: "Marc Frost" },
41
47
  ];
42
48
  await seedLegalContentFromJson(db, blocks);
43
49
 
44
- const de = (await read("imprint", "de")) as TextBlockRow & {
50
+ const de = (await read("imprint", "de")) as TemplateResourceRow & {
45
51
  insertedById: string;
46
52
  modifiedById: string | null;
47
53
  };
48
- expect(de).toMatchObject({ title: "Impressum", body: "Marc Frost" });
54
+ expect(de).toMatchObject({ title: "Impressum", content: "Marc Frost" });
49
55
  expect(de.insertedById).toBe(SYSTEM_USER_ID);
50
- expect(await read("imprint", "en")).toMatchObject({ title: "Imprint", body: "Marc Frost" });
56
+ expect(await read("imprint", "en")).toMatchObject({ title: "Imprint", content: "Marc Frost" });
51
57
  });
52
58
 
53
59
  test("re-seed lifts an existing block to the new template state (ifExists:update)", async () => {
54
60
  const v1: LegalContentBlock[] = [
55
- { slug: "privacy", lang: "de", title: "Datenschutz", body: "v1" },
61
+ { slug: "privacy", locale: "de", title: "Datenschutz", content: "v1" },
56
62
  ];
57
63
  await seedLegalContentFromJson(db, v1);
58
- expect(await read("privacy", "de")).toMatchObject({ body: "v1" });
64
+ expect(await read("privacy", "de")).toMatchObject({ content: "v1" });
59
65
 
60
66
  const v2: LegalContentBlock[] = [
61
- { slug: "privacy", lang: "de", title: "Datenschutz", body: "v2 + Sub-Processor-Tabelle" },
67
+ {
68
+ slug: "privacy",
69
+ locale: "de",
70
+ title: "Datenschutz",
71
+ content: "v2 + Sub-Processor-Tabelle",
72
+ },
62
73
  ];
63
74
  await seedLegalContentFromJson(db, v2);
64
- const row = (await read("privacy", "de")) as TextBlockRow & { modifiedById: string };
65
- expect(row).toMatchObject({ body: "v2 + Sub-Processor-Tabelle" });
75
+ const row = (await read("privacy", "de")) as TemplateResourceRow & { modifiedById: string };
76
+ expect(row).toMatchObject({ content: "v2 + Sub-Processor-Tabelle" });
66
77
  expect(row.modifiedById).toBe(SYSTEM_USER_ID);
67
78
  });
68
79
 
69
80
  test("identical re-seed is a no-op (no update event, version/modifiedAt stable)", async () => {
70
81
  const blocks: LegalContentBlock[] = [
71
- { slug: "terms", lang: "de", title: "AGB", body: "stable" },
82
+ { slug: "terms", locale: "de", title: "AGB", content: "stable" },
72
83
  ];
73
84
  await seedLegalContentFromJson(db, blocks);
74
- const before = (await read("terms", "de")) as TextBlockRow & {
85
+ const before = (await read("terms", "de")) as TemplateResourceRow & {
75
86
  modifiedAt: unknown;
76
87
  };
77
88
  expect(before).not.toBeNull();
78
89
 
79
90
  await seedLegalContentFromJson(db, blocks);
80
- const after = (await read("terms", "de")) as TextBlockRow & { modifiedAt: unknown };
91
+ const after = (await read("terms", "de")) as TemplateResourceRow & { modifiedAt: unknown };
81
92
  expect(after.version).toBe(before.version);
82
- expect(after.body).toBe("stable");
93
+ expect(after.content).toBe("stable");
83
94
  expect(after.modifiedAt).toEqual(before.modifiedAt);
84
95
 
85
96
  const events = await selectMany(db, eventsTable, { aggregateId: String(before.id) });
86
- expect(events.map((e) => e.type)).toEqual(["text-block.created"]);
97
+ expect(events.map((e) => e.type)).toEqual(["template-resource.created"]);
87
98
  });
88
99
  });