@cosmicdrift/kumiko-bundled-features 0.229.0 → 0.230.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.230.0",
|
|
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>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.230.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.230.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.230.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.230.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.230.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.230.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.230.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.230.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -57,6 +57,8 @@ See [template-resolver/README.md](../template-resolver/README.md).
|
|
|
57
57
|
|
|
58
58
|
## Routes
|
|
59
59
|
|
|
60
|
+
**Default** (DACH: DE required, EN optional):
|
|
61
|
+
|
|
60
62
|
| Path | Slug + locale | Title fallback (when block empty) |
|
|
61
63
|
|---|---|---|
|
|
62
64
|
| `GET /legal/impressum` | `imprint` / `de` | "Impressum" |
|
|
@@ -75,22 +77,57 @@ directly and render themselves.
|
|
|
75
77
|
|
|
76
78
|
---
|
|
77
79
|
|
|
80
|
+
### Non-DACH apps: custom routes + required blocks
|
|
81
|
+
|
|
82
|
+
The default four routes and the two DE required blocks
|
|
83
|
+
(`LEGAL_ROUTES`/`LEGAL_REQUIRED_BLOCKS`) are DACH conventions, not a
|
|
84
|
+
hard constraint. An app with a different default language, or one
|
|
85
|
+
that needs an additional page (terms/AGB), passes its own lists:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
createLegalPagesFeature({
|
|
89
|
+
routes: [
|
|
90
|
+
{ path: "/legal/aviso-legal", slug: "imprint", lang: "es", titleFallback: "Aviso legal" },
|
|
91
|
+
{ path: "/legal/privacidad", slug: "privacy", lang: "es", titleFallback: "Política de privacidad" },
|
|
92
|
+
{ path: "/legal/terminos", slug: "terms", lang: "es", titleFallback: "Términos y condiciones" },
|
|
93
|
+
],
|
|
94
|
+
requiredBlocks: [{ slug: "imprint", lang: "es" }, { slug: "privacy", lang: "es" }],
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`slug` is free-form — it only has to match the `slug` you seed the
|
|
99
|
+
text-block under via `template-resolver`. `requiredBlocks` drives the
|
|
100
|
+
boot check (see below); routes not listed there stay optional and
|
|
101
|
+
just 404 until seeded, same as the DACH default.
|
|
102
|
+
|
|
103
|
+
Validated once at feature-build time (fails app startup, not a
|
|
104
|
+
request): no two routes may share a `path`, every route needs a
|
|
105
|
+
non-empty `path`/`slug`/`lang`, and every `path` must start with `/`.
|
|
106
|
+
An empty `routes` array is allowed — the boot check alone, no public
|
|
107
|
+
routes.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
78
111
|
## Boot check
|
|
79
112
|
|
|
80
|
-
`r.job` with `runOnBoot: true` checks at app start whether the
|
|
81
|
-
required blocks exist in SYSTEM_TENANT:
|
|
113
|
+
`r.job` with `runOnBoot: true` checks at app start whether the
|
|
114
|
+
required blocks exist in SYSTEM_TENANT. **Default** (DACH):
|
|
82
115
|
|
|
83
116
|
| Slug + locale | What happens when missing |
|
|
84
117
|
|---|---|
|
|
85
118
|
| `imprint` / `de` | **Production:** `throw new Error(...)` blocks app start. **Dev:** `ctx.log.warn(...)` |
|
|
86
119
|
| `privacy` / `de` | as above |
|
|
87
120
|
|
|
88
|
-
EN versions are **not** boot-fail-relevant (`LEGAL_OPTIONAL_BLOCKS`).
|
|
121
|
+
EN versions are **not** boot-fail-relevant by default (`LEGAL_OPTIONAL_BLOCKS`).
|
|
89
122
|
Routes return `404` if an EN block is missing.
|
|
90
123
|
|
|
91
|
-
→ Apps that activate the feature
|
|
92
|
-
production deploy — either via a bootstrap
|
|
93
|
-
manually via the TenantAdmin API.
|
|
124
|
+
→ Apps that activate the feature with the default config must seed
|
|
125
|
+
both DE blocks before a production deploy — either via a bootstrap
|
|
126
|
+
script (`seedTextBlock`) or manually via the TenantAdmin API.
|
|
127
|
+
|
|
128
|
+
→ Apps with a `requiredBlocks` override (see above) get the same
|
|
129
|
+
production-hard-fail/dev-warn behavior for their own slug/lang list
|
|
130
|
+
instead — the DE blocks are not checked when `requiredBlocks` is set.
|
|
94
131
|
|
|
95
132
|
---
|
|
96
133
|
|
|
@@ -328,6 +328,88 @@ describe("legal-pages :: wrapLayout erhält route.slug (alt-lang-switch-regressi
|
|
|
328
328
|
});
|
|
329
329
|
});
|
|
330
330
|
|
|
331
|
+
describe("legal-pages :: configurable routes/requiredBlocks (non-DACH apps)", () => {
|
|
332
|
+
test("custom route (es) serves the seeded Spanish text-block", async () => {
|
|
333
|
+
const esStack = await setupTestStack({
|
|
334
|
+
features: [
|
|
335
|
+
createTemplateResolverFeature(),
|
|
336
|
+
createLegalPagesFeature({
|
|
337
|
+
routes: [
|
|
338
|
+
{
|
|
339
|
+
path: "/legal/aviso-legal",
|
|
340
|
+
slug: "imprint",
|
|
341
|
+
lang: "es",
|
|
342
|
+
titleFallback: "Aviso legal",
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
requiredBlocks: [{ slug: "imprint", lang: "es" }],
|
|
346
|
+
}),
|
|
347
|
+
],
|
|
348
|
+
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
349
|
+
extraContext: ({ db }) => ({
|
|
350
|
+
templateResolver: createTemplateResolverApi(db),
|
|
351
|
+
}),
|
|
352
|
+
});
|
|
353
|
+
try {
|
|
354
|
+
await unsafeCreateEntityTable(esStack.db, templateResourceEntity);
|
|
355
|
+
await createEventsTable(esStack.db);
|
|
356
|
+
await seedTextBlock(esStack.db, {
|
|
357
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
358
|
+
slug: "imprint",
|
|
359
|
+
locale: "es",
|
|
360
|
+
title: "Aviso legal",
|
|
361
|
+
content: "## Datos del titular\n\n**Marc Frost**",
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const res = await esStack.app.request("/legal/aviso-legal");
|
|
365
|
+
expect(res.status).toBe(200);
|
|
366
|
+
const body = await res.text();
|
|
367
|
+
expect(body).toContain('lang="es"');
|
|
368
|
+
expect(body).toContain("<title>Aviso legal</title>");
|
|
369
|
+
expect(body).toContain("Datos del titular");
|
|
370
|
+
|
|
371
|
+
// Default DACH routes are gone once `routes` is overridden.
|
|
372
|
+
const dachRes = await esStack.app.request("/legal/impressum");
|
|
373
|
+
expect(dachRes.status).toBe(404);
|
|
374
|
+
} finally {
|
|
375
|
+
await esStack.cleanup();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("createLegalPagesFeature throws on duplicate route path", () => {
|
|
380
|
+
expect(() =>
|
|
381
|
+
createLegalPagesFeature({
|
|
382
|
+
routes: [
|
|
383
|
+
{ path: "/legal/aviso-legal", slug: "imprint", lang: "es", titleFallback: "Aviso" },
|
|
384
|
+
{ path: "/legal/aviso-legal", slug: "privacy", lang: "es", titleFallback: "Privacidad" },
|
|
385
|
+
],
|
|
386
|
+
}),
|
|
387
|
+
).toThrow(/duplicate route path/);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("createLegalPagesFeature throws on empty path/slug/lang", () => {
|
|
391
|
+
expect(() =>
|
|
392
|
+
createLegalPagesFeature({
|
|
393
|
+
routes: [{ path: "", slug: "imprint", lang: "es", titleFallback: "Aviso" }],
|
|
394
|
+
}),
|
|
395
|
+
).toThrow(/missing a required field/);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("createLegalPagesFeature throws when path doesn't start with /", () => {
|
|
399
|
+
expect(() =>
|
|
400
|
+
createLegalPagesFeature({
|
|
401
|
+
routes: [
|
|
402
|
+
{ path: "legal/aviso-legal", slug: "imprint", lang: "es", titleFallback: "Aviso" },
|
|
403
|
+
],
|
|
404
|
+
}),
|
|
405
|
+
).toThrow(/must start with/);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("empty routes array is allowed (boot-check only, no public routes)", () => {
|
|
409
|
+
expect(() => createLegalPagesFeature({ routes: [] })).not.toThrow();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
331
413
|
describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
|
|
332
414
|
// Direkter Test der Boot-Check-Logik mit constructed ctx-Objects —
|
|
333
415
|
// keine JobRunner-Coupling, keine Test-Stacks. Das ist die echte
|
|
@@ -471,4 +553,98 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
|
|
|
471
553
|
expect(call.tenantId).toBe(SYSTEM_TENANT_ID);
|
|
472
554
|
}
|
|
473
555
|
});
|
|
556
|
+
|
|
557
|
+
test("custom requiredBlock (es privacy) fehlt → throws in production", async () => {
|
|
558
|
+
const { api } = fakeTemplateResolver([
|
|
559
|
+
{ slug: "imprint", lang: "es", title: "I", content: "x" },
|
|
560
|
+
]);
|
|
561
|
+
const originalEnv = process.env["NODE_ENV"];
|
|
562
|
+
process.env["NODE_ENV"] = "production";
|
|
563
|
+
try {
|
|
564
|
+
await expect(
|
|
565
|
+
runLegalPagesBootCheck({ templateResolver: api }, [
|
|
566
|
+
{ slug: "imprint", lang: "es" },
|
|
567
|
+
{ slug: "privacy", lang: "es" },
|
|
568
|
+
]),
|
|
569
|
+
).rejects.toThrow(/Boot-Validation failed.*privacy\/es/s);
|
|
570
|
+
} finally {
|
|
571
|
+
if (originalEnv === undefined) delete process.env["NODE_ENV"];
|
|
572
|
+
else process.env["NODE_ENV"] = originalEnv;
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("custom requiredBlock (es privacy) vorhanden → kein throw", async () => {
|
|
577
|
+
const { api } = fakeTemplateResolver([
|
|
578
|
+
{ slug: "imprint", lang: "es", title: "I", content: "x" },
|
|
579
|
+
{ slug: "privacy", lang: "es", title: "P", content: "x" },
|
|
580
|
+
]);
|
|
581
|
+
const originalEnv = process.env["NODE_ENV"];
|
|
582
|
+
process.env["NODE_ENV"] = "production";
|
|
583
|
+
try {
|
|
584
|
+
await expect(
|
|
585
|
+
runLegalPagesBootCheck({ templateResolver: api }, [
|
|
586
|
+
{ slug: "imprint", lang: "es" },
|
|
587
|
+
{ slug: "privacy", lang: "es" },
|
|
588
|
+
]),
|
|
589
|
+
).resolves.toBeUndefined();
|
|
590
|
+
} finally {
|
|
591
|
+
if (originalEnv === undefined) delete process.env["NODE_ENV"];
|
|
592
|
+
else process.env["NODE_ENV"] = originalEnv;
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("DE-Blocks fehlen, custom requiredBlocks (es) konfiguriert + geseedet → kein Fail", async () => {
|
|
597
|
+
// Proves the boot check only looks at the configured requiredBlocks,
|
|
598
|
+
// not hardcoded LEGAL_REQUIRED_BLOCKS (DE) — required for apps with a
|
|
599
|
+
// default language other than DE.
|
|
600
|
+
const { api } = fakeTemplateResolver([
|
|
601
|
+
{ slug: "imprint", lang: "es", title: "I", content: "x" },
|
|
602
|
+
{ slug: "privacy", lang: "es", title: "P", content: "x" },
|
|
603
|
+
]);
|
|
604
|
+
const originalEnv = process.env["NODE_ENV"];
|
|
605
|
+
process.env["NODE_ENV"] = "production";
|
|
606
|
+
try {
|
|
607
|
+
await expect(
|
|
608
|
+
runLegalPagesBootCheck({ templateResolver: api }, [
|
|
609
|
+
{ slug: "imprint", lang: "es" },
|
|
610
|
+
{ slug: "privacy", lang: "es" },
|
|
611
|
+
]),
|
|
612
|
+
).resolves.toBeUndefined();
|
|
613
|
+
} finally {
|
|
614
|
+
if (originalEnv === undefined) delete process.env["NODE_ENV"];
|
|
615
|
+
else process.env["NODE_ENV"] = originalEnv;
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("createLegalPagesFeature threads requiredBlocks into the boot-check job, not the DACH default", async () => {
|
|
620
|
+
// Goes through the actual job registered by createLegalPagesFeature
|
|
621
|
+
// (not a direct runLegalPagesBootCheck call, unlike the tests above) —
|
|
622
|
+
// proves the `requiredBlocks` option reaches the job closure. DE blocks
|
|
623
|
+
// are seeded but the feature is built with an es-only requiredBlocks
|
|
624
|
+
// list: if the job still checked the hardcoded LEGAL_REQUIRED_BLOCKS
|
|
625
|
+
// (DE), this would resolve instead of rejecting on the missing es block.
|
|
626
|
+
const { api } = fakeTemplateResolver([
|
|
627
|
+
{ slug: "imprint", lang: "de", title: "I", content: "x" },
|
|
628
|
+
{ slug: "privacy", lang: "de", title: "P", content: "x" },
|
|
629
|
+
]);
|
|
630
|
+
const feature = createLegalPagesFeature({
|
|
631
|
+
requiredBlocks: [{ slug: "privacy", lang: "es" }],
|
|
632
|
+
});
|
|
633
|
+
const job = feature.jobs["legal-pages-boot-check"];
|
|
634
|
+
if (!job) throw new Error("legal-pages-boot-check job not registered");
|
|
635
|
+
// @cast-boundary test-only — job.handler is typed against the full
|
|
636
|
+
// JobContext; runLegalPagesBootCheck only reads templateResolver off it.
|
|
637
|
+
const handler = job.handler as unknown as (
|
|
638
|
+
payload: Record<string, unknown>,
|
|
639
|
+
ctx: { templateResolver: TemplateResolverApi },
|
|
640
|
+
) => Promise<void>;
|
|
641
|
+
const originalEnv = process.env["NODE_ENV"];
|
|
642
|
+
process.env["NODE_ENV"] = "production";
|
|
643
|
+
try {
|
|
644
|
+
await expect(handler({}, { templateResolver: api })).rejects.toThrow(/privacy\/es/);
|
|
645
|
+
} finally {
|
|
646
|
+
if (originalEnv === undefined) delete process.env["NODE_ENV"];
|
|
647
|
+
else process.env["NODE_ENV"] = originalEnv;
|
|
648
|
+
}
|
|
649
|
+
});
|
|
474
650
|
});
|
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
// Feature name
|
|
3
3
|
export const LEGAL_PAGES_FEATURE = "legal-pages" as const;
|
|
4
4
|
|
|
5
|
+
// Shape of one public route entry — the type behind LEGAL_ROUTES, exported
|
|
6
|
+
// so non-DACH apps can type their own `routes` option (e.g. a Spanish-
|
|
7
|
+
// default app with an additional AGB/terms page).
|
|
8
|
+
export type LegalPageRoute = {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly slug: string;
|
|
11
|
+
readonly lang: string;
|
|
12
|
+
readonly titleFallback: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Shape of one boot-check-required block — the type behind
|
|
16
|
+
// LEGAL_REQUIRED_BLOCKS, exported for the same reason as LegalPageRoute.
|
|
17
|
+
export type LegalRequiredBlock = {
|
|
18
|
+
readonly slug: string;
|
|
19
|
+
readonly lang: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
5
22
|
// Required slugs that must exist as text-blocks for production-boot.
|
|
6
23
|
// Pro Sprache + Slug eine Pflicht-Kombo. Wer mehr Sprachen will, ergänzt
|
|
7
24
|
// die Liste — Boot-Check wird dynamisch aus der Liste generiert.
|
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
SYSTEM_TENANT_ID,
|
|
11
11
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
import { cachedSecurePageResponse } from "../page-render";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
LEGAL_REQUIRED_BLOCKS,
|
|
15
|
+
LEGAL_ROUTES,
|
|
16
|
+
type LegalPageRoute,
|
|
17
|
+
type LegalRequiredBlock,
|
|
18
|
+
} from "./constants";
|
|
14
19
|
import { renderMarkdownToHtml, wrapInLayout } from "./markdown";
|
|
15
20
|
|
|
16
21
|
// QN-Konstante als dokumentierter Public-Contract des template-resolver-
|
|
@@ -32,11 +37,13 @@ type TextBlockQueryResult = {
|
|
|
32
37
|
// 60s-shared-cache saves the origin-revalidate roundtrip; legal-content edits are live within 60s.
|
|
33
38
|
const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
|
|
34
39
|
|
|
35
|
-
// legal-pages —
|
|
36
|
-
//
|
|
37
|
-
// /legal/datenschutz, /legal/imprint, /legal/privacy)
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
+
// legal-pages — opt-in wrapper around template-resolver (kind text-block).
|
|
41
|
+
// Default config ships four DACH public HTML routes (/legal/impressum,
|
|
42
|
+
// /legal/datenschutz, /legal/imprint, /legal/privacy) with Markdown-to-HTML
|
|
43
|
+
// rendering and a boot check that hard-fails in production when the DE
|
|
44
|
+
// required blocks aren't seeded. `routes`/`requiredBlocks` are configurable
|
|
45
|
+
// (see LegalPagesOptions) for apps with a different default language or
|
|
46
|
+
// additional pages (e.g. terms/AGB).
|
|
40
47
|
//
|
|
41
48
|
// Cross-Feature-Decoupling:
|
|
42
49
|
// • Routes nutzen app.fetch zu "/api/query" mit dem QN-string
|
|
@@ -55,7 +62,7 @@ export type LegalPagesWrapLayout = (opts: {
|
|
|
55
62
|
readonly title: string;
|
|
56
63
|
readonly bodyHtml: string;
|
|
57
64
|
readonly lang: string;
|
|
58
|
-
readonly slug?:
|
|
65
|
+
readonly slug?: string;
|
|
59
66
|
}) => string;
|
|
60
67
|
|
|
61
68
|
export type LegalPagesOptions = {
|
|
@@ -64,13 +71,48 @@ export type LegalPagesOptions = {
|
|
|
64
71
|
* Marketing-Layout (Header/Footer/Theme) auch um Legal-Body legen
|
|
65
72
|
* wollen, übergeben hier ihre Render-Function. */
|
|
66
73
|
readonly wrapLayout?: LegalPagesWrapLayout;
|
|
74
|
+
/** Public HTML routes to register. Default: LEGAL_ROUTES (DACH: DE+EN
|
|
75
|
+
* imprint/privacy). Non-DACH apps (other default language, additional
|
|
76
|
+
* pages like terms/AGB) pass their own list instead. Validated at
|
|
77
|
+
* feature-build time: no duplicate `path`, no empty field, every `path`
|
|
78
|
+
* starts with "/". An empty array is allowed (boot-check only, no
|
|
79
|
+
* public routes). */
|
|
80
|
+
readonly routes?: readonly LegalPageRoute[];
|
|
81
|
+
/** Slug+lang combos the boot check requires to be seeded in
|
|
82
|
+
* SYSTEM_TENANT. Default: LEGAL_REQUIRED_BLOCKS (`imprint/de`,
|
|
83
|
+
* `privacy/de`). Apps whose routes use a different slug/lang set
|
|
84
|
+
* (e.g. Spanish-default) pass their own list here. */
|
|
85
|
+
readonly requiredBlocks?: readonly LegalRequiredBlock[];
|
|
67
86
|
};
|
|
68
87
|
|
|
88
|
+
// Validated once at feature-build time (not per-request) so a config typo
|
|
89
|
+
// fails app startup instead of surfacing as a confusing 404 later.
|
|
90
|
+
function validateRoutes(routes: readonly LegalPageRoute[]): void {
|
|
91
|
+
const seenPaths = new Set<string>();
|
|
92
|
+
for (const route of routes) {
|
|
93
|
+
if (!route.path || !route.slug || !route.lang) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`legal-pages: route is missing a required field (path/slug/lang): ${JSON.stringify(route)}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!route.path.startsWith("/")) {
|
|
99
|
+
throw new Error(`legal-pages: route path must start with "/", got "${route.path}"`);
|
|
100
|
+
}
|
|
101
|
+
if (seenPaths.has(route.path)) {
|
|
102
|
+
throw new Error(`legal-pages: duplicate route path "${route.path}"`);
|
|
103
|
+
}
|
|
104
|
+
seenPaths.add(route.path);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
69
108
|
export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
|
|
70
109
|
const wrapLayout = opts.wrapLayout ?? wrapInLayout;
|
|
110
|
+
const routes = opts.routes ?? LEGAL_ROUTES;
|
|
111
|
+
const requiredBlocks = opts.requiredBlocks ?? LEGAL_REQUIRED_BLOCKS;
|
|
112
|
+
validateRoutes(routes);
|
|
71
113
|
return defineFeature("legal-pages", (r) => {
|
|
72
114
|
r.describe(
|
|
73
|
-
"Opt-in wrapper around `template-resolver` text-blocks that registers
|
|
115
|
+
"Opt-in wrapper around `template-resolver` text-blocks that registers public HTML routes (default: `/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 blocks (default: `imprint/de`, `privacy/de`) are not seeded in `SYSTEM_TENANT`. Both are configurable via `routes`/`requiredBlocks` for apps with a different default language or additional pages. 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.",
|
|
74
116
|
);
|
|
75
117
|
r.uiHints({
|
|
76
118
|
displayLabel: "Legal Pages",
|
|
@@ -79,8 +121,8 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
79
121
|
});
|
|
80
122
|
r.requires("template-resolver");
|
|
81
123
|
|
|
82
|
-
// 4
|
|
83
|
-
for (const route of
|
|
124
|
+
// Public HTML routes (default: 4 DACH routes, configurable via opts.routes)
|
|
125
|
+
for (const route of routes) {
|
|
84
126
|
r.httpRoute({
|
|
85
127
|
method: "GET",
|
|
86
128
|
path: route.path,
|
|
@@ -169,7 +211,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
169
211
|
runOnBoot: true,
|
|
170
212
|
runIn: "api",
|
|
171
213
|
},
|
|
172
|
-
async (_payload, ctx) => runLegalPagesBootCheck(ctx),
|
|
214
|
+
async (_payload, ctx) => runLegalPagesBootCheck(ctx, requiredBlocks),
|
|
173
215
|
);
|
|
174
216
|
|
|
175
217
|
return {};
|
|
@@ -192,14 +234,17 @@ export type LegalPagesBootCheckCtx = {
|
|
|
192
234
|
// nicht gewired ist (Hinweis auf fehlenden extraContext). Wirft Error
|
|
193
235
|
// in NODE_ENV=production wenn Pflicht-Blocks fehlen, sonst log.warn.
|
|
194
236
|
// Logged log.info wenn alles vorhanden ist (kein silent-skip).
|
|
195
|
-
export async function runLegalPagesBootCheck(
|
|
237
|
+
export async function runLegalPagesBootCheck(
|
|
238
|
+
ctx: LegalPagesBootCheckCtx,
|
|
239
|
+
requiredBlocks: readonly LegalRequiredBlock[] = LEGAL_REQUIRED_BLOCKS,
|
|
240
|
+
): Promise<void> {
|
|
196
241
|
const templateResolver: TemplateResolverApi = requireTemplateResolver(
|
|
197
242
|
ctx,
|
|
198
243
|
"legal-pages-boot-check",
|
|
199
244
|
);
|
|
200
245
|
const missing: { slug: string; lang: string }[] = [];
|
|
201
246
|
|
|
202
|
-
for (const required of
|
|
247
|
+
for (const required of requiredBlocks) {
|
|
203
248
|
const block = await templateResolver.findExact({
|
|
204
249
|
tenantId: SYSTEM_TENANT_ID,
|
|
205
250
|
slug: required.slug,
|