fix(env): read NEXT_PUBLIC_LOG_TRPC from its own variable, drop dead public env vars (#4035)

* fix(env): read NEXT_PUBLIC_LOG_TRPC from its own variable, drop dead public env

`clientEnv` restates every key by hand because Next.js inlines only the
`process.env.NEXT_PUBLIC_*` references it can see literally. `NEXT_PUBLIC_LOG_TRPC`
named `NEXT_PUBLIC_LOG_TRP`, so it was permanently undefined and the schema default
turned that into `false` with nothing to read.

`NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION` has no consumers anywhere in the
workspace; removed from the schema, the Dockerfile ARG and `.env-example`. Same for
the `NEXT_PUBLIC_MAINTENANCE_MODE` ARG (no such var in either schema) and the
`NEXT_PUBLIC_UI_CATEGORY_VIEWS` / `NEXT_PUBLIC_ADS` lines in `.env-example`.

`NEXT_PUBLIC_IMAGE_LOCATION` keeps its `.default('')` deliberately. Requiring it
would fail builds that legitimately do not pass it, and the dangerous call sites
already refuse a relative URL.

The new test stamps a per-key sentinel into `process.env` and asserts each key read
its own name, so this class of typo fails naming the variable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(env): close the gaps the review found in the client-env guard

Three findings from the adversarial pass, each with the control run rather than
assumed.

The sentinel sweep exempted `NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER` — the one key
resolved from an expression, and so the one most able to carry the typo the guard
exists to catch. Dropping the trailing `R` from the read pinned every user to Stripe
with the suite still green. Replaced the blanket skip with assertions on the value,
plus a check that nothing is exempted which is not in the schema.

Making the `NEXT_PUBLIC_LOG_TRPC` read live changes parse behaviour for anything
already setting it: `z.stringbool()` rejects the empty string, and a failed parse
throws out of `~/env/client` at import. A value that was inert before this branch
would have hard-failed after it, so the schema now catches to `false`.

`NEXT_PUBLIC_BASE_URL` falls back to `NEXTAUTH_URL`, which has no `NEXT_PUBLIC_`
prefix and is therefore never inlined into the client bundle — an environment
setting only that one resolves server-side and is `undefined` in the browser. Left
in place rather than removed, since dropping it changes behaviour beyond this
ticket, but pinned by tests so it is visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(env): treat an empty NEXT_PUBLIC_LOG_TRPC as unset, not as a catch-all

`.catch(false)` swallowed every unparseable value, which is the same plausible-false
failure this branch exists to remove — just moved from the variable name to its
value — and made this the only stringbool in the file that hides a misconfiguration.

Only the empty case needs handling: the read named the wrong variable until this
branch, so a config carrying a valueless key had been inert and would otherwise
start throwing out of `~/env/client`. Anything else unparseable throws, as it does
for every other flag here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Justin Maier
2026-08-17 15:10:06 -06:00
committed by GitHub
parent 960d160027
commit 7cff7ceb4f
4 changed files with 125 additions and 9 deletions
-3
View File
@@ -69,11 +69,8 @@ S3_IMAGE_UPLOAD_OVERRIDE=
# Client env vars
NEXT_PUBLIC_IMAGE_LOCATION=http://localhost:3000
NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION=https://publicstore.civitai.com/content_detection/model.json
NEXT_PUBLIC_CIVITAI_LINK=http://localhost:3000
NEXT_PUBLIC_UI_CATEGORY_VIEWS=false
NEXT_PUBLIC_UI_HOMEPAGE_IMAGES=false
NEXT_PUBLIC_ADS=true
# Clickhouse
CLICKHOUSE_HOST=http://localhost:18123
-2
View File
@@ -31,8 +31,6 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
# inherited too, so they don't need re-running here.
FROM deps AS builder
ARG NEXT_PUBLIC_IMAGE_LOCATION
ARG NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION
ARG NEXT_PUBLIC_MAINTENANCE_MODE
WORKDIR /app
# Overlay the full source. node_modules is dockerignored, so this never clobbers the node_modules inherited
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
/**
* `clientEnv` restates every key by hand because Next.js inlines only the
* `process.env.NEXT_PUBLIC_*` references it can see literally. A key that names a
* variable other than its own is therefore permanently undefined, and the schema's
* default hides it — which is how `NEXT_PUBLIC_LOG_TRPC` spent its life reading
* `NEXT_PUBLIC_LOG_TRP`.
*/
// Resolved from an expression rather than from a variable of the same name, so the
// sentinel sweep cannot reach it. Each one is asserted on its own below instead.
const NOT_A_PASSTHROUGH = ['NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER'];
async function importWith(vars: Record<string, string | undefined>) {
vi.resetModules();
for (const [key, value] of Object.entries(vars)) {
if (value === undefined) vi.stubEnv(key, undefined);
else vi.stubEnv(key, value);
}
return import('../client-schema');
}
describe('env/client-schema', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it('reads each key from the env var of the same name', async () => {
const { clientSchema } = await importWith({});
const keys = Object.keys(clientSchema.shape);
expect(keys.length).toBeGreaterThan(10);
const sentinels = Object.fromEntries(keys.map((key) => [key, `sentinel::${key}`]));
const { clientEnv } = await importWith(sentinels);
for (const key of keys) {
if (NOT_A_PASSTHROUGH.includes(key)) continue;
expect({ [key]: clientEnv[key as keyof typeof clientEnv] }).toEqual({
[key]: `sentinel::${key}`,
});
}
});
it('exempts only keys that genuinely are not passthroughs', async () => {
const { clientSchema } = await importWith({});
expect(NOT_A_PASSTHROUGH.filter((key) => !(key in clientSchema.shape))).toEqual([]);
});
it('declares the same keys in the schema and in clientEnv', async () => {
const { clientSchema, clientEnv } = await importWith({});
expect(Object.keys(clientEnv).sort()).toEqual(Object.keys(clientSchema.shape).sort());
});
it('names every key with the NEXT_PUBLIC_ prefix', async () => {
const { clientSchema } = await importWith({});
const keys = Object.keys(clientSchema.shape);
expect(keys.filter((key) => !key.startsWith('NEXT_PUBLIC_'))).toEqual([]);
});
describe('NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER', () => {
it('is Paddle only when the variable says Paddle', async () => {
const { clientEnv } = await importWith({ NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER: 'Paddle' });
expect(clientEnv.NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER).toBe('Paddle');
});
it('falls back to Stripe when unset or unrecognised', async () => {
const unset = await importWith({ NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER: undefined });
expect(unset.clientEnv.NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER).toBe('Stripe');
const junk = await importWith({ NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER: 'Coinbase' });
expect(junk.clientEnv.NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER).toBe('Stripe');
});
});
describe('NEXT_PUBLIC_BASE_URL', () => {
/**
* The `NEXTAUTH_URL` fallback resolves server-side only: Next inlines nothing
* without the `NEXT_PUBLIC_` prefix, so an environment setting only `NEXTAUTH_URL`
* leaves the browser with `undefined`. Pinned rather than removed — see PR #4035.
*/
it('prefers its own variable', async () => {
const { clientEnv } = await importWith({
NEXT_PUBLIC_BASE_URL: 'https://own.test',
NEXTAUTH_URL: 'https://fallback.test',
});
expect(clientEnv.NEXT_PUBLIC_BASE_URL).toBe('https://own.test');
});
it('falls back to the server-only NEXTAUTH_URL when its own is unset', async () => {
const { clientEnv } = await importWith({
NEXT_PUBLIC_BASE_URL: undefined,
NEXTAUTH_URL: 'https://fallback.test',
});
expect(clientEnv.NEXT_PUBLIC_BASE_URL).toBe('https://fallback.test');
});
});
describe('NEXT_PUBLIC_LOG_TRPC', () => {
it('parses a boolean token', async () => {
const { clientSchema } = await importWith({});
expect(clientSchema.parse({ NEXT_PUBLIC_LOG_TRPC: 'true' }).NEXT_PUBLIC_LOG_TRPC).toBe(true);
});
it('treats an empty value as unset rather than as a parse error', async () => {
const { clientSchema } = await importWith({});
expect(clientSchema.parse({ NEXT_PUBLIC_LOG_TRPC: '' }).NEXT_PUBLIC_LOG_TRPC).toBe(false);
});
it('still rejects a value it cannot parse', async () => {
const { clientSchema } = await importWith({});
expect(() => clientSchema.parse({ NEXT_PUBLIC_LOG_TRPC: 'verbose' })).toThrow();
});
});
});
+9 -4
View File
@@ -8,7 +8,6 @@ import { isProd } from './other';
* To expose them to the client, prefix them with `NEXT_PUBLIC_`.
*/
export const clientSchema = z.object({
NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION: z.string().default(''),
NEXT_PUBLIC_IMAGE_LOCATION: z.string().default(''),
NEXT_PUBLIC_CIVITAI_LINK: isProd ? z.url() : z.url().optional(),
NEXT_PUBLIC_GIT_HASH: z.string().optional(),
@@ -26,7 +25,14 @@ export const clientSchema = z.object({
NEXT_PUBLIC_GPTT_UUID_GREEN: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string().optional(),
NEXT_PUBLIC_UI_HOMEPAGE_IMAGES: z.stringbool().default(true),
NEXT_PUBLIC_LOG_TRPC: z.stringbool().default(false),
// An empty value reads as unset rather than as a parse error: the read below named
// the wrong variable until #4035, so a config carrying a valueless key had been
// inert and would otherwise start throwing out of `~/env/client`. Anything else
// unparseable still throws, as it does for every other stringbool here.
NEXT_PUBLIC_LOG_TRPC: z.preprocess(
(value) => (value === '' ? undefined : value),
z.stringbool().default(false)
),
NEXT_PUBLIC_RECAPTCHA_KEY: z.string().optional(),
NEXT_PUBLIC_PAYPAL_CLIENT_ID: z.string().optional(),
NEXT_PUBLIC_CHOPPED_ENDPOINT: z.url().optional(),
@@ -75,7 +81,6 @@ export const clientSchema = z.object({
* @type {{ [k in keyof z.infer<typeof clientSchema>]: z.infer<typeof clientSchema>[k] | undefined }}
*/
export const clientEnv = {
NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION: process.env.NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION,
NEXT_PUBLIC_IMAGE_LOCATION: process.env.NEXT_PUBLIC_IMAGE_LOCATION,
NEXT_PUBLIC_GIT_HASH: process.env.NEXT_PUBLIC_GIT_HASH,
NEXT_PUBLIC_CIVITAI_LINK: process.env.NEXT_PUBLIC_CIVITAI_LINK,
@@ -93,7 +98,7 @@ export const clientEnv = {
NEXT_PUBLIC_GPTT_UUID_GREEN: process.env.NEXT_PUBLIC_GPTT_UUID_GREEN,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXTAUTH_URL,
NEXT_PUBLIC_UI_HOMEPAGE_IMAGES: process.env.NEXT_PUBLIC_UI_HOMEPAGE_IMAGES,
NEXT_PUBLIC_LOG_TRPC: process.env.NEXT_PUBLIC_LOG_TRP,
NEXT_PUBLIC_LOG_TRPC: process.env.NEXT_PUBLIC_LOG_TRPC,
NEXT_PUBLIC_RECAPTCHA_KEY: process.env.NEXT_PUBLIC_RECAPTCHA_KEY,
NEXT_PUBLIC_PAYPAL_CLIENT_ID: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID,
NEXT_PUBLIC_CHOPPED_ENDPOINT: process.env.NEXT_PUBLIC_CHOPPED_ENDPOINT,