Files
tehran_taj/docs/parity/CHANGELOG.md
T
s.esmaeiliandClaude Opus 5 53436ec855 chore(release): prepare release/delivery + fix docker env passthrough
DEPLOY-01: docker-compose.yml passed only DATABASE_URL and PORT to the api
service, so JWT_SECRET, JWT_CUSTOMER_SECRET, OTP_MASTER_CODE and every SMS_TREZ_*
variable were silently dropped in Docker. A deployed build would have used the
development JWT fallback and would not have had the delivery master code at all,
so the demo login would have failed with no obvious cause. All are now forwarded,
defaulting to empty (which the code treats as unset); verified with
`docker compose config`.

TEST-01: test-sales-expert-roundtrip.cjs had localhost:3077 hardcoded and could
only connect by accident. Now defaults to the repo's API_PORT. 33/33.

DELIVERY.md documents env vars (names only), run/deploy commands, how migrations
run inside the container, the verification results and the known issues.

release/delivery contains both parity branches with no conflicts (fast-forward).
build 5/5, jest 45/45, admin smoke 43/43, apps smoke 43/43, B2B scan 34/34,
B2B writes 25/25, auth-smoke 32/32, typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:30:09 +03:30

39 KiB
Raw Blame History

CHANGELOG — parity changes

Every change made to gold-banking-monorepo during the parity project, with the reason and the reference path that justifies it.

Format:

## <date> — <phase> — <group title>
- **What:** <the change>
- **Why:** <the parity gap it closes>
- **Reference:** <henza_* path:line>
- **Target:** <gold-banking-monorepo path:line>
- **Matrix row:** <id in 01-ADMIN-MATRIX.md / 02-APPS-MATRIX.md>
- **Commit:** <sha>

2026-09-21 — Phase 0 — Setup

  • What: Created branch parity/phase-0 and docs/parity/ scaffolding (PROGRESS.md, ISSUES.md, CHANGELOG.md).
  • Why: Persistent state required by CLAUDE.md §6.
  • Reference: n/a
  • Target: docs/parity/
  • Matrix row: n/a

No source code changed in Phase 0 (read-only phase).


2026-09-22 — Security hotfix, stage 1 — close the unauthenticated write paths

Branch parity/security-hotfix. Authorised by the user's rulings D7, T1–T3.

1. Ledger routes are admin-only (SEC-09)

  • What: removed the controller-level @Public() from AccountingController and gave each route a rid: wallet.list (balance), wallet.charge (reconcile), document.create (both repayments), document.list.confirm (confirm entry).
  • Why: four of the five routes WRITE to the ledger and were reachable with no token at all. Faithful to the reference, whose entire admin route file is unauthenticated, but contrary to confirmed decision D1.
  • Reference: henza_server/routes/admin.php:12,272 (the auth:api wrapper is commented out at both ends)
  • Target: apps/api/src/modules/accounting/accounting.controller.ts
  • Breakage check: api.getBalance() is the only app-side consumer and it has zero call sites in either user app (dead code, already logged as DEAD-01/02).

2. Order status change is admin-only (SEC-10)

  • What: moved @Public() from the class to the two customer-facing handlers, leaving PATCH orders/:id/status behind order.list.sts.submit.
  • Why: it let an anonymous caller move any order's status and attribute the change to any admin through the DTO's actorAdminUserId.
  • Reference: an admin operation; the admin panel posts to admin/orders/status (apps/admin-panel/src/api/endpoints.ts:42,48), so this route had no caller.
  • Target: apps/api/src/modules/orders/orders.controller.ts
  • Still public: POST /orders and GET /orders/:id, because the target has no customer session yet — closed in stage 2.

3. GET /customers no longer leaks the customer book (SEC-08)

  • What: added LOGIN_LOOKUP_SELECT (id, mobile, isActive, isArchived) and pointed list() at it. Removed fullName, nationalCode, goldDebtGrams, fiatDebtRial, goldCreditLimitGrams, verificationStep, cityId, referredByAgentId, nationalIdImageUrl, businessLicenseUrl, shopImageUrl, createdAt, updatedAt from that response.
  • Why: the route is unauthenticated because both apps match the phone number in the browser, so it shipped every customer's national ID number, debts, credit limit and identity-document URLs to any visitor.
  • Reference: login was POST userAuth → SMS → POST userVerfiy, returning exactly one customer after the code matched (henza_server/app/Http/Controllers/Api/ApiMobileController.php:39-78).
  • Target: apps/api/src/modules/customers/customers.service.ts, plus CustomerSummary narrowed in both apps' src/api/client.ts (parity rule: the same logic change in both).
  • Route survives until stage 2 replaces the client-side phone match with OTP.

4. Test script

  • What: added apps/api/scripts/auth-smoke.sh — asserts 401 without a token, 403 with a low-privilege token, and pass-through for a super admin, on every closed route; plus that the public routes still answer 200 and that GET /customers exposes only the four login fields.
  • Result: 23 passed, 0 failed against a live server and the dev seed.

Not changed

/uploads — per ruling T2, uploaded files including identity documents stay public. The filename check requested alongside it passed with no changes needed (md5 of time + name + UUID; no directory listing).


2026-09-22 — Security hotfix, stage 2 — real customer sessions

Branch parity/security-hotfix. Authorised by D6, D7, T1 and T3.

Stage 1 narrowed GET /customers but, as the user pointed out, did not close the leak: the response still carried every customer id, and /customers/:id, /:id/orders and /:id/ledger-entries were still public — so two requests still reconstructed the whole book. Stage 2 removes the id-bearing surface entirely.

1. Customer JWT, separate from the admin one (T1)

  • New: src/modules/customer-auth/ — CustomerJwtStrategy (passport strategy 'customer-jwt'), CustomerJwtGuard, @CustomerAuth(), @CurrentCustomer().
  • Signed with JWT_CUSTOMER_SECRET, separate from JWT_SECRET, and every token carries typ: 'customer' which the strategy verifies. Either check alone would be enough; both are present deliberately.
  • Customer state is re-read from the DB on every request, so deactivating or archiving a customer takes effect at once rather than at token expiry.
  • Reference: the same split existed there — auth:api for admins, auth('api-customer') for the app.

2. Real OTP (D6)

  • New table CustomerOtp (migration 20260922000000_customer_otp, additive: one new table, nothing existing touched).
  • POST /api/customer/auth/request-otp ← ApiMobileController::userAuth (:39-49)
  • POST /api/customer/auth/verify-otp ← ApiMobileController::userVerfiy (:51-64)
  • Parity behaviour: the 4-digit rand(1000,9999) range, the active-customer filter, Utility::faTOen() digit normalisation on both the number and the code, and rejection on mismatch.
  • Deliberate improvements over the reference (user-approved, recorded in DECISIONS.md so the matrix does not flag them later):
    • the code is stored as a bcrypt hash, never plain text;
    • it expires after 2 minutes (the reference's never did);
    • 5 attempts per challenge, then lockout (the reference had no limit);
    • single use — a consumed code cannot be replayed;
    • send throttle: 60s cooldown per number, 5 per number and 20 per IP per 10 minutes, counted off the table so it survives restarts;
    • no enumeration — an unregistered number returns the same {sent:true}. The reference's firstOrFail() leaked a 404 for unknown numbers.
  • No SMS gateway exists in this project (STUB-02), so the code is logged in non-production only and the message is persisted as an SmsMessage row. The reference's message text is kept verbatim for when a gateway lands.

3. The id-bearing customer routes are gone

  • Deleted customers.controller.ts — with it GET /customers, /customers/:id, /:id/orders, /:id/ledger-entries.
  • Replaced by /api/customer/me, /me/orders, /me/ledger-entries, which take the id from the token. There is no longer any route that accepts a customer id, so the IDOR is closed structurally rather than by a check.
  • CustomersService stays and is reached only through those routes.
  • The admin panel was unaffected — it uses admin/customers-omde/*.

4. Both apps migrated together (parity rule)

  • stores/session.ts: token is now the credential and isLoggedIn is token-based. customerId is kept for display only and is no longer an identity claim.
  • api/client.ts: sends Authorization: Bearer, throws UnauthorizedError on 401 and calls the handler registered in main.ts, which clears the session and routes to login so stale personal data cannot stay on screen.
  • LoginView.vue: real two-step OTP. Each app keeps its own markup and styling; only the logic changed, and it is identical in both.
  • The four account screens now call getMe() / getMyOrders() / getMyLedgerEntries(); router guards test isLoggedIn.

Tests

  • apps/api/scripts/auth-smoke.sh extended with the stage-2 assertions: 46 passed, 0 failed against a live server — old routes 404, customer routes 401 without a token, wrong/reused code 401, correct code 200, throttle 429, no enumeration, customer token rejected on admin routes and admin token rejected on customer routes, no id-bearing surface, and /customer/me returning the token's own customer.
  • jest installed (dev-only, user-approved) with jest.config.cjs, so pnpm test works for the first time: 7 passed covering faToEn. faToEn moved to its own digits.ts so it can be tested without booting Nest.
  • Typecheck clean on api, user-app and user-app-v2; both apps build.

2026-09-22 — Security hotfix, stage 3 — SEC-02 (orders) + OTP audit

Branch parity/security-hotfix. The user declined to defer SEC-02 to Phase 2: a public POST /orders taking customerId from the body let anyone place an order on any customer's account, and an order lands on that customer's gold debt and ledger. That is a money hole, so it is closed here.

1. POST /orders is customer-authenticated

  • What: the route is behind @CustomerAuth(). OrdersService.createOrder now takes customerId as a separate argument, supplied by the controller from the token. The body's customerId is ignored entirely; a mismatch is logged (OrdersService warn) and the token always wins.
  • CreateOrderDto.customerId and the shared CreateOrderDto are optional, so both apps keep validating while they still send it. Removed in Phase 2.
  • Reference: ApiMobileController::setOrder read auth()->id() (henza_server:533) behind the api-customer guard — this restores that.
  • Admin path unaffected: AdminOrdersService places orders for a selected customer and passes that id explicitly; its route is admin-guarded.

2. GET /orders/:id is scoped to the owner

  • getOwnedById(orderId, customerId) matches on both, and a miss is 404, not 403 — a 403 would confirm the id exists, the same enumeration leak the old /customers/:id routes had. A nonexistent id and someone else's id are indistinguishable.
  • Operators use the new GET /orders/admin/:id behind rid order.list. Declared before :id because Nest matches in declaration order.

3. Both apps

  • CartView.vue in both apps now gates on session.isLoggedIn (the token) rather than on a remembered customerId, which is no longer a credential.

4. OTP audit (user request 3)

  • No master code, no bypass, no hardcoded code anywhere in the API or either app — verified by grep for master.?code|bypass|backdoor|1111|0000|1234|skipOtp|DEV_OTP|TEST_OTP. Exactly one code path issues a token, and only after bcrypt.compare.
  • SMS provider: none. No gateway is wired up (STUB-02). In development the code is logged once via Logger, guarded by NODE_ENV !== 'production'.
  • Fixed while auditing: deliver() persisted the real message text — with the code in it — into SmsMessage.body, which the admin SMS grid both lists and full-text searches (admin-misc.service.ts:135-136). Any operator with the sms rid could read live OTP codes, defeating the point of hashing codeHash. The persisted row is now a redacted audit line; the real text is built for the future gateway and not stored. The 9 test rows created during this session were deleted.

Tests

apps/api/scripts/auth-smoke.sh extended again — 53 passed, 0 failed: order routes 401 without a token, a spoofed body customerId ignored in favour of the token, own order readable, unknown/foreign order id 404, and the admin order route rejecting a customer token while admitting an admin one. Typecheck clean on api, user-app, user-app-v2 and shared-types.


2026-09-22 — migrate resolve, D13 scope, and the D0/percent migration set

Standing rule adopted (owner, 2026-09-22)

  1. Migrations run only through the standard Prisma workflow — migrate dev in development. prisma db execute is no longer used to apply schema changes. (It was used once, in the security hotfix, to create the CustomerOtp table; that is what the resolve below repairs.)
  2. Every data deletion is recorded here — including throwaway rows this session creates itself.

Data deletions (rule 2, applied retroactively to this session)

When What How many Why
Security hotfix stage 3 SmsMessage rows written by the OTP flow during testing, whose body still held the live code before the SEC-11 fix 9 They contained real OTP codes in a grid any sms-rid operator can read. Test data only; no customer-facing message was ever sent (no gateway is wired).

No other data has been deleted in this project.

Ledger repair — approved and executed

npx prisma migrate resolve --applied 20260922000000_customer_otp
→ Migration 20260922000000_customer_otp marked as applied.
npx prisma migrate status
→ 9 migrations found. Database schema is up to date!

The CustomerOtp table already existed (created with db execute in stage 2); this wrote the missing _prisma_migrations row without running any SQL, so migrate deploy will no longer try to re-create the table. No schema change.

Documentation

  • D13 recorded — the retail storefront (wallet, cashback, ConfigStatic['percent']) is preserved but out of scope. DECISIONS.md §D13, PROTECTED.md §H, and status ⏸️ plus RULE C1 added to the audit skill.
  • Endpoint count corrected: 412, not 369. routes/admin.php has 151 live explicit routes, not the 117 the Phase 0 inventory recorded (DOC-01). Of the 412, 109 are retail storefront, 15 are web.php dev/ops, and 288 stay in scope (218 admin + 70 B2B app).
  • SMS provider report — the reference uses the Trez panel (smspanel.trez.ir, Sms.php:69,97) with credentials committed to git (SEC-12, must be rotated). All 12 message templates catalogued, env var names proposed. Nothing implemented; awaiting approval.
  • MON-08 answered — henza_pwa computes percent_pay, discount and rial_pay; the user types none of them (cart.vue:170-179, Mixins/product/index.ts:69-75). So Phase 2 item #1 is to derive them server-side and ignore the client's values, with a comparison test proving the total is unchanged for an ordinary customer. rash_weight stays read from the DB, recorded as a deliberate deviation.

New, not yet applied — awaiting approval

File Purpose
prisma/migrations/20260922120000_customer_name_parts/migration.sql D0 — adds firstName/lastName, backfills first-token/rest, adds a customer_sync_full_name trigger keeping fullName in sync in both directions, indexes both parts. Additive; nothing dropped.
prisma/migrations/20260922120100_customer_making_charge_offset/migration.sql D9 — adds makingChargeOffsetPercent DECIMAL(6,3) NOT NULL DEFAULT 0.
scripts/report-name-split.cjs Read-only review report for names with more than two tokens (D0 requirement 3), with --csv output.

Added and already passing

File Purpose
src/modules/pricing/variant-price.ts The reference price formula ported verbatim from ProductVariant.php:183-190, with the customer offset as an explicit input defaulting to 0.
src/modules/pricing/variant-price.spec.ts 11 tests against hand calculations derived from the PHP source, covering with/without the offset, the VAT-on-non-metal-portion-only rule, floor-to-1000, and the equivalence between the app folding the offset into percent_pay and the server deriving it.

npx jest → 18 passed (7 existing + 11 new), 2 suites.


2026-09-22 (later) — D0 + percent applied, SMS provider implemented

Migrations applied

migrate dev could not run (interactive-only, and this shell is not a TTY). Drift was checked read-only first: the only difference is the pre-existing _goldbank_migrations table, which Prisma would DROP — it was not dropped (DEAD-07). Applied with migrate deploy, which touches only the pending files:

Applying migration `20260922120000_customer_name_parts`
Applying migration `20260922120100_customer_making_charge_offset`
All migrations have been successfully applied.
→ 11 migrations found. Database schema is up to date!

Backfill result (node scripts/report-name-split.cjs): 8 customers, 7 split reliably, 0 single-token, 0 empty, 1 needs review — customer-dev-test («کاربر تست توسعه»), a row this session created itself.

Data deletions (standing rule 2)

When What How many Why
Round-trip test Customer rows created by the test: 2 successful + 1 whose Persian text the Windows shell had mangled into ? 3 Test fixtures. Customer count back to its original 8.
Trigger test 1 temporary Customer row per run, deleted by the script itself and reported on stdout 1/run Test fixture.

API

  • toRow() returns the real fname/lname (D0) and percent now reads makingChargeOffsetPercent from the customer instead of the referring agent's commission rate (D9 / CUST-02).
  • create() stores the name parts, and requires percent with the reference's own message, وارد کردن percent الزامی است — Laravel's stock required text (resources/lang/fa/validation.php:99) with no attribute translation for percent, so it is verbatim.
  • update() writes the parts (letting the trigger rebuild fullName) and persists percent, which it previously ignored entirely.
  • updateCredit() accepted percent in its signature and never wrote it — fixed.
  • Sort/filter aliases fname/lname pointed at fullName; they now point at the real columns, and percent is aliased too. Grid search covers both parts.
  • createSingle() (retail form, D13) stores the parts too. No storefront behaviour changed — only the shared columns are kept consistent.

SMS — implemented (owner-approved)

  • New SmsModule / SmsService (@Global), a port of Sms.php:57-82: POST, form-urlencoded, 20s timeout, PhoneNumber/Username/Password/Mobile/ Message, body trimmed, every message persisted as Sms::set() did.
  • Failure handling matches the reference: send() never throws. The reference discarded Sms::Send's result, so a gateway outage never aborted the order, the document approval or the customer creation that triggered it. Failures are logged and returned in SmsSendResult.
  • OTP text is byte-identical to ApiMobileController.php:47, including the <#> code is … first line and the trailing aMo6iFyUQaY — the Android SMS Retriever hash. A unit test asserts the exact string.
  • assertSmsConfigured() runs in main.ts before the server listens: in production a missing key throws, naming it; outside production it warns and codes are logged locally, so today's dev flow is unchanged.
  • Credentials come from the environment only. .env.example lists the six names with empty values; .env is gitignored (verified).
  • The 12 B2B message templates are implemented per flow in their own phase — admin in Phase 1, app in Phase 2 — not pre-emptively.

Admin panel

  • singleCustomer.vue — the split(' ') fallback is removed (D0 item 6).
  • bulkCustomer.vue — percent defaults to 3 on create and is required with the legacy message; the edit path coerces the Decimal string to a number so diff-send does not re-send an unchanged field; name is only sent when a name part actually changed, so the whole name is no longer re-submitted on every save.
  • ReagentCustomer.vue — unchanged. Its «درصد کمیسیون» is AdminUser.wagePercent, a genuinely different field from customers.percent.

Tests

  • npx jest — 32 passed (7 existing + 11 price formula + 14 SMS config/template).
  • scripts/test-name-trigger.cjs — 9 assertions, all three sync directions.
  • scripts/auth-smoke.sh — 53 passed, 0 failed.
  • Round-trip test (D12 rule E0) on the customer entity, live: create «محمد علی» + «احمدی» + percent 3 → edit form shows each field separately and correctly → no-op save leaves the row byte-identical → changing only lname changes lastName and fullName (trigger) and nothing else → changing only percent changes exactly that column. The two-token first name «محمد علی» survives intact, which is the defect the owner originally reported.
  • Typecheck: api ✅ · admin-panel ✅.

2026-09-22 (later still) — D16 priority change, SEC-02 re-verified

PRICE-01 stopped (D16)

Price display is retail-storefront work, so it falls under D13 and is now ⏸️.

  • apps/api/src/modules/pricing/variant-price.ts and its 11 tests are kept and still run; they are simply not used.
  • Nothing needed reverting. computeVariantPrice was never called from anywhere outside its own file and spec — verified by grep over apps/api/src and apps/admin-panel/src. No code had been wired to display a price.
  • Making-charge work (percentage_pay, makingChargeOffsetPercent, wage display on variants and order lines) stays in scope.
  • ISSUES.md PRICE-01 re-marked ⏸️ rather than deleted, so the evidence survives.

SEC-02 re-verified live

Re-tested end to end rather than quoting the earlier run:

Check Result
POST /orders with no token 401
Body customerId: "customer-2" + customer-dev-test token landed on customer-dev-test; warn-logged
GET /orders/:id own 200
GET /orders/:id another customer's 404 (not 403)
GET /orders/:id unknown 404, identical
GET /orders/admin/:id with a customer token 401

9/9 passed, plus 32/32 in auth-smoke.sh.

Data deletions (standing rule 2)

What How many Why
1 Order created by the SEC-02 test, with its OrderItem, OrderStatusEvent and LedgerEntry rows 1 order + its children Test fixture, removed by the test itself.

A testing note worth keeping

The dev OTP line briefly appeared to be missing after the SMS change. It was not a regression: a stale API process from an earlier test still held port 3077, so the new instance died with EADDRINUSE and the log being tailed belonged to the old process. Kill the listener (netstat -ano | grep :3077) before concluding anything from a log. smsConfigured() returns false with no credentials, so the dev log fires exactly as intended — confirmed once the port was free.


2026-09-22 (evening) — D17: SalesExpertProfile + AdminUser name parts

Migration 20260922180000_sales_expert_profile_and_admin_name_parts

Shown, dry-run and approved before running. Applied with migrate deploy (D14); drift afterwards is zero.

  • SalesExpertProfile — adminUserId (unique FK, cascade), cityId (FK → City, SET NULL), gramLevel, phone, nationalCode.
  • AdminUser.firstName / lastName — additive, backfilled first-token/rest, with the admin_user_sync_full_name trigger (parts win when both change). Applies to all admins, closing D0 requirement 7.
  • Profile backfill — INSERT … WHERE NOT EXISTS gave all 3 existing sales experts a profile; the id is derived from the admin id, so a replay cannot duplicate.

Owner's two corrections, both applied:

  1. Profiles for existing experts + an upsert in the service on create and on a role change to SALES_EXPERT, and listReagents left-joins so an expert without a profile still lists (verified by test).
  2. gramLevel type matched to Customer.goldCreditLimitGrams — DECIMAL(14,4), asserted identical in the dry-run.

Validation research (as instructed). The reference is deliberately lax: ncode is string(20) nullable and NOT unique (2020_10_01_120430_customers_table.php:22), phone is string(15) nullable (2020_10_25_155247_customers_cols.php:17). Neither has a server rule — createCustomer validates only mobile (required|unique) and percent (required) (AdminController.php:354) — and both are required: false on the client (ReagentCustomer.vue:31,95). Copied exactly; no uniqueness invented, since that would reject data the reference accepts.

Dry-run before applying

Ran the whole file inside a transaction, twice, then rolled back: 14 statements, table created, 3 profiles inserted, backfill correct, replay produced no duplicates, and the rollback left the database untouched (verified by re-querying information_schema).

API

  • listReagents returns real city_id, city, gram_level, phone, ncode; the grid's «شهر» and «سطح اعتبار» filters map through the relation. remain / rial_remain stay null — a sales expert is an AdminUser and has no ledger of its own (D10).
  • findOne includes the profile so the edit form populates.
  • create stores name parts and creates the profile for a sales expert; update writes the parts and upserts the profile.
  • Every remaining fname: fullName, lname: '' was fixed — 6 more sites in admin-users, admin-comments, admin-documents (×2), admin-orders, admin-preorders.

Admin panel

ReagentCustomer.vue gains «شهر», «سقف اعتبار», «کد ملی», «تلفن», and the city → commission autofill ported verbatim from henza_admin/src/views/apps/customer/ReagentCustomer.vue:215-228 — including the reference's falsy test, so a city percent of 0 also falls through to cities/get-percent-aux/{id} (replicated, not "fixed" — D4). name is now sent only when a name part changed, so diff-send is not defeated.

Tests

Suite Result
scripts/test-sales-expert-roundtrip.cjs (new) 33/33
npx jest 32/32
scripts/auth-smoke.sh 53/53
scripts/test-name-trigger.cjs 9/9
Typecheck api ✅ · admin-panel ✅

The round trip covers create-with-every-field → edit form populated separately → byte-identical no-op save → single-field edits, with «محمد علی» + «احمدی» surviving intact, plus both grid filters (positive and negative), the left-join case, and the referrer dropdown.

A test bug worth recording: the filter assertions first failed because the test sent op: 'eq'. The grid actually sends op: '=' (XTable.vue:197-200), and toCondition returns null for an unknown operator, so the filter was silently dropped and the "returns ONLY matches" assertions passed vacuously against an empty list. The product was correct; the test was not. A filter test needs a negative case, or a dropped filter looks like a pass.

Data deletions (standing rule 2)

What How many Why
AdminUser rows created by the round-trip test (one full expert, one profile-less) + their SalesExpertProfile rows 2 per run Test fixtures, removed by the test itself.
1 temporary Customer row per test-name-trigger.cjs run 1 per run Test fixture.

2026-09-22 — Delivery mode (D19/D20)

API

  • OTP_MASTER_CODE (D20) — customer-auth.service.ts. A delivery-only login bypass, read only from the env var and disabled when unset or blank. It is checked before the stored challenge so it works with no CustomerOtp row, and it still enforces "an active, non-archived customer must exist". Every use is logged with the customer's mobile. main.ts prints a boot banner while armed. Token issuance was factored into issueToken() so the bypass and the real path produce an identical token. Logged as REL-01 (Critical).
  • SMS never blocks the boot (D20) — sms.service.ts. assertSmsConfigured() no longer throws in production; it warns and returns. send() already never threw. Reverses D14. Logged as REL-02.
  • faToEn on admin-entered mobiles (D19 gap 2) — admin-customers.service.ts (create, update) and admin-users.service.ts (create, update). Ports Utility::faTOen (AdminController.php:333,364). In admin-users the normalisation runs before the uniqueness lookup, which previously let the same number be registered twice as ۰۹۱۲… and 0912….
  • Welcome SMS on customer create (D19 gap 1) — admin-customers.service.ts. Byte-identical to AdminController.php:378-387 (verified by comparison, including the trailing space in لینک pwa ). Create-only, as in the reference, and fire-and-forget so no gateway problem can delay or fail the save.

Apps — user-app-v2 brought level with user-app

  • Cart stock ceiling — stores/cartStore.ts. Ported capQty, maxQty, the un-orderable guard in addToCart (now returning a boolean) and the clamp in updateFeild. The two stores are now byte-identical.
  • composables/useProduct.ts — ported isVariantOrderable and the orderable-first default-variant selection. v2's variant-image image computed is PROTECTED and was left untouched.
  • components/product/CartItem.vue, SingleProduct.vue — the + button is disabled at the ceiling; un-orderable variant chips are disabled and dimmed. Only logic was ported: v2 keeps its own stepper DOM order, borderless rows and chip design.
  • user-app/.../SingleProduct.vue — addToCart now checks the store's return value, so the "added" toast no longer fires when the store refused the item. Applied to BOTH apps, per the rule that a logic fix lands in each.

Tests

  • New customer-auth/master-otp.spec.ts — 13 cases: the env switch, login with the master code (including Persian digits), the audit log, refusal for inactive/unknown customers, rejection when the var is absent, and the real code still working both with and without the bypass armed.
  • sms/sms.service.spec.ts — the boot-contract block rewritten for D20: never throws, warns and names the missing variables.
  • jest: 45 passed (was 32). typecheck clean on api, admin-panel, user-app, user-app-v2.

Data

  • No data was deleted or modified.

Live verification of D20 (against a running API, port 3000)

Two scripts, both kept in apps/api/scripts/:

  • test-master-otp-live.cjs — API started with OTP_MASTER_CODE: 8/8. Signs the customer in, the JWT works on GET customer/me, Persian digits are accepted (faToEn), and — the RULE E1 negatives — a near-miss code, an unknown mobile and an inactive customer are all still rejected.
  • test-master-otp-disabled.cjs — API restarted without the variable: 6/6. The same master code now returns 401, the boot banner is absent, a freshly requested real code still logs in, and replaying it fails (single use).

Boot behaviour confirmed from the log: with no SMS credentials the server started normally, warned once, and [dev] SMS not configured; not sending appeared instead of an error — no operation was blocked (D20).

Data touched: test-master-otp-disabled.cjs deletes the CustomerOtp rows for its own test number only (to clear the 60s resend cooldown); 1 row was deleted on the recorded run. No other data was created, changed or removed.

Admin smoke test + a real bug it caught (D19 step 3)

New apps/api/scripts/smoke-admin.cjs — 43/43. Walks the flows the owner listed: login → create a customer → create and edit a sales expert → create and edit a product with variants → list, view and change an order's status. Each step asserts the DATABASE changed, not just that the call returned 200.

ORD-01 (fixed) — retail order status editing was completely broken. views/order/List.vue:203,215 posted the timeline ACTIVITY entry straight to single/order-sts/edit. That object is {status, title, proceed, uaj, note} (admin-orders.service.ts:249-258) and carries no order id, so UpdateOrderStatusDto rejected every call with 400: neither «ثبت وضعیت» nor «حذف وضعیت» worked on that screen. Both handlers now build the body from tempRow.id, the order loaded when the dialog opens — the shape OmdeList.vue:187 and TaminList.vue:169,184 already used. Those two screens were checked and were already correct; List.vue was the only broken caller.

Two assertions in the first draft of the smoke test were wrong, not the product: the product form reads variants_all (not variants), and the status endpoint takes orderId (not id/sts). Both corrected.

Data touched: the script creates and then deletes its own rows only (1 product + its variants/images, 1 admin user + profile, 2 customers per run). The order status change is made against an existing order and then restored to its original value in the same run; no pre-existing row is left modified.

2026-09-23 — Apps smoke test (D19 step 1)

New apps/api/scripts/smoke-apps.cjs — 43/43. Walks the customer flow the owner listed: login with the master OTP → see products → add to cart → place an order → see my orders, plus the negatives (empty order → 400, unauthenticated order → 401).

Because both apps share one API client, the network flow is exercised once; the cart rules are asserted against BOTH apps' real source, loaded from disk and executed, so "the apps work" cannot be proved by v1 alone. capQty was checked over five cases (over-stock, under-stock, pre-order, zero, negative) in each app.

Result: no behavioural divergence between user-app and user-app-v2. Confirmed by direct comparison:

Surface Result
All 9 Pinia stores byte-identical
Router (route names) identical
API client call list identical
Composables identical except useProduct.image — v1's variant-image picker, PROTECTED
Components none missing from v2; its 4 extra home components are its own design

The making-charge formula was verified against a real order: 5.65g +15% = 6.4975 exactly, matching the reference's rash × (1 + (wage − discount)/100).

APP-01 (constraint, not a code change) — the master code must be 4 digits

Both apps render the login OTP as four single-character boxes and submit only on @on-complete, i.e. at exactly four digits (LoginView.vue:43 in each app; OtpInput.vue:43). The API accepts any length, so a longer OTP_MASTER_CODE works over HTTP but cannot be typed into either app. The real OTP is 4 digits (Math.floor(1000 + random*9000)), so the box is correct — only the master code has to match it.

The apps were NOT changed (that would touch the shipped login UI for a delivery-only feature). Instead main.ts now checks the code at boot and adds an explicit warning when it is not 4 digits, and .env.example states the rule. Re-ran the full apps smoke with a 4-digit code: 43/43.

APP-02 (logged, not fixed) — v2 does not show the admin's home slider

user-app calls GET /home/slider and prefers admin-configured slides, falling back to category-derived ones (HomeView.vue:258). user-app-v2 has no getHomeSlider in its API client at all and always derives its hero from categories. So slides configured in the admin panel appear in v1 only.

Not fixed tonight: v2's home is its own design (HeroCarousel/ProductPanel), and wiring a different slide source into it during a delivery risks breaking the page that is actually being demonstrated. Listed under known issues.

Data touched: the script creates one order and deletes it again, with its items, status events and ledger entries. No pre-existing data is modified.

2026-09-23 — B2B admin scan (D19 step 2)

Two new scripts, scoped to the B2B surfaces the owner named. Retail/storefront (single*, wallet, comments) was not scanned — D13, preserved untouched.

scripts/scan-admin-b2b.cjs — 34 ok, 0 broken. Every read endpoint the wholesale desk opens daily: the five customer grids, the referrer dropdown, customer detail/dashboard/orders/documents, the sales-expert list and detail, products, variants, brands, collections, product detail and its variants, wholesale and tamin order grids, order detail, the documents grid and detail, and the supporting lookups (cities, branches, workshops, pre-orders, users).

scripts/smoke-admin-b2b-writes.cjs — 25/25. The money-touching writes a read-only scan cannot cover:

  • Placing a wholesale order from the admin panel: the making charge computes exactly (8.133g +2% = 8.2957 per unit, ×2 = 16.5913 total), the order lands on the right customer and appears in the grid with a status timeline.
  • Recording a cash payment document: the reference's formula price / (price_per_gram / 4.331802) gives 0.8664g, the entry is created unconfirmed (the intentional deviation from legacy's auto-confirm), confirming moves the customer's gold debt 8.25 → 7.3836, and confirming again toggles it back and restores the balance exactly.

No B2B breakage was found. Three failures in the first drafts were test bugs, not product bugs, each corrected after checking the real caller:

Wrong assumption What the admin panel actually sends
POST cities/list admin/cities/list (endpoints.ts:152)
by-ids takes ids products_id (stores/cart.ts:102)
document type: 2 is cash type: 4 is «پولی»; 2 is «حواله» and takes a weight in value (addFinDoc.vue:33)

Data touched: both scripts delete only what they create (one order with its items/status events/ledger entries; one ledger entry). The write script also asserts the customer's gold and rial balances end exactly as they started.

2026-09-23 — Delivery branch prepared (D19 step 4)

release/delivery created from parity/phase-1-admin, which already contained every commit of parity/security-hotfix — so the merge was a fast-forward and there were no conflicts. main untouched.

DEPLOY-01 (fixed) — the compose file dropped every auth and SMS variable

docker-compose.yml's api service passed only DATABASE_URL and PORT. JWT_SECRET, JWT_CUSTOMER_SECRET, OTP_MASTER_CODE and all five SMS_TREZ_* variables were never forwarded into the container, so in a deployed environment the admin JWT would have fallen back to the well-known development secret and the delivery master code would simply not have existed — the demo login would have failed with no obvious cause. All of them are now passed through, each defaulting to empty, which the code already treats as "unset" (verified with docker compose config, with and without values set).

TEST-01 (fixed) — a stale hardcoded port

test-sales-expert-roundtrip.cjs pointed at localhost:3077, a port from an earlier dev session, so it failed with ECONNREFUSED. Now defaults to localhost:3000 (the repo's API_PORT) and takes an override argument. Re-ran: 33/33.

DELIVERY.md written

Covers the env variables (names only, no values), dev/build/deploy commands, how migrations run (the in-container runner and why _goldbank_migrations exists alongside _prisma_migrations), the full verification table, and the known issues — the four missing app pages, APP-02, PERS-01/02/03, PERF-01, and the parity work Phase 1 has not reached. The two pre-release actions (remove OTP_MASTER_CODE, configure SMS) are at the top of the file and logged as REL-01/REL-02 in ISSUES.

Verification on release/delivery — all green

build 5/5 · jest 45/45 · admin smoke 43/43 · apps smoke 43/43 · B2B scan 34 ok / 0 broken · B2B writes 25/25 · auth-smoke 32/32 · sales-expert 33/33 · name trigger 9/9 · typecheck clean on all four apps.