@cosmicdrift/kumiko-bundled-features 0.50.0 → 0.52.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 -6
- package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
- package/src/config/__tests__/config.integration.test.ts +60 -0
- package/src/config/feature.ts +5 -2
- package/src/config/handlers/cascade.query.ts +4 -1
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/config/handlers/reset.write.ts +23 -2
- package/src/config/handlers/set.write.ts +36 -2
- package/src/config/handlers/values.query.ts +5 -1
- package/src/config/resolver.ts +93 -3
- package/src/config/write-helpers.ts +37 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
- package/src/jobs/feature.ts +13 -0
- package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
- package/src/legal-pages/README.md +16 -13
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
- package/src/legal-pages/feature.ts +9 -4
- package/src/legal-pages/markdown.ts +6 -56
- package/src/legal-pages/security-headers.ts +1 -0
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
- package/src/managed-pages/branding.ts +142 -0
- package/src/managed-pages/css-gate.ts +24 -0
- package/src/managed-pages/feature.ts +246 -0
- package/src/managed-pages/handlers/branding.query.ts +30 -0
- package/src/managed-pages/handlers/by-slug.query.ts +35 -0
- package/src/managed-pages/handlers/set.write.ts +113 -0
- package/src/managed-pages/index.ts +30 -0
- package/src/managed-pages/screens/branding-screen.ts +85 -0
- package/src/managed-pages/screens/page-screens.ts +82 -0
- package/src/managed-pages/seeding.ts +99 -0
- package/src/managed-pages/table.ts +58 -0
- package/src/page-render/__tests__/branding.test.ts +57 -0
- package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
- package/src/page-render/__tests__/markdown.test.ts +41 -0
- package/src/page-render/branding.ts +99 -0
- package/src/page-render/css-sanitize.ts +344 -0
- package/src/page-render/index.ts +13 -0
- package/src/page-render/layout.ts +100 -0
- package/src/page-render/markdown.ts +39 -0
- package/src/page-render/security-headers.ts +16 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +3 -2
- package/src/subscription-stripe/__tests__/runtime.test.ts +12 -10
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +24 -12
- package/src/subscription-stripe/constants.ts +6 -5
- package/src/subscription-stripe/feature.ts +69 -50
- package/src/subscription-stripe/runtime.ts +29 -14
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
17
|
import {
|
|
18
18
|
AccessDeniedError,
|
|
19
|
+
InternalError,
|
|
19
20
|
type KumikoError,
|
|
20
21
|
NotFoundError,
|
|
21
22
|
UnprocessableError,
|
|
@@ -262,3 +263,39 @@ export function validateBounds(
|
|
|
262
263
|
|
|
263
264
|
return null;
|
|
264
265
|
}
|
|
266
|
+
|
|
267
|
+
// Regex enforcement for text config keys (keyDef.pattern). Hard-reject on
|
|
268
|
+
// mismatch (same posture as validateBounds — never silent-coerce). The value
|
|
269
|
+
// is tenant-supplied, so this is the write-side gate behind the configEdit
|
|
270
|
+
// screen (which dispatches config:write:set per key). A malformed author-
|
|
271
|
+
// supplied regex is surfaced as an InternalError instead of throwing
|
|
272
|
+
// unhandled in the write path.
|
|
273
|
+
export function validatePattern(
|
|
274
|
+
value: string | number | boolean,
|
|
275
|
+
keyDef: ConfigKeyDefinition,
|
|
276
|
+
): KumikoError | null {
|
|
277
|
+
if (keyDef.type !== "text" || !keyDef.pattern) return null;
|
|
278
|
+
// skip: validateType runs first and guarantees a string for type==="text"
|
|
279
|
+
if (typeof value !== "string") return null;
|
|
280
|
+
|
|
281
|
+
let re: RegExp;
|
|
282
|
+
try {
|
|
283
|
+
re = new RegExp(keyDef.pattern.regex, keyDef.pattern.flags);
|
|
284
|
+
} catch {
|
|
285
|
+
return new InternalError({
|
|
286
|
+
message: `config key pattern is not a valid RegExp: ${keyDef.pattern.regex}`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (re.test(value)) return null;
|
|
290
|
+
|
|
291
|
+
return new ValidationError({
|
|
292
|
+
fields: [
|
|
293
|
+
{
|
|
294
|
+
path: "value",
|
|
295
|
+
code: "invalid_format",
|
|
296
|
+
i18nKey: "errors.validation.invalid_format",
|
|
297
|
+
params: { value, pattern: keyDef.pattern.regex },
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// #362: das `jobs`-Feature registriert den framework-eigenen Single-Run-Job
|
|
2
|
+
// `jobs:job:projection-rebuild`. Sobald jobs komponiert ist, dispatcht
|
|
3
|
+
// `enqueueProjectionRebuild` einen getrackten, retrybaren Rebuild über BullMQ;
|
|
4
|
+
// der Worker ruft `rebuildProjection`. Ohne jobs fällt der Helper auf einen
|
|
5
|
+
// inline-Rebuild zurück (in migrations/__tests__/pending-rebuilds.* abgedeckt).
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
asRawClient,
|
|
10
|
+
buildEntityTable,
|
|
11
|
+
createEventStoreExecutor,
|
|
12
|
+
createTenantDb,
|
|
13
|
+
type DbConnection,
|
|
14
|
+
integer,
|
|
15
|
+
table as pgTable,
|
|
16
|
+
selectMany,
|
|
17
|
+
type TenantDb,
|
|
18
|
+
uuid,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
20
|
+
import {
|
|
21
|
+
createEntity,
|
|
22
|
+
createRegistry,
|
|
23
|
+
createTextField,
|
|
24
|
+
defineApply,
|
|
25
|
+
defineFeature,
|
|
26
|
+
type ProjectionDefinition,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
29
|
+
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
30
|
+
import {
|
|
31
|
+
enqueueProjectionRebuild,
|
|
32
|
+
PROJECTION_REBUILD_JOB,
|
|
33
|
+
} from "@cosmicdrift/kumiko-framework/migrations";
|
|
34
|
+
import { createProjectionStateTable } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
35
|
+
import {
|
|
36
|
+
createTestDb,
|
|
37
|
+
createTestRedis,
|
|
38
|
+
type TestDb,
|
|
39
|
+
type TestRedis,
|
|
40
|
+
TestUsers,
|
|
41
|
+
unsafeCreateEntityTable,
|
|
42
|
+
unsafePushTables,
|
|
43
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
44
|
+
import { sleep } from "@cosmicdrift/kumiko-framework/testing";
|
|
45
|
+
import { createJobsFeature } from "../feature";
|
|
46
|
+
import { createJobRunLogger } from "../job-run-logger";
|
|
47
|
+
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
48
|
+
|
|
49
|
+
const itemEntity = createEntity({
|
|
50
|
+
table: "read_rebuild_items",
|
|
51
|
+
fields: {
|
|
52
|
+
groupId: createTextField({ required: true }),
|
|
53
|
+
name: createTextField({ required: true }),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const itemTable = buildEntityTable("rebuild-item", itemEntity);
|
|
57
|
+
const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "rebuild-item" });
|
|
58
|
+
|
|
59
|
+
const countsTable = pgTable("read_rebuild_counts", {
|
|
60
|
+
groupId: uuid("group_id").primaryKey(),
|
|
61
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
62
|
+
itemCount: integer("item_count").notNull().default(0),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Eigene (explizite) Projektion — der Executor füllt sie NICHT live, sie wird
|
|
66
|
+
// ausschließlich vom Rebuild materialisiert. Count==2 nach dem Job beweist also
|
|
67
|
+
// den Replay, nicht den Live-Write.
|
|
68
|
+
const countsProjection: ProjectionDefinition = {
|
|
69
|
+
name: "rebuild-counts",
|
|
70
|
+
source: "rebuild-item",
|
|
71
|
+
table: countsTable,
|
|
72
|
+
apply: {
|
|
73
|
+
"rebuild-item.created": defineApply<{ groupId: string }>(async (event, tx) => {
|
|
74
|
+
await asRawClient(tx).unsafe(
|
|
75
|
+
`INSERT INTO "read_rebuild_counts" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1)
|
|
76
|
+
ON CONFLICT (group_id) DO UPDATE SET item_count = read_rebuild_counts.item_count + 1`,
|
|
77
|
+
[event.payload.groupId, event.tenantId],
|
|
78
|
+
);
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const PROJECTION = "rebuildtest:projection:rebuild-counts";
|
|
84
|
+
const GROUP = "00000000-0000-4000-8000-000000000001";
|
|
85
|
+
|
|
86
|
+
const appFeature = defineFeature("rebuildtest", (r) => {
|
|
87
|
+
r.entity("rebuild-item", itemEntity);
|
|
88
|
+
r.projection(countsProjection);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const admin = TestUsers.admin;
|
|
92
|
+
const registry = createRegistry([appFeature, createJobsFeature()]);
|
|
93
|
+
|
|
94
|
+
let testDb: TestDb;
|
|
95
|
+
let testRedis: TestRedis;
|
|
96
|
+
let db: DbConnection;
|
|
97
|
+
let tdb: TenantDb;
|
|
98
|
+
let jobRunner: JobRunner;
|
|
99
|
+
|
|
100
|
+
beforeAll(async () => {
|
|
101
|
+
testDb = await createTestDb();
|
|
102
|
+
testRedis = await createTestRedis();
|
|
103
|
+
db = testDb.db;
|
|
104
|
+
|
|
105
|
+
await unsafeCreateEntityTable(db, itemEntity, "rebuild-item");
|
|
106
|
+
await createEventsTable(db);
|
|
107
|
+
await createProjectionStateTable(db);
|
|
108
|
+
await unsafePushTables(db, { readRebuildCounts: countsTable, jobRunsTable, jobRunLogsTable });
|
|
109
|
+
tdb = createTenantDb(db, admin.tenantId);
|
|
110
|
+
|
|
111
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
112
|
+
const logger = createJobRunLogger({ db, registry });
|
|
113
|
+
jobRunner = createJobRunner({
|
|
114
|
+
registry,
|
|
115
|
+
context: { db },
|
|
116
|
+
redisUrl,
|
|
117
|
+
consumerLane: "worker",
|
|
118
|
+
queueNamePrefix: `kumiko-projrebuild-test-${Date.now()}`,
|
|
119
|
+
...logger,
|
|
120
|
+
});
|
|
121
|
+
await jobRunner.start();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterAll(async () => {
|
|
125
|
+
await jobRunner.stop();
|
|
126
|
+
await testDb.cleanup();
|
|
127
|
+
await testRedis.cleanup();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
async function getCount(): Promise<number | undefined> {
|
|
131
|
+
const [row] = await selectMany<{ itemCount: number }>(db, countsTable, { groupId: GROUP });
|
|
132
|
+
return row?.itemCount;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe("projection-rebuild job (jobs feature composed)", () => {
|
|
136
|
+
test("jobs feature registers the framework rebuild job under its qualified name", () => {
|
|
137
|
+
expect(registry.getJob(PROJECTION_REBUILD_JOB)).toBeDefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("enqueueProjectionRebuild dispatches a tracked job that refills the projection", async () => {
|
|
141
|
+
await executor.create({ groupId: GROUP, name: "a" }, admin, tdb);
|
|
142
|
+
await executor.create({ groupId: GROUP, name: "b" }, admin, tdb);
|
|
143
|
+
// Live executor füllt die explizite Projektion nicht — Rebuild ist der einzige Weg.
|
|
144
|
+
expect(await getCount()).toBeUndefined();
|
|
145
|
+
|
|
146
|
+
const outcome = await enqueueProjectionRebuild(PROJECTION, { db, registry, jobRunner });
|
|
147
|
+
expect(outcome.mode).toBe("dispatched");
|
|
148
|
+
if (outcome.mode === "dispatched") {
|
|
149
|
+
expect(outcome.bullJobId).toBeTruthy();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Poll until the worker drained the queue and the rebuild refilled.
|
|
153
|
+
for (let i = 0; i < 40 && (await getCount()) !== 2; i++) await sleep(200);
|
|
154
|
+
expect(await getCount()).toBe(2);
|
|
155
|
+
|
|
156
|
+
const runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
|
|
157
|
+
jobName: PROJECTION_REBUILD_JOB,
|
|
158
|
+
});
|
|
159
|
+
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
160
|
+
expect(runs.some((r) => r.status === "completed")).toBe(true);
|
|
161
|
+
}, 30000);
|
|
162
|
+
});
|
package/src/jobs/feature.ts
CHANGED
|
@@ -12,6 +12,10 @@ import type { z } from "zod";
|
|
|
12
12
|
import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events";
|
|
13
13
|
import { detailQuery } from "./handlers/detail.query";
|
|
14
14
|
import { listQuery } from "./handlers/list.query";
|
|
15
|
+
import {
|
|
16
|
+
projectionRebuildJob,
|
|
17
|
+
projectionRebuildPayloadSchema,
|
|
18
|
+
} from "./handlers/projection-rebuild.job";
|
|
15
19
|
import { retryWrite } from "./handlers/retry.write";
|
|
16
20
|
import { triggerWrite } from "./handlers/trigger.write";
|
|
17
21
|
import {
|
|
@@ -149,6 +153,15 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
149
153
|
},
|
|
150
154
|
});
|
|
151
155
|
|
|
156
|
+
// Framework-provided single-run rebuild job (QN `jobs:job:projection-rebuild`).
|
|
157
|
+
// Available whenever `jobs` is composed — `enqueueProjectionRebuild` dispatches
|
|
158
|
+
// it; every run is tracked in read_job_runs + retryable via jobs:write:retry.
|
|
159
|
+
r.job(
|
|
160
|
+
"projectionRebuild",
|
|
161
|
+
{ trigger: { manual: true }, schema: projectionRebuildPayloadSchema },
|
|
162
|
+
projectionRebuildJob,
|
|
163
|
+
);
|
|
164
|
+
|
|
152
165
|
const handlers = {
|
|
153
166
|
trigger: r.writeHandler(triggerWrite),
|
|
154
167
|
retry: r.writeHandler(retryWrite),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Single-run projection-rebuild worker (QN `jobs:job:projection-rebuild`).
|
|
2
|
+
// Replays the event log into one projection via the framework's
|
|
3
|
+
// `rebuildProjection`. Triggered manually — typically through
|
|
4
|
+
// `enqueueProjectionRebuild` (migrations) as the self-service repair for an
|
|
5
|
+
// emptied projection, a deliberate manual rebuild, or a post-upcaster refill.
|
|
6
|
+
// Run-tracking (read_job_runs + read_job_run_logs) and retry come for free
|
|
7
|
+
// from the jobs feature that registers this worker.
|
|
8
|
+
|
|
9
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
12
|
+
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
export const projectionRebuildPayloadSchema = z.object({ projection: z.string().min(1) });
|
|
16
|
+
|
|
17
|
+
export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
18
|
+
const { projection } = projectionRebuildPayloadSchema.parse(rawPayload);
|
|
19
|
+
if (!ctx.db) {
|
|
20
|
+
throw new InternalError({
|
|
21
|
+
message:
|
|
22
|
+
"[jobs:projection-rebuild] ctx.db missing — job context requires a database connection.",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (!ctx.registry) {
|
|
26
|
+
throw new InternalError({
|
|
27
|
+
message:
|
|
28
|
+
"[jobs:projection-rebuild] ctx.registry missing — job context requires the registry.",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
32
|
+
const result = await rebuildProjection(projection, { db, registry: ctx.registry });
|
|
33
|
+
ctx.log?.info?.(
|
|
34
|
+
`[jobs:projection-rebuild] rebuilt ${projection}: ${result.eventsProcessed} events in ${result.durationMs}ms`,
|
|
35
|
+
);
|
|
36
|
+
};
|
|
@@ -156,19 +156,22 @@ datenschutz-generator.de).
|
|
|
156
156
|
|
|
157
157
|
---
|
|
158
158
|
|
|
159
|
-
## XSS
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
159
|
+
## XSS hardening (untrusted authors)
|
|
160
|
+
|
|
161
|
+
The server-render path is hardened for untrusted tenant authors —
|
|
162
|
+
no DOMPurify dependency needed:
|
|
163
|
+
|
|
164
|
+
- **Raw HTML is escaped, not passed through.** `renderMarkdownToHtml`
|
|
165
|
+
(`markdown.ts`) configures `marked` so block- and inline-level HTML
|
|
166
|
+
tokens are emitted as escaped text (`<script>` → `<script>`).
|
|
167
|
+
Markdown structure (headings, lists, links, code) stays intact.
|
|
168
|
+
- **Link/image hrefs are scheme-restricted** to `http(s)`/`mailto`/
|
|
169
|
+
relative; `javascript:`/`data:` hrefs are neutralised to `#`.
|
|
170
|
+
- **Defense-in-depth headers** on every response (`security-headers.ts`):
|
|
171
|
+
`content-security-policy: script-src 'none'; object-src 'none';
|
|
172
|
+
base-uri 'none'` (no script can run even if injection slips through),
|
|
173
|
+
plus `x-content-type-options`, `x-frame-options`, `referrer-policy`.
|
|
174
|
+
No `default-src`, so inline `<style>` layouts stay unaffected.
|
|
172
175
|
|
|
173
176
|
---
|
|
174
177
|
|
|
@@ -141,12 +141,9 @@ describe("legal-pages :: edge-cases", () => {
|
|
|
141
141
|
expect(body).toContain("Tenant-Admin");
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
test("Markdown-Body mit <script> wird
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// ('XSS — bewusst aktuell nicht gesichert'). Test pinnt das
|
|
148
|
-
// Verhalten — wenn es sich ändert (z.B. DOMPurify dazu), schlägt
|
|
149
|
-
// dieser Test fehl und der Wechsel ist dokumentiert.
|
|
144
|
+
test("Markdown-Body mit <script> wird escaped (XSS-Härtung)", async () => {
|
|
145
|
+
// Server-Render ist gegen untrusted Tenant-Authoren gehärtet:
|
|
146
|
+
// Raw-HTML im Markdown-Body wird als Text escaped (kein Passthrough).
|
|
150
147
|
await seedTextBlock(db, {
|
|
151
148
|
tenantId: SYSTEM_TENANT_ID,
|
|
152
149
|
slug: "imprint",
|
|
@@ -158,8 +155,8 @@ describe("legal-pages :: edge-cases", () => {
|
|
|
158
155
|
const res = await stack.app.request("/legal/impressum");
|
|
159
156
|
expect(res.status).toBe(200);
|
|
160
157
|
const html = await res.text();
|
|
161
|
-
|
|
162
|
-
expect(html).toContain("
|
|
158
|
+
expect(html).not.toContain("<script>window.x=1</script>");
|
|
159
|
+
expect(html).toContain("<script>");
|
|
163
160
|
});
|
|
164
161
|
});
|
|
165
162
|
|
|
@@ -170,6 +167,16 @@ describe("legal-pages :: cache-control", () => {
|
|
|
170
167
|
});
|
|
171
168
|
});
|
|
172
169
|
|
|
170
|
+
describe("legal-pages :: security headers", () => {
|
|
171
|
+
test("server-gerenderte Pages tragen CSP + Hardening-Header", async () => {
|
|
172
|
+
const res = await stack.app.request("/legal/impressum");
|
|
173
|
+
expect(res.headers.get("content-security-policy")).toContain("script-src 'none'");
|
|
174
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
175
|
+
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
|
176
|
+
expect(res.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
173
180
|
describe("markdown render helpers", () => {
|
|
174
181
|
test("renderMarkdownToHtml converts markdown to HTML", () => {
|
|
175
182
|
const html = renderMarkdownToHtml("# Title\n\n**bold**");
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
10
|
import { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES } from "./constants";
|
|
11
11
|
import { renderMarkdownToHtml, wrapInLayout } from "./markdown";
|
|
12
|
+
import { securePageHeaders } from "./security-headers";
|
|
12
13
|
|
|
13
14
|
// QN-Konstante als dokumentierter Public-Contract des text-content-
|
|
14
15
|
// Features. Ein magic-string statt eines Code-Imports ist hier explizit
|
|
@@ -113,10 +114,14 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
113
114
|
lang: route.lang,
|
|
114
115
|
});
|
|
115
116
|
|
|
116
|
-
return c.body(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
return c.body(
|
|
118
|
+
html,
|
|
119
|
+
200,
|
|
120
|
+
securePageHeaders({
|
|
121
|
+
"content-type": "text/html; charset=utf-8",
|
|
122
|
+
"cache-control": "public, max-age=300",
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
120
125
|
},
|
|
121
126
|
});
|
|
122
127
|
}
|
|
@@ -1,57 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Re-export aus dem geteilten page-render-Kern (managed-pages nutzt
|
|
2
|
+
// denselben gehärteten Renderer + Default-Layout). Namen bleiben stabil
|
|
3
|
+
// für legal-pages' Public-API (index.ts exportiert renderMarkdownToHtml +
|
|
4
|
+
// wrapInLayout).
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// Task-Lists nicht nötig sind. Headers + Listen + Links + Code reichen.
|
|
7
|
-
//
|
|
8
|
-
// Instance statt globaler `marked.setOptions()` damit andere Features
|
|
9
|
-
// die `marked` als runtime-dep nutzen ihre eigenen Optionen behalten —
|
|
10
|
-
// modul-level side-effect auf shared library wäre brittle bei mehreren
|
|
11
|
-
// Konsumenten.
|
|
12
|
-
//
|
|
13
|
-
// XSS-Schutz: marked rendered tags 1:1, also kann ein böswilliger Text-
|
|
14
|
-
// Editor (TenantAdmin) <script>-Tags reinschreiben. Aktuell akzeptiert
|
|
15
|
-
// weil nur trusted Roles (TenantAdmin/SystemAdmin) Texte setzen können —
|
|
16
|
-
// bei einem Multi-Author-Setup müsste DOMPurify oder isomorphic-dompurify
|
|
17
|
-
// dazu. Dokumentiert in README, Phase-2-Hardening.
|
|
18
|
-
const markdownRenderer = new Marked({
|
|
19
|
-
gfm: false,
|
|
20
|
-
breaks: false,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
export function renderMarkdownToHtml(markdown: string): string {
|
|
24
|
-
// @cast-boundary render-helper marked.parse return-type ist
|
|
25
|
-
// `string | Promise<string>` — mit `{ async: false }` runtime-garantiert
|
|
26
|
-
// sync (string). Cast nur API-Vertragsfix, kein Type-Loss.
|
|
27
|
-
return markdownRenderer.parse(markdown, { async: false }) as string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Layout-Wrapper für Legal-Pages — minimaler HTML-Skeleton mit Inline-
|
|
31
|
-
// CSS damit die Pages auch ohne App-Layout sauber aussehen. Apps die
|
|
32
|
-
// das in ihr eigenes Layout integrieren wollen, nutzen text-content's
|
|
33
|
-
// by-slug-query direkt und rendern selbst.
|
|
34
|
-
export function wrapInLayout(opts: { title: string; bodyHtml: string; lang: string }): string {
|
|
35
|
-
return `<!doctype html>
|
|
36
|
-
<html lang="${escapeHtmlAttr(opts.lang)}">
|
|
37
|
-
<head>
|
|
38
|
-
<meta charset="utf-8">
|
|
39
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
40
|
-
<title>${escapeHtml(opts.title)}</title>
|
|
41
|
-
<style>
|
|
42
|
-
body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px;
|
|
43
|
-
margin: 2rem auto; padding: 0 1rem; line-height: 1.6; color: #222; }
|
|
44
|
-
h1, h2, h3 { line-height: 1.2; margin-top: 2rem; }
|
|
45
|
-
h1 { font-size: 1.8rem; } h2 { font-size: 1.4rem; } h3 { font-size: 1.15rem; }
|
|
46
|
-
a { color: #0066cc; }
|
|
47
|
-
code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
|
48
|
-
hr { border: 0; border-top: 1px solid #ddd; margin: 2rem 0; }
|
|
49
|
-
</style>
|
|
50
|
-
</head>
|
|
51
|
-
<body>
|
|
52
|
-
<main>
|
|
53
|
-
${opts.bodyHtml}
|
|
54
|
-
</main>
|
|
55
|
-
</body>
|
|
56
|
-
</html>`;
|
|
57
|
-
}
|
|
6
|
+
export { wrapInLayout } from "../page-render/layout";
|
|
7
|
+
export { renderSafeMarkdown as renderMarkdownToHtml } from "../page-render/markdown";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { securePageHeaders } from "../page-render/security-headers";
|