ddcc8c564930aa160ab490d41f4650e26fa72cb4
19
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ddcc8c5649 |
feat(apps): a sales expert can work inside their customer's account (D29)
Reported missing by the owner: in the reference an expert could open any
of their referred customers and use the app AS them — browse, see
balance and debt, place orders — «یکجورایی انگار با اکانت اون ها لاگین
کرده». D21 had deliberately left it out as a session-hijack primitive.
The owner asked for the capability back with the implementation left to
judgement, so the scope is the reference's and the mechanism is not.
Scope: everything, as the reference had it. Its axios interceptor
swapped the bearer token, so every request went as the customer,
document upload included. No route is closed in act-as mode.
Mechanism: the reference minted and handed over the customer's own
permanent api_token. Here the expert keeps their own token and sends
X-Act-As-Customer, resolved in one place — CustomerJwtStrategy
.validateExpert. That single point is why all 17 customer routes work
unchanged: validate() already returns a principal whose `id` is a
Customer id and every customer route reads it. It also fixes four real
defects of the original:
- ownership is re-checked on EVERY request, with referredByAgentId in
the same where clause as the id, so another expert's customer and a
non-existent id both answer 403, and a customer moved to another
expert loses access at once rather than at token expiry;
- nothing durable is handed out — the reference's token sat in
localStorage with no expiry and kept working after the expert left;
- adminUserId survives, so an order placed this way is attributable;
in the reference the request was literally the customer's;
- 403 rather than 401, so the app clears the act-as state instead of
logging a perfectly valid session out.
Performance: one findFirst on the primary key, only when the header is
present, and it replaces the trading-account lookup rather than adding
to it.
Three things the reference never needed, because it swapped the whole
session. queryCache's `me:` keys, favouritesStore and wageQuoteStore all
dropped their data on a TOKEN change, which act-as does not cause — so
an expert would have seen their own profile, orders, balance, ledger and
favourites inside the customer's account. The wage-quote one is the
money case: making charge is per customer, so the figure on screen would
not have been the one the order was priced at. And uploadDocument builds
its own headers, so an expert's upload would have been filed against
their own account, silently.
Added with no reference equivalent: a permanent «در حال مشاهده بهعنوان
…» bar. There, impersonation was invisible once the token had been
swapped, and placing an order on the wrong customer's gold debt was one
tap away. It is also the exit — the reference's only way out was a
logout that ended the expert's own session too. The cart is cleared in
both directions.
CustomerJwtStrategy had no test coverage at all and is now where this
feature's whole authorisation boundary lives: act-as-customer.spec.ts
covers 11 cases, including the header being ignored on a customer token,
the no-header path asserting the extra query does not run, and a revoked
session short-circuiting before the header is read.
api: 308/308 tests, 28 suites; both apps vue-tsc clean.
اپ: کارشناس فروش میتواند وارد حساب مشتریان خود شود، وضعیتشان را ببیند و از طرفشان سفارش ثبت کند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c518f9995b |
fix(auth): tell the user when the number is not registered (D28)
Reported by the owner: a number that is not in the system produced no error at all. The app advanced to the code box and then nothing happened, and there was no way to tell apart "the SMS is late", "the gateway is down" and "this number was never registered" — the only one of the three the person can actually do something about. Step 1 now says which it is: - unregistered number -> 404 «این شماره در سیستم ثبت نشده است…» - SMS send failed -> 503 «ارسال پیام کوتاه … ممکن نیست…» - sales expert, account unusable -> 403, its own message and both apps surface the server's Persian message for a 404 exactly as they already did for a 429. Without that second half the person would still have seen the generic «خطا در ارسال کد», which is the confusion being removed. This restores the reference's behaviour (firstOrFail() in ApiMobileController::userAuth:43 returns 404); the opaque answer was our deviation, not this. The anti-enumeration protection it was there for is deliberately reduced at step 1 on the owner's instruction — the cost was being paid by real users and by support. Still standing: the per-mobile and per-IP send throttles, which the reference never had, so enumeration is rate-bounded rather than free, and verifyOtp keeps one message for every failure reason. ISSUES.md SEC-03's "no enumeration" claim is corrected rather than left to go stale. Two more instances of the same shape turned up while fixing it: A failed SMS send also returned success, because SmsService never throws (D20), so a dead gateway produced the identical dead end. D20 exists so a failed SMS cannot break an operation — an order must still be placed — but in a login the SMS IS the operation, so it now fails loudly. The local-dev path keys off SmsService's own `sms-not-configured` result rather than re-testing the config: with D26's default relay driver, smsConfigured() is true on any machine with an external IPv4, so a config test would have called a dev box configured and then failed its login. The new test caught precisely that on its first run. Three states return no trading account for a sales expert who does exist. Under the new 404 each would have claimed the number was not registered — false, and it would send support hunting a registration problem that is not there. Also corrects both apps' LoginView header comment, which still described this flow as stubbed: no SMS provider, any 4-digit code accepted, step 1 matching the customer list in the client (that was the login leak, D8). All three had been untrue for weeks and the stale text misled a reader during this session. requestOtp had NO test coverage: the 28-test customer-auth suite stayed green through a change to its response for every unregistered number, so its greenness was not treated as evidence. request-otp.spec.ts adds 9 cases, including that the dev fallback does not apply in production. api: 295/295 tests, 27 suites, typecheck clean; both apps vue-tsc clean. اپ: اگر شماره شما در سیستم ثبت نشده باشد، همان را میگوید و دیگر بیدلیل به صفحه کد نمیرود. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
89e2d7937e |
feat(apps): tell a guest why they were bounced to the login page (D3)
A guest tapping a personal page — orders, transactions, profile, favourites, complete-profile — was redirected in silence, so they landed on the login box with nothing explaining what happened. Legacy showed «ابتدا وارد شوید» (henza_pwa/src/router/index.ts:259). The message is now shown in both apps. The destination deliberately stays the target's /login?redirect=…, which legacy did not have: legacy sent the guest to home and lost where they were going, while here signing in returns them to the page they asked for. The owner chose that trade-off, so it is logged as «عمداً تکرار نشد — D3/الف» rather than quietly left as a divergence. The guard also keys off meta.requiresAuth instead of legacy's hand-kept list of five route names, so the two sales-expert pages are covered too. Legacy's isLoaded router guard is NOT ported, and the reasoning is written into both apps' configStore beside the line that says so: it guarded legacy's one big bootstrap request, which this target does not have, so it would guard nothing and only add a bounce — a shared product link would open home instead of the product. That is porting the breakage, not the behaviour (D4). Route analytics is deferred at the owner's request; its design is parked in docs/PHASE2-D3-PLAN.md so it need not be re-derived, along with the two reference defects not to copy (log/create was unauthenticated and accepted any title; log/update was a GET that wrote) and the open retention and IP questions. Seven of D3's ten items turned out to be built already — all five screens in both apps, the sts_verify_step_two guard, v2's cart ceiling — verified in the code rather than from the checkmarks. Neither app has a test runner: verified with vue-tsc -b on both (clean) plus confirming the toast can actually appear, not merely compile — toast.info exists and <ToastHost /> is mounted in each App.vue. اپ: وقتی بدون ورود صفحه شخصی را باز کنید، پیام «ابتدا وارد شوید» نشان داده میشود. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fd9191eca8 |
feat(orders): log the client price figures the server discards (MON-08)
Step 2 of the owner's MON-08 ruling. An app order is priced by the server (steps 1, 3, 4 landed with the wage-formula work), but the client's own wagePercent, discountPercent and flatRialCharge were being thrown away in silence — so a stale app build, a promotion that expired between the product page and checkout, and a deliberately tampered request were indistinguishable and left no trace anywhere. They are now logged with both numbers, the same pattern already used for the body's customerId. The pricing itself is untouched: the server's figures are what the order is charged, and an operator ordering from the panel still keeps the numbers they typed. Also adds the comparison test the ruling asked for, which step 3 never actually had — the suite only proved a tampered request gets overridden. The new test places one honest order as a customer and the same order as an operator and asserts the totals are identical, which is the real claim: an ordinary customer's total does not move. Two more cover the logging, including that an honest order logs nothing — so the quiet case cannot pass merely because the method never ran. Closes STUB-05 in the docs as well: the reference's six hardcoded staff numbers have been behind PROFILE_ADMIN_MOBILES / ORDER_ADMIN_MOBILES for a while, but its status still read "pending". api: 286/286 tests, 26 suites, typecheck clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
83e5d2868b |
feat(orders): send the order-placement SMS and close Phase 1
The reference texted three audiences on every placed order
(henza_server Api/ApiMobileController::setOrder:733-766). None of it was
wired here — OrdersService had no SmsService at all, so a customer could
place an order and nobody was told: not the customer, not the admins, not
the referring sales expert.
Now, after the transaction commits (so a rolled-back order texts nobody)
and without ever being awaited (D20 — a dead gateway must not fail an
order):
- every mobile in ORDER_ADMIN_MOBILES, with the customer's name and
the rounded gold weight;
- the customer, with distinct text for a normal and an over-limit
suspended order;
- the referring sales expert, looked up on AdminUser (the target's
referring expert is an admin, not a customer as in the reference).
The reference's four hardcoded personal numbers stay out of git, behind
the same env-var pattern as PROFILE_ADMIN_MOBILES.
The «در سیستم ویرایش شد» variant was deliberately not ported: the target
has no order-edit route, so it would be dead code — «عمداً تکرار نشد».
Also fills §2 of the admin matrix, the D1 deliverable that had been
(pending) since the phase opened: 163 rid-guarded routes, 68 distinct
rids, 176 grantable tree keys, and a per-module map to the reference
controllers. Only two enforced rids are ungrantable from the permission
screen (newsletters, persons.list.sellers.view) — filed as PERM-02,
awaiting an owner ruling because it changes the permission vocabulary.
Phase 1 (admin parity) is at its gate. api: 283/283 tests, 26 suites.
پنل: پیامک ثبت سفارش برای مشتری، ادمینها و کارشناس فروش فعال شد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
6d0f5c5359 |
test(api): B2B admin scan + write-path smoke, both green
scan-admin-b2b.cjs (34 ok, 0 broken) hits every read endpoint the wholesale desk uses daily. smoke-admin-b2b-writes.cjs (25/25) covers the money-touching writes: admin order placement and a cash payment document, asserting the ledger and the customer balance rather than the HTTP status. Verified: the making charge computes exactly, a document is created unconfirmed, confirming moves the gold debt, and confirming again toggles back and restores the balance to the byte. Retail/storefront surfaces were deliberately not scanned (D13). No B2B breakage found. Three first-draft failures were test bugs, corrected against the real callers: cities/list -> admin/cities/list, by-ids takes products_id, and document type 4 is cash (2 is a transfer). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8b7da90f51 |
test(apps): customer-flow smoke test for both apps + 4-digit master-code guard
smoke-apps.cjs walks login (master OTP), catalogue, cart cap, order placement and order history, with the empty-order and unauthenticated negatives: 43/43. The cart rules are executed from BOTH apps' own source so the result cannot be carried by v1 alone. No behavioural divergence found: all 9 stores are byte-identical, and the router, API client and composables match except v1's protected variant-image picker. APP-01: both apps' OTP box is exactly 4 inputs and only submits at 4, so a longer OTP_MASTER_CODE cannot be typed into them. The login UI was left alone; main.ts now warns at boot when the code is not 4 digits and .env.example states the rule. APP-02 logged, not fixed: user-app-v2 has no getHomeSlider, so admin-configured home slides show in v1 only. Changing v2's hero during a delivery is not worth the risk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0983c42788 |
fix(admin-panel): retail order status editing posted a body with no orderId
views/order/List.vue handed the timeline activity entry straight to
single/order-sts/edit. That entry is {status, title, proceed, uaj, note} and has
no order id, so UpdateOrderStatusDto rejected every call with 400 — neither
"ثبت وضعیت" nor "حذف وضعیت" worked on the retail order screen. Both handlers now
take the id from tempRow, matching OmdeList.vue and TaminList.vue, which were
already correct.
Found by the new scripts/smoke-admin.cjs (43/43), which walks login, customer
create, sales-expert create/edit, product+variant create/edit and order
list/detail/status, asserting the database changed rather than trusting a 200.
[ref: henza_server/routes/admin.php:59]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
676ec3ed72 |
test(api): live D20 verification + document every env var
Two live scripts against a running API, covering both halves of the master-OTP
contract:
* with OTP_MASTER_CODE set (8/8): signs in, the JWT works on customer/me,
Persian digits accepted, and the RULE E1 negatives — near-miss code,
unknown mobile, inactive customer — all still rejected.
* without it (6/6): the same code returns 401, no boot banner, a real code
still logs in, and replaying it fails.
Also confirmed from the boot log that with no SMS credentials the server starts
normally and declines to send instead of erroring (D20).
.env.example gained the auth, SMS and upload variables, names only, no values,
with OTP_MASTER_CODE marked delivery-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
460cf276b6 |
parity(api,apps): delivery-mode gaps — master OTP, non-blocking SMS, faToEn, v2 cart cap
D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when unset, audited on every use, with a boot banner while armed. SMS no longer aborts the boot in production — it warns and declines silently, so no message can stop an operation. D19: port Utility::faTOen onto admin-entered mobiles (before the uniqueness lookup, which could previously be bypassed with Persian digits), and the create-only welcome SMS, byte-identical to the reference and fire-and-forget. user-app-v2 was missing the cart stock ceiling entirely: capQty/maxQty, the un-orderable guard and the qty clamp are ported, leaving v2's own design intact. The two cart stores are now byte-identical. isVariantOrderable and the orderable-first default selection ported too. jest 45/45; typecheck clean on api, admin-panel, user-app, user-app-v2. [ref: henza_server/app/Http/Controllers/Adm/AdminController.php:333,348,364,378-387] [ref: henza_server/app/Models/Utility.php:26] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2194b3d4d9 |
parity(admin): D17 — SalesExpertProfile, AdminUser name parts, agent grid fields
Restores the sales-expert fields that were lost when a sales expert became
an AdminUser instead of a type=1 Customer row.
New SalesExpertProfile holds cityId, gramLevel, phone and nationalCode. A
separate table rather than columns on AdminUser, because these mean
something only for role = SALES_EXPERT. gramLevel is DECIMAL(14,4), matched
to Customer.goldCreditLimitGrams so the same credit limit has identical
precision on both tables. phone and nationalCode copy the reference exactly:
nullable, NOT unique, no format rule — createCustomer validates only mobile
and percent, and both fields are required:false on the reference form.
Inventing a uniqueness constraint would reject data the reference accepts.
reagent_id is deliberately NOT ported (D17). Every use of it in the
reference reads a CUSTOMER's reagent_id, which the target already models as
Customer.referredByAgentId. On a sales expert's own form it was a side
effect of the shared customers form: filling it removed that expert from
the selectable-agent list, because queryReagent filters on
whereNull('reagent_id'). The target's equivalent filters on role and active
only, so no agent can be dropped — strictly better than the reference.
AdminUser gains firstName/lastName with the same additive migration,
backfill and sync trigger as Customer, for all admins rather than only
sales experts. This closes D0 requirement 7, and every remaining
`fname: fullName, lname: ''` was fixed — six further sites across
admin-users, admin-comments, admin-documents, admin-orders and
admin-preorders.
listReagents now returns real city_id, city, gram_level, phone and ncode,
and left-joins so an expert without a profile still lists with empty cells
rather than vanishing. The two grid filters map through the relation. The
service upserts a profile on create and on a role change to SALES_EXPERT,
and the migration backfilled one for every existing expert.
ReagentCustomer.vue gains the four fields plus the city -> commission
autofill, ported verbatim including the reference's falsy test so a city
percent of 0 also falls through to the aux lookup (replicated, not fixed).
Tests: new sales-expert round trip 33/33, covering create with every field,
an edit form with each field populated separately, a byte-identical no-op
save, single-field edits, both grid filters with negative cases, the
left-join case and the referrer dropdown. «محمد علی» + «احمدی» survives
intact. Plus jest 32/32, auth-smoke 53/53, customer trigger 9/9, typecheck
clean on api and admin-panel. Migration dry-run twice in a rolled-back
transaction before applying; drift is zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e3af31948e |
parity(docs): D16 — stop PRICE-01 as storefront, reorder Phase 1, verify SEC-02
Owner ruling: computing and displaying price / price_with_discount is retail storefront work, so it falls under D13 and is marked out of scope. The B2B app works in making charge only. variant-price.ts and its 11 hand-checked tests are kept and still run, just unused. Nothing needed reverting: computeVariantPrice was never called from outside its own file and spec, verified by grep over apps/api/src and apps/admin-panel/src, so no price had been wired into any screen. PRICE-01 is re-marked in ISSUES.md rather than deleted so the evidence survives if the storefront is ever taken up. Making-charge work stays fully in scope: percentage_pay, makingChargeOffsetPercent, and wage display on variants and order lines. New Phase 1 order: Persons -> products/categories/variants/order detail -> financial documents -> users and permissions -> admin SMS -> promotions, notifications, cities, branches, workshops -> reports -> gate. Per section: implement the gaps, then field-by-field test that section, and show a gap summary before fixing. SEC-02 re-verified live rather than quoted: 9/9 assertions, plus 32/32 in auth-smoke.sh. POST /orders is 401 without a token; a body customerId of "customer-2" sent with a customer-dev-test token still lands the order on customer-dev-test and is warn-logged; a foreign order id returns 404 rather than 403 so ids stay unenumerable; the admin read rejects a customer token. Client-supplied wagePercent/discountPercent/flatRialCharge remain as MON-08, already scheduled as Phase 2 item #1. Also records why the dev OTP log briefly looked broken: a stale API process still held port 3077, so the new instance died with EADDRINUSE and the tailed log belonged to the old process. Not a regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8133a2bdf4 |
parity(api,admin): split customer name, add making-charge offset, wire SMS
Applies the two approved additive migrations and fixes every consumer.
D0 — Customer.firstName / lastName
The reference has had two columns since 2020
(2020_10_01_120430_customers_table.php:18-19); the port collapsed them, so
the edit form showed the whole name in the "first name" box and saved an
empty last name. Adds both columns, backfills first-token/rest, and keeps
fullName in sync with a customer_sync_full_name trigger. Nothing dropped.
The trigger is SQL, so Prisma cannot see it: writing the parts rebuilds
fullName, writing fullName re-derives the parts, and writing both in one
statement lets THE PARTS WIN. Documented in the migration header, the
Customer model doc comment and DECISIONS.md §D14, because a write can now
store something other than what was passed.
D9 — Customer.makingChargeOffsetPercent
Legacy customers.percent, the per-customer making-charge offset that the
app adds to the variant's making charge (henza_pwa
Mixins/product/index.ts:69-75). toRow() was returning the referring
AGENT's commission rate instead (CUST-02). DB default 0 reproduces
today's behaviour exactly, so no existing customer's price moves; the
reference's application-layer default of 3 for NEW customers
(AdminController.php:338) lives in the create form, and the field is
required on create with the reference's own message (:354).
update() and updateCredit() both accepted percent and silently discarded
it; both now persist it. Sort/filter aliases fname/lname pointed at
fullName and now point at the real columns.
SMS — Trez provider (Sms.php:57-82)
New global SmsModule/SmsService: form-urlencoded POST, 20s timeout, body
trimmed, every message persisted. send() never throws, matching the
reference, which discarded Sms::Send's result so a gateway outage never
aborted the order or approval that triggered it. The OTP text is
byte-identical, including the Android SMS Retriever markers. Credentials
come from env only; production refuses to boot without them rather than
silently locking every customer out. .env.example carries names, no values.
Admin panel: the split(' ') fallback is removed from singleCustomer.vue,
and bulkCustomer.vue defaults percent to 3, requires it, coerces the
Decimal on load so diff-send works, and only re-sends `name` when a name
part changed. ReagentCustomer.vue is untouched — its «درصد کمیسیون» is
AdminUser.wagePercent, a different field.
Tests: 32 jest (incl. 14 new SMS config/template), 9 trigger assertions,
53 auth smoke, and the full D12 round-trip on the customer entity —
«محمد علی» + «احمدی» survives create, edit, a byte-identical no-op save,
and single-field edits. Typecheck clean on api and admin-panel.
Note: migrate dev is interactive-only and could not run here, so the
migrations were applied with migrate deploy after a read-only drift check.
The only drift is the pre-existing _goldbank_migrations table, which
Prisma would DROP; it was NOT dropped (DEAD-07).
[ref: henza_server/app/Models/Sms.php, app/Http/Controllers/Adm/AdminController.php]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7b598e9e8e |
parity(api): port the reference price formula with tests; SMS + MON-08 reports
Adds computeVariantPrice(), a verbatim port of the live formula at henza_server/app/Models/ProductVariant.php:183-190, with the per-customer making-charge offset as an explicit input defaulting to 0. The three superseded formulas commented out above it apply VAT to the whole price and are deliberately not ported (D4). 11 unit tests assert against hand calculations derived from the PHP source rather than from the implementation, covering: with and without the offset, VAT on the non-metal portion only, floor-to-nearest-1000, the extraPercent default of 7, and the equivalence between the app folding the offset into percent_pay and the server deriving it. 18 tests pass overall. Also adds the D0 name-split review report script (read-only) and two investigations the owner asked for: - SMS: the reference uses the Trez panel (smspanel.trez.ir, Sms.php:69,97) with production credentials committed to git (SEC-12 — rotate them). All 12 message templates catalogued; env var names proposed; nothing implemented pending approval. - MON-08: henza_pwa computes percent_pay, discount and rial_pay rather than asking the user (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. rash_weight stays read from the DB as a recorded deviation. Records the owner's two new standing rules: migrations only through the standard Prisma workflow, and every data deletion logged in CHANGELOG. [ref: henza_server/app/Models/{ProductVariant,Sms}.php, henza_pwa/src/views/cart.vue] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
024e3dd045 |
parity(api,apps): close SEC-02 - orders take the customer from the token
The user declined to defer this to Phase 2, correctly: a public POST /orders with customerId in the body let anyone place an order on any customer's account, and an order lands on that customer's gold debt and ledger. POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the customer id as a separate argument that the controller fills from the token; the body's customerId is ignored and a mismatch is logged. The field stays optional in the DTO and in shared-types only so both apps keep validating while they still send it -- they drop it in Phase 2. The admin builder is unaffected: it legitimately places orders for a chosen customer and passes that id explicitly, behind the admin guard. GET /orders/:id is scoped to the owner. A foreign or nonexistent id both return 404, never 403, so ids stay unenumerable. Operators read through the new GET /orders/admin/:id behind rid order.list, declared before the :id route because Nest matches in declaration order. Both apps' CartView now gates on session.isLoggedIn rather than a remembered customerId, which is no longer a credential. OTP audit (requested): no master code, no bypass, no hardcoded code anywhere; one code path issues a token and only after bcrypt.compare. No SMS provider is wired up -- in development the code is logged once, guarded by NODE_ENV !== 'production'. Fixed while auditing (SEC-11): deliver() persisted the real message text, code included, into SmsMessage.body, which the admin SMS grid lists and full-text searches. Any operator with the sms rid could read live codes, defeating the hashing of codeHash. The stored row is now a redacted audit line. Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken from the client on order creation. The reference is worse -- it takes rash_weight from the client too -- so this is already a partial improvement; logged rather than changed, per D4. Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body customerId being ignored in favour of the token. Typecheck clean on api, user-app, user-app-v2 and shared-types. [ref: henza_server ApiMobileController.php:533,536-545,615-632] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4efe543d62 |
parity(api,apps): security hotfix stage 2 - real customer sessions, close IDOR
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>
|
||
|
|
fdf833565f |
parity(api,apps): security hotfix stage 1 - close unauthenticated write paths
Authorised by rulings D7 and T1-T3 in docs/parity/DECISIONS.md. This is the CLAUDE.md section 3 carve-out: matching the reference here would keep a money write path open to anonymous callers. Closed, each behind the rid the legacy vocabulary already had: GET accounting/customers/:id/balance -> wallet.list POST accounting/customers/:id/reconcile -> wallet.charge POST accounting/repayments/gold -> document.create POST accounting/repayments/rial -> document.create POST accounting/entries/:id/confirm -> document.list.confirm PATCH orders/:id/status -> order.list.sts.submit Four of the accounting routes WRITE to the ledger. The order-status route was worse in kind: its DTO takes a client-supplied actorAdminUserId, so an anonymous caller could move any order and pin it on any admin. It had no caller - the admin panel posts to admin/orders/status. GET /customers kept its public status (both apps' login still matches the phone number in the browser) but now selects only id/mobile/isActive/ isArchived. It previously returned every active customer's full name, national code, both debts, credit limit and the URLs of their identity-document scans. CustomerSummary narrowed in BOTH user apps in the same commit, per the v1/v2 parity rule. Not changed: /uploads stays public per ruling T2. The filename check requested alongside it passed unchanged - md5(time + name + uuid), no directory listing. Tests: apps/api/scripts/auth-smoke.sh asserts 401 without a token, 403 with a low-privilege token (ut 88, 5 unrelated rids) 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. 23 passed, 0 failed against a live server. Typecheck clean on api, user-app and user-app-v2. [ref: henza_server/routes/admin.php:12,272; ApiMobileController.php:39-78] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
71192ba2ba |
parity(docs): Phase 0 inventory, mapping and draft PROTECTED list
Read-only inventory of all 7 projects (3 reference + 4 target) produced by parallel subagents, every claim cited path:line. No source code changed. - 00-INVENTORY.md consolidated mapping: stack, backend, admin, user apps - PROTECTED.md draft protected list (awaiting user confirmation) - ISSUES.md 26 logged issues (security, money/stock, validation, dead code) - CHANGELOG.md change log scaffold - PROGRESS.md resumable state + Q1-Q5 blocking Phase 1 - _inv-*.md 7 raw per-project inventories (8111 lines) Key finding: the reference is a gold-jewellery B2B/B2C e-commerce system, not a gold bank. Buy/sell, deposit/withdraw, transfer, KYC levels, payment callbacks, cron and websockets do not exist in it, so they cannot be parity gaps. [ref: henza_server, henza_admin, henza_pwa] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |