@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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
23
|
+
const TEXT_BLOCK_BY_SLUG_QN = "template-resolver:query:by-slug";
|
|
23
24
|
|
|
24
|
-
// Return
|
|
25
|
-
type TextBlockQueryResult = {
|
|
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-
|
|
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
|
-
// `
|
|
39
|
-
// • Boot-Check nutzt ctx.
|
|
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 (
|
|
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: {
|
|
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 `
|
|
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("
|
|
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
|
|
100
|
+
// (siehe template-resolver/handlers/by-slug.query.ts) vertraut.
|
|
96
101
|
data = (await systemQuery(
|
|
97
|
-
|
|
98
|
-
{ slug: route.slug,
|
|
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-
|
|
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?.
|
|
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.
|
|
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.
|
|
153
|
-
// zu requireConfigResolver in config). App-Bootstrap muss
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
203
|
+
const block = await templateResolver.findExact({
|
|
196
204
|
tenantId: SYSTEM_TENANT_ID,
|
|
197
205
|
slug: required.slug,
|
|
198
|
-
|
|
206
|
+
kind: TEXT_BLOCK_KIND,
|
|
207
|
+
locale: required.lang,
|
|
199
208
|
});
|
|
200
|
-
if (!block?.
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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: "
|
|
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: "
|
|
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
|
-
/**
|
|
10
|
-
*
|
|
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "@cosmicdrift/kumiko-bundled-features/
|
|
13
|
-
import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/
|
|
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
|
|
45
|
+
const templateResolver = createTemplateResolverFeature();
|
|
46
46
|
|
|
47
47
|
beforeAll(async () => {
|
|
48
48
|
const resolver = createConfigResolver();
|
|
49
49
|
stack = await setupTestStack({
|
|
50
|
-
features: [configFeature,
|
|
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
|
-
|
|
57
|
+
templateResolver: createTemplateResolverApi(db),
|
|
58
58
|
}),
|
|
59
59
|
});
|
|
60
60
|
await unsafeCreateEntityTable(stack.db, pageEntity);
|
|
61
|
-
await unsafeCreateEntityTable(stack.db,
|
|
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
|
-
|
|
68
|
+
locale: "de",
|
|
69
69
|
title: "Impressum",
|
|
70
|
-
|
|
70
|
+
content: "## Test\n\nAcme",
|
|
71
71
|
});
|
|
72
72
|
await seedTextBlock(stack.db, {
|
|
73
73
|
tenantId: SYSTEM_TENANT_ID,
|
|
74
74
|
slug: "privacy",
|
|
75
|
-
|
|
75
|
+
locale: "de",
|
|
76
76
|
title: "Datenschutz",
|
|
77
|
-
|
|
77
|
+
content: "## Test\n\nAcme",
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
await seedPage(stack.db, {
|
package/src/seo/robots-txt.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -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
|
|
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`
|
package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts
RENAMED
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
type TestStack,
|
|
8
8
|
unsafeCreateEntityTable,
|
|
9
9
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
10
|
-
import {
|
|
10
|
+
import { TEXT_BLOCK_KIND } from "../constants";
|
|
11
|
+
import { createTemplateResolverFeature } from "../feature";
|
|
11
12
|
import { type LegalContentBlock, seedLegalContentFromJson } from "../seeding";
|
|
12
|
-
import { type
|
|
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: [
|
|
23
|
+
stack = await setupTestStack({ features: [createTemplateResolverFeature()] });
|
|
23
24
|
db = stack.db;
|
|
24
|
-
await unsafeCreateEntityTable(db,
|
|
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,
|
|
33
|
-
return fetchOne<
|
|
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",
|
|
40
|
-
{ slug: "imprint",
|
|
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
|
|
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",
|
|
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",
|
|
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",
|
|
61
|
+
{ slug: "privacy", locale: "de", title: "Datenschutz", content: "v1" },
|
|
56
62
|
];
|
|
57
63
|
await seedLegalContentFromJson(db, v1);
|
|
58
|
-
expect(await read("privacy", "de")).toMatchObject({
|
|
64
|
+
expect(await read("privacy", "de")).toMatchObject({ content: "v1" });
|
|
59
65
|
|
|
60
66
|
const v2: LegalContentBlock[] = [
|
|
61
|
-
{
|
|
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
|
|
65
|
-
expect(row).toMatchObject({
|
|
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",
|
|
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
|
|
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
|
|
91
|
+
const after = (await read("terms", "de")) as TemplateResourceRow & { modifiedAt: unknown };
|
|
81
92
|
expect(after.version).toBe(before.version);
|
|
82
|
-
expect(after.
|
|
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(["
|
|
97
|
+
expect(events.map((e) => e.type)).toEqual(["template-resource.created"]);
|
|
87
98
|
});
|
|
88
99
|
});
|