diff --git a/.oxlintrc.json b/.oxlintrc.json index 1dbfa9fdd1..ac3d742007 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -8,7 +8,8 @@ "./plugins/ban-direct-auth-client-organization.js", "./plugins/ban-ref-current-assignment.js", "./plugins/ban-cross-tree-imports.js", - "./plugins/ban-e2e-app-imports.js" + "./plugins/ban-e2e-app-imports.js", + "./plugins/ban-web-server-imports.js" ], "ignorePatterns": ["apps/docs/*"], "rules": { @@ -24,7 +25,8 @@ "ban-direct-auth-client-organization/ban-direct-auth-client-organization": "error", "ban-ref-current-assignment/ban-ref-current-assignment": "error", "ban-cross-tree-imports/ban-cross-tree-imports": "warn", - "ban-e2e-app-imports/ban-e2e-app-imports": "error" + "ban-e2e-app-imports/ban-e2e-app-imports": "error", + "ban-web-server-imports/ban-web-server-imports": "warn" }, "plugins": ["react"] } diff --git a/plugins/ban-web-server-imports.js b/plugins/ban-web-server-imports.js new file mode 100644 index 0000000000..1bbfe2583a --- /dev/null +++ b/plugins/ban-web-server-imports.js @@ -0,0 +1,129 @@ +/** + * Lint plugin enforcing the web ↔ server boundary inside apps/mesh. + * + * The frontend (`apps/mesh/src/web/`) ships as a separate build artifact (vite → + * dist/client, served by the nginx `-web` container) and talks to the API over + * HTTP via `@decocms/mesh-sdk`. It is therefore free to import *types* from the + * backend (erased at build — end-to-end type safety is a feature), but must NOT + * make **value** imports from server-only trees: those pull server runtime code + * (DB drivers, secrets, provider SDKs) into the browser bundle and couple the UI + * to the server's internal file layout. + * + * Rule: files under `apps/mesh/src/web/` may not make a **value** import (or + * re-export, or dynamic import) that resolves into an `apps/mesh/src/` + * OTHER than the frontend-safe trees below. This is an ALLOWLIST — a new + * server-only tree is guarded by default (fails closed) instead of leaking until + * someone remembers to blocklist it. `import type` / `import { type X }` are + * allowed. Both `@/…` alias imports and `../…` relative climbs are checked. The + * fix is to import it `type`-only, or move the shared value (schema, constant, + * pure helper) into a frontend-safe tree or into `@decocms/mesh-sdk`. + * + * Companion to `ban-cross-tree-imports.js` (which guards packages/ ↛ apps/mesh) + * and `ban-e2e-app-imports.js` (which guards the e2e black-box wall). + */ + +// Trees under apps/mesh/src that the browser bundle may value-import from. +// Everything else (storage, core, api, tools, auth, ai-providers, cli, …) is +// server-only by default. +const FRONTEND_SAFE_TREES = new Set(["web", "mcp-apps", "lib", "shared"]); + +const SRC_MARKER = "/apps/mesh/src/"; + +function inWebTree(filename) { + return ( + filename.includes("/apps/mesh/src/web/") || + filename.startsWith("apps/mesh/src/web/") + ); +} + +// Test files never ship in the browser bundle, so their imports are outside +// this rule's bundle/secret boundary (a web unit test may pull `@/test` helpers). +function isTestFile(filename) { + return /\.test\.[cm]?[jt]sx?$/.test(filename); +} + +// Resolve `../` / `./` segments of a relative spec against the importing file. +function resolveRelative(fromFile, spec) { + const parts = fromFile.split("/"); + parts.pop(); // drop the filename → containing directory + for (const seg of spec.split("/")) { + if (seg === "" || seg === ".") continue; + if (seg === "..") parts.pop(); + else parts.push(seg); + } + return parts.join("/"); +} + +// Returns the server-only `apps/mesh/src/` a specifier resolves into, or +// null if it stays in a frontend-safe tree / points outside apps/mesh/src (bare +// or workspace specifier). +function serverTreeOf(spec, filename) { + if (typeof spec !== "string") return null; + let resolved; + if (spec.startsWith("@/")) { + resolved = `${SRC_MARKER}${spec.slice(2)}`; + } else if (spec.startsWith(".")) { + resolved = resolveRelative(filename, spec); + } else { + return null; // bare / workspace-package specifier + } + const i = resolved.indexOf(SRC_MARKER); + if (i === -1) return null; + const tree = resolved.slice(i + SRC_MARKER.length).split("/")[0]; + if (!tree) return null; + return FRONTEND_SAFE_TREES.has(tree) ? null : tree; +} + +// True when the whole statement is type-only (erased at build → safe). +function isTypeOnly(node) { + const kind = node.importKind ?? node.exportKind; + if (kind === "type") return true; + // `import { type A, type B } from …` — value statement, but every named + // specifier is a type. `export * from` has no specifiers → not type-only. + const specs = node.specifiers; + if (Array.isArray(specs) && specs.length > 0) { + return specs.every( + (s) => s.importKind === "type" || s.exportKind === "type", + ); + } + return false; +} + +const banWebServerImportsRule = { + create(context) { + const filename = context.filename ?? ""; + if (!inWebTree(filename) || isTestFile(filename)) return {}; + + const check = (node, { dynamic = false } = {}) => { + const src = node?.source; + if (!src || src.type !== "Literal" || typeof src.value !== "string") + return; + const tree = serverTreeOf(src.value, filename); + if (!tree) return; + if (!dynamic && isTypeOnly(node)) return; + + context.report({ + node: src, + message: + `Web ↔ server boundary: "${src.value}" is a VALUE import from the server-only "${tree}" tree. ` + + `The frontend is a separate bundle — it may import types (use \`import type\`) but not runtime code, ` + + `which risks pulling server deps/secrets into the browser. Move the shared value into @/web, @/mcp-apps, ` + + `@/lib, @/shared or @decocms/mesh-sdk, or import it type-only.`, + }); + }; + + return { + ImportDeclaration: (node) => check(node), + ExportNamedDeclaration: (node) => check(node), + ExportAllDeclaration: (node) => check(node), + ImportExpression: (node) => check(node, { dynamic: true }), + }; + }, +}; + +const plugin = { + meta: { name: "ban-web-server-imports" }, + rules: { "ban-web-server-imports": banWebServerImportsRule }, +}; + +export default plugin; diff --git a/plugins/ban-web-server-imports.test.ts b/plugins/ban-web-server-imports.test.ts new file mode 100644 index 0000000000..ca8e0fd4c3 --- /dev/null +++ b/plugins/ban-web-server-imports.test.ts @@ -0,0 +1,145 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; + +const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const TMP = `${ROOT}/.ban-web-server.tmp`; +const CONFIG = `${TMP}/.oxlintrc.json`; + +const CONFIG_JSON = JSON.stringify({ + jsPlugins: ["../plugins/ban-web-server-imports.js"], + rules: { "ban-web-server-imports/ban-web-server-imports": "error" }, +}); + +async function lint(relPath: string): Promise { + const proc = Bun.spawn( + ["node_modules/.bin/oxlint", "-c", CONFIG, "-f", "json", relPath], + { cwd: ROOT, stdout: "pipe", stderr: "pipe" }, + ); + const out = await new Response(proc.stdout).text(); + await proc.exited; + const parsed = JSON.parse(out) as { + diagnostics: { code: string; message: string }[]; + }; + return parsed.diagnostics + .filter((d) => d.code.includes("ban-web-server-imports")) + .map((d) => d.message); +} + +function fixture(relPath: string, contents: string): string { + const abs = `${TMP}/${relPath}`; + mkdirSync(abs.slice(0, abs.lastIndexOf("/")), { recursive: true }); + writeFileSync(abs, contents); + return `.ban-web-server.tmp/${relPath}`; +} + +beforeAll(() => { + mkdirSync(TMP, { recursive: true }); + writeFileSync(CONFIG, CONFIG_JSON); +}); +afterAll(() => rmSync(TMP, { recursive: true, force: true })); + +describe("ban-web-server-imports", () => { + test("bans a value import from a server-only tree", async () => { + const f = fixture( + "apps/mesh/src/web/a.ts", + `import { db } from "@/storage/types";\nexport const x = db;\n`, + ); + expect((await lint(f)).length).toBe(1); + }); + + test("bans a value import from @/tools schema", async () => { + const f = fixture( + "apps/mesh/src/web/b.ts", + `import { ConnectionSchema } from "@/tools/connection/schema";\nexport const x = ConnectionSchema;\n`, + ); + expect((await lint(f)).length).toBe(1); + }); + + test("allows `import type` from a server tree (erased at build)", async () => { + const f = fixture( + "apps/mesh/src/web/c.ts", + `import type { Thread } from "@/storage/types";\nexport type T = Thread;\n`, + ); + expect((await lint(f)).length).toBe(0); + }); + + test("allows `import { type X }` inline type specifiers", async () => { + const f = fixture( + "apps/mesh/src/web/d.ts", + `import { type ChatMessage } from "@/api/routes/decopilot/types";\nexport type M = ChatMessage;\n`, + ); + expect((await lint(f)).length).toBe(0); + }); + + test("allows value imports from frontend-safe trees (mcp-apps, web, lib)", async () => { + const f = fixture( + "apps/mesh/src/web/e.ts", + `import { MCPAppRenderer } from "@/mcp-apps/mcp-app-renderer.tsx";\n` + + `import { thing } from "@/web/lib/thing";\n` + + `import { util } from "@/lib/util";\n` + + `export const x = [MCPAppRenderer, thing, util];\n`, + ); + expect((await lint(f)).length).toBe(0); + }); + + test("bans a dynamic import from a server tree", async () => { + const f = fixture( + "apps/mesh/src/web/f.ts", + `export const load = () => import("@/core/studio-context");\n`, + ); + expect((await lint(f)).length).toBe(1); + }); + + test("bans a value re-export from a server tree", async () => { + const f = fixture( + "apps/mesh/src/web/g.ts", + `export { MCP_MESH_KEY } from "@/core/constants";\n`, + ); + expect((await lint(f)).length).toBe(1); + }); + + test("ignores files outside the web tree", async () => { + const f = fixture( + "apps/mesh/src/api/h.ts", + `import { db } from "@/storage/types";\nexport const x = db;\n`, + ); + expect((await lint(f)).length).toBe(0); + }); + + test("ignores in-tree relative and workspace-package imports", async () => { + const f = fixture( + "apps/mesh/src/web/i.ts", + `import { a } from "./sibling";\nimport { useProjectContext } from "@decocms/mesh-sdk";\n` + + `export const x = [a, useProjectContext];\n`, + ); + expect((await lint(f)).length).toBe(0); + }); + + // Allowlist means any not-explicitly-safe tree is guarded by default. `cli` + // is server-only but was not in the old blocklist — it must be caught now. + test("bans a value import from an unlisted server tree (cli)", async () => { + const f = fixture( + "apps/mesh/src/web/j.ts", + `import { run } from "@/cli/cli-store";\nexport const x = run;\n`, + ); + expect((await lint(f)).length).toBe(1); + }); + + // Web test files never ship in the browser bundle → outside this rule. + test("ignores web test files (not bundled)", async () => { + const f = fixture( + "apps/mesh/src/web/x.test.ts", + `import { render } from "@/test/render";\nexport const x = render;\n`, + ); + expect((await lint(f)).length).toBe(0); + }); + + // Relative climbs out of web/ into a server tree bypass the `@/` prefix. + test("bans a relative climb out of web into a server tree", async () => { + const f = fixture( + "apps/mesh/src/web/components/k.ts", + `import { db } from "../../storage/types";\nexport const x = db;\n`, + ); + expect((await lint(f)).length).toBe(1); + }); +});