cf014e1f6c6d1b7cf58ef8b3c5788f1d005014bd
18
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cf014e1f6c |
chore: commit pending admin-panel, api auth and parity doc changes
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
19d490e790 |
feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
24b7a4c395 |
fix(api): treat blank env vars as unset; verify the whole flow in Docker Compose
Found by running the delivery flow against the actual built container rather
than a local dev process.
DEPLOY-02 (Critical) — ADMIN LOGIN RETURNED 500 FOR EVERY USER IN DOCKER.
docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured
one arrives as "" rather than undefined. The code read them with
`process.env.X ?? 'default'`, which only falls back on undefined, so the empty
string reached jsonwebtoken and it threw
"expiresIn" should be a number of seconds or string representing a timespan
on every sign. Invisible to every existing suite, all of which run against a
local process where the variable is genuinely absent.
Added src/common/env.ts (envOr / envOrNull), which treat blank as unset, and
applied it to every auth, port, upload and SMS read. Three were not cosmetic:
a blank UPLOAD_DIR would have served static files from the FILESYSTEM ROOT, a
blank PORT would have bound a random ephemeral port (Number('') === 0), and a
blank JWT_SECRET would have become the verifying key. Recorded as D25, with a
standing rule to use these readers instead of `??`.
Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw
against the real jsonwebtoken and asserts the reader can no longer produce it.
DEPLOY-03 (process, documented) — the API image is `COPY release/ ./`, so it
ships a bundle pre-built on the developer machine. `docker compose build` will
happily produce an image from a STALE release/app.js and report success; at the
start of this run that bundle was four days old and the rebuilt container was
missing every route added since. DELIVERY.md's deploy section now leads with
`pnpm build:release`.
New: scripts/test-compose-e2e.cjs — the whole delivery flow against the built
container, over HTTP only, as a black box: a sales expert and an ordinary
customer both sign in with the master code, both place an order, both orders
appear in the admin grid and open there, and the three token boundaries still
hold in the deployed image. 32/32.
Verified: compose e2e 32/32 · jest 54/54 · build 5/5 ·
against the CONTAINER: complete-profile 38/38, report 40/40, expert login 36/36 ·
locally: apps smoke 43/43, admin smoke 43/43, B2B scan 34 ok/0 broken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3f549f1749 |
parity(apps+api): report + report/detail on the internal ledger [ref: henza_pwa views/report]
Ports "گزارش" end to end (D24), the last missing app page.
In the reference this feature computes nothing: Customer\TahesabController
proxies an external accounting system (hesab.henzagold.com and a bare IP at
31.7.68.147:3030) with TLS verification disabled, a bearer token hardcoded in
the source and a URL-embedded secret, keyed on Customer.mcode. The target has
no such system and no mcode, so per the owner's option الف this is built on our
own ledger with no external connection.
API — customer/me/report{,/detail,/balances,/:id}
- one row per CONFIRMED ledger entry with the running gold and rial balance
after it, newest first as the reference displayed it
- only confirmed entries count, the same rule the cached
Customer.goldDebtGrams/fiatDebtRial follow, so the statement's closing
balance agrees with the figure shown everywhere else in the app
- fields the target has no source for (foreign currency, نام کارساخته,
جنس فلز, the external system's internal ids) are null/zero and the UI OMITS
the row rather than rendering a fabricated value (REP-01)
SEC-16, NOT ported: the reference's detail($id) took the factor code from the
URL and performed no ownership check at all — it never consults auth() — so any
signed-in customer could read any other customer's document by guessing a code.
Here the id is only a filter on top of a customerId scope from the token.
Tested in both directions.
Apps (both, template+script byte-identical, per-app styling only)
- ReportView with the reference's two tabs, its card rows and its «جزئیات»
modal; ReportDocument for one document
- routes /report and /report/detail, the latter opening the second tab
- dashboard entry (v1 tile, v2 row), shown to every signed-in customer since
the reference's hasMCode() gate has no target equivalent
Verified: build 5/5 · jest 45/45 · report 40/40 (new) ·
complete-profile 38/38 · apps smoke 43/43 · typecheck clean on all four apps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
5ad1083aee |
parity(apps+api): complete_profile in both apps [ref: henza_server Major/CustomerController::complete]
Ports "تکمیل پروفایل" end to end (D22). API - POST customer/me/complete-profile — three required document ids, the customer taken from the token, verification step set to the reference's LIVE value (approved outright; its `= 1` line is commented out at :83 in favour of `= 2` at :85). Both SMS messages byte-identical and non-blocking per D20. - POST customer/me/documents — the reference let the app upload through the same file/create the admin used, inside its auth:api-customer group (routes/api.php:95); the target's copy is admin-guarded, so the page could not have uploaded at all. Reuses AdminUploadsService unchanged, so both paths share one size limit, MIME whitelist and naming scheme (UPL-01). - PROFILE_ADMIN_MOBILES replaces the reference's four hardcoded personal numbers; blank means nobody is notified and the upload still succeeds. Three reference behaviours deliberately NOT ported, all in ISSUES.md: - SEC-14: `$customer->update($request->all())` mass-assigned the whole request body, letting the app write its own credit limit, making charge, gold debt, active flag or mobile. Replaced with a three-field whitelist. - SEC-15: four real personal mobiles hardcoded in the source. - APP-03: refresh()'s unterminated setInterval page reload. Apps (both, template+script byte-identical, per-app styling only) - CompleteProfileView + SingleImageUploader - route /complete_profile, redirecting home once the documents are in, as the reference's guard did (router/index.ts:266) - the entry point on the profile screen, shown only while they are missing (profile.vue:96-102) Also (D23): JWT_CUSTOMER_SECRET now warns loudly at boot when unset. It falls back to JWT_SECRET, which signs app and admin tokens with the same key and leaves the `typ` check as the only barrier — and an expert's app token carries their AdminUser id in `sub`. Warn rather than refuse to boot (D19, lowest risk); DELIVERY.md lists it as the first pre-release step (REL-03). Verified: build 5/5 · jest 45/45 · complete-profile 38/38 (new) · expert login 36/36 · apps smoke 43/43 · admin smoke 43/43 · B2B scan 34 ok/0 broken · typecheck clean on all four apps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
69f4ba2b29 |
feat(api): sales-expert login to the customer apps (D21)
A sales expert can now sign into the apps with their AdminUser account, without reversing D17 — the expert remains an AdminUser and is never turned back into a Customer. The app token carries a `typ` of 'customer' or 'sales-expert'; the strategy resolves both to one principal whose `id` is always a Customer id, so every route already written for a customer works unchanged for an expert. That is what the reference gave them, where an agent was a customer with extra screens. Each expert gets ONE linked trading Customer row (additive migration, dry-run clean and rolled back before applying) created at first sign-in, seeded from the two numbers the reference read off the agent's own row: SalesExpertProfile .gramLevel -> goldCreditLimitGrams and AdminUser.wagePercent -> makingChargeOffsetPercent. Orders the expert places land there, exactly as the reference's single row did. New expert-only routes scope on the AdminUser id from the token: POST expert/child-orders (legacy /order/child, ApiMobileController:759, keeping its sts=5 filter as DELIVERED_TO_AGENT), POST expert/my-customers (legacy /my-child, :184) and GET expert/whoami so the apps can hide the entries. Security: jwt.strategy now rejects any token carrying an app `typ`. An expert's app token holds their AdminUser id in `sub`, and JWT_CUSTOMER_SECRET falls back to JWT_SECRET when unset — in that configuration the token would otherwise have validated as an admin with full panel permissions. NOT ported: myChild minted an api_token per listed customer (:197-201), letting an agent act AS that customer. Recorded in D21 rather than silently dropped. Tests: new test-sales-expert-app-login.cjs 36/36 — expert sign-in, trading account seeding, own-customers-only (with both negatives), child orders, the expert ordering for themselves, an ordinary customer refused both screens with their own app unaffected, and all four token boundaries including immediate lockout on deactivation. jest 45/45, admin smoke 43/43, apps smoke 43/43, B2B scan 34/34, B2B writes 25/25, auth-smoke 32/32. [ref: henza_server/app/Http/Controllers/Api/ApiMobileController.php:184-195,534-535,759-781] 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> |
||
|
|
654b98520d |
parity(api): declare _goldbank_migrations as an ignored model; drift now zero
Prisma migrate diff proposed DROP TABLE "_goldbank_migrations" because the table exists in the database but was not declared in the schema. Declaring it with @@ignore removes the proposal without exposing it to Prisma Client, and the table and all its rows are kept. Correcting the earlier DEAD-07 note: this is not a leftover from the pre-Prisma port. It is the tracking table of the application's own migration runner (src/prisma/migrate.ts:5), which exists because the Docker deploy image is copy-only and ships no Prisma CLI, so migrate deploy cannot run in the container (migrate.ts:7-13). The project therefore keeps two ledgers deliberately: _prisma_migrations for the CLI in development, and _goldbank_migrations for the runtime runner in the container. That runner already filtered out any DROP of its own table (migrate.ts:190-196); the schema declaration removes the cause rather than relying on that guard. The practical consequence, recorded in DECISIONS.md D15: a new migration must be safe under both paths, so its SQL stays idempotent. The ledger grew 12 -> 14 rows while testing, with nothing deleted: starting the API ran prepareDatabase(), which appended the two migrations applied earlier today. Its $$-aware statement splitter handled the PL/pgSQL trigger body correctly — trigger and function are both present and the 9 trigger assertions pass against the live database. Also records the UTF-8 testing rule: Persian test input must come from a file, never a shell argument, because the Windows shell replaces Persian characters with '?' and a test can then pass while storing '????'. Verified: migrate diff -> "This is an empty migration" (zero drift); migrate status -> up to date; tsc clean; 32 jest; 9 trigger assertions. 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> |
||
|
|
c71574b1d2 |
parity(docs): record D13 — retail storefront preserved but out of scope
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>
|
||
|
|
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> |
||
|
|
004a3945c2 |
parity(docs): record user decisions D0-D12 and urgent investigation results
Phase 0 gate passed. All rulings recorded in docs/parity/DECISIONS.md and applied to PROTECTED.md, PROGRESS.md and ISSUES.md. No source code changed. Urgent investigations requested by the user, all evidence-cited: - percent trace: the target's implementation is WRONG. `percent` is the customer's OWN column in the reference (customer_add.php:32), a per-customer making-charge offset used to shift catalogue filter bounds (ApiMobileController.php:391-396,76-77). The target reads the referring agent's wagePercent instead (admin-customers.service.ts:245) and never writes the field at all. It is NOT part of the price formula -- that extra percent comes from a global ConfigStatic row (ProductVariant.php:50,183). Two unrelated `percent` keys exist and must not be confused with it. - @Public audit: 15 routes across 7 controllers, grouped by risk. Group A is 5 ledger routes (4 writes) that contradict decision D1; group B is 3 order routes incl. a public status change accepting a client-supplied actorAdminUserId; group C is 8 reads, of which only the catalogue, pricing, slider and uploads are legitimately public. - Login leak: it is in the TARGET. GET /customers is public and returns every active customer with national ID numbers, debts and ID-document URLs. passwordHash is correctly excluded. Not possible in the reference. Also logged 5 customer-module defects found in the deep check the user asked for (CUST-01..05), including the fullName collapse the user reported. Skill updated per D12: Step C gains rule C0 (field-by-field matrix, never page-by-page) and Step E gains rule E0 (mandatory 4-step round-trip test). [ref: henza_server, henza_admin] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |