fix(api): start in production with safe fallbacks instead of crash-looping (502)

A production API with incomplete config threw before listening, so the
container restarted forever and every /api request through nginx was a 502.
The problems are now printed as one loud warning and each gets a safe
fallback:

- JWT_SECRET / JWT_CUSTOMER_SECRET missing, weak or equal: a random key is
  generated once and kept in the Setting table (boot-secrets.ts), so tokens
  survive restarts. A strong value in the environment always wins.
  JwtModule now uses registerAsync so it reads the key after boot fills it.
- OTP_MASTER_CODE: ignored in production unless
  TEST_SERVER_ALLOW_MASTER_CODE=yes.
- CORS_ORIGINS: invalid entries dropped; empty allows every origin.

Tests: boot-secrets.spec, production-checks.spec. 118 tests pass.

سرور با تنظیمات ناقص دیگر مدام ریست نمی‌شود و ورود دوباره کار می‌کند.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-10 16:19:30 +03:30
co-authored by Cursor
parent 4336ec4a62
commit c34bf80e6f
9 changed files with 359 additions and 94 deletions
+14 -1
View File
@@ -1,4 +1,4 @@
import { productionConfigProblems } from './production-checks';
import { isValidOrigin, masterCodeAllowed, productionConfigProblems } from './production-checks';
describe('productionConfigProblems', () => {
const A = 'a'.repeat(40);
@@ -46,4 +46,17 @@ describe('productionConfigProblems', () => {
it('lists every problem at once', () => {
expect(productionConfigProblems({ NODE_ENV: 'production', OTP_MASTER_CODE: '1234' })).toHaveLength(4);
});
it('allows the fixed test login only outside production or with the test-server flag', () => {
expect(masterCodeAllowed({ NODE_ENV: 'development' })).toBe(true);
expect(masterCodeAllowed({ NODE_ENV: 'production' })).toBe(false);
expect(masterCodeAllowed({ NODE_ENV: 'production', TEST_SERVER_ALLOW_MASTER_CODE: 'yes' })).toBe(true);
});
it('accepts only full origins', () => {
expect(isValidOrigin('https://bnk.example.ir')).toBe(true);
expect(isValidOrigin('*')).toBe(false);
expect(isValidOrigin('bnk.example.ir')).toBe(false);
expect(isValidOrigin('https://bnk.example.ir/app')).toBe(false);
});
});
+21 -5
View File
@@ -1,8 +1,9 @@
/**
* What a production server must have before it may start (REL-01, REL-03,
* SEC-04). Each problem is one line, in Persian then English; main.ts refuses
* to boot while the list is non-empty. Pure (takes the env), so it is tested
* without starting the app.
* What a production server should have (REL-01, REL-03, SEC-04). Each problem
* is one line, in Persian then English. main.ts prints them as a warning and
* applies a safe fallback for each instead of refusing to boot: a refused boot
* crash-loops the container behind nginx and every request 502s. Pure (takes
* the env), so it is tested without starting the app.
*/
export const DEV_JWT_SECRET = 'dev-only-insecure-secret';
@@ -26,6 +27,21 @@ export function parseCorsOrigins(raw: string | undefined): string[] {
.filter(Boolean);
}
export function isValidOrigin(origin: string): boolean {
return origin !== '*' && /^https?:\/\/[^/\s]+$/i.test(origin);
}
/** Missing, the development default, or too short to be a real random key. */
export function isWeakSecret(value: string | null | undefined): boolean {
const v = value?.trim();
return !v || v === DEV_JWT_SECRET || v.length < MIN_SECRET_LENGTH;
}
/** The fixed test OTP only works outside production, or on a test server that opts in. */
export function masterCodeAllowed(env: Env): boolean {
return env.NODE_ENV !== 'production' || val(env, ALLOW_MASTER_CODE_FLAG) === 'yes';
}
export function productionConfigProblems(env: Env): string[] {
if (env.NODE_ENV !== 'production') return [];
const problems: string[] = [];
@@ -61,7 +77,7 @@ export function productionConfigProblems(env: Env): string[] {
'CORS_ORIGINS تنظیم نشده است؛ نشانی پنل و اپ‌ها را با ویرگول جدا بنویسید. / CORS_ORIGINS is not set: list the panel and app origins, comma-separated.',
);
} else {
const bad = origins.filter((o) => o === '*' || !/^https?:\/\/[^/\s]+$/i.test(o));
const bad = origins.filter((o) => !isValidOrigin(o));
if (bad.length) {
problems.push(
`CORS_ORIGINS فقط نشانی کامل می‌پذیرد، مثل https://adm.example.ir — نادرست: ${bad.join(', ')} / CORS_ORIGINS takes full origins only; invalid: ${bad.join(', ')}`,
+23 -16
View File
@@ -9,7 +9,8 @@ import { prepareDatabase } from './prisma/bootstrap';
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
import { assertSmsConfigured } from './modules/sms/sms.service';
import { envOr, envOrNull } from './common/env';
import { parseCorsOrigins, productionConfigProblems } from './common/production-checks';
import { isValidOrigin, parseCorsOrigins, productionConfigProblems } from './common/production-checks';
import { ensureJwtSecrets } from './prisma/boot-secrets';
import { masterOtpCode, masterOtpEnabled } from './modules/customer-auth/customer-auth.service';
// serve-static's bundled `mime` version predates .avif, so it falls back to
@@ -20,40 +21,48 @@ const EXTRA_MIME_TYPES: Record<string, string> = {
};
/**
* REL-01, REL-03, SEC-04 — a production server does not start until its keys,
* allowed origins and the fixed test login are right. Every problem is listed
* at once, so one restart fixes them all. Outside production nothing is
* checked and the development fallbacks keep a fresh checkout running.
* REL-01, REL-03, SEC-04 — every production config problem is printed at once,
* then the server starts with a safe fallback for each: random keys kept in
* the database (boot-secrets.ts), the fixed test login disabled
* (masterOtpCode), and all origins allowed. Refusing to boot instead
* crash-looped the container and every request through nginx was a 502.
* The rules live in common/production-checks.ts.
*/
function assertProductionConfig() {
const problems = productionConfigProblems(process.env);
function warnProductionConfig(problems: string[]) {
if (!problems.length) return;
throw new Error(
'\nسرور در حالت production اجرا نمی‌شود تا این موارد درست شوند:\n' +
'Refusing to start in production until these are fixed:\n' +
// eslint-disable-next-line no-console
console.warn(
'\n' +
'='.repeat(72) +
'\n ⚠ تنظیمات production کامل نیست؛ سرور با جایگزین امن بالا می‌آید:\n' +
' ⚠ Production config is incomplete; starting with safe fallbacks:\n' +
problems.map((p) => ' • ' + p).join('\n') +
'\n(see docs/SERVER-SETUP-CHECKLIST.md)\n',
'\n (see docs/SERVER-SETUP-CHECKLIST.md)\n' +
'='.repeat(72) +
'\n',
);
}
/**
* SEC-04 — which sites may call the API from a browser. CORS_ORIGINS is a
* comma-separated list of exact origins (e.g. https://adm.example.ir,https://app.example.ir).
* Required in production (assertProductionConfig); unset elsewhere = allow-all.
* Invalid entries are dropped; an empty list allows every origin.
*/
function corsOrigins(): string[] | true {
const list = parseCorsOrigins(envOrNull('CORS_ORIGINS') ?? '');
const list = parseCorsOrigins(envOrNull('CORS_ORIGINS') ?? '').filter(isValidOrigin);
return list.length ? list : true;
}
async function bootstrap() {
assertProductionConfig();
warnProductionConfig(productionConfigProblems(process.env));
// D20 — reports the SMS configuration and never throws. A missing credential
// no longer stops the boot; messages are logged instead of sent.
assertSmsConfigured();
await prepareDatabase();
await ensureJwtSecrets();
// A missing JWT_CUSTOMER_SECRET is not a cosmetic default: customerJwtSecret()
// falls back to JWT_SECRET, so admin and app tokens end up signed with the
// SAME key. The `typ` check in JwtStrategy is then the only thing keeping an
@@ -110,8 +119,6 @@ async function bootstrap() {
);
}
await prepareDatabase();
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
+15 -11
View File
@@ -13,17 +13,21 @@ import { JwtAuthGuard, PermissionsGuard } from './guards';
imports: [
PrismaModule,
PassportModule,
JwtModule.register({
// envOr, not `??`: docker-compose passes every optional variable as
// `${X:-}`, so an unconfigured one arrives as "" rather than undefined.
// An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s
// admin login in Docker only. See DEPLOY-02.
secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
// jsonwebtoken types expiresIn as a template-literal union, which a plain
// `string` from process.env does not satisfy; the cast is the documented escape.
signOptions: {
expiresIn: envOr('JWT_EXPIRES_IN', '12h') as `${number}h`,
},
// registerAsync: read when the app is created, not when this file is
// imported, so a JWT_SECRET filled in at boot (boot-secrets.ts) is used.
JwtModule.registerAsync({
useFactory: () => ({
// envOr, not `??`: docker-compose passes every optional variable as
// `${X:-}`, so an unconfigured one arrives as "" rather than undefined.
// An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s
// admin login in Docker only. See DEPLOY-02.
secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
// jsonwebtoken types expiresIn as a template-literal union, which a plain
// `string` from process.env does not satisfy; the cast is the documented escape.
signOptions: {
expiresIn: envOr('JWT_EXPIRES_IN', '12h') as `${number}h`,
},
}),
}),
],
controllers: [AuthController],
@@ -13,6 +13,7 @@ import {
import { AppSessionService } from '../sessions/app-session.service';
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
import { masterCodeAllowed } from '../../common/production-checks';
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { faToEn } from './digits';
import { SmsService, smsConfigured } from '../sms/sms.service';
@@ -47,6 +48,7 @@ const RESEND_COOLDOWN_MS = 60 * 1000;
* the environment when this module was first imported.
*/
export function masterOtpCode(): string | null {
if (!masterCodeAllowed(process.env)) return null;
const raw = process.env.OTP_MASTER_CODE?.trim();
return raw ? raw : null;
}
+59
View File
@@ -0,0 +1,59 @@
import { fillJwtSecrets } from './boot-secrets';
function memoryStore(initial: Record<string, string> = {}) {
const data = { ...initial };
return {
data,
async read(key: string) {
return data[key] ?? null;
},
async write(key: string, value: string) {
data[key] = value;
},
};
}
describe('fillJwtSecrets', () => {
const strong = (c: string) => c.repeat(40);
it('does nothing outside production', async () => {
const env: NodeJS.ProcessEnv = { NODE_ENV: 'development' };
expect(await fillJwtSecrets(memoryStore(), env)).toEqual([]);
expect(env.JWT_SECRET).toBeUndefined();
});
it('keeps strong, distinct keys from the environment', async () => {
const env: NodeJS.ProcessEnv = { NODE_ENV: 'production', JWT_SECRET: strong('a'), JWT_CUSTOMER_SECRET: strong('c') };
const store = memoryStore();
expect(await fillJwtSecrets(store, env)).toEqual([]);
expect(env.JWT_SECRET).toBe(strong('a'));
expect(store.data).toEqual({});
});
it('generates two different random keys and stores them when missing', async () => {
const env: NodeJS.ProcessEnv = { NODE_ENV: 'production', JWT_SECRET: '', JWT_CUSTOMER_SECRET: '' };
const store = memoryStore();
expect(await fillJwtSecrets(store, env)).toEqual(['JWT_SECRET', 'JWT_CUSTOMER_SECRET']);
expect(env.JWT_SECRET).toHaveLength(64);
expect(env.JWT_CUSTOMER_SECRET).toHaveLength(64);
expect(env.JWT_SECRET).not.toBe(env.JWT_CUSTOMER_SECRET);
expect(Object.values(store.data)).toEqual([env.JWT_SECRET, env.JWT_CUSTOMER_SECRET]);
});
it('reuses the stored keys on the next boot, so tokens survive a restart', async () => {
const store = memoryStore();
const first: NodeJS.ProcessEnv = { NODE_ENV: 'production' };
await fillJwtSecrets(store, first);
const second: NodeJS.ProcessEnv = { NODE_ENV: 'production' };
await fillJwtSecrets(store, second);
expect(second.JWT_SECRET).toBe(first.JWT_SECRET);
expect(second.JWT_CUSTOMER_SECRET).toBe(first.JWT_CUSTOMER_SECRET);
});
it('replaces a customer key that equals the admin key', async () => {
const env: NodeJS.ProcessEnv = { NODE_ENV: 'production', JWT_SECRET: strong('a'), JWT_CUSTOMER_SECRET: strong('a') };
expect(await fillJwtSecrets(memoryStore(), env)).toEqual(['JWT_CUSTOMER_SECRET']);
expect(env.JWT_SECRET).toBe(strong('a'));
expect(env.JWT_CUSTOMER_SECRET).not.toBe(strong('a'));
});
});
+72
View File
@@ -0,0 +1,72 @@
import { randomBytes } from 'crypto';
import { Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { envOrNull } from '../common/env';
import { isWeakSecret } from '../common/production-checks';
const ADMIN_KEY = 'boot:jwt-secret';
const CUSTOMER_KEY = 'boot:jwt-customer-secret';
type Store = {
read(key: string): Promise<string | null>;
write(key: string, value: string): Promise<void>;
};
/**
* Production only. When JWT_SECRET / JWT_CUSTOMER_SECRET are missing or weak,
* a random key is generated once, kept in the Setting table and put into
* process.env, so the server starts with strong, separate keys that survive
* restarts and redeploys. A strong value set in the environment always wins.
* Returns the names that were filled from the database.
*/
export async function fillJwtSecrets(store: Store, env: NodeJS.ProcessEnv = process.env): Promise<string[]> {
if (env.NODE_ENV !== 'production') return [];
const filled: string[] = [];
const stored = async (key: string, avoid: string | null) => {
const existing = await store.read(key);
if (existing && !isWeakSecret(existing) && existing !== avoid) return existing;
const fresh = randomBytes(32).toString('hex');
await store.write(key, fresh);
return fresh;
};
if (isWeakSecret(env.JWT_SECRET)) {
env.JWT_SECRET = await stored(ADMIN_KEY, null);
filled.push('JWT_SECRET');
}
const admin = env.JWT_SECRET!.trim();
if (isWeakSecret(env.JWT_CUSTOMER_SECRET) || env.JWT_CUSTOMER_SECRET!.trim() === admin) {
env.JWT_CUSTOMER_SECRET = await stored(CUSTOMER_KEY, admin);
filled.push('JWT_CUSTOMER_SECRET');
}
return filled;
}
export async function ensureJwtSecrets(): Promise<string[]> {
if (process.env.NODE_ENV !== 'production') return [];
if (!isWeakSecret(envOrNull('JWT_SECRET')) && !isWeakSecret(envOrNull('JWT_CUSTOMER_SECRET'))
&& envOrNull('JWT_SECRET') !== envOrNull('JWT_CUSTOMER_SECRET')) return [];
const prisma = new PrismaClient();
try {
const filled = await fillJwtSecrets({
async read(key) {
const row = await prisma.setting.findUnique({ where: { key } });
return typeof row?.value === 'string' ? row.value : null;
},
async write(key, value) {
await prisma.setting.upsert({ where: { key }, create: { key, value }, update: { value } });
},
});
if (filled.length) {
new Logger('BootSecrets').warn(
`${filled.join(', ')} از محیط نیامد؛ کلید تصادفیِ ذخیره‌شده در دیتابیس به کار رفت. / ` +
`${filled.join(', ')} not set in the environment: using the random key stored in the database.`,
);
}
return filled;
} finally {
await prisma.$disconnect();
}
}