fix(deploy): harden runtime configuration

Production answered 502 on every url, /favicon.ico included, because the
container had no AUTH_SECRET and the boot check called process.exit(1): the
container died, so Traefik had no upstream and the one line explaining it was
trapped inside a restart-looping container. The exit is already gone (0dc865b).
This makes the configuration contract itself hard to get wrong.

One required variable, one resolver, two probe endpoints.

shared/config/authSecret.ts is now the only place the signing secret is
resolved. sessionToken.ts (HMAC of the identity cookie) and tokenStore.ts
(AES-256-GCM key for the platform token bundle) each read process.env
independently before, under rules that disagreed — one accepted a
whitespace-only value the other rejected. It accepts AUTH_SECRET, or
AUTH_SECRET_FILE for the Docker/Swarm secret convention when a dashboard field
mangles a value, trims both, and is a pure function of the environment.

That purity is load-bearing. Next 16 compiles proxy.ts for the NODE runtime
(its own docs: "Proxy defaults to using the Node.js runtime"), confirmed in the
build output — the proxy is in .next/server/chunks, not .next/server/edge. But
the proxy entry and the route entries are still SEPARATE BUNDLES with their own
copy of this module, so a secret invented in module scope would differ between
them, the proxy would reject every cookie the login route signed, and /login
would redirect forever. There is no generated fallback and there must not be.

LOYALY_API_BASE is no longer required in production. It accepted exactly one
origin, so an unset value could never have meant another, and requiring it added
a failure mode without adding a choice. Verified against the installed @next/env:
a real variable set to the EMPTY STRING is left empty and .env is NOT consulted,
so one blank dashboard field defeated the value shipped in the image and took
production down with "required in production". Any other host set explicitly is
still rejected by name, platform.loyaly.ai included.

/api/health and /api/ready are split. Health was returning 503 on a
configuration fault — readiness semantics on the name every orchestrator probes
by default. A Dockerfile HEALTHCHECK pointed there for one commit, and because
Dokploy runs applications as Swarm services, Swarm removed the task from the
load balancer and rescheduled it: the container was up, serving a 503 that named
the fault, and nothing could reach it to read that 503. Health is now liveness
and always 200 while the process answers; ready is readiness and 503 while a
variable is missing, for a DEPLOY gate (Order start-first + FailureAction
rollback) where failing keeps the previous good task serving. No HEALTHCHECK is
reintroduced.

Diagnostics answer the question that could not be answered from outside the
container: whether the variable never arrived or arrived empty, the secret's
source and length (never its value), and any environment variable whose NAME is
a near-miss for AUTH_SECRET — wrapped (NEXT_PUBLIC_AUTH_SECRET) or mistyped
(AUTH_SECERT, via bounded edit distance). Dokploy's Build Arguments and Build
Secrets are build-time only and absent at runtime, which from inside the
container is indistinguishable from never setting it; the boot log now tells
those apart.

Dockerfile, .env and .env.example changes are comments only — every directive
and every variable value is byte-identical to before.

Verified on the standalone payload the image ships: absent / empty / whitespace
/ typo'd name / wrong API host all keep the container ALIVE and answering 503
with x-loyaly-config: misconfigured; a valid secret gives / 307, /login 200,
/favicon.ico 200, health 200, ready 200. Cross-bundle auth, for both sources: a
cookie signed with the live secret is accepted by the proxy bundle (200) and
independently re-verified by the app/layout.tsx render bundle, while one signed
with a different secret is rejected by both (307). tsc --noEmit clean, eslint
clean on changed files, production build exit 0.

This does not by itself end the outage: AUTH_SECRET still has to be set on the
container, in Dokploy's runtime Environment Variables panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 00:22:11 +05:30
parent 0dc865ba66
commit 781757d377
12 changed files with 481 additions and 129 deletions

View File

@@ -0,0 +1,146 @@
import {readFileSync} from 'node:fs';
import {ConfigError} from '@/shared/errors/configError';
/**
* The ONE place the session-signing secret is resolved.
*
* ── Why this file exists ─────────────────────────────────────────────────
* Before it, `process.env.AUTH_SECRET` was read independently in
* sessionToken.ts (which signs the identity cookie), tokenStore.ts (which
* derives the AES key for the platform token bundle) and configCheck.ts (which
* decides whether the deployment is serviceable). Three readers, three copies
* of the "is it missing?" rule, and three different messages — so a secret that
* arrived empty could satisfy one and fail another.
*
* Next 16 compiles `src/proxy.ts` for the NODE runtime (the docs shipped with
* this version: "Proxy defaults to using the Node.js runtime... Setting the
* runtime config option in Proxy will throw an error"), so every reader now
* lives in one process reading one `process.env`. They are still SEPARATE
* BUNDLES, though — `.next/server/chunks/…` holds one copy of this module for
* the proxy entry and another for the route entries — so anything derived here
* must be a pure function of the environment. A value invented in module scope
* (say, a generated fallback) would differ per bundle, the proxy would reject
* every cookie the login route signed, and /login would redirect forever.
* That is why there is no generated fallback, and why there must not be one.
*
* ── The two accepted sources ─────────────────────────────────────────────
* AUTH_SECRET the value itself. What Dokploy → Environment sets.
* AUTH_SECRET_FILE a path to read it from. The standard Docker/Swarm
* secret convention (/run/secrets/...), and the way to
* supply it when a dashboard field mangles the value.
*
* Env wins when both are set, so a dashboard override never has to fight a
* mounted file. Both are trimmed: a trailing newline is what `echo > secret`
* leaves behind, and an untrimmed newline would make the key silently differ
* from the same secret pasted into a form.
*/
const DEV_SECRET = 'loyaly-dev-secret-not-for-production';
/** Where a resolved secret came from. Reported at boot; never its value. */
export type AuthSecretSource = 'env' | 'file' | 'development' | 'missing';
export interface AuthSecretResolution {
secret: string | null;
source: AuthSecretSource;
/** Set when AUTH_SECRET_FILE was given but could not be read. */
fileError: string | null;
}
let cached: AuthSecretResolution | null = null;
/**
* Resolve without throwing — the form the boot report and /api/health need,
* because they have to describe a broken deployment rather than fail with it.
*
* Memoised: both inputs are fixed for the life of the process, and `key()` in
* tokenStore is called on every cookie read.
*/
export function resolveAuthSecret(): AuthSecretResolution {
if (cached) return cached;
/**
* `.trim()` before the emptiness test, because the failure being caught here
* is a value that ARRIVED but arrived blank — a dashboard field that stored
* whitespace, or a KEY=VALUE line whose value was eaten by a parser splitting
* on the first '='. An AUTH_SECRET of " " is not a configured secret, and
* accepting it would put a one-character key behind every session.
*/
const fromEnv = process.env.AUTH_SECRET?.trim();
if (fromEnv) {
return (cached = {secret: fromEnv, source: 'env', fileError: null});
}
const path = process.env.AUTH_SECRET_FILE?.trim();
if (path) {
try {
const fromFile = readFileSync(path, 'utf8').trim();
if (fromFile) {
return (cached = {secret: fromFile, source: 'file', fileError: null});
}
// A readable but empty file is a misconfiguration, not a missing one —
// say so, rather than reporting the path as simply absent.
return (cached = {
secret: null,
source: 'missing',
fileError: `AUTH_SECRET_FILE (${path}) is empty`,
});
} catch (err) {
return (cached = {
secret: null,
source: 'missing',
fileError: `AUTH_SECRET_FILE (${path}) could not be read: ${
err instanceof Error ? err.message : String(err)
}`,
});
}
}
/**
* Development falls back to a constant so a fresh clone runs with no setup.
* Production does not: a secret checked into git is a session-forging key,
* which is exactly why the literal that used to sit in the Dockerfile was
* removed (8b3fbab).
*/
if (process.env.NODE_ENV !== 'production') {
return (cached = {secret: DEV_SECRET, source: 'development', fileError: null});
}
return (cached = {secret: null, source: 'missing', fileError: null});
}
/**
* The secret, or a ConfigError naming what to do about it. This is what the
* signing and encryption paths call; they cannot proceed without a value and
* must not invent one.
*/
export function authSecret(): string {
const {secret, fileError} = resolveAuthSecret();
if (secret) return secret;
throw new ConfigError(fileError ?? authSecretProblem());
}
/**
* The operator-facing description of what is wrong. Kept next to the resolver
* so the boot report, /api/health and the thrown error cannot describe the same
* state in three different ways.
*/
export function authSecretProblem(): string {
const {secret, fileError} = resolveAuthSecret();
if (secret) return '';
if (fileError) return fileError;
const arrivedButBlank = process.env.AUTH_SECRET !== undefined;
return (
(arrivedButBlank
? 'AUTH_SECRET is set but empty — the variable reached the container with no value. '
: 'AUTH_SECRET is not set — the variable never reached the container. ') +
'It signs the session cookie and encrypts the platform token bundle, and ' +
'production refuses to fall back to the development key. Set it on the ' +
'container (Dokploy → Environment, the RUNTIME panel — a build argument is ' +
'not present at runtime), or mount it and point AUTH_SECRET_FILE at the ' +
'path. Generate one with: openssl rand -hex 32 — hex, not base64, because a ' +
"base64 value ends in '=' and an editor that splits a line on the first '=' " +
'can store it truncated or empty.'
);
}

View File

@@ -20,12 +20,33 @@
import {ConfigError} from '@/shared/errors/configError';
import {resolvePlatformOrigin} from '@/shared/config/platformApi';
import {
authSecretProblem,
resolveAuthSecret,
type AuthSecretSource,
} from '@/shared/config/authSecret';
export interface ConfigStatus {
/** Operator-facing messages. Empty means the server can serve. */
problems: string[];
/** The resolved upstream origin, or null when it could not be resolved. */
platform: string | null;
/** Where the session secret came from. Never the secret itself. */
authSecretSource: AuthSecretSource;
/** Its length, for spotting a value that arrived truncated. Never the value. */
authSecretLength: number;
/**
* Environment variable NAMES present in this container that look like a
* near-miss for AUTH_SECRET. Names only — never values.
*
* This exists because the question "why did the variable not arrive?" was
* unanswerable from outside the container, and the three real answers all
* look identical from a browser: it was never set, it was set in a build-args
* panel instead of the runtime one, or it was set under a slightly different
* name. The first two are invisible from in here; the third is not, and it is
* the one a person cannot see by re-reading their own dashboard.
*/
nearMissNames: string[];
}
let cached: ConfigStatus | null = null;
@@ -62,24 +83,73 @@ export function configStatus(): ConfigStatus {
* inventing a throwaway payload to sign.
*/
/**
* `.trim()` rather than a bare falsiness check, because the failure this is
* most often covering for is a value that ARRIVED but arrived empty — a
* dashboard field that stored whitespace, or a KEY=VALUE line whose value was
* eaten. An AUTH_SECRET of " " is not a configured secret, and treating it as
* one would put a one-character key behind every session in the deployment.
* Asked of the SAME resolver that signing and encryption use, rather than
* re-reading process.env with a fourth copy of the rule. A check that decided
* "configured" by different criteria than the code doing the signing is how a
* container passes its own boot check and then fails every sign-in.
*/
if (process.env.NODE_ENV === 'production' && !process.env.AUTH_SECRET?.trim()) {
problems.push(
'AUTH_SECRET is not set — it signs the session cookie and encrypts the ' +
'platform token bundle, and production refuses to fall back to the ' +
'development key. Set it on the container (Dokploy → Environment). ' +
'Generate one with: openssl rand -hex 32 — hex, not base64, because a ' +
"base64 value ends in '=' and a dashboard field that splits on the " +
'first = can silently store a truncated or empty value.',
);
const {secret, source} = resolveAuthSecret();
if (!secret) problems.push(authSecretProblem());
return (cached = {
problems,
platform,
authSecretSource: source,
authSecretLength: secret?.length ?? 0,
nearMissNames: findNearMissNames(),
});
}
/**
* Names in the container's environment that resemble AUTH_SECRET without being
* it. Deliberately name-only: the values are secrets, and this is printed to a
* log and served from /api/health.
*/
const TARGET = 'AUTHSECRET';
/**
* Edit distance, capped. Substring matching alone catches a WRAPPED name
* (NEXT_PUBLIC_AUTH_SECRET) but not a MISTYPED one — AUTH_SECERT is a
* transposition, and transpositions and single-character slips are what people
* actually type into a dashboard field at the end of a long day.
*
* Bounded at `max`: the loop returns early once every cell in a row exceeds it,
* so an unrelated 40-character variable name costs two rows, not a full matrix.
*/
function withinEditDistance(candidate: string, max: number): boolean {
if (Math.abs(candidate.length - TARGET.length) > max) return false;
let previous = Array.from({length: TARGET.length + 1}, (_, i) => i);
for (let i = 1; i <= candidate.length; i++) {
const current = [i];
let rowMin = i;
for (let j = 1; j <= TARGET.length; j++) {
const cost = candidate[i - 1] === TARGET[j - 1] ? 0 : 1;
const value = Math.min(
current[j - 1] + 1,
previous[j] + 1,
previous[j - 1] + cost,
);
current.push(value);
if (value < rowMin) rowMin = value;
}
if (rowMin > max) return false;
previous = current;
}
return (cached = {problems, platform});
return previous[TARGET.length] <= max;
}
function findNearMissNames(): string[] {
if (process.env.AUTH_SECRET?.trim()) return [];
return Object.keys(process.env).filter((name) => {
if (name === 'AUTH_SECRET' || name === 'AUTH_SECRET_FILE') return false;
const squashed = name.toUpperCase().replace(/[^A-Z]/g, '');
// Wrapped (NEXT_PUBLIC_AUTH_SECRET) or mistyped (AUTH_SECERT).
return squashed.includes(TARGET) || withinEditDistance(squashed, 2);
});
}
/** True when every required variable is present and acceptable. */

View File

@@ -148,12 +148,29 @@ function resolveBase(): string {
return (cachedBase = validateBase(configured, isProduction));
}
if (isProduction) {
throw new ConfigError(
'LOYALY_API_BASE is required in production — refusing to guess the ' +
'Loyaly platform host. Set it to https://mcp.loyaly.ai.',
);
}
/**
* ── Why an unset variable is no longer fatal in production ───────────────
* Production accepts exactly ONE origin (the allowlist below), so an unset
* LOYALY_API_BASE could never have meant anything other than that origin.
* Requiring an operator to type the single permitted value added a failure
* mode without adding a choice — and it is a failure mode that fires easily:
* `.env` ships this value inside the image, but @next/env only fills a
* variable that is ABSENT. Measured against the installed @next/env: a real
* environment variable set to the EMPTY STRING is left empty, and the file is
* not consulted. So one blank field in a dashboard defeated the shipped
* default and took production down with "required in production".
*
* This is not the remote fallback that 759f3b7 removed. That one defaulted to
* `https://platform.loyaly.ai` — the console's OWN origin, a host that is not
* the API at all and that answers wrongly instead of failing. This defaults to
* the one host the validator already insists on, and every other value,
* including that old wrong one, is still rejected by name below.
*
* The result is that production has exactly one required variable —
* AUTH_SECRET — which is the only value that genuinely cannot be shipped.
*/
if (isProduction) return (cachedBase = PRODUCTION_API_ORIGIN);
return (cachedBase = DEV_API_BASE);
}