@cosmicdrift/kumiko-bundled-features 0.147.2 → 0.148.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 +6 -6
- package/src/audit/__tests__/audit-screens.boot.test.ts +2 -2
- package/src/auth-mfa/web/i18n.ts +2 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +36 -29
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +111 -0
- package/src/managed-pages/feature.ts +43 -43
- package/src/seo/__tests__/seo.integration.test.ts +68 -0
- package/src/seo/feature.ts +28 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.148.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>",
|
|
@@ -114,11 +114,11 @@
|
|
|
114
114
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
115
115
|
},
|
|
116
116
|
"dependencies": {
|
|
117
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
118
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
119
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
120
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
121
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
117
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.148.0",
|
|
118
|
+
"@cosmicdrift/kumiko-framework": "0.148.0",
|
|
119
|
+
"@cosmicdrift/kumiko-headless": "0.148.0",
|
|
120
|
+
"@cosmicdrift/kumiko-renderer": "0.148.0",
|
|
121
|
+
"@cosmicdrift/kumiko-renderer-web": "0.148.0",
|
|
122
122
|
"@mollie/api-client": "^4.5.0",
|
|
123
123
|
"imapflow": "^1.3.3",
|
|
124
124
|
"mailparser": "^3.9.8",
|
|
@@ -15,7 +15,7 @@ describe("audit log screen + handler access alignment", () => {
|
|
|
15
15
|
test("audit-log screen is custom, access.admin-gated", () => {
|
|
16
16
|
const audit = createAuditFeature();
|
|
17
17
|
const screen = audit.screens[AUDIT_LOG_SCREEN_ID];
|
|
18
|
-
if (
|
|
18
|
+
if (screen?.type !== "custom") {
|
|
19
19
|
throw new Error(`expected a custom screen for ${AUDIT_LOG_SCREEN_ID}, got ${screen?.type}`);
|
|
20
20
|
}
|
|
21
21
|
if (!("access" in screen) || !screen.access || !("roles" in screen.access)) {
|
|
@@ -27,7 +27,7 @@ describe("audit log screen + handler access alignment", () => {
|
|
|
27
27
|
test("audit-log-detail screen is custom, admin-gated, breadcrumb-linked to list", () => {
|
|
28
28
|
const audit = createAuditFeature();
|
|
29
29
|
const screen = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
|
|
30
|
-
if (
|
|
30
|
+
if (screen?.type !== "custom") {
|
|
31
31
|
throw new Error(
|
|
32
32
|
`expected a custom screen for ${AUDIT_LOG_DETAIL_SCREEN_ID}, got ${screen?.type}`,
|
|
33
33
|
);
|
package/src/auth-mfa/web/i18n.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
|
7
7
|
|
|
8
8
|
export const defaultTranslations: TranslationsByLocale = {
|
|
9
9
|
de: {
|
|
10
|
+
"screen:auth-mfa-enable.title": "Zwei-Faktor-Authentifizierung",
|
|
10
11
|
"auth.mfa.verify.title": "Zwei-Faktor-Bestätigung",
|
|
11
12
|
"auth.mfa.verify.subtitle": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
|
12
13
|
"auth.mfa.verify.code": "Code",
|
|
@@ -36,6 +37,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
36
37
|
"auth.mfa.enable.success": "Zwei-Faktor-Authentifizierung ist jetzt aktiv.",
|
|
37
38
|
},
|
|
38
39
|
en: {
|
|
40
|
+
"screen:auth-mfa-enable.title": "Two-factor authentication",
|
|
39
41
|
"auth.mfa.verify.title": "Two-factor verification",
|
|
40
42
|
"auth.mfa.verify.subtitle": "Enter the 6-digit code from your authenticator app.",
|
|
41
43
|
"auth.mfa.verify.code": "Code",
|
|
@@ -39,7 +39,7 @@ export type MfaEnableScreenProps = { readonly embedded?: boolean };
|
|
|
39
39
|
|
|
40
40
|
export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {}): ReactNode {
|
|
41
41
|
const t = useTranslation();
|
|
42
|
-
const { Button, Banner, Field, Input,
|
|
42
|
+
const { Button, Banner, Field, Input, Section, Heading } = usePrimitives();
|
|
43
43
|
const dispatcher = useDispatcher();
|
|
44
44
|
const session = useSession();
|
|
45
45
|
|
|
@@ -98,22 +98,43 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
|
|
|
98
98
|
{error !== null && <Banner variant="error">{t(`auth.mfa.errors.${error}`)}</Banner>}
|
|
99
99
|
|
|
100
100
|
{!setup && !enabled && (
|
|
101
|
-
<
|
|
101
|
+
<Section
|
|
102
|
+
testId="mfa-enable-intro"
|
|
103
|
+
actions={
|
|
104
|
+
<Button
|
|
105
|
+
variant="primary"
|
|
106
|
+
onClick={() => void startSetup()}
|
|
107
|
+
loading={busy}
|
|
108
|
+
disabled={busy}
|
|
109
|
+
>
|
|
110
|
+
{t("auth.mfa.enable.start")}
|
|
111
|
+
</Button>
|
|
112
|
+
}
|
|
113
|
+
>
|
|
102
114
|
<span className="text-sm text-muted-foreground">{t("auth.mfa.enable.intro")}</span>
|
|
103
|
-
|
|
104
|
-
variant="primary"
|
|
105
|
-
onClick={() => void startSetup()}
|
|
106
|
-
loading={busy}
|
|
107
|
-
disabled={busy}
|
|
108
|
-
>
|
|
109
|
-
{t("auth.mfa.enable.start")}
|
|
110
|
-
</Button>
|
|
111
|
-
</Card>
|
|
115
|
+
</Section>
|
|
112
116
|
)}
|
|
113
117
|
|
|
114
118
|
{setup && (
|
|
115
|
-
<
|
|
116
|
-
|
|
119
|
+
<Section
|
|
120
|
+
testId="mfa-enable-setup"
|
|
121
|
+
actions={
|
|
122
|
+
<>
|
|
123
|
+
<Button variant="secondary" onClick={() => setSetup(null)} disabled={busy}>
|
|
124
|
+
{t("auth.mfa.enable.cancel")}
|
|
125
|
+
</Button>
|
|
126
|
+
<Button
|
|
127
|
+
variant="primary"
|
|
128
|
+
onClick={() => void confirmSetup()}
|
|
129
|
+
loading={busy}
|
|
130
|
+
disabled={busy || !acknowledged || code.length < 6}
|
|
131
|
+
>
|
|
132
|
+
{t("auth.mfa.enable.confirm")}
|
|
133
|
+
</Button>
|
|
134
|
+
</>
|
|
135
|
+
}
|
|
136
|
+
>
|
|
137
|
+
<div className="flex flex-col items-center gap-2 text-center">
|
|
117
138
|
<span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
|
|
118
139
|
{/* qrcode's own SVG string output, not user input — safe to inline */}
|
|
119
140
|
<div
|
|
@@ -124,7 +145,7 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
|
|
|
124
145
|
<span className="text-xs text-muted-foreground">
|
|
125
146
|
{t("auth.mfa.enable.manualEntry")}
|
|
126
147
|
</span>
|
|
127
|
-
<code className="block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
|
|
148
|
+
<code className="inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
|
|
128
149
|
{setup.secretParam}
|
|
129
150
|
</code>
|
|
130
151
|
</div>
|
|
@@ -159,21 +180,7 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
|
|
|
159
180
|
autoComplete="one-time-code"
|
|
160
181
|
/>
|
|
161
182
|
</Field>
|
|
162
|
-
|
|
163
|
-
<div className="flex justify-end gap-2">
|
|
164
|
-
<Button variant="secondary" onClick={() => setSetup(null)} disabled={busy}>
|
|
165
|
-
{t("auth.mfa.enable.cancel")}
|
|
166
|
-
</Button>
|
|
167
|
-
<Button
|
|
168
|
-
variant="primary"
|
|
169
|
-
onClick={() => void confirmSetup()}
|
|
170
|
-
loading={busy}
|
|
171
|
-
disabled={busy || !acknowledged || code.length < 6}
|
|
172
|
-
>
|
|
173
|
-
{t("auth.mfa.enable.confirm")}
|
|
174
|
-
</Button>
|
|
175
|
-
</div>
|
|
176
|
-
</Card>
|
|
183
|
+
</Section>
|
|
177
184
|
)}
|
|
178
185
|
</div>
|
|
179
186
|
);
|
|
@@ -697,3 +697,114 @@ describe("managed-pages :: Custom CSS (gated, sanitized render)", () => {
|
|
|
697
697
|
expect(JSON.stringify(screen)).toContain("branding-custom-css");
|
|
698
698
|
});
|
|
699
699
|
});
|
|
700
|
+
|
|
701
|
+
// resolverTrust: "authoritative" (kumiko-platform#278/1, show-pony#51):
|
|
702
|
+
// diese Suite verdrahtet einen ECHTEN host-basierten anonymousAccess.
|
|
703
|
+
// tenantResolver (nicht nur tenantExists wie die Suite oben) — genau die
|
|
704
|
+
// Konfiguration, unter der publicstatus/show-pony laufen.
|
|
705
|
+
//
|
|
706
|
+
// Mechanismus: ein interner Self-Fetch (der alte app.fetch-Pfad) trägt
|
|
707
|
+
// KEINEN Host-Header — Host steckt nur implizit in der Request-URL, nicht
|
|
708
|
+
// in Headers. Ein Resolver, der (wie publicstatus' echter Resolver) nur
|
|
709
|
+
// c.req.header("host") liest, löst für den inneren Request also null auf;
|
|
710
|
+
// unter resolverTrust: "authoritative" gibt es dafür keinen Client-Tenant-
|
|
711
|
+
// Fallback → tenant_required → "page unavailable" 503, obwohl der äußere
|
|
712
|
+
// Request (Host da) den Tenant längst korrekt aufgelöst hätte. systemQuery
|
|
713
|
+
// (siehe feature.ts) entfernt den internen HTTP-Roundtrip komplett und
|
|
714
|
+
// erzwingt den bereits aufgelösten tenantId in-process — kein Header mehr,
|
|
715
|
+
// der fehlen könnte. Rot/Grün verifiziert: mit dem alten app.fetch-Pfad UND
|
|
716
|
+
// einem fallback-freien Resolver (wie hier) schlagen 4 der Tests in dieser
|
|
717
|
+
// Datei/seo.integration.test.ts fehl (503/leere sitemap-Einträge); mit
|
|
718
|
+
// systemQuery sind alle grün.
|
|
719
|
+
describe("managed-pages :: resolverTrust authoritative (host-based, wie publicstatus/show-pony)", () => {
|
|
720
|
+
let authStack: TestStack;
|
|
721
|
+
const authManaged = createManagedPagesFeature({
|
|
722
|
+
resolveApexTenant: (host) => {
|
|
723
|
+
if (host.startsWith("a.")) return TENANT_A;
|
|
724
|
+
if (host.startsWith("b.")) return TENANT_B;
|
|
725
|
+
return null;
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
const authConfigFeature = createConfigFeature();
|
|
729
|
+
const hostTenant = (host: string): string | null => {
|
|
730
|
+
if (host.startsWith("a.")) return TENANT_A;
|
|
731
|
+
if (host.startsWith("b.")) return TENANT_B;
|
|
732
|
+
return null;
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
beforeAll(async () => {
|
|
736
|
+
const resolver = createConfigResolver();
|
|
737
|
+
authStack = await setupTestStack({
|
|
738
|
+
features: [authConfigFeature, authManaged],
|
|
739
|
+
anonymousAccess: {
|
|
740
|
+
// KEIN URL-Fallback — spiegelt publicstatus' echten Resolver
|
|
741
|
+
// (resolveSubdomain(c.req.header("Host") ?? "")). Ein interner
|
|
742
|
+
// Self-Fetch trägt keinen Host-Header (Host steckt nur in der URL,
|
|
743
|
+
// nicht in Headers) — ein Resolver, der nur den Header liest, löst
|
|
744
|
+
// für den inneren Request also null auf. Mit URL-Fallback würde
|
|
745
|
+
// dieser Test den Bug maskieren.
|
|
746
|
+
tenantResolver: (c) => hostTenant(c.req.header("host") ?? ""),
|
|
747
|
+
resolverTrust: "authoritative",
|
|
748
|
+
tenantExists: async (id) => id === TENANT_A || id === TENANT_B,
|
|
749
|
+
},
|
|
750
|
+
extraContext: ({ registry }) => ({
|
|
751
|
+
configResolver: resolver,
|
|
752
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
753
|
+
}),
|
|
754
|
+
});
|
|
755
|
+
await unsafeCreateEntityTable(authStack.db, pageEntity);
|
|
756
|
+
await unsafePushTables(authStack.db, { configValuesTable });
|
|
757
|
+
await createEventsTable(authStack.db);
|
|
758
|
+
await seedPage(authStack.db, {
|
|
759
|
+
tenantId: TENANT_A,
|
|
760
|
+
slug: "about",
|
|
761
|
+
lang: "en",
|
|
762
|
+
title: "About A",
|
|
763
|
+
body: "# Hello from **A**",
|
|
764
|
+
published: true,
|
|
765
|
+
});
|
|
766
|
+
await seedPage(authStack.db, {
|
|
767
|
+
tenantId: TENANT_B,
|
|
768
|
+
slug: "about",
|
|
769
|
+
lang: "en",
|
|
770
|
+
title: "About B",
|
|
771
|
+
body: "# Hello from **B**",
|
|
772
|
+
published: true,
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
afterAll(async () => {
|
|
777
|
+
await authStack.cleanup();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("published Page → 200 (systemQuery-Render-Pfad übersteht authoritative resolver)", async () => {
|
|
781
|
+
const res = await authStack.app.request("http://a.example.com/p/about");
|
|
782
|
+
expect(res.status).toBe(200);
|
|
783
|
+
const html = await res.text();
|
|
784
|
+
expect(html).toContain("About A");
|
|
785
|
+
expect(html).toContain("<strong>A</strong>");
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("Cross-Tenant-Isolation: Host löst den jeweils eigenen Tenant auf", async () => {
|
|
789
|
+
const a = await (await authStack.app.request("http://a.example.com/p/about")).text();
|
|
790
|
+
const b = await (await authStack.app.request("http://b.example.com/p/about")).text();
|
|
791
|
+
expect(a).toContain("About A");
|
|
792
|
+
expect(a).not.toContain("About B");
|
|
793
|
+
expect(b).toContain("About B");
|
|
794
|
+
expect(b).not.toContain("About A");
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("X-Tenant-Header auf der Render-Route ist wirkungslos — Host bleibt allein maßgeblich", async () => {
|
|
798
|
+
// Die Render-Route liest den Tenant NUR über resolveApexTenant(host),
|
|
799
|
+
// nie über den Client-Header — anders als bei Query/Write-Dispatch gibt
|
|
800
|
+
// es hier also gar keinen X-Tenant-Override-Vektor. Ein gesetzter,
|
|
801
|
+
// abweichender Header wird schlicht ignoriert: 200, weiterhin A's Page.
|
|
802
|
+
const res = await authStack.app.request("http://a.example.com/p/about", {
|
|
803
|
+
headers: { "X-Tenant": TENANT_B },
|
|
804
|
+
});
|
|
805
|
+
expect(res.status).toBe(200);
|
|
806
|
+
const body = await res.text();
|
|
807
|
+
expect(body).toContain("About A");
|
|
808
|
+
expect(body).not.toContain("About B");
|
|
809
|
+
});
|
|
810
|
+
});
|
|
@@ -34,22 +34,27 @@ const ADMIN_ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] } as const;
|
|
|
34
34
|
const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
|
|
35
35
|
|
|
36
36
|
// QN-Konstante als dokumentierter Public-Contract — der Render-Pfad ruft
|
|
37
|
-
// die by-slug-Query via
|
|
38
|
-
// symmetrisch zum legal-pages-Muster).
|
|
37
|
+
// die by-slug-Query via systemQuery in-process (kein Code-Import des
|
|
38
|
+
// Handlers, symmetrisch zum legal-pages-Muster).
|
|
39
39
|
const BY_SLUG_QN = "managed-pages:query:by-slug";
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
// Minimal shape of the httpRoute handler's `{ systemQuery }` dep — mirrors
|
|
42
|
+
// http-route.ts's signature. Not importing HttpRouteHandlerDeps itself: it
|
|
43
|
+
// isn't part of the engine's public surface (only the httpRoute-
|
|
44
|
+
// registration types are; same convention as seo/feature.ts's FetchApp).
|
|
45
|
+
type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
|
|
46
|
+
|
|
47
|
+
// Raw Handler-Return von bySlugQuery (published-only) — systemQuery dispatcht
|
|
48
|
+
// direkt gegen den Handler, keine `{data}`-Envelope wie am /api/query-Wire.
|
|
49
|
+
type BySlugQueryResult = {
|
|
50
|
+
title: string;
|
|
51
|
+
body: string;
|
|
52
|
+
lang: string;
|
|
53
|
+
description: string | null;
|
|
54
|
+
ogImage: string | null;
|
|
55
|
+
version: number;
|
|
56
|
+
updatedAt: string;
|
|
57
|
+
} | null;
|
|
53
58
|
|
|
54
59
|
function brandingRevisionSeed(branding: BrandingTokens): string {
|
|
55
60
|
return JSON.stringify([
|
|
@@ -63,14 +68,15 @@ function brandingRevisionSeed(branding: BrandingTokens): string {
|
|
|
63
68
|
]);
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
async function
|
|
70
|
-
|
|
71
|
+
// Never throws: a query failure or malformed result degrades to the
|
|
72
|
+
// unbranded default (branding is decoration, not a hard dependency of the
|
|
73
|
+
// page render).
|
|
74
|
+
async function readBrandingViaSystemQuery(
|
|
75
|
+
systemQuery: SystemQueryFn,
|
|
76
|
+
tenantId: string,
|
|
77
|
+
): Promise<BrandingTokens> {
|
|
71
78
|
try {
|
|
72
|
-
|
|
73
|
-
return coerceBranding(body.data);
|
|
79
|
+
return coerceBranding(await systemQuery(BRANDING_QUERY_QN, {}, tenantId));
|
|
74
80
|
} catch {
|
|
75
81
|
return EMPTY_BRANDING;
|
|
76
82
|
}
|
|
@@ -205,7 +211,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
|
|
|
205
211
|
method: "GET",
|
|
206
212
|
path: `${basePath}/:slug`,
|
|
207
213
|
anonymous: true,
|
|
208
|
-
handler: async (c, {
|
|
214
|
+
handler: async (c, { systemQuery }) => {
|
|
209
215
|
// `param("slug")` ist string|undefined, weil `path` ein computed
|
|
210
216
|
// Template ist (Hono inferiert `:slug` nur aus String-Literalen).
|
|
211
217
|
const slug = c.req.param("slug");
|
|
@@ -218,31 +224,25 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
|
|
|
218
224
|
const tenantId = await opts.resolveApexTenant(host);
|
|
219
225
|
if (!tenantId) return c.text("not found", 404);
|
|
220
226
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// X-Tenant). Branding is decoration → a failed/empty branding read
|
|
234
|
-
// degrades to the unbranded default, it never blocks the page.
|
|
235
|
-
const [pageRes, brandingRes] = await Promise.all([
|
|
236
|
-
queryReq(BY_SLUG_QN, { slug, lang }),
|
|
237
|
-
queryReq(BRANDING_QUERY_QN, {}),
|
|
227
|
+
// Page + branding read in parallel, beide über systemQuery in-
|
|
228
|
+
// process gegen den host-resolved tenantId erzwungen (kein X-Tenant-
|
|
229
|
+
// Header-Trick über app.fetch — der sähe für einen host-basierten
|
|
230
|
+
// anonymousAccess-Resolver wie ein Client-Override aus und würde
|
|
231
|
+
// von resolverTrust: "authoritative" zurecht abgelehnt; siehe
|
|
232
|
+
// systemQuery's Doc in http-route.ts). Branding ist Deko → ein
|
|
233
|
+
// Fehlschlag degradiert zum unbranded Default, blockt nie die Page.
|
|
234
|
+
const [pageSettled, brandingResult] = await Promise.all([
|
|
235
|
+
systemQuery(BY_SLUG_QN, { slug, lang }, tenantId)
|
|
236
|
+
.then((data) => ({ ok: true as const, data: data as BySlugQueryResult }))
|
|
237
|
+
.catch(() => ({ ok: false as const, data: null as BySlugQueryResult })),
|
|
238
|
+
readBrandingViaSystemQuery(systemQuery, tenantId),
|
|
238
239
|
]);
|
|
239
|
-
if (!
|
|
240
|
+
if (!pageSettled.ok) return c.text("page unavailable", 503);
|
|
240
241
|
|
|
241
|
-
const
|
|
242
|
-
const data = body.data;
|
|
242
|
+
const data = pageSettled.data;
|
|
243
243
|
if (!data) return c.text("not found", 404);
|
|
244
244
|
|
|
245
|
-
const branding =
|
|
245
|
+
const branding = brandingResult;
|
|
246
246
|
|
|
247
247
|
const etag = computeRevisionEtag([
|
|
248
248
|
tenantId,
|
|
@@ -280,3 +280,71 @@ describe("seo :: runSeoBootCheck (direct unit-tests)", () => {
|
|
|
280
280
|
}
|
|
281
281
|
});
|
|
282
282
|
});
|
|
283
|
+
|
|
284
|
+
// resolverTrust: "authoritative" (kumiko-platform#278/1, show-pony#51) —
|
|
285
|
+
// dieselbe Regression wie managed-pages' Suite, für sitemap.xml/llms.txt.
|
|
286
|
+
// gatherEntries' interner Self-Fetch für die by-tenant-published-Query trug
|
|
287
|
+
// keinen Host-Header; ein fallback-freier Resolver (wie hier, wie
|
|
288
|
+
// publicstatus' echter Resolver) löste dafür null auf → tenant_required
|
|
289
|
+
// statt der Managed-Pages-Einträge. systemQuery behebt das (siehe
|
|
290
|
+
// managed-pages' Suite für die volle Mechanismus-Erklärung + Rot/Grün-Beweis).
|
|
291
|
+
describe("seo :: resolverTrust authoritative (host-based, wie publicstatus/show-pony)", () => {
|
|
292
|
+
let authStack: TestStack;
|
|
293
|
+
const authManaged = createManagedPagesFeature({
|
|
294
|
+
resolveApexTenant: (host) => (host.startsWith("a.") ? TENANT_A : null),
|
|
295
|
+
});
|
|
296
|
+
const authSeo = createSeoFeature({
|
|
297
|
+
sitemapEntries: (host) => [{ loc: `https://${host}/`, changefreq: "daily" }],
|
|
298
|
+
managedPages: {
|
|
299
|
+
resolveApexTenant: (host) => (host.startsWith("a.") ? TENANT_A : null),
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
const authConfigFeature = createConfigFeature();
|
|
303
|
+
|
|
304
|
+
beforeAll(async () => {
|
|
305
|
+
const resolver = createConfigResolver();
|
|
306
|
+
authStack = await setupTestStack({
|
|
307
|
+
features: [authConfigFeature, authManaged, authSeo],
|
|
308
|
+
anonymousAccess: {
|
|
309
|
+
// KEIN URL-Fallback — spiegelt publicstatus' echten Resolver. Ein
|
|
310
|
+
// interner Self-Fetch trägt keinen Host-Header; mit Fallback würde
|
|
311
|
+
// dieser Test den Bug maskieren (siehe managed-pages' Suite).
|
|
312
|
+
tenantResolver: (c) => ((c.req.header("host") ?? "").startsWith("a.") ? TENANT_A : null),
|
|
313
|
+
resolverTrust: "authoritative",
|
|
314
|
+
tenantExists: async (id) => id === TENANT_A,
|
|
315
|
+
},
|
|
316
|
+
extraContext: ({ registry }) => ({
|
|
317
|
+
configResolver: resolver,
|
|
318
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
319
|
+
}),
|
|
320
|
+
});
|
|
321
|
+
await unsafeCreateEntityTable(authStack.db, pageEntity);
|
|
322
|
+
await unsafePushTables(authStack.db, { configValuesTable });
|
|
323
|
+
await createEventsTable(authStack.db);
|
|
324
|
+
await seedPage(authStack.db, {
|
|
325
|
+
tenantId: TENANT_A,
|
|
326
|
+
slug: "about",
|
|
327
|
+
lang: "en",
|
|
328
|
+
title: "About",
|
|
329
|
+
body: "# About",
|
|
330
|
+
published: true,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
afterAll(async () => {
|
|
335
|
+
await authStack.cleanup();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("sitemap.xml → 200, merges managed-pages' published slugs via systemQuery", async () => {
|
|
339
|
+
const res = await authStack.app.request("http://a.example.com/sitemap.xml");
|
|
340
|
+
expect(res.status).toBe(200);
|
|
341
|
+
const xml = await res.text();
|
|
342
|
+
expect(xml).toContain("<loc>https://a.example.com/</loc>");
|
|
343
|
+
expect(xml).toContain("<loc>http://a.example.com/p/about</loc>");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("llms.txt → 200", async () => {
|
|
347
|
+
const res = await authStack.app.request("http://a.example.com/llms.txt");
|
|
348
|
+
expect(res.status).toBe(200);
|
|
349
|
+
});
|
|
350
|
+
});
|
package/src/seo/feature.ts
CHANGED
|
@@ -19,11 +19,11 @@ const SEO_CACHE = { kind: "revalidate", maxAgeSeconds: 300 } as const;
|
|
|
19
19
|
const MANAGED_PAGES_BY_TENANT_PUBLISHED_QN = "managed-pages:query:by-tenant-published";
|
|
20
20
|
const SEO_CONFIG_QUERY_QN = "seo:query:config";
|
|
21
21
|
|
|
22
|
-
// Minimal shape of the httpRoute handler's `{
|
|
23
|
-
//
|
|
24
|
-
// itself: it isn't part of the engine's public surface
|
|
25
|
-
// httpRoute-registration types are).
|
|
26
|
-
type
|
|
22
|
+
// Minimal shape of the httpRoute handler's `{ systemQuery }` dep — just
|
|
23
|
+
// enough to force a specific tenant in-process. Not importing
|
|
24
|
+
// HttpRouteHandlerDeps itself: it isn't part of the engine's public surface
|
|
25
|
+
// (only the httpRoute-registration types are).
|
|
26
|
+
type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
|
|
27
27
|
|
|
28
28
|
export type ManagedPagesDiscoveryOptions = {
|
|
29
29
|
/** Same per-host tenant resolver the app already passes to
|
|
@@ -76,26 +76,18 @@ const EMPTY_SEO_CONFIG: SeoConfigValues = {
|
|
|
76
76
|
defaultOgImage: "",
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
function queryRequest(origin: string, tenantId: string, type: string, payload: unknown): Request {
|
|
80
|
-
return new Request(`${origin}/api/query`, {
|
|
81
|
-
method: "POST",
|
|
82
|
-
headers: { "content-type": "application/json", "X-Tenant": tenantId },
|
|
83
|
-
body: JSON.stringify({ type, payload }),
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
79
|
// Config is decoration, not a hard dependency — a failed/unreachable read
|
|
88
80
|
// degrades to empty strings (same posture as managed-pages' readBrandingResponse).
|
|
81
|
+
// systemQuery forces tenantId in-process — no internal X-Tenant self-fetch,
|
|
82
|
+
// which a host-based anonymousAccess resolver in "authoritative" mode would
|
|
83
|
+
// reject as a forged client override.
|
|
89
84
|
async function readSeoConfig(
|
|
90
|
-
|
|
91
|
-
origin: string,
|
|
85
|
+
systemQuery: SystemQueryFn,
|
|
92
86
|
tenantId: string,
|
|
93
87
|
): Promise<SeoConfigValues> {
|
|
94
88
|
try {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
const body: { data?: Partial<SeoConfigValues> } = await res.json();
|
|
98
|
-
return { ...EMPTY_SEO_CONFIG, ...body.data };
|
|
89
|
+
const data = (await systemQuery(SEO_CONFIG_QUERY_QN, {}, tenantId)) as Partial<SeoConfigValues>;
|
|
90
|
+
return { ...EMPTY_SEO_CONFIG, ...data };
|
|
99
91
|
} catch {
|
|
100
92
|
return EMPTY_SEO_CONFIG;
|
|
101
93
|
}
|
|
@@ -104,13 +96,13 @@ async function readSeoConfig(
|
|
|
104
96
|
// Merges the app callback with the optional legal-pages/managed-pages
|
|
105
97
|
// sources. legal-pages routes are static (no per-tenant data) — merged
|
|
106
98
|
// directly from the public LEGAL_ROUTES constant. managed-pages entries need
|
|
107
|
-
// a live query (per-tenant published slugs) — merged via
|
|
99
|
+
// a live query (per-tenant published slugs) — merged via systemQuery, same
|
|
108
100
|
// cross-feature decoupling as legal-pages' own text-content calls; a
|
|
109
101
|
// failed/unreachable read degrades to "no managed-pages entries" rather than
|
|
110
102
|
// failing the whole route.
|
|
111
103
|
async function gatherEntries(
|
|
112
104
|
opts: SeoOptions,
|
|
113
|
-
|
|
105
|
+
systemQuery: SystemQueryFn,
|
|
114
106
|
origin: string,
|
|
115
107
|
host: string,
|
|
116
108
|
): Promise<SitemapEntry[]> {
|
|
@@ -126,21 +118,16 @@ async function gatherEntries(
|
|
|
126
118
|
const tenantId = await opts.managedPages.resolveApexTenant(host);
|
|
127
119
|
if (tenantId) {
|
|
128
120
|
try {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
loc: `${origin}${basePath}/${page.slug}`,
|
|
140
|
-
title: page.title,
|
|
141
|
-
lastmod: page.updatedAt,
|
|
142
|
-
});
|
|
143
|
-
}
|
|
121
|
+
const data = (await systemQuery(MANAGED_PAGES_BY_TENANT_PUBLISHED_QN, {}, tenantId)) as {
|
|
122
|
+
pages?: readonly { slug: string; title: string; updatedAt: string }[];
|
|
123
|
+
};
|
|
124
|
+
const basePath = opts.managedPages.basePath ?? "/p";
|
|
125
|
+
for (const page of data.pages ?? []) {
|
|
126
|
+
entries.push({
|
|
127
|
+
loc: `${origin}${basePath}/${page.slug}`,
|
|
128
|
+
title: page.title,
|
|
129
|
+
lastmod: page.updatedAt,
|
|
130
|
+
});
|
|
144
131
|
}
|
|
145
132
|
} catch {
|
|
146
133
|
// managed-pages unreachable/not mounted — degrade to callback-only entries.
|
|
@@ -203,9 +190,9 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
|
|
|
203
190
|
method: "GET",
|
|
204
191
|
path: paths.sitemap,
|
|
205
192
|
anonymous: true,
|
|
206
|
-
handler: async (c, {
|
|
193
|
+
handler: async (c, { systemQuery }) => {
|
|
207
194
|
const { origin, host } = requestHost(c);
|
|
208
|
-
const entries = await gatherEntries(opts,
|
|
195
|
+
const entries = await gatherEntries(opts, systemQuery, origin, host);
|
|
209
196
|
const xml = buildSitemapXml(entries);
|
|
210
197
|
const etag = computeRevisionEtag([host, xml]);
|
|
211
198
|
return cachedSecurePageResponse(c.req.raw, {
|
|
@@ -221,14 +208,14 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
|
|
|
221
208
|
method: "GET",
|
|
222
209
|
path: paths.llmsTxt,
|
|
223
210
|
anonymous: true,
|
|
224
|
-
handler: async (c, {
|
|
211
|
+
handler: async (c, { systemQuery }) => {
|
|
225
212
|
const { origin, host } = requestHost(c);
|
|
226
213
|
const tenantId = opts.managedPages
|
|
227
214
|
? ((await opts.managedPages.resolveApexTenant(host)) ?? SYSTEM_TENANT_ID)
|
|
228
215
|
: SYSTEM_TENANT_ID;
|
|
229
216
|
const [entries, seoConfig] = await Promise.all([
|
|
230
|
-
gatherEntries(opts,
|
|
231
|
-
readSeoConfig(
|
|
217
|
+
gatherEntries(opts, systemQuery, origin, host),
|
|
218
|
+
readSeoConfig(systemQuery, tenantId),
|
|
232
219
|
]);
|
|
233
220
|
const sections =
|
|
234
221
|
entries.length > 0
|