@cosmicdrift/kumiko-bundled-features 0.147.1 → 0.147.3

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 (57) hide show
  1. package/package.json +12 -7
  2. package/src/auth-email-password/feature.ts +6 -1
  3. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  4. package/src/auth-email-password/handlers/login.write.ts +66 -9
  5. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +15 -5
  6. package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
  7. package/src/auth-email-password/web/auth-client.ts +34 -12
  8. package/src/auth-email-password/web/auth-gate.tsx +32 -3
  9. package/src/auth-email-password/web/client-plugin.ts +11 -2
  10. package/src/auth-email-password/web/index.ts +4 -2
  11. package/src/auth-email-password/web/login-screen.tsx +31 -2
  12. package/src/auth-email-password/web/session.tsx +11 -6
  13. package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +202 -0
  14. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +150 -0
  15. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +167 -0
  16. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +168 -0
  17. package/src/auth-mfa/__tests__/verify.integration.test.ts +186 -0
  18. package/src/auth-mfa/config.ts +26 -0
  19. package/src/auth-mfa/constants.ts +25 -0
  20. package/src/auth-mfa/db/queries.ts +34 -0
  21. package/src/auth-mfa/errors.ts +73 -0
  22. package/src/auth-mfa/feature.ts +146 -0
  23. package/src/auth-mfa/handlers/disable.write.ts +51 -0
  24. package/src/auth-mfa/handlers/enable-confirm.write.ts +68 -0
  25. package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
  26. package/src/auth-mfa/handlers/reencrypt.job.ts +185 -0
  27. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +62 -0
  28. package/src/auth-mfa/handlers/verify.write.ts +141 -0
  29. package/src/auth-mfa/i18n.ts +12 -0
  30. package/src/auth-mfa/index.ts +15 -0
  31. package/src/auth-mfa/mfa-challenge-token.ts +90 -0
  32. package/src/auth-mfa/mfa-setup-token.ts +90 -0
  33. package/src/auth-mfa/mfa-status-checker.ts +75 -0
  34. package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
  35. package/src/auth-mfa/recovery-codes.ts +44 -0
  36. package/src/auth-mfa/schema/user-mfa.ts +40 -0
  37. package/src/auth-mfa/totp.ts +8 -0
  38. package/src/auth-mfa/verify-factor.ts +30 -0
  39. package/src/auth-mfa/web/client-plugin.ts +37 -0
  40. package/src/auth-mfa/web/i18n.ts +69 -0
  41. package/src/auth-mfa/web/index.ts +15 -0
  42. package/src/auth-mfa/web/mfa-client.ts +60 -0
  43. package/src/auth-mfa/web/mfa-enable-screen.tsx +187 -0
  44. package/src/auth-mfa/web/mfa-verify-screen.tsx +106 -0
  45. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +132 -0
  46. package/src/auth-mfa-user-data/hooks.ts +53 -0
  47. package/src/auth-mfa-user-data/index.ts +21 -0
  48. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +4 -0
  49. package/src/legal-pages/feature.ts +19 -28
  50. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +111 -0
  51. package/src/managed-pages/feature.ts +44 -44
  52. package/src/seo/__tests__/seo.integration.test.ts +68 -0
  53. package/src/seo/feature.ts +28 -41
  54. package/src/sessions/feature.ts +2 -1
  55. package/src/sessions/session-callbacks.ts +23 -0
  56. package/src/shared/index.ts +1 -0
  57. package/src/{auth-email-password → shared}/token-burn-store.ts +1 -1
@@ -0,0 +1,106 @@
1
+ // @runtime client
2
+ // Login-Challenge-Step, gerendert wenn LoginScreen's onMfaChallenge feuert
3
+ // (/auth/login antwortete mfaRequired). Stilistisch wie login-screen.tsx:
4
+ // useState + direkter fetch-Call, kein Dispatcher — es existiert noch kein
5
+ // JWT. Nach Erfolg ruft es session.refresh() statt eines Hard-Reloads, weil
6
+ // der Aufrufer (typisch ein Wizard-State im App-Root) sofort neu rendern
7
+ // will, sobald status:"authenticated" ankommt.
8
+
9
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
10
+ import { type FormEvent, type ReactNode, useState } from "react";
11
+ // kumiko-lint-ignore cross-feature-import client-only barrel, the feature's server barrel has no web/ re-export
12
+ import { AuthCard, useSession } from "../../auth-email-password/web";
13
+ import { verifyMfaChallenge } from "./mfa-client";
14
+
15
+ export type MfaVerifyScreenProps = {
16
+ readonly challengeToken: string;
17
+ readonly title?: string;
18
+ readonly subtitle?: ReactNode;
19
+ readonly submitLabel?: string;
20
+ /** Called after the server confirms the code and the session state has
21
+ * refreshed. Optional — apps that swap this screen back out purely on
22
+ * session.status changing to "authenticated" don't need it. */
23
+ readonly onSuccess?: () => void;
24
+ };
25
+
26
+ function reasonToKey(reason: string): string {
27
+ switch (reason) {
28
+ case "invalid_totp_code":
29
+ case "invalid_recovery_code":
30
+ case "invalid_code":
31
+ return "auth.mfa.errors.invalidCode";
32
+ case "challenge_expired":
33
+ case "invalid_challenge_token":
34
+ return "auth.mfa.errors.challengeExpired";
35
+ case "too_many_attempts":
36
+ return "auth.mfa.errors.tooManyAttempts";
37
+ case "rate_limited":
38
+ return "auth.errors.rateLimited";
39
+ default:
40
+ return "auth.mfa.errors.verifyFailed";
41
+ }
42
+ }
43
+
44
+ export function MfaVerifyScreen({
45
+ challengeToken,
46
+ title,
47
+ subtitle,
48
+ submitLabel,
49
+ onSuccess,
50
+ }: MfaVerifyScreenProps): ReactNode {
51
+ const t = useTranslation();
52
+ const { Form, Field, Input, Button, Banner } = usePrimitives();
53
+ const session = useSession();
54
+ const [code, setCode] = useState("");
55
+ const [submitting, setSubmitting] = useState(false);
56
+ const [error, setError] = useState<string | null>(null);
57
+
58
+ const doSubmit = async (): Promise<void> => {
59
+ setSubmitting(true);
60
+ setError(null);
61
+ const res = await verifyMfaChallenge(challengeToken, code);
62
+ if (res.kind === "success") {
63
+ await session.refresh();
64
+ setSubmitting(false);
65
+ onSuccess?.();
66
+ return;
67
+ }
68
+ setSubmitting(false);
69
+ setError(res.error.reason);
70
+ };
71
+
72
+ const onSubmit = (e?: FormEvent): void => {
73
+ e?.preventDefault();
74
+ void doSubmit();
75
+ };
76
+
77
+ return (
78
+ <AuthCard
79
+ title={title ?? t("auth.mfa.verify.title")}
80
+ subtitle={subtitle ?? t("auth.mfa.verify.subtitle")}
81
+ >
82
+ <div className="p-6 pt-0 flex flex-col gap-4">
83
+ <Form onSubmit={onSubmit}>
84
+ <Field id="mfa-verify-code" label={t("auth.mfa.verify.code")} required>
85
+ <Input
86
+ kind="text"
87
+ id="mfa-verify-code"
88
+ name="mfa-verify-code"
89
+ value={code}
90
+ onChange={setCode}
91
+ disabled={submitting}
92
+ required
93
+ autoComplete="one-time-code"
94
+ />
95
+ </Field>
96
+ {error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
97
+ <Button type="submit" loading={submitting} disabled={submitting}>
98
+ {submitting
99
+ ? t("auth.mfa.verify.submitting")
100
+ : (submitLabel ?? t("auth.mfa.verify.submit"))}
101
+ </Button>
102
+ </Form>
103
+ </div>
104
+ </AuthCard>
105
+ );
106
+ }
@@ -0,0 +1,132 @@
1
+ // userMfaExportHook/userMfaDeleteHook — GDPR export must never surface the
2
+ // TOTP secret or recovery codes, and forget must actually remove the row
3
+ // (not just leave it dangling for a future projection rebuild to resurrect).
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
7
+ import {
8
+ createTestUser,
9
+ setupTestStack,
10
+ type TestStack,
11
+ unsafeCreateEntityTable,
12
+ unsafePushTables,
13
+ } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
15
+ import { base32Decode } from "../../auth-mfa/base32";
16
+ import { AuthMfaHandlers } from "../../auth-mfa/constants";
17
+ import { createAuthMfaFeature } from "../../auth-mfa/feature";
18
+ import { userMfaEntity } from "../../auth-mfa/schema/user-mfa";
19
+ import { currentTotpCode } from "../../auth-mfa/totp";
20
+ import { createConfigFeature } from "../../config";
21
+ import { createConfigResolver } from "../../config/resolver";
22
+ import { configValuesTable } from "../../config/table";
23
+ import { createUserFeature } from "../../user/feature";
24
+ import { userEntity } from "../../user/schema/user";
25
+ import { userMfaDeleteHook, userMfaExportHook } from "../hooks";
26
+
27
+ let stack: TestStack;
28
+
29
+ const SETUP_TOKEN_SECRET = "test-setup-token-secret-do-not-use-in-prod";
30
+
31
+ beforeAll(async () => {
32
+ const encryption = createTestEnvelopeCipher();
33
+ configureEntityFieldEncryption(encryption);
34
+ const resolver = createConfigResolver({ cipher: encryption });
35
+ stack = await setupTestStack({
36
+ features: [
37
+ createConfigFeature(),
38
+ createUserFeature(),
39
+ createAuthMfaFeature({
40
+ setupTokenSecret: SETUP_TOKEN_SECRET,
41
+ issuer: "Kumiko Test",
42
+ challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
43
+ }),
44
+ ],
45
+ extraContext: { configResolver: resolver, configEncryption: encryption },
46
+ });
47
+ await unsafeCreateEntityTable(stack.db, userEntity);
48
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
49
+ await unsafePushTables(stack.db, { configValuesTable });
50
+ });
51
+
52
+ afterAll(async () => {
53
+ await stack.cleanup();
54
+ });
55
+
56
+ async function enrollUser(userId: string) {
57
+ const user = createTestUser({ id: userId, roles: ["User"] });
58
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
59
+ AuthMfaHandlers.enableStart,
60
+ { accountLabel: `${userId}@example.com` },
61
+ user,
62
+ );
63
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
64
+ const secret = base32Decode(secretParam);
65
+ await stack.http.writeOk(
66
+ AuthMfaHandlers.enableConfirm,
67
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
68
+ user,
69
+ );
70
+ return user;
71
+ }
72
+
73
+ describe("userMfaExportHook / userMfaDeleteHook", () => {
74
+ test("export confirms enrollment without leaking the TOTP secret or recovery codes", async () => {
75
+ const user = await enrollUser("export-1");
76
+
77
+ const snippet = await userMfaExportHook({
78
+ db: stack.db,
79
+ registry: stack.registry,
80
+ tenantId: user.tenantId,
81
+ userId: user.id,
82
+ });
83
+
84
+ expect(snippet).not.toBeNull();
85
+ expect(snippet?.rows).toHaveLength(1);
86
+ const row = snippet?.rows[0] as Record<string, unknown>;
87
+ expect(row["enrolled"]).toBe(true);
88
+ expect(JSON.stringify(row)).not.toContain("totpSecret");
89
+ expect(JSON.stringify(row)).not.toContain("recoveryCodes");
90
+ });
91
+
92
+ test("export returns null for a user who never enrolled", async () => {
93
+ const user = createTestUser({ id: "never-enrolled", roles: ["User"] });
94
+ const snippet = await userMfaExportHook({
95
+ db: stack.db,
96
+ registry: stack.registry,
97
+ tenantId: user.tenantId,
98
+ userId: user.id,
99
+ });
100
+ expect(snippet).toBeNull();
101
+ });
102
+
103
+ test("delete hard-removes the row via forget (rebuild-safe)", async () => {
104
+ const user = await enrollUser("delete-1");
105
+
106
+ const before = await userMfaExportHook({
107
+ db: stack.db,
108
+ registry: stack.registry,
109
+ tenantId: user.tenantId,
110
+ userId: user.id,
111
+ });
112
+ expect(before).not.toBeNull();
113
+
114
+ await userMfaDeleteHook(
115
+ {
116
+ db: stack.db,
117
+ registry: stack.registry,
118
+ tenantId: user.tenantId,
119
+ userId: user.id,
120
+ },
121
+ "delete",
122
+ );
123
+
124
+ const after = await userMfaExportHook({
125
+ db: stack.db,
126
+ registry: stack.registry,
127
+ tenantId: user.tenantId,
128
+ userId: user.id,
129
+ });
130
+ expect(after).toBeNull();
131
+ });
132
+ });
@@ -0,0 +1,53 @@
1
+ // EXT_USER_DATA hooks for userMfa (GDPR Art. 15/17/20). Both totpSecret and
2
+ // recoveryCodes are marked userOwned in the schema for exactly this: on
3
+ // forget, the row must actually disappear — a leftover TOTP secret or
4
+ // recovery-code hash under an anonymized user is still a live credential
5
+ // nobody can rotate. Mirrors folders-user-data/hooks.ts's executor-based
6
+ // delete (rebuild-safe: forget replays via the event, a raw DELETE would be
7
+ // resurrected by a projection rebuild).
8
+
9
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
11
+ import {
12
+ createSystemUser,
13
+ type UserDataDeleteHook,
14
+ type UserDataExportHook,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { userMfaEntity, userMfaTable } from "../auth-mfa";
17
+
18
+ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
19
+ entityName: "user-mfa",
20
+ });
21
+
22
+ // Never includes totpSecret/recoveryCodes — secret-derived credential
23
+ // material, not portable personal data (same reasoning as api-token's
24
+ // tokenHash exclusion in user-data-rights-defaults). Just confirms
25
+ // enrollment + when, which is what a data subject actually needs to see.
26
+ export const userMfaExportHook: UserDataExportHook = async (ctx) => {
27
+ const rows = await selectMany<{ id: string; enabledAt: unknown }>(ctx.db, userMfaTable, {
28
+ userId: ctx.userId,
29
+ tenantId: ctx.tenantId,
30
+ });
31
+ if (rows.length === 0) return null;
32
+ return {
33
+ entity: "user-mfa",
34
+ rows: rows.map((r) => ({ enrolled: true, enabledAt: String(r.enabledAt ?? "") })),
35
+ };
36
+ };
37
+
38
+ // Always a real hard-delete regardless of the incoming strategy — a 2FA
39
+ // secret can't be anonymized, and userOwned already means this row belongs
40
+ // to exactly one user (never shared tenant data).
41
+ export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
42
+ const rows = await selectMany<{ id: string }>(ctx.db, userMfaTable, {
43
+ userId: ctx.userId,
44
+ tenantId: ctx.tenantId,
45
+ });
46
+ // skip: nothing enrolled for this user — no row to erase
47
+ if (rows.length === 0) return;
48
+ const systemUser = createSystemUser(ctx.tenantId);
49
+ const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
50
+ for (const row of rows) {
51
+ await executor.forget({ id: row.id }, systemUser, tdb);
52
+ }
53
+ };
@@ -0,0 +1,21 @@
1
+ // Provides the EXT_USER_DATA export/delete hooks for the `user-mfa` entity as
2
+ // a standalone feature — mount it alongside `auth-mfa` + `user-data-rights`
3
+ // when an app needs 2FA enrollment in its GDPR export/forget pipeline. Kept
4
+ // separate from `auth-mfa` (which only requires "user"/"config") so auth-mfa
5
+ // consumers without the user-data-rights stack don't pull a hard dependency.
6
+ // Mirrors folders-user-data.
7
+
8
+ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { userMfaDeleteHook, userMfaExportHook } from "./hooks";
10
+
11
+ export const authMfaUserDataFeature = defineFeature("auth-mfa-user-data", (r) => {
12
+ r.describe(
13
+ "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `auth-mfa` feature's `user-mfa` entity. Mounts the EXT_USER_DATA export + delete hooks so 2FA enrollment status is included in the user-data export bundle and a user's TOTP secret + recovery codes are hard-deleted (via executor.forget, rebuild-safe) on a data-subject erasure request. Kept separate from `auth-mfa` so consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `auth-mfa`.",
14
+ );
15
+ r.requires("user-data-rights");
16
+ r.optionalRequires("auth-mfa");
17
+ r.useExtension(EXT_USER_DATA, "user-mfa", {
18
+ export: userMfaExportHook,
19
+ delete: userMfaDeleteHook,
20
+ });
21
+ });
@@ -238,6 +238,10 @@ describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", ()
238
238
  // Resolver gibt IMMER einen anderen Tenant zurück — wenn legal-
239
239
  // pages den respektieren würde, wäre der DB-Lookup leer.
240
240
  tenantResolver: () => otherTenantId,
241
+ // Mirrors publicstatus's real subdomain resolver: the tenant is
242
+ // derived from the host, which the client cannot forge — trusted
243
+ // over any client-supplied tenant.
244
+ resolverTrust: "authoritative",
241
245
  tenantExists: async (id) => id === otherTenantId || id === SYSTEM_TENANT_ID,
242
246
  },
243
247
  extraContext: ({ db }) => ({
@@ -21,10 +21,8 @@ import { renderMarkdownToHtml, wrapInLayout } from "./markdown";
21
21
  // wie bei jedem API-Endpunkt.
22
22
  const TEXT_CONTENT_BY_SLUG_QN = "text-content:query:by-slug";
23
23
 
24
- // Wire-Body-Shape von /api/query — das, was bySlugQuery returnt.
25
- type ByslugQueryBody = {
26
- data: { title: string; body: string | null; updatedAt: string } | null;
27
- };
24
+ // Return-Shape von bySlugQuery (text-content/handlers/by-slug.query.ts).
25
+ type TextBlockQueryResult = { title: string; body: string | null; updatedAt: string } | null;
28
26
 
29
27
  // 60s-shared-cache saves the origin-revalidate roundtrip; legal-content edits are live within 60s.
30
28
  const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
@@ -81,35 +79,28 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
81
79
  method: "GET",
82
80
  path: route.path,
83
81
  anonymous: true,
84
- handler: async (c, { app }) => {
85
- const url = new URL(c.req.url);
82
+ handler: async (c, { systemQuery }) => {
86
83
  // Architektur: 1 App = X Tenants = 1 Impressum. Egal welche
87
84
  // Subdomain der Visitor besucht (apex, admin.*, tenant-x.*) —
88
- // legal-pages serven IMMER die SYSTEM_TENANT-Texte. Deshalb
89
- // explizit X-Tenant-Header setzen statt host weiterreichen
90
- // (sonst würde ein host-basierter anonymousAccess-Resolver
91
- // die tenant-Subdomain auf tenant-tenantId resolven und
92
- // tenant-x's leere imprint-Tabelle abfragen → 404).
93
- const queryRes = await app.fetch(
94
- new Request(`${url.origin}/api/query`, {
95
- method: "POST",
96
- headers: {
97
- "content-type": "application/json",
98
- "X-Tenant": SYSTEM_TENANT_ID,
99
- },
100
- body: JSON.stringify({
101
- type: TEXT_CONTENT_BY_SLUG_QN,
102
- payload: { slug: route.slug, lang: route.lang },
103
- }),
104
- }),
105
- );
106
-
107
- if (!queryRes.ok) {
85
+ // legal-pages serven IMMER die SYSTEM_TENANT-Texte. systemQuery
86
+ // erzwingt SYSTEM_TENANT_ID in-process (kein X-Tenant-Header-
87
+ // Trick über app.fetch — der sähe für einen host-basierten
88
+ // anonymousAccess-Resolver wie ein Client-Override aus und würde
89
+ // von resolverTrust: "authoritative" zurecht abgelehnt).
90
+ let data: TextBlockQueryResult;
91
+ try {
92
+ // @cast-boundary engine-payload — dispatcher.query returnt
93
+ // unknown; Shape ist durch bySlugQuery's Handler-Return-Type
94
+ // (siehe text-content/handlers/by-slug.query.ts) vertraut.
95
+ data = (await systemQuery(
96
+ TEXT_CONTENT_BY_SLUG_QN,
97
+ { slug: route.slug, lang: route.lang },
98
+ SYSTEM_TENANT_ID,
99
+ )) as TextBlockQueryResult;
100
+ } catch {
108
101
  return c.text("legal page unavailable", 503);
109
102
  }
110
103
 
111
- const body: ByslugQueryBody = await queryRes.json();
112
- const data = body.data;
113
104
  if (!data?.body) {
114
105
  return c.text(
115
106
  `${route.titleFallback} not configured. Tenant-Admin must set this text-block.`,
@@ -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 internem app.fetch (kein Code-Import des Handlers,
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
- // Wire-Body-Shape von /api/query das was bySlugQuery returnt (published-only).
42
- type ByslugQueryBody = {
43
- data: {
44
- title: string;
45
- body: string;
46
- lang: string;
47
- description: string | null;
48
- ogImage: string | null;
49
- version: number;
50
- updatedAt: string;
51
- } | null;
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
- // Parse the branding query's `{ data }` envelope into BrandingTokens, never
67
- // throwing: a non-ok status or malformed body degrades to the unbranded
68
- // default (branding is decoration, not a hard dependency of the page render).
69
- async function readBrandingResponse(res: Response): Promise<BrandingTokens> {
70
- if (!res.ok) return EMPTY_BRANDING;
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
- const body: { data?: unknown } = await res.json();
73
- return coerceBranding(body.data);
79
+ return coerceBranding(await systemQuery(BRANDING_QUERY_QN, {}, tenantId));
74
80
  } catch {
75
81
  return EMPTY_BRANDING;
76
82
  }
@@ -162,7 +168,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
162
168
 
163
169
  return defineFeature("managed-pages", (r) => {
164
170
  r.describe(
165
- "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
171
+ "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
166
172
  );
167
173
  r.uiHints({
168
174
  displayLabel: "Managed Pages · Public CMS",
@@ -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, { app }) => {
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
- const queryHeaders = { "content-type": "application/json", "X-Tenant": tenantId };
222
- const queryUrl = `${url.origin}/api/query`;
223
- const queryReq = (type: string, payload: unknown) =>
224
- app.fetch(
225
- new Request(queryUrl, {
226
- method: "POST",
227
- headers: queryHeaders,
228
- body: JSON.stringify({ type, payload }),
229
- }),
230
- );
231
-
232
- // Page + branding read in parallel (same in-process app, same
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 (!pageRes.ok) return c.text("page unavailable", 503);
240
+ if (!pageSettled.ok) return c.text("page unavailable", 503);
240
241
 
241
- const body: ByslugQueryBody = await pageRes.json();
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 = await readBrandingResponse(brandingRes);
245
+ const branding = brandingResult;
246
246
 
247
247
  const etag = computeRevisionEtag([
248
248
  tenantId,