@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.
Files changed (46) hide show
  1. package/package.json +8 -6
  2. package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
  3. package/src/config/__tests__/config.integration.test.ts +60 -0
  4. package/src/config/feature.ts +5 -2
  5. package/src/config/handlers/cascade.query.ts +4 -1
  6. package/src/config/handlers/readiness.query.ts +1 -0
  7. package/src/config/handlers/reset.write.ts +23 -2
  8. package/src/config/handlers/set.write.ts +36 -2
  9. package/src/config/handlers/values.query.ts +5 -1
  10. package/src/config/resolver.ts +93 -3
  11. package/src/config/write-helpers.ts +37 -0
  12. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
  13. package/src/jobs/feature.ts +13 -0
  14. package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
  15. package/src/legal-pages/README.md +16 -13
  16. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
  17. package/src/legal-pages/feature.ts +9 -4
  18. package/src/legal-pages/markdown.ts +6 -56
  19. package/src/legal-pages/security-headers.ts +1 -0
  20. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
  21. package/src/managed-pages/branding.ts +142 -0
  22. package/src/managed-pages/css-gate.ts +24 -0
  23. package/src/managed-pages/feature.ts +246 -0
  24. package/src/managed-pages/handlers/branding.query.ts +30 -0
  25. package/src/managed-pages/handlers/by-slug.query.ts +35 -0
  26. package/src/managed-pages/handlers/set.write.ts +113 -0
  27. package/src/managed-pages/index.ts +30 -0
  28. package/src/managed-pages/screens/branding-screen.ts +85 -0
  29. package/src/managed-pages/screens/page-screens.ts +82 -0
  30. package/src/managed-pages/seeding.ts +99 -0
  31. package/src/managed-pages/table.ts +58 -0
  32. package/src/page-render/__tests__/branding.test.ts +57 -0
  33. package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
  34. package/src/page-render/__tests__/markdown.test.ts +41 -0
  35. package/src/page-render/branding.ts +99 -0
  36. package/src/page-render/css-sanitize.ts +344 -0
  37. package/src/page-render/index.ts +13 -0
  38. package/src/page-render/layout.ts +100 -0
  39. package/src/page-render/markdown.ts +39 -0
  40. package/src/page-render/security-headers.ts +16 -0
  41. package/src/subscription-stripe/__tests__/feature.test.ts +3 -2
  42. package/src/subscription-stripe/__tests__/runtime.test.ts +12 -10
  43. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +24 -12
  44. package/src/subscription-stripe/constants.ts +6 -5
  45. package/src/subscription-stripe/feature.ts +69 -50
  46. 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
+ });
@@ -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 currently not secured by design
160
-
161
- `marked` renders HTML tags 1:1, so a malicious tenant admin could in
162
- theory put `<script>` into the body.
163
-
164
- Currently accepted because:
165
- - only `roles: ["TenantAdmin"]` may set texts
166
- - multi-author setups don't exist yet
167
- - self-hosted tier without unknown tenant admins
168
-
169
- **Phase-2 hardening:** `DOMPurify` or `isomorphic-dompurify`
170
- sanitization step between `marked.parse()` and the response.
171
- Documented when a customer with a multi-author setup shows up.
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>` → `&lt;script&gt;`).
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 NICHT escaped (dokumentiertes XSS-Verhalten, siehe README)", async () => {
145
- // Bewusstes Verhalten: marked.parse rendered HTML 1:1, kein
146
- // DOMPurify-Layer aktuell. Dokumentiert in legal-pages/README.md
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
- // Aktuelles Verhalten: script-tag bleibt unescaped im Output
162
- expect(html).toContain("<script>window.x=1</script>");
158
+ expect(html).not.toContain("<script>window.x=1</script>");
159
+ expect(html).toContain("&lt;script&gt;");
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(html, 200, {
117
- "content-type": "text/html; charset=utf-8",
118
- "cache-control": "public, max-age=300",
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
- import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
- import { Marked } from "marked";
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
- // Markdown→HTML mit eigener `marked`-Instance. GFM aus, breaks aus —
5
- // Legal-Pages sind strukturiert genug dass GFM-Tables/Strikethrough/
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";