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:
+143
-58
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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