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>
This commit is contained in:
s.esmaeili
2026-09-22 07:09:33 +03:30
co-authored by Claude Opus 5
parent 4efe543d62
commit 024e3dd045
12 changed files with 394 additions and 43 deletions
+60
View File
@@ -178,6 +178,66 @@ if [ -n "${CODE_LOG:-}" ] && [ -f "$CODE_LOG" ]; then
sed -n 's/.*"mobile":"\([^"]*\)".*/\1/p')
[ "$who" = "$CUST_PHONE" ] && ok "mobile = $who" \
|| bad "mobile = $who (want $CUST_PHONE)"
# ------------------------------------------------ SEC-02: orders ---
# POST /orders was public with customerId in the body, so anyone could
# place an order on anyone's account — and an order lands on that
# customer's gold debt and ledger.
echo "Orders require a customer token:"
c=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/orders" \
-H 'Content-Type: application/json' \
-d '{"customerId":"x","items":[{"productVariantId":"x","quantity":1,"wagePercent":7}]}')
[ "$c" = 401 ] && ok "POST orders without a token -> 401" \
|| bad "POST orders without a token -> $c (want 401)"
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/any-id")
[ "$c" = 401 ] && ok "GET orders/:id without a token -> 401" \
|| bad "GET orders/:id without a token -> $c (want 401)"
# A variant to order, and a customer that is NOT the token's owner.
VARIANT=$(curl -s "$API/catalog/products" |
sed -n 's/.*"variants":\[{"id":"\([^"]*\)".*/\1/p' | head -1)
if [ -n "$VARIANT" ]; then
echo "The body's customerId must be ignored in favour of the token:"
ORD=$(curl -s -X POST "$API/orders" -H "Authorization: Bearer $CTOK" \
-H 'Content-Type: application/json' \
-d "{\"customerId\":\"some-other-customer\",\"items\":[{\"productVariantId\":\"$VARIANT\",\"quantity\":1,\"wagePercent\":7}]}")
OWNER=$(printf '%s' "$ORD" | sed -n 's/.*"customerId":"\([^"]*\)".*/\1/p')
MY_ID=$(curl -s "$API/customer/me" -H "Authorization: Bearer $CTOK" |
sed -n 's/^{"id":"\([^"]*\)".*/\1/p')
[ -n "$OWNER" ] && [ "$OWNER" = "$MY_ID" ] \
&& ok "order landed on the token's customer ($OWNER), not the body's" \
|| bad "order landed on '$OWNER', expected '$MY_ID'"
# orderNumber is unique per order, so it identifies the row we just made
# without having to parse nested item ids out of the response.
ORDNUM=$(printf '%s' "$ORD" | sed -n 's/.*"orderNumber":"\([^"]*\)".*/\1/p')
MYORD=$(curl -s "$API/customer/me/orders" -H "Authorization: Bearer $CTOK" |
tr '{' '\n' | grep -F "\"orderNumber\":\"$ORDNUM\"" |
sed -n 's/^"id":"\([^"]*\)".*/\1/p' | head -1)
if [ -n "$MYORD" ]; then
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/$MYORD" -H "Authorization: Bearer $CTOK")
[ "$c" = 200 ] && ok "own order readable -> 200" || bad "own order -> $c (want 200)"
fi
# Someone else's order and a nonexistent one must be indistinguishable,
# or the status code itself confirms which ids exist.
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/definitely-not-an-order" \
-H "Authorization: Bearer $CTOK")
[ "$c" = 404 ] && ok "unknown order id -> 404" || bad "unknown order id -> $c (want 404)"
echo "Admin order read is admin-only:"
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/admin/any" -H "Authorization: Bearer $CTOK")
[ "$c" = 401 ] && ok "customer token on orders/admin/:id -> 401" \
|| bad "customer token on orders/admin/:id -> $c (want 401)"
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/admin/any" -H "Authorization: Bearer $SUPER")
case "$c" in
401|403) bad "admin token on orders/admin/:id -> $c (guard rejected an admin)" ;;
*) ok "admin token on orders/admin/:id -> $c (through the guard)" ;;
esac
else
printf ' \033[33mSKIP\033[0m no product variant available to order\n'
fi
else
bad "correct code -> $c (want 200)"
fi
@@ -320,14 +320,21 @@ export class AdminOrdersService {
}),
);
const order = await this.orders.createOrder({
customerId: payload.customer_id,
items,
// The admin builder serves the omde (major/*) flow, so orders it creates
// belong to the wholesale channel — legacy wrote them to the `orders`
// table, which MajorOrderController::index listed.
channel: OrderChannel.WHOLESALE,
});
// The customer id is now a separate argument rather than a body field, so
// the customer-facing route can take it from the caller's token (SEC-02).
// This path is different: an operator legitimately places an order FOR a
// customer, so it passes the one the builder selected. The route itself is
// admin-guarded, which is what makes that safe.
const order = await this.orders.createOrder(
{
items,
// The admin builder serves the omde (major/*) flow, so orders it creates
// belong to the wholesale channel — legacy wrote them to the `orders`
// table, which MajorOrderController::index listed.
channel: OrderChannel.WHOLESALE,
},
payload.customer_id,
);
// legacy `sts`: advance the new order to the chosen delivery status.
const target = this.statusFromLegacyCode(payload.sts);
@@ -213,21 +213,31 @@ export class CustomerAuthService {
* (ApiMobileController.php:47).
*/
private async deliver(mobile: string, code: string) {
const body =
// The exact text the reference sent (ApiMobileController.php:47), kept for
// whenever a gateway is wired up. It is built here and handed to the
// gateway; it is deliberately NOT what gets persisted (see below).
const smsText =
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` +
`aMo6iFyUQaY`;
void smsText; // TODO: hand to the SMS provider once one exists (STUB-02)
if (process.env.NODE_ENV !== 'production') {
this.logger.log(`[dev] OTP for ${mobile}: ${code}`);
}
// Recorded the same way the admin panel's own SMS sends are.
// An audit row, NOT the message body.
//
// SmsMessage.body is listed and full-text searchable on the admin panel's
// SMS grid (admin-misc.service.ts:136 allows `body`, :135 searches it), so
// persisting the real text would show every live OTP to any operator
// holding the `sms` rid — and the whole point of hashing codeHash is that
// the code exists nowhere in readable form. The code is redacted here.
await this.prisma.smsMessage.create({
data: {
audience: MessageAudience.SINGLE_NUMBER,
recipients: mobile,
body,
body: 'کد یکبار مصرف ورود (متن پیام ثبت نمی‌شود)',
sentCount: 1,
},
});
@@ -25,8 +25,20 @@ export class CreateOrderItemInputDto {
}
export class CreateOrderDto {
/**
* IGNORED BY THE SERVER. The customer is taken from the caller's token.
*
* It was required, and the route was public, so anyone could place an order
* on any customer's account — which lands on their gold debt and their
* ledger. Logged as SEC-02 in docs/parity/ISSUES.md.
*
* Kept as an optional field only so the two apps, which still send it, do not
* fail validation mid-flight. OrdersService logs a mismatch and always uses
* the token. Removed from the DTO and from both apps in Phase 2.
*/
@IsOptional()
@IsString()
customerId!: string;
customerId?: string;
@IsOptional()
@IsString()
@@ -2,40 +2,59 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
import { Public, RequirePermission } from '../auth/guards';
import { RequirePermission } from '../auth/guards';
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
/**
* Customer-facing order routes.
* Order routes.
*
* POST / and GET /:id stay `@Public()` for now: the target has no customer
* session yet, so the customer id travels in the request body. The reference
* derived it from the `api-customer` guard instead
* (henza_server ApiMobileController.php:533 reads `auth()->id()`), which is what
* stage 2 of the security hotfix restores. Tracked as SEC-02 / B1-B2 in
* docs/parity/ISSUES.md.
*
* PATCH /:id/status was public too, which was worse in kind: it let an
* anonymous caller move any order's status AND attribute the change to any
* admin via the DTO's actorAdminUserId. It is an admin operation in the
* reference, it had no caller in this repo (the admin panel posts to
* admin/orders/status instead), so it is now behind the order-status rid.
* All three were `@Public()` until the security hotfix. The reference's
* customer-facing order endpoints sat behind its `api-customer` guard and read
* the customer from the token (`auth()->id()`,
* henza_server ApiMobileController.php:533); the target had no customer session
* at the time, so the id travelled in the body and the server trusted it.
* That meant an anonymous caller could place an order on anyone's account —
* and an order lands on that customer's gold debt and ledger. Now that the
* customer token exists, the reference's arrangement is restored.
*/
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Public()
/** The customer comes from the token; the body's `customerId` is ignored. */
@CustomerAuth()
@Post()
create(@Body() dto: CreateOrderDto) {
return this.orders.createOrder(dto);
create(@Body() dto: CreateOrderDto, @CurrentCustomer() me: AuthenticatedCustomer) {
return this.orders.createOrder(dto, me.id);
}
@Public()
@Get(':id')
get(@Param('id') id: string) {
/**
* Unscoped read for operators.
*
* Declared BEFORE the `:id` route below: Nest matches in declaration order,
* so a literal segment that could also be read as a parameter has to come
* first or it is never reached.
*/
@Get('admin/:id')
@RequirePermission('order.list')
getAsAdmin(@Param('id') id: string) {
return this.orders.getById(id);
}
/**
* A customer reading one of their own orders. Scoped by token; someone
* else's order is a 404 rather than a 403, so ids stay unenumerable.
*
* Mounted under /orders/:id (not /customer/me/orders/:id) so the existing
* client paths keep working; it is the scoping that matters, not the shape.
*/
@CustomerAuth()
@Get(':id')
get(@Param('id') id: string, @CurrentCustomer() me: AuthenticatedCustomer) {
return this.orders.getOwnedById(id, me.id);
}
@Patch(':id/status')
@RequirePermission('order.list.sts.submit')
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
+39 -5
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CatalogService } from '../catalog/catalog.service';
@@ -17,6 +17,8 @@ interface ComputedItem {
@Injectable()
export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly catalog: CatalogService,
@@ -29,9 +31,24 @@ export class OrdersService {
* mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here —
* only once the order reaches DELIVERED_TO_AGENT (see updateStatus).
*/
async createOrder(dto: CreateOrderDto) {
const customer = await this.prisma.customer.findUnique({ where: { id: dto.customerId } });
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
/**
* @param customerId taken from the caller's token by the controller, NEVER
* from the request body. The body's `customerId` is ignored; see the note
* on CreateOrderDto.
*/
async createOrder(dto: CreateOrderDto, customerId: string) {
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
// The apps still send customerId in the body (removed from them in Phase 2).
// It is not used for anything; a mismatch means either a stale client or an
// attempt to order on someone else's account, and both are worth seeing.
if (dto.customerId && dto.customerId !== customerId) {
this.logger.warn(
`Order body carried customerId=${dto.customerId} but the token is ${customerId}; ` +
`the token wins.`,
);
}
const computedItems: ComputedItem[] = await Promise.all(
dto.items.map(async (item) => {
@@ -78,7 +95,7 @@ export class OrdersService {
const order = await tx.order.create({
data: {
orderNumber: this.generateOrderNumber(),
customerId: dto.customerId,
customerId,
branchId: dto.branchId,
channel: dto.channel ?? OrderChannel.RETAIL,
status: initialStatus,
@@ -138,6 +155,7 @@ export class OrdersService {
});
}
/** Unscoped read. Only reachable from the admin-guarded route. */
getById(orderId: string) {
return this.prisma.order.findUnique({
where: { id: orderId },
@@ -145,6 +163,22 @@ export class OrdersService {
});
}
/**
* A customer reading one of their own orders.
*
* Someone else's order is reported as 404, not 403: a 403 would confirm the
* id exists, which is the same enumeration leak the id-addressed customer
* routes had.
*/
async getOwnedById(orderId: string, customerId: string) {
const order = await this.prisma.order.findFirst({
where: { id: orderId, customerId },
include: { items: true, statusEvents: true, ledgerEntries: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
return order;
}
private generateOrderNumber(): string {
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `ORD-${Date.now()}-${random}`;
+6 -2
View File
@@ -211,14 +211,18 @@ const goOrders = async () => {
const submitOrder = async () => {
cartData.notify = false
if (!session.customerId) {
// Gate on the token, not on a remembered id: the id alone is no longer a
// credential, and POST /orders now requires a customer JWT (SEC-02).
if (!session.isLoggedIn) {
loginNotify.value = true
return
}
try {
cartData.loadingPage = true
const order = await api.createOrder({
customerId: session.customerId,
// Ignored by the server, which takes the customer from the token; still
// sent so older builds keep validating. Removed in Phase 2.
customerId: session.customerId ?? undefined,
items: cart.items.map((item) => {
const variant = cart.variant(item)
return {
+6 -2
View File
@@ -189,14 +189,18 @@ const goOrders = async () => {
const submitOrder = async () => {
cartData.notify = false
if (!session.customerId) {
// Gate on the token, not on a remembered id: the id alone is no longer a
// credential, and POST /orders now requires a customer JWT (SEC-02).
if (!session.isLoggedIn) {
loginNotify.value = true
return
}
try {
cartData.loadingPage = true
const order = await api.createOrder({
customerId: session.customerId,
// Ignored by the server, which takes the customer from the token; still
// sent so older builds keep validating. Removed in Phase 2.
customerId: session.customerId ?? undefined,
items: cart.items.map((item) => {
const variant = cart.variant(item)
return {
+59
View File
@@ -176,3 +176,62 @@ surface entirely.
`pnpm test` works for the first time: **7 passed** covering `faToEn`.
`faToEn` moved to its own `digits.ts` so it can be tested without booting Nest.
- Typecheck clean on api, user-app and user-app-v2; both apps build.
---
## 2026-09-22 — Security hotfix, stage 3 — SEC-02 (orders) + OTP audit
Branch `parity/security-hotfix`. The user declined to defer SEC-02 to Phase 2:
a public `POST /orders` taking `customerId` from the body let anyone place an
order on any customer's account, and an order lands on that customer's **gold
debt and ledger**. That is a money hole, so it is closed here.
### 1. `POST /orders` is customer-authenticated
- **What:** the route is behind `@CustomerAuth()`. `OrdersService.createOrder`
now takes `customerId` as a **separate argument**, supplied by the controller
from the token. The body's `customerId` is ignored entirely; a mismatch is
logged (`OrdersService` warn) and the token always wins.
- `CreateOrderDto.customerId` and the shared `CreateOrderDto` are **optional**,
so both apps keep validating while they still send it. Removed in Phase 2.
- **Reference:** `ApiMobileController::setOrder` read `auth()->id()`
(henza_server:533) behind the `api-customer` guard — this restores that.
- **Admin path unaffected:** `AdminOrdersService` places orders *for* a selected
customer and passes that id explicitly; its route is admin-guarded.
### 2. `GET /orders/:id` is scoped to the owner
- `getOwnedById(orderId, customerId)` matches on both, and a miss is **404, not
403** — a 403 would confirm the id exists, the same enumeration leak the old
`/customers/:id` routes had. A nonexistent id and someone else's id are
indistinguishable.
- Operators use the new `GET /orders/admin/:id` behind rid `order.list`.
Declared **before** `:id` because Nest matches in declaration order.
### 3. Both apps
- `CartView.vue` in **both** apps now gates on `session.isLoggedIn` (the token)
rather than on a remembered `customerId`, which is no longer a credential.
### 4. OTP audit (user request 3)
- **No master code, no bypass, no hardcoded code** anywhere in the API or either
app — verified by grep for `master.?code|bypass|backdoor|1111|0000|1234|skipOtp|DEV_OTP|TEST_OTP`.
Exactly one code path issues a token, and only after `bcrypt.compare`.
- **SMS provider: none.** No gateway is wired up (STUB-02). In development the
code is logged once via `Logger`, guarded by `NODE_ENV !== 'production'`.
- **Fixed while auditing:** `deliver()` persisted the real message text — with
the code in it — into `SmsMessage.body`, which the admin SMS grid both lists
and full-text searches (`admin-misc.service.ts:135-136`). Any operator with
the `sms` rid could read live OTP codes, defeating the point of hashing
`codeHash`. The persisted row is now a redacted audit line; the real text is
built for the future gateway and not stored. The 9 test rows created during
this session were deleted.
### Tests
`apps/api/scripts/auth-smoke.sh` extended again — **53 passed, 0 failed**:
order routes 401 without a token, a spoofed body `customerId` ignored in favour
of the token, own order readable, unknown/foreign order id 404, and the admin
order route rejecting a customer token while admitting an admin one.
Typecheck clean on api, user-app, user-app-v2 and shared-types.
+8 -1
View File
@@ -16,7 +16,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| SEC-01 | Critical | REF `henza_server/routes/admin.php:12,272` | The whole admin route file is **unauthenticated** — the `Route::middleware('auth:api')->group(...)` wrapper is commented out at both ends, leaving all ~215 admin routes public (incl. `wallet/charge`, `users`, `roles`). | Verified by reading both lines. | n/a in reference (read-only). Drives TGT decision below. |
| SEC-02 | Critical | TGT `apps/api/src/modules/accounting/accounting.controller.ts:7`, `orders/orders.controller.ts:7`, `customers/customers.controller.ts:5`, `catalog/catalog.controller.ts:5`, `pricing/pricing.controller.ts:5` | 15 routes carry `@Public()`, including **ledger writes** (`POST accounting/repayments/*`, `entries/:id/confirm`) and `POST /api/orders` + `PATCH /api/orders/:id/status`. The public status DTO accepts a client-supplied `actorAdminUserId`, so a caller can attribute an action to any admin. | `grep -rn "@Public()"`; source comments say "pre-existing unauthenticated endpoint". | Introduce customer-session auth for the app-facing routes; move ledger/status writes behind the admin guard. **[replicated]** from SEC-01. |
| SEC-02 | ~~Critical~~ **FIXED — hotfix stage 3** | TGT `apps/api/src/modules/accounting/accounting.controller.ts:7`, `orders/orders.controller.ts:7`, `customers/customers.controller.ts:5`, `catalog/catalog.controller.ts:5`, `pricing/pricing.controller.ts:5` | 15 routes carry `@Public()`, including **ledger writes** (`POST accounting/repayments/*`, `entries/:id/confirm`) and `POST /api/orders` + `PATCH /api/orders/:id/status`. The public status DTO accepts a client-supplied `actorAdminUserId`, so a caller can attribute an action to any admin. | `grep -rn "@Public()"`; source comments say "pre-existing unauthenticated endpoint". | ✅ All closed across stages 1-3. Ledger + order-status behind admin rids; customer routes behind the customer JWT; `POST /orders` takes the customer from the token and ignores the body. Only `catalog/products`, `pricing/latest`, `home/slider` and `uploads/:filename` remain public, all by design. |
| SEC-03 | ~~Critical~~ **FIXED — hotfix stage 2** | TGT `apps/user-app/src/views/user/LoginView.vue`, `apps/user-app-v2/` same | OTP is **not verified** — any 4-digit code logs the user in. This is a **parity gap**: the reference does verify it (`ApiMobileController.php:51-57`, mismatch → HTTP 422). | `DECISIONS.md` D6 | ✅ Real server-side OTP: bcrypt-hashed code, 2-min expiry, 5 attempts, single use, send throttle, no enumeration. Verified end-to-end by `auth-smoke.sh`. |
| SEC-08 | ~~Critical~~ **FIXED — hotfix stage 2** | TGT `apps/api/src/modules/customers/customers.service.ts:6-23,44-50` + `customers.controller.ts:10` (`@Public()`) | **Full customer book exposed to anonymous callers.** Login downloads `GET /customers` and matches the phone client-side, so the endpoint returns **every** active customer with `fullName`, `mobile`, **`nationalCode`**, `goldDebtGrams`, `fiatDebtRial`, `goldCreditLimitGrams`, and the URLs of **national-ID / business-licence / shop scans** (and `GET uploads/:filename` is public too, so those URLs resolve). `passwordHash` is correctly excluded. Not possible in the reference, where `userVerfiy` returned **one** customer after the code matched. | Read `customers.service.ts:5-23`; `DECISIONS.md` D8 | ✅ Closed. The whole controller is deleted — no route accepts a customer id any more. Reads moved to token-derived `/customer/me*`. Stage 1 had only narrowed the fields, which left the ids and the `:id` routes exposed. |
| SEC-09 | ~~Critical~~ **FIXED — hotfix stage 1** | TGT `apps/api/src/modules/accounting/accounting.controller.ts:7` | `@Public()` on the whole accounting controller exposes **5 routes, 4 of them writes**: reconcile a balance, record a gold repayment, record a rial repayment, confirm a ledger entry. Faithful to the reference (whose admin routes were all unauthenticated) but contradicts the confirmed decision D1. | `DECISIONS.md` §"@Public audit" group A | ✅ Done: rids `wallet.list`, `wallet.charge`, `document.create` ×2, `document.list.confirm`. Verified 401/403/pass-through by `apps/api/scripts/auth-smoke.sh`. |
@@ -90,3 +90,10 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
| STUB-02 | TGT `apps/api` | No OTP, no SMS provider, no payment gateway/callbacks, no cron, no websockets. SMS and notifications are DB-persistence only. |
| STUB-03 | TGT both user apps | All list filtering and "pagination" is **client-side** over one flat `GET /catalog/products`; no server `page`/`limit` params. "Pagination" is a 20-item IntersectionObserver window. |
| STUB-04 | TGT both user apps | Live price is **pull-on-mount only** (`GET /pricing/latest?karatPerMille=750`) — no socket, no SSE, no polling interval. `quotedAt` renders as a Jalali **date only**, so a stale rate looks current. |
## Found during the stage-3 OTP audit
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| SEC-11 | High | TGT `apps/api/src/modules/customer-auth/customer-auth.service.ts` (deliver) | The OTP message text, **code included**, was persisted to `SmsMessage.body`, which the admin SMS grid lists and full-text searches (`admin-misc.service.ts:135-136`). Any operator holding the `sms` rid could read live codes — defeating the bcrypt hashing of `codeHash`. | Read both files; grid `allowed` includes `body`. | ✅ Fixed in stage 3: the persisted row is a redacted audit line. |
| MON-08 | Medium | TGT `apps/api/src/modules/orders/orders.service.ts:40-52` | `wagePercent`, `discountPercent` and `flatRialCharge` are taken from the **client** on order creation, so a caller can set their own making charge. `rashWeightGrams` is correctly read from the variant. **The reference is worse** — `ApiMobileController.php:538-539,619` takes `rash_weight` from the client too — so this is a partial improvement already. Reported per the user's instruction to flag client-supplied pricing. | REF `:536-545,615-632` vs TGT `:40-52` | Log only (D4). Fix in Phase 2 alongside the price-formula work. |
+127
View File
@@ -0,0 +1,127 @@
# Migration state — investigated before writing D0 / percent
Requested by the user before any new migration is written. All findings are from
**read-only** queries; nothing was reset and `_prisma_migrations` was not touched.
## The five questions
### 1. Is `20260917120000_customer_name_parts_and_percent` recorded as applied?
**Yes — but as a no-op.**
```
migration_name 20260917120000_customer_name_parts_and_percent
finished_at 2026-09-18T16:03:33.956Z
rolled_back_at null
applied_steps_count 0 <-- zero steps ran
```
`finished_at` is set and `rolled_back_at` is null, so **Prisma considers it
applied and will never re-run it**. `applied_steps_count = 0` says no SQL
statement was ever executed by it.
### 2. What is in its SQL file?
**Comments only — no executable statement.** The file is a placeholder that
explains itself: it says the columns were added "directly against the live DB"
and that it was recreated as an empty no-op so `prisma migrate status` would
stop erroring on a missing directory.
### 3. Does `schema.prisma` declare `firstName` / `lastName` / `productPercent`?
**No.** `grep` over `prisma/schema.prisma` finds none of the three. The
`Customer` model has `fullName` and nothing else name-related.
### 4. Do the columns exist in the database?
**No.** Querying `information_schema.columns` for `Customer` and those names
returns only `fullName`:
```
firstName absent
lastName absent
productPercent absent
makingChargeOffsetPercent absent
fullName present
```
**So the placeholder file's own comment is wrong.** It claims the columns "already
exist live"; they do not, at least not in this database (`gold_banking` on
localhost). Whatever database that comment described, it was not this one.
**Consequence for the runtime:** none, and this is the good news. Prisma Client
generates from `schema.prisma`, and the schema does not mention these fields
either. Schema and database agree — both lack the columns. There is **no drift
that could make Prisma Client throw at runtime**. The only thing that is wrong is
the placeholder's comment.
### 5. `prisma migrate status` (read-only)
```
9 migrations found in prisma/migrations
Following migration have not yet been applied:
20260922000000_customer_otp
```
Note on that line: the OTP table **does exist** — stage 2 created it with
`prisma db execute`, which runs SQL without writing a ledger row. So the schema
is correct but the ledger entry is missing, and `migrate deploy` would try to
run it again. The SQL is written with `CREATE TABLE IF NOT EXISTS`, the indexes
with `IF NOT EXISTS`, and the FK inside a `DO $$ ... EXCEPTION WHEN
duplicate_object` block, so a re-run is harmless. Still worth resolving cleanly —
see the recommendation.
### 6. Where did the file come from?
```
b80fa9b Redesign product detail page and fix icon/cart reactivity bugs
```
A single commit, and the message has nothing to do with migrations — the
placeholder was swept in as collateral to an unrelated frontend change.
---
## Recommendation
**The placeholder is recorded as applied, so per the user's rule it must not be
edited.** It stays exactly as it is.
1. **Leave `20260917120000_...` untouched.** Editing a migration Prisma has
recorded would make its checksum mismatch and break `migrate status` for
everyone. Its only sin is a misleading comment, and a follow-up migration
makes that harmless.
2. **Write the D0 and `percent` work as a new, additive migration** with a fresh
timestamp. It adds the columns the placeholder never added. Written to be
idempotent (`IF NOT EXISTS`) so it is safe on a database where someone *did*
apply the columns by hand.
3. **Resolve the OTP ledger entry** with
`npx prisma migrate resolve --applied 20260922000000_customer_otp`.
That writes the ledger row without running SQL, which is exactly right here
since the table already exists. **Needs the user's approval before running**,
as it writes to `_prisma_migrations`.
4. **Never** `migrate reset`, and never edit `_prisma_migrations` by hand.
---
## Naming: one field, not two
The user asked for a single final name. **`productPercent` is dropped;
`makingChargeOffsetPercent` is the name.**
Reason: the reference has **three unrelated things** all called `percent`, and a
vague name is how they got confused in the first place (the target currently
reads the wrong one — CUST-02):
| Reference `percent` | Where | What it is |
|---|---|---|
| `customers.percent` | `customer_add.php:32` | a **per-customer making-charge offset**, used only to shift catalogue filter bounds (`ApiMobileController.php:391-396,76-77`) |
| `ConfigStatic['percent']` | `AdminController.php:903,994` | the **wallet cashback rate**: `total_weight × percent/100` |
| `order_item.percent` | `MajorOrderController.php:220` | the **per-line making charge** on a wholesale order |
`productPercent` would read as "a percent belonging to a product", which is the
third meaning, not the first. `makingChargeOffsetPercent` says which of the
three it is. It is mapped back to the legacy key `percent` in `toRow()`, so the
admin panel's field name does not change.
`productPercent` is not created, so there is no duplicate field.
+9 -1
View File
@@ -64,7 +64,15 @@ export interface CreateOrderItemDto {
}
export interface CreateOrderDto {
customerId: string;
/**
* Ignored by the server, which takes the customer from the caller's token.
*
* It used to be required, on a public route, so anyone could place an order
* on any customer's account — landing on their gold debt and their ledger
* (SEC-02 in docs/parity/ISSUES.md). Optional here only so the two apps,
* which still send it, keep type-checking; both drop it in Phase 2.
*/
customerId?: string;
branchId?: string;
items: CreateOrderItemDto[];
}