Skip to content

Commit b58eeda

Browse files
authored
Merge pull request #181 from JoviDeCroock/jovi/env-safety
Typed env access (serverEnv/publicEnv) and client-bundle env leak detection
2 parents 1e63927 + 924c897 commit b58eeda

31 files changed

Lines changed: 2040 additions & 13 deletions

.changeset/env-safety.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@pracht/core": minor
3+
"@pracht/vite-plugin": minor
4+
"@pracht/cli": minor
5+
"@pracht/adapter-cloudflare": patch
6+
---
7+
8+
Env var safety: typed env access and client-leak detection.
9+
10+
- `@pracht/core` gains `publicEnv` (safe everywhere, only exposes
11+
`PRACHT_PUBLIC_`-prefixed variables) and a server-only
12+
`@pracht/core/env/server` entry exporting `serverEnv`/`setServerEnv`. Both
13+
are typed once via the existing `Register` declaration-merging pattern
14+
(`Register["env"]`). `serverEnv` resolves to `process.env` on Node/Vercel
15+
and to the worker env bindings on Cloudflare (installed per request by the
16+
adapter; not available at module top level there).
17+
- The pracht Vite plugin adds `PRACHT_PUBLIC_` to Vite's `envPrefix`, rejects
18+
client-side imports of `@pracht/core/env/server` at build time, and ships a
19+
new `pracht:env-safety` build check that fails client builds referencing
20+
non-public env vars (`process.env.X` / `import.meta.env.X`), naming the
21+
variable, chunk, and likely source module. Escape hatch:
22+
`pracht({ envSafety: { allow: [...] } })` or `envSafety: false`.
23+
- `pracht verify` / `pracht doctor` read the env-safety build report and re-run
24+
the literal leak scan against an existing `dist/client` build output.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Pick SPA, SSR, SSG, or ISG on a route-by-route basis. Ship less JavaScript by de
2323
- **Vite-native** — instant HMR, fast builds, multi-environment output out of the box.
2424
- **Performance budgets built in**`pracht build --analyze` reports per-route client JS (gzip + raw), and per-route `budgets` fail the build when a route ships too much.
2525
- **Deploy anywhere** — one codebase, one build, three production-ready adapters (Node, Cloudflare Workers, Vercel).
26+
- **Env safety built in** — typed `serverEnv`/`publicEnv` helpers with a `PRACHT_PUBLIC_` prefix rule, and builds fail when client bundles reference non-public env vars.
2627

2728
## At a glance
2829

@@ -114,6 +115,7 @@ Pracht is built to be operated by coding agents as much as by humans:
114115
- [docs/STYLING.md](docs/STYLING.md) — CSS Modules, Tailwind, CSS-in-JS limitations
115116
- [docs/ADAPTERS.md](docs/ADAPTERS.md) — Node, Cloudflare, Vercel deployment paths
116117
- [docs/MCP.md](docs/MCP.md) — built-in MCP server for coding agents
118+
- [docs/ENV.md](docs/ENV.md) — typed env access, `PRACHT_PUBLIC_` prefix rule, leak detection
117119
- [packages/start/README.md](packages/start/README.md) — starter CLI details
118120
- [packages/preact-worker-facets/README.md](packages/preact-worker-facets/README.md) — experimental Cloudflare Dynamic Worker facets runtime for Preact components
119121
- [examples/basic](examples/basic), [examples/cloudflare](examples/cloudflare), [examples/docs](examples/docs) — working apps

docs/ARCHITECTURE.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,10 @@ The published core package also exposes small browser-oriented entries:
511511
- `@pracht/core/error-overlay` and `@pracht/core/dev-404` are dev-only entries
512512
loaded on demand by the Vite dev middleware (via `ssrLoadModule`); no
513513
production entry point or generated server entry imports them.
514+
- `@pracht/core/env` exposes `publicEnv` (client-safe, `PRACHT_PUBLIC_`-prefixed
515+
vars only); `@pracht/core/env/server` exposes `serverEnv` and is server-only —
516+
the vite plugin rejects client-side imports of it at build time, and its
517+
browser condition points at a throwing stub as a backstop for other bundlers.
514518
- The root `@pracht/core` export has a browser condition that points at a
515519
client-safe public entry for route and shell modules.
516520

@@ -554,6 +558,26 @@ from the route id (see [docs/DATA_LOADING.md](DATA_LOADING.md#useroutedata)).
554558

555559
---
556560

561+
## Environment Variable Safety
562+
563+
Pracht separates env access into `serverEnv` (`@pracht/core/env/server`,
564+
server-only, resolved per adapter) and `publicEnv` (`@pracht/core`, only
565+
`PRACHT_PUBLIC_`-prefixed variables, inlined into client bundles via Vite's
566+
`envPrefix`). Both are typed once through the `Register` declaration-merging
567+
pattern (`Register["env"]`).
568+
569+
At build time the `pracht:env-safety` plugin scans client output chunks — and
570+
the transformed sources of first-party modules included in them — for
571+
references to non-public env vars and fails the build naming the variable,
572+
chunk, and likely source module. `pracht({ envSafety: { allow: [...] } })` is
573+
the escape hatch; successful client builds emit an env-safety report under
574+
`dist/client/_pracht/`, and `pracht verify` uses that report plus a literal
575+
chunk scan against `dist/client`.
576+
577+
See [docs/ENV.md](ENV.md) for the full model and per-adapter behavior.
578+
579+
---
580+
557581
## Hydration
558582

559583
Server-rendered HTML includes a non-executable JSON script tag with serialized

docs/ENV.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Environment Variables
2+
3+
Pracht ships a typed, safe-by-default environment model: server secrets stay on
4+
the server, client-visible configuration is explicitly opt-in via a naming
5+
prefix, and the build fails when a non-public variable is referenced in client
6+
code.
7+
8+
---
9+
10+
## The model
11+
12+
| Surface | Import | Contents | Where it works |
13+
| ----------- | --------------------------- | ------------------------------------- | --------------------- |
14+
| `serverEnv` | `@pracht/core/env/server` | The full platform env | Server code only |
15+
| `publicEnv` | `@pracht/core` (any entry) | Only `PRACHT_PUBLIC_`-prefixed vars | Everywhere |
16+
17+
```ts
18+
// Server code (loaders, middleware, API routes, src/server/**):
19+
import { serverEnv } from "@pracht/core/env/server";
20+
const db = connect(serverEnv.DATABASE_URL);
21+
22+
// Anywhere (values are public, inlined into the client bundle at build time):
23+
import { publicEnv } from "@pracht/core";
24+
const api = publicEnv.PRACHT_PUBLIC_API_BASE;
25+
```
26+
27+
### The prefix rule
28+
29+
Only variables prefixed with `PRACHT_PUBLIC_` are exposed through `publicEnv`.
30+
The pracht Vite plugin adds `PRACHT_PUBLIC_` to Vite's
31+
[`envPrefix`](https://vite.dev/config/shared-options#envprefix) (alongside the
32+
default `VITE_`), so prefixed variables are also available directly as
33+
`import.meta.env.PRACHT_PUBLIC_*` in dev and statically inlined at build time.
34+
Because values are inlined, never put a secret behind the prefix.
35+
36+
`publicEnv` reads `import.meta.env` when Vite provides it and falls back to
37+
`process.env` outside Vite (plain Node entries, tests). It is a snapshot of
38+
build-time values on the client; prefer reading it over `import.meta.env` for
39+
the typing below.
40+
41+
## Typing your env once
42+
43+
Declare the env shape via the same `Register` declaration-merging pattern used
44+
for routes and context:
45+
46+
```ts
47+
// src/env.d.ts
48+
declare module "@pracht/core" {
49+
interface Register {
50+
env: {
51+
DATABASE_URL: string;
52+
SESSION_SECRET: string;
53+
PRACHT_PUBLIC_APP_NAME: string;
54+
PRACHT_PUBLIC_API_BASE: string;
55+
};
56+
}
57+
}
58+
```
59+
60+
`serverEnv` is then typed as the full shape, and `publicEnv` automatically
61+
narrows to the `PRACHT_PUBLIC_`-prefixed subset — referencing
62+
`publicEnv.DATABASE_URL` is a type error. Without a registration both fall back
63+
to `Record<string, string | undefined>`.
64+
65+
## Per-adapter behavior of `serverEnv`
66+
67+
- **Node** (`@pracht/adapter-node`): resolves to `process.env`. Available at
68+
module top level.
69+
- **Vercel** (`@pracht/adapter-vercel`): resolves to `process.env`, which the
70+
Vercel runtime populates in both Node and edge functions. Available at module
71+
top level.
72+
- **Cloudflare** (`@pracht/adapter-cloudflare`): there is no ambient env on
73+
Workers — bindings arrive per request. The adapter installs the worker `env`
74+
bindings (via `setServerEnv`) when a request enters the fetch handler, so
75+
`serverEnv` works inside loaders, middleware, and API routes. It does **not**
76+
work at module top level (before the first request it throws with a message
77+
explaining this). Non-string bindings (KV, D1, …) are reachable through
78+
`serverEnv` too, but `context.env` remains the canonical way to access
79+
bindings.
80+
81+
Custom setups can call `setServerEnv(env)` (exported from
82+
`@pracht/core/env/server` and `@pracht/core/server`) to install another source.
83+
84+
## Client-leak detection
85+
86+
During `pracht build` the plugin scans every client chunk for references to
87+
`process.env.X` / `import.meta.env.X` (including `["X"]` bracket access) where
88+
`X` is not `PRACHT_PUBLIC_`- or `VITE_`-prefixed and not a Vite built-in
89+
(`MODE`, `DEV`, `PROD`, `SSR`, `BASE_URL`, plus `NODE_ENV`, which Vite
90+
statically replaces).
91+
References are matched both in the rendered chunks and in the transformed
92+
sources of first-party modules that end up in a chunk — bundlers rewrite
93+
`process.env` in client output, so the source-level signal is what catches
94+
most mistakes. A hit fails the build naming the variable, the chunk, and the
95+
likely source module.
96+
97+
Importing `@pracht/core/env/server` from client code also fails the build
98+
immediately. Route files may import it freely for `loader`/`headers`/
99+
`getStaticPaths` — the client transform strips those exports and the import
100+
with them (see `docs/ARCHITECTURE.md`, client module transform).
101+
102+
`pracht verify` (and `pracht doctor`) read the build-time env-safety report
103+
emitted to `dist/client/_pracht/env-safety.json` and also re-run the literal
104+
chunk scan against an existing `dist/client` output when one is present.
105+
106+
### Escape hatch
107+
108+
Intentional, known-safe references can be allowlisted, or the check disabled:
109+
110+
```ts
111+
pracht({
112+
envSafety: { allow: ["SENTRY_RELEASE"] },
113+
// envSafety: false, // disable entirely (not recommended)
114+
});
115+
```
116+
117+
### Limits
118+
119+
The check detects *references*, not values: a secret returned from a loader
120+
still reaches the client through hydration state, and a value inlined via a
121+
custom `define` is invisible to the scan. Use the `audit-secrets` skill for
122+
dataflow-level review of loader return values.

e2e/env-safety.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { execFileSync } from "node:child_process";
2+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { resolve } from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
6+
import { expect, test } from "@playwright/test";
7+
8+
// Coverage for the env safety feature — builds a copy of examples/basic with a
9+
// route component that references a non-public env var and asserts the client
10+
// build fails naming the variable, then that the envSafety allowlist escape
11+
// hatch lets the same build pass.
12+
const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
13+
const fixtureDir = resolve(repoRoot, "examples/basic");
14+
const cliEntry = resolve(repoRoot, "packages/cli/bin/pracht.js");
15+
16+
const LEAKED_ENV_VAR = "PRACHT_E2E_SECRET_TOKEN";
17+
18+
function prepareLeakyProject(): { tempDir: string; exampleDir: string } {
19+
const tempRoot = resolve(repoRoot, ".tmp");
20+
mkdirSync(tempRoot, { recursive: true });
21+
const tempDir = mkdtempSync(resolve(tempRoot, "pracht-env-safety-"));
22+
const exampleDir = resolve(tempDir, "project");
23+
24+
cpSync(fixtureDir, exampleDir, {
25+
filter(source) {
26+
return ![".vercel", "dist", "test-results"].some((entry) =>
27+
source.includes(`/examples/basic/${entry}`),
28+
);
29+
},
30+
recursive: true,
31+
});
32+
33+
writeFileSync(
34+
resolve(exampleDir, "src/routes/env-leak.tsx"),
35+
[
36+
`export function Component() {`,
37+
` return <p>{process.env.${LEAKED_ENV_VAR}}</p>;`,
38+
`}`,
39+
``,
40+
].join("\n"),
41+
"utf-8",
42+
);
43+
44+
const routesPath = resolve(exampleDir, "src/routes.ts");
45+
const routesSource = readFileSync(routesPath, "utf-8");
46+
writeFileSync(
47+
routesPath,
48+
routesSource.replace(
49+
'route("/", () => import("./routes/home.tsx"), { id: "home", render: "ssg" }),',
50+
`route("/", () => import("./routes/home.tsx"), { id: "home", render: "ssg" }),\n route("/env-leak", () => import("./routes/env-leak.tsx"), { id: "env-leak", render: "ssr" }),`,
51+
),
52+
"utf-8",
53+
);
54+
55+
return { tempDir, exampleDir };
56+
}
57+
58+
function runBuild(exampleDir: string): void {
59+
execFileSync(process.execPath, [cliEntry, "build"], {
60+
cwd: exampleDir,
61+
env: {
62+
...process.env,
63+
NODE_OPTIONS: "--experimental-strip-types",
64+
PRACHT_ADAPTER: "node",
65+
},
66+
stdio: "pipe",
67+
});
68+
}
69+
70+
test("client builds fail when a chunk references a non-public env var", async () => {
71+
test.setTimeout(120_000);
72+
73+
const { tempDir, exampleDir } = prepareLeakyProject();
74+
75+
try {
76+
let failure: Error & { stderr?: Buffer } = new Error("build unexpectedly succeeded");
77+
try {
78+
runBuild(exampleDir);
79+
} catch (error) {
80+
failure = error as Error & { stderr?: Buffer };
81+
}
82+
83+
const output = `${failure.message}\n${failure.stderr?.toString() ?? ""}`;
84+
expect(output).toContain("Environment variable leak detected");
85+
expect(output).toContain(`process.env.${LEAKED_ENV_VAR}`);
86+
} finally {
87+
rmSync(tempDir, { force: true, recursive: true });
88+
}
89+
});
90+
91+
test("envSafety allowlist lets an intentional env reference through", async () => {
92+
test.setTimeout(120_000);
93+
94+
const { tempDir, exampleDir } = prepareLeakyProject();
95+
96+
try {
97+
const configPath = resolve(exampleDir, "vite.config.ts");
98+
const configSource = readFileSync(configPath, "utf-8");
99+
writeFileSync(
100+
configPath,
101+
configSource.replace(
102+
"pracht({ adapter: await resolveAdapter() })",
103+
`pracht({ adapter: await resolveAdapter(), envSafety: { allow: [${JSON.stringify(LEAKED_ENV_VAR)}] } })`,
104+
),
105+
"utf-8",
106+
);
107+
108+
expect(() => runBuild(exampleDir)).not.toThrow();
109+
} finally {
110+
rmSync(tempDir, { force: true, recursive: true });
111+
}
112+
});

examples/docs/src/routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ export const app = defineApp({
5656
id: "styling",
5757
render: "ssg",
5858
}),
59+
route("/docs/env", () => import("./routes/docs/env.md"), {
60+
id: "env",
61+
render: "ssg",
62+
}),
5963
route("/docs/cli", () => import("./routes/docs/cli.md"), {
6064
id: "cli",
6165
render: "ssg",

examples/docs/src/routes/docs/cli.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ title: CLI
33
lead: The <code>@pracht/cli</code> package provides development, build, scaffolding, and doctor commands for your app.
44
breadcrumb: CLI
55
prev:
6-
href: /docs/styling
7-
title: Styling
6+
href: /docs/env
7+
title: Environment Variables
88
next:
99
href: /docs/deployment
1010
title: Deployment

0 commit comments

Comments
 (0)