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:
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user