From 9d3b5388baeae2c91976430f9bb29ac9d7a9db1c Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Thu, 8 Oct 2026 17:00:17 +0330 Subject: [PATCH] fix(security): a production server refuses to start until its keys, allowed origins and test login are right (REL-01, REL-03, SEC-04) With NODE_ENV=production (always, in the Docker image) the API lists every problem at once and does not start while any of these holds: - OTP_MASTER_CODE is set: the fixed test login signs in any customer. A test server built from the production image must say so with TEST_SERVER_ALLOW_MASTER_CODE=yes. - JWT_CUSTOMER_SECRET is missing, equal to JWT_SECRET, or under 32 characters. - JWT_SECRET is missing, the published dev key, or under 32 characters. - CORS_ORIGINS is empty, `*`, or not full origins. It no longer falls back to allow-all. Checked live: a misconfigured start lists all four problems; a correct one starts, answers the allowed origin, and blocks any other. docs/SERVER-SETUP-CHECKLIST.md is the operator's list. DELIVERY.md, docker-compose.yml and .env.example are updated to match. Co-Authored-By: Claude Opus 5.5 --- DELIVERY.md | 19 +++-- apps/api/.env.example | 12 +-- apps/api/src/common/production-checks.spec.ts | 49 ++++++++++++ apps/api/src/common/production-checks.ts | 80 +++++++++++++++++++ apps/api/src/main.ts | 43 +++++----- docker-compose.yml | 16 ++-- docs/SERVER-SETUP-CHECKLIST.md | 75 +++++++++++++++++ docs/parity/ISSUES.md | 4 +- 8 files changed, 256 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/common/production-checks.spec.ts create mode 100644 apps/api/src/common/production-checks.ts create mode 100644 docs/SERVER-SETUP-CHECKLIST.md diff --git a/DELIVERY.md b/DELIVERY.md index 397b40d..18e5dc1 100644 --- a/DELIVERY.md +++ b/DELIVERY.md @@ -25,10 +25,17 @@ DATABASE_URL=postgresql://postgres:@db:5432/goldbank?schema=p # Auth — two DIFFERENT random values. Generate each with: # node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" -JWT_SECRET= -JWT_CUSTOMER_SECRET= +JWT_SECRET= +JWT_CUSTOMER_SECRET= +# Every origin the panel and the two apps are opened from, comma-separated. +CORS_ORIGINS=https://adm.example.ir,https://app.example.ir,https://app2.example.ir ``` +The API **refuses to start** in production until all three are right, and +prints every problem at once (REL-01, REL-03, SEC-04 — +docs/SERVER-SETUP-CHECKLIST.md). A random value: +`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. + Inside Docker the database host is `db`, not `localhost`. Everything else is optional and may be left blank. Two you may want now: @@ -36,8 +43,10 @@ Everything else is optional and may be left blank. Two you may want now: ```dotenv # Delivery only — a fixed OTP that signs in ANY active customer. # Exactly 4 digits, or it cannot be typed into the apps. REMOVE before the -# real release (see "Before the real release" below). +# real release (see "Before the real release" below). The production image +# refuses to start with it unless the server is marked as a test server: OTP_MASTER_CODE=1379 +TEST_SERVER_ALLOW_MASTER_CODE=yes # SMS goes through the "relay" gateway with nothing to set (D26). # SMS_PROVIDER=none turns sending off; the server still starts either way. @@ -112,8 +121,8 @@ before it is a production release. The first three are blocking. | # | Do this | Why it matters | |---|---|---| | **1** | **Set `JWT_CUSTOMER_SECRET` to its own random value**, different from `JWT_SECRET`. | Not a cosmetic default. Unset, the API signs customer and sales-expert **app** tokens with `JWT_SECRET` — *the same key admin tokens use* — and the two token realms stop being cryptographically separate. The only remaining barrier is the `typ` check in `jwt.strategy.ts`, and a sales expert's app token carries their **AdminUser id** in `sub`. Two independent defences collapse into one. A loud boot banner fires while it is missing. **REL-03 (Critical).** | -| **2** | **Remove `OTP_MASTER_CODE` from the environment.** | While set, **any active customer signs in with that one fixed code**. Unset or empty disables it completely; the API prints a banner at boot for as long as it is armed. **REL-01 (Critical).** | -| **3** | **Set real `JWT_SECRET`.** | Without it the API falls back to a well-known development string, so anyone can forge an admin token. | +| **2** | **Remove `OTP_MASTER_CODE` and `TEST_SERVER_ALLOW_MASTER_CODE` from the environment.** The API now refuses to start with the code set unless that flag says this is a test server. | While set, **any active customer signs in with that one fixed code**. Unset or empty disables it completely; the API prints a banner at boot for as long as it is armed. **REL-01 (Critical).** | +| **3** | **Set real `JWT_SECRET` and `CORS_ORIGINS`.** The API refuses to start without them. | Without the key anyone could forge an admin token; without the origin list any website could call the API from a visitor's browser. | | **4** | **Confirm SMS is going out.** The default `relay` gateway needs no credentials (D26); check `docker compose logs api` for `SMS provider: relay` and send yourself a login code. | If the gateway refuses the `host` value (inside Docker it is the container's IP), set `SMS_RELAY_HOST`. A failed send is only a log line — the server still boots (D20). **REL-02.** | | **5** | **Accounting integration (D27), only if it will be switched on:** set `ACCOUNTING_SECRET_KEY`; count products with no image (they disappear from the app — query in ISSUES ACC-05) and photograph them first; for Zarrin, run the trial on temporary documents first (ACC-01). | The integration itself is off until an admin turns it on in «اتصال حسابداری»; nothing changes before that except the no-image rule, which applies to every product. | diff --git a/apps/api/.env.example b/apps/api/.env.example index e8661a2..d07c8ae 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -7,7 +7,9 @@ PORT=3000 # Customer app sessions (security hotfix stage 2). # Separate from JWT_SECRET so an admin token can never validate as a customer -# token. Falls back to JWT_SECRET, then to a dev-only string, when unset. +# token. Falls back to JWT_SECRET, then to a dev-only string, when unset — +# development only: in production it is REQUIRED, at least 32 characters and +# different from JWT_SECRET, or the API refuses to start (REL-03). JWT_CUSTOMER_SECRET= # Customer token lifetime. The reference issued a token that never expired. JWT_CUSTOMER_EXPIRES_IN=30d @@ -53,10 +55,10 @@ ACCOUNTING_SECRET_KEY= ACCOUNTING_WORKER= # SEC-04 — browser origins allowed to call the API, comma-separated, exact -# (e.g. https://adm.example.ir,https://app.example.ir). Blank = any origin -# (development); in production a blank list prints a warning at boot. -# In production (NODE_ENV=production) JWT_SECRET is REQUIRED: the API refuses -# to start on the built-in development key. +# (e.g. https://adm.example.ir,https://app.example.ir). Blank = any origin, +# development only. In production (NODE_ENV=production) the API refuses to +# start until CORS_ORIGINS lists full origins and JWT_SECRET is a random value +# of at least 32 characters (SEC-04). See docs/SERVER-SETUP-CHECKLIST.md. CORS_ORIGINS= # SEC-05 — the seed runs once on a fresh database at boot. In production it diff --git a/apps/api/src/common/production-checks.spec.ts b/apps/api/src/common/production-checks.spec.ts new file mode 100644 index 0000000..3ec49a4 --- /dev/null +++ b/apps/api/src/common/production-checks.spec.ts @@ -0,0 +1,49 @@ +import { productionConfigProblems } from './production-checks'; + +describe('productionConfigProblems', () => { + const A = 'a'.repeat(40); + const C = 'c'.repeat(40); + const good = { + NODE_ENV: 'production', + JWT_SECRET: A, + JWT_CUSTOMER_SECRET: C, + CORS_ORIGINS: 'https://adm.example.ir, https://app.example.ir/', + }; + + it('a complete production config passes', () => { + expect(productionConfigProblems(good)).toEqual([]); + }); + + it('checks nothing outside production', () => { + expect(productionConfigProblems({ NODE_ENV: 'development' })).toEqual([]); + }); + + it('refuses the fixed test login code (REL-01)', () => { + expect(productionConfigProblems({ ...good, OTP_MASTER_CODE: '1234' })).toHaveLength(1); + // explicit opt-in for a test server built from the production image + expect( + productionConfigProblems({ ...good, OTP_MASTER_CODE: '1234', TEST_SERVER_ALLOW_MASTER_CODE: 'yes' }), + ).toEqual([]); + }); + + it('requires a separate customer token key (REL-03)', () => { + expect(productionConfigProblems({ ...good, JWT_CUSTOMER_SECRET: '' })).toHaveLength(1); + expect(productionConfigProblems({ ...good, JWT_CUSTOMER_SECRET: A })).toHaveLength(1); + expect(productionConfigProblems({ ...good, JWT_CUSTOMER_SECRET: 'short' })).toHaveLength(1); + }); + + it('requires a strong admin key (SEC-04)', () => { + expect(productionConfigProblems({ ...good, JWT_SECRET: undefined })).toHaveLength(1); + expect(productionConfigProblems({ ...good, JWT_SECRET: 'dev-only-insecure-secret' })).toHaveLength(1); + }); + + it('requires an explicit list of allowed origins (SEC-04)', () => { + expect(productionConfigProblems({ ...good, CORS_ORIGINS: '' })).toHaveLength(1); + expect(productionConfigProblems({ ...good, CORS_ORIGINS: '*' })).toHaveLength(1); + expect(productionConfigProblems({ ...good, CORS_ORIGINS: 'adm.example.ir' })).toHaveLength(1); + }); + + it('lists every problem at once', () => { + expect(productionConfigProblems({ NODE_ENV: 'production', OTP_MASTER_CODE: '1234' })).toHaveLength(4); + }); +}); diff --git a/apps/api/src/common/production-checks.ts b/apps/api/src/common/production-checks.ts new file mode 100644 index 0000000..226519d --- /dev/null +++ b/apps/api/src/common/production-checks.ts @@ -0,0 +1,80 @@ +/** + * 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. + */ +export const DEV_JWT_SECRET = 'dev-only-insecure-secret'; + +/** Lets a TEST server built from the production image keep the fixed OTP. */ +export const ALLOW_MASTER_CODE_FLAG = 'TEST_SERVER_ALLOW_MASTER_CODE'; + +const MIN_SECRET_LENGTH = 32; + +type Env = Record; + +const val = (env: Env, k: string) => { + const v = env[k]?.trim(); + return v ? v : null; +}; + +/** CORS_ORIGINS as a clean list of origins (no trailing slash). */ +export function parseCorsOrigins(raw: string | undefined): string[] { + return (raw ?? '') + .split(',') + .map((o) => o.trim().replace(/\/+$/, '')) + .filter(Boolean); +} + +export function productionConfigProblems(env: Env): string[] { + if (env.NODE_ENV !== 'production') return []; + const problems: string[] = []; + + // SEC-04 — the admin token key. + const admin = val(env, 'JWT_SECRET'); + if (!admin) { + problems.push('JWT_SECRET تنظیم نشده است. / JWT_SECRET is not set.'); + } else if (admin === DEV_JWT_SECRET || admin.length < MIN_SECRET_LENGTH) { + problems.push( + `JWT_SECRET باید یک مقدار تصادفی دست‌کم ${MIN_SECRET_LENGTH} نویسه باشد. / JWT_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`, + ); + } + + // REL-03 — the customer / sales-expert app token key, separate from the admin one. + const customer = val(env, 'JWT_CUSTOMER_SECRET'); + if (!customer) { + problems.push( + 'JWT_CUSTOMER_SECRET تنظیم نشده است؛ توکن مشتری‌ها با کلید مدیرها امضا می‌شود. / JWT_CUSTOMER_SECRET is not set: app tokens would be signed with the admin key.', + ); + } else if (customer === admin) { + problems.push('JWT_CUSTOMER_SECRET نباید با JWT_SECRET یکی باشد. / JWT_CUSTOMER_SECRET must differ from JWT_SECRET.'); + } else if (customer === DEV_JWT_SECRET || customer.length < MIN_SECRET_LENGTH) { + problems.push( + `JWT_CUSTOMER_SECRET باید یک مقدار تصادفی دست‌کم ${MIN_SECRET_LENGTH} نویسه باشد. / JWT_CUSTOMER_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`, + ); + } + + // SEC-04 — only the panel and the apps may call the API from a browser. + const origins = parseCorsOrigins(env.CORS_ORIGINS); + if (!origins.length) { + problems.push( + '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)); + if (bad.length) { + problems.push( + `CORS_ORIGINS فقط نشانی کامل می‌پذیرد، مثل https://adm.example.ir — نادرست: ${bad.join(', ')} / CORS_ORIGINS takes full origins only; invalid: ${bad.join(', ')}`, + ); + } + } + + // REL-01 — the fixed test OTP signs in ANY customer. + if (val(env, 'OTP_MASTER_CODE') && val(env, ALLOW_MASTER_CODE_FLAG) !== 'yes') { + problems.push( + `OTP_MASTER_CODE (کد ثابت ورود آزمایشی) روی سرور اصلی مجاز نیست؛ آن را حذف کنید. فقط سرور آزمایشی با ${ALLOW_MASTER_CODE_FLAG}=yes. / OTP_MASTER_CODE is not allowed in production; remove it (test servers only: ${ALLOW_MASTER_CODE_FLAG}=yes).`, + ); + } + + return problems; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index baa74dd..3cb1767 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -9,6 +9,7 @@ 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 { masterOtpCode, masterOtpEnabled } from './modules/customer-auth/customer-auth.service'; // serve-static's bundled `mime` version predates .avif, so it falls back to @@ -19,41 +20,35 @@ const EXTRA_MIME_TYPES: Record = { }; /** - * SEC-04 — production must not run on the published development key. Both JWT - * modules fall back to 'dev-only-insecure-secret' (a string anyone can read - * in this repository), with which anyone could mint an admin token. Outside - * production that fallback is kept, so a fresh checkout still runs. + * 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. + * The rules live in common/production-checks.ts. */ -function assertProductionSecrets() { - if (process.env.NODE_ENV !== 'production') return; - if (!envOrNull('JWT_SECRET')) { - throw new Error( - 'JWT_SECRET تنظیم نشده است — سرور در حالت production بدون آن اجرا نمی‌شود.\n' + - 'JWT_SECRET is not set: refusing to start in production on the public development key.', - ); - } +function assertProductionConfig() { + const problems = productionConfigProblems(process.env); + if (!problems.length) return; + throw new Error( + '\nسرور در حالت production اجرا نمی‌شود تا این موارد درست شوند:\n' + + 'Refusing to start in production until these are fixed:\n' + + problems.map((p) => ' • ' + p).join('\n') + + '\n(see docs/SERVER-SETUP-CHECKLIST.md)\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). - * Unset keeps the old allow-all, with a warning in production, so an existing - * deployment keeps working until the list is filled in. + * Required in production (assertProductionConfig); unset elsewhere = allow-all. */ function corsOrigins(): string[] | true { - const list = (envOrNull('CORS_ORIGINS') ?? '') - .split(',') - .map((o) => o.trim().replace(/\/+$/, '')) - .filter(Boolean); - if (list.length) return list; - if (process.env.NODE_ENV === 'production') { - console.warn('⚠ CORS_ORIGINS تنظیم نشده — هر سایتی می‌تواند به API درخواست بفرستد. / CORS_ORIGINS is not set: any website may call this API.'); - } - return true; + const list = parseCorsOrigins(envOrNull('CORS_ORIGINS') ?? ''); + return list.length ? list : true; } async function bootstrap() { - assertProductionSecrets(); + assertProductionConfig(); // D20 — reports the SMS configuration and never throws. A missing credential // no longer stops the boot; messages are logged instead of sent. diff --git a/docker-compose.yml b/docker-compose.yml index 590ff0b..622395f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,21 +31,25 @@ services: environment: - DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@db:5432/goldbank?schema=public} - PORT=${PORT:-3000} - # Auth. Without these the API falls back to a well-known development - # secret, so set them in the deployment environment. + # Auth. REQUIRED: the API refuses to start without both, each a random + # value of 32+ characters, and the two must differ (SEC-04, REL-03). + # See docs/SERVER-SETUP-CHECKLIST.md. - JWT_SECRET=${JWT_SECRET:-} - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-} - JWT_CUSTOMER_SECRET=${JWT_CUSTOMER_SECRET:-} - JWT_CUSTOMER_EXPIRES_IN=${JWT_CUSTOMER_EXPIRES_IN:-} # SEC-04: comma-separated browser origins allowed to call the API - # (e.g. https://adm.example.ir,https://app.example.ir). Blank = any. + # (e.g. https://adm.example.ir,https://app.example.ir). REQUIRED: the API + # refuses to start with it blank. - CORS_ORIGINS=${CORS_ORIGINS:-} # SEC-05: demo seed data is never created in production unless "true". - SEED_DEMO_DATA=${SEED_DEMO_DATA:-} - # ⚠ DELIVERY ONLY — a fixed OTP that signs in ANY active customer. - # Empty/unset disables it, which is the correct state for production. - # See ISSUES REL-01. Must be exactly 4 digits to be typable in the apps. + # ⚠ TEST SERVERS ONLY — a fixed OTP that signs in ANY active customer. + # Leave empty in production: the API refuses to start with it set unless + # TEST_SERVER_ALLOW_MASTER_CODE=yes, which only a test server may carry + # (REL-01). Must be exactly 4 digits to be typable in the apps. - OTP_MASTER_CODE=${OTP_MASTER_CODE:-} + - TEST_SERVER_ALLOW_MASTER_CODE=${TEST_SERVER_ALLOW_MASTER_CODE:-} # SMS. All blank = the "relay" gateway with its built-in defaults (D26). # A failed SMS never blocks the boot or an operation (D20). - SMS_PROVIDER=${SMS_PROVIDER:-} diff --git a/docs/SERVER-SETUP-CHECKLIST.md b/docs/SERVER-SETUP-CHECKLIST.md new file mode 100644 index 0000000..fe85b50 --- /dev/null +++ b/docs/SERVER-SETUP-CHECKLIST.md @@ -0,0 +1,75 @@ +# فهرست تنظیمات سرور اصلی + +برای کسی که به سرور اصلی (سرور واقعیِ مشتری‌ها) دسترسی دارد. + +از این نسخه به بعد، سرور تا وقتی سه مورد زیر درست نشده باشند **روشن نمی‌شود**. +در گزارش سرور، فهرست کامل ایرادها با هم چاپ می‌شود؛ همه را درست کنید و یک بار +دوباره راه‌اندازی کنید. + +این بررسی فقط در حالت اصلی (`NODE_ENV=production`) انجام می‌شود. تصویر داکرِ +سرور همیشه در این حالت است. + +--- + +## ۱. حذف کد ثابت ورود آزمایشی + +| متغیر | مقدار درست | +|---|---| +| `OTP_MASTER_CODE` | **خالی** یا کلاً حذف‌شده | + +تا وقتی این کد هست، هر کسی که آن را بداند وارد حساب **هر** مشتری می‌شود. + +> سرور **آزمایشی**ای که عمداً این کد را لازم دارد، باید کنارش +> `TEST_SERVER_ALLOW_MASTER_CODE=yes` بگذارد. این را **هرگز** روی سرور اصلی +> نگذارید. + +## ۲. کلید جدا برای مشتری‌ها + +| متغیر | مقدار درست | +|---|---| +| `JWT_SECRET` | یک رشته‌ی تصادفی، دست‌کم ۳۲ نویسه (کلید ورود مدیرها) | +| `JWT_CUSTOMER_SECRET` | یک رشته‌ی تصادفیِ **دیگر**، دست‌کم ۳۲ نویسه (کلید ورود مشتری‌ها و کارشناس‌ها) | + +دو مقدار **نباید یکی باشند**. ساختن مقدار تصادفی، روی هر سیستمی که Node دارد: + +``` +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +دو بار اجرا کنید و هر خروجی را برای یکی از دو متغیر بگذارید. + +⚠ **عوض کردن این کلیدها همه را از حساب بیرون می‌کند.** مدیرها و مشتری‌ها +یک بار دوباره وارد می‌شوند. اطلاعات کسی از بین نمی‌رود. + +⚠ اگر `ACCOUNTING_SECRET_KEY` خالی باشد، رمز ذخیره‌شده‌ی نرم‌افزار حسابداری از +`JWT_SECRET` ساخته می‌شود. با عوض شدن `JWT_SECRET` باید یک بار دوباره در +«تنظیمات حسابداری» پنل وارد شوید. بهتر است `ACCOUNTING_SECRET_KEY` هم یک مقدار +تصادفیِ جدا بگیرد و دیگر عوض نشود. + +## ۳. نشانی‌های مجاز + +| متغیر | مقدار درست | +|---|---| +| `CORS_ORIGINS` | نشانی کامل پنل و اپ‌ها، با ویرگول جدا | + +نمونه: + +``` +CORS_ORIGINS=https://adm.henzagold.ir,https://app.henzagold.ir,https://app2.henzagold.ir +``` + +- هر نشانی با `https://` شروع شود و **مسیر نداشته باشد**؛ مثلاً `/admin` در + آخرش نیاید. +- هر دامنه‌ای که پنل یا یکی از دو اپ از آن باز می‌شود باید در فهرست باشد؛ + وگرنه آن صفحه نمی‌تواند با سرور حرف بزند. +- `*` پذیرفته نمی‌شود. + +--- + +## بعد از تنظیم + +1. سرور را دوباره راه‌اندازی کنید. +2. در گزارش سرور باید `Nest application successfully started` دیده شود. + اگر سرور بالا نیامد، فهرست ایرادها همان‌جا چاپ شده است. +3. پنل مدیریت و هر دو اپ را باز کنید و یک بار وارد شوید. +4. ورود مشتری با پیامک را امتحان کنید. کد ثابت دیگر نباید کار کند. diff --git a/docs/parity/ISSUES.md b/docs/parity/ISSUES.md index 81a0a54..2fee778 100644 --- a/docs/parity/ISSUES.md +++ b/docs/parity/ISSUES.md @@ -131,7 +131,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` | ID | Severity | Where | Description | Evidence | Suggested fix | |---|---|---|---|---|---| -| **REL-01** | **Critical — before the real release** | TGT deployment env | **`OTP_MASTER_CODE` must be removed from the environment, and the SMS credentials configured.** While the variable is set, ANY active customer can sign in with that one fixed code. It is off by default (unset = disabled) and the API prints a boot banner while it is armed, but nothing stops it reaching production except this step. | `customer-auth.service.ts` `masterOtpCode()`; `main.ts` boot banner; D20 | Unset `OTP_MASTER_CODE`; set `SMS_TREZ_USERNAME` / `SMS_TREZ_PASSWORD` / `SMS_TREZ_SENDER_LINE`; restart and confirm the banner is gone. | +| **REL-01** | **Critical — before the real release** | TGT deployment env | **`OTP_MASTER_CODE` must be removed from the environment, and the SMS credentials configured.** While the variable is set, ANY active customer can sign in with that one fixed code. It is off by default (unset = disabled) and the API prints a boot banner while it is armed, but nothing stops it reaching production except this step. | `customer-auth.service.ts` `masterOtpCode()`; `main.ts` boot banner; D20 | Unset `OTP_MASTER_CODE`; set `SMS_TREZ_USERNAME` / `SMS_TREZ_PASSWORD` / `SMS_TREZ_SENDER_LINE`; restart and confirm the banner is gone. 🔒 **ENFORCED 2026-10-08:** a production API refuses to start while OTP_MASTER_CODE is set, unless TEST_SERVER_ALLOW_MASTER_CODE=yes marks a test server (common/production-checks.ts). Removing it from the live environment is still the operator's step — docs/SERVER-SETUP-CHECKLIST.md. | | **REL-02** | High | TGT `apps/api/src/modules/sms/sms.service.ts` | D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so **customers cannot receive a login code** and the failure is only a log line. Deliberate, to keep the delivery unblocked. | D14 (superseded) vs D20 | After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. **Eased by D26 (2026-10-05):** the default `relay` driver needs no credentials, so SMS works on a fresh deployment; a gateway refusal is still only a log line. | | **PERS-01** | Medium | REF `AdminController.php:348` vs TGT `admin-customers.service.ts` `update()` | The reference **discards the submitted mobile on edit** (`$input['mobile'] = $customer->mobile;`), making it immutable once set; the target lets an operator change it. D19 ruled the lock NOT ported, so the target keeps the editable behaviour. Changing a mobile changes the customer's login identity. | `AdminController.php:348`; D19 answer B | If the lock is wanted later, apply it in `update()` and hide/disable the field on the edit form. | | **PERS-02** | Medium | REF `cities` table vs TGT `City` model | The reference's cities are a **two-level hierarchy** (`parent` + `level`, provinces at level 0) rendered as an `a-tree-select` with provinces **not selectable** (`citiesModel.js:38`). The target flattened this to a nullable `province` **string** and a flat `a-select` on all three Persons forms. Functionally the city is still captured; the province grouping and its tree UI are absent. | `2021_09_18_171700_city_table.php:18-19`; `henza_admin/src/mixins/citiesModel.js:20-48`; `schema.prisma` `model City` | ✅ **FIXED 2026-10-08** (owner: as legacy). `City.parentId` (self relation, Restrict) replaces `province`; migration `20261008170000_city_tree` turns each province label into a parent row («استان …») before dropping the column. Admin pickers are legacy's tree (`CityTreeSelect.vue`: top level with children not selectable); city form/grid have «والد». **Also restored:** legacy `_getP` — a city without its own percent takes its ancestor's (`getCityPercent`); the omde-customer form's city→percent autofill. Import copies legacy parents. Delete refuses a city with children; a parent loop is refused. | @@ -151,7 +151,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` | ID | Severity | Where | Description | Evidence | Suggested fix | |---|---|---|---|---|---| -| **REL-03** | **Critical — before the real release** | TGT deployment env; `customer-auth.service.ts` `customerJwtSecret()` | **`JWT_CUSTOMER_SECRET` must be set to its own value.** Unset, it falls back to `JWT_SECRET`, so customer/sales-expert **app** tokens are signed with the same key as **admin** tokens and the two realms stop being cryptographically separate. The only remaining defence is the `typ` check in `jwt.strategy.ts` — and an expert's app token carries their **AdminUser id** in `sub`, which is exactly what that check exists to stop. Two independent defences collapse to one. | `customerJwtSecret()` `?? process.env.JWT_SECRET`; `jwt.strategy.ts` `APP_TOKEN_TYPES` | Set `JWT_CUSTOMER_SECRET` to a distinct random value. A loud boot banner now fires while it is missing (`main.ts`), and DELIVERY.md lists it first. | +| **REL-03** | **Critical — before the real release** | TGT deployment env; `customer-auth.service.ts` `customerJwtSecret()` | **`JWT_CUSTOMER_SECRET` must be set to its own value.** Unset, it falls back to `JWT_SECRET`, so customer/sales-expert **app** tokens are signed with the same key as **admin** tokens and the two realms stop being cryptographically separate. The only remaining defence is the `typ` check in `jwt.strategy.ts` — and an expert's app token carries their **AdminUser id** in `sub`, which is exactly what that check exists to stop. Two independent defences collapse to one. | `customerJwtSecret()` `?? process.env.JWT_SECRET`; `jwt.strategy.ts` `APP_TOKEN_TYPES` | Set `JWT_CUSTOMER_SECRET` to a distinct random value. A loud boot banner now fires while it is missing (`main.ts`), and DELIVERY.md lists it first. 🔒 **ENFORCED 2026-10-08:** production refuses to start without JWT_CUSTOMER_SECRET, or with it equal to JWT_SECRET or under 32 characters. Setting it is the operator's step — docs/SERVER-SETUP-CHECKLIST.md. | | **SEC-14** | High (reference bug, NOT ported) | REF `Major/CustomerController.php:87` | `complete()` did `$customer->update($request->all())` — it mass-assigned **the entire request body** onto the customer row. The app could therefore set its own `gram_level` (credit limit), `percent` (making charge), `remain` (gold debt), `active` or even `mobile` by adding fields to the profile-completion POST. A privilege-escalation and money hole reachable from any signed-in customer. | `$input = $request->all(); ... $customer->update($input);` | **Not ported.** The target reads a three-field whitelist (`CompleteProfileDto`) and writes only the three document URLs plus the verification step. Asserted by `test-complete-profile.cjs` §7, which posts credit-limit/debt/mobile overrides and checks the row is unchanged. | | **SEC-15** | Low (reference bug, NOT ported) | REF `Major/CustomerController.php:110-112` | `sendSmsToAdmin()` hardcoded four real personal mobile numbers in the source. | `$admin_mobile = ['0990…','0913…','0912…','0919…'];` | **Not ported.** The target reads `PROFILE_ADMIN_MOBILES` (comma-separated, blank = notify nobody), so no personal number is committed to this repository. | | **APP-03** | Low (reference bug, NOT ported) | REF `completeProfile.vue:175-181` | `refresh()` called `window.setInterval(() => window.location.reload(), 1000)` — an **unterminated** timer that reloaded the whole page every second forever, not a one-off refresh. | `completeProfile.vue:178-180` | **Not ported.** The target navigates home once after the success dialog. |