Skip to content

Commit 73e4d29

Browse files
committed
feat(showcase): add oxlint guard against NEXT_PUBLIC_* shell reads
Plan-B / Option-B migration moved every shell URL/analytics key off the build-time NEXT_PUBLIC_* env channel and onto runtime config served via __SHOWCASE_CONFIG__ + getRuntimeConfig(). To prevent a silent regression where a future change reintroduces a direct process.env.NEXT_PUBLIC_* read in shell code (which would re-freeze the value at build time and break no-rebuild env switching), add a focused lint rule. The rule (copilotkit/no-public-env-shell-read) is implemented as a custom oxlint JS plugin rule in the existing copilotkit plugin and enabled under shell-scoped overrides in .oxlintrc.json: - Errors on process.env.NEXT_PUBLIC_<URL/ANALYTICS> reads in: showcase/shell-dashboard/src/**, showcase/shell-docs/src/**, showcase/shell/src/**, showcase/shell-dojo/src/** - Banned keys: POCKETBASE_URL, SHELL_URL, BASE_URL, OPS_BASE_URL, INTELLIGENCE_SIGNUP_URL, POSTHOG_KEY, POSTHOG_HOST, SCARF_PIXEL_ID, GOOGLE_ANALYTICS_TRACKING_ID, REB2B_KEY, REO_KEY - Intentionally allowed (NOT banned): NEXT_PUBLIC_COMMIT_SHA and NEXT_PUBLIC_BRANCH (build-stamped artifact identifiers per B10/B11) and NEXT_PUBLIC_LOCAL_BACKENDS (computed from shared/local-ports.json at build, local-dev only). - Excluded files (rule disabled via a follow-up override): MDX content under shell-docs/src/content/**, runtime-config implementation files, and *.test.{ts,tsx} / *.spec.{ts,tsx}. oxlint does not support excludedFiles inside an override block, so the exclusion is expressed as a later override that sets the rule to off. Plan-B originally targeted oxlint's eslint/no-restricted-syntax with an AST-selector regex. oxlint 1.x does not implement that rule (only no-restricted-globals / no-restricted-imports), so the equivalent guard is realized as a small custom rule in the existing copilotkit JS plugin (meta.name=copilotkit), reusing the same plugin loader the repo already has for require-cpk-prefix and no-single-arg-zod-record. Verification (red-green): the rule fires on a fixture containing process.env.NEXT_PUBLIC_POCKETBASE_URL and does NOT fire on a fixture containing process.env.NEXT_PUBLIC_COMMIT_SHA. Test pins the config via -c so it works inside git worktrees nested under .claude/worktrees/ where oxlint's automatic upward config search can miss the worktree's own .oxlintrc.json. All four shells lint clean: 0 errors of the new rule across shell-dashboard (114 files), shell-docs (137), shell (29), shell-dojo (6).
1 parent b2a8b41 commit 73e4d29

4 files changed

Lines changed: 207 additions & 0 deletions

File tree

.oxlintrc.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,28 @@
7373
"typescript/no-import-type-side-effects": "off",
7474
"import/consistent-type-specifier-style": "off"
7575
}
76+
},
77+
{
78+
"files": [
79+
"showcase/shell-dashboard/src/**/*.{ts,tsx}",
80+
"showcase/shell-docs/src/**/*.{ts,tsx}",
81+
"showcase/shell/src/**/*.{ts,tsx}",
82+
"showcase/shell-dojo/src/**/*.{ts,tsx}"
83+
],
84+
"rules": {
85+
"copilotkit/no-public-env-shell-read": "error"
86+
}
87+
},
88+
{
89+
"files": [
90+
"showcase/shell-docs/src/content/**",
91+
"showcase/**/*runtime-config*",
92+
"showcase/**/*.test.{ts,tsx}",
93+
"showcase/**/*.spec.{ts,tsx}"
94+
],
95+
"rules": {
96+
"copilotkit/no-public-env-shell-read": "off"
97+
}
7698
}
7799
],
78100
"ignorePatterns": [

packages/react-ui/oxlint-rules/copilotkit-plugin.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import requireCpkPrefix from "./require-cpk-prefix.mjs";
22
import noSingleArgZodRecord from "./no-single-arg-zod-record.mjs";
3+
import noPublicEnvShellRead from "./no-public-env-shell-read.mjs";
34

45
const plugin = {
56
meta: { name: "copilotkit" },
67
rules: {
78
"require-cpk-prefix": requireCpkPrefix,
89
"no-single-arg-zod-record": noSingleArgZodRecord,
10+
"no-public-env-shell-read": noPublicEnvShellRead,
911
},
1012
};
1113

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Oxlint rule: no-public-env-shell-read
3+
*
4+
* Bans direct reads of `process.env.NEXT_PUBLIC_<URL/ANALYTICS>` keys in
5+
* showcase shell client/server code. Those values are now served at runtime
6+
* via `getRuntimeConfig()` (see workstream B / Option-B migration), and a
7+
* stray `process.env.NEXT_PUBLIC_*` read would silently re-freeze the value
8+
* at build time and regress the migration.
9+
*
10+
* Allowed (intentionally NOT in the banned set):
11+
* - `NEXT_PUBLIC_COMMIT_SHA` — build-stamped artifact identifier
12+
* - `NEXT_PUBLIC_BRANCH` — build-stamped artifact identifier
13+
* - `NEXT_PUBLIC_LOCAL_BACKENDS` — local-dev only, computed from
14+
* `shared/local-ports.json` at build time (not a real env var)
15+
*
16+
* Banned keys:
17+
* NEXT_PUBLIC_POCKETBASE_URL, NEXT_PUBLIC_SHELL_URL, NEXT_PUBLIC_BASE_URL,
18+
* NEXT_PUBLIC_OPS_BASE_URL, NEXT_PUBLIC_INTELLIGENCE_SIGNUP_URL,
19+
* NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST,
20+
* NEXT_PUBLIC_SCARF_PIXEL_ID, NEXT_PUBLIC_GOOGLE_ANALYTICS_TRACKING_ID,
21+
* NEXT_PUBLIC_REB2B_KEY, NEXT_PUBLIC_REO_KEY
22+
*
23+
* Scope is enforced via `overrides[].files` in `.oxlintrc.json` (shell
24+
* source trees only; `.mdx` content, runtime-config implementation files,
25+
* and tests are excluded by a follow-up override that turns this rule
26+
* back off).
27+
*
28+
* Note: plan-B's original spec called for oxlint's `eslint/no-restricted-syntax`
29+
* with an AST selector regex. oxlint 1.x does not implement that rule
30+
* (only `no-restricted-globals` / `no-restricted-imports`), so we
31+
* implement the equivalent guard as a focused custom rule in the existing
32+
* copilotkit oxlint plugin instead.
33+
*/
34+
35+
const BANNED_KEYS = new Set([
36+
"NEXT_PUBLIC_POCKETBASE_URL",
37+
"NEXT_PUBLIC_SHELL_URL",
38+
"NEXT_PUBLIC_BASE_URL",
39+
"NEXT_PUBLIC_OPS_BASE_URL",
40+
"NEXT_PUBLIC_INTELLIGENCE_SIGNUP_URL",
41+
"NEXT_PUBLIC_POSTHOG_KEY",
42+
"NEXT_PUBLIC_POSTHOG_HOST",
43+
"NEXT_PUBLIC_SCARF_PIXEL_ID",
44+
"NEXT_PUBLIC_GOOGLE_ANALYTICS_TRACKING_ID",
45+
"NEXT_PUBLIC_REB2B_KEY",
46+
"NEXT_PUBLIC_REO_KEY",
47+
]);
48+
49+
const rule = {
50+
meta: {
51+
type: "problem",
52+
docs: {
53+
description:
54+
"Disallow direct `process.env.NEXT_PUBLIC_*` URL/analytics reads in shell code (use getRuntimeConfig)",
55+
},
56+
schema: [],
57+
messages: {
58+
forbiddenRead:
59+
"Do not read process.env.NEXT_PUBLIC_* directly in shell code. Use getRuntimeConfig() from @/lib/runtime-config.client (client) or @/lib/runtime-config (server). See workstream B.",
60+
},
61+
},
62+
63+
create(context) {
64+
return {
65+
// Match `process.env.<KEY>` — i.e. a MemberExpression whose object is
66+
// itself the MemberExpression `process.env` and whose property is the
67+
// banned identifier.
68+
MemberExpression(node) {
69+
const obj = node.object;
70+
if (
71+
!obj ||
72+
obj.type !== "MemberExpression" ||
73+
obj.computed ||
74+
!obj.object ||
75+
obj.object.type !== "Identifier" ||
76+
obj.object.name !== "process" ||
77+
!obj.property ||
78+
obj.property.type !== "Identifier" ||
79+
obj.property.name !== "env"
80+
) {
81+
return;
82+
}
83+
// `node.property` is the key being read off `process.env`.
84+
// Only flag the static dotted form (`process.env.FOO`) or the
85+
// literal-string computed form (`process.env["FOO"]`).
86+
let keyName = null;
87+
if (!node.computed && node.property && node.property.type === "Identifier") {
88+
keyName = node.property.name;
89+
} else if (
90+
node.computed &&
91+
node.property &&
92+
node.property.type === "Literal" &&
93+
typeof node.property.value === "string"
94+
) {
95+
keyName = node.property.value;
96+
}
97+
if (!keyName) return;
98+
if (!BANNED_KEYS.has(keyName)) return;
99+
100+
context.report({ node, messageId: "forbiddenRead" });
101+
},
102+
};
103+
},
104+
};
105+
106+
export default rule;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it } from "vitest";
2+
import { execSync } from "node:child_process";
3+
import { writeFileSync, rmSync } from "node:fs";
4+
import { join, resolve } from "node:path";
5+
6+
// Repo root is two directories above this test file
7+
// (showcase/scripts/__tests__/X -> showcase/scripts -> showcase -> <repo>).
8+
const REPO_ROOT = resolve(import.meta.dirname, "..", "..", "..");
9+
const CONFIG_PATH = join(REPO_ROOT, ".oxlintrc.json");
10+
11+
describe("oxlint copilotkit/no-public-env-shell-read NEXT_PUBLIC_* guard", () => {
12+
it("fires on direct process.env.NEXT_PUBLIC_POCKETBASE_URL read in shell code", () => {
13+
// Stage a forbidden file inside the actual repo path so the
14+
// override `files` pattern matches. We use a `.lintfixture.tsx`
15+
// suffix that the off-override (which excludes `*.test.{ts,tsx}` /
16+
// `*.spec.{ts,tsx}` / `*runtime-config*`) does NOT match.
17+
const target = join(
18+
REPO_ROOT,
19+
"showcase",
20+
"shell-dashboard",
21+
"src",
22+
"lib",
23+
"__bad.lintfixture.tsx",
24+
);
25+
writeFileSync(
26+
target,
27+
`export const x = process.env.NEXT_PUBLIC_POCKETBASE_URL;\n`,
28+
"utf8",
29+
);
30+
try {
31+
// Use an explicit -c to pin the config: in git worktrees nested
32+
// under .claude/worktrees/, oxlint's automatic upward config
33+
// search may resolve to a different .oxlintrc.json than the
34+
// worktree's own. Pinning it makes the test deterministic.
35+
const result = execSync(
36+
`npx oxlint -c ${CONFIG_PATH} ${target} --quiet`,
37+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], cwd: REPO_ROOT },
38+
).toString();
39+
// Should not reach here — oxlint exits non-zero on errors.
40+
expect(result).toBe("UNREACHABLE: oxlint should have failed");
41+
} catch (e) {
42+
const stderr = (e as { stderr?: Buffer | string }).stderr?.toString() ?? "";
43+
const stdout = (e as { stdout?: Buffer | string }).stdout?.toString() ?? "";
44+
const combined = stderr + stdout;
45+
expect(combined).toMatch(/no-public-env-shell-read/);
46+
expect(combined).toMatch(/getRuntimeConfig/);
47+
} finally {
48+
rmSync(target, { force: true });
49+
}
50+
});
51+
52+
it("does NOT fire on process.env.NEXT_PUBLIC_COMMIT_SHA (build-stamp, intentionally allowed)", () => {
53+
const target = join(
54+
REPO_ROOT,
55+
"showcase",
56+
"shell-dashboard",
57+
"src",
58+
"lib",
59+
"__ok.lintfixture.tsx",
60+
);
61+
writeFileSync(
62+
target,
63+
`export const sha = process.env.NEXT_PUBLIC_COMMIT_SHA;\n`,
64+
"utf8",
65+
);
66+
try {
67+
// Should succeed (exit 0).
68+
execSync(`npx oxlint -c ${CONFIG_PATH} ${target} --quiet`, {
69+
encoding: "utf8",
70+
stdio: ["ignore", "pipe", "pipe"],
71+
cwd: REPO_ROOT,
72+
});
73+
} finally {
74+
rmSync(target, { force: true });
75+
}
76+
});
77+
});

0 commit comments

Comments
 (0)