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
+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();
}
}