@cosmicdrift/kumiko-bundled-features 0.86.0 → 0.87.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.86.0",
3
+ "version": "0.87.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>",
@@ -80,16 +80,17 @@
80
80
  "./renderer-foundation": "./src/renderer-foundation/index.ts",
81
81
  "./legal-pages": "./src/legal-pages/index.ts",
82
82
  "./legal-pages/web": "./src/legal-pages/web/index.ts",
83
+ "./page-render": "./src/page-render/index.ts",
83
84
  "./managed-pages": "./src/managed-pages/index.ts",
84
85
  "./managed-pages/seeding": "./src/managed-pages/seeding.ts",
85
86
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
86
87
  },
87
88
  "dependencies": {
88
- "@cosmicdrift/kumiko-dispatcher-live": "0.86.0",
89
- "@cosmicdrift/kumiko-framework": "0.86.0",
90
- "@cosmicdrift/kumiko-headless": "0.86.0",
91
- "@cosmicdrift/kumiko-renderer": "0.86.0",
92
- "@cosmicdrift/kumiko-renderer-web": "0.86.0",
89
+ "@cosmicdrift/kumiko-dispatcher-live": "0.87.0",
90
+ "@cosmicdrift/kumiko-framework": "0.87.0",
91
+ "@cosmicdrift/kumiko-headless": "0.87.0",
92
+ "@cosmicdrift/kumiko-renderer": "0.87.0",
93
+ "@cosmicdrift/kumiko-renderer-web": "0.87.0",
93
94
  "@mollie/api-client": "^4.5.0",
94
95
  "@node-rs/argon2": "^2.0.2",
95
96
  "@types/nodemailer": "^8.0.0",
@@ -161,9 +161,20 @@ describe("legal-pages :: edge-cases", () => {
161
161
  });
162
162
 
163
163
  describe("legal-pages :: cache-control", () => {
164
- test("sets public cache header for 5min", async () => {
164
+ test("sets revalidate cache header + etag", async () => {
165
165
  const res = await stack.app.request("/legal/impressum");
166
- expect(res.headers.get("cache-control")).toBe("public, max-age=300");
166
+ expect(res.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
167
+ expect(res.headers.get("etag")).toBeTruthy();
168
+ });
169
+
170
+ test("If-None-Match → 304 when content unchanged", async () => {
171
+ const first = await stack.app.request("/legal/impressum");
172
+ const etag = first.headers.get("etag");
173
+ expect(etag).toBeTruthy();
174
+ const second = await stack.app.request("/legal/impressum", {
175
+ headers: { "if-none-match": etag ?? "" },
176
+ });
177
+ expect(second.status).toBe(304);
167
178
  });
168
179
  });
169
180
 
@@ -2,14 +2,15 @@ import {
2
2
  requireTextContent,
3
3
  type TextContentApi,
4
4
  } from "@cosmicdrift/kumiko-bundled-features/text-content";
5
+ import { computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
5
6
  import {
6
7
  defineFeature,
7
8
  type FeatureDefinition,
8
9
  SYSTEM_TENANT_ID,
9
10
  } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { cachedSecurePageResponse } from "../page-render";
10
12
  import { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES } from "./constants";
11
13
  import { renderMarkdownToHtml, wrapInLayout } from "./markdown";
12
- import { securePageHeaders } from "./security-headers";
13
14
 
14
15
  // QN-Konstante als dokumentierter Public-Contract des text-content-
15
16
  // Features. Ein magic-string statt eines Code-Imports ist hier explizit
@@ -22,7 +23,7 @@ const TEXT_CONTENT_BY_SLUG_QN = "text-content:query:by-slug";
22
23
 
23
24
  // Wire-Body-Shape von /api/query — das, was bySlugQuery returnt.
24
25
  type ByslugQueryBody = {
25
- data: { title: string; body: string | null } | null;
26
+ data: { title: string; body: string | null; updatedAt: string } | null;
26
27
  };
27
28
 
28
29
  // legal-pages — Opt-in-Wrapper um text-content für DACH-Compliance.
@@ -113,20 +114,32 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
113
114
  );
114
115
  }
115
116
 
117
+ const etag = computeRevisionEtag([
118
+ SYSTEM_TENANT_ID,
119
+ route.slug,
120
+ route.lang,
121
+ data.updatedAt,
122
+ ]);
123
+ const notModified = cachedSecurePageResponse(c.req.raw, {
124
+ body: null,
125
+ etag,
126
+ cache: { kind: "revalidate" },
127
+ extra: { "content-type": "text/html; charset=utf-8" },
128
+ });
129
+ if (notModified.status === 304) return notModified;
130
+
116
131
  const html = wrapLayout({
117
132
  title: data.title || route.titleFallback,
118
133
  bodyHtml: renderMarkdownToHtml(data.body),
119
134
  lang: route.lang,
120
135
  });
121
136
 
122
- return c.body(
123
- html,
124
- 200,
125
- securePageHeaders({
126
- "content-type": "text/html; charset=utf-8",
127
- "cache-control": "public, max-age=300",
128
- }),
129
- );
137
+ return cachedSecurePageResponse(c.req.raw, {
138
+ body: html,
139
+ etag,
140
+ cache: { kind: "revalidate" },
141
+ extra: { "content-type": "text/html; charset=utf-8" },
142
+ });
130
143
  },
131
144
  });
132
145
  }
@@ -142,13 +142,24 @@ describe("managed-pages :: Cross-Tenant-Isolation", () => {
142
142
  });
143
143
 
144
144
  describe("managed-pages :: Cache + Security-Header", () => {
145
- test("Vary: Host + CSP/Hardening-Header", async () => {
145
+ test("Vary: Host + CSP/Hardening-Header + revalidate cache", async () => {
146
146
  const res = await stack.app.request("http://a.example.com/p/about");
147
147
  expect(res.headers.get("vary")).toBe("Host");
148
- expect(res.headers.get("cache-control")).toBe("public, max-age=300");
148
+ expect(res.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
149
+ expect(res.headers.get("etag")).toBeTruthy();
149
150
  expect(res.headers.get("content-security-policy")).toContain("script-src 'none'");
150
151
  expect(res.headers.get("x-content-type-options")).toBe("nosniff");
151
152
  });
153
+
154
+ test("If-None-Match → 304 when page unchanged", async () => {
155
+ const first = await stack.app.request("http://a.example.com/p/about");
156
+ const etag = first.headers.get("etag");
157
+ expect(etag).toBeTruthy();
158
+ const second = await stack.app.request("http://a.example.com/p/about", {
159
+ headers: { "if-none-match": etag ?? "" },
160
+ });
161
+ expect(second.status).toBe(304);
162
+ });
152
163
  });
153
164
 
154
165
  describe("managed-pages :: XSS-Härtung", () => {
@@ -1,3 +1,4 @@
1
+ import { computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
1
2
  import {
2
3
  defineEntityCreateHandler,
3
4
  defineEntityDeleteHandler,
@@ -9,9 +10,9 @@ import {
9
10
  } from "@cosmicdrift/kumiko-framework/engine";
10
11
  import {
11
12
  type BrandingTokens,
13
+ cachedSecurePageResponse,
12
14
  EMPTY_BRANDING,
13
15
  renderSafeMarkdown,
14
- securePageHeaders,
15
16
  wrapInLayout,
16
17
  } from "../page-render";
17
18
  import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding";
@@ -40,9 +41,23 @@ type ByslugQueryBody = {
40
41
  lang: string;
41
42
  description: string | null;
42
43
  ogImage: string | null;
44
+ version: number;
45
+ updatedAt: string;
43
46
  } | null;
44
47
  };
45
48
 
49
+ function brandingRevisionSeed(branding: BrandingTokens): string {
50
+ return JSON.stringify([
51
+ branding.title,
52
+ branding.description,
53
+ branding.siteUrl,
54
+ branding.accentColor,
55
+ branding.logoUrl,
56
+ branding.layoutPreset,
57
+ branding.customCss,
58
+ ]);
59
+ }
60
+
46
61
  // Parse the branding query's `{ data }` envelope into BrandingTokens, never
47
62
  // throwing: a non-ok status or malformed body degrades to the unbranded
48
63
  // default (branding is decoration, not a hard dependency of the page render).
@@ -221,6 +236,26 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
221
236
 
222
237
  const branding = await readBrandingResponse(brandingRes);
223
238
 
239
+ const etag = computeRevisionEtag([
240
+ tenantId,
241
+ slug,
242
+ lang,
243
+ String(data.version),
244
+ data.updatedAt,
245
+ brandingRevisionSeed(branding),
246
+ ]);
247
+ const pageHeaders = {
248
+ "content-type": "text/html; charset=utf-8",
249
+ vary: "Host",
250
+ } as const;
251
+ const notModified = cachedSecurePageResponse(c.req.raw, {
252
+ body: null,
253
+ etag,
254
+ cache: { kind: "revalidate" },
255
+ extra: pageHeaders,
256
+ });
257
+ if (notModified.status === 304) return notModified;
258
+
224
259
  const html = wrapLayout({
225
260
  title: data.title,
226
261
  bodyHtml: renderSafeMarkdown(data.body),
@@ -231,18 +266,12 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
231
266
  branding,
232
267
  });
233
268
 
234
- // Vary: Host — per-Tenant-Content darf nicht von einem shared CDN
235
- // nur unter dem Pfad gecached werden (sonst Tenant A's Page auf
236
- // Tenant B's Domain). Cache keyed mit auf den Host.
237
- return c.body(
238
- html,
239
- 200,
240
- securePageHeaders({
241
- "content-type": "text/html; charset=utf-8",
242
- "cache-control": "public, max-age=300",
243
- vary: "Host",
244
- }),
245
- );
269
+ return cachedSecurePageResponse(c.req.raw, {
270
+ body: html,
271
+ etag,
272
+ cache: { kind: "revalidate" },
273
+ extra: pageHeaders,
274
+ });
246
275
  },
247
276
  });
248
277
 
@@ -30,6 +30,8 @@ export const bySlugQuery = defineQueryHandler({
30
30
  body: row.body,
31
31
  description: row.description,
32
32
  ogImage: row.ogImage,
33
+ version: row.version,
34
+ updatedAt: row.updatedAt,
33
35
  };
34
36
  },
35
37
  });
@@ -0,0 +1,25 @@
1
+ import { type CachePolicy, cachedResponse } from "@cosmicdrift/kumiko-framework/api";
2
+ import { securePageHeaders } from "./security-headers";
3
+
4
+ export type CachedSecurePageResponseInit = {
5
+ readonly body: BodyInit | null;
6
+ readonly status?: number;
7
+ readonly etag: string;
8
+ readonly cache: CachePolicy;
9
+ readonly extra?: Record<string, string>;
10
+ readonly lastModified?: Date;
11
+ };
12
+
13
+ export function cachedSecurePageResponse(
14
+ req: Request,
15
+ init: CachedSecurePageResponseInit,
16
+ ): Response {
17
+ return cachedResponse(req, {
18
+ body: init.body,
19
+ status: init.status,
20
+ etag: init.etag,
21
+ cache: init.cache,
22
+ lastModified: init.lastModified,
23
+ headers: securePageHeaders(init.extra ?? {}),
24
+ });
25
+ }
@@ -7,6 +7,10 @@ export {
7
7
  isSafeHttpsUrl,
8
8
  layoutMaxWidth,
9
9
  } from "./branding";
10
+ export {
11
+ type CachedSecurePageResponseInit,
12
+ cachedSecurePageResponse,
13
+ } from "./cached-page-response";
10
14
  export { sanitizeTenantCss } from "./css-sanitize";
11
15
  export { TENANT_CONTENT_ATTR, tenantStyleBlock, wrapInLayout } from "./layout";
12
16
  export { renderSafeMarkdown } from "./markdown";