Stage 1 narrowed GET /customers but 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 the full customer book was still two
requests away. The user caught this. Stage 2 deletes the id-bearing surface.
Customer JWT, separate from the admin one (T1):
- new src/modules/customer-auth/ with its own passport strategy,
CustomerJwtGuard, @CustomerAuth() and @CurrentCustomer()
- signed with JWT_CUSTOMER_SECRET, and every token carries typ:'customer'
which the strategy verifies; both checks are present deliberately
- customer state re-read per request, so deactivating takes effect at once
- mirrors the reference's own split: auth:api vs auth('api-customer')
Real OTP (D6), porting ApiMobileController::userAuth/userVerfiy (:39-64):
- new CustomerOtp table, additive migration, nothing existing touched
- parity behaviour: rand(1000,9999), active-customer filter, faTOen digit
normalisation on both number and code, rejection on mismatch
- approved improvements over the reference: bcrypt-hashed code, 2-minute
expiry, 5 attempts then lockout, single use, send throttle (60s per
number, 5/number and 20/IP per 10 min), and no number enumeration
Routes removed, replaced by token-derived equivalents:
GET customers, customers/:id, /:id/orders, /:id/ledger-entries
-> GET customer/me, customer/me/orders, customer/me/ledger-entries
No route accepts a customer id any more, so the IDOR is closed structurally
rather than by a check. The admin panel was unaffected (admin/customers-omde/*).
Both apps migrated in the same commit per the v1/v2 parity rule: token-based
session store, Bearer header, 401 -> clear session and route to login, real
two-step OTP login. Each app keeps its own markup and styling; only logic moved.
Tests: auth-smoke.sh extended with the stage-2 assertions, 46 passed 0 failed
against a live server. jest installed dev-only (user-approved) so pnpm test
works for the first time: 7 passed covering faToEn, which moved to its own
digits.ts to be testable without booting Nest. Typecheck clean on api,
user-app and user-app-v2; both apps build.
[ref: henza_server ApiMobileController.php:39-78]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
22 KiB
DECISIONS — user rulings on the parity project
Binding decisions made by the user. They override defaults and survive context
loss. Referenced from PROTECTED.md and PROGRESS.md.
| Date | Phase | Decisions |
|---|---|---|
| 2026-09-21 | Phase 0 gate | D0–D5, D6–D8 (urgent), D9–D11, D12 (method change) |
D0 — First name / last name: split the column (option الف)
Ruling. Add real firstName / lastName columns to Customer via an
additive migration.
Requirements:
- Additive migration only — nothing dropped.
- Backfill existing rows: first token of
fullName→firstName, the rest →lastName. - Rows whose
fullNamehas more than two tokens must be written to a report for manual review (they cannot be split safely). fullNameis NOT removed. It stays and is kept in sync asfirstName + " " + lastNameso existing code keeps working.toRow()must return the realfname/lname, notfullName/''.- Fix every consumer:
singleCustomer.vue,bulkCustomer.vue,ReagentCustomer.vue, and anywhere a name is displayed, searched or sorted. Remove thesplit(' ')fallback insingleCustomer.vue. - Check the same defect on
AdminUser(sales expert) and on registration / profile inuser-appanduser-app-v2. - Show the migration file and the command, and get approval, before running it.
Evidence for the defect: reference has two columns
(henza_server/database/migrations/2020_10_01_120430_customers_table.php:18-19);
target collapses them (apps/api/src/modules/admin-customers/admin-customers.service.ts:231-233
returns fname: c.fullName, lname: '').
D1 — Admin auth: keep the target stricter
The reference leaves all ~215 admin routes unauthenticated
(henza_server/routes/admin.php:12,272). The target's JWT + 144-rid enforcement
stands, recorded as an accepted deviation.
Additional requirement: build a complete mapping matrix "every reference admin operation ↔ its permission key (rid) in the target", so no operation is left without an equivalent permission. Explain the 172 (reference) vs 174 (target) unique key difference.
D2 — Domain scope: audit only what the reference has
Confirmed. Do not manufacture rows for buy/sell gold, deposit, withdraw,
transfer, KYC levels, price sockets or payment callbacks — none exist in the
reference (00-INVENTORY.md §0).
Target-only capabilities (e.g. the dual-currency ledger, gram/rial debt) are listed with status ➕ and left untouched. Ask when unsure.
D3 — The 5 missing user-app screens: build them in Phase 2
/report, /report/detail, /ChildOrder, /customerChoice, /complete_profile
are built in both apps, each in its own design language, plus whatever backend
they need.
Also brought to reference behaviour in Phase 2:
- route analytics (
POST /log/create/{key},GET /log/update/{id}) - the
isLoadedguard - the
sts_verify_step_twoguard oncompleteProfile - guest behaviour: toast «ابتدا وارد شوید» + redirect to
home(not a redirect to/login) - v2's missing cart stock ceiling (
capQty/maxQty) must match v1 and the reference
D4 — Reference bugs: replicate behaviour, not breakage
- Broken or destructive reference behaviour is not ported — logged only.
- Business behaviour, calculations, rounding and messages must match the reference exactly.
- Every deliberate non-replication gets a matrix note: «عمداً تکرار نشد — ISSUE #n».
- The price formula is the live version at
henza_server/app/Models/ProductVariant.php:183-190— not the commented-out ones above it. - Write a unit test for it with sample numbers and compare against the reference formula computed by hand.
D5 — Stray root folder: leave it for now
gold-banking-monorepo/user-app-v2/ is logged in ISSUES.md (DEAD-03). At the
very end, after verifying nothing references it, delete it in its own commit.
D6 — URGENT: OTP must be verified (exception to "log only", per CLAUDE.md §3)
The reference does verify the OTP, so the target accepting any code is a parity gap, not just a security issue. Fix it to match the reference: code matching, expiry, and attempt limits.
Reference implementation (henza_server/app/Http/Controllers/Api/ApiMobileController.php:39-78):
| Step | Reference behaviour | Line |
|---|---|---|
| Send | userAuth validates username, finds an active customer by mobile, generates rand(1000,9999), stores it in customer.send_password, sends it by SMS |
:39-49 |
| Verify | userVerfiy validates username + password; if faTOen(password) != customer.send_password → HTTP 422 |
:51-57 |
| Token | issues api_token = Str::random(60) only if the customer doesn't already have one |
:60-62 |
| Digits | the code is normalised through Utility::faTOen() (Persian → English digits) on both send and verify |
:42,56 |
⚠️ Note for the matrix: the reference has no expiry and no attempt limit —
send_password is a plain column that stays valid indefinitely. D6 asks for
expiry and attempt limits, which go beyond the reference. Recorded as a
deliberate, user-requested improvement; the parity row is the code match itself.
D7 — URGENT: @Public() routes that write to the ledger
Show the exact list with file paths and what the reference did for each, then protect them after user approval. See §"@Public audit" below.
D8 — URGENT: the login customer-list leak
Identify whether it is reference or target, and what data leaks. See §"Login leak" below.
D9 — percent: trace before acting
Done — see 00-INVENTORY.md and the trace summary delivered to the user.
Key finding: the target's implementation is wrong. percent is a real column
on the customer in the reference, not a derived value from the referring agent.
D10 — Sales expert: the 5 missing fields
The move to AdminUser is accepted. For ncode, phone, city_id,
gram_level, reagent_id: find where the reference uses each for type=1
(list, filter, report, order, calculation). Fields that are genuinely used must
be added — proposed shape: a SalesExpertProfile table related to AdminUser.
Fields with no use are listed for the user to decide.
D11 — singleCustomer.vue: migrate to the diff-send pattern
Bring it in line with bulkCustomer.vue and ReagentCustomer.vue. Diff-send
is recorded as an accepted deviation from the reference (which sends every
field on every save).
D12 — METHOD CHANGE for Phase 1 (and Phase 2)
1. The matrix is field-by-field, not page-by-page. Every form field (name, type, required, validation, message, default), every table column, every filter, every button — and for each one, the client field name ↔ API field name ↔ DB column, on both sides.
2. A round-trip test is mandatory per entity:
create with every field populated
→ open the edit form, assert every field is correctly populated
→ save with no changes, assert the DB row is byte-identical
→ change exactly one field, assert only that field changed
Run it for the sales expert too.
3. Both rules are to be added to the reference-parity-audit skill
(Step C and Step E) so Phase 2 inherits them.
T1 — Customer authentication: a separate JWT strategy (option الف)
Approved. Conditions:
- Separate secrets — customer tokens are signed with
JWT_CUSTOMER_SECRET, admin tokens withJWT_SECRET. - A
typclaim is checked in both guards —typ: 'admin'vstyp: 'customer'. - Mandatory cross-token tests: a customer token on any admin route → 401; an admin token on a customer route → rejected, unless deliberately allowed.
- Token lifetime and logout must be compared against the reference and recorded here (see below).
- No IDOR — customer routes that return personal data derive the customer from the token, never from an id in the request.
Reference comparison — token lifetime and logout
| Aspect | Reference | Target (current) | Decision |
|---|---|---|---|
| Customer token | api_token = Str::random(60), a random opaque string stored on the customer row (ApiMobileController.php:60-62) |
none | new customer JWT |
| Expiry | none — the reference token never expires; it is reused on every login if already set | admin JWT JWT_EXPIRES_IN, default 12h |
customer JWT gets an explicit expiry (deliberate improvement over the reference) |
| Logout | the reference has no logout endpoint; the app drops the token client-side | apps clear localStorage |
keep client-side clear; note that a stolen token stays valid until expiry |
| Rotation | the reference issues a new token only if api_token is null — logging in twice returns the same token forever |
n/a | issue a fresh token on every verify |
The last three rows are deliberate improvements over the reference, approved by the user in the same message that approved D6. Recorded so the matrix does not later flag them as parity gaps.
T2 — Uploaded files stay public
Ruling: /uploads is not touched. No allow-list, no signed URLs, and the
"private folder" stage is dropped.
Recorded verbatim at the user's request:
«فایلهای آپلودی شامل مدارک عمداً عمومی هستند — تصمیم مالک پروژه» (Uploaded files, including identity documents, are deliberately public — the project owner's decision.)
The light check the user asked for — result: ✅ already correct
| Check | Result | Evidence |
|---|---|---|
| Filenames unguessable? | Yes. md5(Date.now() + originalname + randomUUID()) + the original extension |
src/modules/admin-uploads/admin-uploads.service.ts:53 |
| Sequential or original name on disk? | No. originalName is kept only as a DB column, never as the filename |
admin-uploads.service.ts:62 |
| Directory listing? | No. PublicUploadsController only resolves a single :filename, and resolveStoredUpload() rejects ./.. and anything resolving outside the upload dir |
public-uploads.controller.ts:17, upload-path.ts:34-49 |
Nothing to change. No existing files were moved.
T3 — Two-stage hotfix
Approved, with the condition that GET /customers be checked first.
Check result: the apps are dependent on it. Its only consumer is
LoginView.vue:131 in both apps, and CustomerSummary is used nowhere else
(verified by grep). The fields actually read are mobile, isActive,
isArchived, then session.login(id, mobile) — four fields.
So per the ruling: stage 1 strips the sensitive fields and keeps the route; stage 2 removes the route when login moves to OTP.
Urgent investigation results
percent — full trace (D9)
In the reference, percent is a real column on the customer
| Fact | Evidence |
|---|---|
Column exists on customers |
database/migrations/2020_10_19_153228_customer_add.php:32 — $table->double('percent')->default(0); |
| Mass-assignable | app/Models/Customer.php:31 |
| Defaulted to 3 for app-registered customers | app/Http/Controllers/Adm/AdminController.php:338 — $input['percent'] = 3; |
| Required when an admin creates a wholesale customer | AdminController.php:354 — 'percent'=>'required' |
| Selected into the wholesale customer list | Adm/MajorCustomerController.php:36 |
| Shown in a report column «درصد» | Adm/ReportController.php:137 |
What it actually does — it shifts the customer's making-charge filter
app/Http/Controllers/Api/ApiMobileController.php:391-396:
if (sizeof($limit_percent) > 0 and auth('api-customer')->check()) {
$limit_percent[0] = $limit_percent[0] - auth('api-customer')->user()->percent;
$limit_percent[1] = $limit_percent[1] - auth('api-customer')->user()->percent;
$product->whereHas('variants', fn($q) => $q->whereBetween('percentage_pay', $limit_percent));
}
And it shifts the filter bounds the app is told to display, at login
(ApiMobileController.php:76-77):
$customer['filterPercentPay'][] = strval(ProductVariant::min('percentage_pay') + $customer['percent']);
$customer['filterPercentPay'][] = strval(ProductVariant::max('percentage_pay') + $customer['percent']);
So customer.percent is a per-customer making-charge offset: the UI shows
percentage_pay + percent, and the filter subtracts it again before querying.
What it does NOT do
It is not part of the price formula. The formula's extra percent comes from a global config row, not from the customer:
app/Models/ProductVariant.php:50—ConfigStatic::where('key','product_price_extra_percent')->first()['val'] ?? 7app/Models/ProductVariant.php:183—($this->percentage_pay + static::$product_price_extra_percent)
There is a separate, unrelated ConfigStatic key also called percent, used
for the cashback/wallet rule (AdminController.php:903,994; Controller.php:203) —
value = total_weight * (percent/100). Do not confuse the two.
A third unrelated percent is a per-order-item field in wholesale orders
(Adm/MajorOrderController.php:220): w = rash_weight + rash_weight * (percent/100).
That one comes from the request, not from the customer.
The target is wrong
apps/api/src/modules/admin-customers/admin-customers.service.ts:245:
// legacy `percent` came from the referring agent's commission rate
percent: c.referredByAgent?.wagePercent ?? null,
That comment is incorrect — the reference reads customer.percent, its own
column. Consequences:
- Read is wrong — it shows the agent's commission, not the customer's offset.
- Write is missing —
update()never handlesdata.percent(admin-customers.service.ts:600-658), so editing the field in the admin form does nothing. - The create default is missing — the reference sets
percent = 3for app-registered customers and requires it for admin-created wholesale customers. - The app-side effect is missing — no
filterPercentPayoffset anywhere inuser-app/user-app-v2.
Proposed reconstruction (needs user approval — it is a schema change)
- Add
makingChargeOffsetPercent Decimal @default(0)toCustomer(additive migration). Name it clearly so it is never confused with the two otherpercents; map it to the legacy keypercentintoRow(). toRow()returns that column, notreferredByAgent.wagePercent. Remove the incorrect comment.create(): default 3 on the app-registration path; require it on the admin wholesale path (referenceAdminController.php:354).update(): handledata.percent.- Catalogue filter: subtract the customer's offset from the
percentage_paybounds before querying, and return shiftedfilterPercentPaybounds at login — matchingApiMobileController.php:391-396,76-77. - The referring agent's
wagePercentstays where it belongs (the agent), and is not exposed through the customer form.
@Public() audit (D7) — every unauthenticated route in the target
15 routes across 7 controllers. Verified by reading each controller.
🔴 Group A — ledger writes (the ones the user flagged)
apps/api/src/modules/accounting/accounting.controller.ts — @Public() on the
whole controller (:7), prefix accounting (:8):
| # | Route | Line | Effect | Reference equivalent |
|---|---|---|---|---|
| A1 | GET accounting/customers/:customerId/balance |
:12 |
reads any customer's balance | Adm/WalletController — reference had auth commented out too (routes/admin.php:12) |
| A2 | POST accounting/customers/:customerId/reconcile |
:17 |
writes — reconciles a balance | same: unauthenticated in reference |
| A3 | POST accounting/repayments/gold |
:22 |
writes — records a gold repayment | same |
| A4 | POST accounting/repayments/rial |
:27 |
writes — records a rial repayment | same |
| A5 | POST accounting/entries/:entryId/confirm |
:32 |
writes — confirms a ledger entry | same |
In the reference these were admin routes in routes/admin.php, and that whole
file's auth:api wrapper is commented out — so they were unauthenticated there
too. The target faithfully reproduced that. Under D1 the target is supposed
to be stricter, so these five are the clearest violation of D1 still standing.
Recommendation: A1–A5 are admin operations. Put them behind the admin JWT
guard with the matching rid (the wallet/document rids from acl.constants.ts).
Awaiting your approval — this is the list you asked for.
🟠 Group B — order writes
apps/api/src/modules/orders/orders.controller.ts — @Public() at :7, prefix orders:
| # | Route | Line | Note | Reference |
|---|---|---|---|---|
| B1 | POST orders |
:12 |
customer places an order | ApiMobileController::setOrder — was authenticated via auth('api-customer'); it reads auth()->id() at :533 |
| B2 | GET orders/:id |
:17 |
reads any order by id | same guard in reference |
| B3 | PATCH orders/:id/status |
:22 |
changes order status; DTO accepts a client-supplied actorAdminUserId |
admin operation in reference |
B1/B2 differ from the reference in kind: the reference's customer endpoints
sat behind the api-customer guard and derived the customer from the token.
The target has no customer session at all, so it takes customerId from the
request body. That is why they are public. Fixing this properly means building
customer auth — which is exactly what D6 starts.
B3 is the worst of the three: it lets an anonymous caller move an order's status and attribute the action to any admin.
🟢 Group C — genuinely public reads (match the reference's public storefront)
| # | Route | File:line | Reference |
|---|---|---|---|
| C1 | GET customers |
customers/customers.controller.ts:10 |
see the leak below |
| C2 | GET customers/:id |
:15 |
— |
| C3 | GET customers/:id/orders |
:20 |
— |
| C4 | GET customers/:id/ledger-entries |
:25 |
— |
| C5 | GET catalog/products |
catalog/catalog.controller.ts:10 |
reference storefront was public (routes/site.php) ✅ |
| C6 | GET pricing/latest |
pricing/pricing.controller.ts:10 |
reference /price was public ✅ |
| C7 | GET home/slider |
admin-misc/public-slider.controller.ts:21 |
reference mobile_main_slider config, public ✅ |
| C8 | GET uploads/:filename |
admin-uploads/public-uploads.controller.ts:17 |
reference served uploads statically ✅ |
C5–C8 are legitimately public. C1–C4 are not — they expose customer records.
Login leak (D8) — it is in the target, and it is worse than "a list"
Where: target only. apps/user-app and apps/user-app-v2 both call
GET /api/customers at login and match the phone number in the browser.
What leaks: GET /customers is @Public()
(customers/customers.controller.ts:10) and returns every active,
non-archived customer with these fields
(customers/customers.service.ts:6-23, :44-50):
| Field | Sensitive? |
|---|---|
id, createdAt, updatedAt |
low |
fullName |
🔴 PII |
mobile |
🔴 PII |
nationalCode |
🔴 national ID number |
cityId, referredByAgentId |
medium |
goldCreditLimitGrams, goldDebtGrams, fiatDebtRial |
🔴 financial position of every customer |
verificationStep, isActive, isArchived |
medium |
nationalIdImageUrl, businessLicenseUrl, shopImageUrl |
🔴 URLs of ID-document scans |
passwordHash is correctly excluded — the select list is explicit and the
source comment says so (customers.service.ts:5). So no password material leaks.
Everything else does. Any anonymous visitor can fetch the full customer book —
names, mobiles, national ID numbers, debts, and links to identity-document images
(and GET uploads/:filename is public too, so those links resolve).
In the reference this was not possible. Login was
POST userAuth → SMS code → POST userVerfiy
(ApiMobileController.php:39-78): the server looked the customer up by mobile
and returned only that one customer after the code matched. There was no
"download everyone and match on the client" step.
Root cause: the target has no customer authentication, so the client does the
lookup. D6 (real OTP) is the fix for both problems at once — once
POST auth/otp/verify returns the authenticated customer, GET /customers can
be taken off the public surface entirely.
Stage 2 implementation record (2026-09-22)
Deliberate improvements over the reference — user-approved
Recorded here so the Phase 1/2 matrices do not later flag them as parity gaps.
| # | Behaviour | Reference | Target | Approved in |
|---|---|---|---|---|
| 1 | OTP storage | plain text in customer.send_password |
bcrypt hash in CustomerOtp.codeHash |
D6 |
| 2 | OTP expiry | none — valid forever | 2 minutes | D6 |
| 3 | Attempt limit | none | 5 per challenge, then lockout | D6 |
| 4 | Replay | code stayed usable | single use (consumedAt) |
D6 |
| 5 | Send throttle | none | 60s cooldown/number, 5 per number and 20 per IP per 10 min | user message of 2026-09-22 |
| 6 | Unknown number | firstOrFail() → 404, enumerable |
identical {sent:true} |
implied by 5; recorded here |
| 7 | Token rotation | api_token issued once, reused forever |
fresh token on every verify | T1 |
| 8 | Token expiry | none | JWT_CUSTOMER_EXPIRES_IN, default 30d |
T1 |
| 9 | Token secret | single opaque string | JWT_CUSTOMER_SECRET, separate from admin, plus a typ claim |
T1 |
New environment variables
| Name | Default | Purpose |
|---|---|---|
JWT_CUSTOMER_SECRET |
falls back to JWT_SECRET, then a dev string |
signs customer tokens |
JWT_CUSTOMER_EXPIRES_IN |
30d |
customer token lifetime |
Both fall back so local development keeps working, and both inherit the SEC-04 problem (an insecure default when unset) — tracked there, not re-logged.
Known gap left open on purpose
POST /orders and GET /orders/:id are still @Public(). The customer token
now exists, so they can be closed, but CreateOrderDto carries customerId in
the body and changing that is an API-contract change touching both apps' cart
flow. Left for the Phase 2 apps work rather than widened into this hotfix.
Tracked as SEC-02 (B1/B2) in ISSUES.md.