@cosmicdrift/kumiko-bundled-features 0.86.0 → 0.87.1
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/auth-email-password/web/__tests__/session-auth-bootstrap.test.ts +22 -0
- package/src/auth-email-password/web/auth-gate.tsx +20 -1
- package/src/auth-email-password/web/client-plugin.ts +3 -4
- package/src/auth-email-password/web/index.ts +2 -2
- package/src/auth-email-password/web/session.tsx +21 -22
- 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.1",
|
|
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.1",
|
|
90
|
+
"@cosmicdrift/kumiko-framework": "0.87.1",
|
|
91
|
+
"@cosmicdrift/kumiko-headless": "0.87.1",
|
|
92
|
+
"@cosmicdrift/kumiko-renderer": "0.87.1",
|
|
93
|
+
"@cosmicdrift/kumiko-renderer-web": "0.87.1",
|
|
93
94
|
"@mollie/api-client": "^4.5.0",
|
|
94
95
|
"@node-rs/argon2": "^2.0.2",
|
|
95
96
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { emailPasswordClient } from "../client-plugin";
|
|
3
|
+
import { hasLikelyAuthSession } from "../session";
|
|
4
|
+
|
|
5
|
+
describe("hasLikelyAuthSession", () => {
|
|
6
|
+
test("no kumiko_csrf cookie → false", () => {
|
|
7
|
+
expect(hasLikelyAuthSession("theme=dark")).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("kumiko_csrf present → true", () => {
|
|
11
|
+
expect(hasLikelyAuthSession("kumiko_csrf=abc-123")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("emailPasswordClient", () => {
|
|
16
|
+
test("registers SessionAuthGate as gate, not SessionProvider as provider", () => {
|
|
17
|
+
const feature = emailPasswordClient();
|
|
18
|
+
expect(feature.providers).toEqual([]);
|
|
19
|
+
expect(feature.gates).toHaveLength(1);
|
|
20
|
+
expect(feature.gates[0]?.name).toBe("SessionAuthGate");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { ComponentType, ReactNode } from "react";
|
|
13
13
|
import { LoginScreen, type LoginScreenProps } from "./login-screen";
|
|
14
|
-
import { useSession } from "./session";
|
|
14
|
+
import { SessionProvider, useSession } from "./session";
|
|
15
15
|
|
|
16
16
|
export function makeAuthGate(
|
|
17
17
|
LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
|
|
@@ -31,3 +31,22 @@ export function makeAuthGate(
|
|
|
31
31
|
}
|
|
32
32
|
return AuthGate;
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
/** SessionProvider + AuthGate als ein Gate — damit öffentliche Gates davor
|
|
36
|
+
* (z.B. /rechner) den Session-Bootstrap nicht mounten. createKumikoApp
|
|
37
|
+
* stackt providers außerhalb aller gates; SessionProvider darf deshalb
|
|
38
|
+
* kein provider mehr sein. */
|
|
39
|
+
export function makeSessionAuthGate(
|
|
40
|
+
LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
|
|
41
|
+
loginProps?: LoginScreenProps,
|
|
42
|
+
): ComponentType<{ children: ReactNode }> {
|
|
43
|
+
const AuthGate = makeAuthGate(LoginComponent, loginProps);
|
|
44
|
+
function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
45
|
+
return (
|
|
46
|
+
<SessionProvider>
|
|
47
|
+
<AuthGate>{children}</AuthGate>
|
|
48
|
+
</SessionProvider>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return SessionAuthGate;
|
|
52
|
+
}
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
9
9
|
import type { ComponentType, ReactNode } from "react";
|
|
10
10
|
import { defaultTranslations, mergeTranslations } from "../i18n";
|
|
11
|
-
import {
|
|
11
|
+
import { makeSessionAuthGate } from "./auth-gate";
|
|
12
12
|
import type { LoginScreenProps } from "./login-screen";
|
|
13
|
-
import { SessionProvider } from "./session";
|
|
14
13
|
|
|
15
14
|
export type EmailPasswordClientOptions = {
|
|
16
15
|
/** Eigener Login-Screen. Default: der shadcn-stylte LoginScreen
|
|
@@ -41,8 +40,8 @@ export function emailPasswordClient(
|
|
|
41
40
|
const translations = mergeTranslations(defaultTranslations, options.translations ?? {});
|
|
42
41
|
return {
|
|
43
42
|
name: "auth-email-password",
|
|
44
|
-
providers: [
|
|
45
|
-
gates: [
|
|
43
|
+
providers: [],
|
|
44
|
+
gates: [makeSessionAuthGate(options.loginScreen, options.loginScreenProps)],
|
|
46
45
|
translations,
|
|
47
46
|
};
|
|
48
47
|
}
|
|
@@ -26,7 +26,7 @@ export {
|
|
|
26
26
|
} from "./auth-client";
|
|
27
27
|
export type { AuthShellRenderer } from "./auth-form-primitives";
|
|
28
28
|
export { AuthShellProvider, useAuthShell } from "./auth-form-primitives";
|
|
29
|
-
export { makeAuthGate } from "./auth-gate";
|
|
29
|
+
export { makeAuthGate, makeSessionAuthGate } from "./auth-gate";
|
|
30
30
|
export type {
|
|
31
31
|
EmailPasswordClientFeature,
|
|
32
32
|
EmailPasswordClientOptions,
|
|
@@ -43,7 +43,7 @@ export { LoginScreen } from "./login-screen";
|
|
|
43
43
|
export type { ResetPasswordScreenProps } from "./reset-password-screen";
|
|
44
44
|
export { ResetPasswordScreen } from "./reset-password-screen";
|
|
45
45
|
export type { SessionApi, SessionState, SessionStatus } from "./session";
|
|
46
|
-
export { SessionContext, SessionProvider, useSession } from "./session";
|
|
46
|
+
export { hasLikelyAuthSession, SessionContext, SessionProvider, useSession } from "./session";
|
|
47
47
|
export type { SignupCompleteScreenProps } from "./signup-complete-screen";
|
|
48
48
|
export { SignupCompleteScreen } from "./signup-complete-screen";
|
|
49
49
|
export type { SignupScreenProps } from "./signup-screen";
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// unter `<SessionProvider>`; der `useSession()`-Hook liefert den State
|
|
10
10
|
// und die Transitions.
|
|
11
11
|
|
|
12
|
+
import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
12
13
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
|
13
14
|
import {
|
|
14
15
|
type CurrentUserProfile,
|
|
@@ -43,6 +44,14 @@ export type SessionApi = SessionState & {
|
|
|
43
44
|
readonly switchTenant: (tenantId: string) => Promise<void>;
|
|
44
45
|
};
|
|
45
46
|
|
|
47
|
+
const UNAUTHENTICATED: SessionState = {
|
|
48
|
+
status: "unauthenticated",
|
|
49
|
+
user: null,
|
|
50
|
+
activeTenantId: null,
|
|
51
|
+
tenants: [],
|
|
52
|
+
roles: [],
|
|
53
|
+
};
|
|
54
|
+
|
|
46
55
|
const INITIAL: SessionState = {
|
|
47
56
|
status: "loading",
|
|
48
57
|
user: null,
|
|
@@ -51,6 +60,11 @@ const INITIAL: SessionState = {
|
|
|
51
60
|
roles: [],
|
|
52
61
|
};
|
|
53
62
|
|
|
63
|
+
// kumiko_auth ist HttpOnly — kumiko_csrf wird beim Login gemeinsam gesetzt.
|
|
64
|
+
export function hasLikelyAuthSession(cookieSource?: string): boolean {
|
|
65
|
+
return readCsrfToken(cookieSource) !== undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
// Exported damit tests den merge-pfad direkt pinnen können — der hier
|
|
55
69
|
// muss byte-identisch zum server-side merge in auth-routes.ts +
|
|
56
70
|
// login.write.ts sein, sonst sieht der Client andere session-rollen
|
|
@@ -76,27 +90,18 @@ export function computeActiveRoles(
|
|
|
76
90
|
export const SessionContext = createContext<SessionApi | undefined>(undefined);
|
|
77
91
|
|
|
78
92
|
// Eine Refresh-Runde: /auth/tenants → wenn 401 nicht-eingeloggt, sonst
|
|
79
|
-
//
|
|
93
|
+
// /user:me. Beides zusammen ergibt den vollen SessionState.
|
|
80
94
|
async function refresh(): Promise<SessionState> {
|
|
95
|
+
if (!hasLikelyAuthSession()) {
|
|
96
|
+
return UNAUTHENTICATED;
|
|
97
|
+
}
|
|
81
98
|
const tenants = await fetchTenants();
|
|
82
99
|
if (tenants === null) {
|
|
83
|
-
return
|
|
84
|
-
status: "unauthenticated",
|
|
85
|
-
user: null,
|
|
86
|
-
activeTenantId: null,
|
|
87
|
-
tenants: [],
|
|
88
|
-
roles: [],
|
|
89
|
-
};
|
|
100
|
+
return UNAUTHENTICATED;
|
|
90
101
|
}
|
|
91
102
|
const user = await fetchCurrentUser();
|
|
92
103
|
if (user === null) {
|
|
93
|
-
return
|
|
94
|
-
status: "unauthenticated",
|
|
95
|
-
user: null,
|
|
96
|
-
activeTenantId: null,
|
|
97
|
-
tenants: [],
|
|
98
|
-
roles: [],
|
|
99
|
-
};
|
|
104
|
+
return UNAUTHENTICATED;
|
|
100
105
|
}
|
|
101
106
|
return {
|
|
102
107
|
status: "authenticated",
|
|
@@ -131,13 +136,7 @@ export function SessionProvider({ children }: { readonly children: ReactNode }):
|
|
|
131
136
|
|
|
132
137
|
const logout = useCallback<SessionApi["logout"]>(async () => {
|
|
133
138
|
await logoutApi();
|
|
134
|
-
setState(
|
|
135
|
-
status: "unauthenticated",
|
|
136
|
-
user: null,
|
|
137
|
-
activeTenantId: null,
|
|
138
|
-
tenants: [],
|
|
139
|
-
roles: [],
|
|
140
|
-
});
|
|
139
|
+
setState(UNAUTHENTICATED);
|
|
141
140
|
// Hard-Reload: React-Tree, dispatcher-live-Caches, EventSource —
|
|
142
141
|
// alles fliegt auf Null. Nach Logout ist das der billigste Weg zu
|
|
143
142
|
// sauberer Ausgangslage, ohne dass wir jeden einzelnen Consumer
|
|
@@ -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";
|