audit: 2026-05-30 full-codebase audit — report, issues, docs, runbooks
Full-codebase-audit 2026-05-30 outputs: - Audit report: 09 - Audits/Full Codebase Audit - 2026-05-30.md - 81 issue files ISSUE-055..135 (decisions + 1 skipped no-brainer). - Scanner docs from scratch (was zero): architecture, data model, API ref, payment flow, operations runbook + repo README. - Doc-sync updates across API reference, data models, flows, design system. - Secret Rotation Runbook (08 - Operations) for the exposed credentials. - Reusable workflow guide (07 - Development) + .claude/workflows/full-codebase-audit.js. Issues remain status:open intentionally — the code fixes are uncommitted-then-committed working-tree changes per repo and aren't "resolved" until merged/deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,8 @@ 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.
|
||||
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`.
|
||||
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.
|
||||
5. **Cloudflare Turnstile CAPTCHA gate** (`captchaGate` middleware, commit `b8edbbf`): Before the rate-limiter runs, `captchaGate` checks the in-memory failure counter for the caller's IP. If that IP has accumulated **3 or more failed login attempts** within 15 minutes, a valid `cf-turnstile-response` token must be present in the request body. Without it the endpoint returns `429 { captchaRequired: true }`. If `TURNSTILE_SECRET_KEY` is not set (local dev), the gate is skipped. On CAPTCHA pass, the middleware calls Cloudflare's `siteverify` endpoint to validate the token before proceeding.
|
||||
5a. **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.
|
||||
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=...`.
|
||||
|
||||
@@ -12,7 +12,8 @@ audit: "2026-05-29 — corrected against source: Dispute.ts, DisputeService.ts,
|
||||
|
||||
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.
|
||||
> [!success] Security fixes applied (2026-05-30)
|
||||
> The three privilege-escalation bugs documented in the original Security Gaps section were fixed in commit `1d881c5` (ISSUE-003, ISSUE-004) and `fce8a19` (resolver role). Role guards are now enforced on assign/status/resolve; route shadowing is eliminated by remounting the release-hold router at `/api/disputes/pr`. See [Security Gaps](#security-gaps) for the historical record and current state.
|
||||
|
||||
> [!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.
|
||||
@@ -23,7 +24,8 @@ When something goes wrong (item not delivered, wrong item, seller misbehaviour),
|
||||
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
|
||||
- **Admin / Mediator** — assigned to investigate.
|
||||
- **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` (mounted first at `/api/disputes`), and release-hold helpers in `backend/src/services/dispute/disputeRoutes.ts` (mounted second at `/api/disputes`).
|
||||
- **Admin / Mediator** — assigned to investigate (role `admin` or `resolver`).
|
||||
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts` (mounted at `/api/disputes`), and release-hold helpers in `backend/src/services/dispute/disputeRoutes.ts` (mounted at `/api/disputes/pr` since commit `1d881c5`).
|
||||
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
|
||||
- **Socket.IO** — no events fire today; all emits are TODO stubs (see warning above).
|
||||
|
||||
@@ -78,59 +80,40 @@ Valid values: `product_quality | delivery_delay | wrong_item | payment_issue | s
|
||||
|
||||
---
|
||||
|
||||
## Security Gaps
|
||||
## Security Gaps (Historical — All Closed as of 2026-05-30)
|
||||
|
||||
### 1. `PATCH /api/disputes/:id/status` — no role guard
|
||||
The following bugs were identified in the 2026-05-29 audit and fixed in commits `1d881c5` and `fce8a19`. The descriptions below are preserved for historical reference and audit trail.
|
||||
|
||||
**File:** `backend/src/routes/disputeRoutes.ts` line 26
|
||||
### 1. `PATCH /api/disputes/:id/status` — no role guard ✅ FIXED
|
||||
|
||||
```ts
|
||||
router.patch('/:id/status', DisputeController.updateStatus);
|
||||
```
|
||||
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can change dispute status.
|
||||
|
||||
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 ✅ FIXED
|
||||
|
||||
### 2. `POST /api/disputes/:id/resolve` (dashboard router) — no role guard
|
||||
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can resolve disputes.
|
||||
|
||||
**File:** `backend/src/routes/disputeRoutes.ts` line 29
|
||||
**Additional fix (ISSUE-004, commit `1d881c5`):** `DisputeService.resolveDispute` now calls `releaseHoldResolve()` on the linked `purchaseRequestId`, clearing the escrow hold automatically so the payment release is unblocked after resolution.
|
||||
|
||||
```ts
|
||||
router.post('/:id/resolve', DisputeController.resolveDispute);
|
||||
```
|
||||
### 3. `POST /api/disputes/:id/assign` — no role guard ✅ FIXED
|
||||
|
||||
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.
|
||||
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can assign mediators.
|
||||
|
||||
---
|
||||
|
||||
## Route Shadowing
|
||||
## Route Shadowing (Historical — Resolved as of 2026-05-30)
|
||||
|
||||
Both routers are mounted at `/api/disputes` in `app.ts`:
|
||||
Previously both routers were mounted at `/api/disputes`, causing the dashboard router to intercept release-hold requests. Fixed in commit `1d881c5` (ISSUE-003):
|
||||
|
||||
```ts
|
||||
// app.ts line 521 — mounted FIRST
|
||||
// app.ts — current state
|
||||
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
|
||||
app.use("/api/disputes/pr", disputeRoutes); // src/services/dispute/disputeRoutes.ts — new prefix
|
||||
```
|
||||
|
||||
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).
|
||||
Release-hold endpoints now use the `/api/disputes/pr/` prefix:
|
||||
- `POST /api/disputes/pr/:purchaseRequestId/raise`
|
||||
- `GET /api/disputes/pr/:purchaseRequestId/status`
|
||||
- `POST /api/disputes/pr/:purchaseRequestId/resolve`
|
||||
|
||||
---
|
||||
|
||||
@@ -171,7 +154,7 @@ Express evaluates routes in registration order. This creates two concrete hazard
|
||||
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`. **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)).
|
||||
> [!note] `PATCH /api/disputes/:id/status` now requires `admin` or `resolver` role (`authorizeRoles('admin', 'resolver')`, commit `fce8a19`). The previously open privilege-escalation gap is closed.
|
||||
|
||||
### Phase 4 — Resolution
|
||||
|
||||
@@ -190,10 +173,11 @@ Express evaluates routes in registration order. This creates two concrete hazard
|
||||
- `dispute.closedAt = now`
|
||||
- Appends `timeline` entry `dispute_resolved`.
|
||||
- Saves.
|
||||
- **Calls `releaseHoldResolve(purchaseRequestId)`** — this clears the escrow hold automatically so the payment release is unblocked (ISSUE-004 fix, commit `1d881c5`).
|
||||
- **No socket event fires.** (`// TODO: Send notifications via Socket.IO`)
|
||||
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.
|
||||
13. **Financial side-effect:** as of commit `1d881c5` the escrow hold is cleared automatically on resolution. The admin still needs to separately trigger the ledger-gated release ([[Payout Flow]] / [[Escrow Flow]]) or refund for actual fund movement.
|
||||
|
||||
> [!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)).
|
||||
> [!note] `POST /api/disputes/:id/resolve` now requires `admin` or `resolver` role (`authorizeRoles('admin', 'resolver')`, commit `fce8a19`). The previously open privilege-escalation gap is closed.
|
||||
|
||||
---
|
||||
|
||||
|
||||
179
04 - Flows/Payment Flow - Scanner.md
Normal file
179
04 - Flows/Payment Flow - Scanner.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: Payment Flow - Scanner (In-House)
|
||||
tags: [flow, scanner, payment]
|
||||
created: 2026-05-30
|
||||
---
|
||||
|
||||
# Payment Flow — AMN Pay Scanner (In-House)
|
||||
|
||||
End-to-end payment flow using the in-house AMN Pay Scanner, replacing the Request Network integration. The scanner is a separate microservice; the backend talks to it over an internal HTTP API.
|
||||
|
||||
See also: [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md), [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. High-level sequence
|
||||
|
||||
```
|
||||
Buyer Backend Scanner Chain
|
||||
│ │ │ │
|
||||
│ initiate payment │ │ │
|
||||
│────────────────────►│ │ │
|
||||
│ │ POST /intents │ │
|
||||
│ │───────────────────►│ │
|
||||
│ │ 200 checkoutBlock │ │
|
||||
│ │◄───────────────────│ │
|
||||
│ checkoutBlock │ │ │
|
||||
│◄────────────────────│ │ │
|
||||
│ │ │ │
|
||||
│ sign + submit tx ──────────────────────────────────────►│
|
||||
│ │ │ (polling) │
|
||||
│ │ │◄────────────────│
|
||||
│ │ │ log matched │
|
||||
│ │ │ confirmations… │
|
||||
│ │◄───────────────────│ │
|
||||
│ │ POST callbackUrl │ │
|
||||
│ │ (webhook) │ │
|
||||
│ │ │ │
|
||||
│ payment confirmed │ │ │
|
||||
│◄────────────────────│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Step-by-step
|
||||
|
||||
### Step 1 — Backend creates an intent
|
||||
|
||||
When the buyer chooses a payment method (e.g. USDT on BSC), the backend calls:
|
||||
|
||||
```
|
||||
POST http://scanner:8080/intents
|
||||
Authorization: Bearer <SCANNER_API_KEY>
|
||||
|
||||
{
|
||||
"intentId": "<payment._id>",
|
||||
"chainId": 56,
|
||||
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
|
||||
"destination": "0xSellerWalletAddress",
|
||||
"amount": "10000000000000000000",
|
||||
"callbackUrl": "https://api.amn.gg/api/payment/scanner-callback",
|
||||
"callbackSecret": "<per-intent HMAC secret stored in payment doc>",
|
||||
"confirmations": 12
|
||||
}
|
||||
```
|
||||
|
||||
The scanner responds with a `checkoutBlock` that the backend passes to the frontend.
|
||||
|
||||
### Step 2 — Frontend shows checkout
|
||||
|
||||
The `checkoutBlock` contains everything the frontend needs to build the `ERC20FeeProxy.transferWithReferenceAndFee` calldata:
|
||||
|
||||
| Field | Used for |
|
||||
|---|---|
|
||||
| `proxyAddress` | contract to call |
|
||||
| `tokenAddress` | ERC20 token |
|
||||
| `destination` | `_to` param |
|
||||
| `paymentReference` | `_paymentReference` param (8-byte reference) |
|
||||
| `amountWei` | `_amount` param |
|
||||
| `feeAmount` | `_feeAmount` param (always `"0"` currently) |
|
||||
| `feeAddress` | `_feeAddress` param (always dead address) |
|
||||
|
||||
For Tron/TON the buyer sends a plain TRC20/Jetton transfer to `destination`; there is no proxy contract.
|
||||
|
||||
### Step 3 — Buyer submits transaction
|
||||
|
||||
The buyer signs and broadcasts the transaction using their wallet. The scanner independently monitors the chain and does not require the transaction hash.
|
||||
|
||||
### Step 4 — Scanner detects and confirms
|
||||
|
||||
**EVM path:**
|
||||
1. `eth_getLogs` returns a `TransferWithReferenceAndFee` log matching `topicRef`
|
||||
2. `validateLogMatchesIntent` verifies token address, destination, and amount
|
||||
3. Intent moves to `confirming`; scanner waits for N blocks
|
||||
4. Once `confirmationsRequired` blocks have been built on top, intent moves to `confirmed`
|
||||
|
||||
**Tron path:**
|
||||
1. TronGrid `Transfer` event matches `destination` (EVM-hex normalized)
|
||||
2. Amount validated ≥ intent amount
|
||||
3. Intent goes directly to `confirmed` (TronGrid returns only confirmed txs)
|
||||
|
||||
**TON path:**
|
||||
1. TonCenter Jetton transfer matches `destination` (exact base64url) and `jetton_master_address`
|
||||
2. Amount validated ≥ intent amount
|
||||
3. Intent goes directly to `confirmed`
|
||||
|
||||
### Step 5 — Webhook delivery
|
||||
|
||||
The scanner POSTs to `callbackUrl` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"intentId": "...",
|
||||
"paymentReference": "0x...",
|
||||
"txHash": "0x...",
|
||||
"blockNumber": 39000010,
|
||||
"amount": "10000000000000000000",
|
||||
"token": "0x55d...",
|
||||
"chainId": 56,
|
||||
"status": "confirmed"
|
||||
}
|
||||
```
|
||||
|
||||
Header `X-AMN-Signature` = `HMAC-SHA256(body, callbackSecret)`.
|
||||
|
||||
The backend verifies the signature, matches the intentId to a Payment record, and marks it paid.
|
||||
|
||||
### Step 6 — Backend acknowledges
|
||||
|
||||
Backend returns a 2xx response. Scanner records `webhook_delivered_at` and the intent lifecycle ends.
|
||||
|
||||
---
|
||||
|
||||
## 3. Failure paths
|
||||
|
||||
### Webhook delivery failure
|
||||
|
||||
If the backend returns non-2xx or is unreachable, the scanner retries:
|
||||
|
||||
```
|
||||
attempt 1: after 5 s
|
||||
attempt 2: after 30 s
|
||||
attempt 3: after 2 min
|
||||
attempt 4: after 10 min
|
||||
attempt 5: after 1 h
|
||||
→ status = webhook_failed
|
||||
```
|
||||
|
||||
`webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`.
|
||||
|
||||
On startup the scanner reconciles any `confirmed` intents with `webhook_delivered_at IS NULL` (crash recovery).
|
||||
|
||||
### Intent expiry
|
||||
|
||||
Intents in `pending` or `confirming` status older than `INTENT_TTL_HOURS` (default 24 h) are moved to `expired` by a background ticker running every hour.
|
||||
|
||||
`confirming` intents can get stuck if a transaction is deep-reorganised and never re-included; the TTL frees the destination address for reuse.
|
||||
|
||||
### Amount underpayment
|
||||
|
||||
Transfers where the on-chain amount is less than `intent.Amount` are silently skipped. The intent remains `pending` until the TTL.
|
||||
|
||||
### Wrong token or destination
|
||||
|
||||
The EVM log decoder validates all three fields (token, destination, amount). Mismatches are logged as `REJECT` and skipped. The intent remains `pending`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Key differences from Request Network integration
|
||||
|
||||
| Dimension | Request Network | AMN Pay Scanner |
|
||||
|---|---|---|
|
||||
| Dependency | RN SDK + API | None (direct RPC) |
|
||||
| Payment reference | RN-generated | Internal HMAC derivation |
|
||||
| EVM matching | By reference hash (RN) | By Topics[1] / topicRef (indexed) |
|
||||
| Tron | Not supported | TRC20 Transfer events via TronGrid |
|
||||
| TON | Not supported | Jetton transfers via TonCenter v3 |
|
||||
| Confirmations | RN handled | Per-chain configurable |
|
||||
| Webhook | RN webhook → backend adapter | Scanner → backend directly |
|
||||
| State store | External (RN cloud) | Internal SQLite |
|
||||
@@ -5,7 +5,7 @@ related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"]
|
||||
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))
|
||||
> **Last updated:** 2026-05-30 — updated for offer-management page, `withdrawOffer` action, edit-while-pending, `getSellerOffers` API (commits 240a668–e7d1375)
|
||||
|
||||
# Seller Offer Flow
|
||||
|
||||
@@ -90,24 +90,22 @@ The valid `SellerOffer` statuses are `pending | accepted | rejected | withdrawn`
|
||||
- Notifications: `notifyOfferAccepted` to the winning seller, generic rejection notifications to the others (`SellerOfferService.acceptOffer` does the same in the manual path).
|
||||
- Socket events notify the winner and reject/close competing offers.
|
||||
|
||||
### Withdrawal
|
||||
### Edit / withdrawal while awaiting buyer acceptance
|
||||
|
||||
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.
|
||||
17. While a request is in `received_offers` status (buyer has not yet accepted), the seller may **edit** their pending offer or **withdraw** it entirely from the request-detail step-2 card (`step-2-waiting-for-payment.tsx`).
|
||||
|
||||
The only supported HTTP way to withdraw an offer is:
|
||||
- **Edit**: toggles `mode` to `'edit'` inside `Step2WaitingForPayment`, re-mounts `Step1SendProposal` pre-populated with the existing offer values. On save, calls `PATCH /api/marketplace/offers/:id` (via `updateOffer` action, which now correctly uses `PATCH` instead of the old `PUT`).
|
||||
- **Withdraw**: opens a `ConfirmDialog`, then calls `withdrawOffer(offerId)` in `src/actions/marketplace.ts` which uses `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
|
||||
|
||||
```
|
||||
PUT /api/marketplace/offers/:id
|
||||
Body: { status: 'withdrawn' }
|
||||
```
|
||||
`canManageOffer` is only `true` when `requestDetails?.status === 'received_offers'`; once the buyer accepts and the status advances, both buttons are hidden.
|
||||
|
||||
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 `SellerOfferService.withdrawOffer` means withdrawal is impossible once `accepted` or `rejected`.
|
||||
|
||||
The DB filter `{ status: 'pending' }` inside `withdrawOffer` means withdrawal is impossible once `accepted` or `rejected`.
|
||||
> ⚠️ `POST /api/marketplace/offers/:id/withdraw` still does **not** exist as an HTTP route. Always use `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
|
||||
|
||||
### Offer update — method mismatch
|
||||
### Offer update — method mismatch resolved
|
||||
|
||||
> ⚠️ **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.
|
||||
> ✅ **Fixed (commit 240a668)**: The frontend `updateOffer` action now sends `PATCH /api/marketplace/offers/:id`, matching the backend. The `acceptOffer` action was also corrected from `PUT` to `PATCH`.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
@@ -157,10 +155,10 @@ sequenceDiagram
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer | `purchaseRequestId` is a path param |
|
||||
| `GET` | `/api/marketplace/purchase-requests/:id/offers` | Buyer view of offers on a request | |
|
||||
| `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) | Fixed: frontend now sends `PATCH` |
|
||||
| `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~~ | ⚠️ NOT IMPLEMENTED — use `PATCH /api/marketplace/offers/:id` with `{ status: 'withdrawn' }` instead |
|
||||
| `GET` | `/api/marketplace/offers/seller/:sellerId` | All offers by this seller (used by Offer Management page) | Implemented via `getSellerOffers` frontend action (commit 240a668) |
|
||||
| `PUT` | `/api/marketplace/offers/:id/status` | Status mutation — use `{ status: 'withdrawn' }` to withdraw | The only HTTP withdraw path; `POST /api/marketplace/offers/:id/withdraw` does **not** exist |
|
||||
|
||||
## Database writes
|
||||
|
||||
@@ -211,6 +209,9 @@ sequenceDiagram
|
||||
- Backend: `backend/src/services/marketplace/marketplaceController.ts`
|
||||
- Backend: `backend/src/models/SellerOffer.ts`
|
||||
- Backend: `backend/src/services/payment/paymentCoordinator.ts` (payment-state cascade)
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx` — proposal form (also re-used for edit)
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/step-2-waiting-for-payment.tsx` — awaiting-buyer card with edit/withdraw actions
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx`
|
||||
- Frontend: `frontend/src/app/dashboard/seller/marketplace/`
|
||||
- Frontend: `frontend/src/app/dashboard/seller/marketplace/` — seller marketplace browse
|
||||
- Frontend: `frontend/src/app/dashboard/seller/marketplace/offers/page.tsx` — Offer Management page (all offers, status filter, withdraw)
|
||||
- Frontend: `frontend/src/actions/marketplace.ts` — `withdrawOffer`, `getSellerOffers` actions
|
||||
|
||||
@@ -112,6 +112,24 @@ TREZOR_SAFEKEEPING_REQUIRED=false
|
||||
|
||||
Default is permissive so existing Request Network release/refund flows continue to work. Set it to `true` only after registering the operating admin's Trezor account (the frontend signing flow via `TrezorSignDialog` is already implemented). Any value other than the literal string `true` is treated as disabled.
|
||||
|
||||
## Break-Glass Mode (Emergency Bypass)
|
||||
|
||||
When `TREZOR_SAFEKEEPING_REQUIRED=true` but the Trezor device is unavailable (lost, hardware fault, key-holder absent), an admin can activate **break-glass mode** to temporarily bypass the safekeeping requirement:
|
||||
|
||||
| Endpoint | Action |
|
||||
|---|---|
|
||||
| `GET /api/admin/settings/break-glass` | Read current status (`active`, `expiresAt`, `activatedBy`) |
|
||||
| `POST /api/admin/settings/break-glass` | Activate for **1 hour** — fires a Telegram alarm immediately |
|
||||
| `DELETE /api/admin/settings/break-glass` | Cancel before expiry |
|
||||
|
||||
**Properties:**
|
||||
- State is in-memory only (resets on server restart — intentional).
|
||||
- Activation fires a Telegram alert via `tgNotify` regardless of `TG_NOTIFY_BOT_TOKEN` set status.
|
||||
- The exported `isBreakGlassActive()` helper is called by `assertTrezorSignatureForOperation` — when `true`, the signature check is skipped.
|
||||
- Maximum duration: 1 hour. After expiry the guard is automatically re-enabled.
|
||||
|
||||
**Source:** `backend/src/services/admin/breakGlassRoutes.ts` (commit `b21df25`).
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Never store Trezor seed words, private keys, or xprv/tprv values.
|
||||
|
||||
Reference in New Issue
Block a user