The owner ruled that the reference also ran a retail storefront (B2C
single-customer shop) and that the wallet, cashback and
ConfigStatic['percent'] belong to it. These are preserved exactly as they
are: nothing deleted or disabled on either side, no parity work, listed in
the matrices with status "preserved — out of current scope" and no gap or
work row. Shared surfaces (product, variant, price, order, customer) need
the owner's approval before a change that could reach the storefront.
Classifying the endpoints for that split surfaced a counting error in the
Phase 0 inventory: routes/admin.php has 151 live explicit routes, not the
117 recorded, so the reference total is 412 and not 369 (logged DOC-01).
get-analytics-tak reads SingleCustomerOrders, so it is a storefront route
despite its neutral name (logged DOC-02).
Of the 412 reference endpoints, 109 are retail storefront, 15 are web.php
dev/ops closures, and 288 remain in scope (218 admin + 70 B2B app).
[ref: henza_server/routes/{admin,api,customer,site,web}.php]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
532 lines
26 KiB
Markdown
532 lines
26 KiB
Markdown
# 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:
|
||
|
||
1. Additive migration only — nothing dropped.
|
||
2. Backfill existing rows: **first token** of `fullName` → `firstName`, **the
|
||
rest** → `lastName`.
|
||
3. Rows whose `fullName` has **more than two tokens** must be written to a report
|
||
for manual review (they cannot be split safely).
|
||
4. **`fullName` is NOT removed.** It stays and is kept in sync as
|
||
`firstName + " " + lastName` so existing code keeps working.
|
||
5. `toRow()` must return the **real** `fname` / `lname`, not `fullName` / `''`.
|
||
6. Fix every consumer: `singleCustomer.vue`, `bulkCustomer.vue`,
|
||
`ReagentCustomer.vue`, and anywhere a name is displayed, searched or sorted.
|
||
**Remove the `split(' ')` fallback in `singleCustomer.vue`.**
|
||
7. Check the same defect on **`AdminUser`** (sales expert) and on
|
||
**registration / profile in `user-app` and `user-app-v2`**.
|
||
8. **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 `isLoaded` guard
|
||
- the `sts_verify_step_two` guard on `completeProfile`
|
||
- 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:
|
||
|
||
1. **Separate secrets** — customer tokens are signed with `JWT_CUSTOMER_SECRET`,
|
||
admin tokens with `JWT_SECRET`.
|
||
2. **A `typ` claim is checked in both guards** — `typ: 'admin'` vs `typ: 'customer'`.
|
||
3. **Mandatory cross-token tests:** a customer token on any admin route → 401;
|
||
an admin token on a customer route → rejected, unless deliberately allowed.
|
||
4. **Token lifetime and logout must be compared against the reference and
|
||
recorded here** (see below).
|
||
5. **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`:
|
||
|
||
```php
|
||
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`):
|
||
|
||
```php
|
||
$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'] ?? 7`
|
||
- `app/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`:
|
||
|
||
```ts
|
||
// 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:
|
||
|
||
1. **Read is wrong** — it shows the agent's commission, not the customer's offset.
|
||
2. **Write is missing** — `update()` never handles `data.percent`
|
||
(`admin-customers.service.ts:600-658`), so editing the field in the admin form
|
||
does nothing.
|
||
3. **The create default is missing** — the reference sets `percent = 3` for
|
||
app-registered customers and **requires** it for admin-created wholesale
|
||
customers.
|
||
4. **The app-side effect is missing** — no `filterPercentPay` offset anywhere in
|
||
`user-app` / `user-app-v2`.
|
||
|
||
### Proposed reconstruction (needs user approval — it is a schema change)
|
||
|
||
1. Add `makingChargeOffsetPercent Decimal @default(0)` to `Customer` (additive
|
||
migration). Name it clearly so it is never confused with the two other
|
||
`percent`s; map it to the legacy key `percent` in `toRow()`.
|
||
2. `toRow()` returns that column, not `referredByAgent.wagePercent`. Remove the
|
||
incorrect comment.
|
||
3. `create()`: default **3** on the app-registration path; **require** it on the
|
||
admin wholesale path (reference `AdminController.php:354`).
|
||
4. `update()`: handle `data.percent`.
|
||
5. Catalogue filter: subtract the customer's offset from the `percentage_pay`
|
||
bounds before querying, and return shifted `filterPercentPay` bounds at login
|
||
— matching `ApiMobileController.php:391-396,76-77`.
|
||
6. The referring agent's `wagePercent` stays 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`.
|
||
|
||
---
|
||
|
||
# D13 — Retail storefront: preserved, but out of scope (2026-09-22)
|
||
|
||
**Owner's ruling.** The reference also contained a **retail storefront**
|
||
(single-customer / B2C shop). The **wallet**, **cashback** and
|
||
`ConfigStatic['percent']` belong to that storefront, not to the B2B gold
|
||
business this project is porting.
|
||
|
||
## The rule
|
||
|
||
1. **Nothing is deleted or disabled.** No code, table, column, endpoint, setting
|
||
or data belonging to the storefront may be removed or switched off — neither
|
||
in the target nor in the reference. Whatever exists in the target today stays
|
||
exactly as it is.
|
||
2. **No work is done on it.** No parity, no fixes, no refactoring.
|
||
3. **In the matrices** these items are listed with status
|
||
**⏸️ حفظشده — خارج از کار فعلی** (preserved — out of current scope) so they
|
||
remain visible, but **no gap row and no work row is created for them**.
|
||
4. **Shared surfaces need a heads-up.** If fixing something shared — product,
|
||
variant, price, order, customer — would affect the storefront, **ask the
|
||
owner before changing it**, so nothing in the storefront breaks.
|
||
5. **No guessing.** If it is unclear whether something is storefront-only or
|
||
shared, **list it and ask**.
|
||
|
||
## Status legend addition
|
||
|
||
| Symbol | Meaning |
|
||
|---|---|
|
||
| ⏸️ | Preserved — out of current scope (D13). Listed for visibility only; no gap row, no work row. |
|
||
|
||
## Endpoint classification — where the reference endpoints go
|
||
|
||
**The previously reported total of 369 was wrong; the real figure is 412.**
|
||
Recounting the live routes per file (excluding the 6 commented-out ones,
|
||
expanding `Route::resource` ×7 and `Route::match(['get','post'])` ×2) shows
|
||
`admin.php` has **151** explicit routes, not the 117 recorded in
|
||
`_inv-ref-server.md` §3.6 — so admin is **249**, not 215. Corrected in
|
||
`00-INVENTORY.md` §2.1.
|
||
|
||
| Surface | Route file(s) | Endpoints | In scope? |
|
||
|---|---|---|---|
|
||
| Admin panel — B2B | `admin.php` minus the storefront block below | **218** | ✅ in scope (Phase 1) |
|
||
| Admin panel — storefront management | `admin.php`: wallet 6, `single*` 8, comment 2, `get-analytics-tak` 1 explicit + `single` resource 7 + `comment` resource 7 | **31** | ⏸️ preserved, out of scope |
|
||
| B2B mobile app | `api.php` | **70** | ✅ in scope (Phase 2) |
|
||
| Retail storefront — customer API | `customer.php` | **48** | ⏸️ preserved, out of scope |
|
||
| Retail storefront — public site API | `site.php` | **30** | ⏸️ preserved, out of scope |
|
||
| Dev/ops closures, price cron, redirects | `web.php` | **15** | ⏸️ out of scope (dead/dev — see DEAD-\* in `ISSUES.md`) |
|
||
| **Total** | | **412** | |
|
||
|
||
**Result: 109 of the 412 endpoints belong to the retail storefront**
|
||
(48 `customer.php` + 30 `site.php` + 30 storefront management inside
|
||
`admin.php`), **15 more are dev/ops closures in `web.php`**, and
|
||
**288 remain in scope** (218 admin + 70 B2B app).
|
||
|
||
Evidence for the storefront classification:
|
||
|
||
| Block | Where | Why it is storefront |
|
||
|---|---|---|
|
||
| `routes/customer.php` (prefix `cu`) | whole file | guarded by `auth:api-singleCustomer`; cart, fav, address, comment, payment gateway, `customer/wallet` |
|
||
| `routes/site.php` (prefix `site`) | whole file | public shop front: `Site\SiteController`, `Site\CollectionController`, `Api\SiteController` |
|
||
| `admin/wallet/*` | `admin.php:186-191` | `Adm\WalletController` — cashback percent, charge, config |
|
||
| `admin/single*` | `admin.php:35,59,60,194-199` | `Adm\SingleCustomerController` — the retail customer's orders, transactions, wallet, report |
|
||
| `admin/comment*` | `admin.php:150-152` | `Adm\CommentController` — product reviews, a storefront feature |
|
||
|
||
The three unrelated `percent` meanings were already separated in
|
||
`MIGRATION-STATE.md`. D13 confirms that **`ConfigStatic['percent']` (wallet
|
||
cashback) is storefront and out of scope**, while **`customers.percent`
|
||
(→ `makingChargeOffsetPercent`) is B2B and stays in scope**.
|