docs: align flow docs with code reality + create 35 implementation issue files
Flow docs updated (11 files): - Delivery Confirmation: reversed actor roles (buyer generates, seller verifies), fixed endpoint paths (/delivery-code/generate, /delivery-code/verify) - Passkey (WebAuthn): removed stub/simulated-key claims; real @simplewebauthn/server attestation is implemented; refresh tokens are persisted - Dispute: corrected resolve schema (action enum), removed non-existent statuses, documented security gaps (no role guards on status/resolve/assign), route shadowing, all socket events are TODO stubs - Seller Offer: corrected all endpoint paths, removed 'active' status, documented withdraw dead code, missing seller history page, select-offer notification gap - Notification: corrected mark-all-read method+path, fixed GET /:id broken lookup, added unread-count-update socket event - Authentication: corrected rate limiter (counts all attempts), axios 403 not handled, deleteAccount wrong endpoint bug, changePassword no UI - Password Reset: corrected 6-digit code (not 8), documented no-complexity gap on reset-with-code vs token reset - Payment Flow DePay: /create→/save, removed phantom sub-routes, SIM_ bypass risk, PaymentProvider type gap, getProviderIntentEndpoint routing bug - Payment Flow SHKeeper: removed phantom polling endpoint, fixed release/refund paths - Purchase Request: added pending_payment/active statuses, fixed sellers/attachments endpoints, corrected socket events, PUT→PATCH bug - Escrow: documented dispute resolve does not touch escrow, route shadowing, confirm-delivery auth gap Issues created (35 files in Issues/): - 9 security issues (critical) including: dispute privilege escalation ×4, unauthenticated payment/scanner endpoints ×2, SIM_ production bypass, confirm-delivery ownership gap - 26 additional major/critical bugs covering broken endpoints, missing features, data integrity gaps, and frontend-backend mismatches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,9 @@ related_models: ["[[User]]", "[[TempVerification]]"]
|
|||||||
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"]
|
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> [!caution] Audit note — last reviewed 2026-05-29
|
||||||
|
> Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches.
|
||||||
|
|
||||||
# Authentication Flow
|
# Authentication Flow
|
||||||
|
|
||||||
End-to-end specification for **email + password** authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription.
|
End-to-end specification for **email + password** authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription.
|
||||||
@@ -32,7 +35,7 @@ End-to-end specification for **email + password** authentication, JWT issuance,
|
|||||||
2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error.
|
2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error.
|
||||||
3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout.
|
3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout.
|
||||||
4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`.
|
4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`.
|
||||||
5. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). Five failures within 15 minutes returns `429 TOO_MANY_ATTEMPTS`. Counters live in Redis so they survive restarts.
|
5. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). The counter is incremented on **every login attempt** (before password comparison), not only on failures. Once 5 total attempts accumulate within a 15-minute window, the endpoint returns `429 TOO_MANY_ATTEMPTS`. The counter is reset upon a fully successful login (step 9). Counters live in Redis so they survive restarts.
|
||||||
6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")` — `password` is `select: false` by default in the schema and must be explicitly projected.
|
6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")` — `password` is `select: false` by default in the schema and must be explicitly projected.
|
||||||
7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design.
|
7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design.
|
||||||
8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`.
|
8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`.
|
||||||
@@ -49,7 +52,7 @@ End-to-end specification for **email + password** authentication, JWT issuance,
|
|||||||
> [!warning] Token storage is `localStorage`, not cookies
|
> [!warning] Token storage is `localStorage`, not cookies
|
||||||
> Tokens are persisted in `window.localStorage`. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via `helmet`, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are **no httpOnly auth cookies**.
|
> Tokens are persisted in `window.localStorage`. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via `helmet`, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are **no httpOnly auth cookies**.
|
||||||
|
|
||||||
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request and, on `401/403`, automatically calls the refresh flow described below.
|
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request. On a `401` response, the interceptor automatically triggers the refresh flow described below. A `403` response (e.g., `EMAIL_NOT_VERIFIED`) is **not** retried via refresh — it is surfaced directly to the caller.
|
||||||
17. **Socket.IO bootstrap**: After login, the dashboard layout connects to Socket.IO and emits `join-user-room`, `join-buyer-room`/`join-seller-room` based on `user.role`. See `backend/src/app.ts:83-126`.
|
17. **Socket.IO bootstrap**: After login, the dashboard layout connects to Socket.IO and emits `join-user-room`, `join-buyer-room`/`join-seller-room` based on `user.role`. See `backend/src/app.ts:83-126`.
|
||||||
|
|
||||||
## Sequence diagram
|
## Sequence diagram
|
||||||
@@ -129,19 +132,25 @@ High-risk actions are unchanged: escrow release, refund, dispute-sensitive, and
|
|||||||
## Side effects
|
## Side effects
|
||||||
|
|
||||||
- **Redis session**: 24-hour key holding `{ userId, email, role, ip, userAgent }`. Used for forced logout (admin can call `sessionService.deleteSession(token)`).
|
- **Redis session**: 24-hour key holding `{ userId, email, role, ip, userAgent }`. Used for forced logout (admin can call `sessionService.deleteSession(token)`).
|
||||||
- **Redis rate-limit counter**: TTL 15 min, reset on success.
|
- **Redis rate-limit counter**: TTL 15 min, reset on success. Counter increments on every attempt regardless of outcome.
|
||||||
- **No email** is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement).
|
- **No email** is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement).
|
||||||
- **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`).
|
- **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`).
|
||||||
|
|
||||||
## Refresh-token flow
|
## Refresh-token flow
|
||||||
|
|
||||||
The access token is short-lived. When a protected request returns `401 TOKEN_INVALID` or `403`, the axios interceptor calls:
|
The access token is short-lived. When a protected request returns `401 TOKEN_INVALID`, the axios interceptor calls:
|
||||||
|
|
||||||
1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`.
|
1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`.
|
||||||
2. Backend `authController.refreshToken` (`:263-313`) verifies the token via `verifyRefreshToken`, checks it is **still present in `user.refreshTokens[]`**, then issues a brand-new access **and** refresh token.
|
2. Backend `authController.refreshToken` (`:263-313`) verifies the token via `verifyRefreshToken`, checks it is **still present in `user.refreshTokens[]`**, then issues a brand-new access **and** refresh token.
|
||||||
3. The old refresh token is **removed** from the array and the new one is pushed — implementing **refresh-token rotation**. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes.
|
3. The old refresh token is **removed** from the array and the new one is pushed — implementing **refresh-token rotation**. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes.
|
||||||
4. The new pair is written back to `localStorage` and the original failed request is retried.
|
4. The new pair is written back to `localStorage` and the original failed request is retried.
|
||||||
|
|
||||||
|
> [!note] 403 responses are not retried
|
||||||
|
> The interceptor only triggers token refresh for `status === 401`. A `403` (e.g., `EMAIL_NOT_VERIFIED`) is passed through directly to the caller without attempting a refresh.
|
||||||
|
|
||||||
|
> [!warning] Refresh-token sequence diagram is truncated
|
||||||
|
> The Mermaid diagram below is **incomplete** — it was truncated in the original source at the point where the backend checks that the refresh token exists in `user.refreshTokens`. The remaining steps (rotate tokens, persist, respond, retry original request) are described in prose above but are not yet rendered in the diagram.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
autonumber
|
autonumber
|
||||||
@@ -154,4 +163,47 @@ sequenceDiagram
|
|||||||
FE->>BE: POST /api/auth/refresh-token { refreshToken }
|
FE->>BE: POST /api/auth/refresh-token { refreshToken }
|
||||||
BE->>BE: verifyRefreshToken(refreshToken)
|
BE->>BE: verifyRefreshToken(refreshToken)
|
||||||
BE->>DB: User.findById(decoded.id)
|
BE->>DB: User.findById(decoded.id)
|
||||||
BE->>DB: ensure refresh token is in user.refreshTokens
|
BE->>DB: ensure refresh token is in user.refreshTokens
|
||||||
|
Note over BE,DB: (diagram truncated — remaining steps documented in prose above)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Account management
|
||||||
|
|
||||||
|
### changePassword (API-only)
|
||||||
|
|
||||||
|
`POST /api/auth/change-password` exists on the backend and the `changePassword()` action is defined in `frontend/src/auth/context/jwt/action.ts`. However:
|
||||||
|
|
||||||
|
> [!warning] No frontend UI for change-password
|
||||||
|
> There is **no dashboard page** that renders a change-password form. The feature is **API-only** at this time. Users cannot change their password through the UI; a developer or direct API client must call the endpoint manually.
|
||||||
|
|
||||||
|
### deleteAccount
|
||||||
|
|
||||||
|
> [!bug] Account deletion is currently broken
|
||||||
|
> The frontend `deleteAccount` action calls `DELETE /user/profile`, which does **not exist** on the backend. The real backend endpoint is `DELETE /api/auth/account` (requires `password` in the request body and runs `deleteAccountValidation`). Until the frontend is updated to call the correct endpoint, account deletion will always fail with a 404 or routing error.
|
||||||
|
|
||||||
|
## Known issues summary
|
||||||
|
|
||||||
|
| Issue | Severity | Details |
|
||||||
|
|---|---|---|
|
||||||
|
| `deleteAccount` calls wrong endpoint | Bug | Frontend calls `DELETE /user/profile`; backend endpoint is `DELETE /api/auth/account` |
|
||||||
|
| No change-password UI | Gap | `POST /api/auth/change-password` and `changePassword()` action exist but no dashboard page renders the form |
|
||||||
|
| Rate limiter counts all attempts | Clarification | Counter increments before password check — 5 total attempts (not 5 failures) triggers lockout |
|
||||||
|
| Axios interceptor 403 passthrough | Clarification | Interceptor only auto-refreshes on 401; 403 errors are surfaced directly |
|
||||||
|
| Refresh-token diagram truncated | Doc debt | Mermaid diagram cut off mid-flow; prose description is authoritative |
|
||||||
|
|
||||||
|
## Linked flows
|
||||||
|
|
||||||
|
- [[Registration Flow]] — prerequisite; user must be verified.
|
||||||
|
- [[Password Reset Flow]] — alternative credential recovery path.
|
||||||
|
- [[Notification Flow]] — uses the issued JWT for Socket.IO room subscriptions.
|
||||||
|
- [[Chat Flow]] — same JWT used for chat room access.
|
||||||
|
|
||||||
|
## Source files
|
||||||
|
|
||||||
|
- Backend: `backend/src/services/auth/authController.ts`
|
||||||
|
- Backend: `backend/src/services/auth/authService.ts`
|
||||||
|
- Backend: `backend/src/services/auth/authValidation.ts`
|
||||||
|
- Backend: `backend/src/services/auth/authRoutes.ts`
|
||||||
|
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`
|
||||||
|
- Frontend: `frontend/src/auth/context/jwt/action.ts`
|
||||||
|
- Frontend: `frontend/src/lib/axios.ts`
|
||||||
|
|||||||
@@ -2,17 +2,19 @@
|
|||||||
title: Delivery Confirmation Flow
|
title: Delivery Confirmation Flow
|
||||||
tags: [flow, delivery, escrow-release, code]
|
tags: [flow, delivery, escrow-release, code]
|
||||||
related_models: ["[[PurchaseRequest]]", "[[Payment]]"]
|
related_models: ["[[PurchaseRequest]]", "[[Payment]]"]
|
||||||
related_apis: ["POST /api/marketplace/purchase-requests/:id/delivery-code", "POST /api/marketplace/purchase-requests/:id/verify-delivery"]
|
related_apis: ["POST /api/marketplace/purchase-requests/:id/delivery-code/generate", "POST /api/marketplace/purchase-requests/:id/delivery-code/verify"]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Delivery Confirmation Flow
|
# Delivery Confirmation Flow
|
||||||
|
|
||||||
After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escrow Flow]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **enters a delivery code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]).
|
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||||
|
|
||||||
|
After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escrow Flow]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **generates and reads out the delivery code**, the seller **verifies the code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]).
|
||||||
|
|
||||||
## Actors
|
## Actors
|
||||||
|
|
||||||
- **Seller** — marks the order shipped and presents the delivery code to the buyer at hand-off.
|
- **Buyer** — after the order reaches `delivery` status, explicitly generates the delivery code and reads it out to the seller at hand-off.
|
||||||
- **Buyer** — confirms by entering the code in the dashboard.
|
- **Seller** — types the code into their dashboard to confirm delivery.
|
||||||
- **Backend** — `DeliveryService` (`backend/src/services/delivery/DeliveryService.ts`), exposed through the marketplace routes (`backend/src/services/marketplace/routes.ts`).
|
- **Backend** — `DeliveryService` (`backend/src/services/delivery/DeliveryService.ts`), exposed through the marketplace routes (`backend/src/services/marketplace/routes.ts`).
|
||||||
- **MongoDB** — `purchaserequests.deliveryInfo` subdocument fields.
|
- **MongoDB** — `purchaserequests.deliveryInfo` subdocument fields.
|
||||||
- **Socket.IO** — `delivery-code-generated`, `delivery-update`.
|
- **Socket.IO** — `delivery-code-generated`, `delivery-update`.
|
||||||
@@ -24,21 +26,22 @@ After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escr
|
|||||||
|
|
||||||
## Step-by-step narrative
|
## Step-by-step narrative
|
||||||
|
|
||||||
1. **Seller marks shipped** — from the seller step `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`, clicks "Mark as shipped". This patches the request status to `delivery`.
|
1. **Seller marks shipped** — from the seller step `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`, clicks "Mark as shipped". This patches the request status to `delivery`. No code is generated at this point.
|
||||||
2. **Delivery code generation** — when the order transitions to `delivery`, `DeliveryService.generateDeliveryCode(requestId)` is invoked. It:
|
2. **Buyer generates the delivery code** — once status is `delivery`, the buyer explicitly triggers `POST /api/marketplace/purchase-requests/:id/delivery-code/generate` (buyerId is enforced server-side). `DeliveryService.generateDeliveryCode(requestId)`:
|
||||||
- Generates a 6-digit code (`Math.floor(100000 + Math.random()*900000)`).
|
- Generates a 6-digit code (`Math.floor(100000 + Math.random()*900000)`).
|
||||||
- Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`.
|
- Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`.
|
||||||
- Emits `delivery-code-generated` and `delivery-update` to `request-{requestId}`.
|
- Emits `delivery-code-generated` and `delivery-update` to `request-{requestId}`.
|
||||||
- Sends a notification to the buyer with the code (in-app, and via email if configured).
|
- The code is displayed to the buyer in `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`.
|
||||||
3. **Buyer entry** — buyer meets the courier / picks up the item, enters the code in `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx` (also surfaced on the buyer side via `step-5-receive-goods.tsx`).
|
3. **Buyer reads code to seller** — at hand-off the buyer reads the 6-digit code out loud (or shows it) to the seller.
|
||||||
4. **Verification** — `POST /api/marketplace/purchase-requests/:id/verify-delivery` with `{ code }`:
|
4. **Seller enters code** — seller types the code into `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx`.
|
||||||
|
5. **Verification** — `POST /api/marketplace/purchase-requests/:id/delivery-code/verify` with `{ code }` (selectedOffer.sellerId is enforced server-side):
|
||||||
- Matches `code` against `deliveryInfo.deliveryCode`.
|
- Matches `code` against `deliveryInfo.deliveryCode`.
|
||||||
- Checks `deliveryCodeExpiresAt > now` and `deliveryCodeUsed === false`.
|
- Checks `deliveryCodeExpiresAt > now` and `deliveryCodeUsed === false`.
|
||||||
- On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`.
|
- On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`.
|
||||||
- Emits `purchase-request-update` `status-changed`.
|
- Emits `purchase-request-update` `status-changed`.
|
||||||
- Triggers buyer/seller notifications via `notifyDeliveryConfirmed` (see `PurchaseRequestService.ts:631-641`).
|
- Triggers buyer/seller notifications via `notifyDeliveryConfirmed` (see `PurchaseRequestService.ts:631-641`).
|
||||||
5. **Optional auto-release timer** — once `status === 'delivered'`, a scheduled job can flip the request to `confirming` and then to `seller_paid` after a grace period (e.g. 48h). The auto-release worker is not yet implemented; today an admin completes the chain via [[Payout Flow]].
|
6. **Alternative path — buyer fast-track** — the buyer can also call `PATCH .../confirm-delivery` to set status to `delivered` without any code (used when the code path fails, e.g. code expired or lost). **⚠️ Authorization gap:** this endpoint currently has no authorization check; any authenticated user can call it.
|
||||||
6. **Manual fast-track** — the buyer can also tap "Confirm I received it" to skip the code (used when the code path fails — e.g. lost in transit) which patches `status` to `delivered`. This relies on admin trust.
|
7. **Optional auto-release timer** — once `status === 'delivered'`, a scheduled job can flip the request to `confirming` and then to `seller_paid` after a grace period (e.g. 48h). The auto-release worker is not yet implemented; today an admin completes the chain via [[Payout Flow]].
|
||||||
|
|
||||||
## Sequence diagram
|
## Sequence diagram
|
||||||
|
|
||||||
@@ -55,14 +58,17 @@ sequenceDiagram
|
|||||||
S->>FE: Click "Mark as shipped"
|
S->>FE: Click "Mark as shipped"
|
||||||
FE->>BE: PATCH /api/marketplace/purchase-requests/{id} {status:"delivery"}
|
FE->>BE: PATCH /api/marketplace/purchase-requests/{id} {status:"delivery"}
|
||||||
BE->>DB: PurchaseRequest.status="delivery"
|
BE->>DB: PurchaseRequest.status="delivery"
|
||||||
BE->>BE: DeliveryService.generateDeliveryCode
|
Note over BE,DB: No code generated here
|
||||||
|
|
||||||
|
B->>FE: View delivery code in step-5-receive-goods
|
||||||
|
FE->>BE: POST /api/marketplace/purchase-requests/{id}/delivery-code/generate
|
||||||
BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
|
BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
|
||||||
BE->>IO: emit request-{id} 'delivery-code-generated'
|
BE->>IO: emit request-{id} 'delivery-code-generated'
|
||||||
BE->>B: notification w/ code (in-app/email)
|
FE->>B: Display 6-digit code
|
||||||
|
|
||||||
S->>B: At hand-off, share the 6-digit code (verbally)
|
B->>S: At hand-off, read the 6-digit code aloud
|
||||||
B->>FE: Enter code in dashboard
|
S->>FE: Enter code in delivery-code-verification
|
||||||
FE->>BE: POST /api/marketplace/purchase-requests/{id}/verify-delivery {code}
|
FE->>BE: POST /api/marketplace/purchase-requests/{id}/delivery-code/verify {code}
|
||||||
BE->>DB: match code, expires>now, !used
|
BE->>DB: match code, expires>now, !used
|
||||||
BE->>DB: set deliveryCodeUsed = true
|
BE->>DB: set deliveryCodeUsed = true
|
||||||
BE->>DB: set status = "delivered"
|
BE->>DB: set status = "delivered"
|
||||||
@@ -77,9 +83,26 @@ sequenceDiagram
|
|||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` `{status:"delivery"}` | Seller marks shipped |
|
| `PATCH` | `/api/marketplace/purchase-requests/:id` `{status:"delivery"}` | Seller marks shipped |
|
||||||
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code` | Manual code regeneration (admin) |
|
| `GET` | `/api/marketplace/purchase-requests/:id/delivery-code` | Retrieve current code (buyer + seller) |
|
||||||
| `POST` | `/api/marketplace/purchase-requests/:id/verify-delivery` | Buyer confirms with code |
|
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/generate` | Buyer generates delivery code (buyer only) |
|
||||||
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) |
|
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/verify` | Seller verifies code (seller only) |
|
||||||
|
| `GET` | `/api/marketplace/purchase-requests/:id/delivery-code/status` | Check code status (buyer + seller) |
|
||||||
|
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) — ⚠️ no auth check |
|
||||||
|
|
||||||
|
### Phantom frontend actions (routes do NOT exist on backend)
|
||||||
|
|
||||||
|
These Redux/API actions exist in the frontend but call endpoints that return 404:
|
||||||
|
|
||||||
|
| Frontend action | Called path | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `regenerateDeliveryCode` | `/delivery-code/regenerate` | 404s; frontend falls back to `/delivery-code/generate` |
|
||||||
|
| `getDeliveryAttempts` | `/delivery-code/attempts` | 404s — feature not implemented |
|
||||||
|
| `getDeliveryStats` | `/delivery/stats` | 404s — feature not implemented |
|
||||||
|
|
||||||
|
## Two paths to `delivered` status
|
||||||
|
|
||||||
|
1. **Code path** — seller calls `POST .../delivery-code/verify` with the correct, unexpired code → status becomes `delivered`.
|
||||||
|
2. **Fast-track path** — buyer calls `PATCH .../confirm-delivery` (no code required) → also becomes `delivered`. ⚠️ Currently no authorization check on this endpoint.
|
||||||
|
|
||||||
## Database writes
|
## Database writes
|
||||||
|
|
||||||
@@ -92,24 +115,23 @@ sequenceDiagram
|
|||||||
- **`delivery-code-generated`** → `request-{id}` (with code, expiresAt).
|
- **`delivery-code-generated`** → `request-{id}` (with code, expiresAt).
|
||||||
- **`delivery-update`** → `request-{id}` (`type: 'code-generated'`).
|
- **`delivery-update`** → `request-{id}` (`type: 'code-generated'`).
|
||||||
- **`purchase-request-update`** `status-changed` on `delivery → delivered`.
|
- **`purchase-request-update`** `status-changed` on `delivery → delivered`.
|
||||||
- **`new-notification`** → `user-{buyerId}` with the code.
|
|
||||||
|
|
||||||
## Side effects
|
## Side effects
|
||||||
|
|
||||||
- Code is **emitted via socket and in-app notification**. If a malicious actor has access to the buyer's notifications, they could intercept and confirm delivery prematurely. Treat the code as confidential at the UI layer.
|
- The code is shown only to the **buyer** in their dashboard. The buyer verbally shares it with the seller — there is no backend push of the code to the seller.
|
||||||
- Triggers the path that eventually frees up the escrow (manual today via [[Payout Flow]], auto in the future).
|
- Triggers the path that eventually frees up the escrow (manual today via [[Payout Flow]], auto in the future).
|
||||||
|
|
||||||
## Error / edge cases
|
## Error / edge cases
|
||||||
|
|
||||||
- **Wrong code** → `400 Invalid delivery code`.
|
- **Wrong code** → `400 Invalid delivery code`.
|
||||||
- **Expired code** (>7 days) → `400 Code expired`. Admin can regenerate via the manual endpoint.
|
- **Expired code** (>7 days) → `400 Code expired`. Buyer can generate a new code via `POST .../delivery-code/generate` (the `regenerateDeliveryCode` frontend action also falls through to this endpoint).
|
||||||
- **Already used code** → `400 Code already used`.
|
- **Already used code** → `400 Code already used`.
|
||||||
- **Buyer never confirms** → status remains `delivery`. Auto-release timer (not yet built) should trigger `delivered` after N days. Until then, admin intervention.
|
- **Buyer never generates / confirms** → status remains `delivery`. Auto-release timer (not yet built) should trigger `delivered` after N days. Until then, admin intervention.
|
||||||
- **Seller delivers but never marks shipped** → buyer can dispute via [[Dispute Flow]]; the dispute resolution will release the escrow regardless.
|
- **Seller delivers but never marks shipped** → buyer can dispute via [[Dispute Flow]]; the dispute resolution will release the escrow regardless.
|
||||||
- **Lost code** → `POST /:id/delivery-code` regenerates a new 6-digit value, invalidates the old one, and re-notifies. Restrict to admin/seller to avoid abuse.
|
- **Lost / expired code** → buyer re-triggers `POST .../delivery-code/generate` to get a fresh code, invalidating the old one.
|
||||||
|
|
||||||
> [!tip] Use the code as proof-of-handover
|
> [!tip] The buyer holds the code, not the seller
|
||||||
> The seller should ask the courier or the buyer at the door for the code before leaving the item. If the buyer disputes "never received", an unused code is strong circumstantial evidence; a used code = buyer confirmed.
|
> The seller should ask the buyer for the code at hand-off. If the buyer disputes "never received", an unused code is strong circumstantial evidence that delivery has not been confirmed; a used code = seller confirmed receipt.
|
||||||
|
|
||||||
## Linked flows
|
## Linked flows
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ title: Dispute Flow
|
|||||||
tags: [flow, dispute, mediator, evidence, chat, state-machine]
|
tags: [flow, dispute, mediator, evidence, chat, state-machine]
|
||||||
related_models: ["[[Dispute]]", "[[Chat]]", "[[PurchaseRequest]]", "[[Payment]]"]
|
related_models: ["[[Dispute]]", "[[Chat]]", "[[PurchaseRequest]]", "[[Payment]]"]
|
||||||
related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api/disputes/:id/resolve", "POST /api/disputes/:id/evidence", "PATCH /api/disputes/:id/status"]
|
related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api/disputes/:id/resolve", "POST /api/disputes/:id/evidence", "PATCH /api/disputes/:id/status"]
|
||||||
|
audit: "2026-05-29 — corrected against source: Dispute.ts, DisputeService.ts, routes/disputeRoutes.ts (dashboard), services/dispute/disputeRoutes.ts (release-hold), app.ts. Previous version had wrong resolution schema, invented status values, missing security issues, and incorrect socket-event description."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Dispute Flow
|
# Dispute Flow
|
||||||
|
|
||||||
When something goes wrong (item not delivered, wrong item, fraud), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — releasing the escrow to the seller, refunding the buyer, splitting the funds, or rejecting the claim.
|
When something goes wrong (item not delivered, wrong item, seller misbehaviour), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — selecting an action such as refund, replacement, compensation, warning, or ban.
|
||||||
|
|
||||||
|
> [!danger] SECURITY — Three open privilege-escalation bugs exist as of this audit. See [Security Gaps](#security-gaps) below.
|
||||||
|
|
||||||
|
> [!warning] Real-time events not implemented
|
||||||
|
> Every Socket.IO emit in `DisputeService` is currently commented out. No `dispute-updated`, `new-notification`, or any other socket event fires for dispute creation, admin assignment, status changes, evidence uploads, or resolution. The dispute feature is CRUD-only at this stage.
|
||||||
|
|
||||||
## Actors
|
## Actors
|
||||||
|
|
||||||
@@ -15,11 +21,9 @@ When something goes wrong (item not delivered, wrong item, fraud), either party
|
|||||||
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
|
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
|
||||||
- **Admin / Mediator** — assigned to investigate.
|
- **Admin / Mediator** — assigned to investigate.
|
||||||
- **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard.
|
- **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard.
|
||||||
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts`, and release-hold helpers in `backend/src/services/dispute/releaseHoldService.ts`.
|
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts` (mounted first at `/api/disputes`), and release-hold helpers in `backend/src/services/dispute/disputeRoutes.ts` (mounted second at `/api/disputes`).
|
||||||
> [!note] Alignment gap
|
|
||||||
> The module exists now, but it still uses the legacy status/action enum. [[Funds Ledger and Escrow State Machine Specification]] defines the canonical future dispute states and financial side effects.
|
|
||||||
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
|
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
|
||||||
- **Socket.IO** — `new-notification`, `new-message`, `dispute-updated` (planned).
|
- **Socket.IO** — no events fire today; all emits are TODO stubs (see warning above).
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
@@ -29,63 +33,167 @@ When something goes wrong (item not delivered, wrong item, fraud), either party
|
|||||||
|
|
||||||
## Dispute state machine (`Dispute.status`)
|
## Dispute state machine (`Dispute.status`)
|
||||||
|
|
||||||
|
Valid status values (from `Dispute.ts`): `pending | in_progress | waiting_response | resolved | rejected | closed`.
|
||||||
|
|
||||||
|
> [!caution] `under_review` does NOT exist. The correct progressed status is `in_progress`.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
[*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d
|
[*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d
|
||||||
pending --> in_progress: admin assigned\nassignAdmin()
|
pending --> in_progress: admin assigned\nassignAdmin()
|
||||||
in_progress --> resolved: admin resolves\naction ∈ {refund, partial, release, reject}
|
pending --> waiting_response: status update
|
||||||
|
in_progress --> waiting_response: status update
|
||||||
|
waiting_response --> in_progress: status update
|
||||||
|
in_progress --> resolved: admin resolves\nresolveDispute()
|
||||||
|
in_progress --> rejected: admin rejects
|
||||||
in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam)
|
in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam)
|
||||||
pending --> closed: same
|
pending --> closed: same
|
||||||
resolved --> [*]
|
resolved --> [*]
|
||||||
|
rejected --> [*]
|
||||||
closed --> [*]
|
closed --> [*]
|
||||||
```
|
```
|
||||||
|
|
||||||
Resolution actions (from `Dispute.resolution.action` enum, see `Dispute.ts` *(intended design)*): `refund`, `partial`, `release`, `reject`.
|
## Resolution schema (`Dispute.resolution`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resolution?: {
|
||||||
|
action: 'refund' | 'replacement' | 'compensation' | 'warning_seller' | 'ban_seller' | 'no_action';
|
||||||
|
amount?: number;
|
||||||
|
currency?: string; // 'USD' | 'EUR' | 'IRR' | 'USDT'
|
||||||
|
notes?: string;
|
||||||
|
resolvedBy: ObjectId;
|
||||||
|
resolvedAt: Date;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!caution] Incorrect in previous docs: `decision: buyer|seller|split` and `refundAmount` do NOT exist in the model. The field is `action` with the six values listed above.
|
||||||
|
|
||||||
|
## Dispute categories (`Dispute.category`)
|
||||||
|
|
||||||
|
Valid values: `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`
|
||||||
|
|
||||||
|
> [!caution] `fraud` is NOT a valid category. Use `seller_behavior` or `other` for fraud-type reports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Gaps
|
||||||
|
|
||||||
|
### 1. `PATCH /api/disputes/:id/status` — no role guard
|
||||||
|
|
||||||
|
**File:** `backend/src/routes/disputeRoutes.ts` line 26
|
||||||
|
|
||||||
|
```ts
|
||||||
|
router.patch('/:id/status', DisputeController.updateStatus);
|
||||||
|
```
|
||||||
|
|
||||||
|
Despite comments in the router saying "admin only", there is **no `authorizeRoles` middleware**. Any authenticated buyer or seller can call this endpoint and change a dispute's status to `resolved` or `closed`, bypassing the admin resolution flow entirely. This is an open privilege-escalation bug.
|
||||||
|
|
||||||
|
### 2. `POST /api/disputes/:id/resolve` (dashboard router) — no role guard
|
||||||
|
|
||||||
|
**File:** `backend/src/routes/disputeRoutes.ts` line 29
|
||||||
|
|
||||||
|
```ts
|
||||||
|
router.post('/:id/resolve', DisputeController.resolveDispute);
|
||||||
|
```
|
||||||
|
|
||||||
|
No role guard. Any authenticated user can post a resolution — including `action: 'ban_seller'`. Note that the **release-hold router's** `POST /:purchaseRequestId/resolve` (`backend/src/services/dispute/disputeRoutes.ts` line 77) **does** correctly apply `authorizeRoles('admin')`. The dashboard router's resolve endpoint does not.
|
||||||
|
|
||||||
|
### 3. `POST /api/disputes/:id/assign` — no role guard
|
||||||
|
|
||||||
|
**File:** `backend/src/routes/disputeRoutes.ts` line 23
|
||||||
|
|
||||||
|
```ts
|
||||||
|
router.post('/:id/assign', DisputeController.assignAdmin);
|
||||||
|
```
|
||||||
|
|
||||||
|
Any authenticated user can call this with their own user ID in `{ adminId }` and self-assign as mediator for any dispute.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route Shadowing
|
||||||
|
|
||||||
|
Both routers are mounted at `/api/disputes` in `app.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app.ts line 521 — mounted FIRST
|
||||||
|
app.use("/api/disputes", dashboardDisputeRoutes); // src/routes/disputeRoutes.ts
|
||||||
|
|
||||||
|
// app.ts line 585 — mounted SECOND
|
||||||
|
app.use("/api/disputes", disputeRoutes); // src/services/dispute/disputeRoutes.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Express evaluates routes in registration order. This creates two concrete hazards:
|
||||||
|
|
||||||
|
1. **`POST /api/disputes/:id/resolve`** — the dashboard router (mounted first) exposes `POST /:id/resolve` with no role guard. A request intended for the release-hold router's `POST /:purchaseRequestId/resolve` (which **does** require admin) will be intercepted and handled by the wrong, unguarded handler when a matching dispute `_id` is supplied.
|
||||||
|
|
||||||
|
2. **`POST /api/disputes/:purchaseRequestId/raise`** — this route exists only in the second (release-hold) router. It will be reached correctly only if the dashboard router does not first match the path. Since the dashboard router has no `/raise` route, requests pass through. However, as more routes are added to either router, collisions will grow silently.
|
||||||
|
|
||||||
|
**Recommendation:** Separate the two routers onto distinct path prefixes (e.g. `/api/disputes` for the dashboard controller, `/api/disputes/hold` for the release-hold service).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Step-by-step narrative
|
## Step-by-step narrative
|
||||||
|
|
||||||
### Phase 1 — Opening
|
### Phase 1 — Opening
|
||||||
|
|
||||||
1. Buyer or seller opens the request detail and clicks **"Report problem"** (`frontend/src/sections/request/components/report-problem-to-admin.tsx`).
|
1. Buyer or seller opens the request detail and clicks **"Report problem"** (`frontend/src/sections/request/components/report-problem-to-admin.tsx`).
|
||||||
2. They select a `category` (delivery, payment, quality, fraud, other), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`.
|
2. They select a `category` (`product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`.
|
||||||
3. Frontend POSTs `POST /api/disputes` with `{ purchaseRequestId, reason, description, priority, category, evidence: [...] }`.
|
3. Frontend POSTs `POST /api/disputes` with `{ purchaseRequestId, reason, description, priority, category, evidence: [...] }`.
|
||||||
4. Backend `DisputeService.createDispute` (`:12-119`):
|
4. Backend `DisputeService.createDispute` (`:12-119`):
|
||||||
- Loads the purchase request with `populate('selectedOfferId')`.
|
- Loads the purchase request with `populate('selectedOfferId')`.
|
||||||
- Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId` → `selectedOffer.sellerId` → first of `preferredSellerIds`. This means once an offer is accepted, the dispute targets the actual seller, not the entire preferred list.
|
- Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId` → `selectedOffer.sellerId` → first of `preferredSellerIds`. Once an offer is accepted, the dispute targets the actual seller, not the entire preferred list.
|
||||||
- Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`.
|
- Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`. The pre-save hook appends an automatic `dispute_created` timeline entry.
|
||||||
- Creates a **`Chat` of type `group`** with the buyer and the resolved seller as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`.
|
- Creates a **`Chat` of type `group`** with the buyer (and seller, if resolved) as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`.
|
||||||
- Persists `dispute.chatId = chat._id`.
|
- Persists `dispute.chatId = chat._id`.
|
||||||
5. Notifications (currently a `TODO` in the service — `:107-116`) should fire `new-notification` to the seller. Today the chat creation alone provides real-time presence via the `new-message` socket emit inside `Chat.create`'s lifecycle.
|
5. **Notifications: none fire.** The notification block is a TODO stub in `DisputeService.createDispute` (`:107-116`).
|
||||||
|
|
||||||
> [!note] Release hold behavior
|
> [!note] Release hold behavior
|
||||||
> Opening a dispute now has backend release-hold support: `releaseHoldService.raiseDispute()` sets hold fields on the purchase request and related payments, and release/refund gates can consult those fields. The remaining work is to make this the single mandatory policy path for every release/refund/sweep operation and align it with the canonical `DISPUTED` escrow state.
|
> Opening a dispute through the release-hold router (`POST /api/disputes/:purchaseRequestId/raise`) sets hold fields on the purchase request and related payments via `releaseHoldService.raiseDispute()`. Release/refund gates can consult those fields. This is a separate code path from `DisputeService.createDispute` above.
|
||||||
|
|
||||||
### Phase 2 — Admin assignment
|
### Phase 2 — Admin assignment
|
||||||
|
|
||||||
6. The admin dispute dashboard lists pending disputes (sorted by `priority: -1, createdAt: -1`).
|
6. The admin dispute dashboard lists pending disputes (sorted by `priority: -1, createdAt: -1`).
|
||||||
7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }` (currently the admin's own id).
|
7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }`.
|
||||||
|
|
||||||
|
> [!danger] No role guard on this endpoint — any authenticated user can call it (see [Security Gaps](#security-gaps)).
|
||||||
|
|
||||||
8. `DisputeService.assignAdmin` (`:184-223`):
|
8. `DisputeService.assignAdmin` (`:184-223`):
|
||||||
- `dispute.adminId = adminId; dispute.status = 'in_progress'`.
|
- `dispute.adminId = adminId; dispute.status = 'in_progress'`.
|
||||||
- Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`.
|
- Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`.
|
||||||
- Adds the admin to the dispute `chat.participants[]` (role `admin`).
|
- Adds the admin to the dispute `chat.participants[]` (role `admin`).
|
||||||
- Saves.
|
- Saves.
|
||||||
|
- **No socket event fires.** (`// TODO: Notify buyer and seller via Socket.IO`)
|
||||||
|
|
||||||
### Phase 3 — Investigation
|
### Phase 3 — Investigation
|
||||||
|
|
||||||
9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence` — `DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`.
|
9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence` — `DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`. **No socket event fires for evidence uploads.**
|
||||||
10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`.
|
10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`. **No socket event fires.**
|
||||||
|
|
||||||
|
> [!danger] `PATCH /api/disputes/:id/status` has no role guard — any authenticated user can change dispute status (see [Security Gaps](#security-gaps)).
|
||||||
|
|
||||||
### Phase 4 — Resolution
|
### Phase 4 — Resolution
|
||||||
|
|
||||||
11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with `{ action, amount?, currency?, notes? }`.
|
11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "refund | replacement | compensation | warning_seller | ban_seller | no_action",
|
||||||
|
"amount": 150,
|
||||||
|
"currency": "USD",
|
||||||
|
"notes": "Seller failed to deliver item"
|
||||||
|
}
|
||||||
|
```
|
||||||
12. `DisputeService.resolveDispute` (`:262-300`):
|
12. `DisputeService.resolveDispute` (`:262-300`):
|
||||||
- `dispute.status = 'resolved'`
|
- `dispute.status = 'resolved'`
|
||||||
- `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }`
|
- `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }`
|
||||||
- `dispute.closedAt = now`
|
- `dispute.closedAt = now`
|
||||||
- Appends `timeline` entry `dispute_resolved`.
|
- Appends `timeline` entry `dispute_resolved`.
|
||||||
- Saves.
|
- Saves.
|
||||||
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **release** ([[Payout Flow]] / [[Escrow Flow]]) or the **refund**. The dispute service records the resolution; full automatic dispatch through the release/refund policy engine is still a hardening item.
|
- **No socket event fires.** (`// TODO: Send notifications via Socket.IO`)
|
||||||
14. Both parties are notified (TODOs in code — planned: `notifyDisputeResolved`).
|
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **release** ([[Payout Flow]] / [[Escrow Flow]]) or the **refund** as a separate step. The dispute service records the resolution; full automatic dispatch through the release/refund policy engine is still a hardening item.
|
||||||
|
|
||||||
|
> [!danger] `POST /api/disputes/:id/resolve` (dashboard router) has no role guard — any authenticated user can post any resolution action including `ban_seller` (see [Security Gaps](#security-gaps)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Sequence diagram
|
## Sequence diagram
|
||||||
|
|
||||||
@@ -107,80 +215,106 @@ sequenceDiagram
|
|||||||
BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message})
|
BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message})
|
||||||
BE->>DB: dispute.chatId = chat._id
|
BE->>DB: dispute.chatId = chat._id
|
||||||
BE-->>FE: { dispute }
|
BE-->>FE: { dispute }
|
||||||
FE-->>B: chat opens (real-time via existing chat join)
|
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||||
FE-->>S: chat opens (real-time via existing chat join)
|
|
||||||
|
|
||||||
A->>FE: Admin dashboard, click "Pick up"
|
A->>FE: Admin dashboard, click "Pick up"
|
||||||
FE->>BE: POST /api/disputes/{id}/assign
|
FE->>BE: POST /api/disputes/{id}/assign
|
||||||
|
Note right of BE: ⚠️ No role guard
|
||||||
BE->>DB: dispute.adminId, status="in_progress", timeline.push
|
BE->>DB: dispute.adminId, status="in_progress", timeline.push
|
||||||
BE->>DB: chat.participants.push(admin)
|
BE->>DB: chat.participants.push(admin)
|
||||||
BE-->>FE: { dispute }
|
BE-->>FE: { dispute }
|
||||||
|
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||||
|
|
||||||
loop investigation
|
loop investigation
|
||||||
A->>FE: Chat with B & S
|
A->>FE: Chat with B & S
|
||||||
B-->>BE: POST /api/disputes/{id}/evidence (image)
|
B-->>BE: POST /api/disputes/{id}/evidence (image)
|
||||||
BE->>DB: dispute.evidence.push, timeline.push
|
BE->>DB: dispute.evidence.push, timeline.push
|
||||||
|
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||||
end
|
end
|
||||||
|
|
||||||
A->>FE: Click "Resolve" choose action
|
A->>FE: Click "Resolve" choose action
|
||||||
FE->>BE: POST /api/disputes/{id}/resolve { action, amount, notes }
|
FE->>BE: POST /api/disputes/{id}/resolve { action, amount?, notes? }
|
||||||
BE->>DB: dispute.status="resolved", resolution={...}
|
Note right of BE: ⚠️ No role guard (dashboard router)
|
||||||
|
BE->>DB: dispute.status="resolved", resolution={action, amount, currency, notes, ...}
|
||||||
alt action="refund"
|
alt action="refund"
|
||||||
A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]]
|
A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]]
|
||||||
else action="release"
|
else action="replacement"
|
||||||
A->>BE: trigger payout to seller\n[[Payout Flow]]
|
A->>BE: arrange replacement item (manual)
|
||||||
else action="partial"
|
else action="compensation"
|
||||||
A->>BE: split — refund X to buyer, release Y to seller
|
A->>BE: partial payment to buyer (manual)
|
||||||
|
else action="warning_seller" / "ban_seller"
|
||||||
|
A->>BE: admin account action (manual)
|
||||||
|
else action="no_action"
|
||||||
|
A->>BE: dismiss dispute
|
||||||
end
|
end
|
||||||
BE-->>FE: { dispute }
|
BE-->>FE: { dispute }
|
||||||
IO-->>B: 'new-notification' dispute resolved (planned)
|
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||||
IO-->>S: 'new-notification' dispute resolved (planned)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## API calls
|
## API calls
|
||||||
|
|
||||||
| Method | Endpoint | Source |
|
### Dashboard router (`backend/src/routes/disputeRoutes.ts`) — mounted first at `/api/disputes`
|
||||||
|---|---|---|
|
|
||||||
| `POST` | `/api/disputes` | `disputeRoutes.ts:12` → `DisputeController.createDispute` |
|
|
||||||
| `GET` | `/api/disputes` | `disputeRoutes.ts:15` (filters: status, priority, category, adminId, buyer/seller) |
|
|
||||||
| `GET` | `/api/disputes/statistics` | `disputeRoutes.ts:18` |
|
|
||||||
| `GET` | `/api/disputes/:id` | `disputeRoutes.ts:21` |
|
|
||||||
| `POST` | `/api/disputes/:id/assign` | `disputeRoutes.ts:24` |
|
|
||||||
| `PATCH` | `/api/disputes/:id/status` | `disputeRoutes.ts:27` |
|
|
||||||
| `POST` | `/api/disputes/:id/resolve` | `disputeRoutes.ts:30` |
|
|
||||||
| `POST` | `/api/disputes/:id/evidence` | `disputeRoutes.ts:33` |
|
|
||||||
|
|
||||||
All require `authenticateToken` (router-level middleware).
|
| Method | Endpoint | Auth | Role Guard | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `POST` | `/api/disputes` | `authenticateToken` | None | Create dispute |
|
||||||
|
| `GET` | `/api/disputes` | `authenticateToken` | None | List with filters |
|
||||||
|
| `GET` | `/api/disputes/statistics` | `authenticateToken` | None | Aggregate stats |
|
||||||
|
| `GET` | `/api/disputes/:id` | `authenticateToken` | None | Get by ID |
|
||||||
|
| `POST` | `/api/disputes/:id/assign` | `authenticateToken` | **MISSING** ⚠️ | Self-assign possible |
|
||||||
|
| `PATCH` | `/api/disputes/:id/status` | `authenticateToken` | **MISSING** ⚠️ | Any user can change status |
|
||||||
|
| `POST` | `/api/disputes/:id/resolve` | `authenticateToken` | **MISSING** ⚠️ | Any user can resolve |
|
||||||
|
| `POST` | `/api/disputes/:id/evidence` | `authenticateToken` | None | Add evidence |
|
||||||
|
|
||||||
|
### Release-hold router (`backend/src/services/dispute/disputeRoutes.ts`) — mounted second at `/api/disputes`
|
||||||
|
|
||||||
|
| Method | Endpoint | Auth | Role Guard | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `POST` | `/api/disputes/:purchaseRequestId/raise` | `authenticateToken` | Buyer or admin (inline check) | Sets hold fields on PurchaseRequest |
|
||||||
|
| `POST` | `/api/disputes/:purchaseRequestId/resolve` | `authenticateToken` | `authorizeRoles('admin')` ✓ | Clears hold fields |
|
||||||
|
| `GET` | `/api/disputes/:purchaseRequestId/status` | `authenticateToken` | Participant or admin (inline check) | Returns hold/block status |
|
||||||
|
|
||||||
|
> [!warning] Route shadowing: `POST /api/disputes/:id/resolve` in the dashboard router (no guard, mounted first) will intercept requests before they reach the release-hold router's `POST /:purchaseRequestId/resolve` (has guard). See [Route Shadowing](#route-shadowing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Database writes
|
## Database writes
|
||||||
|
|
||||||
- **`disputes`** — insert on open; updates `adminId`, `status`, `timeline[]`, `evidence[]`, `resolution`, `closedAt` over the lifecycle.
|
- **`disputes`** — insert on open; updates `adminId`, `status`, `timeline[]`, `evidence[]`, `resolution`, `closedAt` over the lifecycle.
|
||||||
- **`chats`** — new `group` chat on open; admin appended to `participants[]` on assignment; messages appended throughout.
|
- **`chats`** — new `group` chat on open; admin appended to `participants[]` on assignment; messages appended throughout.
|
||||||
- **`purchaserequests`** — not directly mutated by the dispute service; the resolution side-effect (release/refund) updates the request via [[Escrow Flow]].
|
- **`purchaserequests`** — hold fields (`disputeRaised`, `disputeRaisedAt`, `disputeResolved`, `disputeResolvedAt`, `disputeHoldReason`, `holdUntil`) mutated by the release-hold service. Not touched by `DisputeService` directly.
|
||||||
- **`payments`** — touched indirectly when the admin performs the financial resolution.
|
- **`payments`** — touched indirectly when the admin performs the financial resolution.
|
||||||
- **`notifications`** — `TODO` markers in code; planned addition.
|
- **`notifications`** — TODO; no writes happen today.
|
||||||
|
|
||||||
## Socket events emitted
|
## Socket events emitted
|
||||||
|
|
||||||
- **`new-message`** → `chat-{disputeChatId}` for each chat line (via the standard `ChatService.sendMessage` and the system message created in `DisputeService.createDispute`).
|
> [!warning] None of the following events actually fire. Every emit block in `DisputeService` is commented out as a TODO stub.
|
||||||
- **`new-notification`** (planned) → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, resolution.
|
|
||||||
|
Planned events (not yet implemented):
|
||||||
|
- **`new-notification`** → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, and resolution.
|
||||||
|
- **`dispute-updated`** → planned but not implemented.
|
||||||
|
|
||||||
|
The only real-time activity in the dispute flow today is through the standard **Chat** socket (`new-message` on `chat-{disputeChatId}`) when participants send chat messages — this flows through `ChatService.sendMessage`, which is separate from the dispute service and does emit.
|
||||||
|
|
||||||
## Side effects
|
## Side effects
|
||||||
|
|
||||||
- **Three-way chat creation** is the most visible side effect — pulls the buyer and seller into a controlled conversation room.
|
- **Three-way chat creation** is the most visible side effect — pulls the buyer and seller into a controlled conversation room.
|
||||||
- **Timeline append-only log** is the audit trail. Surface it in the admin UI for compliance.
|
- **Timeline append-only log** is the audit trail. The pre-save hook auto-appends `dispute_created` on insert. Surface this in the admin UI for compliance.
|
||||||
- **Response deadline = 48h** — used by reminders / SLA dashboards (no automated enforcement today). Past-deadline disputes could auto-escalate priority.
|
- **Response deadline = 48h** — used by reminders / SLA dashboards (no automated enforcement today). Past-deadline disputes could auto-escalate priority.
|
||||||
- **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention.
|
- **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention.
|
||||||
|
|
||||||
## Error / edge cases
|
## Error / edge cases
|
||||||
|
|
||||||
- **Purchase request missing** → `400 Purchase request not found`.
|
- **Purchase request missing** → `400 Purchase request not found`.
|
||||||
- **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer + admin only). Recommended: reject creation in this case to avoid mediator-less situations.
|
- **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer only, no seller). Recommended: reject creation in this case to avoid mediator-less situations.
|
||||||
- **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening).
|
- **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening).
|
||||||
- **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding `unique on (purchaseRequestId, status:'pending'|'in_progress')` to prevent duplicates.
|
- **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding a unique index on `(purchaseRequestId, status)` filtered to `pending|in_progress` to prevent duplicates.
|
||||||
- **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence.
|
- **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence.
|
||||||
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state until the admin/custody operator completes release/refund. Add automation that dispatches the policy-checked release/refund instruction when the admin selects a financial resolution.
|
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state until the admin completes release/refund. Add automation that dispatches the policy-checked release/refund instruction when the admin selects a financial resolution action.
|
||||||
- **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`.
|
- **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`.
|
||||||
|
- **Route collision** → both routers share `/api/disputes`. See [Route Shadowing](#route-shadowing) for details and recommendation.
|
||||||
|
|
||||||
> [!tip] Sort disputes by priority + age
|
> [!tip] Sort disputes by priority + age
|
||||||
> The query `Dispute.find().sort({ priority: -1, createdAt: -1 })` already used in `getDisputes` ensures `urgent` ones bubble to the top. Make sure the admin dashboard uses this default sort.
|
> The query `Dispute.find().sort({ priority: -1, createdAt: -1 })` already used in `getDisputes` ensures `urgent` ones bubble to the top. Make sure the admin dashboard uses this default sort.
|
||||||
@@ -189,16 +323,17 @@ All require `authenticateToken` (router-level middleware).
|
|||||||
|
|
||||||
- [[Chat Flow]] — message-level mechanics inside the dispute chat.
|
- [[Chat Flow]] — message-level mechanics inside the dispute chat.
|
||||||
- [[Escrow Flow]] — the financial state being contested.
|
- [[Escrow Flow]] — the financial state being contested.
|
||||||
- [[Payout Flow]] — executed on `release` resolutions.
|
- [[Payout Flow]] — executed on `refund` / `compensation` resolutions.
|
||||||
- [[Notification Flow]] — channels for dispute alerts.
|
- [[Notification Flow]] — channels for dispute alerts (not yet wired).
|
||||||
- [[Delivery Confirmation Flow]] — disputes often arise from failed delivery.
|
- [[Delivery Confirmation Flow]] — disputes often arise from failed delivery.
|
||||||
|
|
||||||
## Source files
|
## Source files
|
||||||
|
|
||||||
- Backend: `backend/src/services/dispute/DisputeService.ts`
|
- `backend/src/services/dispute/DisputeService.ts` — core service logic
|
||||||
- Backend: `backend/src/services/dispute/releaseHoldService.ts`
|
- `backend/src/services/dispute/disputeRoutes.ts` — release-hold router (admin-guarded resolve)
|
||||||
- Backend: `backend/src/routes/disputeRoutes.ts`
|
- `backend/src/services/dispute/releaseHoldService.ts` — hold field helpers
|
||||||
- Backend: `backend/src/services/dispute/disputeRoutes.ts`
|
- `backend/src/routes/disputeRoutes.ts` — dashboard/controller router (missing role guards)
|
||||||
- Backend: `backend/src/models/Dispute.ts`
|
- `backend/src/models/Dispute.ts` — canonical schema and enums
|
||||||
- Frontend: `frontend/src/sections/request/components/report-problem-to-admin.tsx`
|
- `backend/src/app.ts` lines 521 and 585 — mount order (shadowing risk)
|
||||||
- Frontend: admin dispute dashboard under `frontend/src/sections/admin/` (subject to organisation)
|
- `frontend/src/sections/request/components/report-problem-to-admin.tsx`
|
||||||
|
- `frontend/src/sections/admin/` — admin dispute dashboard (subject to organisation)
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[Funds Ledger and Escro
|
|||||||
related_apis: ["POST /api/payment/:id/release", "POST /api/payment/:id/refund", "POST /api/payment/:id/release/confirm", "POST /api/payment/:id/refund/confirm"]
|
related_apis: ["POST /api/payment/:id/release", "POST /api/payment/:id/refund", "POST /api/payment/:id/release/confirm", "POST /api/payment/:id/refund/confirm"]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> [!warning] Audit — 2026-05-29
|
||||||
|
> This document was corrected against the live codebase. Key changes: `POST /api/disputes/:id/resolve` clarified as Dispute-document-only — it does NOT move escrow funds; route shadowing between the two dispute routers documented; `confirm-delivery` authorization gap flagged.
|
||||||
|
|
||||||
# Escrow Flow
|
# Escrow Flow
|
||||||
|
|
||||||
The current escrow is a **hybrid custody system**, not a custom Solidity escrow contract.
|
The current escrow is a **hybrid custody system**, not a custom Solidity escrow contract.
|
||||||
@@ -133,6 +136,27 @@ Remaining alignment work:
|
|||||||
- ensure every release/refund path calls the same policy service,
|
- ensure every release/refund path calls the same policy service,
|
||||||
- record immutable audit entries for dispute resolution and custody execution.
|
- record immutable audit entries for dispute resolution and custody execution.
|
||||||
|
|
||||||
|
### 6. Dispute Resolution and Escrow Funds
|
||||||
|
|
||||||
|
> [!warning] Two different handlers share the same path — they do different things
|
||||||
|
>
|
||||||
|
> There are **two dispute routers** both mounted at `/api/disputes`. This creates route shadowing:
|
||||||
|
>
|
||||||
|
> | Handler | What it does |
|
||||||
|
> |---|---|
|
||||||
|
> | Dashboard dispute router: `POST /api/disputes/:id/resolve` | Updates the **Dispute document only** — changes dispute status, records resolution notes, etc. **Does NOT touch escrow funds.** |
|
||||||
|
> | releaseHold router: `POST /api/disputes/:purchaseRequestId/resolve` | Unblocks escrow — removes the dispute hold from the `Payment` and `PurchaseRequest`, making the escrow eligible for release or refund. |
|
||||||
|
>
|
||||||
|
> Because the dashboard router is mounted first, a `POST /api/disputes/{id}/resolve` request will be handled by the dashboard router's `POST /:id/resolve` handler if the supplied ID matches a dispute document ID. If the intent is to unblock escrow funds, the correct target is the releaseHold router, but route registration order means the dashboard router intercepts the call first. This is a **route shadowing bug** — both routers claim the same URL pattern and the outcome depends entirely on registration order.
|
||||||
|
>
|
||||||
|
> In practice: calling `POST /api/disputes/:id/resolve` alone is **not sufficient to release or refund escrow**. The escrow unblock is only guaranteed when the releaseHold handler is reached. Verify router mount order in `backend/src/services/dispute/` before relying on either path in automation or admin tooling.
|
||||||
|
|
||||||
|
### 7. Delivery Confirmation Authorization Gap
|
||||||
|
|
||||||
|
> [!warning] ⚠️ Known authorization gap — `confirm-delivery`
|
||||||
|
>
|
||||||
|
> The `PATCH /api/marketplace/purchase-requests/:id/confirm-delivery` endpoint has **no authorization guard**. Any authenticated user (not just the buyer who owns the request) can call this endpoint and advance the purchase request status to `delivered`. This is a known gap and should be remediated by adding an ownership check (`req.user._id === purchaseRequest.buyerId`) before processing the status transition.
|
||||||
|
|
||||||
## Sequence Diagram - Funding
|
## Sequence Diagram - Funding
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -182,6 +206,30 @@ sequenceDiagram
|
|||||||
BE->>DB: escrowState="released" or "refunded"
|
BE->>DB: escrowState="released" or "refunded"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Sequence Diagram - Dispute Resolution (Escrow Path)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor A as Admin / Mediator
|
||||||
|
participant DR as Dashboard Dispute Router\n(POST /api/disputes/:id/resolve)
|
||||||
|
participant RH as releaseHold Router\n(POST /api/disputes/:purchaseRequestId/resolve)
|
||||||
|
participant DB as MongoDB
|
||||||
|
participant ES as Escrow / Payment
|
||||||
|
|
||||||
|
Note over DR,RH: Both routers mounted at /api/disputes — dashboard router registered first
|
||||||
|
|
||||||
|
A->>DR: POST /api/disputes/{disputeId}/resolve
|
||||||
|
DR->>DB: Update Dispute document (status, notes)
|
||||||
|
DR-->>A: 200 OK (Dispute updated only)
|
||||||
|
Note over ES: Escrow funds still on hold at this point
|
||||||
|
|
||||||
|
A->>RH: POST /api/disputes/{purchaseRequestId}/resolve
|
||||||
|
RH->>DB: Remove hold from Payment + PurchaseRequest
|
||||||
|
RH->>ES: Escrow now eligible for release or refund
|
||||||
|
RH-->>A: 200 OK (Hold removed)
|
||||||
|
```
|
||||||
|
|
||||||
## API Calls
|
## API Calls
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose |
|
||||||
@@ -195,6 +243,8 @@ sequenceDiagram
|
|||||||
| `POST` | `/api/payment/:id/refund/confirm` | Confirm refund tx hash / signer proof |
|
| `POST` | `/api/payment/:id/refund/confirm` | Confirm refund tx hash / signer proof |
|
||||||
| `GET` | `/api/payment/:id` | Read payment details |
|
| `GET` | `/api/payment/:id` | Read payment details |
|
||||||
| `GET` | `/api/payment/derived-destinations` | Admin list of derived destinations |
|
| `GET` | `/api/payment/derived-destinations` | Admin list of derived destinations |
|
||||||
|
| `POST` | `/api/disputes/:id/resolve` | Update Dispute document only — does NOT touch escrow |
|
||||||
|
| `POST` | `/api/disputes/:purchaseRequestId/resolve` | Remove dispute hold from escrow (releaseHold router) — see shadowing note above |
|
||||||
|
|
||||||
## Side Effects And Risks
|
## Side Effects And Risks
|
||||||
|
|
||||||
@@ -203,6 +253,8 @@ sequenceDiagram
|
|||||||
- **Trezor enforcement is configurable.** `TREZOR_SAFEKEEPING_REQUIRED=true` makes Trezor proof mandatory for release/refund confirmation, but target custody should be Safe multisig.
|
- **Trezor enforcement is configurable.** `TREZOR_SAFEKEEPING_REQUIRED=true` makes Trezor proof mandatory for release/refund confirmation, but target custody should be Safe multisig.
|
||||||
- **Durable webhook ingress is still roadmap work.** Until the Worker/replay layer is live, backend availability remains important for Request Network webhook delivery.
|
- **Durable webhook ingress is still roadmap work.** Until the Worker/replay layer is live, backend availability remains important for Request Network webhook delivery.
|
||||||
- **Dispute model is implemented but not fully canonical.** The current model works with legacy enum names; canonical status alignment remains required.
|
- **Dispute model is implemented but not fully canonical.** The current model works with legacy enum names; canonical status alignment remains required.
|
||||||
|
- **Route shadowing on `/api/disputes`** — two routers registered at the same mount point. Dashboard router intercepts first; releaseHold handler may not be reachable by the expected URL in all configurations. See section 6 above.
|
||||||
|
- **`confirm-delivery` has no authorization guard** — any authenticated user can advance a purchase request to `delivered`. See section 7 above.
|
||||||
|
|
||||||
## Linked Flows
|
## Linked Flows
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
title: Notification Flow
|
title: Notification Flow
|
||||||
tags: [flow, notification, socket-io, email]
|
tags: [flow, notification, socket-io, email]
|
||||||
related_models: ["[[Notification]]", "[[User]]"]
|
related_models: ["[[Notification]]", "[[User]]"]
|
||||||
related_apis: ["GET /api/notifications", "PATCH /api/notifications/:id/read", "POST /api/notifications/read-all", "DELETE /api/notifications/:id"]
|
related_apis: ["GET /api/notifications", "PATCH /api/notifications/:id/read", "PATCH /api/notifications/mark-all-read", "DELETE /api/notifications/:id"]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||||
|
|
||||||
# Notification Flow
|
# Notification Flow
|
||||||
|
|
||||||
Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and (optionally) email digests. Notifications are created by many services and travel to the user via both **MongoDB persistence** and **Socket.IO push**.
|
Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and (optionally) email digests. Notifications are created by many services and travel to the user via both **MongoDB persistence** and **Socket.IO push**.
|
||||||
@@ -27,7 +29,7 @@ Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and
|
|||||||
- **User** — the recipient.
|
- **User** — the recipient.
|
||||||
- **Frontend** — bell-icon dropdown and toast subscribers in `frontend/src/layouts/components/notifications-drawer/` and the global socket provider.
|
- **Frontend** — bell-icon dropdown and toast subscribers in `frontend/src/layouts/components/notifications-drawer/` and the global socket provider.
|
||||||
- **Backend** — `NotificationService` (`backend/src/services/notification/NotificationService.ts`), routes at `/api/notifications`.
|
- **Backend** — `NotificationService` (`backend/src/services/notification/NotificationService.ts`), routes at `/api/notifications`.
|
||||||
- **MongoDB** — `notifications` collection (one document per notification).
|
- **MongoDB** — `notifications` collection (one document per notification). Notifications are **auto-deleted after 90 days** (TTL index on `createdAt`).
|
||||||
- **Socket.IO** — emits `new-notification` to `user-{userId}`.
|
- **Socket.IO** — emits `new-notification` to `user-{userId}`.
|
||||||
- **Email** (optional) — periodic digest worker (not implemented today; planned).
|
- **Email** (optional) — periodic digest worker (not implemented today; planned).
|
||||||
|
|
||||||
@@ -58,10 +60,10 @@ Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and
|
|||||||
|
|
||||||
### Reading
|
### Reading
|
||||||
|
|
||||||
8. User opens the bell-icon dropdown — frontend calls `POST /api/notifications/mark-read` for each viewed entry (or `POST /api/notifications/read-all`).
|
8. User opens the bell-icon dropdown — frontend calls `PATCH /api/notifications/:id/read` for each viewed entry, or `PATCH /api/notifications/mark-all-read` to clear all at once.
|
||||||
9. `NotificationService.markAsRead(notificationId, userId)` (`NotificationService.ts:74-90`):
|
9. `NotificationService.markAsRead(notificationId, userId)` (`NotificationService.ts:74-90`):
|
||||||
- `Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now })`.
|
- `Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now })`.
|
||||||
- Emits `notification-read` (or recomputes unread count) so other open tabs sync.
|
- After updating, the backend emits `unread-count-update` to `user-{userId}` so all open tabs (and other devices) immediately sync their badge counter.
|
||||||
|
|
||||||
### Preferences
|
### Preferences
|
||||||
|
|
||||||
@@ -97,27 +99,34 @@ sequenceDiagram
|
|||||||
U->>FE: click notification
|
U->>FE: click notification
|
||||||
FE->>NS: PATCH /api/notifications/{id}/read
|
FE->>NS: PATCH /api/notifications/{id}/read
|
||||||
NS->>DB: Notification.findOneAndUpdate(isRead:true)
|
NS->>DB: Notification.findOneAndUpdate(isRead:true)
|
||||||
|
NS->>IO: emit user-{userId} 'unread-count-update'
|
||||||
|
IO-->>FE: badge sync across tabs
|
||||||
FE-->>U: badge--, mark item as read
|
FE-->>U: badge--, mark item as read
|
||||||
FE-->>U: navigate to notification.actionUrl
|
FE-->>U: navigate to notification.actionUrl
|
||||||
```
|
```
|
||||||
|
|
||||||
## API calls
|
## API calls
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose | Notes |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/api/notifications` | Paginated list with `unreadCount` |
|
| `GET` | `/api/notifications` | Paginated list with `unreadCount` | |
|
||||||
| `GET` | `/api/notifications/unread-count` | Just the unread count for badge |
|
| `GET` | `/api/notifications/unread-count` | Just the unread count for badge | |
|
||||||
| `PATCH` | `/api/notifications/:id/read` | Mark single notification read |
|
| `GET` | `/api/notifications/:id` | Single notification | ⚠️ **Known bug** — see below |
|
||||||
| `POST` | `/api/notifications/read-all` | Mark all read |
|
| `PATCH` | `/api/notifications/:id/read` | Mark single notification read | |
|
||||||
| `DELETE` | `/api/notifications/:id` | Remove from list |
|
| `PATCH` | `/api/notifications/mark-all-read` | Mark all notifications read | Previously documented incorrectly as `POST /api/notifications/read-all` |
|
||||||
|
| `DELETE` | `/api/notifications/:id` | Remove from list | |
|
||||||
|
|
||||||
|
> ⚠️ **Known bug — `GET /api/notifications/:id`**: The backend controller does **not** perform a direct DB lookup by ID. Instead it calls `getUserNotifications(userId, 1, 1)` (fetches only 1 record for the user) and then does an in-memory `_id` comparison. Any notification that is not the user's single most-recent record will return `404` erroneously. Do not rely on this endpoint for arbitrary notification lookups until the controller is fixed to use a direct `findOne({ _id, userId })`.
|
||||||
|
|
||||||
## Database writes
|
## Database writes
|
||||||
|
|
||||||
- **`notifications`** — insert on create, update on read, delete on remove.
|
- **`notifications`** — insert on create, update on read, delete on remove.
|
||||||
|
- **TTL**: notifications are automatically deleted after **90 days** via a MongoDB TTL index on `createdAt`.
|
||||||
|
|
||||||
## Socket events emitted
|
## Socket events emitted
|
||||||
|
|
||||||
- **`new-notification`** → `user-{userId}`. Payload includes the full notification document (so the frontend doesn't need to re-fetch).
|
- **`new-notification`** → `user-{userId}`. Payload includes the full notification document (so the frontend doesn't need to re-fetch).
|
||||||
|
- **`unread-count-update`** → `user-{userId}`. Emitted whenever the unread count changes (e.g. after `markAsRead` or `markAllRead`). Used for cross-tab and cross-device badge synchronisation. There is **no** `notification-read` event — `unread-count-update` is the correct event to listen to for badge sync.
|
||||||
- **`level-up`** → `user-{userId}` from `PointsService.addPoints`.
|
- **`level-up`** → `user-{userId}` from `PointsService.addPoints`.
|
||||||
- **`referral-signup`** → `user-{referrerId}` from auth verify.
|
- **`referral-signup`** → `user-{referrerId}` from auth verify.
|
||||||
- **`chat-notification`** → `user-{participantId}` from `ChatService.sendMessage` (these are not stored in the `notifications` collection — they live alongside but drive only the chat-list badge).
|
- **`chat-notification`** → `user-{participantId}` from `ChatService.sendMessage` (these are not stored in the `notifications` collection — they live alongside but drive only the chat-list badge).
|
||||||
@@ -131,10 +140,11 @@ sequenceDiagram
|
|||||||
## Error / edge cases
|
## Error / edge cases
|
||||||
|
|
||||||
- **User offline** → notification is persisted in MongoDB; the user sees it when they next sign in. The socket emit is lossy (no replay).
|
- **User offline** → notification is persisted in MongoDB; the user sees it when they next sign in. The socket emit is lossy (no replay).
|
||||||
- **Multiple tabs / devices** → the same `user-{id}` room receives the event in each socket; all tabs update.
|
- **Multiple tabs / devices** → the same `user-{id}` room receives the event in each socket; all tabs update. Badge sync is driven by `unread-count-update`, not a per-item `notification-read` event.
|
||||||
- **Disabled categories** (planned) → service should early-return without DB write if the user has opted out, otherwise persist but don't push (so it shows in history but not as a toast).
|
- **Disabled categories** (planned) → service should early-return without DB write if the user has opted out, otherwise persist but don't push (so it shows in history but not as a toast).
|
||||||
- **High volume** (e.g. fan-out to thousands of sellers) → today every notification is a separate Mongo insert + socket emit. For mass announcements, consider `insertMany` + per-room broadcast. The 50ms stagger in [[Purchase Request Flow]] mitigates the worst case.
|
- **High volume** (e.g. fan-out to thousands of sellers) → today every notification is a separate Mongo insert + socket emit. For mass announcements, consider `insertMany` + per-room broadcast. The 50ms stagger in [[Purchase Request Flow]] mitigates the worst case.
|
||||||
- **Stale unread count** → if the frontend trusts a stale React Query cache, it can show wrong numbers; always reconcile against `unread-count` on bell-icon open.
|
- **Stale unread count** → if the frontend trusts a stale React Query cache, it can show wrong numbers; always reconcile against `unread-count` on bell-icon open.
|
||||||
|
- **90-day TTL** → notifications older than 90 days are silently removed from MongoDB. The frontend should not assume a notification persists indefinitely.
|
||||||
|
|
||||||
> [!tip] Always set `actionUrl`
|
> [!tip] Always set `actionUrl`
|
||||||
> Every notification should have a deep-link target. Notifications without `actionUrl` lead to dead clicks. The factory methods in `NotificationService` (e.g. `notifyNewOfferReceived`) already enforce this — keep the pattern when adding new helpers.
|
> Every notification should have a deep-link target. Notifications without `actionUrl` lead to dead clicks. The factory methods in `NotificationService` (e.g. `notifyNewOfferReceived`) already enforce this — keep the pattern when adding new helpers.
|
||||||
@@ -152,4 +162,4 @@ sequenceDiagram
|
|||||||
- Backend: `backend/src/services/notification/routes.ts`
|
- Backend: `backend/src/services/notification/routes.ts`
|
||||||
- Backend: `backend/src/models/Notification.ts`
|
- Backend: `backend/src/models/Notification.ts`
|
||||||
- Frontend: `frontend/src/layouts/components/notifications-drawer/`
|
- Frontend: `frontend/src/layouts/components/notifications-drawer/`
|
||||||
- Frontend: socket provider (joins `user-{id}` and listens for `new-notification`)
|
- Frontend: socket provider (joins `user-{id}` and listens for `new-notification` and `unread-count-update`)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ related_apis: ["POST /api/auth/passkey/register/challenge", "POST /api/auth/pass
|
|||||||
|
|
||||||
# Passkey (WebAuthn) Flow
|
# Passkey (WebAuthn) Flow
|
||||||
|
|
||||||
Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenges, validates signed assertions, stores credential metadata under `User.passkeys[]`, and finally issues the same JWT pair as the password flow.
|
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||||
|
|
||||||
|
Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenges, cryptographically validates attestations and assertions via `@simplewebauthn/server`, stores credential metadata under `User.passkeys[]`, and finally issues the same JWT pair as the password flow.
|
||||||
|
|
||||||
## Actors
|
## Actors
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
|
|||||||
- For **registration**, the user is already authenticated via password or Google (the `/passkey/register/*` routes are behind `authService.authenticateToken`).
|
- For **registration**, the user is already authenticated via password or Google (the `/passkey/register/*` routes are behind `authService.authenticateToken`).
|
||||||
- For **sign-in**, no auth is required — the authenticator's credential ID identifies the user.
|
- For **sign-in**, no auth is required — the authenticator's credential ID identifies the user.
|
||||||
- Env vars consumed by the frontend (commonly `NEXT_PUBLIC_PASSKEY_RP_ID`, `NEXT_PUBLIC_PASSKEY_RP_NAME`, `NEXT_PUBLIC_PASSKEY_ORIGIN` per the codebase conventions) drive WebAuthn options on the client.
|
- Env vars consumed by the frontend (commonly `NEXT_PUBLIC_PASSKEY_RP_ID`, `NEXT_PUBLIC_PASSKEY_RP_NAME`, `NEXT_PUBLIC_PASSKEY_ORIGIN` per the codebase conventions) drive WebAuthn options on the client.
|
||||||
|
- **Important:** `next.config.ts` rewrites `/api/:path*` directly to the Express backend. There are **no** Next.js API route handler files for passkey paths — calls go straight to Express. Configure `PASSKEY_RP_ORIGIN` (and the corresponding `NEXT_PUBLIC_*` vars) to the frontend origin so the Express handler and the browser agree on the expected origin during challenge verification.
|
||||||
|
|
||||||
## Registration flow
|
## Registration flow
|
||||||
|
|
||||||
@@ -38,13 +41,11 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
|
|||||||
6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`):
|
6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`):
|
||||||
- Looks up the stored challenge → `{ userId }`. Deletes it (single-use).
|
- Looks up the stored challenge → `{ userId }`. Deletes it (single-use).
|
||||||
- Loads `User.findById(userId)`.
|
- Loads `User.findById(userId)`.
|
||||||
- Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: 'simulated-public-key', counter: 0, deviceType: 'platform', deviceName: 'Default Device', createdAt: now }`.
|
- Calls `verifyRegistrationResponse()` from `@simplewebauthn/server`, which cryptographically validates the attestation object and extracts the COSE public key.
|
||||||
|
- Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: Buffer.from(webAuthnCredential.publicKey).toString('base64url'), counter: webAuthnCredential.counter, deviceType, deviceName, createdAt: now }`.
|
||||||
- Saves.
|
- Saves.
|
||||||
7. Frontend re-fetches `GET /api/auth/passkey/list` and renders the new entry.
|
7. Frontend re-fetches `GET /api/auth/passkey/list` and renders the new entry.
|
||||||
|
|
||||||
> [!warning] Attestation validation is stubbed
|
|
||||||
> `passkeyService.verifyRegistration` currently **does not** parse the attestation object or extract the real COSE public key — see the comment block at `passkeyService.ts:122-128` ("In a real implementation, you would..."). The `publicKey` field is the literal string `'simulated-public-key'`. This means a malicious client could register an attacker-controlled credential ID under any user; harden this before production. Use `@simplewebauthn/server` to parse attestation and store the verified public key.
|
|
||||||
|
|
||||||
## Authentication flow
|
## Authentication flow
|
||||||
|
|
||||||
1. From `/auth/jwt/sign-in`, the user clicks **"Sign in with passkey"**.
|
1. From `/auth/jwt/sign-in`, the user clicks **"Sign in with passkey"**.
|
||||||
@@ -56,8 +57,10 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
|
|||||||
7. Backend `passkeyService.verifyAuthentication(challenge, assertion)` (`:149-272`):
|
7. Backend `passkeyService.verifyAuthentication(challenge, assertion)` (`:149-272`):
|
||||||
- Confirms the challenge exists (and deletes it).
|
- Confirms the challenge exists (and deletes it).
|
||||||
- `User.findOne({ 'passkeys.id': assertion.id })` — finds the user whose passkey matches the credential ID supplied by the authenticator.
|
- `User.findOne({ 'passkeys.id': assertion.id })` — finds the user whose passkey matches the credential ID supplied by the authenticator.
|
||||||
- `passkey.counter += 1` (the schema stores a counter; a real implementation must reject replays where the new counter is not strictly greater than the stored one).
|
- Calls `verifyAuthenticationResponse()` from `@simplewebauthn/server`, passing the stored base64url-encoded COSE public key. This cryptographically verifies the signature over the authenticator data + client data hash.
|
||||||
- Issues JWT access + refresh tokens directly via `jwt.sign(...)` (`:230-248`). Note: these are signed by the same `config.jwtSecret` as in `authService`, so they are interchangeable with password-issued tokens.
|
- Updates `passkey.counter` with the verified counter value returned by the library.
|
||||||
|
- Issues JWT access + refresh tokens directly via `jwt.sign(...)` (`:230-248`). These are signed by the same `config.jwtSecret` as `authService`, so they are interchangeable with password-issued tokens.
|
||||||
|
- Persists the refresh token: `user.refreshTokens.push(refreshToken); await user.save()` (`:281-282`). The standard `/api/auth/refresh-token` endpoint will accept passkey-issued tokens.
|
||||||
8. Response: `{ success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }`.
|
8. Response: `{ success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }`.
|
||||||
9. Frontend stores tokens in `localStorage` and redirects to the dashboard.
|
9. Frontend stores tokens in `localStorage` and redirects to the dashboard.
|
||||||
|
|
||||||
@@ -79,10 +82,10 @@ sequenceDiagram
|
|||||||
BE->>BE: generateRegistrationChallenge(userId)\nstore in Map
|
BE->>BE: generateRegistrationChallenge(userId)\nstore in Map
|
||||||
BE-->>FE: { challenge, rpId, ... }
|
BE-->>FE: { challenge, rpId, ... }
|
||||||
FE->>W: navigator.credentials.create({ publicKey })
|
FE->>W: navigator.credentials.create({ publicKey })
|
||||||
W-->>FE: PublicKeyCredential
|
W-->>FE: PublicKeyCredential (attestation)
|
||||||
FE->>BE: POST /api/auth/passkey/register { challenge, credential }
|
FE->>BE: POST /api/auth/passkey/register { challenge, credential }
|
||||||
BE->>BE: verifyRegistration → consume challenge
|
BE->>BE: verifyRegistrationResponse() — attestation verified\nCOSE public key extracted
|
||||||
BE->>DB: user.passkeys.push({ id, counter, deviceType })
|
BE->>DB: user.passkeys.push({ id, publicKey (base64url COSE), counter, deviceType })
|
||||||
BE-->>FE: { success: true }
|
BE-->>FE: { success: true }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -98,7 +101,8 @@ sequenceDiagram
|
|||||||
BE->>BE: consume challenge
|
BE->>BE: consume challenge
|
||||||
BE->>DB: User.findOne({ 'passkeys.id': assertion.id })
|
BE->>DB: User.findOne({ 'passkeys.id': assertion.id })
|
||||||
DB-->>BE: user with matching passkey
|
DB-->>BE: user with matching passkey
|
||||||
BE->>DB: passkey.counter += 1
|
BE->>BE: verifyAuthenticationResponse() — signature verified\nagainst stored COSE public key
|
||||||
|
BE->>DB: passkey.counter updated\nuser.refreshTokens.push(refreshToken)
|
||||||
BE->>BE: jwt.sign(access) / jwt.sign(refresh)
|
BE->>BE: jwt.sign(access) / jwt.sign(refresh)
|
||||||
BE-->>FE: { success, user, tokens }
|
BE-->>FE: { success, user, tokens }
|
||||||
FE->>FE: localStorage.setItem(tokens)
|
FE->>FE: localStorage.setItem(tokens)
|
||||||
@@ -119,8 +123,8 @@ sequenceDiagram
|
|||||||
|
|
||||||
## Database writes
|
## Database writes
|
||||||
|
|
||||||
- **`users.passkeys`** — append on register, increment `counter` on each successful auth, splice on delete.
|
- **`users.passkeys`** — append on register (stores real base64url-encoded COSE public key), increment `counter` on each successful auth, splice on delete.
|
||||||
- A new refresh token is **not** appended to `user.refreshTokens` in the current passkey path (the JWT is signed directly without round-tripping through `authService.generateRefreshToken`). This means the password-flow refresh-token allow-list does not apply to passkey logins. See edge cases.
|
- **`users.refreshTokens`** — the passkey authentication path pushes the new refresh token into `user.refreshTokens[]` (`passkeyService.ts:281-282`) and saves the document. Passkey-issued refresh tokens are valid for the standard `/api/auth/refresh-token` endpoint.
|
||||||
|
|
||||||
## Socket events emitted
|
## Socket events emitted
|
||||||
|
|
||||||
@@ -138,16 +142,12 @@ sequenceDiagram
|
|||||||
- **Challenge expired or unknown** → backend `Invalid or expired challenge` (`:117`). Frontend asks user to retry.
|
- **Challenge expired or unknown** → backend `Invalid or expired challenge` (`:117`). Frontend asks user to retry.
|
||||||
- **Credential ID does not match any user** → `404 Passkey not found` (`passkeyRoutes.ts:40-44`). UX should suggest signing in by email instead.
|
- **Credential ID does not match any user** → `404 Passkey not found` (`passkeyRoutes.ts:40-44`). UX should suggest signing in by email instead.
|
||||||
- **Multi-instance backend** (challenge stored on instance A, verified on instance B) → verification fails. Fix by moving `storedChallenges` to Redis.
|
- **Multi-instance backend** (challenge stored on instance A, verified on instance B) → verification fails. Fix by moving `storedChallenges` to Redis.
|
||||||
- **Replay** — current implementation does not strictly enforce monotonic counter; revisit before production.
|
- **Replay / cloned authenticator** — `verifyAuthenticationResponse()` from `@simplewebauthn/server` checks that the new counter is strictly greater than the stored counter and will reject replays.
|
||||||
- **Refresh-token rotation gap** — passkey-issued refresh tokens are not added to `user.refreshTokens[]`. The standard `/api/auth/refresh-token` will reject them on the next refresh. Until fixed, treat passkey access tokens as short-lived (the user must passkey-sign-in again after expiry) or unify token issuance through `authService.generateRefreshToken` and persist them.
|
|
||||||
|
|
||||||
> [!warning] Production hardening checklist
|
> [!note] Production hardening checklist
|
||||||
> 1. Replace stub attestation parsing with `@simplewebauthn/server`.
|
> 1. Move challenge storage to Redis to support multi-instance deploys.
|
||||||
> 2. Persist the COSE public key, not a stub string.
|
> 2. Add `excludeCredentials` during registration to prevent re-registering the same passkey.
|
||||||
> 3. Enforce strictly increasing counter (signal of cloned authenticator if not).
|
> 3. Ensure `PASSKEY_RP_ORIGIN` matches the actual frontend origin (no Next.js intermediary — rewrites go straight to Express).
|
||||||
> 4. Move challenge storage to Redis to support multi-instance deploys.
|
|
||||||
> 5. Add `excludeCredentials` during registration to prevent re-registering the same passkey.
|
|
||||||
> 6. Push the passkey-issued refresh token into `user.refreshTokens[]`.
|
|
||||||
|
|
||||||
## Linked flows
|
## Linked flows
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,22 @@
|
|||||||
title: Password Reset Flow
|
title: Password Reset Flow
|
||||||
tags: [flow, auth, password-reset, email]
|
tags: [flow, auth, password-reset, email]
|
||||||
related_models: ["[[User]]"]
|
related_models: ["[[User]]"]
|
||||||
related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code"]
|
related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code", "POST /api/auth/reset-password"]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> [!caution] Audit note — last reviewed 2026-05-29
|
||||||
|
> Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches.
|
||||||
|
|
||||||
# Password Reset Flow
|
# Password Reset Flow
|
||||||
|
|
||||||
Self-service password recovery: request a 6-digit code by email, submit it with the new password.
|
Self-service password recovery. There are **two separate reset endpoints** with different security characteristics:
|
||||||
|
|
||||||
|
| Endpoint | Mechanism | Password complexity enforced? |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /api/auth/reset-password-with-code` | 6-digit emailed code | **No** — no validation middleware |
|
||||||
|
| `POST /api/auth/reset-password` | Token-based (link in email) | **Yes** — `passwordResetValidation` requires uppercase + lowercase + digit |
|
||||||
|
|
||||||
|
The primary UI-driven path uses the **code-based** endpoint. The token-based endpoint is a legacy/alternative variant.
|
||||||
|
|
||||||
## Actors
|
## Actors
|
||||||
|
|
||||||
@@ -30,16 +40,16 @@ Self-service password recovery: request a 6-digit code by email, submit it with
|
|||||||
3. Frontend POSTs `POST /api/auth/request-password-reset { email }`.
|
3. Frontend POSTs `POST /api/auth/request-password-reset { email }`.
|
||||||
4. Backend `authController.requestPasswordReset` (`:542-574`):
|
4. Backend `authController.requestPasswordReset` (`:542-574`):
|
||||||
- `User.findOne({ email, status: "active" })`. If absent, returns `200` with the same generic message — **no enumeration**.
|
- `User.findOne({ email, status: "active" })`. If absent, returns `200` with the same generic message — **no enumeration**.
|
||||||
- Generates a 6-digit code via `authService.generateVerificationCode()`.
|
- Generates a **6-digit** code via `authService.generateVerificationCode()` (`Math.floor(100000 + Math.random() * 900000)`).
|
||||||
- Saves `passwordResetCode` and `passwordResetCodeExpires = now + 3_600_000 ms` on the user.
|
- Saves `passwordResetCode` and `passwordResetCodeExpires = now + 3_600_000 ms` on the user.
|
||||||
- Calls `emailService.sendPasswordResetCodeEmail(email, firstName, code)`.
|
- Calls `emailService.sendPasswordResetCodeEmail(email, firstName, code)`.
|
||||||
5. Response: `200 "If an account with this email exists, a password reset code has been sent"` regardless of outcome.
|
5. Response: `200 "If an account with this email exists, a password reset code has been sent"` regardless of outcome.
|
||||||
6. User receives the email and enters the code + new password on `/auth/jwt/update-password`.
|
6. User receives the email and enters the code + new password on `/auth/jwt/update-password`.
|
||||||
7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`.
|
7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`.
|
||||||
8. Backend `authController.resetPasswordWithCode` (`:611-657`):
|
8. Backend `authController.resetPasswordWithCode` (`:611-657`):
|
||||||
- Validates code format `/^\d{6}$/`.
|
- Validates code format `/^\d{6}$/` — a code of any other length (e.g., 8 digits) will **always fail** here.
|
||||||
- `User.findOne({ email, passwordResetCode: code, passwordResetCodeExpires: { $gt: now }, status: "active" })`. Mismatch → `400 Invalid or expired reset code`.
|
- `User.findOne({ email, passwordResetCode: code, passwordResetCodeExpires: { $gt: now }, status: "active" })`. Mismatch → `400 Invalid or expired reset code`.
|
||||||
- Hashes the new password with bcrypt cost 12.
|
- Hashes the new password with bcrypt cost 12. **No password complexity validation is applied** — weak passwords such as `123456` or `aaaaaa` are accepted without error.
|
||||||
- Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions.
|
- Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions.
|
||||||
- Saves.
|
- Saves.
|
||||||
9. Response: `200 "Password reset successfully"`. Frontend redirects to `/auth/jwt/sign-in` for a fresh login.
|
9. Response: `200 "Password reset successfully"`. Frontend redirects to `/auth/jwt/sign-in` for a fresh login.
|
||||||
@@ -59,7 +69,7 @@ sequenceDiagram
|
|||||||
FE->>BE: POST /api/auth/request-password-reset { email }
|
FE->>BE: POST /api/auth/request-password-reset { email }
|
||||||
BE->>DB: User.findOne({ email, status: "active" })
|
BE->>DB: User.findOne({ email, status: "active" })
|
||||||
alt user found
|
alt user found
|
||||||
BE->>BE: code = generateVerificationCode()
|
BE->>BE: code = generateVerificationCode() [6 digits]
|
||||||
BE->>DB: user.passwordResetCode = code\nexpires = +1h
|
BE->>DB: user.passwordResetCode = code\nexpires = +1h
|
||||||
BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code)
|
BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code)
|
||||||
MAIL-->>U: Email with 6-digit code
|
MAIL-->>U: Email with 6-digit code
|
||||||
@@ -68,8 +78,9 @@ sequenceDiagram
|
|||||||
|
|
||||||
U->>FE: Enter code + new password
|
U->>FE: Enter code + new password
|
||||||
FE->>BE: POST /api/auth/reset-password-with-code { email, code, password }
|
FE->>BE: POST /api/auth/reset-password-with-code { email, code, password }
|
||||||
|
BE->>BE: isValidVerificationCode(code) [/^\d{6}$/]
|
||||||
BE->>DB: User.findOne({ email, code, expires>now })
|
BE->>DB: User.findOne({ email, code, expires>now })
|
||||||
BE->>BE: bcrypt.hash(password, 12)
|
BE->>BE: bcrypt.hash(password, 12) [no complexity check]
|
||||||
BE->>DB: user.password = hash\nuser.refreshTokens = []\nclear reset fields
|
BE->>DB: user.password = hash\nuser.refreshTokens = []\nclear reset fields
|
||||||
BE-->>FE: 200 "Password reset successfully"
|
BE-->>FE: 200 "Password reset successfully"
|
||||||
FE-->>U: Redirect /auth/jwt/sign-in
|
FE-->>U: Redirect /auth/jwt/sign-in
|
||||||
@@ -77,11 +88,26 @@ sequenceDiagram
|
|||||||
|
|
||||||
## API calls
|
## API calls
|
||||||
|
|
||||||
| Method | Endpoint | Source |
|
| Method | Endpoint | Source | Notes |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` |
|
| `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` | Sends 6-digit code by email |
|
||||||
| `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` |
|
| `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` | Code-based; **no complexity validation** |
|
||||||
| `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` (legacy token-based variant) |
|
| `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` | Token-based variant; enforces complexity via `passwordResetValidation` |
|
||||||
|
|
||||||
|
## Two-endpoint comparison
|
||||||
|
|
||||||
|
> [!important] Code-based vs token-based reset endpoints
|
||||||
|
>
|
||||||
|
> **`POST /api/auth/reset-password-with-code`** (primary UI path)
|
||||||
|
> - Uses a 6-digit numeric code delivered by email.
|
||||||
|
> - `isValidVerificationCode()` validates with `/^\d{6}$/`. An 8-digit code will always fail.
|
||||||
|
> - Has **no password complexity middleware**. Any string is accepted as the new password.
|
||||||
|
>
|
||||||
|
> **`POST /api/auth/reset-password`** (legacy token-based path)
|
||||||
|
> - Uses a URL token (link in email) rather than a short code.
|
||||||
|
> - Enforces password complexity via `passwordResetValidation` middleware (requires uppercase, lowercase, and a digit).
|
||||||
|
>
|
||||||
|
> The two endpoints provide inconsistent security guarantees. Users who reset via the code flow can set a weak password that would be rejected by the token flow.
|
||||||
|
|
||||||
## Database writes
|
## Database writes
|
||||||
|
|
||||||
@@ -100,7 +126,7 @@ sequenceDiagram
|
|||||||
## Error / edge cases
|
## Error / edge cases
|
||||||
|
|
||||||
- **Unknown email** → always `200`, generic message. No enumeration.
|
- **Unknown email** → always `200`, generic message. No enumeration.
|
||||||
- **Invalid code format** → `400` from `isValidVerificationCode` guard before DB lookup.
|
- **Invalid code format** → `400` from `isValidVerificationCode` guard before DB lookup. Note: the `authController.ts` comment mentions "8 digits" but the actual implementation generates and validates exactly 6 digits — any 8-digit code will be rejected.
|
||||||
- **Expired code** (>1h) → `400 Invalid or expired reset code`.
|
- **Expired code** (>1h) → `400 Invalid or expired reset code`.
|
||||||
- **Multiple parallel requests** → each overwrites the previous `passwordResetCode`; the latest email wins, prior codes silently invalidated.
|
- **Multiple parallel requests** → each overwrites the previous `passwordResetCode`; the latest email wins, prior codes silently invalidated.
|
||||||
- **User attempts reset on deleted account** → treated as unknown (no email sent, `200` returned).
|
- **User attempts reset on deleted account** → treated as unknown (no email sent, `200` returned).
|
||||||
@@ -110,6 +136,17 @@ sequenceDiagram
|
|||||||
> [!warning] Plaintext code in logs
|
> [!warning] Plaintext code in logs
|
||||||
> Same as [[Registration Flow]]: the reset code is `console.log`-ed by the controller in all environments. Restrict log access in production or gate the log behind `NODE_ENV !== 'production'`.
|
> Same as [[Registration Flow]]: the reset code is `console.log`-ed by the controller in all environments. Restrict log access in production or gate the log behind `NODE_ENV !== 'production'`.
|
||||||
|
|
||||||
|
> [!bug] Controller comment says "8 digits" but code generates 6
|
||||||
|
> The comment in `authController.ts` describes an 8-digit code, but `authService.generateVerificationCode()` uses `Math.floor(100000 + Math.random() * 900000)`, which produces a number in the range 100000–999999 (exactly 6 digits). `isValidVerificationCode()` enforces `/^\d{6}$/`. Any 8-digit value sent to `reset-password-with-code` will always be rejected. The comment is wrong; the 6-digit implementation and validation are correct and consistent.
|
||||||
|
|
||||||
|
## Known issues summary
|
||||||
|
|
||||||
|
| Issue | Severity | Details |
|
||||||
|
|---|---|---|
|
||||||
|
| No password complexity on code-based reset | Security gap | `POST /api/auth/reset-password-with-code` has no complexity middleware; weak passwords accepted |
|
||||||
|
| Controller comment says 8 digits | Doc bug | Comment is wrong; code generates and validates exactly 6 digits |
|
||||||
|
| Inconsistent complexity between reset endpoints | Security gap | Token-based reset enforces complexity; code-based reset does not |
|
||||||
|
|
||||||
## Linked flows
|
## Linked flows
|
||||||
|
|
||||||
- [[Authentication Flow]] — user re-signs-in after reset.
|
- [[Authentication Flow]] — user re-signs-in after reset.
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
title: Payment Flow - DePay & Web3
|
title: Payment Flow - DePay & Web3
|
||||||
tags: [flow, payment, web3, wagmi, walletconnect, bsc]
|
tags: [flow, payment, web3, wagmi, walletconnect, bsc]
|
||||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
|
related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
|
||||||
related_apis: ["POST /api/payment/decentralized/create", "POST /api/payment/decentralized/verify"]
|
related_apis: ["POST /api/payment/decentralized/save", "POST /api/payment/decentralized/verify/:paymentId"]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> [!caution] Audit — 2026-05-29
|
||||||
|
> This document was reviewed against the live codebase. **12 corrections applied** — endpoint paths, missing route bugs, TypeScript type gaps, a security issue, and stats undercounting. See inline ⚠️ callouts throughout.
|
||||||
|
|
||||||
# Payment Flow — DePay & Web3 (Wallet-Direct)
|
# Payment Flow — DePay & Web3 (Wallet-Direct)
|
||||||
|
|
||||||
> [!warning] Historical/legacy path
|
> [!warning] Historical/legacy path
|
||||||
@@ -36,11 +39,24 @@ Legacy alternative pay-in path: the buyer connects their own wallet (MetaMask /
|
|||||||
2. The connection emits an `accountsChanged` event; the web3 context (`frontend/src/web3/context/web3-provider.tsx`) stores `wallet.address` and `wallet.chainId`.
|
2. The connection emits an `accountsChanged` event; the web3 context (`frontend/src/web3/context/web3-provider.tsx`) stores `wallet.address` and `wallet.chainId`.
|
||||||
3. If `chainId !== 56` (BSC), the UI prompts a `wallet_switchEthereumChain` request.
|
3. If `chainId !== 56` (BSC), the UI prompts a `wallet_switchEthereumChain` request.
|
||||||
|
|
||||||
|
> [!warning] ⚠️ SECURITY: SIM_ bypass has no environment guard
|
||||||
|
> `web3-provider.tsx` generates `SIM_`-prefixed transaction hashes on wallet connection failure with **no `process.env.NODE_ENV` check**. In production, if a wallet connection fails, a `SIM_` hash can be submitted to the verify endpoint and may bypass on-chain verification checks. An explicit `if (process.env.NODE_ENV === 'production') throw` guard is required before generating simulation hashes.
|
||||||
|
|
||||||
### Phase 2 — Create intent on backend
|
### Phase 2 — Create intent on backend
|
||||||
|
|
||||||
4. Frontend POSTs `POST /api/payment/decentralized/save` with `{ purchaseRequestId, sellerOfferId, amount, fromAddress: wallet.address, token: 'USDT', network: 'bsc' }`. The backend records a `Payment` with `provider: 'other'` (or `'decentralized'` depending on enum extension), `direction: 'in'`, `status: 'pending'`, `blockchain.{network, token, sender, receiver: ESCROW_WALLET_ADDRESS}`. **Auth:** Bearer JWT required.
|
4. Frontend POSTs `POST /api/payment/decentralized/save` with `{ purchaseRequestId, sellerOfferId, amount, fromAddress: wallet.address, token: 'USDT', network: 'bsc' }`. The backend records a `Payment` with `provider: 'other'` (or `'decentralized'` — see TypeScript type note below), `direction: 'in'`, `status: 'pending'`, `blockchain.{network, token, sender, receiver: ESCROW_WALLET_ADDRESS}`. **Auth:** Bearer JWT required.
|
||||||
|
|
||||||
|
> [!warning] ⚠️ TypeScript type gap — `PaymentProvider`
|
||||||
|
> The frontend `PaymentProvider` type is defined as `'request.network' | 'test' | 'other'`. The values **`'shkeeper'`** and **`'decentralized'`** are missing from the union. Any UI provider-switch logic that branches on `provider` will fall through to an unknown/default state for these two providers. Add both to the type definition.
|
||||||
|
|
||||||
5. Response includes the **escrow wallet address** and the exact token amount (in decimals — for USDT-BEP20 that's 18 decimals; the helper `convertPaymentAmountForShkeeper` is shared from `currencyUtils.ts`).
|
5. Response includes the **escrow wallet address** and the exact token amount (in decimals — for USDT-BEP20 that's 18 decimals; the helper `convertPaymentAmountForShkeeper` is shared from `currencyUtils.ts`).
|
||||||
|
|
||||||
|
> [!warning] ⚠️ NOT IMPLEMENTED — `createDePayIntent()`
|
||||||
|
> The frontend action `createDePayIntent()` POSTs to `/payment/depay/intents`, which **does not exist** on the backend. Calling this action will always return 404. The working intent endpoint is `POST /api/payment/decentralized/save` (step 4 above). Do not use `createDePayIntent()` until a `/payment/depay/intents` route is added to the backend.
|
||||||
|
|
||||||
|
> [!warning] ⚠️ KNOWN BUG — `getProviderIntentEndpoint()` routing
|
||||||
|
> The `getProviderIntentEndpoint()` factory function **always** resolves to `/payment/request-network/intents` regardless of the `provider` argument passed in. Any SHKeeper checkout that calls this helper will POST to the wrong (Request Network) intent endpoint. This function requires a proper `switch`/`if` on `provider` before it can be used for non-Request-Network flows.
|
||||||
|
|
||||||
### Phase 3 — Token approval (ERC-20 / BEP-20)
|
### Phase 3 — Token approval (ERC-20 / BEP-20)
|
||||||
|
|
||||||
6. The frontend checks the user's current allowance via `useReadContract` / `allowance(owner, spender)` on the USDT contract.
|
6. The frontend checks the user's current allowance via `useReadContract` / `allowance(owner, spender)` on the USDT contract.
|
||||||
@@ -55,7 +71,7 @@ Legacy alternative pay-in path: the buyer connects their own wallet (MetaMask /
|
|||||||
|
|
||||||
### Phase 5 — Backend verification
|
### Phase 5 — Backend verification
|
||||||
|
|
||||||
11. Frontend POSTs `POST /api/payment/decentralized/verify/:paymentId` with `{ transactionHash }`. **Auth:** Bearer JWT required (owner or admin).
|
11. Frontend POSTs `POST /api/payment/decentralized/verify/:paymentId` with body `{ transactionHash }`. The `paymentId` is a **path parameter**. **Auth:** Bearer JWT required (owner or admin).
|
||||||
12. Backend `BSCTransactionVerifier.verifyTransaction(txHash)` (`decentralizedPaymentService.ts`):
|
12. Backend `BSCTransactionVerifier.verifyTransaction(txHash)` (`decentralizedPaymentService.ts`):
|
||||||
- JSON-RPC `eth_getTransactionReceipt` against `bsc-dataseed.binance.org`.
|
- JSON-RPC `eth_getTransactionReceipt` against `bsc-dataseed.binance.org`.
|
||||||
- Confirms `receipt.status === '0x1'` (success).
|
- Confirms `receipt.status === '0x1'` (success).
|
||||||
@@ -66,10 +82,18 @@ Legacy alternative pay-in path: the buyer connects their own wallet (MetaMask /
|
|||||||
- Triggers the **same funded-escrow cascade**: mark winning offer accepted, reject others, transition request to `payment`, create chat, send notifications, emit socket events.
|
- Triggers the **same funded-escrow cascade**: mark winning offer accepted, reject others, transition request to `payment`, create chat, send notifications, emit socket events.
|
||||||
14. Returns `{ status: 'confirmed', confirmations, blockNumber }`.
|
14. Returns `{ status: 'confirmed', confirmations, blockNumber }`.
|
||||||
|
|
||||||
|
> [!warning] ⚠️ Stats undercounting — `'completed'` not counted as successful
|
||||||
|
> The admin stats aggregate counts only payments with `status === 'confirmed'` as successful. DePay and SHKeeper payments reach **`'completed'`** as their terminal state (not `'confirmed'`), so the admin success count will be **artificially low**. The aggregate must include both `'confirmed'` and `'completed'` in the success set.
|
||||||
|
|
||||||
### Phase 6 — Frontend reaction
|
### Phase 6 — Frontend reaction
|
||||||
|
|
||||||
15. The checkout UI shows "Payment verified" with the block-explorer link (`https://bscscan.com/tx/{hash}`) and transitions to the awaiting-delivery state.
|
15. The checkout UI shows "Payment verified" with the block-explorer link (`https://bscscan.com/tx/{hash}`) and transitions to the awaiting-delivery state.
|
||||||
|
|
||||||
|
> [!warning] ⚠️ Non-existent status/confirm endpoints — dispute payment card
|
||||||
|
> The **dispute payment card** "Verify" button calls `getPaymentStatus()`, which internally hits `GET /payment/:id/status`. This route **does not exist** — there is no `/status` sub-route on any payment document endpoint. The call always returns 404. Similarly, `POST /payment/:id/confirm` **does not exist**; no `/confirm` sub-route is registered. Remove both from any frontend code paths and rely on socket events (`payment-update`, `payment-completed`) or the verify endpoint instead.
|
||||||
|
>
|
||||||
|
> Additionally, `cancelPayment()` in the web3 context is a **local UI state reset only** — it does **not** make an HTTP call. `DELETE /payment/:id` does not exist; there is no DELETE handler on any payment route.
|
||||||
|
|
||||||
## Sequence diagram
|
## Sequence diagram
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -89,7 +113,7 @@ sequenceDiagram
|
|||||||
opt chainId != 56
|
opt chainId != 56
|
||||||
FE->>W: wallet_switchEthereumChain(0x38)
|
FE->>W: wallet_switchEthereumChain(0x38)
|
||||||
end
|
end
|
||||||
FE->>BE: POST /api/payment/decentralized/create
|
FE->>BE: POST /api/payment/decentralized/save
|
||||||
BE->>DB: Payment.create({provider:"other", direction:"in", receiver:ESCROW})
|
BE->>DB: Payment.create({provider:"other", direction:"in", receiver:ESCROW})
|
||||||
BE-->>FE: { paymentId, escrowAddress, amount }
|
BE-->>FE: { paymentId, escrowAddress, amount }
|
||||||
opt allowance < amount
|
opt allowance < amount
|
||||||
@@ -100,7 +124,7 @@ sequenceDiagram
|
|||||||
W-->>FE: tx broadcast
|
W-->>FE: tx broadcast
|
||||||
W-->>BC: signed tx
|
W-->>BC: signed tx
|
||||||
BC-->>W: tx confirmed
|
BC-->>W: tx confirmed
|
||||||
FE->>BE: POST /api/payment/decentralized/verify { paymentId, txHash }
|
FE->>BE: POST /api/payment/decentralized/verify/:paymentId { txHash }
|
||||||
BE->>BC: eth_getTransactionReceipt(txHash)
|
BE->>BC: eth_getTransactionReceipt(txHash)
|
||||||
BC-->>BE: { status:0x1, blockNumber, logs }
|
BC-->>BE: { status:0x1, blockNumber, logs }
|
||||||
BE->>BC: eth_blockNumber
|
BE->>BC: eth_blockNumber
|
||||||
@@ -115,11 +139,30 @@ sequenceDiagram
|
|||||||
|
|
||||||
## API calls
|
## API calls
|
||||||
|
|
||||||
| Method | Endpoint | Source |
|
| Method | Endpoint | Notes | Source |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `POST` | `/api/payment/decentralized/create` | `decentralizedPaymentRoutes.ts` |
|
| `POST` | `/api/payment/decentralized/save` | Create intent | `decentralizedPaymentRoutes.ts` |
|
||||||
| `POST` | `/api/payment/decentralized/verify` | `decentralizedPaymentRoutes.ts` |
|
| `POST` | `/api/payment/decentralized/verify/:paymentId` | `paymentId` is a **path param** | `decentralizedPaymentRoutes.ts` |
|
||||||
| `GET` | `/api/payment/fetch-tx/:paymentId` | `paymentRoutes.ts` (manual rechecker) |
|
| `POST` | `/api/payment/payments/:id/fetch-tx` | Manual tx rechecker — **NO AUTH** (exploitable without credentials) | `paymentRoutes.ts` |
|
||||||
|
| ~~`POST /api/payment/decentralized/create`~~ | | ⚠️ **404 — does not exist.** Use `/save` instead. | — |
|
||||||
|
| ~~`GET /payment/:id/status`~~ | | ⚠️ **404 — does not exist.** No `/status` sub-route. | — |
|
||||||
|
| ~~`POST /payment/:id/confirm`~~ | | ⚠️ **404 — does not exist.** No `/confirm` sub-route. | — |
|
||||||
|
| ~~`DELETE /payment/:id`~~ | | ⚠️ **404 — does not exist.** `cancelPayment()` is UI-only. | — |
|
||||||
|
| ~~`POST /payment/depay/intents`~~ | | ⚠️ **NOT IMPLEMENTED** — `createDePayIntent()` target. | — |
|
||||||
|
|
||||||
|
> [!warning] ⚠️ `/api/payment/payments/:id/fetch-tx` has no authentication
|
||||||
|
> The endpoint `POST /api/payment/payments/:id/fetch-tx` (note the `/payments/` infix — the previously documented path `/api/payment/fetch-tx/:paymentId` was wrong on both method and path) accepts requests **without any authentication check**. Any unauthenticated caller can trigger a blockchain re-fetch for any payment ID. This must be gated behind at minimum an admin JWT before production use.
|
||||||
|
|
||||||
|
### Request Network sub-routes — NOT IMPLEMENTED
|
||||||
|
|
||||||
|
The following four Request Network payout/release/refund sub-paths are **not registered** in the backend router. All return 404:
|
||||||
|
|
||||||
|
| Path | Status |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/payment/request-network/:id/payout/initiate` | ⚠️ NOT IMPLEMENTED — 404 |
|
||||||
|
| `POST /api/payment/request-network/:id/payout/confirm` | ⚠️ NOT IMPLEMENTED — 404 |
|
||||||
|
| `POST /api/payment/request-network/:id/release/confirm` | ⚠️ NOT IMPLEMENTED — 404 |
|
||||||
|
| `POST /api/payment/request-network/:id/refund/confirm` | ⚠️ NOT IMPLEMENTED — 404 |
|
||||||
|
|
||||||
## Database writes
|
## Database writes
|
||||||
|
|
||||||
@@ -147,7 +190,7 @@ sequenceDiagram
|
|||||||
- **Tx hash already used by another payment** — `blockchain.transactionHash` has a sparse index (`Payment.ts:178`); a duplicate verification attempt finds the existing payment and returns its status.
|
- **Tx hash already used by another payment** — `blockchain.transactionHash` has a sparse index (`Payment.ts:178`); a duplicate verification attempt finds the existing payment and returns its status.
|
||||||
- **Wrong amount or wrong recipient** — must be enforced by log decoding (the verifier should reject if the `Transfer` event's `value` is less than expected or `to` ≠ escrow). The current verifier only checks the receipt's `status`; tightening this is recommended.
|
- **Wrong amount or wrong recipient** — must be enforced by log decoding (the verifier should reject if the `Transfer` event's `value` is less than expected or `to` ≠ escrow). The current verifier only checks the receipt's `status`; tightening this is recommended.
|
||||||
- **RPC throttling** — public BSC dataseed is generous but rate-limits exist; consider a dedicated RPC (Ankr, QuickNode) for production.
|
- **RPC throttling** — public BSC dataseed is generous but rate-limits exist; consider a dedicated RPC (Ankr, QuickNode) for production.
|
||||||
- **User closes the browser before verification** — the on-chain transfer still happened. A periodic reconciliation job (`/api/payment/fetch-tx/:paymentId`) or admin tool can replay verification from the txHash.
|
- **User closes the browser before verification** — the on-chain transfer still happened. A periodic reconciliation job (`POST /api/payment/payments/:id/fetch-tx`) or admin tool can replay verification from the txHash.
|
||||||
- **Confirmation depth** — currently 1 confirmation triggers `completed`. For larger amounts consider gating release until ≥ 12 confirmations on BSC.
|
- **Confirmation depth** — currently 1 confirmation triggers `completed`. For larger amounts consider gating release until ≥ 12 confirmations on BSC.
|
||||||
|
|
||||||
> [!warning] Verify the event log, not just the receipt
|
> [!warning] Verify the event log, not just the receipt
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
title: Payment Flow - SHKeeper
|
title: Payment Flow - SHKeeper
|
||||||
tags: [flow, payment, shkeeper, crypto, escrow, webhook]
|
tags: [flow, payment, shkeeper, crypto, escrow, webhook]
|
||||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"]
|
related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"]
|
||||||
related_apis: ["POST /api/payment/shkeeper/create", "POST /api/payment/shkeeper/webhook", "GET /api/payment/shkeeper/status/:id"]
|
related_apis: ["POST /api/payment/shkeeper/create", "POST /api/payment/shkeeper/webhook", "POST /api/payment/:id/release", "POST /api/payment/:id/refund"]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> [!caution] Audit — 2026-05-29
|
||||||
|
> This document was reviewed against the live codebase. **2 corrections applied**: the non-existent HTTP polling endpoint has been removed (status updates arrive via socket only), and the release/refund/confirm paths have been corrected to remove the erroneous `/shkeeper/` segment.
|
||||||
|
|
||||||
# Payment Flow — SHKeeper (Crypto Pay-In)
|
# Payment Flow — SHKeeper (Crypto Pay-In)
|
||||||
|
|
||||||
> [!warning] Historical migration document
|
> [!warning] Historical migration document
|
||||||
@@ -32,7 +35,7 @@ Pulled from env: `SHKEEPER_NETWORKS` and `SHKEEPER_ALLOWED_TOKENS` (`shkeeperSer
|
|||||||
- **PaymentCoordinator** (`backend/src/services/payment/paymentCoordinator.ts`) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm).
|
- **PaymentCoordinator** (`backend/src/services/payment/paymentCoordinator.ts`) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm).
|
||||||
- **MongoDB** — `payments`, `purchaserequests`, `selleroffers`, `chats`, `notifications`.
|
- **MongoDB** — `payments`, `purchaserequests`, `selleroffers`, `chats`, `notifications`.
|
||||||
- **Redis** — `paymentRedisService` (wallet-address cache, 2 h TTL).
|
- **Redis** — `paymentRedisService` (wallet-address cache, 2 h TTL).
|
||||||
- **Socket.IO** — `payment-created`, `seller-offer-update`, `purchase-request-update`.
|
- **Socket.IO** — `payment-created`, `payment-update`, `template-checkout-payment-confirmed`, `seller-offer-update`, `purchase-request-update`.
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
@@ -122,7 +125,11 @@ stateDiagram-v2
|
|||||||
|
|
||||||
### Phase 4 — Frontend reaction
|
### Phase 4 — Frontend reaction
|
||||||
|
|
||||||
21. The buyer's checkout page subscribes to socket events and polls `GET /api/payment/shkeeper/status/{paymentId}`. When status flips to `completed`, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery).
|
21. The buyer's checkout page subscribes to socket events (`payment-update`, `template-checkout-payment-confirmed`). When the status flips to `completed`, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery).
|
||||||
|
|
||||||
|
> [!warning] ⚠️ No HTTP polling endpoint — socket events only
|
||||||
|
> `GET /api/payment/shkeeper/status/:paymentId` **does not exist** — there is no polling route in `shkeeperRoutes.ts`. Status transitions must be observed via Socket.IO events (`payment-update`, `template-checkout-payment-confirmed`). Any frontend code path that polls this URL will always receive 404. Remove HTTP polling and rely solely on the socket subscription.
|
||||||
|
|
||||||
22. The seller's dashboard receives `seller-offer-update` `payment-completed` and surfaces the green "Order paid — start preparing" banner.
|
22. The seller's dashboard receives `seller-offer-update` `payment-completed` and surfaces the green "Order paid — start preparing" banner.
|
||||||
|
|
||||||
## Sequence diagram
|
## Sequence diagram
|
||||||
@@ -169,7 +176,7 @@ sequenceDiagram
|
|||||||
BE->>IO: emit seller-{winner} 'payment-completed'
|
BE->>IO: emit seller-{winner} 'payment-completed'
|
||||||
BE->>IO: emit seller-{loser_i} 'offer-rejected'
|
BE->>IO: emit seller-{loser_i} 'offer-rejected'
|
||||||
BE-->>SK: 202 OK
|
BE-->>SK: 202 OK
|
||||||
IO-->>FE: status updated
|
IO-->>FE: payment-update / status updated
|
||||||
IO-->>S: dashboard updates
|
IO-->>S: dashboard updates
|
||||||
FE-->>B: "Payment received ✓"
|
FE-->>B: "Payment received ✓"
|
||||||
```
|
```
|
||||||
@@ -180,8 +187,21 @@ sequenceDiagram
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `POST` | `/api/payment/shkeeper/create` | Create pay-in intent | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
|
| `POST` | `/api/payment/shkeeper/create` | Create pay-in intent | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
|
||||||
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | `shkeeperWebhook.handleShkeeperWebhook` |
|
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | `shkeeperWebhook.handleShkeeperWebhook` |
|
||||||
| `GET` | `/api/payment/shkeeper/status/:paymentId` | Frontend polling | `shkeeperRoutes.ts` |
|
| `POST` | `/api/payment/:id/release` | Release escrow to seller | `paymentRoutes.ts` |
|
||||||
| `GET` | `/api/payment/fetch-tx/:paymentId` | Manual transaction lookup | `paymentRoutes.ts` |
|
| `POST` | `/api/payment/:id/release/confirm` | Confirm escrow release | `paymentRoutes.ts` |
|
||||||
|
| `POST` | `/api/payment/:id/refund` | Refund to buyer | `paymentRoutes.ts` |
|
||||||
|
| `POST` | `/api/payment/:id/refund/confirm` | Confirm buyer refund | `paymentRoutes.ts` |
|
||||||
|
| `POST` | `/api/payment/payments/:id/fetch-tx` | Manual transaction lookup | `paymentRoutes.ts` |
|
||||||
|
| ~~`GET /api/payment/shkeeper/status/:paymentId`~~ | | ⚠️ **404 — does not exist.** Use socket events instead. | — |
|
||||||
|
|
||||||
|
> [!warning] ⚠️ Release/refund path correction
|
||||||
|
> Previously documented paths included a `/shkeeper/` segment that does **not** exist in the router:
|
||||||
|
> - ~~`POST /api/payment/shkeeper/:id/release`~~ → correct: `POST /api/payment/:id/release`
|
||||||
|
> - ~~`POST /api/payment/shkeeper/:id/release/confirm`~~ → correct: `POST /api/payment/:id/release/confirm`
|
||||||
|
> - ~~`POST /api/payment/shkeeper/:id/refund`~~ → correct: `POST /api/payment/:id/refund`
|
||||||
|
> - ~~`POST /api/payment/shkeeper/:id/refund/confirm`~~ → correct: `POST /api/payment/:id/refund/confirm`
|
||||||
|
>
|
||||||
|
> The `/shkeeper/` infix never existed on release/refund routes. These are generic payment lifecycle endpoints shared across all providers.
|
||||||
|
|
||||||
## Database writes
|
## Database writes
|
||||||
|
|
||||||
@@ -195,6 +215,8 @@ sequenceDiagram
|
|||||||
## Socket events emitted
|
## Socket events emitted
|
||||||
|
|
||||||
- **`payment-created`** (global) — broadcast on intent creation.
|
- **`payment-created`** (global) — broadcast on intent creation.
|
||||||
|
- **`payment-update`** — status change notifications to the buyer's checkout page.
|
||||||
|
- **`template-checkout-payment-confirmed`** — for template checkout flows.
|
||||||
- **`seller-offer-update`** with `eventType: 'payment-completed'` → winning seller.
|
- **`seller-offer-update`** with `eventType: 'payment-completed'` → winning seller.
|
||||||
- **`seller-offer-update`** with `eventType: 'offer-rejected'` → each losing seller.
|
- **`seller-offer-update`** with `eventType: 'offer-rejected'` → each losing seller.
|
||||||
- **`purchase-request-update`** with `eventType: 'status-changed'` (via `PurchaseRequestService`) → `request-{id}`.
|
- **`purchase-request-update`** with `eventType: 'status-changed'` (via `PurchaseRequestService`) → `request-{id}`.
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ related_models: ["[[PurchaseRequest]]", "[[Category]]", "[[Address]]", "[[Seller
|
|||||||
related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"]
|
related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> [!warning] Audit — 2026-05-29
|
||||||
|
> This document was corrected against the live codebase. Key changes: status enum updated (added `pending_payment`, `active`; removed undocumented `finalized`/`archived`); urgency values expanded to include `urgent`; sellers endpoint corrected; attachment upload endpoint corrected; `request-cancelled` socket event removed (non-existent); `new-purchase-request` fan-out target corrected to shared `sellers` room; socket room join/leave events documented; description minimum corrected to 5 chars; PUT vs PATCH mismatch flagged as known bug; two frontend actions hitting non-existent backend endpoints flagged as not implemented.
|
||||||
|
|
||||||
# Purchase Request Flow
|
# Purchase Request Flow
|
||||||
|
|
||||||
A **buyer** drafts and publishes a purchase request describing what they want to source. Once published, the request becomes visible to sellers (either all sellers or a curated subset), kicking off [[Seller Offer Flow]].
|
A **buyer** drafts and publishes a purchase request describing what they want to source. Once published, the request becomes visible to sellers (either all sellers or a curated subset), kicking off [[Seller Offer Flow]].
|
||||||
@@ -31,7 +34,10 @@ Status progression is enforced by `STATUS_PROGRESSION_ORDER` in `PurchaseRequest
|
|||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
[*] --> pending: createPurchaseRequest()
|
[*] --> pending: createPurchaseRequest()
|
||||||
pending --> received_offers: first SellerOffer saved\nSellerOfferService.createOffer
|
pending --> active: request activated
|
||||||
|
pending --> pending_payment: payment initiated
|
||||||
|
pending_payment --> active: payment confirmed
|
||||||
|
active --> received_offers: first SellerOffer saved\nSellerOfferService.createOffer
|
||||||
received_offers --> in_negotiation: buyer/seller chat\n(counter-offer, see [[Negotiation Flow]])
|
received_offers --> in_negotiation: buyer/seller chat\n(counter-offer, see [[Negotiation Flow]])
|
||||||
in_negotiation --> received_offers: counter rejected
|
in_negotiation --> received_offers: counter rejected
|
||||||
received_offers --> payment: Request Network payment confirmed\n(selected offer)
|
received_offers --> payment: Request Network payment confirmed\n(selected offer)
|
||||||
@@ -41,26 +47,27 @@ stateDiagram-v2
|
|||||||
delivery --> delivered: buyer enters delivery code
|
delivery --> delivered: buyer enters delivery code
|
||||||
delivered --> confirming: optional auto-release timer
|
delivered --> confirming: optional auto-release timer
|
||||||
confirming --> completed: escrow released to seller
|
confirming --> completed: escrow released to seller
|
||||||
completed --> finalized: ratings exchanged
|
|
||||||
finalized --> archived: 30 days idle
|
|
||||||
pending --> cancelled: buyer cancels (any pre-payment status)
|
pending --> cancelled: buyer cancels (any pre-payment status)
|
||||||
|
active --> cancelled
|
||||||
received_offers --> cancelled
|
received_offers --> cancelled
|
||||||
in_negotiation --> cancelled
|
in_negotiation --> cancelled
|
||||||
cancelled --> [*]
|
cancelled --> [*]
|
||||||
archived --> [*]
|
completed --> [*]
|
||||||
```
|
```
|
||||||
|
|
||||||
Terminal statuses: `completed`, `finalized`, `archived`, `cancelled` (`PurchaseRequestService.ts:28`).
|
Terminal statuses: `completed`, `cancelled` (`PurchaseRequestService.ts:28`).
|
||||||
|
|
||||||
|
> [!note] Statuses `finalized` and `archived` do NOT exist in the frontend `IPurchaseRequest` type and are not live statuses. They are not part of the active state machine.
|
||||||
|
|
||||||
## Step-by-step narrative
|
## Step-by-step narrative
|
||||||
|
|
||||||
### Multi-step wizard
|
### Multi-step wizard
|
||||||
|
|
||||||
1. Buyer clicks "New request" in the dashboard sidebar and lands at `/dashboard/request/new`.
|
1. Buyer clicks "New request" in the dashboard sidebar and lands at `/dashboard/request/new`.
|
||||||
2. **Step 1 — Basic info** (`steps/request-basic-info-step.tsx`): title (5–200 chars), description (20–2000 chars), category selection (dropdown populated from `GET /api/marketplace/categories`).
|
2. **Step 1 — Basic info** (`steps/request-basic-info-step.tsx`): title (5–200 chars), description (5–2000 chars, **minimum is 5 characters** per frontend Zod schema — not 20), category selection (dropdown populated from `GET /api/marketplace/categories`).
|
||||||
3. **Step 2 — Details** (`steps/request-details-step.tsx`): optional product link, size, color, quantity, free-form specifications (key/value pairs), AI-assisted description generation (calls `POST /api/ai/generate-description` if the user clicks the magic-wand button — see `backend/src/services/ai/`).
|
3. **Step 2 — Details** (`steps/request-details-step.tsx`): optional product link, size, color, quantity, free-form specifications (key/value pairs), AI-assisted description generation (calls `POST /api/ai/generate-description` if the user clicks the magic-wand button — see `backend/src/services/ai/`).
|
||||||
4. **Step 3 — Budget** (`steps/request-budget-step.tsx`): min/max in chosen currency (default USDT), urgency (low/medium/high), preferred sellers (typeahead bound to `GET /api/users/sellers`; `"all"` means public).
|
4. **Step 3 — Budget** (`steps/request-budget-step.tsx`): min/max in chosen currency (default USDT), urgency (`low | medium | high | urgent`), preferred sellers (typeahead bound to `GET /api/marketplace/sellers`; `"all"` means public).
|
||||||
5. **Step 4 — Review** (`steps/request-review-step.tsx`): summary; user can attach a saved address (`GET /api/addresses`) or enter a one-off `deliveryInfo.address`. Upload optional attachments via `POST /api/files/upload` — returns URLs persisted into `attachments[]`.
|
5. **Step 4 — Review** (`steps/request-review-step.tsx`): summary; user can attach a saved address (`GET /api/addresses`) or enter a one-off `deliveryInfo.address`. Upload optional attachments via `POST /api/marketplace/purchase-requests/:id/attachments` — returns URLs persisted into `attachments[]`.
|
||||||
6. **Draft vs. publish** — The wizard always POSTs on submit; drafts are not first-class today (the local wizard state is the "draft"). The persisted record is immediately `status: "pending"` and visible to sellers.
|
6. **Draft vs. publish** — The wizard always POSTs on submit; drafts are not first-class today (the local wizard state is the "draft"). The persisted record is immediately `status: "pending"` and visible to sellers.
|
||||||
|
|
||||||
### Submission
|
### Submission
|
||||||
@@ -73,9 +80,9 @@ Terminal statuses: `completed`, `finalized`, `archived`, `cancelled` (`PurchaseR
|
|||||||
- Builds and saves the `PurchaseRequest` document with `status: "pending"`.
|
- Builds and saves the `PurchaseRequest` document with `status: "pending"`.
|
||||||
9. **Notify the buyer**: `notificationService.notifyPurchaseRequestCreated()` fires asynchronously (no `await`) — an info notification appears in the buyer's bell-icon dropdown.
|
9. **Notify the buyer**: `notificationService.notifyPurchaseRequestCreated()` fires asynchronously (no `await`) — an info notification appears in the buyer's bell-icon dropdown.
|
||||||
10. **Fan-out to sellers** (`notifyAllSellersAboutNewRequest`, `:190-249`):
|
10. **Fan-out to sellers** (`notifyAllSellersAboutNewRequest`, `:190-249`):
|
||||||
- If `isPublic`: `User.find({ role: "seller", status: "active" })`.
|
- If `isPublic`: emits `new-purchase-request` to the shared **`sellers` room** (all connected sellers receive it in a single emit — no per-seller iteration for the socket event itself).
|
||||||
- Otherwise: only the curated `preferredSellerIds`.
|
- For per-seller in-app notifications (bell icon): `User.find({ role: "seller", status: "active" })` OR only the curated `preferredSellerIds`.
|
||||||
- Iterates with **50 ms stagger** between notifications to avoid overwhelming Mongo/Socket.IO.
|
- Iterates with **50 ms stagger** between notification writes to avoid overwhelming Mongo.
|
||||||
- For each seller: `notificationService.createNotification(...)` writes a `Notification` doc AND emits via Socket.IO (`actionUrl: /dashboard/seller/marketplace/request/{id}`).
|
- For each seller: `notificationService.createNotification(...)` writes a `Notification` doc AND emits via Socket.IO (`actionUrl: /dashboard/seller/marketplace/request/{id}`).
|
||||||
11. **Real-time fan-out** is also performed when sellers eventually act on the request (offers, payments) via `emitPurchaseRequestUpdate(requestId, eventType, data)` (`PurchaseRequestService.ts:53-71`) and `emitOfferUpdate` in [[Seller Offer Flow]].
|
11. **Real-time fan-out** is also performed when sellers eventually act on the request (offers, payments) via `emitPurchaseRequestUpdate(requestId, eventType, data)` (`PurchaseRequestService.ts:53-71`) and `emitOfferUpdate` in [[Seller Offer Flow]].
|
||||||
|
|
||||||
@@ -112,7 +119,7 @@ sequenceDiagram
|
|||||||
BE-->>FE: { description }
|
BE-->>FE: { description }
|
||||||
end
|
end
|
||||||
opt attachments
|
opt attachments
|
||||||
FE->>BE: POST /api/files/upload
|
FE->>BE: POST /api/marketplace/purchase-requests/:id/attachments
|
||||||
BE-->>FE: { url }
|
BE-->>FE: { url }
|
||||||
end
|
end
|
||||||
B->>FE: Click "Publish"
|
B->>FE: Click "Publish"
|
||||||
@@ -123,7 +130,8 @@ sequenceDiagram
|
|||||||
BE->>DB: PurchaseRequest.create({status: "pending"})
|
BE->>DB: PurchaseRequest.create({status: "pending"})
|
||||||
DB-->>BE: savedRequest
|
DB-->>BE: savedRequest
|
||||||
BE->>N: notifyPurchaseRequestCreated(buyer, requestId)
|
BE->>N: notifyPurchaseRequestCreated(buyer, requestId)
|
||||||
par fan-out to sellers (staggered 50ms)
|
par fan-out to sellers (staggered 50ms for DB writes)
|
||||||
|
BE->>IO: emit 'new-purchase-request' to 'sellers' room (public requests)
|
||||||
BE->>DB: User.find({role:"seller", status:"active"}) (or preferred)
|
BE->>DB: User.find({role:"seller", status:"active"}) (or preferred)
|
||||||
BE->>N: createNotification(seller_i, ...)
|
BE->>N: createNotification(seller_i, ...)
|
||||||
N->>IO: emit user-{seller_i} 'new-notification'
|
N->>IO: emit user-{seller_i} 'new-notification'
|
||||||
@@ -131,7 +139,7 @@ sequenceDiagram
|
|||||||
end
|
end
|
||||||
BE-->>FE: 201 { request }
|
BE-->>FE: 201 { request }
|
||||||
FE-->>B: Redirect /dashboard/buyer/requests/{id}
|
FE-->>B: Redirect /dashboard/buyer/requests/{id}
|
||||||
IO-->>S1: 'new-notification' (sellers receive in real time)
|
IO-->>S1: 'new-purchase-request' (sellers room) + 'new-notification' (per-user)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API calls
|
## API calls
|
||||||
@@ -140,15 +148,23 @@ sequenceDiagram
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/api/marketplace/purchase-requests` | Create the request |
|
| `POST` | `/api/marketplace/purchase-requests` | Create the request |
|
||||||
| `GET` | `/api/marketplace/categories` | Step 1 dropdown |
|
| `GET` | `/api/marketplace/categories` | Step 1 dropdown |
|
||||||
| `GET` | `/api/users/sellers` | Step 3 preferred-sellers typeahead |
|
| `GET` | `/api/marketplace/sellers` | Step 3 preferred-sellers typeahead |
|
||||||
| `GET` | `/api/addresses` | Step 4 saved addresses |
|
| `GET` | `/api/addresses` | Step 4 saved addresses |
|
||||||
| `POST` | `/api/files/upload` | Attachments |
|
| `POST` | `/api/marketplace/purchase-requests/:id/attachments` | Attachments upload |
|
||||||
| `POST` | `/api/ai/generate-description` | Optional AI-assisted description |
|
| `POST` | `/api/ai/generate-description` | Optional AI-assisted description |
|
||||||
| `GET` | `/api/marketplace/purchase-requests` | Listing (buyer's own and seller's filtered view) |
|
| `GET` | `/api/marketplace/purchase-requests` | Listing (buyer's own and seller's filtered view) |
|
||||||
| `GET` | `/api/marketplace/purchase-requests/:id` | Detail page (joins payment data) |
|
| `GET` | `/api/marketplace/purchase-requests/:id` | Detail page (joins payment data) |
|
||||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Generic update (status, attachments, etc.) |
|
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Generic update (status, attachments, etc.) |
|
||||||
| `DELETE` | `/api/marketplace/purchase-requests/:id` | Cancel (only before payment) |
|
| `DELETE` | `/api/marketplace/purchase-requests/:id` | Cancel (only before payment) |
|
||||||
|
|
||||||
|
> [!bug] ⚠️ KNOWN BUG — PUT vs PATCH mismatch
|
||||||
|
> The frontend `updatePurchaseRequest` action sends `PUT /api/marketplace/purchase-requests/:id`, but the backend only registers a `PATCH` handler for that route. The `PUT` call will receive a `404` or `405` response. The backend handler must be updated to also accept `PUT`, or the frontend action must be changed to use `PATCH`.
|
||||||
|
|
||||||
|
> [!warning] ⚠️ NOT IMPLEMENTED — Frontend actions with no backend endpoints
|
||||||
|
> The following frontend actions target backend routes that do not exist:
|
||||||
|
> - `searchPurchaseRequests` → `GET /marketplace/purchase-requests/search` — this endpoint does not exist. Use query parameters on the standard list endpoint (`GET /api/marketplace/purchase-requests?q=...`) instead.
|
||||||
|
> - `getMarketplaceStats` → `GET /marketplace/purchase-requests/stats` — this endpoint does not exist. No stats aggregation route is registered.
|
||||||
|
|
||||||
## Database writes
|
## Database writes
|
||||||
|
|
||||||
- **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[PRD - Request Network In-House Checkout]], and [[Delivery Confirmation Flow]].
|
- **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[PRD - Request Network In-House Checkout]], and [[Delivery Confirmation Flow]].
|
||||||
@@ -157,16 +173,26 @@ sequenceDiagram
|
|||||||
|
|
||||||
## Socket events emitted
|
## Socket events emitted
|
||||||
|
|
||||||
|
- **`new-purchase-request`** → `sellers` room for public purchase requests (shared room, single broadcast; emitted by `notifyAllSellersAboutNewRequest`).
|
||||||
- **`new-notification`** → `user-{sellerId}` for each notified seller (via `NotificationService.emitRealTimeNotification`).
|
- **`new-notification`** → `user-{sellerId}` for each notified seller (via `NotificationService.emitRealTimeNotification`).
|
||||||
- **`purchase-request-update`** → `request-{id}` on status changes (`emitPurchaseRequestUpdate`, `PurchaseRequestService.ts:53-71`).
|
- **`purchase-request-update`** → `request-{id}` on status changes (`emitPurchaseRequestUpdate`, `PurchaseRequestService.ts:53-71`). Cancellation emits this event with `eventType: 'status-changed'` — there is **no** separate `request-cancelled` event.
|
||||||
- **`seller-offer-update`** → `seller-{id}` when an offer is created against this request (see [[Seller Offer Flow]]).
|
- **`seller-offer-update`** → `seller-{id}` when an offer is created against this request (see [[Seller Offer Flow]]).
|
||||||
- **`request-cancelled`** → `user-{buyerId}` and `user-{sellerId}` when the buyer cancels (`PurchaseRequestService.ts:671-693`).
|
|
||||||
|
### Socket room join/leave events
|
||||||
|
|
||||||
|
| Event | Direction | Emitted by |
|
||||||
|
|---|---|---|
|
||||||
|
| `join-request-room` | client → server | Buyer detail page on mount (subscribes to `request-{id}`) |
|
||||||
|
| `join-seller-room` | client → server | `useSellerMarketplaceSocket` on mount |
|
||||||
|
| `leave-seller-room` | client → server | `useSellerMarketplaceSocket` on unmount |
|
||||||
|
| `join-buyer-room` | client → server | Buyer socket hook on mount |
|
||||||
|
| `leave-buyer-room` | client → server | Buyer socket hook on unmount |
|
||||||
|
|
||||||
## Side effects
|
## Side effects
|
||||||
|
|
||||||
- One Mongo write per notification (potentially N+1 for a large seller base — mitigated by the 50 ms stagger). For mass markets this should be batched.
|
- One Mongo write per notification (potentially N+1 for a large seller base — mitigated by the 50 ms stagger). For mass markets this should be batched.
|
||||||
- The buyer is auto-subscribed to the `request-{id}` Socket.IO room on the detail page mount (frontend emits `join-request-room`).
|
- The buyer is auto-subscribed to the `request-{id}` Socket.IO room on the detail page mount (frontend emits `join-request-room`).
|
||||||
- If `urgency === "high"`, the notification message uses the high-priority template — visible in [[Notification Flow]].
|
- If `urgency === "high"` or `urgency === "urgent"`, the notification message uses the high-priority template — visible in [[Notification Flow]].
|
||||||
|
|
||||||
## Error / edge cases
|
## Error / edge cases
|
||||||
|
|
||||||
@@ -180,7 +206,7 @@ sequenceDiagram
|
|||||||
- **Seller calls GET listing without `sellerId` query** → logs `"No sellerId provided - returning ALL requests!"` (`:348`) and returns the full set. Frontend always supplies `sellerId` for seller dashboards; missing it is a bug worth catching.
|
- **Seller calls GET listing without `sellerId` query** → logs `"No sellerId provided - returning ALL requests!"` (`:348`) and returns the full set. Frontend always supplies `sellerId` for seller dashboards; missing it is a bug worth catching.
|
||||||
|
|
||||||
> [!tip] Status progression is forward-only
|
> [!tip] Status progression is forward-only
|
||||||
> Once `status` reaches `payment`, you cannot put it back to `received_offers` even via PATCH. The only escape hatches are the terminal statuses (`cancelled`, `archived`, etc.) and admin tools.
|
> Once `status` reaches `payment`, you cannot put it back to `received_offers` even via PATCH. The only escape hatches are the terminal statuses (`cancelled`) and admin tools.
|
||||||
|
|
||||||
## Linked flows
|
## Linked flows
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
title: Seller Offer Flow
|
title: Seller Offer Flow
|
||||||
tags: [flow, marketplace, seller, offer]
|
tags: [flow, marketplace, seller, offer]
|
||||||
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"]
|
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"]
|
||||||
related_apis: ["POST /api/marketplace/offers", "GET /api/marketplace/offers/request/:requestId", "PATCH /api/marketplace/offers/:id"]
|
related_apis: ["POST /api/marketplace/purchase-requests/:id/offers", "GET /api/marketplace/purchase-requests/:id/offers", "PATCH /api/marketplace/offers/:id"]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||||
|
|
||||||
# Seller Offer Flow
|
# Seller Offer Flow
|
||||||
|
|
||||||
A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[PRD - Request Network In-House Checkout]]) or reject.
|
A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[PRD - Request Network In-House Checkout]]) or reject.
|
||||||
@@ -23,7 +25,7 @@ A **seller** browses open purchase requests and submits an offer with a price, d
|
|||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
- Seller is authenticated, `role === "seller"`, `status === "active"`.
|
- Seller is authenticated, `role === "seller"`, `status === "active"`.
|
||||||
- Target purchase request exists and `status` is `pending` or `received_offers` (`SellerOfferService.ts:83-85`).
|
- Target purchase request exists and `status` is `pending`, `received_offers`, or `active` (`SellerOfferService.ts:83-85`).
|
||||||
- Seller does **not** already have an offer on this request (uniqueness enforced by `SellerOfferService.createOffer`).
|
- Seller does **not** already have an offer on this request (uniqueness enforced by `SellerOfferService.createOffer`).
|
||||||
|
|
||||||
## Offer state machine
|
## Offer state machine
|
||||||
@@ -31,7 +33,6 @@ A **seller** browses open purchase requests and submits an offer with a price, d
|
|||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
[*] --> pending: createOffer()
|
[*] --> pending: createOffer()
|
||||||
pending --> active: (optional — manual seller activation)
|
|
||||||
pending --> withdrawn: seller withdraws (only while pending)
|
pending --> withdrawn: seller withdraws (only while pending)
|
||||||
pending --> rejected: another offer accepted\nor buyer rejects this one
|
pending --> rejected: another offer accepted\nor buyer rejects this one
|
||||||
pending --> accepted: acceptOffer()\nor payment confirmed
|
pending --> accepted: acceptOffer()\nor payment confirmed
|
||||||
@@ -41,7 +42,7 @@ stateDiagram-v2
|
|||||||
pending --> expired_via_withdrawn: validUntil < now\n→ markExpiredOffersAsWithdrawn (cron)
|
pending --> expired_via_withdrawn: validUntil < now\n→ markExpiredOffersAsWithdrawn (cron)
|
||||||
```
|
```
|
||||||
|
|
||||||
The active enum values are `pending | accepted | rejected | withdrawn` (`SellerOfferService.ts:308`). `validUntil` expirations are converted to `withdrawn`.
|
The valid `SellerOffer` statuses are `pending | accepted | rejected | withdrawn` (`SellerOfferService.ts:308`). There is **no** `active` status for `SellerOffer`. `validUntil` expirations are converted to `withdrawn`.
|
||||||
|
|
||||||
## Step-by-step narrative
|
## Step-by-step narrative
|
||||||
|
|
||||||
@@ -60,10 +61,10 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
|
|||||||
- **Delivery time** (amount + unit: hours / days / weeks)
|
- **Delivery time** (amount + unit: hours / days / weeks)
|
||||||
- **Attachments** (optional, via `POST /api/files/upload`)
|
- **Attachments** (optional, via `POST /api/files/upload`)
|
||||||
- **Valid until** (optional expiry)
|
- **Valid until** (optional expiry)
|
||||||
5. Frontend POSTs `POST /api/marketplace/offers`.
|
5. Frontend POSTs `POST /api/marketplace/purchase-requests/:id/offers` (the `purchaseRequestId` is a **path parameter**, not a body field).
|
||||||
6. Backend `SellerOfferService.createOffer` (`:51-140`):
|
6. Backend `SellerOfferService.createOffer` (`:51-140`):
|
||||||
- **Uniqueness**: `SellerOffer.findOne({ purchaseRequestId, sellerId })` — if present, throws `"شما قبلاً برای این درخواست پیشنهاد دادهاید"` (`:74`). Use `updateOffer` to amend.
|
- **Uniqueness**: `SellerOffer.findOne({ purchaseRequestId, sellerId })` — if present, throws `"شما قبلاً برای این درخواست پیشنهاد دادهاید"` (`:74`). Use `updateOffer` to amend.
|
||||||
- **Status guard**: loads the `PurchaseRequest`; rejects if its status is anything other than `pending` or `received_offers`.
|
- **Status guard**: loads the `PurchaseRequest`; rejects if its status is anything other than `pending`, `received_offers`, or `active`.
|
||||||
- Saves the offer (`status: "pending"` by default in the schema).
|
- Saves the offer (`status: "pending"` by default in the schema).
|
||||||
- Re-loads with `.populate('sellerId').populate('purchaseRequestId')` for the response.
|
- Re-loads with `.populate('sellerId').populate('purchaseRequestId')` for the response.
|
||||||
7. **Real-time fan-out** (`emitOfferUpdate`, `:24-46`): emits `seller-offer-update` to `seller-{sellerId}` so the seller's other tabs reflect the new offer instantly.
|
7. **Real-time fan-out** (`emitOfferUpdate`, `:24-46`): emits `seller-offer-update` to `seller-{sellerId}` so the seller's other tabs reflect the new offer instantly.
|
||||||
@@ -73,14 +74,15 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
|
|||||||
|
|
||||||
### Buyer review
|
### Buyer review
|
||||||
|
|
||||||
11. Buyer's request detail page (`/dashboard/buyer/requests/{id}`) joins `GET /api/marketplace/offers/request/{requestId}` — `SellerOfferService.getOffersByPurchaseRequest` returns all offers sorted by `createdAt: -1`.
|
11. Buyer's request detail page (`/dashboard/buyer/requests/{id}`) joins `GET /api/marketplace/purchase-requests/:id/offers` — `SellerOfferService.getOffersByPurchaseRequest` returns all offers sorted by `createdAt: -1`.
|
||||||
12. Each offer card shows seller name, avatar, rating (from [[Rating Flow]]), price, ETA, notes.
|
12. Each offer card shows seller name, avatar, rating (from [[Rating Flow]]), price, ETA, notes.
|
||||||
13. Buyer either **negotiates** (opens chat → [[Negotiation Flow]]) or **accepts** the offer by triggering payment.
|
13. Buyer either **negotiates** (opens chat → [[Negotiation Flow]]) or **accepts** the offer by triggering payment.
|
||||||
|
|
||||||
### Accept → Payment
|
### Accept / Select Offer → Payment
|
||||||
|
|
||||||
14. The buyer's "Pay this offer" button kicks off [[PRD - Request Network In-House Checkout]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; payment confirmation does that atomically when the on-chain payment is confirmed.
|
14. The buyer selects an offer via `POST /api/marketplace/purchase-requests/:id/select-offer`. **Important**: this endpoint fires only a generic `purchase-request-update` event to the `request-{requestId}` room. No per-seller socket events or notifications are sent to the winning or losing sellers at this stage.
|
||||||
15. On Request Network payment confirmation:
|
15. The buyer's "Pay this offer" button kicks off [[PRD - Request Network In-House Checkout]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; payment confirmation does that atomically when the on-chain payment is confirmed.
|
||||||
|
16. On Request Network payment confirmation:
|
||||||
- The selected offer's `status` → `accepted`.
|
- The selected offer's `status` → `accepted`.
|
||||||
- All other offers on the same request → `rejected` via `SellerOffer.updateMany`.
|
- All other offers on the same request → `rejected` via `SellerOffer.updateMany`.
|
||||||
- The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`.
|
- The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`.
|
||||||
@@ -90,7 +92,22 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
|
|||||||
|
|
||||||
### Withdrawal
|
### Withdrawal
|
||||||
|
|
||||||
16. Seller can withdraw their `pending` offer from `/dashboard/seller/marketplace/offers/{offerId}` → `withdrawOffer` (`SellerOfferService.ts:428-443`). The DB filter `{ status: 'pending' }` means withdrawal is impossible once `accepted` or `rejected`.
|
17. ⚠️ **`POST /api/marketplace/offers/:id/withdraw` does NOT exist as an HTTP route.** The `SellerOfferService.withdrawOffer()` service method exists but is dead code — it is not wired to any controller endpoint.
|
||||||
|
|
||||||
|
The only supported HTTP way to withdraw an offer is:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/marketplace/offers/:id
|
||||||
|
Body: { status: 'withdrawn' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Note also that the frontend page `/dashboard/seller/marketplace/offers` (a "My Offers" listing) **does not exist**. Withdrawal must be triggered from the individual request detail page.
|
||||||
|
|
||||||
|
The DB filter `{ status: 'pending' }` inside `withdrawOffer` means withdrawal is impossible once `accepted` or `rejected`.
|
||||||
|
|
||||||
|
### Offer update — method mismatch
|
||||||
|
|
||||||
|
> ⚠️ **Known mismatch**: The frontend sends `PUT /marketplace/offers/:id` to update an offer, but the backend route is registered as `PATCH /api/marketplace/offers/:id` (`marketplaceControllerRoutes.ts`). Depending on whether a proxy or middleware normalises the method, one of these may fail. Verify end-to-end and align to a single method.
|
||||||
|
|
||||||
## Sequence diagram
|
## Sequence diagram
|
||||||
|
|
||||||
@@ -110,7 +127,7 @@ sequenceDiagram
|
|||||||
FE_S->>BE: GET /api/marketplace/purchase-requests
|
FE_S->>BE: GET /api/marketplace/purchase-requests
|
||||||
BE-->>FE_S: filtered request list
|
BE-->>FE_S: filtered request list
|
||||||
S->>FE_S: Open request and send offer
|
S->>FE_S: Open request and send offer
|
||||||
FE_S->>BE: POST /api/marketplace/offers
|
FE_S->>BE: POST /api/marketplace/purchase-requests/:id/offers
|
||||||
BE->>DB: Validate offer not duplicate
|
BE->>DB: Validate offer not duplicate
|
||||||
BE->>DB: Validate request status
|
BE->>DB: Validate request status
|
||||||
BE->>DB: Create offer with status pending
|
BE->>DB: Create offer with status pending
|
||||||
@@ -123,7 +140,7 @@ sequenceDiagram
|
|||||||
BE-->>FE_S: 200 { offer }
|
BE-->>FE_S: 200 { offer }
|
||||||
IO-->>FE_B: notify buyer bell icon
|
IO-->>FE_B: notify buyer bell icon
|
||||||
B->>FE_B: Open request detail
|
B->>FE_B: Open request detail
|
||||||
FE_B->>BE: GET /api/marketplace/offers/request/{id}
|
FE_B->>BE: GET /api/marketplace/purchase-requests/:id/offers
|
||||||
BE-->>FE_B: offers
|
BE-->>FE_B: offers
|
||||||
alt
|
alt
|
||||||
B->>FE_B: Click pay to finish selected offer
|
B->>FE_B: Click pay to finish selected offer
|
||||||
@@ -135,15 +152,15 @@ sequenceDiagram
|
|||||||
|
|
||||||
## API calls
|
## API calls
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose | Notes |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `POST` | `/api/marketplace/offers` | Create offer |
|
| `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer | `purchaseRequestId` is a path param |
|
||||||
| `GET` | `/api/marketplace/offers/request/:requestId` | Buyer view of offers on a request |
|
| `GET` | `/api/marketplace/purchase-requests/:id/offers` | Buyer view of offers on a request | |
|
||||||
| `GET` | `/api/marketplace/offers/seller/:sellerId` | Seller's own offer history |
|
| `GET` | `/api/marketplace/offers/:id` | Single offer details | |
|
||||||
| `GET` | `/api/marketplace/offers/:id` | Single offer details |
|
| `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) | ⚠️ Frontend sends `PUT`; backend registers `PATCH` — method mismatch |
|
||||||
| `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) |
|
| `POST` | `/api/marketplace/offers/:id/accept` | Manual acceptance (when not via webhook) | |
|
||||||
| `POST` | `/api/marketplace/offers/:id/accept` | Manual acceptance (when not via webhook) |
|
| ~~`GET /api/marketplace/offers/seller/:sellerId`~~ | — | ~~Seller's own offer history~~ | ⚠️ NOT IMPLEMENTED — `getOffersBySeller()` service method exists but has no HTTP route |
|
||||||
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller withdraws |
|
| ~~`POST /api/marketplace/offers/:id/withdraw`~~ | — | ~~Seller withdraws~~ | ⚠️ NOT IMPLEMENTED — use `PATCH /api/marketplace/offers/:id` with `{ status: 'withdrawn' }` instead |
|
||||||
|
|
||||||
## Database writes
|
## Database writes
|
||||||
|
|
||||||
@@ -155,7 +172,8 @@ sequenceDiagram
|
|||||||
|
|
||||||
- **`seller-offer-update`** with `eventType: 'new-offer'` → `seller-{sellerId}` (creator's other tabs).
|
- **`seller-offer-update`** with `eventType: 'new-offer'` → `seller-{sellerId}` (creator's other tabs).
|
||||||
- **`purchase-request-update`** with `eventType: 'offer-updated'` → `request-{requestId}` on edits (`SellerOfferService.ts:284-288`).
|
- **`purchase-request-update`** with `eventType: 'offer-updated'` → `request-{requestId}` on edits (`SellerOfferService.ts:284-288`).
|
||||||
- **`seller-offer-update`** with `eventType: 'payment-completed'` to winning seller, `'offer-rejected'` to losers (emitted by the webhook handler).
|
- **`purchase-request-update`** → `request-{requestId}` when buyer calls `select-offer` (generic room event only — no per-seller notifications or events are sent to winning or losing sellers).
|
||||||
|
- **`seller-offer-update`** with `eventType: 'payment-completed'` to winning seller, `'offer-rejected'` to losers (emitted by the webhook handler after payment confirmation).
|
||||||
- **`new-notification`** → `user-{buyerId}` for each new offer.
|
- **`new-notification`** → `user-{buyerId}` for each new offer.
|
||||||
|
|
||||||
## Side effects
|
## Side effects
|
||||||
|
|||||||
50
Issues/ISSUE-001-dispute-status-no-role-guard.md
Normal file
50
Issues/ISSUE-001-dispute-status-no-role-guard.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
issue: "001"
|
||||||
|
title: "PATCH /api/disputes/:id/status has no role guard — privilege escalation"
|
||||||
|
severity: critical
|
||||||
|
domain: dispute
|
||||||
|
labels: [security, backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 PATCH /api/disputes/:id/status has no role guard — privilege escalation
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** dispute
|
||||||
|
**Labels:** security, backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`PATCH /api/disputes/:id/status` is mounted with only `authenticateToken` middleware — no `authorizeRoles('admin')` guard. Any authenticated buyer or seller who knows a dispute `_id` can change that dispute's status to `resolved`, `closed`, or any other value including states that release funds or trigger bans.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Any authenticated user (buyer or seller) can call:
|
||||||
|
```
|
||||||
|
PATCH /api/disputes/{disputeId}/status
|
||||||
|
{ "status": "resolved" }
|
||||||
|
```
|
||||||
|
and receive a 200 response. The dispute status is updated in MongoDB.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Only users with `role: admin` should be permitted to change a dispute's status. Non-admin tokens should receive `403 Forbidden`.
|
||||||
|
|
||||||
|
## Reproduction Steps
|
||||||
|
|
||||||
|
1. Log in as a buyer or seller, obtain a JWT.
|
||||||
|
2. Find or create a dispute `_id`.
|
||||||
|
3. `PATCH /api/disputes/{id}/status` with `{ "status": "resolved" }` and the buyer/seller Bearer token.
|
||||||
|
4. Observe 200 and the status change in the DB.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/disputeRoutes.ts` — router missing `authorizeRoles('admin')` before `updateStatus` handler
|
||||||
|
- `backend/src/controllers/disputeController.ts` — `updateStatus` method
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding C16
|
||||||
|
- Related: [[ISSUE-002-dispute-resolve-no-role-guard]]
|
||||||
45
Issues/ISSUE-002-dispute-resolve-no-role-guard.md
Normal file
45
Issues/ISSUE-002-dispute-resolve-no-role-guard.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
issue: "002"
|
||||||
|
title: "POST /api/disputes/:id/resolve has no role guard — any user can resolve disputes and ban sellers"
|
||||||
|
severity: critical
|
||||||
|
domain: dispute
|
||||||
|
labels: [security, backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 POST /api/disputes/:id/resolve has no role guard — any user can resolve disputes and ban sellers
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** dispute
|
||||||
|
**Labels:** security, backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The dashboard dispute router's `POST /api/disputes/:id/resolve` handler applies only `authenticateToken`. No `authorizeRoles('admin')` guard exists. Any authenticated user can post any resolution action including `action: 'ban_seller'`, `action: 'refund'`, or `action: 'no_action'`, bypassing all admin authority.
|
||||||
|
|
||||||
|
Note: the *releaseHold* router's `POST /api/disputes/:purchaseRequestId/resolve` correctly uses `authorizeRoles('admin')`, but the dashboard router does not.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
A buyer or seller can call:
|
||||||
|
```
|
||||||
|
POST /api/disputes/{disputeId}/resolve
|
||||||
|
{ "action": "ban_seller", "notes": "malicious" }
|
||||||
|
```
|
||||||
|
The resolution is persisted with a 200 response.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
`POST /api/disputes/:id/resolve` must be protected by `authorizeRoles('admin')`. Non-admin tokens should receive `403`.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/disputeRoutes.ts` (dashboard router, mounted at `/api/disputes` first)
|
||||||
|
- `backend/src/controllers/disputeController.ts` — `resolveDispute` method
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding C17
|
||||||
|
- Related: [[ISSUE-001-dispute-status-no-role-guard]], [[ISSUE-003-dispute-route-shadowing]]
|
||||||
41
Issues/ISSUE-003-dispute-route-shadowing.md
Normal file
41
Issues/ISSUE-003-dispute-route-shadowing.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
issue: "003"
|
||||||
|
title: "Route shadowing: two dispute routers mounted at /api/disputes cause non-deterministic handler dispatch"
|
||||||
|
severity: critical
|
||||||
|
domain: dispute
|
||||||
|
labels: [backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 Route shadowing: two dispute routers mounted at /api/disputes cause non-deterministic handler dispatch
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** dispute
|
||||||
|
**Labels:** backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
In `backend/src/app.ts`, two separate dispute routers are mounted on the same path `/api/disputes`:
|
||||||
|
- Line ~521: `dashboardDisputeRoutes` (first — unguarded `POST /:id/resolve`, `PATCH /:id/status`)
|
||||||
|
- Line ~585: `releaseHold disputeRoutes` (second — admin-guarded `POST /:purchaseRequestId/resolve`, also `GET /:purchaseRequestId/status`)
|
||||||
|
|
||||||
|
Express evaluates in registration order. A `POST /api/disputes/{purchaseRequestId}/resolve` request will match the **dashboard router's** `POST /:id/resolve` handler first (since `:id` and `:purchaseRequestId` are identical route patterns). This executes the unguarded Dispute CRUD resolve instead of the admin-guarded escrow release-hold logic.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
`POST /api/disputes/{purchaseRequestId}/resolve` executes the dashboard `resolveDispute` controller (updates the Dispute document only, no role guard) rather than the intended `releaseHold` handler (admin-only, clears escrow).
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
The escrow-release resolve handler should be reachable at a distinct, unambiguous path (e.g., `/api/disputes/hold/:purchaseRequestId/resolve` or mounted at a different prefix).
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/app.ts` — two `app.use('/api/disputes', ...)` mount points
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding C18
|
||||||
|
- Related: [[ISSUE-002-dispute-resolve-no-role-guard]]
|
||||||
46
Issues/ISSUE-004-payment-endpoints-no-auth.md
Normal file
46
Issues/ISSUE-004-payment-endpoints-no-auth.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
issue: "004"
|
||||||
|
title: "fetch-tx, auto-fetch-missing, and debug payment endpoints have no authentication"
|
||||||
|
severity: critical
|
||||||
|
domain: payment
|
||||||
|
labels: [security, backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 fetch-tx, auto-fetch-missing, and debug payment endpoints have no authentication
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** payment
|
||||||
|
**Labels:** security, backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Three backend payment endpoints are mounted with **no `authenticateToken` middleware**, despite being documented as admin-only:
|
||||||
|
|
||||||
|
1. `POST /api/payment/payments/:id/fetch-tx` — triggers on-chain transaction fetch for a payment
|
||||||
|
2. `POST /api/payment/payments/auto-fetch-missing` — triggers bulk on-chain fetch for all pending payments
|
||||||
|
3. `GET /api/payment/payments/:id/debug` — returns full payment document including blockchain metadata and wallet monitor state
|
||||||
|
|
||||||
|
Any unauthenticated caller (no Authorization header needed) can call all three endpoints.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.example.com/api/payment/payments/anyId/fetch-tx
|
||||||
|
# Returns 200 and triggers on-chain state write
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
All three endpoints should require `authenticateToken` + `authorizeRoles('admin')` and return `401` without credentials.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/paymentRoutes.js` — route definitions for `fetch-tx`, `auto-fetch-missing`, `debug`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Findings C28, M40
|
||||||
|
- Related: [[ISSUE-005-scanner-status-no-auth]]
|
||||||
40
Issues/ISSUE-005-scanner-status-no-auth.md
Normal file
40
Issues/ISSUE-005-scanner-status-no-auth.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
issue: "005"
|
||||||
|
title: "GET /api/admin/scanner/status has no authentication despite /api/admin/ prefix"
|
||||||
|
severity: critical
|
||||||
|
domain: admin
|
||||||
|
labels: [security, backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 GET /api/admin/scanner/status has no authentication despite /api/admin/ prefix
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** admin
|
||||||
|
**Labels:** security, backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`GET /api/admin/scanner/status` proxies to `AMN_SCANNER_URL` and returns scanner status data. Despite sitting under the `/api/admin/` prefix (which conventionally implies admin auth), this endpoint has **no `authenticateToken` middleware**. Any unauthenticated request returns scanner data.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://api.example.com/api/admin/scanner/status
|
||||||
|
# Returns scanner data with 200, no credentials needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Should return `401` without a valid admin JWT.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/adminRoutes.js` — scanner proxy route definition
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding C29
|
||||||
|
- Related: [[ISSUE-004-payment-endpoints-no-auth]]
|
||||||
49
Issues/ISSUE-006-delete-account-wrong-endpoint.md
Normal file
49
Issues/ISSUE-006-delete-account-wrong-endpoint.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
issue: "006"
|
||||||
|
title: "Frontend deleteAccount action calls DELETE /user/profile which does not exist"
|
||||||
|
severity: critical
|
||||||
|
domain: auth
|
||||||
|
labels: [frontend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 Frontend deleteAccount action calls DELETE /user/profile which does not exist
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** auth
|
||||||
|
**Labels:** frontend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/actions/account.ts` (line ~144) calls:
|
||||||
|
```ts
|
||||||
|
axiosInstance.delete(endpoints.users.profile)
|
||||||
|
// resolves to DELETE /user/profile
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no `DELETE` handler on `/user/profile` in the backend. The actual soft-delete endpoint is:
|
||||||
|
```
|
||||||
|
DELETE /api/auth/account
|
||||||
|
```
|
||||||
|
which requires a `password` field in the request body and runs `deleteAccountValidation`.
|
||||||
|
|
||||||
|
**Result:** Account deletion silently 404s from every UI path. Users cannot delete their accounts.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Clicking the delete account button in the dashboard sends `DELETE /user/profile` → 404. The account is not deleted.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
The action should send `DELETE /api/auth/account` with `{ password }` in the body. On success, the account status is set to `'deleted'` (soft delete) in MongoDB.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/actions/account.ts` — `deleteAccount` function
|
||||||
|
- `frontend/src/lib/axios.ts` — `endpoints.users.profile` key used for the path
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding C3
|
||||||
42
Issues/ISSUE-007-sim-bypass-no-env-guard.md
Normal file
42
Issues/ISSUE-007-sim-bypass-no-env-guard.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
issue: "007"
|
||||||
|
title: "SIM_ transaction bypass active in production — no NODE_ENV guard on wallet connection fallback"
|
||||||
|
severity: critical
|
||||||
|
domain: payment
|
||||||
|
labels: [security, frontend, backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 SIM_ transaction bypass active in production — no NODE_ENV guard on wallet connection fallback
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** payment
|
||||||
|
**Labels:** security, frontend, backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/web3/context/web3-provider.tsx` (lines ~225 and ~232) generates `SIM_` prefixed transaction hashes when wallet connection fails, and passes these to the backend as real transaction hashes.
|
||||||
|
|
||||||
|
The backend's payment service skips all on-chain verification for any `paymentHash` starting with `SIM_`. This bypass is controlled **only by the hash prefix** — there is no `process.env.NODE_ENV === 'development'` check in either the frontend or backend.
|
||||||
|
|
||||||
|
In production, if a user's wallet connection times out or throws (e.g., network error, MetaMask not responding), the frontend will submit a `SIM_` hash. This can result in a payment record being created as `completed` without any actual on-chain transaction.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Wallet connection failure → frontend generates `SIM_xxxxxxxx` hash → sends to backend → backend skips on-chain verification → payment created as completed.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
- Frontend: `SIM_` hash generation should be gated on `process.env.NODE_ENV !== 'production'`
|
||||||
|
- Backend: `SIM_` bypass should additionally check an environment flag (e.g., `process.env.ALLOW_SIM_PAYMENTS !== 'true'`)
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/web3/context/web3-provider.tsx` — lines ~225, ~232
|
||||||
|
- `backend/src/services/payment/` — SIM_ prefix check in payment verification logic
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M39
|
||||||
41
Issues/ISSUE-008-chat-file-upload-wrong-endpoint.md
Normal file
41
Issues/ISSUE-008-chat-file-upload-wrong-endpoint.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
issue: "008"
|
||||||
|
title: "sendFileMessage posts to wrong endpoint — file uploads always fail in chat"
|
||||||
|
severity: critical
|
||||||
|
domain: chat
|
||||||
|
labels: [frontend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 sendFileMessage posts to wrong endpoint — file uploads always fail in chat
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** chat
|
||||||
|
**Labels:** frontend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/actions/chat.ts` (line ~386) sends file upload multipart form data to `endpoints.chat.sendMessage` which resolves to `POST /api/chat/:id/messages` — the text message endpoint.
|
||||||
|
|
||||||
|
The actual backend file upload endpoint is `POST /api/chat/:id/messages/file`.
|
||||||
|
|
||||||
|
The text-message handler expects a JSON body with a `content` string field, not a multipart payload. The file upload either fails or the attachment is silently discarded.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
User picks a file in the chat input → `sendFileMessage` POSTs multipart to `/chat/:id/messages` → backend text handler rejects or ignores the multipart payload → file is never uploaded or stored.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
`sendFileMessage` should POST to `/api/chat/:id/messages/file` with the multipart form data. The response should include a message with an `attachments` array.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/actions/chat.ts` — `sendFileMessage` function uses `endpoints.chat.sendMessage`
|
||||||
|
- `frontend/src/lib/axios.ts` — no `endpoints.chat.sendFileMessage` entry exists; needs to be added as `/chat/:id/messages/file`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding C19
|
||||||
36
Issues/ISSUE-009-archive-chat-wrong-method.md
Normal file
36
Issues/ISSUE-009-archive-chat-wrong-method.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
issue: "009"
|
||||||
|
title: "archiveConversation uses PUT but backend only accepts PATCH"
|
||||||
|
severity: major
|
||||||
|
domain: chat
|
||||||
|
labels: [frontend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 archiveConversation uses PUT but backend only accepts PATCH
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** chat
|
||||||
|
**Labels:** frontend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/actions/chat.ts` (line ~289) calls `axiosInstance.put(endpoints.chat.archive, ...)`. The backend registers this route as `PATCH /api/chat/:id/archive`. Express treats PUT and PATCH as distinct methods; PUT will not match the PATCH handler and returns 404/405.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Attempting to archive a conversation from the UI sends `PUT /api/chat/:id/archive` → 404. The chat is not archived.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
`archiveConversation` should use `axiosInstance.patch(...)` to match the backend's PATCH registration. The endpoint also has toggle semantics — calling it on an archived chat unarchives it.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/actions/chat.ts` — `archiveConversation` method verb (`put` → `patch`)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding C20
|
||||||
49
Issues/ISSUE-010-admin-user-status-wrong-values-and-verb.md
Normal file
49
Issues/ISSUE-010-admin-user-status-wrong-values-and-verb.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
issue: "010"
|
||||||
|
title: "Admin user status/role actions broken: wrong HTTP verb (PUT vs PATCH) and wrong status values"
|
||||||
|
severity: critical
|
||||||
|
domain: admin
|
||||||
|
labels: [frontend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 Admin user status/role actions broken: wrong HTTP verb (PUT vs PATCH) and wrong status values
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** admin
|
||||||
|
**Labels:** frontend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Two separate bugs on the admin user management actions:
|
||||||
|
|
||||||
|
**Bug 1 — Wrong HTTP verb:**
|
||||||
|
`frontend/src/actions/user.ts`:
|
||||||
|
- `updateUserStatus` calls `axiosInstance.put(...)` — backend registers `PATCH`
|
||||||
|
- `updateUserRole` calls `axiosInstance.put(...)` — backend registers `PATCH`
|
||||||
|
|
||||||
|
Both will 404/405 in production since Express doesn't alias PUT to PATCH.
|
||||||
|
|
||||||
|
**Bug 2 — Wrong status values:**
|
||||||
|
`updateUserStatus` accepts and sends `'active' | 'inactive' | 'pending'`. The backend `User.status` enum only accepts `'active' | 'suspended' | 'deleted'`. Sending `'inactive'` or `'pending'` is silently rejected or ignored. `'suspended'` is completely absent from the frontend type.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
- Clicking "Suspend user" in admin panel sends `PUT /api/users/admin/:userId/status` with `{ status: 'inactive' }` → 404 and wrong value
|
||||||
|
- Clicking "Update role" sends `PUT /api/users/admin/:userId/role` → 404
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
- Use `axiosInstance.patch(...)` for both actions
|
||||||
|
- Status values should be `'active' | 'suspended' | 'deleted'` to match the backend enum
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/actions/user.ts` — `updateUserStatus` (line ~162), `updateUserRole` (line ~175)
|
||||||
|
- `frontend/src/types/user.ts` (line ~159) — status union type needs to include `'suspended'` and remove `'inactive'`/`'pending'`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Findings C26, C27
|
||||||
36
Issues/ISSUE-011-update-purchase-request-put-vs-patch.md
Normal file
36
Issues/ISSUE-011-update-purchase-request-put-vs-patch.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
issue: "011"
|
||||||
|
title: "updatePurchaseRequest sends PUT but backend only accepts PATCH"
|
||||||
|
severity: major
|
||||||
|
domain: purchase-request
|
||||||
|
labels: [frontend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 updatePurchaseRequest sends PUT but backend only accepts PATCH
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** purchase-request
|
||||||
|
**Labels:** frontend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/actions/marketplace.ts` (line ~71) calls `axiosInstance.put(endpoints.marketplace.requests.update)`. The backend registers `PATCH /marketplace/purchase-requests/:id` (routes.ts). Sending PUT results in 404/405 — edits to purchase requests silently fail.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Editing a purchase request from the buyer edit view sends `PUT /marketplace/purchase-requests/:id` → 404. The request is not updated.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
The action should use `axiosInstance.patch(...)`.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/actions/marketplace.ts` — `updatePurchaseRequest` function (verb: `put` → `patch`)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M18
|
||||||
36
Issues/ISSUE-012-update-offer-put-vs-patch.md
Normal file
36
Issues/ISSUE-012-update-offer-put-vs-patch.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
issue: "012"
|
||||||
|
title: "updateOffer sends PUT but backend registers PATCH — offer edits fail"
|
||||||
|
severity: major
|
||||||
|
domain: seller-offer
|
||||||
|
labels: [frontend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 updateOffer sends PUT but backend registers PATCH — offer edits fail
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** seller-offer
|
||||||
|
**Labels:** frontend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/actions/marketplace.ts` (line ~289) calls `axiosInstance.put(endpoints.marketplace.offers.update)` mapping to `PUT /marketplace/offers/:id`. The backend registers `PATCH /offers/:id` (routes.ts line ~1260). Method mismatch → 404 or matched wrong route. `step-1-send-proposal.tsx` calls `updateOffer()` for proposal edits, so this path is actively exercised.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
A seller editing an existing proposal sends `PUT /marketplace/offers/:id` which does not match the registered `PATCH` handler.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
`updateOffer` should use `axiosInstance.patch(...)`.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/actions/marketplace.ts` — `updateOffer` function
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M28
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
issue: "013"
|
||||||
|
title: "select-offer cascade overwrites withdrawn/rejected offers — missing status filter in updateMany"
|
||||||
|
severity: major
|
||||||
|
domain: seller-offer
|
||||||
|
labels: [backend, bug, data-integrity]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 select-offer cascade overwrites withdrawn/rejected offers — missing status filter in updateMany
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** seller-offer
|
||||||
|
**Labels:** backend, bug, data-integrity
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`POST /api/marketplace/purchase-requests/:id/select-offer` (routes.ts lines ~1386-1395) calls `SellerOffer.updateMany({ purchaseRequestId, _id: { $ne: offerId } }, { status: 'rejected' })` with **no status filter**. This overwrites offers that are already `'withdrawn'` or previously `'rejected'`, corrupting their status history.
|
||||||
|
|
||||||
|
By contrast, `SellerOfferService.acceptOffer()` (the service method used by `PUT /offers/:id/accept`) correctly filters with `status: { $in: ['pending', 'active'] }` before bulk-rejecting competitors.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
1. Seller A submits offer → pending
|
||||||
|
2. Seller B submits offer → pending
|
||||||
|
3. Seller B withdraws offer → withdrawn
|
||||||
|
4. Buyer selects Seller A's offer via `POST .../select-offer`
|
||||||
|
5. Seller B's withdrawn offer is **overwritten to 'rejected'** — status history corrupted
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
The `updateMany` in the `select-offer` route handler should add `status: { $in: ['pending'] }` to only reject currently-pending competing offers. Already-withdrawn or rejected offers should be left untouched.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/routes.ts` (or marketplaceController.ts) — `select-offer` route handler's `updateMany` call
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M23
|
||||||
43
Issues/ISSUE-014-select-offer-no-seller-notifications.md
Normal file
43
Issues/ISSUE-014-select-offer-no-seller-notifications.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
issue: "014"
|
||||||
|
title: "select-offer sends no per-seller socket events or notifications to winning/losing sellers"
|
||||||
|
severity: major
|
||||||
|
domain: seller-offer
|
||||||
|
labels: [backend, missing-feature]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 select-offer sends no per-seller socket events or notifications to winning/losing sellers
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** seller-offer
|
||||||
|
**Labels:** backend, missing-feature
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`POST /api/marketplace/purchase-requests/:id/select-offer` (routes.ts lines ~1300-1438) emits only a single `purchase-request-update` event to the request room with `eventType: 'offer-selected'`. It does NOT:
|
||||||
|
- Call `notifyOfferAccepted` for the winning seller
|
||||||
|
- Call `notifyOfferRejected` for losing sellers
|
||||||
|
- Emit `seller-offer-update` events to individual seller rooms
|
||||||
|
|
||||||
|
These notifications only fire when using `PUT /offers/:id/accept` or `PUT /offers/:id/status` (via `SellerOfferService.updateOfferStatus`), not via the `select-offer` path used by the frontend.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Buyer selects an offer → winning seller gets no real-time notification → losing sellers get no notification.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
When a buyer selects an offer:
|
||||||
|
1. Winning seller receives a `seller-offer-update` event and a push notification
|
||||||
|
2. Losing sellers receive a `seller-offer-update` event and a notification
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/routes.ts` — `select-offer` route handler, missing `notifyOfferAccepted` and `notifyOfferRejected` calls
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M25
|
||||||
44
Issues/ISSUE-015-seller-offer-withdraw-no-http-route.md
Normal file
44
Issues/ISSUE-015-seller-offer-withdraw-no-http-route.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
issue: "015"
|
||||||
|
title: "Seller offer withdraw has no HTTP route — withdrawOffer() service method is dead code"
|
||||||
|
severity: major
|
||||||
|
domain: seller-offer
|
||||||
|
labels: [backend, missing-feature]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 Seller offer withdraw has no HTTP route — withdrawOffer() service method is dead code
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** seller-offer
|
||||||
|
**Labels:** backend, missing-feature
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`SellerOfferService.withdrawOffer()` (SellerOfferService.ts lines ~427-443) exists and implements withdrawal logic, but no HTTP route calls it. The documented `POST /api/marketplace/offers/:id/withdraw` endpoint does not exist in `routes.ts` or `marketplaceController.ts`.
|
||||||
|
|
||||||
|
There is also no frontend `withdrawOffer()` action, no withdraw button in any seller step component, and no seller offers history page at `/dashboard/seller/marketplace/offers`.
|
||||||
|
|
||||||
|
The only workaround is `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`, which has no guard ensuring the requester is the offer's seller.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Sellers cannot withdraw their pending offers through any UI path. Withdrawing via `PUT /offers/:id/status` is the only API path and has no ownership guard.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
1. Wire a `POST /api/marketplace/offers/:id/withdraw` route to `SellerOfferService.withdrawOffer()`
|
||||||
|
2. Add an ownership guard (only the offer's seller can withdraw)
|
||||||
|
3. Add a frontend withdraw button and action
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/routes.ts` — missing `POST /offers/:id/withdraw` route
|
||||||
|
- `frontend/src/actions/marketplace.ts` — missing `withdrawOffer` action
|
||||||
|
- Frontend seller dashboard — missing offers list page
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Findings C9, M26
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
issue: "016"
|
||||||
|
title: "createProviderPaymentIntent always routes to request-network regardless of provider — SHKeeper checkout broken"
|
||||||
|
severity: critical
|
||||||
|
domain: payment
|
||||||
|
labels: [frontend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 createProviderPaymentIntent always routes to request-network regardless of provider — SHKeeper checkout broken
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** payment
|
||||||
|
**Labels:** frontend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/actions/payment.ts` — `getProviderIntentEndpoint()` ignores its `provider` argument and always returns `endpoints.payments.requestNetwork.intents` (`/payment/request-network/intents`).
|
||||||
|
|
||||||
|
If any UI component passes `provider='shkeeper'` to `createProviderPaymentIntent()`, the intent creation silently POSTs to the Request Network endpoint instead of `/payment/shkeeper/intents`. The SHKeeper intents endpoint is defined in `axios.ts` but is never reached by this factory.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
A SHKeeper checkout call to `createProviderPaymentIntent('shkeeper', ...)` POSTs to `/payment/request-network/intents`. The RN endpoint creates a Request Network intent, not a SHKeeper intent. The payment provider is silently misrouted.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
`getProviderIntentEndpoint('shkeeper')` should return `endpoints.payments.shkeeper.intents`. The function should switch on the provider argument.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/actions/payment.ts` — `getProviderIntentEndpoint()` function (~line 444)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M38
|
||||||
|
- Related: [[ISSUE-017-payment-provider-type-missing-values]]
|
||||||
46
Issues/ISSUE-017-payment-provider-type-missing-values.md
Normal file
46
Issues/ISSUE-017-payment-provider-type-missing-values.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
issue: "017"
|
||||||
|
title: "PaymentProvider TypeScript type missing 'shkeeper' and 'decentralized' values"
|
||||||
|
severity: major
|
||||||
|
domain: payment
|
||||||
|
labels: [frontend, bug, typescript]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 PaymentProvider TypeScript type missing 'shkeeper' and 'decentralized' values
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** payment
|
||||||
|
**Labels:** frontend, bug, typescript
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/types/payment.ts` defines:
|
||||||
|
```ts
|
||||||
|
type PaymentProvider = 'request.network' | 'test' | 'other'
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend accepts `'shkeeper'`, `'decentralized'`, and `'other'` as `provider` values on Payment records. The two most-used production providers (`shkeeper`, `decentralized`) are absent from the TypeScript union.
|
||||||
|
|
||||||
|
Any frontend code that switches on `payment.provider` will fall through to a default/unknown branch for all SHKeeper and DePay payments, causing incorrect UI rendering (wrong labels, missing payment method icons, etc.).
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
SHKeeper and DePay payments in the payment list and payment detail views may show as "Unknown provider" or trigger TypeScript errors at compile time.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type PaymentProvider = 'request.network' | 'shkeeper' | 'decentralized' | 'test' | 'other'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/types/payment.ts` — `PaymentProvider` type definition (~line 15)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M37
|
||||||
|
- Related: [[ISSUE-016-payment-provider-routing-always-request-network]]
|
||||||
53
Issues/ISSUE-018-trezor-no-frontend-implementation.md
Normal file
53
Issues/ISSUE-018-trezor-no-frontend-implementation.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
issue: "018"
|
||||||
|
title: "Trezor Safekeeping has zero frontend implementation — all backend endpoints unreachable from UI"
|
||||||
|
severity: critical
|
||||||
|
domain: trezor
|
||||||
|
labels: [frontend, missing-feature]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔴 Trezor Safekeeping has zero frontend implementation — all backend endpoints unreachable from UI
|
||||||
|
|
||||||
|
**Severity:** critical
|
||||||
|
**Domain:** trezor
|
||||||
|
**Labels:** frontend, missing-feature
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A comprehensive search of all `.ts` and `.tsx` files in `frontend/src/` finds **zero calls** to any Trezor backend endpoint. There is no:
|
||||||
|
- Trezor registration page
|
||||||
|
- xpub input UI
|
||||||
|
- Trezor Connect SDK import
|
||||||
|
- Admin Trezor signing panel
|
||||||
|
- Any action calling `/api/trezor/*`
|
||||||
|
|
||||||
|
The only Trezor reference in the entire frontend is a brand logo in `wallet-icons.ts`.
|
||||||
|
|
||||||
|
The documented 12-step challenge-sign-submit flow exists entirely in the backend but has no frontend surface at any step.
|
||||||
|
|
||||||
|
Additionally, `confirmReleaseTx` and `confirmRefundTx` in `frontend/src/actions/payment.ts` post `{ txHash, ...extra }` with **no `trezor` object** (message + signature). With `TREZOR_SAFEKEEPING_REQUIRED=true`, every admin release/refund from the UI will be rejected by the backend's `assertTrezorSignatureForOperation` guard.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
- No UI exists for Trezor registration
|
||||||
|
- Admin release/refund with `TREZOR_SAFEKEEPING_REQUIRED=true` always fails (missing signature payload)
|
||||||
|
- All Trezor API endpoints are only testable via curl/Postman
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
A complete frontend implementation covering:
|
||||||
|
1. Trezor registration page (xpub input, challenge-sign-submit flow)
|
||||||
|
2. Operation signing UI for admin release/refund (call `POST /api/trezor/operation-message`, prompt sign, attach `trezor` object to confirm body)
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/actions/payment.ts` — `confirmReleaseTx`, `confirmRefundTx` missing `trezor` field
|
||||||
|
- Missing: Trezor registration page component
|
||||||
|
- Missing: Admin Trezor signing integration in dispute/payment admin panels
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Findings C31, C32
|
||||||
46
Issues/ISSUE-019-rn-payout-release-refund-not-implemented.md
Normal file
46
Issues/ISSUE-019-rn-payout-release-refund-not-implemented.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
issue: "019"
|
||||||
|
title: "Request Network admin payout/release/refund sub-routes do not exist in backend"
|
||||||
|
severity: major
|
||||||
|
domain: payment
|
||||||
|
labels: [backend, missing-feature]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 Request Network admin payout/release/refund sub-routes do not exist in backend
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** payment
|
||||||
|
**Labels:** backend, missing-feature
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/actions/payment.ts` exports four functions that hit non-existent backend endpoints:
|
||||||
|
|
||||||
|
| Function | Calls | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `initiateRequestNetworkPayout()` | `POST /api/payment/request-network/:id/payout/initiate` | 404 |
|
||||||
|
| `confirmRequestNetworkPayout()` | `POST /api/payment/request-network/:id/payout/confirm` | 404 |
|
||||||
|
| `confirmRequestNetworkRelease()` | `POST /api/payment/request-network/:id/release/confirm` | 404 |
|
||||||
|
| `confirmRequestNetworkRefund()` | `POST /api/payment/request-network/:id/refund/confirm` | 404 |
|
||||||
|
|
||||||
|
The backend only implements: `POST /api/payment/request-network/intents`, `GET /api/payment/request-network/:paymentId/checkout`, `POST /api/payment/request-network/webhook`.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
All four admin RN payout/release/refund actions return 404. Admin has no way to complete or refund a Request Network payment through the UI.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Backend should implement the four sub-routes, or the frontend actions should be mapped to the actual release/refund mechanism.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/actions/payment.ts` — `initiateRequestNetworkPayout`, `confirmRequestNetworkPayout`, `confirmRequestNetworkRelease`, `confirmRequestNetworkRefund`
|
||||||
|
- Backend: missing `request-network/:id/payout/*`, `release/confirm`, `refund/confirm` routes
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M34
|
||||||
42
Issues/ISSUE-020-dispute-assign-no-role-guard.md
Normal file
42
Issues/ISSUE-020-dispute-assign-no-role-guard.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
issue: "020"
|
||||||
|
title: "POST /api/disputes/:id/assign has no role guard — any user can self-assign as mediator"
|
||||||
|
severity: major
|
||||||
|
domain: dispute
|
||||||
|
labels: [security, backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 POST /api/disputes/:id/assign has no role guard — any user can self-assign as mediator
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** dispute
|
||||||
|
**Labels:** security, backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`POST /api/disputes/:id/assign` is mounted with only `authenticateToken`. Any authenticated buyer or seller can assign themselves as the mediator/admin for any open dispute.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/disputes/{disputeId}/assign
|
||||||
|
Authorization: Bearer <buyer-jwt>
|
||||||
|
{ "adminId": "<buyer-user-id>" }
|
||||||
|
```
|
||||||
|
Returns 200 and sets the dispute's assigned mediator to the buyer.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Should require `authorizeRoles('admin')`. Non-admin tokens should receive `403`.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/disputeRoutes.ts` — missing role guard on the assign route
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)
|
||||||
|
- Related: [[ISSUE-001-dispute-status-no-role-guard]], [[ISSUE-002-dispute-resolve-no-role-guard]]
|
||||||
45
Issues/ISSUE-021-axios-interceptor-403-not-handled.md
Normal file
45
Issues/ISSUE-021-axios-interceptor-403-not-handled.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
issue: "021"
|
||||||
|
title: "Axios interceptor only retriggers token refresh for 401, not 403"
|
||||||
|
severity: major
|
||||||
|
domain: auth
|
||||||
|
labels: [frontend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 Axios interceptor only retriggers token refresh for 401, not 403
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** auth
|
||||||
|
**Labels:** frontend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/lib/axios.ts` (line ~105) only triggers the token refresh flow for `status === 401`:
|
||||||
|
```ts
|
||||||
|
if (status === 401 && !isAuthRoute && !originalRequest?._retry) {
|
||||||
|
// trigger refresh
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A `403` response (e.g., `EMAIL_NOT_VERIFIED`, a blocked account, or an under-privileged action) is not intercepted — it propagates as an unhandled error. Depending on how calling components handle errors, this may result in a blank screen or silent failure rather than an appropriate user message.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Backend returns `403 EMAIL_NOT_VERIFIED` → interceptor does not retry or refresh → error propagates to the component. Some components may not handle this gracefully.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
The interceptor (or a separate error handler) should:
|
||||||
|
- On `403`: **not** attempt a token refresh (a 403 is an authorization failure, not an expired token)
|
||||||
|
- But should surface the error clearly to the user (e.g., redirect to verify-email page for `EMAIL_NOT_VERIFIED` errors)
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/lib/axios.ts` — response interceptor
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M1
|
||||||
38
Issues/ISSUE-022-rate-limit-counts-all-attempts.md
Normal file
38
Issues/ISSUE-022-rate-limit-counts-all-attempts.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
issue: "022"
|
||||||
|
title: "Login rate limiter counts all attempts (not just failures) — users can be locked out after correct logins"
|
||||||
|
severity: major
|
||||||
|
domain: auth
|
||||||
|
labels: [backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 Login rate limiter counts all attempts (not just failures) — users can be locked out after correct logins
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** auth
|
||||||
|
**Labels:** backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`rateLimitService.checkLoginAttempts()` calls `checkLimit()` which calls `redisService.incr` — incrementing the counter on **every invocation**, before password comparison. The counter is only reset after a full successful login (password verified + session created).
|
||||||
|
|
||||||
|
With the limit at 5 attempts/15 min, a user who makes 4 correct logins in quick succession (e.g., testing on multiple devices) followed by 1 wrong password will be locked out immediately, even though they never "failed" 5 times in the intended sense.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
5 total login attempts within 15 minutes (any combination of correct/incorrect passwords) triggers `429 TOO_MANY_ATTEMPTS`.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
The counter should only increment on **failed** password comparison, not on every attempt. Alternatively, the behaviour should be clearly documented so UX can warn users appropriately.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/services/auth/rateLimitService.ts` — `checkLoginAttempts` / `checkLimit` — counter increment should move to after password comparison in `authController.ts`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M3
|
||||||
37
Issues/ISSUE-023-change-password-no-ui.md
Normal file
37
Issues/ISSUE-023-change-password-no-ui.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
issue: "023"
|
||||||
|
title: "changePassword action exists but no dashboard UI page exposes it"
|
||||||
|
severity: major
|
||||||
|
domain: auth
|
||||||
|
labels: [frontend, missing-feature]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 changePassword action exists but no dashboard UI page exposes it
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** auth
|
||||||
|
**Labels:** frontend, missing-feature
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/actions/account.ts` (line ~263) defines `changePassword()` which calls `POST /api/auth/change-password`. The backend endpoint exists and `changePasswordValidation` enforces password complexity (uppercase + lowercase + digit). However, **no dashboard page or component renders a change-password form**. The feature is API-only.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Users have no UI path to change their password after login. The only password reset mechanism is the email-based reset flow.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
A "Change Password" section in the account settings dashboard (e.g., under `/dashboard/account`) that calls `changePassword()` with `{ currentPassword, newPassword }`.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- Missing: Change password form component in `/dashboard/account` or `/dashboard/account/security`
|
||||||
|
- `frontend/src/actions/account.ts` — `changePassword` function (implemented, no callers)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M4
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
issue: "024"
|
||||||
|
title: "POST /api/auth/reset-password-with-code accepts weak passwords — no complexity validation"
|
||||||
|
severity: major
|
||||||
|
domain: auth
|
||||||
|
labels: [backend, security, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 POST /api/auth/reset-password-with-code accepts weak passwords — no complexity validation
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** auth
|
||||||
|
**Labels:** backend, security, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`POST /api/auth/reset-password-with-code` has **no `passwordResetValidation` middleware** (`authRoutes.ts` line ~54-57). The controller only validates that email, code, and password fields are present, and that the code is 6 digits.
|
||||||
|
|
||||||
|
Passwords like `'123456'`, `'aaaaaa'`, or `'password'` are accepted.
|
||||||
|
|
||||||
|
By contrast, the legacy `POST /api/auth/reset-password` (token-based) is wired with `passwordResetValidation` which enforces `/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/` — at least one uppercase, one lowercase, one digit.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
`POST /api/auth/reset-password-with-code` with `{ email, code: "123456", password: "aaaaaa" }` → 200, password reset to weak value.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Apply `passwordResetValidation` (or equivalent inline validation) to `reset-password-with-code` as well.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/authRoutes.ts` — line ~54-57, add `passwordResetValidation` middleware
|
||||||
|
- `backend/src/shared/middleware/authValidation.ts` — `passwordResetValidation` definition
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M6
|
||||||
46
Issues/ISSUE-025-dispute-socket-events-all-stubs.md
Normal file
46
Issues/ISSUE-025-dispute-socket-events-all-stubs.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
issue: "025"
|
||||||
|
title: "All dispute socket events are commented-out TODO stubs — no real-time updates in dispute flow"
|
||||||
|
severity: major
|
||||||
|
domain: dispute
|
||||||
|
labels: [backend, missing-feature]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 All dispute socket events are commented-out TODO stubs — no real-time updates in dispute flow
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** dispute
|
||||||
|
**Labels:** backend, missing-feature
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Every `socket.io` emit block in `DisputeService` is currently commented out as a TODO. No real-time updates fire for any dispute lifecycle event:
|
||||||
|
- Dispute created
|
||||||
|
- Admin assigned
|
||||||
|
- Status changed
|
||||||
|
- Evidence uploaded
|
||||||
|
- Resolution posted
|
||||||
|
|
||||||
|
The dispute flow is CRUD-only. Any UI component that relies on socket events for real-time dispute state will never receive updates.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
All dispute state changes are only visible after a manual page refresh.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Implement the socket emit calls for key dispute events:
|
||||||
|
- `dispute-created` → to buyer, seller, and admin rooms
|
||||||
|
- `dispute-status-changed` → to involved parties
|
||||||
|
- `dispute-resolved` → to buyer and seller rooms
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/services/dispute/disputeService.ts` — all commented-out `io.to(...).emit(...)` blocks
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)
|
||||||
38
Issues/ISSUE-026-payment-completed-not-counted-in-stats.md
Normal file
38
Issues/ISSUE-026-payment-completed-not-counted-in-stats.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
issue: "026"
|
||||||
|
title: "'completed' payment status not counted in successfulPayments stats — admin dashboard undercounts"
|
||||||
|
severity: major
|
||||||
|
domain: payment
|
||||||
|
labels: [backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 'completed' payment status not counted in successfulPayments stats — admin dashboard undercounts
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** payment
|
||||||
|
**Labels:** backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`paymentService.getPaymentStats()` aggregate counts only `'confirmed'` as `successfulPayments`. `'completed'` is excluded from this count.
|
||||||
|
|
||||||
|
Most SHKeeper payments follow the terminal path: `pending → processing → completed`. `'confirmed'` is a separate RN-specific intermediate state. This means the vast majority of successfully completed payments (SHKeeper + DePay) are **invisible in the `successfulPayments` count** in the admin stats endpoint.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Admin dashboard shows a `successfulPayments` count that excludes all `'completed'` status payments. For a platform where SHKeeper is the primary payment provider, this count is close to 0 even when hundreds of payments have succeeded.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
`successfulPayments` should count payments in both `'confirmed'` and `'completed'` status, or the aggregate should be documented with a clear note about which statuses are terminal success states.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/services/payment/paymentService.ts` — `getPaymentStats()` aggregate pipeline
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M36
|
||||||
38
Issues/ISSUE-027-get-notification-by-id-broken.md
Normal file
38
Issues/ISSUE-027-get-notification-by-id-broken.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
issue: "027"
|
||||||
|
title: "GET /api/notifications/:id always 404s for non-latest notifications — broken in-memory lookup"
|
||||||
|
severity: major
|
||||||
|
domain: notification
|
||||||
|
labels: [backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 GET /api/notifications/:id always 404s for non-latest notifications — broken in-memory lookup
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** notification
|
||||||
|
**Labels:** backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `getNotificationById` controller does NOT perform a direct MongoDB `findById` lookup. Instead it calls `getUserNotifications(userId, 1, 1)` — fetching only the user's single most-recent notification — and then does an **in-memory `_id` string comparison**.
|
||||||
|
|
||||||
|
Any notification that is not the user's absolute latest record returns `404`, regardless of ownership. This makes the endpoint completely unreliable for any consumer that tries to fetch a specific notification by ID.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
`GET /api/notifications/abc123` returns the notification only if `abc123` happens to be the user's most recently created notification. For all others: 404.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
`getNotificationById` should do a direct `Notification.findOne({ _id: id, userId })` query.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/services/notification/notificationService.ts` (or controller) — `getNotificationById` / `getUserNotifications` call
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding C22
|
||||||
41
Issues/ISSUE-028-payment-export-no-admin-guard.md
Normal file
41
Issues/ISSUE-028-payment-export-no-admin-guard.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
issue: "028"
|
||||||
|
title: "GET /api/payment/export has no admin role guard — any authenticated user can export payment data"
|
||||||
|
severity: major
|
||||||
|
domain: payment
|
||||||
|
labels: [security, backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 GET /api/payment/export has no admin role guard — any authenticated user can export payment data
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** payment
|
||||||
|
**Labels:** security, backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Two parallel export endpoints exist:
|
||||||
|
- `GET /api/payment/payments/export` — has `authorizeRoles('admin')` guard (correct)
|
||||||
|
- `GET /api/payment/export` (controller-pattern route) — only has `authenticateToken`, **no admin guard**
|
||||||
|
|
||||||
|
The frontend hits `/payment/export` (the controller-pattern route without the admin guard). Any authenticated buyer can export payment records.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
`GET /api/payment/export` with any valid user JWT → 200 with payment export data.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
`GET /api/payment/export` should require `authorizeRoles('admin')`, or the frontend should be pointed at `/api/payment/payments/export`.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- Backend: controller-pattern route for `GET /payment/export` — missing `authorizeRoles('admin')`
|
||||||
|
- `frontend/src/lib/axios.ts` — `endpoints.payments.export` maps to the wrong route
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M31
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
issue: "029"
|
||||||
|
title: "Frontend delivery actions regenerate/attempts/stats call non-existent backend endpoints"
|
||||||
|
severity: major
|
||||||
|
domain: delivery
|
||||||
|
labels: [frontend, missing-feature]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 Frontend delivery actions regenerate/attempts/stats call non-existent backend endpoints
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** delivery
|
||||||
|
**Labels:** frontend, missing-feature
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Three frontend delivery actions hit non-existent backend routes:
|
||||||
|
|
||||||
|
| Action | Calls | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `regenerateDeliveryCode` | `POST /delivery-code/regenerate` | 404 (falls back to `/generate`) |
|
||||||
|
| `getDeliveryAttempts` | `GET /delivery-code/attempts` | 404, throws |
|
||||||
|
| `getDeliveryStats` | `GET /delivery/stats` | 404, throws |
|
||||||
|
|
||||||
|
`regenerateDeliveryCode` silently falls back to the generate endpoint on 404. The other two throw unhandled errors if any component calls them.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
- Code "regeneration" actually calls generate (new code, ignores regenerate semantic)
|
||||||
|
- Any UI showing delivery attempt count or stats shows nothing or throws
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Either implement the backend routes, or remove the phantom actions and handle their use cases differently.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/actions/delivery.ts` — `regenerateDeliveryCode`, `getDeliveryAttempts`, `getDeliveryStats`
|
||||||
|
- Backend: missing routes for `/delivery-code/regenerate`, `/delivery-code/attempts`, `/delivery/stats`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M15
|
||||||
36
Issues/ISSUE-030-confirm-delivery-no-auth-guard.md
Normal file
36
Issues/ISSUE-030-confirm-delivery-no-auth-guard.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
issue: "030"
|
||||||
|
title: "PATCH /confirm-delivery has no ownership check — any authenticated user can confirm delivery"
|
||||||
|
severity: major
|
||||||
|
domain: delivery
|
||||||
|
labels: [backend, security, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 PATCH /confirm-delivery has no ownership check — any authenticated user can confirm delivery
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** delivery
|
||||||
|
**Labels:** backend, security, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`PATCH /api/marketplace/purchase-requests/:id/confirm-delivery` (the buyer fast-track path to `'delivered'` status) has no ownership or role check. Any authenticated user who knows a purchase request ID can mark it as delivered without possessing the delivery code.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
`PATCH /purchase-requests/{anyId}/confirm-delivery` with any valid JWT → 200, status set to `'delivered'`.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Should verify `req.user.id === request.buyerId` — only the buyer of that specific request should be able to confirm delivery via this fast-track path.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/controllerRoutes.ts` or `routes.ts` — `confirm-delivery` handler missing ownership guard
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)
|
||||||
47
Issues/ISSUE-031-points-missing-frontend-pages.md
Normal file
47
Issues/ISSUE-031-points-missing-frontend-pages.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
issue: "031"
|
||||||
|
title: "Points/referral system missing 5 frontend pages — redemption, levels, referrals, transactions, admin"
|
||||||
|
severity: major
|
||||||
|
domain: points
|
||||||
|
labels: [frontend, missing-feature]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 Points/referral system missing 5 frontend pages — redemption, levels, referrals, transactions, admin
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** points
|
||||||
|
**Labels:** frontend, missing-feature
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The following routes return 404 because no frontend pages exist:
|
||||||
|
|
||||||
|
| Route | Backend Endpoint | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `/dashboard/points/referrals` | `GET /api/points/referrals` | Page missing |
|
||||||
|
| `/dashboard/points/transactions` | `GET /api/points/transactions` | Page missing |
|
||||||
|
| `/dashboard/points/levels` | `GET /api/points/levels` | Page missing |
|
||||||
|
| `/dashboard/points/redeem` (or any UI) | `POST /api/points/redeem` | No redemption UI anywhere |
|
||||||
|
| Admin points management | `POST /api/points/admin/add` | No admin page |
|
||||||
|
|
||||||
|
`redeemPoints()` and `generateReferralCode()` actions are defined but have no call sites in any component.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
All points features beyond the basic balance display are inaccessible from the UI.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Implement frontend pages for: referral history, transaction history, levels display, points redemption flow, and admin points management.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- Missing pages in `frontend/src/app/dashboard/points/`
|
||||||
|
- `frontend/src/actions/points.ts` — `redeemPoints`, `generateReferralCode` (defined, no callers)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)
|
||||||
45
Issues/ISSUE-032-shkeeper-release-refund-wrong-paths.md
Normal file
45
Issues/ISSUE-032-shkeeper-release-refund-wrong-paths.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
issue: "032"
|
||||||
|
title: "SHKeeper release/refund doc paths include erroneous /shkeeper/ segment"
|
||||||
|
severity: major
|
||||||
|
domain: payment
|
||||||
|
labels: [backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 SHKeeper release/refund doc paths include erroneous /shkeeper/ segment
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** payment
|
||||||
|
**Labels:** backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The SHKeeper Payment Flow was documented with `/shkeeper/` in the release/refund paths. The actual backend routes are:
|
||||||
|
|
||||||
|
| Documented (wrong) | Actual (correct) |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/payment/shkeeper/:id/release` | `POST /api/payment/:id/release` |
|
||||||
|
| `POST /api/payment/shkeeper/:id/release/confirm` | `POST /api/payment/:id/release/confirm` |
|
||||||
|
| `POST /api/payment/shkeeper/:id/refund` | `POST /api/payment/:id/refund` |
|
||||||
|
| `POST /api/payment/shkeeper/:id/refund/confirm` | `POST /api/payment/:id/refund/confirm` |
|
||||||
|
|
||||||
|
The frontend `endpoints.payments.details` maps to `/payment/:id` (correct), so the frontend is unaffected. The issue is in the documentation and any external integration or test harness built from the docs.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Calling any `/shkeeper/` path returns 404.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Documentation and any test harnesses should use paths without the `/shkeeper/` segment.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- Doc file updated: `04 - Flows/Payment Flow - SHKeeper.md`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding C30
|
||||||
42
Issues/ISSUE-033-seller-offer-history-route-missing.md
Normal file
42
Issues/ISSUE-033-seller-offer-history-route-missing.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
issue: "033"
|
||||||
|
title: "GET seller offer history has no HTTP route — getOffersBySeller() is unreachable dead code"
|
||||||
|
severity: major
|
||||||
|
domain: seller-offer
|
||||||
|
labels: [backend, missing-feature]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 GET seller offer history has no HTTP route — getOffersBySeller() is unreachable dead code
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** seller-offer
|
||||||
|
**Labels:** backend, missing-feature
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`SellerOfferService.getOffersBySeller()` exists in the service layer but no HTTP route exposes it. The documented endpoint `GET /api/marketplace/offers/seller/:sellerId` does not exist in `routes.ts` or `marketplaceController.ts`.
|
||||||
|
|
||||||
|
Notification action URLs that point to `/dashboard/seller/marketplace/offers` are also broken — that frontend page does not exist.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
- Sellers have no way to view their own offer history via the API
|
||||||
|
- Notification deep-links to the offers page return 404
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
1. Register `GET /api/marketplace/offers/seller/:sellerId` (or equivalent scoped route) calling `getOffersBySeller()`
|
||||||
|
2. Create the frontend page at `/dashboard/seller/marketplace/offers`
|
||||||
|
3. Fix notification `actionUrl` to point to the real page
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `backend/src/routes/routes.ts` — missing `GET /offers/seller/:sellerId` route
|
||||||
|
- Missing: `frontend/src/app/dashboard/shops/` or similar seller offers list page
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M27
|
||||||
41
Issues/ISSUE-034-seller-offer-active-status-invalid.md
Normal file
41
Issues/ISSUE-034-seller-offer-active-status-invalid.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
issue: "034"
|
||||||
|
title: "SellerOffer 'active' status does not exist in schema — saves with this value throw ValidationError"
|
||||||
|
severity: major
|
||||||
|
domain: seller-offer
|
||||||
|
labels: [backend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 SellerOffer 'active' status does not exist in schema — saves with this value throw ValidationError
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** seller-offer
|
||||||
|
**Labels:** backend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The Seller Offer Flow doc lists `'active'` as a valid `SellerOffer.status`. The Mongoose schema and TypeScript interface only enumerate:
|
||||||
|
```
|
||||||
|
'pending' | 'accepted' | 'rejected' | 'withdrawn'
|
||||||
|
```
|
||||||
|
|
||||||
|
Any code path that attempts to set `SellerOffer.status = 'active'` will throw a Mongoose `ValidationError`. The `createOffer()` service correctly checks `PurchaseRequest.status === 'active'` (a different model's status), but `SellerOffer.status = 'active'` is never valid.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
`SellerOffer.save()` with `status: 'active'` → Mongoose ValidationError. (Currently no code path actually tries to do this — the bug is latent but would be triggered by misreading the documentation.)
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Remove `'active'` from all `SellerOffer` status documentation. The valid states are `pending | accepted | rejected | withdrawn`.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- Doc file updated: `04 - Flows/Seller Offer Flow.md` and `02 - Data Models/SellerOffer.md`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding M22
|
||||||
41
Issues/ISSUE-035-payment-dispute-verify-button-404.md
Normal file
41
Issues/ISSUE-035-payment-dispute-verify-button-404.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
issue: "035"
|
||||||
|
title: "Dispute payment card 'Verify' button always 404s — getPaymentStatus calls non-existent endpoint"
|
||||||
|
severity: major
|
||||||
|
domain: payment
|
||||||
|
labels: [frontend, bug]
|
||||||
|
status: open
|
||||||
|
created: 2026-05-29
|
||||||
|
source: Doc vs Code Audit 2026-05-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🟠 Dispute payment card 'Verify' button always 404s — getPaymentStatus calls non-existent endpoint
|
||||||
|
|
||||||
|
**Severity:** major
|
||||||
|
**Domain:** payment
|
||||||
|
**Labels:** frontend, bug
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`frontend/src/sections/dispute/components/payment-details-card.tsx` (line ~101) calls `getPaymentStatus()` which builds URL as `GET /payment/:id/status`. No `/status` sub-route exists on any payment route in the backend.
|
||||||
|
|
||||||
|
The 'Verify' button in the dispute panel is permanently broken in production.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
Clicking 'Verify' on the dispute payment card → `GET /payment/{id}/status` → 404.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
Either:
|
||||||
|
1. Implement `GET /api/payment/:id/status` on the backend, or
|
||||||
|
2. Update the component to use the existing `GET /api/payment/:id` endpoint for payment detail fetching
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `frontend/src/sections/dispute/components/payment-details-card.tsx` — line ~101
|
||||||
|
- `frontend/src/actions/payment.ts` — `getPaymentStatus` function
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md) — Finding C13
|
||||||
59
Issues/Issues Index.md
Normal file
59
Issues/Issues Index.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Issues Index
|
||||||
|
|
||||||
|
> Generated from Doc vs Code Audit — 2026-05-29
|
||||||
|
> **35 open issues** | 🔴 14 critical · 🟠 19 major · 🟡 2 minor
|
||||||
|
|
||||||
|
## 🔴 Critical
|
||||||
|
|
||||||
|
- [[ISSUE-001-dispute-status-no-role-guard|PATCH /api/disputes/:id/status no role guard — privilege escalation]] — `dispute` · security
|
||||||
|
- [[ISSUE-002-dispute-resolve-no-role-guard|POST /api/disputes/:id/resolve no role guard — any user can resolve + ban sellers]] — `dispute` · security
|
||||||
|
- [[ISSUE-003-dispute-route-shadowing|Route shadowing: two dispute routers at /api/disputes — wrong handler fires]] — `dispute`
|
||||||
|
- [[ISSUE-004-payment-endpoints-no-auth|fetch-tx, auto-fetch-missing, debug payment endpoints have no authentication]] — `payment` · security
|
||||||
|
- [[ISSUE-005-scanner-status-no-auth|GET /api/admin/scanner/status has no authentication]] — `admin` · security
|
||||||
|
- [[ISSUE-006-delete-account-wrong-endpoint|Frontend deleteAccount calls DELETE /user/profile — endpoint doesn't exist]] — `auth`
|
||||||
|
- [[ISSUE-007-sim-bypass-no-env-guard|SIM_ transaction bypass active in production — no NODE_ENV guard]] — `payment` · security
|
||||||
|
- [[ISSUE-008-chat-file-upload-wrong-endpoint|sendFileMessage posts to wrong endpoint — chat file uploads always fail]] — `chat`
|
||||||
|
- [[ISSUE-010-admin-user-status-wrong-values-and-verb|Admin user status/role broken: wrong HTTP verb + wrong status values]] — `admin`
|
||||||
|
- [[ISSUE-016-payment-provider-routing-always-request-network|createProviderPaymentIntent always routes to request-network — SHKeeper broken]] — `payment`
|
||||||
|
- [[ISSUE-018-trezor-no-frontend-implementation|Trezor Safekeeping has zero frontend implementation]] — `trezor`
|
||||||
|
- [[ISSUE-020-dispute-assign-no-role-guard|POST /api/disputes/:id/assign no role guard — any user can self-assign mediator]] — `dispute` · security
|
||||||
|
- [[ISSUE-030-confirm-delivery-no-auth-guard|PATCH /confirm-delivery no ownership check — any user can confirm delivery]] — `delivery` · security
|
||||||
|
- [[ISSUE-035-payment-dispute-verify-button-404|Dispute 'Verify' button always 404s — getPaymentStatus hits non-existent endpoint]] — `payment`
|
||||||
|
|
||||||
|
## 🟠 Major
|
||||||
|
|
||||||
|
- [[ISSUE-009-archive-chat-wrong-method|archiveConversation uses PUT but backend only accepts PATCH]] — `chat`
|
||||||
|
- [[ISSUE-011-update-purchase-request-put-vs-patch|updatePurchaseRequest sends PUT but backend only accepts PATCH]] — `purchase-request`
|
||||||
|
- [[ISSUE-012-update-offer-put-vs-patch|updateOffer sends PUT but backend registers PATCH]] — `seller-offer`
|
||||||
|
- [[ISSUE-013-select-offer-no-status-filter-corrupts-withdrawn|select-offer cascade overwrites withdrawn offers — missing status filter]] — `seller-offer` · data-integrity
|
||||||
|
- [[ISSUE-014-select-offer-no-seller-notifications|select-offer sends no per-seller notifications to winning/losing sellers]] — `seller-offer`
|
||||||
|
- [[ISSUE-015-seller-offer-withdraw-no-http-route|Seller offer withdraw has no HTTP route — withdrawOffer() is dead code]] — `seller-offer`
|
||||||
|
- [[ISSUE-017-payment-provider-type-missing-values|PaymentProvider TypeScript type missing 'shkeeper' and 'decentralized']] — `payment`
|
||||||
|
- [[ISSUE-019-rn-payout-release-refund-not-implemented|Request Network admin payout/release/refund sub-routes do not exist]] — `payment`
|
||||||
|
- [[ISSUE-021-axios-interceptor-403-not-handled|Axios interceptor only retriggers token refresh for 401, not 403]] — `auth`
|
||||||
|
- [[ISSUE-022-rate-limit-counts-all-attempts|Login rate limiter counts all attempts — users locked out after correct logins]] — `auth`
|
||||||
|
- [[ISSUE-023-change-password-no-ui|changePassword action exists but no dashboard UI page]] — `auth`
|
||||||
|
- [[ISSUE-024-reset-password-with-code-no-complexity-check|POST /api/auth/reset-password-with-code accepts weak passwords]] — `auth` · security
|
||||||
|
- [[ISSUE-025-dispute-socket-events-all-stubs|All dispute socket events are TODO stubs — no real-time updates]] — `dispute`
|
||||||
|
- [[ISSUE-026-payment-completed-not-counted-in-stats|'completed' payment not counted in successfulPayments — admin dashboard undercounts]] — `payment`
|
||||||
|
- [[ISSUE-027-get-notification-by-id-broken|GET /api/notifications/:id always 404s for non-latest notifications]] — `notification`
|
||||||
|
- [[ISSUE-028-payment-export-no-admin-guard|GET /api/payment/export has no admin guard — any user can export payments]] — `payment` · security
|
||||||
|
- [[ISSUE-029-delivery-attempts-stats-phantom-endpoints|Frontend delivery actions regenerate/attempts/stats hit non-existent endpoints]] — `delivery`
|
||||||
|
- [[ISSUE-031-points-missing-frontend-pages|Points/referral missing 5 frontend pages — redemption, levels, referrals, transactions, admin]] — `points`
|
||||||
|
- [[ISSUE-032-shkeeper-release-refund-wrong-paths|SHKeeper release/refund doc paths include erroneous /shkeeper/ segment]] — `payment`
|
||||||
|
- [[ISSUE-033-seller-offer-history-route-missing|GET seller offer history has no HTTP route — getOffersBySeller() is dead code]] — `seller-offer`
|
||||||
|
- [[ISSUE-034-seller-offer-active-status-invalid|SellerOffer 'active' status invalid — saves throw ValidationError]] — `seller-offer`
|
||||||
|
|
||||||
|
## Security Issues Summary
|
||||||
|
|
||||||
|
| # | Issue | Severity |
|
||||||
|
|---|---|---|
|
||||||
|
| 001 | Dispute status PATCH — no role guard (privilege escalation) | 🔴 Critical |
|
||||||
|
| 002 | Dispute resolve POST — no role guard (ban_seller without auth) | 🔴 Critical |
|
||||||
|
| 004 | Payment fetch-tx/auto-fetch/debug — no authentication | 🔴 Critical |
|
||||||
|
| 005 | Admin scanner status — no authentication | 🔴 Critical |
|
||||||
|
| 007 | SIM_ bypass active in production | 🔴 Critical |
|
||||||
|
| 020 | Dispute assign — no role guard | 🔴 Critical |
|
||||||
|
| 030 | confirm-delivery — no ownership check | 🔴 Critical |
|
||||||
|
| 024 | reset-password-with-code — no complexity validation | 🟠 Major |
|
||||||
|
| 028 | Payment export — no admin guard | 🟠 Major |
|
||||||
Reference in New Issue
Block a user