diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 13870c6e2f..870887b8b8 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -12,11 +12,11 @@ Core dependencies: `@langchain/langgraph-sdk` ^1.5.3 for orchestration/streaming `@langchain/core` ^1.1.15, and `@tanstack/react-query` ^5.90.17 for server state. Registry UI: Shadcn, MagicUI, React Bits, Vercel AI SDK (see Code Style). -`pnpm-workspace.yaml` overrides vulnerable `@xmldom/xmldom` 0.9.x releases to -0.9.12 for GHSA-965w-775f-mr7g. Nextra pulls it in through MathJax and -`speech-rule-engine@4.1.2`, which pins 0.9.8. Keep the override until the -upstream dependency chain resolves a patched version without it; regenerate -`pnpm-lock.yaml` and verify the docs build when changing this constraint. +`pnpm-workspace.yaml` pins vulnerable `@xmldom/xmldom` 0.9.x to patched +0.9.12 (GHSA-965w-775f-mr7g) and Nextra 4.6.1's Zod dependencies to 4.3.6 +until upstream fixes missing-`children` validation. App schemas use Zod 4.6.6. +Remove each override after its upstream fix; refresh the lockfile and test +docs builds after changes. ## Commands diff --git a/frontend/package.json b/frontend/package.json index d3ecea5361..69b6bc6131 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -101,7 +101,7 @@ "unist-util-visit": "^5.0.0", "use-stick-to-bottom": "^1.1.1", "uuid": "^14.0.0", - "zod": "^3.24.2" + "zod": "^4.6.6" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 40f3487f86..da41b3a7b4 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,6 +6,8 @@ settings: overrides: '@xmldom/xmldom@>=0.9.0 <0.9.12': 0.9.12 + nextra@4.6.1>zod: 4.3.6 + nextra-theme-docs@4.6.1>zod: 4.3.6 importers: @@ -100,7 +102,7 @@ importers: version: 1.0.2(react@19.2.4) '@t3-oss/env-nextjs': specifier: ^0.12.0 - version: 0.12.0(typescript@5.9.3)(zod@3.25.76) + version: 0.12.0(typescript@5.9.3)(zod@4.6.6) '@tanstack/react-query': specifier: ^5.90.17 version: 5.90.20(react@19.2.4) @@ -124,7 +126,7 @@ importers: version: 12.10.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ai: specifier: ^6.0.33 - version: 6.0.78(zod@3.25.76) + version: 6.0.78(zod@4.6.6) best-effort-json-parser: specifier: ^1.2.1 version: 1.2.1 @@ -252,8 +254,8 @@ importers: specifier: ^14.0.0 version: 14.0.0 zod: - specifier: ^3.24.2 - version: 3.25.76 + specifier: ^4.6.6 + version: 4.6.6 devDependencies: '@eslint/eslintrc': specifier: ^3.3.1 @@ -5985,12 +5987,12 @@ packages: yoga-wasm-web@0.3.3: resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.6.6: + resolution: {integrity: sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -6029,19 +6031,19 @@ packages: snapshots: - '@ai-sdk/gateway@3.0.39(zod@3.25.76)': + '@ai-sdk/gateway@3.0.39(zod@4.6.6)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + '@ai-sdk/provider-utils': 4.0.14(zod@4.6.6) '@vercel/oidc': 3.1.0 - zod: 3.25.76 + zod: 4.6.6 - '@ai-sdk/provider-utils@4.0.14(zod@3.25.76)': + '@ai-sdk/provider-utils@4.0.14(zod@4.6.6)': dependencies: '@ai-sdk/provider': 3.0.8 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 3.25.76 + zod: 4.6.6 '@ai-sdk/provider@3.0.8': dependencies: @@ -6702,7 +6704,7 @@ snapshots: mustache: 4.2.0 p-queue: 6.6.2 uuid: 10.0.0 - zod: 3.25.76 + zod: 4.6.6 transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/exporter-trace-otlp-proto' @@ -7907,17 +7909,17 @@ snapshots: dependencies: tslib: 2.8.1 - '@t3-oss/env-core@0.12.0(typescript@5.9.3)(zod@3.25.76)': + '@t3-oss/env-core@0.12.0(typescript@5.9.3)(zod@4.6.6)': optionalDependencies: typescript: 5.9.3 - zod: 3.25.76 + zod: 4.6.6 - '@t3-oss/env-nextjs@0.12.0(typescript@5.9.3)(zod@3.25.76)': + '@t3-oss/env-nextjs@0.12.0(typescript@5.9.3)(zod@4.6.6)': dependencies: - '@t3-oss/env-core': 0.12.0(typescript@5.9.3)(zod@3.25.76) + '@t3-oss/env-core': 0.12.0(typescript@5.9.3)(zod@4.6.6) optionalDependencies: typescript: 5.9.3 - zod: 3.25.76 + zod: 4.6.6 '@tailwindcss/node@4.1.18': dependencies: @@ -8578,13 +8580,13 @@ snapshots: acorn@8.15.0: {} - ai@6.0.78(zod@3.25.76): + ai@6.0.78(zod@4.6.6): dependencies: - '@ai-sdk/gateway': 3.0.39(zod@3.25.76) + '@ai-sdk/gateway': 3.0.39(zod@4.6.6) '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + '@ai-sdk/provider-utils': 4.0.14(zod@4.6.6) '@opentelemetry/api': 1.9.0 - zod: 3.25.76 + zod: 4.6.6 ajv@6.12.6: dependencies: @@ -12578,10 +12580,10 @@ snapshots: yoga-wasm-web@0.3.3: {} - zod@3.25.76: {} - zod@4.3.6: {} + zod@4.6.6: {} + zustand@4.5.7(@types/react@19.2.13)(react@19.2.4): dependencies: use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 70c2ebb4f7..aa5f953f31 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -2,6 +2,10 @@ packages: [] overrides: # GHSA-965w-775f-mr7g: speech-rule-engine 4.1.2 pins vulnerable 0.9.8. "@xmldom/xmldom@>=0.9.0 <0.9.12": 0.9.12 + # Nextra 4.6.1 strips children before validating required Layout props. + # Keep its previous Zod until https://github.com/shuding/nextra/issues/5036 is fixed. + "nextra@4.6.1>zod": 4.3.6 + "nextra-theme-docs@4.6.1>zod": 4.3.6 ignoredBuiltDependencies: - esbuild - sharp diff --git a/frontend/src/core/extensions/api.ts b/frontend/src/core/extensions/api.ts index 2606c1dc58..b8ce37a1b9 100644 --- a/frontend/src/core/extensions/api.ts +++ b/frontend/src/core/extensions/api.ts @@ -14,7 +14,10 @@ const contributions = z.array( transport: z.string().nullable().optional(), title: z.string(), description: z.string(), - settings: z.record(z.union([z.boolean(), z.number(), z.string()])), + settings: z.record( + z.string(), + z.union([z.boolean(), z.number(), z.string()]), + ), }), ); diff --git a/frontend/tests/unit/core/extensions/api.test.ts b/frontend/tests/unit/core/extensions/api.test.ts new file mode 100644 index 0000000000..b3a4248b7a --- /dev/null +++ b/frontend/tests/unit/core/extensions/api.test.ts @@ -0,0 +1,72 @@ +import { afterEach, expect, rs, test } from "@rstest/core"; + +import { fetchFrontendExtensions } from "@/core/extensions/api"; +import type { FrontendContribution } from "@/core/extensions/contracts"; + +rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" })); +rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false })); +rs.mock("@/core/api/static-response", () => ({ staticApiResponse: rs.fn() })); + +const entry = { + namespace: "community.bookmarks", + module: "bookmarks.v1", + entry: `/api/plugins/modules/bookmarks.v1/${"a".repeat(64)}.mjs`, + title: "Bookmarks", + description: "", +}; + +afterEach(() => { + rs.restoreAllMocks(); +}); + +for (const [label, settings] of [ + ["empty", {}], + [ + "mixed primitive", + { + enabled: true, + disabled: false, + limit: 3, + zero: 0, + label: "Bookmarks", + empty: "", + }, + ], +] as const) { + test(`discovery preserves ${label} settings for the extension loader`, async () => { + const request = rs + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(JSON.stringify([{ ...entry, settings }])), + ); + + // pnpm typecheck verifies the discovery schema's inferred public contract. + const discovered: FrontendContribution[] = await fetchFrontendExtensions(); + + expect(discovered).toEqual([{ ...entry, settings }]); + expect(request).toHaveBeenCalledWith( + "/api/plugins", + expect.objectContaining({ credentials: "include", cache: "no-store" }), + ); + }); +} + +for (const [label, settings] of [ + ["null value", { enabled: null }], + ["nested object", { options: { enabled: true } }], + ["array value", { options: ["bookmarks"] }], + ["null record", null], + ["array record", []], + ["string record", "enabled"], + ["missing record", undefined], +] as const) { + test(`discovery rejects settings with a ${label}`, async () => { + rs.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify([{ ...entry, settings }])), + ); + + await expect(fetchFrontendExtensions()).rejects.toMatchObject({ + name: "ZodError", + }); + }); +}