Add full system audit reports and Telegram Mini App debug handoff
- Three-stream audit (security / logic / performance) with 35+ findings derived from actual source code, each with file:line and remediation - Audit Index cross-references criticals across streams into prioritized fix tiers: immediately / before soft launch / before public launch - Telegram Mini App debug handoff documenting what was implemented and all remaining work items with exact file lists and test commands - Updated architecture, data model, auth API, and registration flow docs to reflect Telegram auth, TON wallet, and email verification additions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,10 +38,11 @@ flowchart TB
|
||||
subgraph BE["Backend tier — Node.js / Express 5"]
|
||||
REST["REST API<br/>/api/*"]
|
||||
SocketS["Socket.IO server<br/>rooms per user / chat / request"]
|
||||
Auth["Auth service<br/>JWT + Passkey + Google"]
|
||||
Auth["Auth service<br/>JWT + Passkey + Google + Telegram"]
|
||||
Market["Marketplace service<br/>Requests, Offers, Templates"]
|
||||
ChatSvc["Chat service"]
|
||||
PaySvc["Payment service<br/>+ PaymentCoordinator"]
|
||||
PaySvc["Payment service<br/>SHKeeper + Request Network + ledger"]
|
||||
TelegramSvc["Telegram service<br/>bot + Mini App + notifications"]
|
||||
Disp["Dispute service"]
|
||||
Points["Points / Referrals"]
|
||||
BlogSvc["Blog service"]
|
||||
@@ -65,6 +66,8 @@ flowchart TB
|
||||
Google["Google OAuth"]
|
||||
Sentry["Sentry"]
|
||||
Alchemy["Alchemy RPC"]
|
||||
TelegramAPI["Telegram Bot API<br/>+ Mini App"]
|
||||
ReqNet["Request Network<br/>pay-in / webhooks"]
|
||||
end
|
||||
|
||||
Browser --> SSR
|
||||
@@ -78,20 +81,25 @@ flowchart TB
|
||||
ClientJS --> REST
|
||||
SocketC <--> SocketS
|
||||
|
||||
REST --> Auth & Market & ChatSvc & PaySvc & Disp & Points & BlogSvc & AISvc & Notif & Files
|
||||
REST --> Auth & Market & ChatSvc & PaySvc & TelegramSvc & Disp & Points & BlogSvc & AISvc & Notif & Files
|
||||
SocketS --> ChatSvc & Notif & Market
|
||||
|
||||
Auth & Market & ChatSvc & PaySvc & Disp & Points & BlogSvc --> Mongo
|
||||
Auth & Market & ChatSvc & PaySvc & Disp & Points & BlogSvc & TelegramSvc --> Mongo
|
||||
Auth & PaySvc & Notif --> RedisDB
|
||||
Files --> Disk
|
||||
|
||||
PaySvc <--> SHK
|
||||
SHK -.webhook.-> PaySvc
|
||||
PaySvc <--> ReqNet
|
||||
ReqNet -.webhook.-> PaySvc
|
||||
PaySvc --> Chain
|
||||
Wagmi --> DePay
|
||||
DePay --> Chain
|
||||
PaySvc -.tx fetch.-> Alchemy
|
||||
|
||||
TelegramSvc <--> TelegramAPI
|
||||
TelegramAPI -.webhook.-> TelegramSvc
|
||||
Auth --> TelegramAPI
|
||||
Notif --> SMTP
|
||||
Auth --> Google
|
||||
AISvc --> OpenAI
|
||||
@@ -103,11 +111,12 @@ flowchart TB
|
||||
|
||||
### Authentication & identity — [[Authentication Flow]]
|
||||
|
||||
Auth is the gate to every authenticated route. Amn supports three login methods in parallel:
|
||||
Auth is the gate to every authenticated route. Amn supports four login methods in parallel:
|
||||
|
||||
- **Email + password (JWT).** Standard `bcrypt`-hashed credentials, access + refresh token pair, six-digit email verification codes, and password reset codes. Source: `backend/src/services/auth/authService.ts`.
|
||||
- **Passkey (WebAuthn).** Platform and cross-platform authenticators are registered against the user account; multiple devices per user are stored in `User.passkeys[]` (`backend/src/models/User.ts:125`).
|
||||
- **Google OAuth.** Server-side verification via `google-auth-library`. See `backend/src/services/auth/googleOAuthService.ts`.
|
||||
- **Telegram (first-class).** `POST /api/auth/telegram` accepts a Telegram Mini App `initData` string or a Telegram Login Widget payload. The backend verifies the Telegram HMAC signature, then signs the user in or auto-creates a new Amanat account with `authProvider: "telegram"` and no required email. This means a user who opens the Telegram Mini App is authenticated with zero sign-up friction. See `backend/src/services/auth/authController.ts` (`telegramAuth`) and [[Authentication Flow#Telegram first-class auth flow]].
|
||||
|
||||
Roles are `admin | buyer | seller` (`backend/src/models/User.ts:94`). Role checks happen in route middleware. **Refresh tokens** are stored on the `User` document and rotated.
|
||||
|
||||
@@ -123,13 +132,14 @@ Services live in `backend/src/services/marketplace/` and are exposed through `/a
|
||||
|
||||
### Payments — [[Payments Overview]] / [[SHKeeper Integration]]
|
||||
|
||||
Payments are where Amn is most distinctive. The backend supports **three payment surfaces** routed through a common `Payment` model (`backend/src/models/Payment.ts`):
|
||||
Payments are where Amn is most distinctive. The backend supports **four payment surfaces** routed through a common `Payment` model (`backend/src/models/Payment.ts`) via a provider-neutral adapter layer (`backend/src/services/payment/adapters/`):
|
||||
|
||||
- **SHKeeper** — `/api/payment/shkeeper`. Mounted at `backend/src/app.ts:327`. Issues a fresh wallet address per invoice, polls / webhooks for payment confirmation, and runs through `PaymentCoordinator` to avoid race conditions where a payment status is updated twice. Health is monitored in the background (`shkeeperHealthCheck.ts`, started in `app.ts:433`).
|
||||
- **Decentralized (Wagmi + DePay)** — `/api/payment/decentralized`. The user signs and sends the transfer from their own wallet; the backend then verifies the transaction on-chain via `blockchainTxFetcher.ts` and the Alchemy SDK.
|
||||
- **SHKeeper** — `/api/payment/shkeeper`. Issues a fresh wallet address per invoice, polls / webhooks for payment confirmation, and runs through `PaymentCoordinator` to avoid race conditions. Health is monitored in the background (`shkeeperHealthCheck.ts`).
|
||||
- **Request Network** — `/api/payment/request-network`. Creates on-chain payment requests via the Request Network protocol, generates Secure Payment Page URLs for the buyer, and receives real-time payment status via signed webhooks (`x-request-network-signature`). Pay-in service: `requestNetworkPayInService.ts`; reconciliation: `requestNetworkReconciliationService.ts`.
|
||||
- **Decentralized (Wagmi + DePay)** — `/api/payment/decentralized`. The user signs and sends the transfer from their own wallet; the backend verifies on-chain via `blockchainTxFetcher.ts` and the Alchemy SDK.
|
||||
- **Payout** — `/api/payment/shkeeper/payout`. Admin-triggered release of escrow funds to the seller's wallet once delivery is confirmed.
|
||||
|
||||
All three surfaces converge on the same `Payment` record (with `direction: 'in' | 'out' | 'refund'`) and trigger the same downstream events: order status update, notification, points award. **Pending payments are auto-cleaned** by a background timer started in `app.ts:374`.
|
||||
All surfaces converge on the same `Payment` record (with `direction: 'in' | 'out' | 'refund'`) and share the internal **funds ledger** (`backend/src/services/payment/ledger/`) which tracks available / held / releasable amounts independently of the provider. **Pending payments are auto-cleaned** by a background timer started in `app.ts`.
|
||||
|
||||
### Real-time chat — [[Chat System]]
|
||||
|
||||
@@ -202,7 +212,7 @@ OpenAI (model configurable per call) is exposed through `/api/ai/*`. The current
|
||||
## Cross-cutting concerns
|
||||
|
||||
- **Observability** — Sentry is initialised at the very top of `app.ts` (line 2-3) and on the frontend in `sentry.{client,edge,server}.config.ts`. Logs flow through `backend/src/utils/logger.ts`.
|
||||
- **Security** — `helmet`, CORS scoped to `FRONTEND_URL`, JWT with bcrypt-hashed passwords, role-gated middleware. Rate limiting is plumbed but currently disabled (see `app.ts:227`).
|
||||
- **Security** — `helmet`, CORS scoped to `FRONTEND_URL`, JWT with bcrypt-hashed passwords, role-gated middleware. Rate limiting is active (10 req/15 min on auth, 30 on payment, 100 global; Request Network and Telegram webhooks are skip-listed to avoid false limits).
|
||||
- **Internationalisation** — Six locales (en, fr, vi, cn, ar, fa), with `fa` as default. RTL via `stylis-plugin-rtl`. See `frontend/src/locales/`.
|
||||
- **Theming** — MUI v7 with a custom theme in `frontend/src/theme/`. Dark mode is on the roadmap.
|
||||
- **Containerisation** — Docker Compose stacks for dev and prod live in both repos (`docker-compose.dev.yml`, `docker-compose.production.yml`).
|
||||
|
||||
@@ -129,7 +129,7 @@ The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backe
|
||||
| body-parser | ^2.2.0 | Body parsing (legacy fallback) | Body middleware |
|
||||
| helmet | ^8.1.0 | HTTP security headers | `app.ts:189` |
|
||||
| cors | ^2.8.5 | Cross-origin policy | `app.ts:194` |
|
||||
| express-rate-limit | ^8.0.1 | Rate-limit middleware (currently off) | Plumbed in |
|
||||
| express-rate-limit | ^8.0.1 | Rate-limit middleware | Active — auth 10/15min, payment 30/15min, AI 20/15min, global 100/15min |
|
||||
| express-validator | ^7.2.1 | Request validation | Auth, marketplace |
|
||||
| multer | ^2.0.2 | Multipart file uploads | `services/file/` |
|
||||
| sharp | ^0.34.3 | Image resizing / format conversion | Upload pipeline |
|
||||
@@ -211,10 +211,12 @@ The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backe
|
||||
| Service | Purpose | Touchpoint in code |
|
||||
|---|---|---|
|
||||
| **SHKeeper** | Self-hosted crypto payment processor — issues wallets, watches for incoming USDT, pays out | `backend/src/services/payment/shkeeper/` |
|
||||
| **Request Network** | On-chain payment request protocol — creates invoices, generates Secure Payment Pages, signs webhooks | `backend/src/services/payment/requestNetwork/` + adapters |
|
||||
| **DePay** | Drop-in Web3 widget for wallet-to-wallet payment | `@depay/widgets` on frontend |
|
||||
| **EVM chains** (BSC, Ethereum mainnet, Sepolia, Polygon) | Settlement layer for stablecoin transfers | `frontend/src/web3/config.ts`, backend `blockchain/` |
|
||||
| **Alchemy RPC** | Hosted EVM RPC + transaction lookup | Frontend `alchemy-sdk`, backend `blockchainTxFetcher.ts` |
|
||||
| **MetaMask / WalletConnect** | Wallet connectors via Wagmi | `web3/config.ts` (WalletConnect commented out pending SSR fix) |
|
||||
| **Telegram Bot API + Mini App** | Bot commands, inline keyboards, webhook updates, Mini App launch surface, Login Widget | `backend/src/services/telegram/`, `frontend/src/app/telegram/`, `frontend/src/utils/telegram-webapp.ts` |
|
||||
| **OpenAI** | LLM for drafting / summarising | `backend/src/services/ai/` |
|
||||
| **Google OAuth** | Federated login | `googleOAuthService.ts` |
|
||||
| **SMTP** (provider configured per env) | Transactional email | `services/email/` |
|
||||
|
||||
@@ -37,10 +37,17 @@ backend/src/
|
||||
│ ├── file/ # Multer uploads, MIME validation
|
||||
│ ├── marketplace/ # PurchaseRequest, SellerOffer, Template, Shop
|
||||
│ ├── notification/ # Templates, delivery, mark-as-read
|
||||
│ ├── payment/ # Payment orchestration + shkeeper/ subdir
|
||||
│ ├── payment/ # Payment orchestration + provider adapters + ledger
|
||||
│ │ ├── adapters/ # Provider-neutral adapter interface + registry
|
||||
│ │ ├── ledger/ # Internal funds ledger (available / held / releasable)
|
||||
│ │ ├── reconciliation/ # Webhook + status reconciliation per provider
|
||||
│ │ ├── migration/ # Legacy data backfill utilities
|
||||
│ │ ├── observability/ # Logging and incident controls
|
||||
│ │ ├── requestNetwork/ # Request Network pay-in, routes, webhook signature
|
||||
│ │ └── shkeeper/ # SHKeeper API, webhook, payout
|
||||
│ ├── points/ # Loyalty points, levels, redemption
|
||||
│ ├── redis/ # Redis client, cache helpers
|
||||
│ ├── telegram/ # Bot webhook, Mini App session, identity linking, notifications
|
||||
│ ├── user/ # Profile, preferences, addresses
|
||||
│ ├── admin/ # Admin-only operations
|
||||
│ └── email/ # Nodemailer transport + templates
|
||||
@@ -98,8 +105,8 @@ The bootstrap is intentionally linear and easy to audit. Execution order:
|
||||
| 10 | `notFound` | tail | Returns 404 envelope for unmatched routes. |
|
||||
| 11 | `errorHandler` | tail | Catches thrown errors, formats response. |
|
||||
|
||||
> [!warning]
|
||||
> Rate-limit middleware is **disabled by default for personal use** (see `app.ts:227` cited in the architecture review). Enable before any real public traffic — `express-rate-limit` is already a dependency.
|
||||
> [!note]
|
||||
> Rate-limit middleware is **active** as of 2026-05-24: auth 10 req/15 min, payment 30/15 min, AI 20/15 min, global 100/15 min. Request Network and Telegram webhooks are exempt from the global limiter. Counters are in-memory — a Redis adapter is planned for distributed deployments.
|
||||
|
||||
---
|
||||
|
||||
@@ -121,7 +128,8 @@ The full route table mounted by `app.ts`:
|
||||
| `/api/payment/decentralized` | `services/payment/decentralizedPaymentRoutes.ts` | mixed | Web3 save, verify, receiver |
|
||||
| `/api/payment/shkeeper` | `services/payment/shkeeper/shkeeperRoutes.ts` | mixed | Intents, webhook, release, refund, config |
|
||||
| `/api/payment/shkeeper/payout` | `services/payment/shkeeper/shkeeperPayoutRoutes.ts` | JWT (seller/admin) | Withdraw to wallet |
|
||||
| `/api/payment/request-network` | `services/payment/requestNetwork/requestNetworkRoutes.ts` | HMAC | Request Network webhooks |
|
||||
| `/api/payment/request-network` | `services/payment/requestNetwork/requestNetworkRoutes.ts` | HMAC sig | Request Network pay-in creation, Secure Payment Page, webhooks |
|
||||
| `/api/telegram` | `services/telegram/telegramRoutes.ts` | mixed (some JWT, webhook uses secret-token) | Mini App verify/session, identity link/unlink, bot webhook |
|
||||
| `/api/chat` | `services/chat/chatRoutes.ts` | JWT | Conversations, messages |
|
||||
| `/api/notification` | `services/notification/notificationRoutes.ts` + `notificationControllerRouter` | JWT | List, mark read |
|
||||
| `/api/dispute` | `services/dispute/disputeRoutes.ts` | JWT | **Not implemented** — planned |
|
||||
@@ -175,15 +183,19 @@ flowchart TB
|
||||
file[file]
|
||||
email[email]
|
||||
socket[socket]
|
||||
telegram[telegram]
|
||||
|
||||
auth --> user
|
||||
auth --> notify
|
||||
auth --> telegram
|
||||
market --> notify
|
||||
market --> chat
|
||||
market --> file
|
||||
pay --> market
|
||||
pay --> notify
|
||||
pay --> socket
|
||||
telegram --> notify
|
||||
telegram --> auth
|
||||
dispute -.-> market
|
||||
dispute -.-> chat
|
||||
dispute -.-> notify
|
||||
|
||||
@@ -17,17 +17,20 @@ How identity, authorization, transport, and integrity are handled across the pla
|
||||
|
||||
| Threat | Mitigation |
|
||||
|---|---|
|
||||
| Credential stuffing | bcrypt 12-round hashing + account lockout + rate-limit (when enabled) |
|
||||
| Credential stuffing | bcrypt 12-round hashing + account lockout + rate-limit |
|
||||
| Session hijacking | Short-lived JWTs (7d), opaque refresh tokens (30d), token rotation |
|
||||
| CSRF | JWT in `Authorization` header (not cookie), CORS allow-list |
|
||||
| XSS | Helmet CSP, React auto-escaping, sanitize HTML before storage |
|
||||
| SQL/NoSQL injection | Mongoose parameterized queries, no `$where` strings, schema validation |
|
||||
| Webhook spoofing | HMAC SHA-256 over body + secret, constant-time compare |
|
||||
| Webhook spoofing | HMAC SHA-256 over body + secret (SHKeeper, Request Network, Telegram), constant-time compare |
|
||||
| File upload abuse | Multer MIME validation, 5 MB cap, non-executable storage, served by Nginx not Node |
|
||||
| Replay attacks | Per-payment idempotency on `providerPaymentId`, per-request `X-Request-Id` |
|
||||
| Replay attacks | Per-payment idempotency on `providerPaymentId`; Telegram initData in-memory replay map; per-request `X-Request-Id` |
|
||||
| Account takeover | Email verification required, password reset code expiry (1h), passkey support |
|
||||
| Phishing | Passkey origin binding (`NEXT_PUBLIC_PASSKEY_ORIGIN`), email domain pinning |
|
||||
| Data leakage | Role-gated endpoints, field-level projection (`select: false` on password), redacted logs |
|
||||
| Forged Telegram identity | `initData` HMAC-SHA256 verified server-side; `initDataUnsafe` never trusted; auth_date max-age enforced |
|
||||
| Telegram callback replay | In-memory replay map with configurable window in `telegramService.ts` |
|
||||
| Blocked Telegram user bypass | `TelegramLink.status === 'blocked'` returns 403 regardless of unlink/re-link attempts |
|
||||
|
||||
---
|
||||
|
||||
@@ -79,7 +82,38 @@ sequenceDiagram
|
||||
> [!warning]
|
||||
> Dev env files ship `NEXT_PUBLIC_PASSKEY_RP_ID=localhost`. In production this MUST be the actual eTLD+1 domain (e.g., `amn.gg`) — passkeys are scoped to the RP ID and can't be transferred.
|
||||
|
||||
### 2.4 Refresh-token rotation
|
||||
### 2.4 Telegram (first-class auth provider)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as User (Telegram)
|
||||
participant FE as Frontend / Mini App
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
U->>FE: opens Mini App (initData available) or clicks Login Widget
|
||||
FE->>BE: POST /api/auth/telegram { initData | loginWidget }
|
||||
BE->>BE: verifyMiniAppInitData() or verifyTelegramLoginWidget()
|
||||
BE->>BE: reject if auth_date stale / replay / bot account
|
||||
BE->>DB: TelegramLink.findOne({ telegramUserId })
|
||||
alt link exists and active
|
||||
BE->>DB: load linked User
|
||||
else no link — auto-provision
|
||||
BE->>DB: User.create({ authProvider:"telegram", telegramVerified:true, email:null })
|
||||
BE->>DB: TelegramLink.create(...)
|
||||
end
|
||||
BE->>DB: upsert TelegramLink.lastSeenAt
|
||||
BE->>BE: generateToken() + generateRefreshToken()
|
||||
BE-->>FE: 200 { user, tokens, isNewUser }
|
||||
```
|
||||
|
||||
- Backend source: `backend/src/services/auth/authController.ts` (`telegramAuth`) and `backend/src/services/telegram/telegramService.ts`.
|
||||
- Mini App path uses `HMAC-SHA256("WebAppData", BOT_TOKEN)` per Telegram spec; Login Widget path uses `HMAC-SHA256(data_check_string, SHA256(BOT_TOKEN))`.
|
||||
- In-memory replay maps guard against duplicate `initData` submissions within a configurable window.
|
||||
- Blocked `TelegramLink` records return `403` — users cannot circumvent by unlinking and re-linking.
|
||||
- Users with `authProvider: "telegram"` have nullable email; email-based operations (password reset) are not applicable to them.
|
||||
- See [[Authentication Flow#Telegram first-class auth flow]].
|
||||
|
||||
### 2.5 Refresh-token rotation
|
||||
|
||||
- On `POST /api/auth/refresh`, the backend:
|
||||
- Verifies the supplied refresh token.
|
||||
@@ -119,7 +153,9 @@ A single User may be `buyer` and `seller` simultaneously (combined role).
|
||||
|
||||
---
|
||||
|
||||
## 5. Webhook integrity (SHKeeper)
|
||||
## 5. Webhook integrity
|
||||
|
||||
### 5.1 SHKeeper
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -136,11 +172,26 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
- Body must be the raw bytes used for HMAC — apply `express.raw({ type: 'application/json' })` on the webhook route ONLY (the rest of the app uses parsed JSON).
|
||||
- Raw body must be used for HMAC — `express.raw({ type: 'application/json' })` is mounted on this route only (before the global `express.json()` parser).
|
||||
- In dev (`NODE_ENV === 'development'`) signature verification can be bypassed for local testing — confirm this is gated and never reachable in prod.
|
||||
- Idempotency: identical webhook delivered twice should be no-op. Check by `(providerPaymentId, status)` tuple before mutating.
|
||||
|
||||
See [[Payment Flow - SHKeeper]] for the full flow.
|
||||
### 5.2 Request Network
|
||||
|
||||
- Webhooks arrive at `/api/payment/request-network/webhook` with an `x-request-network-signature` header.
|
||||
- The backend verifies the signature using `backend/src/services/payment/requestNetwork/signature.ts` before any state mutation.
|
||||
- The route is mounted **before** the global `express.json()` body parser so raw body bytes are available for signature computation.
|
||||
- The global rate-limit middleware is configured to skip this path to avoid blocking high-frequency payment events.
|
||||
- Reconciliation service (`requestNetworkReconciliationService.ts`) handles replayed or out-of-order webhooks idempotently.
|
||||
|
||||
### 5.3 Telegram Bot webhook
|
||||
|
||||
- Webhooks arrive at `/api/telegram/webhook` with an `x-telegram-bot-api-secret-token` header.
|
||||
- The backend verifies the token with `verifyTelegramWebhookSecret()` (`telegramService.ts`) before processing updates.
|
||||
- A per-update-id in-memory replay map prevents duplicate processing within the configured window.
|
||||
- The global rate-limit middleware is configured to skip this path.
|
||||
|
||||
See [[Payment Flow - SHKeeper]] for the SHKeeper full flow.
|
||||
|
||||
---
|
||||
|
||||
@@ -224,7 +275,7 @@ The codebase currently uses `morgan` (HTTP access logs) and ad-hoc `logger.info/
|
||||
|
||||
## Related
|
||||
|
||||
- [[Authentication Flow]] · [[Google OAuth Flow]] · [[Passkey (WebAuthn) Flow]] · [[Password Reset Flow]]
|
||||
- [[Authentication Flow]] (includes Telegram first-class auth flow) · [[Google OAuth Flow]] · [[Passkey (WebAuthn) Flow]] · [[Password Reset Flow]]
|
||||
- [[Backend Architecture]] · [[Frontend Architecture]] · [[Real-time Layer]]
|
||||
- [[Payment Flow - SHKeeper]] — webhook HMAC details
|
||||
- [[Environment Variables]] — secret catalog
|
||||
|
||||
@@ -9,7 +9,7 @@ aliases: [Models Index, Schema Overview]
|
||||
This section documents every Mongoose model that backs the marketplace. The persistence layer lives in `backend/src/models/` and is exported through a single barrel file at `backend/src/models/index.ts`. All models target MongoDB via Mongoose, lean on `timestamps: true` for `createdAt` / `updatedAt`, and follow a consistent pattern: one schema per file, an exported `I<Name>` TypeScript interface, and named exports for the compiled model.
|
||||
|
||||
> [!note] Scope
|
||||
> Sixteen models are documented here. The "File" concept exists only at the service layer (`backend/src/services/file/`) and is not persisted as its own Mongoose collection, so it is not listed below.
|
||||
> Eighteen models are documented here. The "File" concept exists only at the service layer (`backend/src/services/file/`) and is not persisted as its own Mongoose collection, so it is not listed below.
|
||||
>
|
||||
> [!warning] Implementation gap
|
||||
> As of the 2026-05-24 audit, the following documented models **do not yet have Mongoose schema files** in `backend/src/models/`:
|
||||
@@ -20,7 +20,7 @@ This section documents every Mongoose model that backs the marketplace. The pers
|
||||
> - [[LevelConfig]]
|
||||
> - [[ShopSettings]]
|
||||
> The following *are* implemented in code and are documented accurately:
|
||||
> - [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Payment]], [[Chat]], [[Notification]], [[RequestTemplate]], [[Address]], [[Category]], [[TempVerification]]
|
||||
> - [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Payment]], [[Chat]], [[Notification]], [[RequestTemplate]], [[Address]], [[Category]], [[TempVerification]], [[TelegramLink]], [[TelegramSession]]
|
||||
> Additionally, `FundsLedgerEntry.ts` and `TrezorAccount.ts` exist in `backend/src/models/` but are not yet documented in this vault.
|
||||
|
||||
## Index of Models
|
||||
@@ -41,6 +41,8 @@ This section documents every Mongoose model that backs the marketplace. The pers
|
||||
- [[LevelConfig]] — Static configuration of loyalty tiers (level number, point thresholds, benefits, icon, color). Driven by admins; consumed by the [[User]].points.level field.
|
||||
- [[ShopSettings]] — One-to-one storefront branding for a seller: name, description, avatar, cover image, review toggles, and social links.
|
||||
- [[TempVerification]] — Short-lived signup record that holds candidate user data and a verification code. Auto-purges via TTL when `emailVerificationCodeExpires` passes.
|
||||
- [[TelegramLink]] — Permanent auditable association between a Telegram user ID and an Amanat [[User]]. Stores Telegram profile metadata, link source (`miniapp` / `bot` / `login_widget`), status (`active` / `blocked`), and last-seen timestamp. One per Telegram user (unique on both `userId` and `telegramUserId`).
|
||||
- [[TelegramSession]] — Short-lived Telegram Mini App session token issued when `initData` is verified. Carries the `initDataFingerprint` for replay protection and auto-expires via a MongoDB TTL index on `expiresAt`.
|
||||
|
||||
## Relationship Diagram
|
||||
|
||||
@@ -83,6 +85,10 @@ erDiagram
|
||||
LEVEL_CONFIG ||..|| USER : "level lookup"
|
||||
|
||||
TEMP_VERIFICATION ||..|| USER : "promoted to"
|
||||
|
||||
TELEGRAM_LINK }o--|| USER : "links identity"
|
||||
TELEGRAM_SESSION }o--o| USER : "session for"
|
||||
TELEGRAM_SESSION }o--|| TELEGRAM_LINK : "matches"
|
||||
```
|
||||
|
||||
## Conventions Across All Models
|
||||
|
||||
@@ -328,9 +328,11 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
|
||||
| HTTP | App code | Meaning |
|
||||
| --- | --- | --- |
|
||||
| 400 | `Validation Error` | `express-validator` rejected the body |
|
||||
| 401 | — | Bad credentials / missing token |
|
||||
| 403 | — | Email not verified or insufficient role |
|
||||
| 401 | — | Bad credentials / missing token / invalid Telegram signature |
|
||||
| 403 | — | Email not verified, insufficient role, or blocked Telegram account |
|
||||
| 409 | `USER_EXISTS` | Email already in use |
|
||||
| 409 | `TELEGRAM_REPLAY` | Reused Telegram Mini App `initData` (replay protection) |
|
||||
| 423 | — | Account temporarily locked after failed logins |
|
||||
| 429 | — | Rate-limited (auth tier: 10 req / 15 min / IP) |
|
||||
|
||||
See [[Error Codes]] for the global error shape.
|
||||
|
||||
@@ -179,11 +179,14 @@ sequenceDiagram
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Authentication Flow]] — the next time the user signs in.
|
||||
- [[Authentication Flow]] — the next time the user signs in (includes the Telegram first-class auth section).
|
||||
- [[Referral Flow]] — full points-awarding mechanics triggered here.
|
||||
- [[Google OAuth Flow]] — alternative path that bypasses `TempVerification` (Google identities are pre-verified).
|
||||
- [[Password Reset Flow]] — if the user forgets the password they set during verification.
|
||||
|
||||
> [!tip] Telegram — zero-step registration
|
||||
> Users who open the Amanat Telegram Mini App do **not** go through this flow at all. `POST /api/auth/telegram` verifies the Telegram-signed `initData` and auto-provisions a new `User` (no email, `authProvider: "telegram"`) in a single round-trip. The `TempVerification` + email code cycle only applies to email-based sign-ups. See [[Authentication Flow#Telegram first-class auth flow]].
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/auth/authController.ts:33-158` (register), `:364-469` (verify), `:498-539` (resend)
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
# Handoff - Telegram Mini App Debug - 2026-05-24
|
||||
|
||||
## Scope
|
||||
|
||||
This handoff covers the work done after bringing the app up under the consolidated `https://amn.gg` URL and debugging the Telegram Mini App flow.
|
||||
|
||||
Primary user-reported issues:
|
||||
|
||||
- The red `N` badge in the Telegram webview was interpreted as backend/socket failure.
|
||||
- Telegram-created users without an existing Amanat account were blocked by the link/sign-in screen instead of being treated as buyer users.
|
||||
- Wallet connect did not support Telegram Wallet / TON.
|
||||
- Profile email updates for Telegram-created users did not persist and did not send confirmation email.
|
||||
- The page needed visible debug statistics to identify which subsystem is working.
|
||||
|
||||
## Repositories Touched
|
||||
|
||||
Backend:
|
||||
|
||||
- `backend/src/models/User.ts`
|
||||
- `backend/src/services/user/userController.ts`
|
||||
- `backend/src/services/user/userControllerRoutes.ts`
|
||||
- `backend/src/services/user/userRoutes.ts`
|
||||
|
||||
Frontend:
|
||||
|
||||
- `frontend/next.config.ts`
|
||||
- `frontend/package.json`
|
||||
- `frontend/yarn.lock`
|
||||
- `frontend/public/tonconnect-manifest.json`
|
||||
- `frontend/src/actions/account.ts`
|
||||
- `frontend/src/app/layout.tsx`
|
||||
- `frontend/src/auth/context/jwt/auth-provider.tsx`
|
||||
- `frontend/src/auth/types.ts`
|
||||
- `frontend/src/components/debug/telegram-debug-panel.tsx`
|
||||
- `frontend/src/lib/axios.ts`
|
||||
- `frontend/src/sections/account/account-general.tsx`
|
||||
- `frontend/src/sections/account/account-wallet-connection.tsx`
|
||||
- `frontend/src/types/user.ts`
|
||||
- `frontend/src/web3/tonconnect-provider.tsx`
|
||||
|
||||
Vault:
|
||||
|
||||
- This file.
|
||||
|
||||
## Implemented
|
||||
|
||||
### Debug Statistics
|
||||
|
||||
Added `TelegramDebugPanel` to account/profile and wallet pages.
|
||||
|
||||
It reports:
|
||||
|
||||
- API URL
|
||||
- Socket URL
|
||||
- socket connected/disconnected state and socket id
|
||||
- auth state
|
||||
- user id, role, email, email verification state
|
||||
- Telegram Mini App detection, platform, version, and initData presence
|
||||
- saved wallet type/address
|
||||
- last frontend account/wallet action and result
|
||||
|
||||
Visibility:
|
||||
|
||||
- Always visible in development.
|
||||
- Visible inside Telegram Mini App.
|
||||
- Visible in production browser with `?debug=1` or `localStorage.setItem('amn-debug', '1')`.
|
||||
|
||||
### Red `N` Badge
|
||||
|
||||
The red `N` indicator is the Next.js development indicator, not the backend/socket status.
|
||||
|
||||
Change:
|
||||
|
||||
- `frontend/next.config.ts` now sets `devIndicators: false`.
|
||||
|
||||
The actual socket status should be read from the debug panel or the app socket status component.
|
||||
|
||||
### Telegram User / Buyer Flow
|
||||
|
||||
Frontend auth mapping was changed so users without an email are not treated as email-unverified blockers:
|
||||
|
||||
- `isEmailVerified` is treated as true when the user has no email.
|
||||
- If an email exists, normal verification state applies.
|
||||
|
||||
This supports Telegram-created buyer accounts without forcing immediate email collection.
|
||||
|
||||
### Profile Email Update And Verification
|
||||
|
||||
Backend profile update now accepts `email` and handles verification:
|
||||
|
||||
- Normalizes email to lowercase.
|
||||
- Rejects duplicate email.
|
||||
- Stores a six-digit verification code on the user.
|
||||
- Sets `isEmailVerified=false` when email changes.
|
||||
- Attempts to send a confirmation email.
|
||||
- Returns `verificationEmailQueued`.
|
||||
|
||||
New authenticated endpoints:
|
||||
|
||||
- `POST /api/user/profile/email/resend-verification`
|
||||
- `POST /api/user/profile/email/verify`
|
||||
|
||||
Frontend:
|
||||
|
||||
- `updateUserProfile` now unwraps the backend `{ user, verificationEmailQueued }` response correctly.
|
||||
- Profile page shows a verification code input and resend/verify controls when a user has an unverified email.
|
||||
- Debug events are emitted for profile update/resend/verify actions.
|
||||
|
||||
### Telegram Wallet / TON Support
|
||||
|
||||
Frontend:
|
||||
|
||||
- Added `@tonconnect/ui-react` and `@ton/core`.
|
||||
- Added `TonWalletProvider` in the root layout.
|
||||
- Added `public/tonconnect-manifest.json`.
|
||||
- Wallet connection page now offers Telegram Wallet / TON connection inside Telegram Mini App.
|
||||
- Connected TON wallet can be saved as `walletType: "ton"` with provider metadata.
|
||||
|
||||
Backend:
|
||||
|
||||
- User profile schema now supports:
|
||||
- `profile.walletType: "evm" | "ton"`
|
||||
- `profile.walletProvider`
|
||||
- Current `/user/wallet-address` controller supports TON address validation.
|
||||
- Legacy `/users/wallet-address` route also accepts TON.
|
||||
- EVM still requires signature verification.
|
||||
- TON currently validates address format only and skips EVM signature.
|
||||
|
||||
## Verification Completed
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run typecheck --if-present
|
||||
```
|
||||
|
||||
Result: passed.
|
||||
|
||||
Frontend focused tests:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm test -- __tests__/account-test/account-actions.test.ts __tests__/auth/telegram-auth-action.test.ts __tests__/sections/telegram/telegram-mini-app-shell.test.tsx --runInBand
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- 3 suites passed
|
||||
- 38 tests passed
|
||||
|
||||
Diff hygiene:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Result: passed in backend and frontend after whitespace cleanup.
|
||||
|
||||
Public smoke checks completed during the session:
|
||||
|
||||
- `https://amn.gg/` returned HTTP 200.
|
||||
- `https://amn.gg/tonconnect-manifest.json` returned HTTP 200 with JSON.
|
||||
- `https://amn.gg/socket.io/?EIO=4&transport=polling` returned HTTP 200 polling handshake.
|
||||
- `https://amn.gg/dashboard/account/wallet/` returned HTTP 200 after compilation.
|
||||
|
||||
## Known Issues Left
|
||||
|
||||
### Frontend Typecheck Still Has Existing Failures
|
||||
|
||||
`frontend npx tsc --noEmit -p tsconfig.json` still fails in pre-existing areas not introduced by this Telegram/debug pass:
|
||||
|
||||
- `src/components/payment/shkeeper-payment-widget.tsx`
|
||||
- `src/sections/address/address-form.tsx`
|
||||
- `src/sections/address/address-new-form.tsx`
|
||||
- `src/sections/request-template/request-template-checkout-payment.tsx`
|
||||
- `src/sections/request-template/request-template-new-edit-form.tsx`
|
||||
- `src/web3/components/provider-payment.tsx`
|
||||
|
||||
Representative issues:
|
||||
|
||||
- Missing `sellerId` in `IPaymentIntentPayload`.
|
||||
- React Hook Form resolver typing mismatches.
|
||||
- Invalid icon name `solar:wallet-money-bold`.
|
||||
|
||||
Next agent should decide whether to clean these before treating global frontend typecheck as a release gate.
|
||||
|
||||
### Backend Broad Test Pattern Is Noisy
|
||||
|
||||
A broad backend Jest pattern for `user|auth|telegram` pulled in unrelated payment/shkeeper/basic suites and failed with pre-existing app/test setup problems such as supertest app undefined and expected status mismatch.
|
||||
|
||||
Focused backend typecheck passed. The next agent should add targeted backend tests for:
|
||||
|
||||
- `PUT /api/user/profile` email update.
|
||||
- duplicate email rejection.
|
||||
- resend profile email verification.
|
||||
- verify profile email code.
|
||||
- TON wallet save.
|
||||
- EVM wallet signature requirement still enforced.
|
||||
|
||||
### TON Wallet Signature Model Is Not Final
|
||||
|
||||
TON wallet save currently validates address shape only.
|
||||
|
||||
This is acceptable as a first Telegram Wallet integration smoke path, but not sufficient as final wallet ownership proof.
|
||||
|
||||
Next steps:
|
||||
|
||||
- Use TonConnect proof payload/signature validation server-side.
|
||||
- Store wallet proof timestamp/provider.
|
||||
- Decide whether Telegram Wallet should be required for payments or only optional profile wallet metadata.
|
||||
|
||||
### Email Delivery Needs Runtime Confirmation
|
||||
|
||||
Backend is wired to call `emailService.sendVerificationCodeEmail`, and Resend support was configured earlier in the session, but the live flow should still be verified end-to-end:
|
||||
|
||||
- Add an email from the Telegram-created profile.
|
||||
- Confirm the API returns `verificationEmailQueued: true`.
|
||||
- Confirm the message arrives from the configured `@amn.gg` sender.
|
||||
- Submit code and confirm `isEmailVerified=true`.
|
||||
|
||||
Do not put API keys or bot tokens in docs or commits.
|
||||
|
||||
### Package Manager State
|
||||
|
||||
The frontend project declares Yarn 1 as package manager and `yarn.lock` was updated for TON dependencies.
|
||||
|
||||
`package-lock.json` was intentionally restored to avoid unrelated massive lockfile churn.
|
||||
|
||||
Next agent should continue using Yarn for dependency changes unless the project standard changes.
|
||||
|
||||
### Dev Runtime Note
|
||||
|
||||
During dependency installation, the mounted `node_modules` was temporarily broken by an Alpine/Yarn install attempt involving native packages. It was repaired on the host with dependency install and the public app recovered.
|
||||
|
||||
If the container shows stale Next errors in logs, check for a later successful startup before treating them as current.
|
||||
|
||||
## Current Git State At Handoff
|
||||
|
||||
Backend and frontend contain uncommitted changes for this Telegram/debug/email/wallet pass. They have not been committed yet.
|
||||
|
||||
Backend current modified files:
|
||||
|
||||
- `src/models/User.ts`
|
||||
- `src/services/user/userController.ts`
|
||||
- `src/services/user/userControllerRoutes.ts`
|
||||
- `src/services/user/userRoutes.ts`
|
||||
|
||||
Frontend current modified/untracked files:
|
||||
|
||||
- `next.config.ts`
|
||||
- `package.json`
|
||||
- `yarn.lock`
|
||||
- `public/tonconnect-manifest.json`
|
||||
- `src/actions/account.ts`
|
||||
- `src/app/layout.tsx`
|
||||
- `src/auth/context/jwt/auth-provider.tsx`
|
||||
- `src/auth/types.ts`
|
||||
- `src/components/debug/telegram-debug-panel.tsx`
|
||||
- `src/lib/axios.ts`
|
||||
- `src/sections/account/account-general.tsx`
|
||||
- `src/sections/account/account-wallet-connection.tsx`
|
||||
- `src/types/user.ts`
|
||||
- `src/web3/tonconnect-provider.tsx`
|
||||
|
||||
Vault also has older uncommitted audit/doc updates from prior task work, plus this handoff file.
|
||||
|
||||
Vault current modified/untracked files at handoff:
|
||||
|
||||
- `00 - Overview/System Overview.md`
|
||||
- `00 - Overview/Tech Stack.md`
|
||||
- `01 - Architecture/Backend Architecture.md`
|
||||
- `01 - Architecture/Security Architecture.md`
|
||||
- `02 - Data Models/Data Model Overview.md`
|
||||
- `03 - API Reference/Authentication API.md`
|
||||
- `04 - Flows/Registration Flow.md`
|
||||
- `08 - Operations/Handoff - Telegram Mini App Debug - 2026-05-24.md`
|
||||
- `09 - Audits/Audit Index - 2026-05-24.md`
|
||||
- `09 - Audits/Logic Audit - 2026-05-24.md`
|
||||
- `09 - Audits/Performance Audit - 2026-05-24.md`
|
||||
- `09 - Audits/Security Audit - 2026-05-24.md`
|
||||
|
||||
Recommended commit split:
|
||||
|
||||
1. Backend commit:
|
||||
- user profile email verification endpoints
|
||||
- wallet type/provider model fields
|
||||
- TON wallet address support
|
||||
|
||||
2. Frontend commit:
|
||||
- debug stats panel
|
||||
- TonConnect provider and manifest
|
||||
- Telegram Wallet connect/save UI
|
||||
- profile email verification UI/actions
|
||||
- disabled Next dev indicator
|
||||
|
||||
3. Vault commit:
|
||||
- existing task/audit docs
|
||||
- this handoff document
|
||||
|
||||
Before committing, rerun:
|
||||
|
||||
```bash
|
||||
cd /Users/manwe/CascadeProjects/escrow/backend
|
||||
git diff --check
|
||||
npm run typecheck --if-present
|
||||
|
||||
cd /Users/manwe/CascadeProjects/escrow/frontend
|
||||
git diff --check
|
||||
npm test -- __tests__/account-test/account-actions.test.ts __tests__/auth/telegram-auth-action.test.ts __tests__/sections/telegram/telegram-mini-app-shell.test.tsx --runInBand
|
||||
```
|
||||
|
||||
Then commit only the relevant files for each repository. Do not include secrets, local `.env` files, or regenerated `package-lock.json` churn.
|
||||
|
||||
## Suggested Next Steps
|
||||
|
||||
1. Run the app in Telegram and open profile with `?debug=1` if needed.
|
||||
2. Confirm debug panel socket state matches `https://amn.gg/socket.io/?EIO=4&transport=polling`.
|
||||
3. Test Telegram-created user account creation as buyer without email.
|
||||
4. Add profile email, verify confirmation email delivery, and submit the code.
|
||||
5. Connect Telegram Wallet and confirm backend stores `profile.walletType="ton"`.
|
||||
6. Add targeted backend Jest tests for the new endpoints.
|
||||
7. Fix existing frontend typecheck blockers or document them as accepted debt.
|
||||
8. Commit backend, frontend, and vault changes separately.
|
||||
|
||||
## Detailed Remaining Work
|
||||
|
||||
### 1. Confirm Telegram Bot Web App URL
|
||||
|
||||
Make sure the Telegram bot opens the consolidated production URL:
|
||||
|
||||
- Target URL: `https://amn.gg`
|
||||
- Bot username seen in the app: `@amnescrow_Bot`
|
||||
- Mini App return URL configured for TonConnect: `https://t.me/amnescrow_Bot/escrow`
|
||||
|
||||
If the Telegram button still opens an old frontend URL, update the bot menu/button/web app URL through BotFather or the bot startup/config code, depending on how this bot is registered.
|
||||
|
||||
### 2. Recheck Pangolin/Newt Routing
|
||||
|
||||
The desired public shape is one URL:
|
||||
|
||||
- Browser and Telegram app: `https://amn.gg`
|
||||
- Backend proxied by path under the same host.
|
||||
|
||||
Expected route intent:
|
||||
|
||||
- `/*` -> frontend container/service
|
||||
- `/api/*` -> backend container/service
|
||||
- `/socket.io/*` -> backend container/service with WebSocket support
|
||||
- `/uploads/*` -> backend container/service
|
||||
- `/health` -> backend container/service
|
||||
|
||||
If Telegram shows network errors:
|
||||
|
||||
1. Open `https://amn.gg/?debug=1`.
|
||||
2. Check `API`, `Socket URL`, and `Socket` fields in the debug panel.
|
||||
3. Hit `https://amn.gg/socket.io/?EIO=4&transport=polling` directly and confirm HTTP 200 with a Socket.IO polling handshake.
|
||||
4. Check newt target health in Pangolin.
|
||||
|
||||
### 3. Finish Email Verification Test Coverage
|
||||
|
||||
The code path exists but targeted backend tests still need to be written.
|
||||
|
||||
Recommended backend tests:
|
||||
|
||||
- Profile email update stores normalized email and sets `isEmailVerified=false`.
|
||||
- Profile email update rejects duplicate email.
|
||||
- Profile email update queues an email verification code and calls email service.
|
||||
- Resend verification fails when user has no email.
|
||||
- Resend verification fails when email is already verified.
|
||||
- Resend verification refreshes code/expires and sends email.
|
||||
- Verify email rejects malformed code.
|
||||
- Verify email rejects expired/wrong code.
|
||||
- Verify email sets `isEmailVerified=true` and clears code fields.
|
||||
|
||||
Mock `emailService.sendVerificationCodeEmail` in these tests so Resend is not called.
|
||||
|
||||
### 4. Finish Wallet Test Coverage
|
||||
|
||||
Recommended backend tests:
|
||||
|
||||
- `PATCH /api/user/wallet-address` with `walletType="ton"` accepts a valid friendly TON address.
|
||||
- TON wallet save stores `profile.walletAddress`, `profile.walletType`, and `profile.walletProvider`.
|
||||
- TON wallet save rejects malformed TON addresses.
|
||||
- EVM wallet save still rejects missing signature/message.
|
||||
- EVM wallet save still rejects a signature that does not recover the submitted address.
|
||||
- Legacy `/api/users/wallet-address` behavior remains compatible.
|
||||
|
||||
Recommended frontend tests:
|
||||
|
||||
- `updateWalletAddress` sends legacy EVM payload unchanged when no options are passed.
|
||||
- `updateWalletAddress` sends TON payload without signature when `{ walletType: "ton" }` is passed.
|
||||
- `TelegramDebugPanel` renders socket/auth/TG fields when debug is enabled.
|
||||
- Account profile page renders email verification controls for an unverified email.
|
||||
|
||||
### 5. Upgrade TON Ownership Proof
|
||||
|
||||
Current TON wallet support is intentionally minimal: it validates address format and stores the address.
|
||||
|
||||
Before using TON wallet as strong proof of ownership, implement TonConnect proof verification:
|
||||
|
||||
- Generate a backend nonce/payload for wallet proof.
|
||||
- Pass the proof request into TonConnect.
|
||||
- Verify the returned proof server-side using TON address public key/proof data.
|
||||
- Store proof metadata, timestamp, wallet provider, and verified status.
|
||||
- Expire or rotate proof when needed.
|
||||
|
||||
Until this is done, treat saved TON wallet as user-supplied wallet metadata, not a cryptographic ownership guarantee.
|
||||
|
||||
### 6. Check Profile Update UX
|
||||
|
||||
The backend now supports email changes and confirmation codes, but the profile UI should be manually checked in Telegram:
|
||||
|
||||
- Start with a Telegram-created buyer without email.
|
||||
- Add email in profile.
|
||||
- Confirm the page does not discard the email.
|
||||
- Confirm warning/verification controls appear.
|
||||
- Confirm Resend works.
|
||||
- Enter verification code.
|
||||
- Confirm user session refresh shows verified state.
|
||||
|
||||
If the UI toast says success but no email arrives, inspect backend email logs and Resend domain/sender configuration.
|
||||
|
||||
### 7. Resolve Existing Typecheck Debt
|
||||
|
||||
Do not assume the global frontend typecheck failure is caused by this pass. It was already failing in other modules.
|
||||
|
||||
Known blockers to resolve or explicitly defer:
|
||||
|
||||
- `shkeeper-payment-widget.tsx`: payload type expects `sellerId`.
|
||||
- address form files: React Hook Form resolver type mismatches.
|
||||
- request template form files: resolver/data typing issues.
|
||||
- `provider-payment.tsx`: invalid icon id `solar:wallet-money-bold`.
|
||||
|
||||
After resolving these, make `npx tsc --noEmit -p tsconfig.json` part of the normal frontend verification gate.
|
||||
|
||||
### 8. Deployment/Container Follow-Up
|
||||
|
||||
Because dependencies changed in `frontend/package.json` and `yarn.lock`, the deployed frontend image/container should be rebuilt from a clean dependency install.
|
||||
|
||||
Recommended sequence:
|
||||
|
||||
```bash
|
||||
cd /Users/manwe/CascadeProjects/escrow/frontend
|
||||
yarn install --frozen-lockfile
|
||||
yarn test __tests__/account-test/account-actions.test.ts __tests__/auth/telegram-auth-action.test.ts __tests__/sections/telegram/telegram-mini-app-shell.test.tsx --runInBand
|
||||
|
||||
cd /Users/manwe/CascadeProjects/escrow
|
||||
docker compose build frontend backend
|
||||
docker compose up -d frontend backend
|
||||
```
|
||||
|
||||
If the existing compose setup relies on mounted `node_modules`, verify the mounted dependencies include `@tonconnect/ui-react` and `@ton/core`.
|
||||
|
||||
### 9. Spark Agent Status
|
||||
|
||||
The user asked to use codex-spark agents. Earlier in this session, existing subagents were already active and a fresh spark verifier could not be spawned because the agent thread limit was reached.
|
||||
|
||||
Next agent can continue with spark workers once capacity is available. Good isolated assignments:
|
||||
|
||||
- Worker A: backend tests for profile email verification.
|
||||
- Worker B: backend tests for wallet address EVM/TON behavior.
|
||||
- Worker C: frontend tests for debug panel and TON wallet action payload.
|
||||
- Worker D: manual Telegram/Pangolin runtime verification report.
|
||||
|
||||
### 10. Safety Notes
|
||||
|
||||
- Do not commit Telegram bot token, Resend API key, Pangolin/Newt secret, or any `.env` file.
|
||||
- Do not revert unrelated vault audit files; they predate this handoff and should be reviewed as separate documentation work.
|
||||
- Do not regenerate package locks casually. The frontend project declares Yarn 1 and `yarn.lock` is the relevant lockfile for this change.
|
||||
- Keep Trezor optional; this pass did not modify Trezor safekeeping behavior.
|
||||
- Payment adapter remains optional; this pass did not force SHKeeper or Request Network.
|
||||
123
09 - Audits/Audit Index - 2026-05-24.md
Normal file
123
09 - Audits/Audit Index - 2026-05-24.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Audit Index — 2026-05-24
|
||||
tags: [audit, index, security, logic, performance]
|
||||
created: 2026-05-24
|
||||
status: open
|
||||
---
|
||||
|
||||
# Audit Index — 2026-05-24
|
||||
|
||||
Full-system audit triggered by completion of Telegram first-class auth, Request Network integration, rate-limiting enablement, and funds ledger. Three parallel audit streams were run against actual source code.
|
||||
|
||||
| Report | Findings |
|
||||
|--------|---------|
|
||||
| [[Security Audit - 2026-05-24]] | 6 critical · 5 high · 7 medium · 4 low |
|
||||
| [[Logic Audit - 2026-05-24]] | 4 critical · 5 high · 7 medium · 2 low |
|
||||
| [[Performance Audit - 2026-05-24]] | 6 high · 8 medium · 4 low |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Criticals (Fix Immediately)
|
||||
|
||||
These items appear in multiple audit streams or are exploitable right now.
|
||||
|
||||
| ID | Severity | What | Where | Fix Effort |
|
||||
|----|----------|------|-------|------------|
|
||||
| SEC-C3 / LOG-CRIT3 | CRITICAL | Simulated transaction bypass (`SIM_*`) active in production | `paymentRoutes.ts:379` | 1 line — wrap in `NODE_ENV !== 'production'` |
|
||||
| SEC-C4 | CRITICAL | `forceVerifyUser` gate is wrong (`!== development` → unset env passes) | `authController.ts:1127` | 1 line — flip to `=== 'development'` |
|
||||
| SEC-C1 / SEC-C2 | CRITICAL | Hardcoded admin password + logged to stdout on every deploy | `init-admin.ts:7,50` | Remove fallback + delete log line + rotate credential |
|
||||
| SEC-C5 / LOG-CRIT4 | CRITICAL | Hardcoded SHKeeper admin credential in source | `shkeeperPayoutService.ts:224` | Move to env var + rotate |
|
||||
| SEC-C6 | CRITICAL | Access and refresh tokens share the same JWT signing secret | `authService.ts:18,54` | Add `REFRESH_TOKEN_SECRET` env var |
|
||||
| LOG-CRIT1 | CRITICAL | Concurrent webhooks can double-process the same payment (no DB-level lock) | `paymentCoordinator.ts` / `shkeeperWebhook.ts` | Atomic `findOneAndUpdate` with status guard |
|
||||
| LOG-CRIT2 | CRITICAL | Parallel Telegram auth creates orphan User documents (TOCTOU on link+user creation) | `authController.ts:377` | Upsert link first, create user only if upsert won |
|
||||
|
||||
---
|
||||
|
||||
## High-Priority Queue (Fix Before Soft Launch)
|
||||
|
||||
| ID | Stream | What | Where |
|
||||
|----|--------|------|-------|
|
||||
| SEC-H2 | Security | SHKeeper webhook authentication bypass via `User-Agent` / `crypto` heuristic | `shkeeperWebhook.ts:95` |
|
||||
| SEC-H3 | Security | Request Network `allowTestMode: true` hardcoded — test header skips all sig verification | `requestNetworkRoutes.ts:104` |
|
||||
| SEC-H5 | Security | `global.io.emit(...)` broadcasts financial event data to all connected sockets | `shkeeperWebhook.ts:546` |
|
||||
| SEC-H4 | Security | Typing indicator IDOR — no chat membership check | `app.ts:267` |
|
||||
| SEC-H1 | Security | Telegram in-memory replay map reset on restart; replay possible within 24h window | `telegramService.ts:395` |
|
||||
| LOG-HIGH5 | Logic | `verifyEmailWithCode` non-atomic User.save + TempVerification.delete | `authController.ts:620` |
|
||||
| LOG-HIGH3 | Logic | `refreshTokens[]` array grows unboundedly | `authController.ts:62` |
|
||||
| LOG-HIGH4 | Logic | Blocked Telegram user bypasses block when TelegramLink is deleted | `authController.ts:355` |
|
||||
| LOG-MED5 | Logic | Unauthenticated `/payment/callback` endpoint can mutate any payment status | `paymentControllerRoutes.ts:20` |
|
||||
| PERF-H3 | Performance | Unbounded seller fan-out on new request: `User.find({role:'seller'})` + N socket emits | `PurchaseRequestService.ts:190` |
|
||||
| PERF-H4 | Performance | Full chat document (~250 MB for large chats) loaded into memory for every paginated request | `ChatService.ts:370` |
|
||||
|
||||
---
|
||||
|
||||
## Medium Priority (Fix Before Public Launch)
|
||||
|
||||
| ID | Stream | What | Where |
|
||||
|----|--------|------|-------|
|
||||
| SEC-M1 | Security | OTP + reset codes logged in plaintext in all environments | `authController.ts:174,715` |
|
||||
| SEC-M2 | Security | `Math.random()` used for OTP (not CSPRNG) | `authService.ts:226` |
|
||||
| SEC-M3 | Security | No refresh token reuse/theft detection | `authController.ts:510` |
|
||||
| SEC-M4 | Security | Profile update mass-assignment + `validateBeforeSave: false` | `authController.ts:921` |
|
||||
| SEC-M5 | Security | Login Widget auth has no replay protection (Mini App has it) | `authController.ts:110` |
|
||||
| SEC-M6 | Security | No JWT secret length enforcement at startup | `config/index.ts:42` |
|
||||
| LOG-MED1 | Logic | Funds ledger availability check + append not atomic (concurrent double-release possible) | `releaseRefundService.ts:37` |
|
||||
| LOG-MED2 | Logic | `updatePurchaseRequestStatus` bypasses state machine validator | `PurchaseRequestService.ts:551` |
|
||||
| LOG-MED3 | Logic | `getUserPayments` queries `userId` (wrong field) — always returns empty | `paymentService.ts:342` |
|
||||
| LOG-MED4 | Logic | Payout created without verifying a completed inbound payment exists | `shkeeperPayoutService.ts:42` |
|
||||
| PERF-H1 | Performance | N+1: one `Payment.findOne` per request row in buyer dashboard | `PurchaseRequestService.ts:516` |
|
||||
| PERF-H2 | Performance | Missing index on `Payment.purchaseRequestId` | `models/Payment.ts:190` |
|
||||
| PERF-M1 | Performance | Missing compound index `(buyerId, createdAt)` on PurchaseRequest | `models/PurchaseRequest.ts:360` |
|
||||
| PERF-M2 | Performance | Unanchored regex on title/description — full collection scan | `PurchaseRequestService.ts:703` |
|
||||
| PERF-M7 | Performance | `user-online` event broadcast to all sockets globally | `app.ts:300` |
|
||||
|
||||
---
|
||||
|
||||
## Low Priority / Hardening
|
||||
|
||||
| ID | Stream | What |
|
||||
|----|--------|------|
|
||||
| SEC-L1 | Security | Passkey challenge debug logs expose all active challenges + all users' passkey IDs |
|
||||
| SEC-L2 | Security | Login attempt counters in-memory (multi-replica bypass possible) |
|
||||
| SEC-L3 | Security | `FRONTEND_URL` unset allows CORS `*` |
|
||||
| SEC-M7 | Security | Legacy `verifyEmail` token route has no expiry check |
|
||||
| LOG-LOW1 | Logic | Duplicate `/payment/callback` route definition |
|
||||
| LOG-MED7 | Logic | `acceptOffer` notification uses undefined `offer.title` |
|
||||
| PERF-M3 | Performance | Double-fetch pattern in update methods (no `.lean()` on pre-check) |
|
||||
| PERF-M6 | Performance | `getSellers()` unbounded — no `.limit()` |
|
||||
| PERF-M8 | Performance | Post-filter after pagination causes wrong `totalItems` count |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Index Additions
|
||||
|
||||
Add these to eliminate the collection scans identified in the performance audit:
|
||||
|
||||
```ts
|
||||
// models/Payment.ts
|
||||
paymentSchema.index({ purchaseRequestId: 1, status: 1 });
|
||||
|
||||
// models/PurchaseRequest.ts
|
||||
PurchaseRequestSchema.index({ buyerId: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ status: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ categoryId: 1, status: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ title: 'text', description: 'text', tags: 'text' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Items Confirmed Correctly Handled (PASS)
|
||||
|
||||
- HMAC timing-safe comparison on all webhooks ✓
|
||||
- Telegram `initData` HMAC derivation and bot account rejection ✓
|
||||
- Blocked Telegram user check on existing links ✓
|
||||
- Refresh token rotation (old removed before new issued) ✓
|
||||
- Password change / reset clears all refresh tokens ✓
|
||||
- Socket.IO JWT enforcement on connect ✓
|
||||
- `join-chat-room` membership check ✓
|
||||
- bcrypt work factor = 12 ✓
|
||||
- WebAuthn challenge consumed on first use ✓
|
||||
- All TTL indexes (TempVerification, TelegramSession, Notification) ✓
|
||||
- FundsLedgerEntry idempotency key (sparse unique index) ✓
|
||||
- SHKeeper polling bounded and self-cleaning ✓
|
||||
- Socket.IO room cleanup on disconnect ✓
|
||||
343
09 - Audits/Logic Audit - 2026-05-24.md
Normal file
343
09 - Audits/Logic Audit - 2026-05-24.md
Normal file
@@ -0,0 +1,343 @@
|
||||
---
|
||||
title: Logic & Correctness Audit — 2026-05-24
|
||||
tags: [audit, logic, correctness, findings]
|
||||
created: 2026-05-24
|
||||
status: open
|
||||
---
|
||||
|
||||
# Logic & Correctness Audit — 2026-05-24
|
||||
|
||||
Full-codebase review of business logic, state machines, race conditions, and data integrity. Triggered by completion of Request Network integration, Telegram first-class auth, and the internal funds ledger. Every finding is derived from actual source code — no hypothetical issues are included.
|
||||
|
||||
> [!danger] Action required
|
||||
> 4 CRITICAL findings: two are exploitable in the current production code path (double-spend via concurrent webhooks, simulated-payment bypass); two cause data corruption (orphan User documents, non-atomic create/delete).
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### CRIT-1 — Concurrent Webhooks Can Double-Process the Same Payment
|
||||
**File:** `src/services/payment/shkeeper/shkeeperWebhook.ts:195–241` and `src/services/payment/paymentCoordinator.ts:25–60`
|
||||
**Status:** Open
|
||||
|
||||
The duplicate-detection guard uses an in-memory 10-second window. There is no database-level atomic lock. Two simultaneous webhook deliveries for the same `external_id` will both read `payment` before either writes, both pass the in-memory deduplication check, both enter `PaymentCoordinator.coordinatePaymentUpdate`, and both proceed — because `coordinationState` is an in-memory `Map` whose `get/set` pair is not atomic under async concurrency (any `await` between them allows the event loop to interleave).
|
||||
|
||||
The `status === 'completed'` DB guard at `paymentCoordinator.ts:54` only protects re-processing of already-completed payments. The first webhook has not yet written `completed` when the second arrives, so both pass.
|
||||
|
||||
Side effects of double-processing:
|
||||
- Duplicate chats created between buyer and seller (`chatService.createChat` has no uniqueness guard).
|
||||
- Duplicate socket events sent to the buyer.
|
||||
- Depending on timing, duplicate state transitions on `PurchaseRequest`.
|
||||
|
||||
**Remediation:** Replace the coordinator's check with an atomic MongoDB conditional update:
|
||||
```ts
|
||||
const result = await Payment.findOneAndUpdate(
|
||||
{ _id: paymentId, status: { $ne: 'completed' } },
|
||||
{ $set: { status: 'completed', ... } },
|
||||
{ new: true }
|
||||
);
|
||||
if (!result) return; // already processed
|
||||
```
|
||||
For chat creation, add a unique compound index on `{ 'relatedTo.type': 1, 'relatedTo.id': 1 }` and handle the duplicate key error gracefully.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-2 — Telegram Auto-Provisioning Race Creates Orphan User Documents
|
||||
**File:** `src/services/auth/authController.ts:377–434`
|
||||
**Status:** Open
|
||||
|
||||
The flow is:
|
||||
1. `TelegramLink.findOne({ telegramUserId })` → null (new user)
|
||||
2. `new User({...}).save()` — creates User document
|
||||
3. `TelegramLink.findOneAndUpdate({ telegramUserId }, ..., { upsert: true })`
|
||||
|
||||
Two parallel Telegram auth requests for the same `telegramUserId` both find no link, both create a new `User`, and both try to upsert `TelegramLink`. Because `TelegramLink.telegramUserId` has `unique: true`, only one upsert wins. The loser's `User.save()` has already committed, producing a fully-active `User` document with no `TelegramLink` — it can never be authenticated via Telegram and will not be cleaned up.
|
||||
|
||||
**Remediation:** Use optimistic locking or a MongoDB transaction. Practical approach without transactions:
|
||||
```ts
|
||||
// Step 1: try to upsert the link first
|
||||
const link = await TelegramLink.findOneAndUpdate(
|
||||
{ telegramUserId },
|
||||
{ $setOnInsert: { telegramUserId, source, ... } },
|
||||
{ upsert: true, new: true, rawResult: true }
|
||||
);
|
||||
const isNew = link.lastErrorObject?.upserted != null;
|
||||
// Step 2: create User only if this process won the upsert
|
||||
if (isNew) { user = await User.create({...}); await link.value.updateOne({ userId: user._id }); }
|
||||
else { user = await User.findById(link.value.userId); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CRIT-3 — Simulated Transaction Bypass Active in Production
|
||||
**File:** `src/services/payment/paymentRoutes.ts:379–396`
|
||||
**Status:** Open
|
||||
|
||||
*(Also cited in Security Audit C-3.)*
|
||||
|
||||
```ts
|
||||
if (paymentHash.startsWith('SIM_') || ...) {
|
||||
isVerified = true;
|
||||
}
|
||||
```
|
||||
|
||||
No environment guard. A client sending `paymentHash: "SIM_anything"` gets a completed `Payment` and the `PurchaseRequest` advances without any real funds.
|
||||
|
||||
**Remediation:** Wrap in `if (process.env.NODE_ENV !== 'production')`.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-4 — Hardcoded Credential in Production Payment Code
|
||||
**File:** `src/services/payment/shkeeper/shkeeperPayoutService.ts:224–228`
|
||||
**Status:** Open
|
||||
|
||||
*(Also cited in Security Audit C-5.)*
|
||||
|
||||
```ts
|
||||
'Authorization': `Basic ${Buffer.from(`admin:!NMI4WdGkVQ#dQ`).toString('base64')}`
|
||||
```
|
||||
|
||||
**Remediation:** `process.env.SHKEEPER_ADMIN_PASSWORD` — rotate immediately.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### HIGH-1 — `updatePurchaseRequest` Has a Read-Modify-Write Race Condition
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:405–425`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
const currentRequest = await PurchaseRequest.findById(requestId); // read
|
||||
// ... validate status ...
|
||||
await PurchaseRequest.findByIdAndUpdate(requestId, updateData, { new: true }); // write
|
||||
```
|
||||
|
||||
Two concurrent calls both read the same `currentRequest.status`, both pass `isValidStatusProgression`, and both write — potentially into conflicting terminal states or setting the same field twice.
|
||||
|
||||
Furthermore, `isValidStatusProgression` allows any status → any terminal state transition (e.g., `pending` → `completed` in one step), because the guard only blocks backward progression among non-terminal states.
|
||||
|
||||
**Remediation:** Use atomic optimistic locking:
|
||||
```ts
|
||||
const updated = await PurchaseRequest.findOneAndUpdate(
|
||||
{ _id: requestId, status: currentStatus }, // guard: status unchanged
|
||||
{ $set: { status: newStatus } },
|
||||
{ new: true }
|
||||
);
|
||||
if (!updated) throw new ConflictError('State changed concurrently — retry');
|
||||
```
|
||||
Additionally, tighten `isValidStatusProgression` to use an explicit allowed-transitions table rather than a blanket terminal-from-anywhere rule.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-2 — Seller Offer Acceptance Has No Concurrency Guard
|
||||
**File:** `src/services/marketplace/SellerOfferService.ts:355–426`
|
||||
**Status:** Open
|
||||
|
||||
`acceptOffer` reads the offer, then updates it in a separate write. Two simultaneous accept calls for different offers on the same purchase request will both succeed: both set their target offer to `accepted`, and both run `updateMany` to reject all others — each query excluding its own `offerId`. The result is two `accepted` offers on the same purchase request.
|
||||
|
||||
**Remediation:** Use `findOneAndUpdate` with a status guard:
|
||||
```ts
|
||||
const updated = await SellerOffer.findOneAndUpdate(
|
||||
{ _id: offerId, status: 'pending' },
|
||||
{ $set: { status: 'accepted' } },
|
||||
{ new: true }
|
||||
);
|
||||
if (!updated) throw new ConflictError('Offer already processed');
|
||||
await SellerOffer.updateMany(
|
||||
{ purchaseRequestId, _id: { $ne: offerId } },
|
||||
{ $set: { status: 'rejected' } }
|
||||
);
|
||||
```
|
||||
Consider wrapping in a MongoDB transaction to keep the accept + reject-others atomic.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-3 — `refreshTokens[]` Array Grows Unboundedly
|
||||
**File:** `src/services/auth/authController.ts:62–63` and login handler
|
||||
**Status:** Open
|
||||
|
||||
Every `login` pushes a new refresh token onto `user.refreshTokens[]` without any cap or expiry pruning. The `User` model has no `maxlength` on this array. A user logging in from many devices, or an attacker rapidly requesting tokens, causes the array to grow without limit — increasing document size and slowing any query that reads the user document.
|
||||
|
||||
The `refreshToken` rotation endpoint prunes the old token on rotation, but `login` does not prune expired tokens before pushing.
|
||||
|
||||
**Remediation:** Before pushing, prune expired tokens:
|
||||
```ts
|
||||
const now = Date.now() / 1000;
|
||||
user.refreshTokens = user.refreshTokens.filter(t => {
|
||||
try { return jwt.decode(t)?.exp > now; } catch { return false; }
|
||||
});
|
||||
```
|
||||
Enforce a hard cap (e.g. `slice(-10)`) after pruning.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-4 — Blocked Telegram User Can Bypass Block via Deleted TelegramLink
|
||||
**File:** `src/services/auth/authController.ts:355–363`
|
||||
**Status:** Open
|
||||
|
||||
The block check queries `TelegramLink` where `status = 'blocked'` AND `blockedReason != 'unlinked_by_user'`. If the blocked user's `TelegramLink` is **deleted** entirely (not just status-changed), the check finds nothing, `activeLink` finds nothing, and the code auto-provisions a brand-new `User` with a fresh `TelegramLink` — bypassing the block entirely.
|
||||
|
||||
**Remediation:** Store the blocked Telegram user ID on the `User` document (a `blockedTelegramIds` set at the app level, or a `status: 'suspended'` flag tied to the Telegram ID) so the block persists even without a `TelegramLink` record.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-5 — `verifyEmailWithCode`: Non-Atomic User Create + TempVerification Delete
|
||||
**File:** `src/services/auth/authController.ts:620–633`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
await user.save(); // line 630
|
||||
await TempVerification.findByIdAndDelete(tempVerification._id); // line 633
|
||||
```
|
||||
|
||||
If `user.save()` succeeds but the delete fails (network error, timeout), the `TempVerification` document remains. The next call with the same `email+code` will find it valid, attempt `User.create` again, and hit the unique email index — returning a 500 error instead of a clean "already verified" message. This persists until the MongoDB TTL scan runs (up to 15 min + scan interval).
|
||||
|
||||
**Remediation:** Delete the TempVerification **before** saving the user, or check for duplicate-key errors after `user.save()` and delete the TempVerification in the catch handler.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### MED-1 — Funds Ledger Availability Check Is Not Atomic with Append
|
||||
**File:** `src/services/payment/orchestration/releaseRefundService.ts:37–55,99–114`
|
||||
**Status:** Open
|
||||
|
||||
`validateReleaseAvailability` reads a balance via aggregation, checks `availableBalance >= amount`, then `appendFundsLedgerEntry` inserts the entry. Two concurrent release/refund calls can both read the same balance snapshot, both pass the availability check, and both insert entries — resulting in a double-spend from the ledger.
|
||||
|
||||
The `idempotencyKey` on `FundsLedgerEntry` prevents exact duplicates when the same `txHash` is used, but does not prevent two different `txHash` values from both passing the availability check concurrently.
|
||||
|
||||
**Remediation:** Wrap the check + insert in a MongoDB transaction, or add a single-release constraint: before inserting a `release` entry, verify no prior `release` entry exists for the same `paymentId`.
|
||||
|
||||
---
|
||||
|
||||
### MED-2 — `updatePurchaseRequestStatus` Bypasses the State Machine Validator
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:551–589`
|
||||
**Status:** Open
|
||||
|
||||
`updatePurchaseRequestStatus` calls `PurchaseRequest.findByIdAndUpdate` directly, without calling `isValidStatusProgression`. The webhook path (`paymentCoordinator.ts:208`) uses this method, meaning payment-triggered state transitions skip the state machine guard entirely.
|
||||
|
||||
**Remediation:** Add `isValidStatusProgression(currentStatus, newStatus)` check inside `updatePurchaseRequestStatus` before writing.
|
||||
|
||||
---
|
||||
|
||||
### MED-3 — `getUserPayments` Queries Non-Existent Field `userId`
|
||||
**File:** `src/services/payment/paymentService.ts:342–346`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
query.userId = userId;
|
||||
```
|
||||
|
||||
The `Payment` schema has no top-level `userId` field. The buyer is stored as `buyerId` and the seller as `sellerId`. This query will always return zero results for current payments; only legacy payments with `metadata.legacyUserId` are returned (none for new users). The endpoint appears non-functional for all users.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
query.$or = [{ buyerId: userId }, { sellerId: userId }];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MED-4 — Payout Created Without Verifying an Inbound Completed Payment Exists
|
||||
**File:** `src/services/payment/shkeeper/shkeeperPayoutService.ts:42–80`
|
||||
**Status:** Open
|
||||
|
||||
`createPayoutTask` takes `buyerId`, `sellerId`, `purchaseRequestId`, and `amount` from the request body. The service checks for duplicate payouts (`existingPayout` idempotency) but does not verify that a corresponding inbound `Payment` with `status: 'completed'` and `direction: 'in'` exists. An admin could accidentally initiate a payout for a purchase request that has never been paid.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
const inboundPayment = await Payment.findOne({
|
||||
purchaseRequestId, direction: 'in', status: 'completed'
|
||||
});
|
||||
if (!inboundPayment) throw new AppError(400, 'No completed inbound payment for this request');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MED-5 — Unauthenticated `/payment/callback` Endpoint Can Mutate Payment Status
|
||||
**File:** `src/services/payment/paymentControllerRoutes.ts:20`
|
||||
**Status:** Open
|
||||
|
||||
`POST /callback` is mounted with no authentication middleware. The handler accepts `{ transactionId, status, amount }` and updates a payment's status to any value without verification. Unlike the SHKeeper webhook (which has optional HMAC), this endpoint has zero authentication.
|
||||
|
||||
**Remediation:** Either remove the endpoint (the SHKeeper webhook path supersedes it), restrict to internal network only (Nginx `allow` rules), or add HMAC verification.
|
||||
|
||||
---
|
||||
|
||||
### MED-6 — In-Memory Payment Coordinator State Not Shared Across Replicas
|
||||
**File:** `src/services/payment/paymentCoordinator.ts:24–25`
|
||||
**Status:** Open
|
||||
|
||||
The `coordinationState` Map is process-local. In a multi-process deployment (PM2 cluster, k8s with >1 replica), the 10-second debounce window provides no protection — each process has its own empty map at startup, and webhooks routed to different replicas will both process. The existing DB `status === 'completed'` guard is the only durable protection.
|
||||
|
||||
*(The code comment at line 24 acknowledges this. Fix is the same as CRIT-1 — atomic DB conditional update makes this moot.)*
|
||||
|
||||
---
|
||||
|
||||
### MED-7 — Seller Offer Notification Uses Undefined `offer.title` Field
|
||||
**File:** `src/services/marketplace/SellerOfferService.ts:403,415`
|
||||
**Status:** Open
|
||||
|
||||
`SellerOffer` does not have a `title` field directly — the title belongs to the associated `PurchaseRequest`. `offer.title` resolves to `undefined` in the notification message body, resulting in broken notification text for buyers and sellers.
|
||||
|
||||
**Remediation:** Either populate the `purchaseRequestId` and use `purchaseRequest.title`, or populate the field name from the offer's own descriptive field.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### LOW-1 — Duplicate Callback Route Definitions
|
||||
**File:** `src/services/payment/paymentRoutes.ts:29,256`
|
||||
**Status:** Open
|
||||
|
||||
`POST /callback` is defined at line 29 (mapped to `handlePaymentCallback`) and again as `POST /payments/callback` at line 256. These resolve to different URL paths given the router mount, but suggest dead code. No security impact; creates maintenance confusion.
|
||||
|
||||
**Remediation:** Remove the dead route definition.
|
||||
|
||||
---
|
||||
|
||||
### LOW-2 — `paymentCoordinator.ts` Map Grows Without Bound Until Cleanup Fires
|
||||
**File:** `src/services/payment/paymentCoordinator.ts:35`
|
||||
**Status:** Open
|
||||
|
||||
The cleanup function fires after each coordination cycle but only on active payments. Under a webhook flood before cleanup, the Map accumulates entries. Not a crash risk at current scale; becomes a concern at high webhook volume.
|
||||
|
||||
**Remediation:** The atomic DB fix in CRIT-1 eliminates the need for the Map entirely. Until then, add a size cap and a periodic cleanup interval independent of payment processing.
|
||||
|
||||
---
|
||||
|
||||
## Confirmed PASS
|
||||
|
||||
| Check | Result | Source |
|
||||
|-------|--------|--------|
|
||||
| TempVerification TTL index | PASS | `TempVerification.ts:59` — `expireAfterSeconds: 0` on expiry field |
|
||||
| Legacy email token cleared on first use | PASS | `authController.ts:667` — token set to undefined before save |
|
||||
| SHKeeper HMAC timing-safe comparison | PASS | `shkeeperWebhook.ts:84` — `crypto.timingSafeEqual` |
|
||||
| Socket event DB as source of truth | PASS | DB write committed before emit; emit failure is non-fatal |
|
||||
| `FundsLedgerEntry` idempotency key (unique) | PASS | `FundsLedgerEntry.ts:86` — sparse unique index |
|
||||
| `TelegramSession` TTL index | PASS | `TelegramSession.ts:66` |
|
||||
| Password change clears refresh tokens | PASS | `authController.ts:887` |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| CRITICAL | 4 |
|
||||
| HIGH | 5 |
|
||||
| MEDIUM | 7 |
|
||||
| LOW | 2 |
|
||||
|
||||
**Top priority:** CRIT-1 (concurrent webhook double-processing) and CRIT-2 (Telegram race condition) require atomic DB operations. CRIT-3 (simulation bypass) and CRIT-4 (hardcoded credential) are one-line fixes.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[Security Audit - 2026-05-24]]
|
||||
- [[Performance Audit - 2026-05-24]]
|
||||
- [[Funds Ledger and Escrow State Machine Specification]]
|
||||
- [[Payment Provider Adapter Spec]]
|
||||
- [[Authentication Flow]]
|
||||
- [[Platform Logical Audit - 2026-05-24]]
|
||||
338
09 - Audits/Performance Audit - 2026-05-24.md
Normal file
338
09 - Audits/Performance Audit - 2026-05-24.md
Normal file
@@ -0,0 +1,338 @@
|
||||
---
|
||||
title: Performance Audit — 2026-05-24
|
||||
tags: [audit, performance, database, findings]
|
||||
created: 2026-05-24
|
||||
status: open
|
||||
---
|
||||
|
||||
# Performance Audit — 2026-05-24
|
||||
|
||||
Performance review of MongoDB queries, indexes, in-memory data structures, Socket.IO emission patterns, and pagination. Triggered by completion of Request Network integration, Telegram auth, and the internal funds ledger. Every finding is derived from actual source code.
|
||||
|
||||
> [!warning] Scale risk
|
||||
> H3 (unbounded seller fan-out) and H4 (full chat document in memory) are time-bombs that will cause outages once user counts and message volumes grow past modest scale. Both have zero performance headroom at their current implementation.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1 — N+1 Query: `getPurchaseRequestsByBuyer` Issues One Payment Lookup Per Request Row
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:516–534`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
await Promise.all(requests.map(async (request) =>
|
||||
Payment.findOne({ purchaseRequestId: request._id })
|
||||
))
|
||||
```
|
||||
|
||||
A page of 10 requests produces 11 DB round-trips (1 list + 10 payment lookups). With a 50-item page, that is 51 round-trips on every buyer dashboard load.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
const requestIds = requests.map(r => r._id);
|
||||
const payments = await Payment.find({ purchaseRequestId: { $in: requestIds } }).lean();
|
||||
const paymentMap = new Map(payments.map(p => [p.purchaseRequestId.toString(), p]));
|
||||
// then: requestWithPayment = { ...r, payment: paymentMap.get(r._id.toString()) }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H2 — Missing Index on `Payment.purchaseRequestId`
|
||||
**File:** `src/models/Payment.ts:190–196`
|
||||
**Status:** Open
|
||||
|
||||
The Payment schema indexes `buyerId`, `sellerId`, `status`, `transactionHash`, and `providerPaymentId` — but not `purchaseRequestId`. Every call to `Payment.findOne({ purchaseRequestId })` (used in `getPurchaseRequestsByBuyer`, `getPurchaseRequestById`, payment coordinator, notification handlers) causes a full collection scan.
|
||||
|
||||
**Remediation:** Add to `Payment.ts`:
|
||||
```ts
|
||||
paymentSchema.index({ purchaseRequestId: 1, status: 1 });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H3 — Unbounded Seller Fan-Out on New Public Purchase Request
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:190–191`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
sellers = await User.find({ role: "seller", status: "active" }).select(...)
|
||||
```
|
||||
|
||||
When a public purchase request is created, every active seller is fetched with no limit. For 10 000 sellers:
|
||||
- 10 000 `Notification` documents are inserted (not bulk-inserted — individual creates in a loop).
|
||||
- Each insert calls `emitRealTimeNotification` → `getUnreadCount` (an additional `countDocuments` query per seller = 10 000 extra queries).
|
||||
- A 50 ms staggered `setTimeout` per seller means the event loop is managing 500 seconds of scheduled callbacks.
|
||||
|
||||
**Remediation:**
|
||||
1. Cap the fan-out with a configurable batch limit (e.g., 500 most-relevant sellers by category).
|
||||
2. Use `Notification.insertMany(docs)` for bulk creation.
|
||||
3. Push a single room-broadcast (`io.to('sellers').emit(...)`) rather than per-user socket emits.
|
||||
4. Move heavy fan-out to a background job queue (Bull/BullMQ).
|
||||
|
||||
---
|
||||
|
||||
### H4 — Full Chat Document with All Embedded Messages Loaded for Pagination
|
||||
**File:** `src/services/chat/ChatService.ts:370–408`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
const chat = await Chat.findById(chatId).populate("messages.senderId", ...)
|
||||
const messages = chat.messages.sort(...).slice(skip, skip + limit)
|
||||
```
|
||||
|
||||
A chat with 50 000 messages (~5 KB each = 250 MB) is fully loaded into Node.js memory on every paginated message request. Pagination happens in JavaScript after the full document is fetched.
|
||||
|
||||
**Remediation (minimum):** Use MongoDB's `$slice` projection:
|
||||
```ts
|
||||
const chat = await Chat.findById(chatId, {
|
||||
messages: { $slice: [skip, limit] },
|
||||
participants: 1,
|
||||
unreadCounts: 1
|
||||
}).lean();
|
||||
```
|
||||
**Recommended long-term:** Extract messages into a separate `Message` collection with server-side pagination (`Message.find({ chatId }).sort({ timestamp: -1 }).skip(skip).limit(limit).lean()`).
|
||||
|
||||
---
|
||||
|
||||
### H5 — Extra `countDocuments` Query on Every Single Notification Creation
|
||||
**File:** `src/services/notification/NotificationService.ts:131–152`
|
||||
**Status:** Open
|
||||
|
||||
`emitRealTimeNotification` calls `emitUnreadCountUpdate`, which calls `getUnreadCount` (a full `countDocuments` query) synchronously within the notification creation hot path. When H3's 10 000-seller fan-out fires, this produces 10 000 additional `countDocuments` queries in rapid succession.
|
||||
|
||||
**Remediation:** Maintain an unread count field directly on the `User` or in a Redis hash. Increment on notification insert rather than re-counting. Or skip the re-query and have the client increment its local counter on socket event receipt.
|
||||
|
||||
---
|
||||
|
||||
### H6 — `getUserPayments` Queries Non-Indexed Field and Has No Default Limit
|
||||
**File:** `src/services/payment/paymentService.ts:331–370`
|
||||
**Status:** Open
|
||||
|
||||
The query builds `query.userId = userId` (line 343), but the Payment schema stores buyers as `buyerId` (ObjectId) — not `userId`. The query hits `metadata.legacyUserId`, which has no index. Additionally, if `options.limit` is not supplied the query returns all matching documents with no limit.
|
||||
|
||||
**Remediation:**
|
||||
1. Fix field: `query.$or = [{ buyerId: userId }, { sellerId: userId }]`
|
||||
2. Add default limit: `.limit(options.limit ?? 100)`
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M1 — Missing Compound Index `(buyerId, createdAt)` on PurchaseRequest
|
||||
**File:** `src/models/PurchaseRequest.ts:360–369`
|
||||
**Status:** Open
|
||||
|
||||
`getPurchaseRequestsByBuyer` queries `{ buyerId }` with `.sort({ createdAt: -1 })`. The existing single-field index on `buyerId` satisfies the filter but forces MongoDB to sort results post-scan. A compound index eliminates the sort entirely.
|
||||
|
||||
**Remediation:** Add to `PurchaseRequest.ts`:
|
||||
```ts
|
||||
PurchaseRequestSchema.index({ buyerId: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ status: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ categoryId: 1, status: 1, createdAt: -1 });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M2 — Unanchored Case-Insensitive Regex Search — Full Collection Scan
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:703–720`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
{ title: { $regex: searchTerm, $options: "i" } }
|
||||
```
|
||||
|
||||
An unanchored, case-insensitive regex cannot use a B-tree index. MongoDB performs a full collection scan with in-memory string matching on every search request.
|
||||
|
||||
**Remediation:** Create a text index:
|
||||
```ts
|
||||
PurchaseRequestSchema.index({ title: 'text', description: 'text', tags: 'text' });
|
||||
```
|
||||
Query with `{ $text: { $search: searchTerm } }` and sort by `{ score: { $meta: 'textScore' } }`.
|
||||
|
||||
---
|
||||
|
||||
### M3 — Double-Fetch Pattern in Update Methods (No `.lean()` on Pre-Check)
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:406–425,557–572`
|
||||
**Status:** Open
|
||||
|
||||
Both `updatePurchaseRequest` and `updatePurchaseRequestStatus` call `PurchaseRequest.findById(requestId)` to validate current status, then call `findByIdAndUpdate`. The pre-check `findById` instantiates a full Mongoose document that is immediately discarded.
|
||||
|
||||
**Remediation:** Use `findByIdAndUpdate` with `{ new: false }` to get the pre-update document in a single round-trip, or use `.lean().select('status')` on the pre-check to minimize overhead.
|
||||
|
||||
---
|
||||
|
||||
### M4 — Regex on Embedded `messages.content` Array — Full Array Deserialization
|
||||
**File:** `src/services/chat/ChatService.ts:318–320`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
{ "messages.content": { $regex: filters.search, $options: "i" } }
|
||||
```
|
||||
|
||||
MongoDB must deserialize the entire embedded `messages` array for every chat document to evaluate this filter. There is no index support for nested array field regex searches.
|
||||
|
||||
**Remediation:** Move to a dedicated `Message` collection with a text index on `content`, then query `Message.find({ chatId: { $in: userChatIds }, $text: { $search: searchTerm } })`.
|
||||
|
||||
---
|
||||
|
||||
### M5 — In-Memory Coordination State Not Shared Across Replicas
|
||||
**File:** `src/services/payment/paymentCoordinator.ts:25`
|
||||
**Status:** Open
|
||||
|
||||
The `coordinationState` Map is process-local. In a multi-replica deployment, each process starts with an empty map; the 10-second debounce provides no cross-process deduplication. The DB guard in CRIT-1 (Logic Audit) is the correct production fix; this Map is supplementary at best.
|
||||
|
||||
*(Code comment at line 24 already acknowledges this — a Redis migration is planned.)*
|
||||
|
||||
---
|
||||
|
||||
### M6 — `getSellers()` Is Unbounded (No Limit)
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:728–741`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
await User.find({ role: "seller", isEmailVerified: true }).select(...).lean()
|
||||
```
|
||||
|
||||
No `.limit()`. Returns every seller in the collection. This method is exposed via API.
|
||||
|
||||
**Remediation:** Add `.limit(500)` at minimum, or paginate with a cursor.
|
||||
|
||||
---
|
||||
|
||||
### M7 — `user-online` Event Broadcast to All Connected Sockets Globally
|
||||
**File:** `src/app.ts:300`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
socket.broadcast.emit('user-status-change', { userId, status: 'online', ... })
|
||||
```
|
||||
|
||||
This pushes the online-presence event to every connected socket regardless of whether they have a relationship with the user. With 10 000 concurrent connections, this is 10 000 individual socket writes per login event.
|
||||
|
||||
**Remediation:** Restrict to relevant rooms. For a marketplace, "relevant" means chat participants and active purchase request counterparties:
|
||||
```ts
|
||||
userChatIds.forEach(chatId => {
|
||||
socket.to(`chat-${chatId}`).emit('user-status-change', { userId, status: 'online' });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M8 — Post-Filter After Pagination Causes Wrong `totalItems` Count
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:258–337`
|
||||
**Status:** Open
|
||||
|
||||
The code paginates the base query, then applies visibility filtering (offer ownership, anonymization) after pagination. This means `totalItems` in the response is the pre-filter count, not the count of items the caller can actually see. Users may see fewer items than indicated by the pagination metadata (e.g., "showing 8 of 10" when only 6 are visible).
|
||||
|
||||
**Remediation:** Move visibility filtering into the MongoDB query (aggregation pipeline with `$match` conditions) so `countDocuments` reflects the actual visible set.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### L1 — `getUserChats` Runs Count Query Sequentially After Find
|
||||
**File:** `src/services/chat/ChatService.ts:324–335`
|
||||
**Status:** Open
|
||||
|
||||
`Chat.find(query)` and `Chat.countDocuments(query)` are independent queries run sequentially. They should run in parallel.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
const [chats, total] = await Promise.all([
|
||||
Chat.find(query).lean(),
|
||||
Chat.countDocuments(query)
|
||||
]);
|
||||
```
|
||||
*(The notification service at `NotificationService.ts:53–61` already does this correctly — apply the same pattern.)*
|
||||
|
||||
---
|
||||
|
||||
### L2 — `findByIdAndUpdate` Results Not Using `.lean()`
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:418–425,566–572`
|
||||
**Status:** Open
|
||||
|
||||
`findByIdAndUpdate` with `.populate(...)` returns full Mongoose document objects for data that is only read and returned to the client. `.lean()` saves ~15–30% memory and processing per returned document.
|
||||
|
||||
**Remediation:** Add `.lean()` to read-only `findByIdAndUpdate` calls.
|
||||
|
||||
---
|
||||
|
||||
### L3 — Telegram Replay Maps Are Process-Local (Multi-Replica Gap, Same as M5)
|
||||
**File:** `src/services/telegram/telegramService.ts:395–396`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
const initDataReplayWindow = new Map<string, number>();
|
||||
const webhookReplayWindow = new Map<string, number>();
|
||||
```
|
||||
|
||||
`cleanupReplayMap` is called lazily before each check — entries are evicted correctly once the window expires, so the Maps do not grow unboundedly under normal load. However, like `coordinationState`, they are process-local and will not catch replays across multiple Node replicas.
|
||||
|
||||
**Remediation:** Redis `SET NX EX` (same as Security Audit H-1 recommendation).
|
||||
|
||||
---
|
||||
|
||||
### L4 — PasskeyService Challenge Map: 5-Minute Cleanup Is Correct, Process-Local Only
|
||||
**File:** `src/services/auth/passkeyService.ts:56–63`
|
||||
**Status:** Open
|
||||
|
||||
Cleanup is implemented and the code comment (lines 50–55) already notes the Redis migration path. Not a performance risk at current scale. Same multi-replica caveat as L3.
|
||||
|
||||
---
|
||||
|
||||
## Confirmed PASS
|
||||
|
||||
| Check | Result | Source |
|
||||
|-------|--------|--------|
|
||||
| `TempVerification` TTL index | PASS | `TempVerification.ts:59` — MongoDB-native TTL, no app polling |
|
||||
| `TelegramSession` TTL index | PASS | `TelegramSession.ts:66` |
|
||||
| `Notification` 90-day TTL index | PASS | `Notification.ts:77` — `expireAfterSeconds: 7776000` |
|
||||
| `FundsLedgerEntry` indexes (purchaseRequestId, paymentId, idempotencyKey) | PASS | `FundsLedgerEntry.ts:86–107` |
|
||||
| SHKeeper polling rate bounded | PASS | `shkeeperSimpleAuto.ts:64` — 2-minute interval, self-cleaning |
|
||||
| Socket.IO room cleanup on disconnect | PASS | Socket.IO removes socket from all rooms automatically on disconnect |
|
||||
| `getUserNotifications` count + find in parallel | PASS | `NotificationService.ts:53–61` — already uses `Promise.all` |
|
||||
| Telegram replay map bounded (lazy eviction) | PASS | `telegramService.ts:100` — `cleanupReplayMap` evicts expired entries |
|
||||
|
||||
---
|
||||
|
||||
## Index Additions Summary
|
||||
|
||||
The following indexes should be added to eliminate collection scans identified in this audit:
|
||||
|
||||
```ts
|
||||
// Payment.ts
|
||||
paymentSchema.index({ purchaseRequestId: 1, status: 1 });
|
||||
|
||||
// PurchaseRequest.ts
|
||||
PurchaseRequestSchema.index({ buyerId: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ status: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ categoryId: 1, status: 1, createdAt: -1 });
|
||||
|
||||
// PurchaseRequest.ts (text search)
|
||||
PurchaseRequestSchema.index({ title: 'text', description: 'text', tags: 'text' });
|
||||
```
|
||||
|
||||
Caution: adding indexes increases write latency and storage. Run `db.collection.getIndexes()` before adding to avoid duplicating existing indexes (some `unique: true` fields already create indexes automatically).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| HIGH | 6 |
|
||||
| MEDIUM | 8 |
|
||||
| LOW | 4 |
|
||||
|
||||
**Highest urgency:** H3 (seller fan-out) and H4 (full chat in memory) will cause visible degradation with modest growth. H1 (N+1 payments) and H2 (missing index) are easy wins with high query-count impact.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[Security Audit - 2026-05-24]]
|
||||
- [[Logic Audit - 2026-05-24]]
|
||||
- [[Backend Architecture]]
|
||||
- [[Data Model Overview]]
|
||||
- [[Funds Ledger and Escrow State Machine Specification]]
|
||||
405
09 - Audits/Security Audit - 2026-05-24.md
Normal file
405
09 - Audits/Security Audit - 2026-05-24.md
Normal file
@@ -0,0 +1,405 @@
|
||||
---
|
||||
title: Security Audit — 2026-05-24
|
||||
tags: [audit, security, findings]
|
||||
created: 2026-05-24
|
||||
status: open
|
||||
---
|
||||
|
||||
# Security Audit — 2026-05-24
|
||||
|
||||
Full-codebase security review triggered by the completion of Telegram first-class auth, Request Network payment integration, and rate-limiting enablement. Every finding below was verified against actual source code — no hypothetical issues are included.
|
||||
|
||||
> [!danger] Action required
|
||||
> 6 CRITICAL findings exist. C-3 and C-4 are exploitable in any non-`development` environment right now and must be fixed before staging is accessible to external testers.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### C-1 — Hardcoded Admin Password as Fallback Default
|
||||
**File:** `src/infrastructure/database/init-admin.ts:7`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'Moji6364';
|
||||
```
|
||||
|
||||
If `ADMIN_PASSWORD` is not set, the admin account is seeded with a password that is now committed to version control. Combined with the predictable default email (`admin@marketplace.com`), the admin account is trivially discoverable.
|
||||
|
||||
**Remediation:**
|
||||
- Remove the `|| 'Moji6364'` fallback entirely.
|
||||
- Add a startup assertion: `if (!process.env.ADMIN_PASSWORD) throw new Error('ADMIN_PASSWORD is required')`.
|
||||
- Rotate the credential immediately on any environment where the default may have been used.
|
||||
|
||||
---
|
||||
|
||||
### C-2 — Admin Password Logged to Stdout on Every Fresh Deploy
|
||||
**File:** `src/infrastructure/database/init-admin.ts:50`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
console.log(`🔑 Password: ${adminPassword}`);
|
||||
```
|
||||
|
||||
The raw admin password (env-supplied or hardcoded default) is written to stdout on every seeding run. Container log aggregators, CloudWatch, Sentry breadcrumbs, and anyone with log-viewer access will have the credential.
|
||||
|
||||
**Remediation:** Delete line 50 entirely. Log only `"Admin user created successfully"` — never the credential value.
|
||||
|
||||
---
|
||||
|
||||
### C-3 — Simulated Transaction Bypass Active in Production
|
||||
**File:** `src/services/payment/paymentRoutes.ts:379–396`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
if (paymentHash.startsWith('SIM_') ||
|
||||
(paymentHash.startsWith('0x') && paymentHash.length < 64)) {
|
||||
isVerified = true;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
No environment guard. Any client that sends `paymentHash: "SIM_anything"` in production gets `isVerified = true`, causing a real `Payment` record to be created with `status: 'completed'` and `PurchaseRequest` to advance to `processing` — without any actual funds.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// SIM_ test path
|
||||
}
|
||||
```
|
||||
Or gate on a separate `ENABLE_PAYMENT_SIMULATION=true` env flag that is absent from production env files.
|
||||
|
||||
---
|
||||
|
||||
### C-4 — `forceVerifyUser` Gate Uses Wrong Condition
|
||||
**File:** `src/services/auth/authController.ts:1127–1159` and `src/services/auth/authRoutes.ts:60`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return ResponseHandler.forbidden(res, "...");
|
||||
}
|
||||
```
|
||||
|
||||
`undefined !== "development"` is `true`, so when `NODE_ENV` is unset (common in CI, some staging configs) the guard **passes** and any unauthenticated caller can verify any user's email instantly, bypassing the entire verification flow.
|
||||
|
||||
**Remediation:**
|
||||
- Change condition to `process.env.NODE_ENV === "development"` (allowlist, not denylist).
|
||||
- Require an admin JWT on the route regardless of env.
|
||||
- Consider removing the route from `authRoutes.ts` and only mounting it conditionally at the app level.
|
||||
|
||||
---
|
||||
|
||||
### C-5 — Hardcoded SHKeeper Admin Credential in Source
|
||||
**File:** `src/services/payment/shkeeper/shkeeperPayoutService.ts:224–228`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
'Authorization': `Basic ${Buffer.from(`admin:!NMI4WdGkVQ#dQ`).toString('base64')}`
|
||||
```
|
||||
|
||||
A plaintext password is committed to version-controlled source. It will be included in every build artifact, Docker image layer, and any repository fork or export.
|
||||
|
||||
**Remediation:**
|
||||
- Replace with `process.env.SHKEEPER_ADMIN_PASSWORD`.
|
||||
- Add to required-env-vars startup check.
|
||||
- Rotate the credential immediately.
|
||||
|
||||
---
|
||||
|
||||
### C-6 — Access Token and Refresh Token Share the Same Signing Secret
|
||||
**File:** `src/services/auth/authService.ts:18,54`
|
||||
**Status:** Open
|
||||
|
||||
Both `generateToken` (access) and `generateRefreshToken` (refresh) use `this.JWT_SECRET`. The only distinction between token types is the `type: 'refresh'` payload claim — a field under caller control. Algorithm-confusion or secret-leakage attacks can forge long-lived refresh tokens using the access token secret.
|
||||
|
||||
**Remediation:**
|
||||
- Introduce `REFRESH_TOKEN_SECRET` env var (separate value, ≥32 chars).
|
||||
- Sign refresh tokens with it; verify refresh tokens only against it.
|
||||
- This fully segregates the two token families.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H-1 — Telegram Replay Map Lost on Server Restart (In-Memory Only)
|
||||
**File:** `src/services/telegram/telegramService.ts:395–407`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
const initDataReplayWindow = new Map<string, number>();
|
||||
```
|
||||
|
||||
`initData` replay protection, the webhook replay window, and the Request Network delivery ID deduplication are all stored in process-local `Map` objects. Any process restart, pod restart, or scale-out creates a fresh empty map. A captured `initData` is replayable immediately after any restart, within the configured max-age window (up to 24 hours).
|
||||
|
||||
**Remediation:** Replace with Redis `SET NX EX` pattern:
|
||||
```ts
|
||||
const key = `replay:telegram:${fingerprint}`;
|
||||
const inserted = await redis.set(key, '1', 'NX', 'EX', windowSeconds);
|
||||
if (!inserted) throw new ReplayError();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H-2 — SHKeeper Webhook Authentication Has Spoofable Bypass
|
||||
**File:** `src/services/payment/shkeeper/shkeeperWebhook.ts:95–103`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
const isShkeeperWebhook =
|
||||
req.headers['x-shkeeper-api-key'] ||
|
||||
req.headers['user-agent']?.includes('python-requests') ||
|
||||
payload.crypto === 'BNB-USDT';
|
||||
```
|
||||
|
||||
Any caller who sets `User-Agent: python-requests/2.x.x` or includes `"crypto": "BNB-USDT"` in the body can inject arbitrary payment completion events. The `x-shkeeper-api-key` header value is not validated — its presence alone is sufficient.
|
||||
|
||||
Additionally, the HMAC failure branch only rejects in `production` environments (`!== 'development'`), meaning unset `NODE_ENV` silently ignores invalid signatures.
|
||||
|
||||
**Remediation:**
|
||||
- Remove the heuristic `isShkeeperWebhook` fallback entirely.
|
||||
- Require `SHKEEPER_WEBHOOK_SECRET` at startup; fail if absent.
|
||||
- Change environment guard from `!== 'development'` to `=== 'development'` (or always enforce).
|
||||
|
||||
---
|
||||
|
||||
### H-3 — Request Network `allowTestMode: true` Hardcoded in Production Route
|
||||
**File:** `src/services/payment/requestNetwork/requestNetworkRoutes.ts:104–105` and `signature.ts:51–53`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
allowTestMode: true,
|
||||
// in signature.ts:
|
||||
if (allowTestMode && isTestHeader(headers, testHeader)) {
|
||||
return true; // skip verification
|
||||
}
|
||||
```
|
||||
|
||||
Any request bearing `x-request-network-test: 1` (or `true` / `yes`) completely bypasses HMAC signature verification. The flag is unconditional — there is no environment check.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
allowTestMode: process.env.NODE_ENV !== 'production',
|
||||
```
|
||||
Or introduce an explicit `ENABLE_RN_TEST_MODE=true` env flag absent from production.
|
||||
|
||||
---
|
||||
|
||||
### H-4 — Typing Indicator Socket Events Have No Chat Membership Check (IDOR)
|
||||
**File:** `src/app.ts:267–291`
|
||||
**Status:** Open
|
||||
|
||||
`typing-start` and `typing-stop` handlers verify only `data.userId === userId` (the caller is who they claim to be) but do not verify the caller is a participant of `data.chatId`. Any authenticated user who knows a chat ID can broadcast typing presence to that room — information disclosure.
|
||||
|
||||
**Remediation:** Check membership before broadcasting:
|
||||
```ts
|
||||
const chat = await Chat.findById(data.chatId, { participants: 1 }).lean();
|
||||
const isMember = chat?.participants.some(p => p.userId.equals(userId));
|
||||
if (!isMember) return;
|
||||
```
|
||||
Or maintain a per-socket set of joined room IDs and validate against it without a DB round-trip.
|
||||
|
||||
---
|
||||
|
||||
### H-5 — Financial Events Broadcast to All Connected Sockets
|
||||
**File:** `src/services/payment/shkeeper/shkeeperWebhook.ts:546,560`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
global.io?.emit('seller-offer-update', { sellerId: ..., paymentId: ..., transactionHash: ... });
|
||||
```
|
||||
|
||||
`io.emit()` sends to every connected socket. Payment completion events including `paymentId`, `transactionHash`, and `offerId` are sent to all users — any client can observe other users' financial events.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
global.io?.to(`seller-${selectedOffer.sellerId}`).emit('seller-offer-update', { ... });
|
||||
global.io?.to(`buyer-${payment.buyerId}`).emit('payment-completed', { ... });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M-1 — OTP and Reset Codes Logged in Plaintext (All Environments)
|
||||
**File:** `src/services/auth/authController.ts:174,203,715,757`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
console.log(`🔢 Generated verification code for ${email}: ${emailVerificationCode}`);
|
||||
console.log(`🔢 Generated password reset code for ${email}: ${resetCode}`);
|
||||
```
|
||||
|
||||
6-digit OTPs and password reset codes are written to stdout with no environment guard. Anyone with log access can take over any unverified account or reset any password.
|
||||
|
||||
**Remediation:** Delete all four log lines. Never log secrets or codes in any environment.
|
||||
|
||||
---
|
||||
|
||||
### M-2 — `Math.random()` Used for OTP Generation (Not a CSPRNG)
|
||||
**File:** `src/services/auth/authService.ts:226`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
```
|
||||
|
||||
`Math.random()` is not cryptographically secure. Replace with:
|
||||
```ts
|
||||
const bytes = crypto.randomBytes(3);
|
||||
return (100000 + (bytes.readUIntBE(0, 3) % 900000)).toString();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M-3 — Refresh Token Rotation Has No Theft Detection
|
||||
**File:** `src/services/auth/authController.ts:510–529`
|
||||
**Status:** Open
|
||||
|
||||
Token rotation works (old is removed, new is issued). However, if an attacker steals a refresh token and rotates it first, the legitimate user's subsequent refresh gets `403` but the attacker's session continues. There is no "token already rotated" detection that would invalidate all sessions for that user.
|
||||
|
||||
**Remediation:** Implement refresh token families. If a token that has already been rotated is presented again, treat it as a theft signal and set `user.refreshTokens = []` (force re-login everywhere).
|
||||
|
||||
---
|
||||
|
||||
### M-4 — Profile Update Uses `validateBeforeSave: false` with Full Object Spread
|
||||
**File:** `src/services/auth/authController.ts:921–938`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
user.profile = { ...user.profile, ...profile };
|
||||
user.preferences = { ...user.preferences, ...preferences };
|
||||
await user.save({ validateBeforeSave: false });
|
||||
```
|
||||
|
||||
The entire `profile` and `preferences` objects from the request body are spread without an explicit allowlist. `validateBeforeSave: false` additionally skips schema-level guards. A user could inject unexpected fields into their document.
|
||||
|
||||
**Remediation:** Use an explicit pick:
|
||||
```ts
|
||||
const allowedProfile = pick(profile, ['phone', 'bio', 'website']);
|
||||
user.profile = { ...user.profile, ...allowedProfile };
|
||||
```
|
||||
Remove `{ validateBeforeSave: false }`.
|
||||
|
||||
---
|
||||
|
||||
### M-5 — Telegram Login Widget Path Has No Replay Protection
|
||||
**File:** `src/services/auth/authController.ts:110–116`
|
||||
**Status:** Open
|
||||
|
||||
The Mini App flow correctly calls `checkMiniAppReplay` / `rememberMiniAppInitData`. The Login Widget flow (the `else` branch) calls only `verifyTelegramLoginWidget` — no replay check. The Login Widget payload contains a static `hash` valid for up to `miniAppMaxAgeMs` (up to 24 hours). An intercepted payload can be replayed freely within that window.
|
||||
|
||||
**Remediation:** Apply the same replay-map (or Redis key) logic to the Login Widget path, keying on `loginWidget.hash`.
|
||||
|
||||
---
|
||||
|
||||
### M-6 — No JWT Secret Strength Enforcement at Startup
|
||||
**File:** `src/shared/config/index.ts:42`
|
||||
**Status:** Open
|
||||
|
||||
`JWT_SECRET` is read with `process.env.JWT_SECRET!`. An empty string, `"secret"`, or `"changeme"` is silently accepted, producing trivially forgeable JWTs.
|
||||
|
||||
**Remediation:** Add to startup checks:
|
||||
```ts
|
||||
if (config.jwtSecret.length < 32) throw new Error('JWT_SECRET must be at least 32 characters');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M-7 — Legacy `verifyEmail` Token Route Has No Expiry Check
|
||||
**File:** `src/services/auth/authController.ts:667–692`
|
||||
**Status:** Open
|
||||
|
||||
The code-based flow (`/verify-email-code`) enforces a 15-minute expiry. The legacy URL-based flow (`GET /verify-email/:token`) queries only by token value, with no `emailVerificationTokenExpires` check. If the token field is persisted without an expiry date, it never becomes invalid.
|
||||
|
||||
**Remediation:** Either add `emailVerificationTokenExpires: { $gt: new Date() }` to the query, or deprecate and remove this route if the code-based flow has fully replaced it.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### L-1 — Passkey Challenge Debug Logs Expose All Active Challenges
|
||||
**File:** `src/services/auth/passkeyService.ts:128–130,207–212`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
console.log('🔍 Available challenges:', Array.from(this.storedChallenges.keys()));
|
||||
// ...
|
||||
allUsers.forEach(u => { console.log(`User ${u.email}:`, u.passkeys.map(pk => pk.id)); });
|
||||
```
|
||||
|
||||
On any failed passkey assertion, every registered user's email and all their passkey IDs are dumped to logs. An attacker who triggers many failed assertions can enumerate the passkey corpus from log infrastructure.
|
||||
|
||||
**Remediation:** Remove both blocks entirely. Log only the failed assertion's credential ID.
|
||||
|
||||
---
|
||||
|
||||
### L-2 — In-Memory Login Attempt Counters Not Shared Across Replicas
|
||||
**File:** `src/services/auth/authService.ts:112`
|
||||
**Status:** Open
|
||||
|
||||
`authAttempts: Map<string, ...>` is process-local. In a multi-replica deployment, an attacker distributing login attempts across replicas bypasses per-user lockout.
|
||||
|
||||
**Remediation:** Move to Redis with TTL-expiring keys (same pattern as the rate-limiter Redis adapter that is already planned).
|
||||
|
||||
---
|
||||
|
||||
### L-3 — CORS Origin: Unset `FRONTEND_URL` Allows All Origins
|
||||
**File:** `src/app.ts:332`
|
||||
**Status:** Open
|
||||
|
||||
`cors({ origin: process.env.FRONTEND_URL })` — if `FRONTEND_URL` is unset, `cors` treats `undefined` as "allow all origins."
|
||||
|
||||
**Remediation:** Add a startup assertion that `FRONTEND_URL` is a non-empty string.
|
||||
|
||||
---
|
||||
|
||||
### L-4 — Auth Rate-Limit Counters Are In-Memory (Multi-Replica Gap)
|
||||
**File:** `src/app.ts` (rate-limit middleware configuration)
|
||||
**Status:** Open
|
||||
|
||||
Same class as L-2. The rate-limit counters are in-memory. Distributing requests across replicas bypasses per-IP limits. A Redis store adapter is already planned.
|
||||
|
||||
---
|
||||
|
||||
## Confirmed PASS (Verified Correctly Handled)
|
||||
|
||||
| Check | Result | Source |
|
||||
|-------|--------|--------|
|
||||
| HMAC timing-safe comparison (SHKeeper + RN) | PASS | `shkeeperWebhook.ts:84`, `signature.ts:74` |
|
||||
| Telegram HMAC derivation (Mini App + Login Widget) | PASS | `telegramService.ts:223-278` |
|
||||
| Bot account rejection | PASS | `telegramService.ts:278,353` |
|
||||
| Blocked Telegram user check (`status: 'blocked'`) | PASS | `authController.ts:355-363` |
|
||||
| Refresh token rotation (old removed before new issued) | PASS | `authController.ts:527-529` |
|
||||
| Password change clears all refresh tokens | PASS | `authController.ts:887` |
|
||||
| Password reset clears all refresh tokens | PASS | `authController.ts:797,846` |
|
||||
| Socket.IO JWT enforcement on connect | PASS | `app.ts:76-94` |
|
||||
| `join-user-room` IDOR prevention | PASS | `app.ts:114` |
|
||||
| `join-chat-room` membership check | PASS | `app.ts:241-247` |
|
||||
| bcrypt work factor = 12 | PASS | `authService.ts` |
|
||||
| WebAuthn challenge consumed on first use | PASS | `passkeyService.ts:87` |
|
||||
| Telegram `auth_date` freshness enforcement | PASS | `telegramService.ts:208-223` |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| CRITICAL | 6 |
|
||||
| HIGH | 5 |
|
||||
| MEDIUM | 7 |
|
||||
| LOW | 4 |
|
||||
|
||||
**Immediate priority:** C-3 (simulation bypass) and C-4 (forceVerify gate) are one-line fixes and are exploitable today. C-1, C-2, C-5 (hardcoded/logged credentials) must be resolved and rotated before any external access to staging or production.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[Security Architecture]]
|
||||
- [[Authentication Flow]]
|
||||
- [[Webhook Security Spec]]
|
||||
- [[Threat Model - Amanat Escrow Platform]]
|
||||
- [[Logic Audit - 2026-05-24]]
|
||||
- [[Performance Audit - 2026-05-24]]
|
||||
Reference in New Issue
Block a user