import {resolvePlatformOrigin} from '@/shared/config/platformApi'; import {ConfigError} from '@/shared/errors/configError'; /** * The Node half of the boot-time configuration check. * * ── Why this is a separate file ────────────────────────────────────────── * `instrumentation.ts` is compiled for EVERY runtime the app uses, and * src/proxy.ts makes this app compile an Edge one. `process.exit` does not * exist there, so Turbopack statically flagged it — "A Node.js API is used * (process.exit) which is not supported in the Edge Runtime" — even though the * call sits behind a NEXT_RUNTIME guard and could never run in that bundle. * * A runtime guard is not a bundling boundary. A separate module reached through * a dynamic import IS one, so the Edge bundle never contains this code at all. * * These imports are static rather than dynamic because this module is only ever * loaded from the Node branch. Neither is `server-only`: apiClient is, and that * package resolves to a module which THROWS ON IMPORT outside a react-server * condition, which this bundle is not. shared/config/platformApi exists so the * boot check and the request path can share one validator without that hazard. */ export async function checkConfiguration() { const isProduction = process.env.NODE_ENV === 'production'; const problems: string[] = []; /** * Resolve the platform origin exactly the way a request would — same * function, same validation, same allowlist. A check that reimplemented the * rules would be a second source of truth and would drift from the real one. */ let platform: string | null = null; try { platform = resolvePlatformOrigin(); } catch (err) { if (!(err instanceof ConfigError)) throw err; problems.push(err.message); } /** * AUTH_SECRET is checked by presence rather than by calling the signing * functions, which would have to be handed a throwaway payload to probe. * Presence is the whole rule — sessionToken and tokenStore both refuse the * development key in production and accept anything else. */ if (isProduction && !process.env.AUTH_SECRET) { problems.push( 'AUTH_SECRET is required in production — it signs the session cookie and ' + 'encrypts the platform token bundle. Generate one with: openssl rand -base64 48', ); } if (problems.length > 0) { // Numbered, because a container missing its environment is usually missing // more than one variable, and finding that out one deploy at a time is the // slow way. const detail = problems.map((p, i) => ` ${i + 1}. ${p}`).join('\n'); console.error( `\n[loyaly] refusing to start — ${problems.length} configuration problem(s):\n${detail}\n` + '\nSet these as environment variables on the container (Dokploy → Environment). ' + 'LOYALY_API_BASE also ships in the committed .env; AUTH_SECRET never does.\n', ); /** * Exit, rather than throw. * * Throwing from `register()` does NOT stop the server. Measured against a * real standalone build: the port is already bound by the time this runs, * so Next logs "Failed to prepare server" and the process STAYS ALIVE, * answering 500 to every route — /login, /favicon.ico, everything. That is * the worst of both worlds. A TCP or HTTP healthcheck sees an open port and * calls the container healthy, so a dead deploy shows up green and keeps * receiving traffic. * * Exiting non-zero makes the container die, which is what a deployment * platform reads as a failed deploy. The message above is already on * stderr, so each restart attempt reprints the reason. */ process.exit(1); } // The healthy path says what it resolved, so a log reader can confirm the // host WITHOUT having to trigger a request. Never prints AUTH_SECRET, only // whether one was supplied. console.log( `[loyaly] config ok — platform ${platform}, ` + `auth secret ${process.env.AUTH_SECRET ? 'set' : 'using development key'}, ` + `NODE_ENV=${process.env.NODE_ENV}`, ); }