From 213de59f945912602508e3163124bdc7640d25ee Mon Sep 17 00:00:00 2001 From: AI_Assistant Date: Sat, 5 Sep 2026 21:44:13 +1000 Subject: [PATCH] cmms-embed: fix cmms-config validation rejecting the real server response Browser-verified bug (2026-09-05, reported via the prod-side session): /api/cmms-config now returns 200 with aiEnabled:true, but the widget still never mounted. Root cause: isCmmsConfig() required entityType/entityId as strict strings, but the actual response omits them entirely - the whole payload failed validation and silently fell back to NO_WIDGET, with nothing logged to say why. Fix: only dark/aiEnabled/aiEditorKind actually gate anything - validate just those, and default csrfToken/entityType/entityId to "" when absent (they only feed the iframe's query string, harmless empty). Also added a .catch() + console.error on the widget's own boot effect, so a future mistake here logs instead of silently no-op'ing. Verified against the exact captured response body ({"dark":true,"aiEnabled":true,"aiEditorKind":"kicad","csrfToken":"..."}): now resolves aiEnabled:true. tsc --noEmit clean, vitest 469/469 passing. Also investigated the separately-reported "Uncaught Error: undefined self-hosted:8:1" console error: built the pre-patch source (0146488) and the patched source with an identical toolchain/base and diffed the eager main chunk - same file list, same wasm/collab/index.ts dynamic/static-import warning, ~4KB size delta matching only this patch's own new code. yjs/Y.Doc was ALREADY present in the pre-patch eager main chunk (grepped the unpatched build output directly), so this patch did not newly pull collab/yjs code into eager load - whatever's throwing predates this patch's chunk composition. Not provable further without a live browser (not available in this repo's env, see CLAUDE.md) - not a fix, just contrary evidence to "this patch's widget code caused it" for the prod-side session to browser-verify. Co-Authored-By: Claude Sonnet 5 --- .../src/components/CmmsAiWidget.tsx | 33 ++++++++++------- web/standalone/src/lib/cmms-config.ts | 36 +++++++++++++------ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/web/standalone/src/components/CmmsAiWidget.tsx b/web/standalone/src/components/CmmsAiWidget.tsx index a4953f3..3aabf5f 100644 --- a/web/standalone/src/components/CmmsAiWidget.tsx +++ b/web/standalone/src/components/CmmsAiWidget.tsx @@ -20,18 +20,27 @@ export function CmmsAiWidget(): React.ReactElement | null { React.useEffect(() => { let cancelled = false; - fetchCmmsConfig().then((c) => { - if (cancelled) return; - setConfig(c); - if (c.aiEnabled) installAiBridge(); - // Belt-and-braces alongside the `?theme=` hand-off (lib/theme.ts) — that - // param already wins whenever edit.php sets it, so this only matters if - // it's ever absent. Skip if the URL param already ran; setTheme is - // otherwise idempotent (persists + toggles the same `.dark` class). - if (c.dark && !new URLSearchParams(window.location.search).get("theme")) { - setTheme("dark"); - } - }); + // fetchCmmsConfig() never rejects (its own internal .catch() falls back + // to NO_WIDGET) — this .catch() guards the callback body itself instead + // (setConfig/installAiBridge/setTheme), so a mistake here becomes a + // logged no-op, not a silent unhandled rejection with nothing to say why + // the widget didn't appear. + fetchCmmsConfig() + .then((c) => { + if (cancelled) return; + setConfig(c); + if (c.aiEnabled) installAiBridge(); + // Belt-and-braces alongside the `?theme=` hand-off (lib/theme.ts) — + // that param already wins whenever edit.php sets it, so this only + // matters if it's ever absent. Skip if the URL param already ran; + // setTheme is otherwise idempotent (persists + toggles `.dark`). + if (c.dark && !new URLSearchParams(window.location.search).get("theme")) { + setTheme("dark"); + } + }) + .catch((err: unknown) => { + console.error("[cmms-ai-widget] boot failed:", err); + }); return () => { cancelled = true; }; diff --git a/web/standalone/src/lib/cmms-config.ts b/web/standalone/src/lib/cmms-config.ts index 754e157..4ede25a 100644 --- a/web/standalone/src/lib/cmms-config.ts +++ b/web/standalone/src/lib/cmms-config.ts @@ -15,17 +15,22 @@ export interface CmmsConfig { entityId: string; } -function isCmmsConfig(v: unknown): v is CmmsConfig { +// Only `dark` / `aiEnabled` / `aiEditorKind` actually gate behavior — a +// bug found 2026-09-05 in browser verification required all six fields as +// strict strings, so the real server response (which omits `entityType` / +// `entityId`) failed validation and silently fell back to NO_WIDGET despite +// `aiEnabled: true` — the widget never mounted and nothing logged why. +// `csrfToken` / `entityType` / `entityId` are only used to build the iframe's +// query string (harmless as `""`), so a missing one shouldn't disable the +// widget entirely. +function isCmmsConfigCore(v: unknown): v is Pick { if (!v || typeof v !== "object") return false; const c = v as Record; - return ( - typeof c.dark === "boolean" && - typeof c.aiEnabled === "boolean" && - c.aiEditorKind === "kicad" && - typeof c.csrfToken === "string" && - typeof c.entityType === "string" && - typeof c.entityId === "string" - ); + return typeof c.dark === "boolean" && typeof c.aiEnabled === "boolean" && c.aiEditorKind === "kicad"; +} + +function optionalString(v: unknown): string { + return typeof v === "string" ? v : ""; } const NO_WIDGET: CmmsConfig = { @@ -47,7 +52,18 @@ export function fetchCmmsConfig(): Promise { if (!cached) { cached = fetch(`${API_BASE_URL}/api/cmms-config`, { credentials: "include" }) .then((r) => (r.ok ? r.json() : null)) - .then((v) => (isCmmsConfig(v) ? v : NO_WIDGET)) + .then((v): CmmsConfig => { + if (!isCmmsConfigCore(v)) return NO_WIDGET; + const c = v as Record; + return { + dark: v.dark, + aiEnabled: v.aiEnabled, + aiEditorKind: v.aiEditorKind, + csrfToken: optionalString(c.csrfToken), + entityType: optionalString(c.entityType), + entityId: optionalString(c.entityId), + }; + }) .catch(() => NO_WIDGET); } return cached;