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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f71fd5bfe1
commit
5ad1083aee
@@ -1255,3 +1255,49 @@ If one mobile ever belonged to both an expert and a customer, the owner ruled
|
||||
the **expert wins**. Since `Customer.mobile` is unique, the code adopts the
|
||||
existing customer row as the expert's trading account rather than failing the
|
||||
login or creating a duplicate, and logs a warning.
|
||||
|
||||
---
|
||||
|
||||
## D22 — «تکمیل پروفایل» (complete_profile), 2026-09-23
|
||||
|
||||
Ports `POST /major/complete`
|
||||
(henza_server `Major/CustomerController::complete`, reached from
|
||||
`henza_pwa/src/views/user/src/completeProfile.vue`) as
|
||||
`POST customer/me/complete-profile`.
|
||||
|
||||
**Reference behaviour kept**
|
||||
- all three document ids required; a miss is rejected before anything is written
|
||||
- the customer comes from the session, never from the body (D21)
|
||||
- the verification step is set to the reference's **live** value 2 — approved
|
||||
outright, not "waiting for an expert". The reference's own
|
||||
`sts_verify_step_two = 1` line is commented out at `:83` in favour of `= 2`
|
||||
at `:85`. That is `VerificationStep.VERIFIED` here.
|
||||
- both SMS messages byte-identical, and non-blocking per D20
|
||||
- the entry point is the **profile screen**, shown only while the documents are
|
||||
missing (`profile.vue:96-102`), and the route redirects home once they are in
|
||||
(`router/index.ts:266`)
|
||||
|
||||
**Reference behaviour 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. They come
|
||||
from `PROFILE_ADMIN_MOBILES` instead; blank means nobody is notified.
|
||||
- **APP-03** — `refresh()`'s unterminated `setInterval` page reload.
|
||||
|
||||
**One gap had to be closed first (UPL-01).** The reference let the app upload
|
||||
through the same `file/create` the admin used, inside its `auth:api-customer`
|
||||
group; the target's copy is admin-guarded. Added `POST customer/me/documents`,
|
||||
reusing `AdminUploadsService` unchanged so both paths share one size limit,
|
||||
MIME whitelist and naming scheme.
|
||||
|
||||
## D23 — `JWT_CUSTOMER_SECRET` warns loudly, 2026-09-23
|
||||
|
||||
`customerJwtSecret()` falls back to `JWT_SECRET`. When it does, app and admin
|
||||
tokens are signed with the **same key**, leaving the `typ` check in
|
||||
`jwt.strategy.ts` as the only barrier — and an expert's app token carries their
|
||||
AdminUser id in `sub`. The owner asked for a refusal to boot **or** a very
|
||||
clear warning; the lower-risk option was chosen (D19), since failing the boot
|
||||
could take down a running deployment that has not set the variable yet. A
|
||||
banner as loud as the master-OTP one now fires at boot, and DELIVERY.md lists
|
||||
it as the **first** pre-release step (REL-03).
|
||||
|
||||
@@ -146,3 +146,13 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|
||||
| **APP-02** | Medium | TGT `apps/user-app-v2/src/api/client.ts` (no `getHomeSlider`) vs `apps/user-app/src/api/client.ts:228` | `user-app` calls `GET /home/slider` and prefers admin-configured slides, falling back to category-derived ones (`HomeView.vue:258`). `user-app-v2` has no such call at all and always derives its hero from categories, so **slides configured in the admin panel never appear in v2**. | `grep getHomeSlider` finds it only in user-app | Add `getHomeSlider` to v2's client and let `HeroCarousel` prefer admin slides. Deliberately deferred: v2's home is its own design (PROTECTED) and rewiring its hero during a delivery risks the page being demonstrated. |
|
||||
| **DEPLOY-01** | ~~High~~ **FIXED** | TGT `docker-compose.yml` | The `api` service passed only `DATABASE_URL` and `PORT`, so **every auth and SMS variable was silently ignored in Docker** — `JWT_SECRET` would fall back to the development default, and `OTP_MASTER_CODE` would never reach the container, so the delivery login would not have worked in a deployed environment. | `docker compose config` before/after | ✅ All auth, SMS and master-OTP variables are now passed through, each defaulting to empty (which the code treats as "unset"). |
|
||||
| **TEST-01** | Low (fixed) | TGT `apps/api/scripts/test-sales-expert-roundtrip.cjs:10` | The script had `localhost:3077` hardcoded — a port from an earlier dev session — so it could only ever connect by accident. It failed with ECONNREFUSED on this branch. | Run against the standard port | ✅ Now defaults to `localhost:3000` (`API_PORT`) and accepts an override argument. Re-ran: 33/33. |
|
||||
|
||||
## complete_profile — found while porting the page (2026-09-23)
|
||||
|
||||
| ID | Severity | Where | Description | Evidence | Suggested fix |
|
||||
|---|---|---|---|---|---|
|
||||
| **REL-03** | **Critical — before the real release** | TGT deployment env; `customer-auth.service.ts` `customerJwtSecret()` | **`JWT_CUSTOMER_SECRET` must be set to its own value.** Unset, it falls back to `JWT_SECRET`, so customer/sales-expert **app** tokens are signed with the same key as **admin** tokens and the two realms stop being cryptographically separate. The only remaining defence is the `typ` check in `jwt.strategy.ts` — and an expert's app token carries their **AdminUser id** in `sub`, which is exactly what that check exists to stop. Two independent defences collapse to one. | `customerJwtSecret()` `?? process.env.JWT_SECRET`; `jwt.strategy.ts` `APP_TOKEN_TYPES` | Set `JWT_CUSTOMER_SECRET` to a distinct random value. A loud boot banner now fires while it is missing (`main.ts`), and DELIVERY.md lists it first. |
|
||||
| **SEC-14** | High (reference bug, NOT ported) | REF `Major/CustomerController.php:87` | `complete()` did `$customer->update($request->all())` — it mass-assigned **the entire request body** onto the customer row. The app could therefore set its own `gram_level` (credit limit), `percent` (making charge), `remain` (gold debt), `active` or even `mobile` by adding fields to the profile-completion POST. A privilege-escalation and money hole reachable from any signed-in customer. | `$input = $request->all(); ... $customer->update($input);` | **Not ported.** The target reads a three-field whitelist (`CompleteProfileDto`) and writes only the three document URLs plus the verification step. Asserted by `test-complete-profile.cjs` §7, which posts credit-limit/debt/mobile overrides and checks the row is unchanged. |
|
||||
| **SEC-15** | Low (reference bug, NOT ported) | REF `Major/CustomerController.php:110-112` | `sendSmsToAdmin()` hardcoded four real personal mobile numbers in the source. | `$admin_mobile = ['0990…','0913…','0912…','0919…'];` | **Not ported.** The target reads `PROFILE_ADMIN_MOBILES` (comma-separated, blank = notify nobody), so no personal number is committed to this repository. |
|
||||
| **APP-03** | Low (reference bug, NOT ported) | REF `completeProfile.vue:175-181` | `refresh()` called `window.setInterval(() => window.location.reload(), 1000)` — an **unterminated** timer that reloaded the whole page every second forever, not a one-off refresh. | `completeProfile.vue:178-180` | **Not ported.** The target navigates home once after the success dialog. |
|
||||
| **UPL-01** | Medium (gap, fixed) | TGT `admin-uploads.controller.ts` | The reference let the app upload documents through the same `file/create` the admin panel used, inside its `auth:api-customer` group (`routes/api.php:95`). The target's copy of that route is **admin-guarded**, which no app token can pass — so without a customer-facing upload route, complete_profile could not have worked at all. | `AdminUploadsController` has no `@CustomerAuth()` route | ✅ Added `POST customer/me/documents`, reusing `AdminUploadsService` unchanged so the size limit, MIME whitelist and stored-name scheme are identical on both paths. The uploader is recorded as `null` rather than as an admin, since that column is an AdminUser FK. |
|
||||
|
||||
Reference in New Issue
Block a user