@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.147.2",
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.147.2",
118
- "@cosmicdrift/kumiko-framework": "0.147.2",
119
- "@cosmicdrift/kumiko-headless": "0.147.2",
120
- "@cosmicdrift/kumiko-renderer": "0.147.2",
121
- "@cosmicdrift/kumiko-renderer-web": "0.147.2",
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 (!screen || screen.type !== "custom") {
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 (!screen || screen.type !== "custom") {
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
  );
@@ -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, Card, Heading } = usePrimitives();
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
- <Card options={{ padded: true }} className="flex flex-col gap-3">
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
- <Button
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
- <Card options={{ padded: true }} className="flex flex-col gap-4">
116
- <div className="flex flex-col gap-2">
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 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
  }
@@ -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,
@@ -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
+ });
@@ -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 `{ app }` dep — just enough to
23
- // make an internal /api/query call. Not importing HttpRouteHandlerDeps
24
- // itself: it isn't part of the engine's public surface (only the
25
- // httpRoute-registration types are).
26
- type FetchApp = { readonly fetch: (req: Request) => Promise<Response> | Response };
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
- app: FetchApp,
91
- origin: string,
85
+ systemQuery: SystemQueryFn,
92
86
  tenantId: string,
93
87
  ): Promise<SeoConfigValues> {
94
88
  try {
95
- const res = await app.fetch(queryRequest(origin, tenantId, SEO_CONFIG_QUERY_QN, {}));
96
- if (!res.ok) return EMPTY_SEO_CONFIG;
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 app.fetch, same
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
- app: FetchApp,
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 res = await app.fetch(
130
- queryRequest(origin, tenantId, MANAGED_PAGES_BY_TENANT_PUBLISHED_QN, {}),
131
- );
132
- if (res.ok) {
133
- const body: {
134
- data?: { pages?: readonly { slug: string; title: string; updatedAt: string }[] };
135
- } = await res.json();
136
- const basePath = opts.managedPages.basePath ?? "/p";
137
- for (const page of body.data?.pages ?? []) {
138
- entries.push({
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, { app }) => {
193
+ handler: async (c, { systemQuery }) => {
207
194
  const { origin, host } = requestHost(c);
208
- const entries = await gatherEntries(opts, app, origin, host);
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, { app }) => {
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, app, origin, host),
231
- readSeoConfig(app, origin, tenantId),
217
+ gatherEntries(opts, systemQuery, origin, host),
218
+ readSeoConfig(systemQuery, tenantId),
232
219
  ]);
233
220
  const sections =
234
221
  entries.length > 0