@cosmicdrift/kumiko-bundled-features 0.85.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 +7 -6
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +13 -2
- package/src/legal-pages/feature.ts +23 -10
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +13 -2
- package/src/managed-pages/feature.ts +42 -13
- package/src/managed-pages/handlers/by-slug.query.ts +2 -0
- package/src/page-render/cached-page-response.ts +25 -0
- package/src/page-render/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
89
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
92
|
-
"@cosmicdrift/kumiko-renderer-web": "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
|
|
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=
|
|
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.
|
|
123
|
-
html,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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=
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
|
@@ -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
|
+
}
|
package/src/page-render/index.ts
CHANGED
|
@@ -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";
|