Files
tehran_taj/docs/parity/CHANGELOG.md
T
s.esmaeiliandClaude Opus 5 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>
2026-09-22 00:43:11 +03:30

4.2 KiB
Raw Blame History

CHANGELOG — parity changes

Every change made to gold-banking-monorepo during the parity project, with the reason and the reference path that justifies it.

Format:

## <date> — <phase> — <group title>
- **What:** <the change>
- **Why:** <the parity gap it closes>
- **Reference:** <henza_* path:line>
- **Target:** <gold-banking-monorepo path:line>
- **Matrix row:** <id in 01-ADMIN-MATRIX.md / 02-APPS-MATRIX.md>
- **Commit:** <sha>

2026-09-21 — Phase 0 — Setup

  • What: Created branch parity/phase-0 and docs/parity/ scaffolding (PROGRESS.md, ISSUES.md, CHANGELOG.md).
  • Why: Persistent state required by CLAUDE.md §6.
  • Reference: n/a
  • Target: docs/parity/
  • Matrix row: n/a

No source code changed in Phase 0 (read-only phase).


2026-09-22 — Security hotfix, stage 1 — close the unauthenticated write paths

Branch parity/security-hotfix. Authorised by the user's rulings D7, T1–T3.

1. Ledger routes are admin-only (SEC-09)

  • What: removed the controller-level @Public() from AccountingController and gave each route a rid: wallet.list (balance), wallet.charge (reconcile), document.create (both repayments), document.list.confirm (confirm entry).
  • Why: four of the five routes WRITE to the ledger and were reachable with no token at all. Faithful to the reference, whose entire admin route file is unauthenticated, but contrary to confirmed decision D1.
  • Reference: henza_server/routes/admin.php:12,272 (the auth:api wrapper is commented out at both ends)
  • Target: apps/api/src/modules/accounting/accounting.controller.ts
  • Breakage check: api.getBalance() is the only app-side consumer and it has zero call sites in either user app (dead code, already logged as DEAD-01/02).

2. Order status change is admin-only (SEC-10)

  • What: moved @Public() from the class to the two customer-facing handlers, leaving PATCH orders/:id/status behind order.list.sts.submit.
  • Why: it let an anonymous caller move any order's status and attribute the change to any admin through the DTO's actorAdminUserId.
  • Reference: an admin operation; the admin panel posts to admin/orders/status (apps/admin-panel/src/api/endpoints.ts:42,48), so this route had no caller.
  • Target: apps/api/src/modules/orders/orders.controller.ts
  • Still public: POST /orders and GET /orders/:id, because the target has no customer session yet — closed in stage 2.

3. GET /customers no longer leaks the customer book (SEC-08)

  • What: added LOGIN_LOOKUP_SELECT (id, mobile, isActive, isArchived) and pointed list() at it. Removed fullName, nationalCode, goldDebtGrams, fiatDebtRial, goldCreditLimitGrams, verificationStep, cityId, referredByAgentId, nationalIdImageUrl, businessLicenseUrl, shopImageUrl, createdAt, updatedAt from that response.
  • Why: the route is unauthenticated because both apps match the phone number in the browser, so it shipped every customer's national ID number, debts, credit limit and identity-document URLs to any visitor.
  • Reference: login was POST userAuth → SMS → POST userVerfiy, returning exactly one customer after the code matched (henza_server/app/Http/Controllers/Api/ApiMobileController.php:39-78).
  • Target: apps/api/src/modules/customers/customers.service.ts, plus CustomerSummary narrowed in both apps' src/api/client.ts (parity rule: the same logic change in both).
  • Route survives until stage 2 replaces the client-side phone match with OTP.

4. Test script

  • What: added apps/api/scripts/auth-smoke.sh — asserts 401 without a token, 403 with a low-privilege token, 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.
  • Result: 23 passed, 0 failed against a live server and the dev seed.

Not changed

/uploads — per ruling T2, uploaded files including identity documents stay public. The filename check requested alongside it passed with no changes needed (md5 of time + name + UUID; no directory listing).