Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"]
}
129 changes: 129 additions & 0 deletions plugins/ban-web-server-imports.js
Original file line number Diff line number Diff line change
@@ -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/<tree>`
* 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/<tree>` 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;
145 changes: 145 additions & 0 deletions plugins/ban-web-server-imports.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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);
});
});
Loading