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