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
+143 -58
View File
@@ -87091,6 +87091,72 @@ var require_app_session_service = __commonJS({
}
});
// dist/common/production-checks.js
var require_production_checks = __commonJS({
"dist/common/production-checks.js"(exports2) {
"use strict";
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.ALLOW_MASTER_CODE_FLAG = exports2.DEV_JWT_SECRET = void 0;
exports2.parseCorsOrigins = parseCorsOrigins;
exports2.isValidOrigin = isValidOrigin;
exports2.isWeakSecret = isWeakSecret;
exports2.masterCodeAllowed = masterCodeAllowed;
exports2.productionConfigProblems = productionConfigProblems;
exports2.DEV_JWT_SECRET = "dev-only-insecure-secret";
exports2.ALLOW_MASTER_CODE_FLAG = "TEST_SERVER_ALLOW_MASTER_CODE";
var MIN_SECRET_LENGTH = 32;
var val = (env2, k) => {
const v = env2[k]?.trim();
return v ? v : null;
};
function parseCorsOrigins(raw) {
return (raw ?? "").split(",").map((o) => o.trim().replace(/\/+$/, "")).filter(Boolean);
}
function isValidOrigin(origin) {
return origin !== "*" && /^https?:\/\/[^/\s]+$/i.test(origin);
}
function isWeakSecret(value) {
const v = value?.trim();
return !v || v === exports2.DEV_JWT_SECRET || v.length < MIN_SECRET_LENGTH;
}
function masterCodeAllowed(env2) {
return env2.NODE_ENV !== "production" || val(env2, exports2.ALLOW_MASTER_CODE_FLAG) === "yes";
}
function productionConfigProblems(env2) {
if (env2.NODE_ENV !== "production")
return [];
const problems = [];
const admin = val(env2, "JWT_SECRET");
if (!admin) {
problems.push("JWT_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A. / JWT_SECRET is not set.");
} else if (admin === exports2.DEV_JWT_SECRET || admin.length < MIN_SECRET_LENGTH) {
problems.push(`JWT_SECRET \u0628\u0627\u06CC\u062F \u06CC\u06A9 \u0645\u0642\u062F\u0627\u0631 \u062A\u0635\u0627\u062F\u0641\u06CC \u062F\u0633\u062A\u200C\u06A9\u0645 ${MIN_SECRET_LENGTH} \u0646\u0648\u06CC\u0633\u0647 \u0628\u0627\u0634\u062F. / JWT_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`);
}
const customer = val(env2, "JWT_CUSTOMER_SECRET");
if (!customer) {
problems.push("JWT_CUSTOMER_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\u061B \u062A\u0648\u06A9\u0646 \u0645\u0634\u062A\u0631\u06CC\u200C\u0647\u0627 \u0628\u0627 \u06A9\u0644\u06CC\u062F \u0645\u062F\u06CC\u0631\u0647\u0627 \u0627\u0645\u0636\u0627 \u0645\u06CC\u200C\u0634\u0648\u062F. / JWT_CUSTOMER_SECRET is not set: app tokens would be signed with the admin key.");
} else if (customer === admin) {
problems.push("JWT_CUSTOMER_SECRET \u0646\u0628\u0627\u06CC\u062F \u0628\u0627 JWT_SECRET \u06CC\u06A9\u06CC \u0628\u0627\u0634\u062F. / JWT_CUSTOMER_SECRET must differ from JWT_SECRET.");
} else if (customer === exports2.DEV_JWT_SECRET || customer.length < MIN_SECRET_LENGTH) {
problems.push(`JWT_CUSTOMER_SECRET \u0628\u0627\u06CC\u062F \u06CC\u06A9 \u0645\u0642\u062F\u0627\u0631 \u062A\u0635\u0627\u062F\u0641\u06CC \u062F\u0633\u062A\u200C\u06A9\u0645 ${MIN_SECRET_LENGTH} \u0646\u0648\u06CC\u0633\u0647 \u0628\u0627\u0634\u062F. / JWT_CUSTOMER_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`);
}
const origins = parseCorsOrigins(env2.CORS_ORIGINS);
if (!origins.length) {
problems.push("CORS_ORIGINS \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\u061B \u0646\u0634\u0627\u0646\u06CC \u067E\u0646\u0644 \u0648 \u0627\u067E\u200C\u0647\u0627 \u0631\u0627 \u0628\u0627 \u0648\u06CC\u0631\u06AF\u0648\u0644 \u062C\u062F\u0627 \u0628\u0646\u0648\u06CC\u0633\u06CC\u062F. / CORS_ORIGINS is not set: list the panel and app origins, comma-separated.");
} else {
const bad = origins.filter((o) => !isValidOrigin(o));
if (bad.length) {
problems.push(`CORS_ORIGINS \u0641\u0642\u0637 \u0646\u0634\u0627\u0646\u06CC \u06A9\u0627\u0645\u0644 \u0645\u06CC\u200C\u067E\u0630\u06CC\u0631\u062F\u060C \u0645\u062B\u0644 https://adm.example.ir \u2014 \u0646\u0627\u062F\u0631\u0633\u062A: ${bad.join(", ")} / CORS_ORIGINS takes full origins only; invalid: ${bad.join(", ")}`);
}
}
if (val(env2, "OTP_MASTER_CODE") && val(env2, exports2.ALLOW_MASTER_CODE_FLAG) !== "yes") {
problems.push(`OTP_MASTER_CODE (\u06A9\u062F \u062B\u0627\u0628\u062A \u0648\u0631\u0648\u062F \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC) \u0631\u0648\u06CC \u0633\u0631\u0648\u0631 \u0627\u0635\u0644\u06CC \u0645\u062C\u0627\u0632 \u0646\u06CC\u0633\u062A\u061B \u0622\u0646 \u0631\u0627 \u062D\u0630\u0641 \u06A9\u0646\u06CC\u062F. \u0641\u0642\u0637 \u0633\u0631\u0648\u0631 \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC \u0628\u0627 ${exports2.ALLOW_MASTER_CODE_FLAG}=yes. / OTP_MASTER_CODE is not allowed in production; remove it (test servers only: ${exports2.ALLOW_MASTER_CODE_FLAG}=yes).`);
}
return problems;
}
}
});
// ../../node_modules/.pnpm/passport-jwt@4.0.1/node_modules/passport-jwt/lib/auth_header.js
var require_auth_header = __commonJS({
"../../node_modules/.pnpm/passport-jwt@4.0.1/node_modules/passport-jwt/lib/auth_header.js"(exports2, module2) {
@@ -87851,6 +87917,7 @@ var require_customer_auth_service = __commonJS({
var app_session_service_1 = require_app_session_service();
var password_hash_1 = require_password_hash();
var env_12 = require_env();
var production_checks_12 = require_production_checks();
var customer_jwt_strategy_1 = require_customer_jwt_strategy();
var digits_1 = require_digits();
var sms_service_12 = require_sms_service();
@@ -87866,6 +87933,8 @@ var require_customer_auth_service = __commonJS({
var SEND_WINDOW_MS = 10 * 60 * 1e3;
var RESEND_COOLDOWN_MS = 60 * 1e3;
function masterOtpCode() {
if (!(0, production_checks_12.masterCodeAllowed)(process.env))
return null;
const raw = process.env.OTP_MASTER_CODE?.trim();
return raw ? raw : null;
}
@@ -93351,17 +93420,21 @@ var require_auth_module = __commonJS({
imports: [
prisma_module_1.PrismaModule,
passport_1.PassportModule,
jwt_1.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: (0, env_12.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: (0, env_12.envOr)("JWT_EXPIRES_IN", "12h")
}
// 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.
jwt_1.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: (0, env_12.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: (0, env_12.envOr)("JWT_EXPIRES_IN", "12h")
}
})
})
],
controllers: [auth_controller_1.AuthController],
@@ -162522,55 +162595,66 @@ var require_prisma_exception_filter = __commonJS({
}
});
// dist/common/production-checks.js
var require_production_checks = __commonJS({
"dist/common/production-checks.js"(exports2) {
// dist/prisma/boot-secrets.js
var require_boot_secrets = __commonJS({
"dist/prisma/boot-secrets.js"(exports2) {
"use strict";
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.ALLOW_MASTER_CODE_FLAG = exports2.DEV_JWT_SECRET = void 0;
exports2.parseCorsOrigins = parseCorsOrigins;
exports2.productionConfigProblems = productionConfigProblems;
exports2.DEV_JWT_SECRET = "dev-only-insecure-secret";
exports2.ALLOW_MASTER_CODE_FLAG = "TEST_SERVER_ALLOW_MASTER_CODE";
var MIN_SECRET_LENGTH = 32;
var val = (env2, k) => {
const v = env2[k]?.trim();
return v ? v : null;
};
function parseCorsOrigins(raw) {
return (raw ?? "").split(",").map((o) => o.trim().replace(/\/+$/, "")).filter(Boolean);
}
function productionConfigProblems(env2) {
exports2.fillJwtSecrets = fillJwtSecrets;
exports2.ensureJwtSecrets = ensureJwtSecrets;
var crypto_1 = require("crypto");
var common_12 = require_common();
var client_1 = require("@prisma/client");
var env_12 = require_env();
var production_checks_12 = require_production_checks();
var ADMIN_KEY = "boot:jwt-secret";
var CUSTOMER_KEY = "boot:jwt-customer-secret";
async function fillJwtSecrets(store, env2 = process.env) {
if (env2.NODE_ENV !== "production")
return [];
const problems = [];
const admin = val(env2, "JWT_SECRET");
if (!admin) {
problems.push("JWT_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A. / JWT_SECRET is not set.");
} else if (admin === exports2.DEV_JWT_SECRET || admin.length < MIN_SECRET_LENGTH) {
problems.push(`JWT_SECRET \u0628\u0627\u06CC\u062F \u06CC\u06A9 \u0645\u0642\u062F\u0627\u0631 \u062A\u0635\u0627\u062F\u0641\u06CC \u062F\u0633\u062A\u200C\u06A9\u0645 ${MIN_SECRET_LENGTH} \u0646\u0648\u06CC\u0633\u0647 \u0628\u0627\u0634\u062F. / JWT_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`);
const filled = [];
const stored = async (key, avoid) => {
const existing = await store.read(key);
if (existing && !(0, production_checks_12.isWeakSecret)(existing) && existing !== avoid)
return existing;
const fresh = (0, crypto_1.randomBytes)(32).toString("hex");
await store.write(key, fresh);
return fresh;
};
if ((0, production_checks_12.isWeakSecret)(env2.JWT_SECRET)) {
env2.JWT_SECRET = await stored(ADMIN_KEY, null);
filled.push("JWT_SECRET");
}
const customer = val(env2, "JWT_CUSTOMER_SECRET");
if (!customer) {
problems.push("JWT_CUSTOMER_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\u061B \u062A\u0648\u06A9\u0646 \u0645\u0634\u062A\u0631\u06CC\u200C\u0647\u0627 \u0628\u0627 \u06A9\u0644\u06CC\u062F \u0645\u062F\u06CC\u0631\u0647\u0627 \u0627\u0645\u0636\u0627 \u0645\u06CC\u200C\u0634\u0648\u062F. / JWT_CUSTOMER_SECRET is not set: app tokens would be signed with the admin key.");
} else if (customer === admin) {
problems.push("JWT_CUSTOMER_SECRET \u0646\u0628\u0627\u06CC\u062F \u0628\u0627 JWT_SECRET \u06CC\u06A9\u06CC \u0628\u0627\u0634\u062F. / JWT_CUSTOMER_SECRET must differ from JWT_SECRET.");
} else if (customer === exports2.DEV_JWT_SECRET || customer.length < MIN_SECRET_LENGTH) {
problems.push(`JWT_CUSTOMER_SECRET \u0628\u0627\u06CC\u062F \u06CC\u06A9 \u0645\u0642\u062F\u0627\u0631 \u062A\u0635\u0627\u062F\u0641\u06CC \u062F\u0633\u062A\u200C\u06A9\u0645 ${MIN_SECRET_LENGTH} \u0646\u0648\u06CC\u0633\u0647 \u0628\u0627\u0634\u062F. / JWT_CUSTOMER_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`);
const admin = env2.JWT_SECRET.trim();
if ((0, production_checks_12.isWeakSecret)(env2.JWT_CUSTOMER_SECRET) || env2.JWT_CUSTOMER_SECRET.trim() === admin) {
env2.JWT_CUSTOMER_SECRET = await stored(CUSTOMER_KEY, admin);
filled.push("JWT_CUSTOMER_SECRET");
}
const origins = parseCorsOrigins(env2.CORS_ORIGINS);
if (!origins.length) {
problems.push("CORS_ORIGINS \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\u061B \u0646\u0634\u0627\u0646\u06CC \u067E\u0646\u0644 \u0648 \u0627\u067E\u200C\u0647\u0627 \u0631\u0627 \u0628\u0627 \u0648\u06CC\u0631\u06AF\u0648\u0644 \u062C\u062F\u0627 \u0628\u0646\u0648\u06CC\u0633\u06CC\u062F. / 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));
if (bad.length) {
problems.push(`CORS_ORIGINS \u0641\u0642\u0637 \u0646\u0634\u0627\u0646\u06CC \u06A9\u0627\u0645\u0644 \u0645\u06CC\u200C\u067E\u0630\u06CC\u0631\u062F\u060C \u0645\u062B\u0644 https://adm.example.ir \u2014 \u0646\u0627\u062F\u0631\u0633\u062A: ${bad.join(", ")} / CORS_ORIGINS takes full origins only; invalid: ${bad.join(", ")}`);
return filled;
}
async function ensureJwtSecrets() {
if (process.env.NODE_ENV !== "production")
return [];
if (!(0, production_checks_12.isWeakSecret)((0, env_12.envOrNull)("JWT_SECRET")) && !(0, production_checks_12.isWeakSecret)((0, env_12.envOrNull)("JWT_CUSTOMER_SECRET")) && (0, env_12.envOrNull)("JWT_SECRET") !== (0, env_12.envOrNull)("JWT_CUSTOMER_SECRET"))
return [];
const prisma = new client_1.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 common_12.Logger("BootSecrets").warn(`${filled.join(", ")} \u0627\u0632 \u0645\u062D\u06CC\u0637 \u0646\u06CC\u0627\u0645\u062F\u061B \u06A9\u0644\u06CC\u062F \u062A\u0635\u0627\u062F\u0641\u06CC\u0650 \u0630\u062E\u06CC\u0631\u0647\u200C\u0634\u062F\u0647 \u062F\u0631 \u062F\u06CC\u062A\u0627\u0628\u06CC\u0633 \u0628\u0647 \u06A9\u0627\u0631 \u0631\u0641\u062A. / ${filled.join(", ")} not set in the environment: using the random key stored in the database.`);
}
return filled;
} finally {
await prisma.$disconnect();
}
if (val(env2, "OTP_MASTER_CODE") && val(env2, exports2.ALLOW_MASTER_CODE_FLAG) !== "yes") {
problems.push(`OTP_MASTER_CODE (\u06A9\u062F \u062B\u0627\u0628\u062A \u0648\u0631\u0648\u062F \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC) \u0631\u0648\u06CC \u0633\u0631\u0648\u0631 \u0627\u0635\u0644\u06CC \u0645\u062C\u0627\u0632 \u0646\u06CC\u0633\u062A\u061B \u0622\u0646 \u0631\u0627 \u062D\u0630\u0641 \u06A9\u0646\u06CC\u062F. \u0641\u0642\u0637 \u0633\u0631\u0648\u0631 \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC \u0628\u0627 ${exports2.ALLOW_MASTER_CODE_FLAG}=yes. / OTP_MASTER_CODE is not allowed in production; remove it (test servers only: ${exports2.ALLOW_MASTER_CODE_FLAG}=yes).`);
}
return problems;
}
}
});
@@ -162587,23 +162671,25 @@ var prisma_exception_filter_1 = require_prisma_exception_filter();
var sms_service_1 = require_sms_service();
var env_1 = require_env();
var production_checks_1 = require_production_checks();
var boot_secrets_1 = require_boot_secrets();
var customer_auth_service_1 = require_customer_auth_service();
var EXTRA_MIME_TYPES = {
".avif": "image/avif"
};
function assertProductionConfig() {
const problems = (0, production_checks_1.productionConfigProblems)(process.env);
function warnProductionConfig(problems) {
if (!problems.length)
return;
throw new Error("\n\u0633\u0631\u0648\u0631 \u062F\u0631 \u062D\u0627\u0644\u062A production \u0627\u062C\u0631\u0627 \u0646\u0645\u06CC\u200C\u0634\u0648\u062F \u062A\u0627 \u0627\u06CC\u0646 \u0645\u0648\u0627\u0631\u062F \u062F\u0631\u0633\u062A \u0634\u0648\u0646\u062F:\nRefusing to start in production until these are fixed:\n" + problems.map((p) => " \u2022 " + p).join("\n") + "\n(see docs/SERVER-SETUP-CHECKLIST.md)\n");
console.warn("\n" + "=".repeat(72) + "\n \u26A0 \u062A\u0646\u0638\u06CC\u0645\u0627\u062A production \u06A9\u0627\u0645\u0644 \u0646\u06CC\u0633\u062A\u061B \u0633\u0631\u0648\u0631 \u0628\u0627 \u062C\u0627\u06CC\u06AF\u0632\u06CC\u0646 \u0627\u0645\u0646 \u0628\u0627\u0644\u0627 \u0645\u06CC\u200C\u0622\u06CC\u062F:\n \u26A0 Production config is incomplete; starting with safe fallbacks:\n" + problems.map((p) => " \u2022 " + p).join("\n") + "\n (see docs/SERVER-SETUP-CHECKLIST.md)\n" + "=".repeat(72) + "\n");
}
function corsOrigins() {
const list = (0, production_checks_1.parseCorsOrigins)((0, env_1.envOrNull)("CORS_ORIGINS") ?? "");
const list = (0, production_checks_1.parseCorsOrigins)((0, env_1.envOrNull)("CORS_ORIGINS") ?? "").filter(production_checks_1.isValidOrigin);
return list.length ? list : true;
}
async function bootstrap() {
assertProductionConfig();
warnProductionConfig((0, production_checks_1.productionConfigProblems)(process.env));
(0, sms_service_1.assertSmsConfigured)();
await (0, bootstrap_1.prepareDatabase)();
await (0, boot_secrets_1.ensureJwtSecrets)();
if (!(0, env_1.envOrNull)("JWT_CUSTOMER_SECRET")) {
const sharedWithAdmin = Boolean((0, env_1.envOrNull)("JWT_SECRET"));
console.warn("\n" + "=".repeat(72) + "\n \u26A0 JWT_CUSTOMER_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\n \u26A0 JWT_CUSTOMER_SECRET IS NOT SET.\n" + (sharedWithAdmin ? " \u26A0 Customer/expert app tokens are being signed with JWT_SECRET,\n \u26A0 the SAME key as admin tokens. The two token realms are no\n \u26A0 longer cryptographically separated.\n" : " \u26A0 Neither JWT_SECRET nor JWT_CUSTOMER_SECRET is set, so app\n \u26A0 tokens are signed with a well-known development string.\n") + " \u26A0 Set JWT_CUSTOMER_SECRET to a distinct random value before release.\n" + "=".repeat(72) + "\n");
@@ -162613,7 +162699,6 @@ async function bootstrap() {
const typable = /^\d{4}$/.test(code);
console.warn("\n" + "=".repeat(72) + "\n \u26A0 OTP_MASTER_CODE \u0641\u0639\u0627\u0644 \u0627\u0633\u062A \u2014 \u0642\u0628\u0644 \u0627\u0632 \u0627\u0646\u062A\u0634\u0627\u0631 \u0627\u0635\u0644\u06CC \u062D\u0630\u0641 \u0634\u0648\u062F\n \u26A0 OTP_MASTER_CODE IS ACTIVE: any active customer can sign in with\n \u26A0 this fixed code. Remove it from the environment before release.\n" + (typable ? "" : " \u26A0\n \u26A0 NOTE: this code is not 4 digits, so it CANNOT be entered in the\n \u26A0 customer apps \u2014 their OTP box has exactly 4 boxes. Use a\n \u26A0 4-digit code for the apps; this one works over the API only.\n") + "=".repeat(72) + "\n");
}
await (0, bootstrap_1.prepareDatabase)();
const app = await core_1.NestFactory.create(app_module_1.AppModule);
app.setGlobalPrefix("api");
app.useGlobalPipes(new common_1.ValidationPipe({ whitelist: true, transform: true }));