174 Commits

Author SHA1 Message Date
a8ff2ba1fe Merge pull request 'docs: sync vault with codebase state (2026-06-12)' (#2) from docs/vault-sync-2026-06-12 into main
Reviewed-on: #2
2026-06-12 07:43:08 +00:00
Siavash Sameni
e52ffce48a docs: sync vault with codebase state (2026-06-12)
- Update backend, frontend, scanner, deployment, amanat-assist service docs
- Update System Overview, Scanner Architecture, Telegram Mini App flow
- Update 10 - Services/README.md
- Add Tenant data model, Tenant API reference, Tenant Storefront Flow
- Add Multi-Shop Branch Project Scan (2026-06-10)
- Add tenant.md service doc
- Append activity log entry
- Reflects archived/search/stats route fix and new E2E test suite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 11:42:18 +04:00
Siavash Sameni
18073afb52 docs: add inventory management gap analysis + design recommendations
Covers gaps in Mojtaba's digital-goods work (G1–G12), architecture
decision (module vs microservice), pool schema additions, and
auto-fulfillment hook design. Priority order for the next 13–15 eng-days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:13:54 +04:00
moojttaba
efc3af71d8 docs: activity log v2.11.42 2026-06-10 11:25:39 +03:30
moojttaba
cc3df36631 docs: activity log v2.11.41 build fix 2026-06-10 11:25:39 +03:30
moojttaba
d63b3441b6 docs: activity log v2.11.40 DataGrid migration 2026-06-10 11:25:39 +03:30
Siavash Sameni
035dbfeb6a docs: add White-Label Shops PRD (gap analysis + phase plan)
14 feature gaps mapped against existing implementation on
feature/white-label-shops: upgrade/billing, theming, seller dashboard,
storefront, user base, custom email, invoicing, bot admin commands,
multi-admin, monitoring, DB isolation, backup, external payment, CRM.

Phase 0 (schema + services + admin UI) marked complete.
Phases 1–4 planned with estimates and dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:49:14 +04:00
Siavash Sameni
c5fa6516e8 docs: add 2026-06-10 audit and remediation planning documents
- Comprehensive Workspace Audit - 2026-06-10.md
- C1-Secrets-Rotation-Checklist-2026-06-10.md
- Mistral-Outsource-Package-2026-06-10.md
- Workflow-Remediation-Plan-2026-06-10.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:09:29 +04:00
Siavash Sameni
481de7301d docs: sync from backend ce06f47 — main backport to multi-shop branch 2026-06-10 08:26:46 +04:00
moojttaba
3f86929654 WEBAPP_ENABLED 2026-06-09 15:16:23 +03:30
moojttaba
d124a2a60c docs: sync from frontend feffed0 — v2.11.31 webapp gate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:33:56 +03:30
moojttaba
bb3b9c7456 docs: sync from backend 0db041c — v2.11.7 Telegram STDOUT logging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:02:37 +03:30
moojttaba
eee43f632c docs: activity log for backend v2.11.6 + frontend v2.11.29 2026-06-09 10:37:34 +03:30
Siavash Sameni
67244223ec docs: add sub-project service docs + sync vault 2026-06-08
Add 10 - Services/ docs for all sub-projects: backend, frontend, scanner,
deployment (new), update amanat-assist. Update Scanner Architecture,
Telegram Mini App flow, and Activity Log. Add payment safety edge cases.
2026-06-08 16:23:00 +04:00
moojttaba
181e8e9c2f docs: reorganize agent instructions and session logs; remove outdated files 2026-06-08 15:49:53 +03:30
moojttaba
024a41f125 docs: add PRD for mini app & marketplace sprint v2.11
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:01:52 +03:30
moojttaba
29d21204b1 docs: add session log for marketplace fixes and eBay-style counter-offer implementation 2026-06-08 08:45:34 +03:30
Siavash Sameni
727207d949 docs: sync from backend c0e80a7 — security audit closeout 2026-06-07 21:46:14 +04:00
Siavash Sameni
0771b3d163 docs: start security performance logic audit 2026-06-07 21:11:35 +04:00
Siavash Sameni
dabad19b03 docs: record v2.10.4 CI success 2026-06-07 17:15:04 +04:00
Siavash Sameni
5cb9354f9f docs: sync from backend dedc5fe - close remaining audit items 2026-06-07 17:09:56 +04:00
Siavash Sameni
74a7e71969 docs: record v2.10.3 CI success 2026-06-07 16:48:24 +04:00
Siavash Sameni
ffc0d83c60 docs: record H34-H36 audit closure 2026-06-07 16:40:44 +04:00
Siavash Sameni
af9bce3967 docs: record v2.10.1 CI success 2026-06-07 15:49:09 +04:00
Siavash Sameni
a97097020d docs: sync DB audit cleanup to v2.10.1 2026-06-07 15:42:32 +04:00
Siavash Sameni
4879046f44 docs: sync from backend e8cb64c — DB audit bounded high cleanup 2026-06-07 15:40:42 +04:00
Siavash Sameni
c5aecb4130 docs: sync from backend 55321c0 — restore build procedure 2026-06-07 14:26:57 +04:00
moojttaba
d93b48916d add initial task list and project structure for AMN Marketplace 2026-06-07 12:59:13 +03:30
Siavash Sameni
ea038b5ed2 docs: sync from backend 9363d8c — Woodpecker Docker ENOSPC cleanup 2026-06-07 11:06:58 +04:00
Siavash Sameni
f974f88ab9 docs: sync from frontend a433067 — Docker Yarn cache ENOSPC fix 2026-06-07 10:40:41 +04:00
Siavash Sameni
dd45528f58 docs: sync from backend 957c356 — H16-H18 transaction closeout 2026-06-07 10:22:56 +04:00
Siavash Sameni
aac297d241 docs: sync from backend 259f3fb — H19-H21 auth save consolidation 2026-06-07 10:09:14 +04:00
Siavash Sameni
ae10a16481 docs: sync from backend 5d7d2af — H10 sweep balance probe parallelism 2026-06-07 08:54:43 +04:00
moojttaba
7bbb3d6d6b docs: update graph scale value for improved visualization 2026-06-07 07:46:05 +03:30
Siavash Sameni
cc6395f75b docs: sync from backend 8835068 — C2 chat query bounds closeout 2026-06-07 08:15:46 +04:00
Siavash Sameni
3362e2e1b8 docs: sync from backend c3ad979 — medium transaction audit closeout 2026-06-07 08:01:15 +04:00
Siavash Sameni
4ba2a556f7 docs: sync from backend 5364704 — money-flow transaction audit closeout 2026-06-07 07:43:21 +04:00
Siavash Sameni
0b78ad2e74 docs: sync from backend c39b14a — M38 review UUID FKs, M40 decimal.js balances, M41 trezor concurrency 2026-06-07 07:35:34 +04:00
Siavash Sameni
beec0c0a59 docs: sync from backend 4766eba — dispute integrity audit closeout 2026-06-07 07:23:48 +04:00
Siavash Sameni
a49d6ebfc4 docs: sync from backend f5e53cb — M24 DISTINCT ON, M30 blog indexes, M39 notification enums 2026-06-07 07:19:32 +04:00
Siavash Sameni
0bb60dbc98 docs: sync from backend 8fc2309 — M43/M44 missing FKs + H37 dispute enums 2026-06-07 07:16:02 +04:00
Siavash Sameni
a2967ec594 docs: sync from backend 5752f13 — Low-priority DB audit batch L1–L10 2026-06-07 07:10:20 +04:00
Siavash Sameni
311e44aac2 docs: sync from backend b743b5e — dispute relation fks 2026-06-07 06:57:29 +04:00
Siavash Sameni
5352fa1b08 docs: sync from frontend 607587c — docker yarn install 2026-06-07 06:37:57 +04:00
Siavash Sameni
b14c9fb314 docs: sync from backend 38d0e76 — db audit c6 2026-06-07 06:29:45 +04:00
Siavash Sameni
b651753125 docs: sync from backend fcee958 — db audit m16 2026-06-07 06:13:30 +04:00
Siavash Sameni
822cc4e1d5 docs: sync from backend 2c5e80d — db audit waves 5-6 2026-06-07 06:02:49 +04:00
Siavash Sameni
9f246be2b8 docs: sync from backend 51ca048 — db audit wave 4 2026-06-06 21:41:18 +04:00
Siavash Sameni
d8121b549b docs: sync from backend 885745e — db audit wave 3 2026-06-06 21:27:10 +04:00
Siavash Sameni
dd23f013ad docs: sync from backend 3955430 — db audit wave 2 2026-06-06 21:07:39 +04:00
Siavash Sameni
58c613af3a docs: sync from backend 5ff0013 — db audit wave 1 2026-06-06 20:53:50 +04:00
Siavash Sameni
bac1ae3986 docs: sync from backend 0835be9 — db audit marketplace batching 2026-06-06 20:26:15 +04:00
Siavash Sameni
01aad6977a docs: sync from backend 3ad3bbe — db audit chat notification batch 2026-06-06 20:18:22 +04:00
Siavash Sameni
df8eba1233 docs: sync from backend 2a56f98 — db audit batch 2 2026-06-06 20:05:53 +04:00
Siavash Sameni
942e8d60a5 docs: sync from backend 4aa6ccb — auth postgres batching 2026-06-06 19:57:26 +04:00
Siavash Sameni
32e1acc5ef docs: DB query & schema audit 2026-06-06 — 104 findings, 3 fixes landed (v2.9.13)
Full multi-agent audit of all Drizzle repos, service layer, and schema
files. 104 findings across critical/high/medium/low. Three fixes already
committed to backend in 2484150. Top open items: auth 1+3N rowToUser,
chat table-scan, GIN indexes, missing-transaction on money paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 19:41:01 +04:00
Siavash Sameni
79dab07243 docs: sync from backend ca2b1c4 — marketplace e2e smoke runner 2026-06-06 11:48:02 +04:00
Siavash Sameni
bee91dd01f docs: add notification and concurrency test procedures 2026-06-06 10:57:44 +04:00
Siavash Sameni
9267961909 docs: add testing procedures and scenario catalog 2026-06-06 10:35:47 +04:00
Siavash Sameni
b3eea176d5 docs: sync from frontend 2a3e5c9 — BSC Testnet checkout UI 2026-06-06 09:04:33 +04:00
Siavash Sameni
f4fad02e1c docs: sync from backend 99ae2db — delivery confirmation id seam 2026-06-06 08:34:58 +04:00
Siavash Sameni
641334a2e5 docs: sync from backend 3e9a2f2 — BSC Testnet tUSDT rail 2026-06-06 08:20:55 +04:00
Siavash Sameni
cafef04a75 docs: sync from backend 810098f — BSC Testnet scanner rail 2026-06-06 07:37:39 +04:00
moojttaba
e9bb2211b5 Activity Log: backend v2.8.94 — log verification code in SMTP test mode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:01:35 +03:30
moojttaba
8c31e23a94 Activity Log: backend v2.8.93 + frontend v2.8.109 — email change hang fix + labels
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:56:12 +03:30
moojttaba
03f952628a Activity Log: backend v2.8.92 — seed categories via non-vital pool
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:27:07 +03:30
moojttaba
b462623f78 Activity Log: backend v2.8.91 — complete SEC-007 non-vital pool routing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:14:19 +03:30
moojttaba
14d9b7388e Activity Log: backend v2.8.90 — login lockout off by default (env-gated)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:50:09 +03:30
moojttaba
a4ded94ae2 Activity Log: backend v2.8.89 — 10-level gamification ladder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:27:22 +03:30
moojttaba
a1a042ffcd Activity Log: backend v2.8.88 — bot launcher opens Mini App route
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:05:52 +03:30
moojttaba
1e914a4c37 Activity Log: frontend v2.8.104 — onboarding in-shell settings + achievements
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:50:55 +03:30
moojttaba
69d95113f0 Activity Log: frontend v2.8.103 — account row icons
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:40:29 +03:30
moojttaba
d117aa9c18 Activity Log: frontend v2.8.102 — Mini App new-request preferred-sellers picker
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:25:37 +03:30
Siavash Sameni
0a7527ef79 Merge branch 'main' of ssh://git.tbs.amn.gg:2222/escrow/nick-doc
# Conflicts:
#	09 - Audits/Activity Log.md
2026-06-05 07:51:18 +04:00
Siavash Sameni
c98c31dc24 docs: sync documentation with latest codebase state (merged)
- Update Activity Log with 108 missing commits (48 backend + 60 frontend)
- Update version references: backend v2.8.79, frontend v2.8.94
- Update migration count: 18 migrations (0000-0017)
- Update Telegram Mini App Flow to v2.8.94
- Update Payment Flow - Scanner to 2026-06-05
- Update all architectural and database references
- Add MongoDB removal handoff document with updated versions

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-05 07:51:00 +04:00
moojttaba
010bf2be1e Activity Log: backend v2.8.84 + frontend v2.8.101 — notifications wired end-to-end
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:14:48 +03:30
Siavash Sameni
e51236af91 docs: add MongoDB removal handoff document with updated versions
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-05 07:42:22 +04:00
Siavash Sameni
a5d71bcc05 docs: sync documentation with latest codebase state
- Update Activity Log with 108 missing commits (48 backend + 60 frontend)
- Update version references: backend v2.8.79, frontend v2.8.94
- Update migration count: 18 migrations (0000-0017)
- Update Telegram Mini App Flow to v2.8.94
- Update Payment Flow - Scanner to 2026-06-05
- Update all architectural and database references

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-05 07:34:49 +04:00
moojttaba
de3d61b11d Activity Log: backend v2.8.83 + frontend v2.8.100 — select-offer 403 + delivery-time fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:45:59 +03:30
moojttaba
25abdbffe4 Activity Log: frontend v2.8.99 — Mini App buyer offers list + select & pay
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:21:55 +03:30
moojttaba
e848be0a90 Activity Log: frontend v2.8.98 — seller shop product-type filter + small-shop controls
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:02:43 +03:30
moojttaba
5998acabdd Activity Log: backend v2.8.82 + frontend v2.8.97 — stepper advance fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:50:17 +03:30
moojttaba
e83942413c Activity Log: frontend v2.8.96 — no false email-verified badge for no-email users
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:22:47 +03:30
moojttaba
209c6f03da Activity Log: frontend v2.8.95 — seller-entered delivery code, buyer confirm removed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:15:52 +03:30
moojttaba
39e2cd18e2 Activity Log: backend v2.8.81 — public shop settings uuid->legacy resolve fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:47:55 +03:30
moojttaba
3982a167ac Activity Log: backend v2.8.80 — shop sellers list cache invalidation fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:23:04 +03:30
moojttaba
9d36e8dc88 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	09 - Audits/Activity Log.md
2026-06-04 20:41:00 +03:30
moojttaba
02d933d737 Activity Log: frontend v2.8.94 — seller delivery-code entry field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:54:59 +03:30
moojttaba
b509d8403d Activity Log: backend v2.8.79 — delivery code sameUser buyer auth fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:45:07 +03:30
moojttaba
cd8991e821 Activity Log: backend v2.8.78 + frontend v2.8.93 — gate shop orders on payment
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:33:34 +03:30
moojttaba
5523bf7774 Activity Log: frontend v2.8.92 — buyer receive-goods/confirm + stepper fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:21:56 +03:30
moojttaba
485929509c docs: activity log — backend v2.8.77 (seller delivery 403 / uuid-ObjectId seam)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:01:11 +03:30
moojttaba
824663bcaa docs: activity log — frontend v2.8.91 (seller stepper paid→ship)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:11:08 +03:30
moojttaba
98fa717f7d docs: activity log — frontend v2.8.90 (checkout stepper + paid-step advance)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:47:05 +03:30
moojttaba
eafa3941b4 docs: activity log — frontend v2.8.89 (checkout → payment step, shop model)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:43:57 +03:30
moojttaba
8f865c97a7 docs: activity log — frontend v2.8.88 (in-shell direct-transfer payment)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:54:04 +03:30
moojttaba
fef46f2f96 docs: activity log — archive boolean-filter fix (be 2.8.76) + archived search (fe 2.8.87)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:20:05 +03:30
moojttaba
59670f7c74 docs: activity log — chat search/archive (fe 2.8.85/86, be 2.8.75)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:40:08 +03:30
moojttaba
ec6e9022dd docs: activity log — frontend v2.8.84 (chat archive) + deploy branch now main
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:15:47 +03:30
moojttaba
524dd52cf5 docs: activity log — frontend v2.8.83 (pay CTA + stepper line fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 02:07:34 +03:30
moojttaba
c3a4fdf204 docs: activity log — frontend v2.8.82 (digital checkout no-address + pending_payment stepper)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:32:55 +03:30
moojttaba
5b43a9b783 docs: activity log — frontend v2.8.81 (in-shell checkout)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:09:33 +03:30
moojttaba
37c7159801 docs: activity log — backend v2.8.74 (chat uuid→session ObjectId participant fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:40:16 +03:30
moojttaba
8253ec0659 docs: activity log — chat participant fix revert (v2.8.73) + template validation (frontend v2.8.80)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:23:24 +03:30
moojttaba
43ae7f39e1 docs: activity log — frontend v2.8.79 (template maxUsage optional)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:40:04 +03:30
moojttaba
5ec1b6d55b docs: activity log — backend v2.8.71 (template-creation 500 / CHECK constraints)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:14:06 +03:30
moojttaba
18c280cbb0 docs: activity log — backend v2.8.70 (persist telegram user updates on sign-in)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:43:04 +03:30
moojttaba
9823194125 docs: activity log — backend v2.8.69 + frontend v2.8.78 (chat system msgs + review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:23:03 +03:30
moojttaba
afc0c81c2d docs: activity log — frontend v2.8.77 (email verify panel stays mounted)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:52:50 +03:30
moojttaba
7b99c66348 docs: activity log — frontend v2.8.76 (email send-code reveals verify panel)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:46:16 +03:30
moojttaba
f35ab6cbe7 docs: activity log — frontend v2.8.75 (self-contained email-change code entry)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:34:30 +03:30
Siavash Sameni
9dcdb420fc docs: sync from backend 22ae0bd — scanner balance watches 2026-06-03 21:23:50 +04:00
moojttaba
d2cfe63132 docs: activity log — frontend v2.8.74 (chat alignment + read-only email)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:28:07 +03:30
moojttaba
c2b6adcf64 docs: activity log — backend v2.8.68 (pending-email + referral bonus) + frontend v2.8.73
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:07:07 +03:30
moojttaba
d962807c4f docs: activity log — frontend v2.8.72 (avatar/email/web-link fixes + search/sort)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:27:58 +03:30
moojttaba
d6628db325 docs: activity log — frontend v2.8.71 (solar icons, avatar upload, achievements, theme-toggle fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:05:36 +03:30
moojttaba
17755b97e0 docs: activity log — frontend v2.8.70 (in-shell settings/addresses, central theme + dark mode)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:45:58 +03:30
moojttaba
22b1ad4f2b docs: activity log — backend v2.8.67 (auto-seed flag), frontend v2.8.69 (web-parity shop UI)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:42:52 +03:30
moojttaba
128945f0e9 docs: sync — template cards redesign + detail view (frontend v2.8.68) 2026-06-03 14:01:47 +03:30
moojttaba
c39e89b266 docs: sync — PG seeds, mock shops, template-creation fix (backend v2.8.65-66) 2026-06-03 13:38:34 +03:30
moojttaba
32a033869b docs: sync — backend v2.8.64-65 ratings + chat names 2026-06-03 13:17:29 +03:30
moojttaba
773e2e23c4 docs: sync — v2.8.66-67 + review system audit 2026-06-03 12:44:27 +03:30
moojttaba
a28bcff4f4 docs: sync — v2.8.65 + Mini App UX queue 2026-06-03 11:55:10 +03:30
moojttaba
f68b54a9ef docs: sync from backend 91877ae — points legacy-id fix (v2.8.63) 2026-06-03 11:41:21 +03:30
moojttaba
23c4a717ba docs: sync — Mini App points + email verify (v2.8.64) 2026-06-03 11:18:48 +03:30
moojttaba
c61028f880 docs: sync — compact Mini App FABs (v2.8.63) 2026-06-03 10:57:27 +03:30
moojttaba
c663c657e2 docs: sync from backend c5d6490 — points level fixes (v2.8.62) 2026-06-03 10:54:04 +03:30
moojttaba
3cac5bd45e docs: sync — Mini App support bubble (v2.8.61) 2026-06-03 10:35:17 +03:30
moojttaba
7b727cec53 docs: sync — Mini App cart FAB (v2.8.60) 2026-06-03 10:18:53 +03:30
Siavash Sameni
4b1d8ea36d docs: Telegram Mini App pass 2 — shop/cart/account parity + frontend arch (v2.8.59)
- 04 - Flows/Telegram Mini App.md: major expansion — TelegramSellerShopView,
  TelegramCartView, TelegramAccountView, useTelegramCart/useTelegramShops hooks,
  full nav model, SDK surface table, shop→cart→checkout handoff flow
- 01 - Architecture/Frontend Architecture.md: add Telegram Mini App section,
  TON Connect dependency, update to v2.8.59
- 09 - Audits/Activity Log.md: new entry for frontend@9bafbbb (v2.8.57–2.8.59)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:41:01 +04:00
Siavash Sameni
d072238fe8 docs: update PG migration status, data models, architecture + add Telegram Mini App flow (v2.8.59)
- Postgres Runtime Cutover Status: 17 migrations (0000–0017), dual-write repo matrix
- Backend Architecture: dual-DB architecture, repo factory, MONGO_CONNECT_MODE modes
- Data Model Overview: 23-model index with PG table names and migration status
- User, PurchaseRequest, SellerOffer, Chat, Dispute: Drizzle schema + cutover status added
- 04 - Flows/Telegram Mini App.md: new doc covering Mini App architecture and flows
- mongo-to-pg-migration-prd.md: status block prepended with 2026-06-03 milestone tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:30:51 +04:00
Siavash Sameni
6f13903644 docs: sync from backend 7c4dedf — complete dual-write repos, migrations pipeline, TTL scheduler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:30:51 +04:00
moojttaba
a283f0ef21 docs: sync — Mini App in-shell cart, buyer-parity phase 1 (v2.8.59) 2026-06-03 09:54:56 +03:30
moojttaba
27da7e18e6 docs: sync — Mini App account parity (v2.8.58) 2026-06-03 09:25:10 +03:30
moojttaba
2c27a7e58d docs: sync from frontend a8ae1e3 — in-shell Mini App shop (v2.8.57) 2026-06-03 09:12:58 +03:30
moojttaba
49e7d614ce docs: sync from backend 14d164c / frontend 6adb2e0 — Mini App account, support chat, shop fix (v2.8.56) 2026-06-03 08:42:57 +03:30
moojttaba
af7459e4dd docs: sync from backend 9424395 / frontend a18e870 — chat, notifications, role dashboards (v2.8.55) 2026-06-03 08:04:38 +03:30
moojttaba
8e71f629d4 docs: sync from backend 8b8c1ae / frontend 583d55a — guard role + Mini App tab fix (v2.8.54) 2026-06-03 02:03:45 +03:30
moojttaba
bbb16fb2a6 docs: sync from frontend 7b949bf — Mini App live socket updates (v2.8.53) 2026-06-03 01:39:41 +03:30
moojttaba
4d8aea38fd docs: sync from backend 804bb99 — PG serialization & id resolution fixes (v2.8.52) 2026-06-03 01:18:37 +03:30
moojttaba
92d3307f55 docs: sync from backend 14c231e+378f8f6 — admin user management fixes (v2.8.50–51) 2026-06-03 00:29:23 +03:30
Siavash Sameni
476aac2b08 docs: sync from backend 515bea3 — guard dataCleanupService against MONGO_CONNECT_MODE=never
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:47:01 +04:00
Siavash Sameni
4196c119ea docs: sync from backend 4949988 — route admin user counts through postgres-capable stores
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:20:23 +04:00
Siavash Sameni
345c58542e docs: sync from backend cf59726 — normalize pg repo modes 2026-06-02 12:41:54 +04:00
Siavash Sameni
85fe50aca0 docs: sync from backend 882096f — notification pg dev cutover 2026-06-02 12:33:58 +04:00
Siavash Sameni
bf82e7d628 docs: sync from backend f1ba14b — notification pg backfill tooling 2026-06-02 10:44:18 +04:00
Siavash Sameni
c90f286b12 docs: sync from backend 10de752 — defer legacy mongo imports 2026-06-02 10:30:06 +04:00
Siavash Sameni
1a59dacf87 docs: sync from backend 134d155 — lazy-load pg-capable store fallbacks 2026-06-02 10:21:43 +04:00
Siavash Sameni
1d983c8bfa docs: sync from backend 2c5c3c7 — pg ledger repo seam 2026-06-01 22:38:33 +04:00
Siavash Sameni
e908cfce63 docs: sync from deployment 38cb75b — pg store defaults 2026-06-01 21:40:42 +04:00
Siavash Sameni
8a90bb69be docs: sync from backend c5db471 — request templates 2026-06-01 19:02:03 +04:00
Siavash Sameni
02641e1333 docs: sync from backend 1543b53 — category uniqueness 2026-06-01 17:22:53 +04:00
Siavash Sameni
78707c11a7 docs: sync from backend 6df113d — marketplace pg backfill 2026-06-01 14:53:35 +04:00
Siavash Sameni
5352a78e96 docs: record postgres health store modes 2026-06-01 14:00:16 +04:00
Siavash Sameni
7b5dbb2683 docs: sync from backend 1757f1e - postgres cutover stores 2026-06-01 11:54:56 +04:00
Siavash Sameni
e8a1bba471 docs: sync from backend 8e03360 — auth health hotfix 2026-05-31 16:28:09 +04:00
Siavash Sameni
35640e38cc docs: sync from backend cbc32dc — template delivery rails 2026-05-31 15:52:30 +04:00
Siavash Sameni
9f8cc104c7 docs: sync from backend a4d72df - cap confirmation floors 2026-05-31 15:21:28 +04:00
Siavash Sameni
798fa2f48e docs: sync from backend 896f17f - persist webhook confirmations 2026-05-31 15:08:50 +04:00
Siavash Sameni
0bd3fe5598 docs: sync from backend cab0719 - align request budget validation 2026-05-31 14:46:59 +04:00
Siavash Sameni
773f5db454 docs: sync from backend 3a50dc4 - promote postgres integration 2026-05-31 14:20:40 +04:00
moojttaba
622dbe4dcb Merge branch 'main' of ssh://git.manko.yoga:222/nick/nick-doc 2026-05-31 07:50:51 +03:30
Siavash Sameni
dceaf82934 audit: 2026-05-30 full-codebase audit — report, issues, docs, runbooks
Full-codebase-audit 2026-05-30 outputs:
- Audit report: 09 - Audits/Full Codebase Audit - 2026-05-30.md
- 81 issue files ISSUE-055..135 (decisions + 1 skipped no-brainer).
- Scanner docs from scratch (was zero): architecture, data model, API ref, payment
  flow, operations runbook + repo README.
- Doc-sync updates across API reference, data models, flows, design system.
- Secret Rotation Runbook (08 - Operations) for the exposed credentials.
- Reusable workflow guide (07 - Development) + .claude/workflows/full-codebase-audit.js.

Issues remain status:open intentionally — the code fixes are uncommitted-then-committed
working-tree changes per repo and aren't "resolved" until merged/deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:48:04 +04:00
Siavash Sameni
eab1d77582 docs(issues): mark ISSUE-003 through ISSUE-006 resolved, update index
Index: 47 open (8 critical, 39 major), 6 resolved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:48:04 +04:00
Siavash Sameni
12348ebb80 docs(issues): mark ISSUE-001 and ISSUE-002 resolved, update index
Both dispute privilege-escalation issues fixed in backend disputeRoutes.ts.
Index updated: 51 open (12 critical), 2 resolved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:48:04 +04:00
moojttaba
90c2588b9b vault backup: 2026-05-28 09:31:01 2026-05-28 09:31:01 +03:30
moojttaba
061e797544 Merge remote-tracking branch 'origin/main' 2026-05-27 20:06:42 +03:30
moojttaba
3e063cbf88 Merge remote-tracking branch 'origin/main' 2026-05-25 00:23:05 +03:30
moojttaba
1925469311 Merge remote-tracking branch 'origin/main' 2026-05-24 08:41:43 +03:30
moojttaba
7b011270d6 Merge remote-tracking branch 'origin/main' 2026-05-24 08:17:24 +03:30
moojttaba
41cb3604df Merge remote-tracking branch 'origin/main' 2026-05-24 08:16:28 +03:30
moojttaba
389ee2453c version 0 2026-05-23 22:13:43 +03:30
moojttaba
adba5c95c9 vault backup: 2026-05-23 22:10:30 2026-05-23 22:10:30 +03:30
moojttaba
c8cee4a733 docs: git sync test line in README
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:08:55 +03:30
264 changed files with 34152 additions and 934 deletions

3
.obsidian/app.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"promptDelete": false
}

1
.obsidian/appearance.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

3
.obsidian/community-plugins.json vendored Normal file
View File

@@ -0,0 +1,3 @@
[
"obsidian-git"
]

33
.obsidian/core-plugins.json vendored Normal file
View File

@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}

22
.obsidian/graph.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
"collapse-filter": true,
"search": "",
"showTags": false,
"showAttachments": false,
"hideUnresolved": false,
"showOrphans": true,
"collapse-color-groups": true,
"colorGroups": [],
"collapse-display": true,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1,
"lineSizeMultiplier": 1,
"collapse-forces": true,
"centerStrength": 0.518713248970312,
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 250,
"scale": 0.19993564150556878,
"close": true
}

View File

@@ -0,0 +1,65 @@
{
"commitMessage": "vault backup: {{date}}",
"autoCommitMessage": "vault backup: {{date}}",
"commitMessageScript": "",
"commitDateFormat": "YYYY-MM-DD HH:mm:ss",
"autoSaveInterval": 0,
"autoPushInterval": 0,
"autoPullInterval": 0,
"autoPullOnBoot": true,
"autoCommitOnlyStaged": false,
"disablePush": false,
"pullBeforePush": true,
"disablePopups": false,
"showErrorNotices": true,
"disablePopupsForNoChanges": false,
"listChangedFilesInMessageBody": false,
"showStatusBar": true,
"updateSubmodules": false,
"syncMethod": "merge",
"mergeStrategy": "none",
"customMessageOnAutoBackup": false,
"autoBackupAfterFileChange": false,
"treeStructure": false,
"refreshSourceControl": true,
"basePath": "",
"differentIntervalCommitAndPush": false,
"changedFilesInStatusBar": false,
"showedMobileNotice": false,
"refreshSourceControlTimer": 7000,
"showBranchStatusBar": true,
"setLastSaveToLastCommit": false,
"submoduleRecurseCheckout": false,
"gitDir": "",
"showFileMenu": true,
"authorInHistoryView": "hide",
"dateInHistoryView": false,
"diffStyle": "split",
"hunks": {
"showSigns": false,
"hunkCommands": false,
"statusBar": "disabled"
},
"lineAuthor": {
"show": false,
"followMovement": "inactive",
"authorDisplay": "initials",
"showCommitHash": false,
"dateTimeFormatOptions": "date",
"dateTimeFormatCustomString": "YYYY-MM-DD HH:mm",
"dateTimeTimezone": "viewer-local",
"coloringMaxAge": "1y",
"colorNew": {
"r": 255,
"g": 150,
"b": 150
},
"colorOld": {
"r": 120,
"g": 160,
"b": 255
},
"textColorCss": "var(--text-muted)",
"ignoreWhitespace": false
}
}

413
.obsidian/plugins/obsidian-git/main.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
{
"author": "Vinzent",
"authorUrl": "https://github.com/Vinzent03",
"id": "obsidian-git",
"name": "Git",
"description": "Integrate Git version control with automatic backup and other advanced features.",
"isDesktopOnly": false,
"fundingUrl": "https://ko-fi.com/vinzent",
"version": "2.38.3"
}

View File

@@ -0,0 +1,705 @@
@keyframes loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.workspace-leaf-content[data-type="git-view"] .button-border {
border: 2px solid var(--interactive-accent);
border-radius: var(--radius-s);
}
.workspace-leaf-content[data-type="git-view"] .view-content {
padding-left: 0;
padding-top: 0;
padding-right: 0;
}
.workspace-leaf-content[data-type="git-history-view"] .view-content {
padding-left: 0;
padding-top: 0;
padding-right: 0;
}
.loading {
overflow: hidden;
}
.loading > svg {
animation: 2s linear infinite loading;
transform-origin: 50% 50%;
display: inline-block;
}
.obsidian-git-center {
margin: auto;
text-align: center;
width: 50%;
}
.obsidian-git-textarea {
display: block;
margin-left: auto;
margin-right: auto;
}
.obsidian-git-disabled {
opacity: 0.5;
}
.obsidian-git-center-button {
display: block;
margin: 20px auto;
}
.tooltip.mod-left {
overflow-wrap: break-word;
}
.tooltip.mod-right {
overflow-wrap: break-word;
}
/* Limits the scrollbar to the view body */
.git-view {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
}
/* Re-enable wrapping of nav buttns to prevent overflow on smaller screens #*/
.workspace-drawer .git-view .nav-buttons-container {
flex-wrap: wrap;
}
.git-tools {
display: flex;
margin-left: auto;
}
.git-tools .type {
padding-left: var(--size-2-1);
display: flex;
align-items: center;
justify-content: center;
width: 11px;
}
.git-tools .type[data-type="M"] {
color: orange;
}
.git-tools .type[data-type="D"] {
color: red;
}
.git-tools .buttons {
display: flex;
}
.git-tools .buttons > * {
padding: 0;
height: auto;
}
.workspace-leaf-content[data-type="git-view"] .tree-item-self,
.workspace-leaf-content[data-type="git-history-view"] .tree-item-self {
align-items: center;
}
.workspace-leaf-content[data-type="git-view"]
.tree-item-self:hover
.clickable-icon,
.workspace-leaf-content[data-type="git-history-view"]
.tree-item-self:hover
.clickable-icon {
color: var(--icon-color-hover);
}
/* Highlight an item as active if it's diff is currently opened */
.is-active .git-tools .buttons > * {
color: var(--nav-item-color-active);
}
.git-author {
color: var(--text-accent);
}
.git-date {
color: var(--text-accent);
}
.git-ref {
color: var(--text-accent);
}
/* ====== diff2html ======
The following styles are adapted from the obsidian-version-history plugin by
@kometenstaub https://github.com/kometenstaub/obsidian-version-history-diff/blob/main/src/styles.scss
which itself is adapted from the diff2html library with the following original license:
https://github.com/rtfpessoa/diff2html/blob/master/LICENSE.md
Copyright 2014-2016 Rodrigo Fernandes https://rtfpessoa.github.io/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.theme-dark,
.theme-light {
--git-delete-bg: #ff475040;
--git-delete-hl: #96050a75;
--git-insert-bg: #68d36840;
--git-insert-hl: #23c02350;
--git-change-bg: #ffd55840;
--git-selected: #3572b0;
--git-delete: #cc3333;
--git-insert: #399839;
--git-change: #d0b44c;
--git-move: #3572b0;
}
.git-diff {
.d2h-d-none {
display: none;
}
.d2h-wrapper {
text-align: left;
border-radius: 0.25em;
overflow: auto;
}
.d2h-file-header.d2h-file-header {
background-color: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
font-family:
Source Sans Pro,
Helvetica Neue,
Helvetica,
Arial,
sans-serif;
height: 35px;
padding: 5px 10px;
}
.d2h-file-header,
.d2h-file-stats {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
}
.d2h-file-header {
display: none;
}
.d2h-file-stats {
font-size: 14px;
margin-left: auto;
}
.d2h-lines-added {
border: 1px solid var(--color-green);
border-radius: 5px 0 0 5px;
color: var(--color-green);
padding: 2px;
text-align: right;
vertical-align: middle;
}
.d2h-lines-deleted {
border: 1px solid var(--color-red);
border-radius: 0 5px 5px 0;
color: var(--color-red);
margin-left: 1px;
padding: 2px;
text-align: left;
vertical-align: middle;
}
.d2h-file-name-wrapper {
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-size: 15px;
width: 100%;
}
.d2h-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-normal);
font-size: var(--h5-size);
}
.d2h-file-wrapper {
border: 1px solid var(--background-secondary-alt);
border-radius: 3px;
margin-bottom: 1em;
max-height: 100%;
}
.d2h-file-collapse {
-webkit-box-pack: end;
-ms-flex-pack: end;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
border: 1px solid var(--background-secondary-alt);
border-radius: 3px;
cursor: pointer;
display: none;
font-size: 12px;
justify-content: flex-end;
padding: 4px 8px;
}
.d2h-file-collapse.d2h-selected {
background-color: var(--git-selected);
}
.d2h-file-collapse-input {
margin: 0 4px 0 0;
}
.d2h-diff-table {
border-collapse: collapse;
font-family: var(--font-monospace);
font-size: var(--code-size);
width: 100%;
}
.d2h-files-diff {
width: 100%;
}
.d2h-file-diff {
/*
overflow-y: scroll;
*/
border-radius: 5px;
font-size: var(--font-text-size);
line-height: var(--line-height-normal);
}
.d2h-file-side-diff {
display: inline-block;
margin-bottom: -8px;
margin-right: -4px;
overflow-x: scroll;
overflow-y: hidden;
width: 50%;
}
.d2h-code-line {
padding-left: 6em;
padding-right: 1.5em;
}
.d2h-code-line,
.d2h-code-side-line {
display: inline-block;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
white-space: nowrap;
width: 100%;
}
.d2h-code-side-line {
/* needed to be changed */
padding-left: 0.5em;
padding-right: 0.5em;
}
.d2h-code-line-ctn {
word-wrap: normal;
background: none;
display: inline-block;
padding: 0;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
vertical-align: middle;
width: 100%;
/* only works for line-by-line */
white-space: pre-wrap;
}
.d2h-code-line del,
.d2h-code-side-line del {
background-color: var(--git-delete-hl);
color: var(--text-normal);
}
.d2h-code-line del,
.d2h-code-line ins,
.d2h-code-side-line del,
.d2h-code-side-line ins {
border-radius: 0.2em;
display: inline-block;
margin-top: -1px;
text-decoration: none;
vertical-align: middle;
}
.d2h-code-line ins,
.d2h-code-side-line ins {
background-color: var(--git-insert-hl);
text-align: left;
}
.d2h-code-line-prefix {
word-wrap: normal;
background: none;
display: inline;
padding: 0;
white-space: pre;
}
.line-num1 {
float: left;
}
.line-num1,
.line-num2 {
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
/*
padding: 0 0.5em;
*/
text-overflow: ellipsis;
width: 2.5em;
padding-left: 0;
}
.line-num2 {
float: right;
}
.d2h-code-linenumber {
background-color: var(--background-primary);
border: solid var(--background-modifier-border);
border-width: 0 1px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: var(--text-faint);
cursor: pointer;
display: inline-block;
position: absolute;
text-align: right;
width: 5.5em;
}
.d2h-code-linenumber:after {
content: "\200b";
}
.d2h-code-side-linenumber {
background-color: var(--background-primary);
border: solid var(--background-modifier-border);
border-width: 0 1px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: var(--text-faint);
cursor: pointer;
overflow: hidden;
padding: 0 0.5em;
text-align: right;
text-overflow: ellipsis;
width: 4em;
/* needed to be changed */
display: table-cell;
position: relative;
}
.d2h-code-side-linenumber:after {
content: "\200b";
}
.d2h-code-side-emptyplaceholder,
.d2h-emptyplaceholder {
background-color: var(--background-primary);
border-color: var(--background-modifier-border);
}
.d2h-code-line-prefix,
.d2h-code-linenumber,
.d2h-code-side-linenumber,
.d2h-emptyplaceholder {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.d2h-code-linenumber,
.d2h-code-side-linenumber {
direction: rtl;
}
.d2h-del {
background-color: var(--git-delete-bg);
border-color: var(--git-delete-hl);
}
.d2h-ins {
background-color: var(--git-insert-bg);
border-color: var(--git-insert-hl);
}
.d2h-info {
background-color: var(--background-primary);
border-color: var(--background-modifier-border);
color: var(--text-faint);
}
.d2h-del,
.d2h-ins,
.d2h-file-diff .d2h-change {
color: var(--text-normal);
}
.d2h-file-diff .d2h-del.d2h-change {
background-color: var(--git-change-bg);
}
.d2h-file-diff .d2h-ins.d2h-change {
background-color: var(--git-insert-bg);
}
.d2h-file-list-wrapper {
a {
text-decoration: none;
cursor: default;
-webkit-user-drag: none;
}
svg {
display: none;
}
}
.d2h-file-list-header {
text-align: left;
}
.d2h-file-list-title {
display: none;
}
.d2h-file-list-line {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
text-align: left;
}
.d2h-file-list {
}
.d2h-file-list > li {
border-bottom: 1px solid var(--background-modifier-border);
margin: 0;
padding: 5px 10px;
}
.d2h-file-list > li:last-child {
border-bottom: none;
}
.d2h-file-switch {
cursor: pointer;
display: none;
font-size: 10px;
}
.d2h-icon {
fill: currentColor;
margin-right: 10px;
vertical-align: middle;
}
.d2h-deleted {
color: var(--git-delete);
}
.d2h-added {
color: var(--git-insert);
}
.d2h-changed {
color: var(--git-change);
}
.d2h-moved {
color: var(--git-move);
}
.d2h-tag {
background-color: var(--background-secondary);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-size: 10px;
margin-left: 5px;
padding: 0 2px;
}
.d2h-deleted-tag {
border: 1px solid var(--git-delete);
}
.d2h-added-tag {
border: 1px solid var(--git-insert);
}
.d2h-changed-tag {
border: 1px solid var(--git-change);
}
.d2h-moved-tag {
border: 1px solid var(--git-move);
}
/* needed for line-by-line*/
.d2h-diff-tbody {
position: relative;
}
/* My additions */
.cm-merge-revert {
width: 4em;
}
/* Ensure that merge revert markers are positioned correctly */
.cm-merge-revert > * {
position: absolute;
background-color: var(--background-secondary);
display: flex;
}
}
/* ====================== Line Authoring Information ====================== */
.cm-gutterElement.obs-git-blame-gutter {
/* Add background color to spacing inbetween and around the gutter for better aesthetics */
border-width: 0px 2px 0.2px;
border-style: solid;
border-color: var(--background-secondary);
background-color: var(--background-secondary);
}
.cm-gutterElement.obs-git-blame-gutter > div,
.line-author-settings-preview {
/* delegate text color to settings */
color: var(--obs-git-gutter-text);
font-family: monospace;
height: 100%; /* ensure, that age-based background color occupies entire parent */
text-align: right;
padding: 0px 6px;
white-space: pre; /* Keep spaces and do not collapse them. */
}
@media (max-width: 800px) {
/* hide git blame gutter not to superpose text */
.cm-gutterElement.obs-git-blame-gutter {
display: none;
}
}
.git-unified-diff-view,
.git-split-diff-view .cm-deletedLine .cm-changedText {
background-color: #ee443330;
}
.git-unified-diff-view,
.git-split-diff-view .cm-insertedLine .cm-changedText {
background-color: #22bb2230;
}
.git-obscure-prompt[git-is-obscured="true"] #git-show-password:after {
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-eye"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path><circle cx="12" cy="12" r="3"></circle></svg>');
}
.git-obscure-prompt[git-is-obscured="false"] #git-show-password:after {
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-eye-off"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"></path><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"></path><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"></path><path d="m2 2 20 20"></path></svg>');
}
/* Override styling of Codemirror merge view "collapsed lines" indicator */
.git-split-diff-view .ͼ2 .cm-collapsedLines {
background: var(--interactive-normal);
border-radius: var(--radius-m);
color: var(--text-accent);
font-size: var(--font-small);
padding: var(--size-4-1) var(--size-4-1);
}
.git-split-diff-view .ͼ2 .cm-collapsedLines:hover {
background: var(--interactive-hover);
color: var(--text-accent-hover);
}
.git-signs-gutter {
.cm-gutterElement {
display: grid;
/* Needed to align the sign properly for different line heigts. Such as
* when having a heading or list item.
*/
padding-top: 0 !important;
}
}
.git-gutter-marker:hover {
border-radius: 2px;
}
.git-gutter-marker.git-add {
background-color: var(--color-green);
justify-self: center;
height: inherit;
width: 0.2rem;
}
.git-gutter-marker.git-change {
background-color: var(--color-yellow);
justify-self: center;
height: inherit;
width: 0.2rem;
}
.git-gutter-marker.git-changedelete {
color: var(--color-yellow);
font-weight: var(--font-bold);
font-size: 1rem;
justify-self: center;
height: inherit;
}
.git-gutter-marker.git-delete {
background-color: var(--color-red);
height: 0.2rem;
width: 0.8rem;
align-self: end;
}
.git-gutter-marker.git-topdelete {
background-color: var(--color-red);
height: 0.2rem;
width: 0.8rem;
align-self: start;
}
div:hover > .git-gutter-marker.git-change {
width: 0.6rem;
}
div:hover > .git-gutter-marker.git-add {
width: 0.6rem;
}
div:hover > .git-gutter-marker.git-delete {
height: 0.6rem;
}
div:hover > .git-gutter-marker.git-topdelete {
height: 0.6rem;
}
div:hover > .git-gutter-marker.git-changedelete {
font-weight: var(--font-bold);
}
.git-gutter-marker.staged {
opacity: 0.5;
}
/* Prevent shifting of the editor when git signs gutter is the only gutter present */
.cm-gutters.cm-gutters-before:has(> .git-signs-gutter:only-child) {
margin-inline-end: 0;
.git-signs-gutter {
margin-inline-start: -1rem;
}
}
.git-changes-status-bar-colored {
.git-add {
color: var(--color-green);
}
.git-change {
color: var(--color-yellow);
}
.git-delete {
color: var(--color-red);
}
}
.git-changes-status-bar .git-add {
margin-right: 0.3em;
}
.git-changes-status-bar .git-change {
margin-right: 0.3em;
}

View File

@@ -631,7 +631,7 @@
{
"id": 10,
"title": "Implement Telegram as first-class authentication provider",
"description": "Add a POST /auth/telegram endpoint and frontend login flow so users can authenticate with Amanat using only their Telegram identity \u2014 no email or password required.",
"description": "Add a POST /auth/telegram endpoint and frontend login flow so users can authenticate with Amanat using only their Telegram identity no email or password required.",
"details": "Source PRD: .taskmaster/docs/prd-telegram-phone-auth.md. Backend: create POST /auth/telegram that accepts Mini App initData or Telegram Login Widget payload, verifies the signature (reuse verifyMiniAppInitData; add verifyTelegramLoginWidget for the widget path), looks up TelegramLink by telegramUserId, and either authenticates the linked user or auto-provisions a new Amanat account (authProvider: telegram, telegramVerified: true, nullable email via sparse unique index). Returns JWT + refreshToken + isNewUser flag. Apply existing replay protection and rate limits. User model: make email nullable (sparse index), add authProvider and telegramVerified fields. Frontend: auto-detect Telegram Mini App context and skip login page; POST initData to /auth/telegram; show lightweight onboarding overlay for new users (optional email, language, currency). Add 'Continue with Telegram' button on web login page alongside Google OAuth. Security: blocked Telegram accounts return 403 regardless of re-linking attempts; high-risk action step-up policy is unchanged; never expose raw phone number.",
"status": "done",
"dependencies": [
@@ -650,7 +650,7 @@
"id": "6",
"title": "Request Network in-house checkout (Rabby-supporting)",
"description": "Replace the redirect to pay.request.network with an Amanat-rendered checkout page that submits the same on-chain calls as RN's hosted UI, so RN's webhook fires unchanged but buyers stay on amn.gg and Rabby works.",
"details": "See PRD: nick-doc/.taskmaster/docs/prd-request-network-in-house-checkout.md (summary at nick-doc/PRD - Request Network In-House Checkout.md). Status: draft, pending review with second developer. Approach: replicate the two on-chain calls (approve + RN_FEE_PROXY.transferFromWithReferenceAndFee) using wagmi v2 with existing injected()/metaMask() connectors (Rabby works via EIP-6963). Hard-known: proxy 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9, selector 0xc219a14d, paymentRef = last8Bytes(keccak256(requestId+salt+dest)), feeAmount=0, feeAddress=0x...dEaD. Backend: extend POST /payment/request-network/intents response with inHouseCheckout object (destination, tokenAddress, decimals, chainId, proxyAddress, paymentReference, feeAmount, feeAddress, amountWei). Frontend: new page /checkout/request-network/:paymentId with state machine reusing manual-payment.tsx layout chrome, hosted-page link kept as escape hatch. Implementation gated on a $0.50 cold probe on dev BSC to confirm RN's webhook fires for an externally-built tx. Out of scope: per-seller multi-chain config (\u00a72), ephemeral wallets (\u00a73), full RN removal (\u00a74), gasless. Open questions in PRD \u00a710.",
"details": "See PRD: nick-doc/.taskmaster/docs/prd-request-network-in-house-checkout.md (summary at nick-doc/PRD - Request Network In-House Checkout.md). Status: draft, pending review with second developer. Approach: replicate the two on-chain calls (approve + RN_FEE_PROXY.transferFromWithReferenceAndFee) using wagmi v2 with existing injected()/metaMask() connectors (Rabby works via EIP-6963). Hard-known: proxy 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9, selector 0xc219a14d, paymentRef = last8Bytes(keccak256(requestId+salt+dest)), feeAmount=0, feeAddress=0x...dEaD. Backend: extend POST /payment/request-network/intents response with inHouseCheckout object (destination, tokenAddress, decimals, chainId, proxyAddress, paymentReference, feeAmount, feeAddress, amountWei). Frontend: new page /checkout/request-network/:paymentId with state machine reusing manual-payment.tsx layout chrome, hosted-page link kept as escape hatch. Implementation gated on a $0.50 cold probe on dev BSC to confirm RN's webhook fires for an externally-built tx. Out of scope: per-seller multi-chain config (§2), ephemeral wallets (§3), full RN removal (§4), gasless. Open questions in PRD §10.",
"testStrategy": "",
"status": "done",
"dependencies": [],
@@ -674,7 +674,7 @@
"id": "7",
"title": "Per-(buyer, sellerOffer) ephemeral RN destination wallets",
"description": "Replace the single shared Amanat destination wallet with a per-(buyerId, sellerOfferId) HD-derived address sent to Request Network on intent creation, plus sweep-on-approval and an admin UI.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md \u00a71. Files: new backend/src/services/payment/wallets/derivedDestinations.ts (getDestinationFor(buyerId, sellerOfferId) \u2192 {address, derivationPath, chainId}); Payment schema add metadata.derivedDestination; requestNetworkPayInService.ts override destinationId before POST /v2/secure-payments (we confirmed RN accepts different destinations per intent); new sweep cron + admin manual-trigger endpoint gated on Transaction Safety Provider; admin UI at /dashboard/admin/derived-destinations with address, balance, last sweep tx (BscScan link), ownership status. Open questions to settle first: HD vs disposable EOAs vs smart-forwarder (recommended HD); sweep cadence (recommended immediate); granularity (recommended per-(buyer, seller), not per-payment); re-use vs rotate after sweep. KMS-rooted seed; backend never holds raw private keys; signing via KMS API (Task #11 Trezor flow is the longer-term replacement). Acceptance: two payments from one buyer to two sellers land on two different addresses; RN webhook fires for both; sweep is idempotent; master seed never leaves KMS.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §1. Files: new backend/src/services/payment/wallets/derivedDestinations.ts (getDestinationFor(buyerId, sellerOfferId) {address, derivationPath, chainId}); Payment schema add metadata.derivedDestination; requestNetworkPayInService.ts override destinationId before POST /v2/secure-payments (we confirmed RN accepts different destinations per intent); new sweep cron + admin manual-trigger endpoint gated on Transaction Safety Provider; admin UI at /dashboard/admin/derived-destinations with address, balance, last sweep tx (BscScan link), ownership status. Open questions to settle first: HD vs disposable EOAs vs smart-forwarder (recommended HD); sweep cadence (recommended immediate); granularity (recommended per-(buyer, seller), not per-payment); re-use vs rotate after sweep. KMS-rooted seed; backend never holds raw private keys; signing via KMS API (Task #11 Trezor flow is the longer-term replacement). Acceptance: two payments from one buyer to two sellers land on two different addresses; RN webhook fires for both; sweep is idempotent; master seed never leaves KMS.",
"testStrategy": "",
"status": "in-progress",
"dependencies": [],
@@ -686,7 +686,7 @@
"id": "8",
"title": "Multichain RN proxy registry + USDC/USDT support",
"description": "Probe and persist RN ERC20FeeProxy addresses on BSC/Arb/ETH/Polygon/Base, add USDC + USDT token entries with correct decimals per chain, and surface an admin networks page. Include the USDT-mainnet approve(0) reset quirk in the frontend approve step.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md \u00a72. Tasks: new backend/scripts/probe-rn-chains.ts that walks each chain in supported-chains.json and verifies the canonical 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9 proxy is the real RN proxy via a known view fn (CREATE2 is deterministic, but verify); promote backend/src/services/payment/requestNetwork/tokens.ts to load from JSON + admin override; add USDT entries on all 5 chains (BSC USDT 18-dec quirk, mainnet/Arb/Polygon/Base USDT 6-dec); buildInHouseCheckoutBlock returns reason='unsupported_chain:<id>' for unknowns; new admin route GET /api/admin/rn/networks + frontend page /dashboard/admin/networks rendering the registry with per-row 'probe again'. Frontend approve flow: if buyer is on Ethereum mainnet AND token is USDT AND current allowance > 0, do approve(spender, 0) first then approve(spender, amount). Acceptance: probe succeeds on at least BSC/Arb/Polygon/ETH/Base; one paid probe on BSC USDT end-to-end; mainnet USDT approve(0) reset works; admin page reflects registry. Dependencies: none \u2014 runs in parallel with #9. This is task #8 in the PRD.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §2. Tasks: new backend/scripts/probe-rn-chains.ts that walks each chain in supported-chains.json and verifies the canonical 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9 proxy is the real RN proxy via a known view fn (CREATE2 is deterministic, but verify); promote backend/src/services/payment/requestNetwork/tokens.ts to load from JSON + admin override; add USDT entries on all 5 chains (BSC USDT 18-dec quirk, mainnet/Arb/Polygon/Base USDT 6-dec); buildInHouseCheckoutBlock returns reason='unsupported_chain:<id>' for unknowns; new admin route GET /api/admin/rn/networks + frontend page /dashboard/admin/networks rendering the registry with per-row 'probe again'. Frontend approve flow: if buyer is on Ethereum mainnet AND token is USDT AND current allowance > 0, do approve(spender, 0) first then approve(spender, amount). Acceptance: probe succeeds on at least BSC/Arb/Polygon/ETH/Base; one paid probe on BSC USDT end-to-end; mainnet USDT approve(0) reset works; admin page reflects registry. Dependencies: none runs in parallel with #9. This is task #8 in the PRD.",
"testStrategy": "",
"status": "done",
"dependencies": [],
@@ -698,51 +698,55 @@
"id": "9",
"title": "Per-chain confirmation thresholds + admin UI",
"description": "Make TransactionSafetyProvider's confirmation threshold tunable at runtime per chain via admin UI, with an awaiting-confirmation payments view that shows live confirmations vs threshold.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md \u00a73. Today TRANSACTION_SAFETY_MIN_CONFIRMATIONS is a global env var, default 12, baked in until redeploy. Move to runtime config: new Setting docs keyed 'confirmation_threshold:<chainId>' or extend existing model; cache reads in transactionSafetyProvider.ts for 30s; GET/PATCH /api/admin/settings/confirmation-thresholds (auth: admin); new admin page /dashboard/admin/confirmation-thresholds (table: chain, current, recommended default, edit-in-place with confirm dialog, audit log of changes); new admin page /dashboard/admin/payments/awaiting-confirmation (payments where escrowState !== 'funded' AND metadata.transactionSafety.lastCheck.status === 'pending'; for each show tx hash linked to explorer, current confirmations via 12s poll on BSC, threshold, ETA). Acceptance: admin lowers BSC threshold from 12 to 3 on dev, next webhook honors new value within 30s; awaiting-confirmation table updates live; audit log records every change. Non-goals: per-asset, per-seller thresholds. Dependencies: none. This is task #9 in the PRD.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §3. Today TRANSACTION_SAFETY_MIN_CONFIRMATIONS is a global env var, default 12, baked in until redeploy. Move to runtime config: new Setting docs keyed 'confirmation_threshold:<chainId>' or extend existing model; cache reads in transactionSafetyProvider.ts for 30s; GET/PATCH /api/admin/settings/confirmation-thresholds (auth: admin); new admin page /dashboard/admin/confirmation-thresholds (table: chain, current, recommended default, edit-in-place with confirm dialog, audit log of changes); new admin page /dashboard/admin/payments/awaiting-confirmation (payments where escrowState !== 'funded' AND metadata.transactionSafety.lastCheck.status === 'pending'; for each show tx hash linked to explorer, current confirmations via 12s poll on BSC, threshold, ETA). Acceptance: admin lowers BSC threshold from 12 to 3 on dev, next webhook honors new value within 30s; awaiting-confirmation table updates live; audit log records every change. Non-goals: per-asset, per-seller thresholds. Dependencies: none. This is task #9 in the PRD.",
"testStrategy": "",
"status": "pending",
"status": "done",
"dependencies": [],
"priority": "medium",
"subtasks": []
"subtasks": [],
"updatedAt": "2026-05-29T09:51:57.565Z"
},
{
"id": "10",
"title": "Optional AML screening on incoming payments (seller-paid)",
"description": "Turn the existing aml_screening placeholder in TransactionSafetyProvider into a real Chainalysis (or equivalent) Address Screening call that the seller opts into per-offer and pays the per-check cost for.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md \u00a74. Default provider recommendation: Chainalysis Address Screening (cheapest, simplest). Files: new backend/src/services/payment/safety/amlProvider.ts interface + chainalysisProvider.ts impl behind env TRANSACTION_SAFETY_AML_PROVIDER=chainalysis with API_KEY in KMS; transactionSafetyProvider's evaluateAmlPlaceholder() becomes real, persists raw provider response on Payment.metadata.amlResult; Offer schema add requireAmlCheck + amlBlockOnFailure booleans; offer-edit UI toggle 'Require AML on incoming payments ($X per payment, paid by you)'; admin global config UI for provider selection + API key rotation + per-chain enabled flag; cost accounting: deduct per-check cost from seller's escrow on completion as a separate ledger line item, surfaced on payment-details. Open questions before code: pick provider (Chainalysis vs TRM vs Elliptic \u2014 need 1-page comparison of cost/latency/coverage); failure mode (fail-closed only when seller opted in AND amlBlockOnFailure=true, else warn/log); cost batching cadence. Acceptance: seller toggles AML on an offer; incoming payment triggers a real Chainalysis call; sanctions verdict blocks the safety gate; clean verdict passes; seller's settled amount reduced by check cost; admin can rotate API key without redeploy; provider-down + amlBlockOnFailure=true keeps payment pending with provider_unavailable reason. Dependencies: none. This is task #10 in the PRD.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §4. Default provider recommendation: Chainalysis Address Screening (cheapest, simplest). Files: new backend/src/services/payment/safety/amlProvider.ts interface + chainalysisProvider.ts impl behind env TRANSACTION_SAFETY_AML_PROVIDER=chainalysis with API_KEY in KMS; transactionSafetyProvider's evaluateAmlPlaceholder() becomes real, persists raw provider response on Payment.metadata.amlResult; Offer schema add requireAmlCheck + amlBlockOnFailure booleans; offer-edit UI toggle 'Require AML on incoming payments ($X per payment, paid by you)'; admin global config UI for provider selection + API key rotation + per-chain enabled flag; cost accounting: deduct per-check cost from seller's escrow on completion as a separate ledger line item, surfaced on payment-details. Open questions before code: pick provider (Chainalysis vs TRM vs Elliptic need 1-page comparison of cost/latency/coverage); failure mode (fail-closed only when seller opted in AND amlBlockOnFailure=true, else warn/log); cost batching cadence. Acceptance: seller toggles AML on an offer; incoming payment triggers a real Chainalysis call; sanctions verdict blocks the safety gate; clean verdict passes; seller's settled amount reduced by check cost; admin can rotate API key without redeploy; provider-down + amlBlockOnFailure=true keeps payment pending with provider_unavailable reason. Dependencies: none. This is task #10 in the PRD.",
"testStrategy": "",
"status": "pending",
"status": "done",
"dependencies": [],
"priority": "medium",
"subtasks": []
"subtasks": [],
"updatedAt": "2026-05-29T10:00:28.716Z"
},
{
"id": "11",
"title": "Trezor signing for admin actions (release/refund/sweep)",
"description": "Replace the hot-key admin signing flow with a WebUSB-based Trezor flow so the backend never holds a private key. All admin-side txes are built backend, signed via Trezor in the browser, broadcast from the browser.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md \u00a75. Lib: @trezor/connect-web (WebUSB; Chromium-only \u2014 Firefox users need Trezor Bridge native helper). Files: new frontend/src/web3/trezor/trezorConnector.ts wrapping @trezor/connect-web; existing admin actions (release/refund/sweep when #7 lands) get a 'Sign with Trezor' button that flows: POST /api/admin/actions/build-tx \u2192 returns unsigned tx bytes \u2192 send to Trezor \u2192 sign \u2192 wagmi sendTransaction broadcasts \u2192 POST /api/admin/actions/confirm-tx with hash; admin settings page to register Trezor address(es) (backend rejects signatures from unauthorized devices); audit log on every Trezor-signed action; break-glass hot-key path requires explicit admin toggle, expires after 1h, fires Telegram alarm. Open questions: m-of-n multi-admin signing \u2014 default single-signer for v1; Trezor One vs Model T \u2014 lib abstracts; fallback when Trezor unavailable \u2014 break-glass with alarm. Acceptance: admin registers Trezor address; release flow uses Trezor end-to-end; backend rejects signatures from unregistered devices; audit log captures admin user + Trezor addr + tx hash + before/after escrow state; break-glass works and alarms. Non-goals: mobile Trezor flow, buyer-side Trezor (buyer uses wagmi injected). Dependencies: task #7 (ephemeral wallets) for the sweep step \u2014 but task #11 can ship the release/refund flows first. This is task #11 in the PRD.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §5. Lib: @trezor/connect-web (WebUSB; Chromium-only Firefox users need Trezor Bridge native helper). Files: new frontend/src/web3/trezor/trezorConnector.ts wrapping @trezor/connect-web; existing admin actions (release/refund/sweep when #7 lands) get a 'Sign with Trezor' button that flows: POST /api/admin/actions/build-tx returns unsigned tx bytes send to Trezor → sign → wagmi sendTransaction broadcasts POST /api/admin/actions/confirm-tx with hash; admin settings page to register Trezor address(es) (backend rejects signatures from unauthorized devices); audit log on every Trezor-signed action; break-glass hot-key path requires explicit admin toggle, expires after 1h, fires Telegram alarm. Open questions: m-of-n multi-admin signing default single-signer for v1; Trezor One vs Model T lib abstracts; fallback when Trezor unavailable break-glass with alarm. Acceptance: admin registers Trezor address; release flow uses Trezor end-to-end; backend rejects signatures from unregistered devices; audit log captures admin user + Trezor addr + tx hash + before/after escrow state; break-glass works and alarms. Non-goals: mobile Trezor flow, buyer-side Trezor (buyer uses wagmi injected). Dependencies: task #7 (ephemeral wallets) for the sweep step but task #11 can ship the release/refund flows first. This is task #11 in the PRD.",
"testStrategy": "",
"status": "pending",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": []
"subtasks": [],
"updatedAt": "2026-05-29T10:50:02.957Z"
},
{
"id": "12",
"title": "Replace auth rate limiter with CAPTCHA (Cloudflare Turnstile or reCAPTCHA v3)",
"description": "The current authLimiter blocks all login attempts from an IP for 15 minutes after N failures. This creates terrible UX (legitimate users get locked out, especially during testing) and is bypassable via rotating IPs anyway. Replace with a progressive challenge: allow 3 attempts freely, then require CAPTCHA (Cloudflare Turnstile preferred \u2014 no user friction; reCAPTCHA v3 as fallback). Backend verifies the token server-side before proceeding with auth. Rate limiter can stay as a last-resort backstop but with a much higher threshold (e.g. 100 req/15 min).",
"description": "The current authLimiter blocks all login attempts from an IP for 15 minutes after N failures. This creates terrible UX (legitimate users get locked out, especially during testing) and is bypassable via rotating IPs anyway. Replace with a progressive challenge: allow 3 attempts freely, then require CAPTCHA (Cloudflare Turnstile preferred no user friction; reCAPTCHA v3 as fallback). Backend verifies the token server-side before proceeding with auth. Rate limiter can stay as a last-resort backstop but with a much higher threshold (e.g. 100 req/15 min).",
"details": "",
"testStrategy": "",
"status": "pending",
"status": "in-progress",
"dependencies": [],
"priority": "medium",
"subtasks": []
"subtasks": [],
"updatedAt": "2026-05-29T11:23:30.368Z"
},
{
"id": "13",
"title": "AMN Pay Scanner \u2014 retire Request Network API (Go microservice)",
"title": "AMN Pay Scanner retire Request Network API (Go microservice)",
"description": "Build a standalone Go microservice (AMN Pay Scanner) that replaces the RN API: generates paymentReferences locally, scans ERC20FeeProxy eth_getLogs per chain, and delivers HMAC-signed webhooks to the backend on confirmation. Backend swaps provider from 'request.network' to 'amn.scanner' via a new adapter. Supports any destination address, enabling HD-derived addresses as real payment destinations.",
"details": "See PRD - Retire Request Network \u2014 In-House Payment Scanner.md. Service exposes: POST /intents, GET /intents/:id, GET /scanner/status, GET /health. Node.js backend adds amnPayAdapter.ts and POST /api/payment/amn-scanner/webhook receiver. Parallel-run with RN during drain period. Language: Go v1 (Rust rewrite if volume justifies).\n\nImplemented by Kimi 2026-05-29. Scanner repo: scanner@8fee27e. Backend: backend@cdc8df1. Frontend: frontend@a5dd48e. Still open: live e2e probe (manual ops step \u2014 deploy scanner + send real BSC TransferWithReferenceAndFee tx to verify event topic match + webhook delivery).",
"details": "See PRD - Retire Request Network In-House Payment Scanner.md. Service exposes: POST /intents, GET /intents/:id, GET /scanner/status, GET /health. Node.js backend adds amnPayAdapter.ts and POST /api/payment/amn-scanner/webhook receiver. Parallel-run with RN during drain period. Language: Go v1 (Rust rewrite if volume justifies).\n\nImplemented by Kimi 2026-05-29. Scanner repo: scanner@8fee27e. Backend: backend@cdc8df1. Frontend: frontend@a5dd48e. Still open: live e2e probe (manual ops step deploy scanner + send real BSC TransferWithReferenceAndFee tx to verify event topic match + webhook delivery).",
"testStrategy": "1. POST /intents returns checkoutBlock within 300ms with no RN API call. 2. Scanner detects TransferWithReferenceAndFee on BSC within 2 poll cycles. 3. Payment marked confirmed after threshold blocks. 4. Scanner resumes from checkpoint after restart. 5. Webhook rejected on bad HMAC.",
"priority": "high",
"status": "done",
@@ -753,21 +757,22 @@
},
{
"id": "14",
"title": "Sweep service \u2014 PermitPull + GasTopUp (Kimi, backend@7688f57)",
"description": "Standalone sweep service with three signer modes: PermitPullSweepSigner (EIP-712 gasless permit for ETH/Arb/Polygon/Base), GasTopUpSweepSigner (BNB top-up for BSC), BuildOnlySweepSigner (fallback). Auto-selects by chainId and token. Currently uses SWEEP_MASTER_PRIVKEY hot key \u2014 Task #11 (Trezor) replaces this.",
"title": "Sweep service PermitPull + GasTopUp (Kimi, backend@7688f57)",
"description": "Standalone sweep service with three signer modes: PermitPullSweepSigner (EIP-712 gasless permit for ETH/Arb/Polygon/Base), GasTopUpSweepSigner (BNB top-up for BSC), BuildOnlySweepSigner (fallback). Auto-selects by chainId and token. Currently uses SWEEP_MASTER_PRIVKEY hot key Task #11 (Trezor) replaces this.",
"details": "Implemented by Kimi in backend@7688f57 (integrate-main-into-development). Files: src/services/payment/wallets/sweepService.ts, __tests__/sweep-service.test.ts. PERMIT_CAPABLE_TOKENS seeded from 2026-05-29 on-chain audit. 31/31 unit tests pass. Still open: on-chain integration tests (one per signer mode against testnet or Anvil fork). Env vars added: SWEEP_MASTER_PRIVKEY, SWEEP_GAS_MIN_BNB, SWEEP_GAS_TOP_UP_BNB.",
"testStrategy": "Unit: 31/31 pass (auto-selection, permit capability matrix, gas top-up logic). Integration (open): one live broadcast per signer mode on BSC testnet or local Anvil fork.",
"priority": "high",
"status": "in-progress",
"status": "done",
"dependencies": [],
"subtasks": []
"subtasks": [],
"updatedAt": "2026-05-29T11:56:24.674Z"
}
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-05-29T08:21:05.470Z",
"taskCount": 12,
"completedCount": 6,
"lastModified": "2026-05-29T11:56:24.675Z",
"taskCount": 14,
"completedCount": 11,
"tags": [
"master"
]

View File

@@ -78,4 +78,4 @@ A handful of design choices set Amn apart from generic marketplace software:
## Project status at a glance
Amn is at version **2.6.x** across both repositories, on the `development` branch, and tagged "production-ready with minor enhancements" by the project leads. The core escrow loop, real-time chat, multi-language UI, dispute system, points programme, and blog are all live. Active work focuses on Request Network hardening, durable webhook ingress, derived-destination custody, admin signing, and a more granular permissions matrix. The custody/smart-contract strategy lives in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
Amn is at version **2.6.x/2.7.x** across the integration worktrees, with backend `integrate-main-into-development@3a50dc4` at `2.6.79`. The core escrow loop, real-time chat, multi-language UI, dispute system, points programme, and blog are all live. Active work focuses on Request Network/AMN scanner hardening, Postgres migration readiness, oracle/depeg quote protection, durable webhook ingress, derived-destination custody, admin signing, and a more granular permissions matrix. The Postgres status lives in [[Postgres Runtime Cutover Status]]; the custody/smart-contract strategy lives in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].

View File

@@ -7,9 +7,9 @@ created: 2026-05-23
# Roles & Personas
> [!info] Where roles live in code
> The hard role enum is defined in `backend/src/models/User.ts:94` as `"admin" | "buyer" | "seller"`. Support is implemented as an admin variant (a dedicated `support@amn.gg` user is created at bootstrap — see `backend/TODO.md`) rather than as its own enum value. Permission checks live in route middleware and in service guards.
> The hard role enum is defined in `backend/src/models/User.ts:94` as `"admin" | "buyer" | "seller" | "resolver"`. The `resolver` role was added to the backend in commit `fce8a19` and is now a first-class enum value in `User.ts`, `UserRole` enum in `shared/types/index.ts`, and the dispute routes. Support is implemented as an admin variant (a dedicated `support@amn.gg` user is created at bootstrap — see `backend/TODO.md`) rather than as its own enum value. Permission checks live in route middleware and in service guards.
Amn has four user personas. Three are first-class roles in the data model; the fourth (Support) is a special-cased admin with reduced privileges.
Amn has five user personas. Four are first-class roles in the data model; the fifth (Support) is a special-cased admin with reduced privileges.
```mermaid
flowchart LR
@@ -18,11 +18,13 @@ flowchart LR
Seller["Seller<br/>(Owner)"]
Support["Support<br/>(admin variant)"]
Admin["Admin"]
Resolver["Resolver<br/>(dispute specialist)"]
Visitor -->|signs up| Buyer
Buyer -->|requests seller mode<br/>+ admin approval| Seller
Buyer & Seller -->|opens ticket| Support
Support -->|escalates| Admin
Admin -->|assigns role| Resolver
```
---
@@ -82,7 +84,7 @@ The buyer dashboard lives under `/dashboard` (`frontend/src/app/dashboard/`). No
- **Configure shop**: shop name, banner, description, response time SLA, accepted payment methods, payout wallet address. See `backend/src/models/ShopSettings.ts` and `frontend/src/sections/shop-settings/`.
- **Discover requests** through the seller feed (filtered by category and preferred-seller status). Receive live notifications when a relevant request is posted via the `sellers` / `seller-<id>` Socket.IO rooms (`backend/src/app.ts:101-112`).
- **Submit offers** with price, currency (USDT default, USDC, USD, EUR, IRR supported), delivery time, optional attachments and notes.
- **Submit offers** with price in **USDT** (the only supported currency for the escrow MVP — USD/EUR/IRR removed in commit 3aaa2fe), delivery time, optional attachments and notes.
- **Negotiate** in the per-request chat — bilateral with the buyer until an offer is accepted.
- **Fulfil** the order: ship physical goods (with optional tracking number), or upload/email digital deliverables.
- **Use the [[delivery code]]** for physical handoffs: a six-digit one-time code the buyer reads to the courier to confirm receipt.
@@ -110,6 +112,7 @@ Seller dashboard reuses the same `/dashboard` shell with extra modules:
- `/dashboard/request-template` — create / edit shop-scoped templates
- `/dashboard/payment` — receivables, payout history, pending releases
- `/dashboard/disputes` — disputes where the seller is the respondent
- `/dashboard/seller/marketplace/offers`**Offer Management** (tabbed view of all own offers filtered by status: pending / accepted / rejected / withdrawn; inline withdraw action; commit 9cf1686)
> [!tip] See also
> [[Seller Guide]] walks through onboarding, first listing, and payout setup end-to-end. [[Payments Overview]] explains the escrow + payout state machine.
@@ -193,6 +196,30 @@ Support sees a stripped-down admin view focused on the inbox:
---
## Resolver
> [!example] Who they are
> A platform-employed dispute resolver (`role: "resolver"`). Added to the backend as a first-class role in commit `fce8a19`. Resolvers have targeted authority to mediate and formally resolve disputes — they can assign disputes, update status, issue final resolutions (including `ban_seller` or `refund`), view statistics, and bypass chat membership checks (commit `766a9a2`) to read/send in any chat.
### Primary workflows
- **Review dispute details**: read buyer and seller evidence, chat history, delivery confirmations.
- **Communicate** directly through any chat — bypasses participant membership guard.
- **Assign, update status, and resolve disputes** with the same actions as admins (`refund | replacement | compensation | warning_seller | ban_seller | no_action`).
- **Monitor dispute health** via `GET /api/disputes/statistics`.
### Key permissions
- Full triage on disputes: `POST /:id/assign`, `PATCH /:id/status`, `POST /:id/resolve`, `GET /statistics`.
- Read and write messages in any chat (bypass membership check in `ChatService`).
- Read any dispute and its evidence.
- **Cannot**: change roles, issue payouts, suspend users, delete content, access non-dispute admin endpoints.
> [!note] Implementation
> The `resolver` role was added as a first-class backend enum in commit `fce8a19` (`User.ts`, `UserRole` in `shared/types/index.ts`, dispute routes). Chat bypass was added in commit `766a9a2`.
---
## Cross-cutting concerns
### Role transitions
@@ -202,6 +229,7 @@ Support sees a stripped-down admin view focused on the inbox:
| Anonymous | Buyer | Self-service signup | `User` created |
| Buyer | Seller | Application → admin approval | `User.role` change |
| Buyer / Seller | Admin | Manual DB / boot-time seed | High-risk, manual |
| Buyer / Seller | Resolver | Admin role assignment | `User.role` change |
| Admin | Support | Permission profile applied at middleware | Role stays `admin` |
### Permission model

View File

@@ -11,12 +11,18 @@ created: 2026-05-23
## The 10,000-foot view
Amn is a **two-repo system**:
Amn is a **multi-repo workspace**:
- **Frontend** (`/Users/mojtabaheidari/code/frontend`) — a Next.js 16 App Router application that serves the marketplace UI, the admin dashboard, the public blog, and the user-facing Web3 wallet flow.
- **Backend** (`/Users/mojtabaheidari/code/backend`) — an Express 5 + TypeScript API server that owns all business logic, persists to MongoDB, caches in Redis, and brokers all external integrations.
- **Frontend** (`frontend/`) — a Next.js 16 App Router application that serves the marketplace UI, admin dashboard, public blog, Telegram Mini App shell, seller shop surfaces, and the white-label tenant admin UI.
- **Backend** (`backend/`) — an Express 5 + TypeScript API server that owns business logic, persists runtime state through PostgreSQL/Drizzle repositories, caches in Redis, brokers external integrations, and now hosts the tenant/storefront/custom-domain APIs.
- **Deployment** (`deployment/`) — Docker Compose, Caddy, migrations, and Gatus configuration for `dev.amn.gg` plus the `escrow-multi` / `multi.amn.gg` stack.
- **Scanner** (`scanner/`) — the Go AMN Pay Scanner that watches chains and delivers signed payment webhooks back to the backend.
- **Amanat Assist** (`amanat-assist/`) — the AI request-assistant Mini App and LLM proxy.
- **Documentation vault** (`nick-doc/`) — Obsidian/Taskmaster documentation and audit history.
The two repos are deployable independently. They communicate over **HTTPS (REST)** for stateful actions and over **WebSocket (Socket.IO)** for live updates. The frontend never talks directly to MongoDB, Redis, Request Network API keys, OpenAI, or admin custody secrets -- every sensitive external interaction is mediated by the backend so that secrets stay on the server.
The deployable repos are versioned independently, but frontend/backend are kept in lockstep for image tags. They communicate over **HTTPS (REST)** for stateful actions and over **WebSocket (Socket.IO)** for live updates. The frontend never talks directly to PostgreSQL, Redis, scanner API keys, OpenAI, Telegram BotFather tokens, or admin custody secrets -- every sensitive external interaction is mediated by server-side services.
The active multi-shop branch is `feature/white-label-shops` in `frontend/` and `backend/`. It powers `multi.amn.gg`, tenant subdomains, custom domains routed dynamically through Caddy, and tenant-owned Telegram bots. See [[Tenant]], [[Tenant API]], and [[Tenant Storefront Flow]].
## System map
@@ -40,6 +46,7 @@ flowchart TB
SocketS["Socket.IO server<br/>rooms per user / chat / request"]
Auth["Auth service<br/>JWT + Passkey + Google + Telegram"]
Market["Marketplace service<br/>Requests, Offers, Templates"]
TenantSvc["Tenant service<br/>host resolution + domain + bot"]
ChatSvc["Chat service"]
PaySvc["Payment service<br/>Request Network + ledger + custody controls"]
TelegramSvc["Telegram service<br/>bot + Mini App + notifications"]
@@ -52,7 +59,7 @@ flowchart TB
end
subgraph Data["Data tier"]
Mongo[("MongoDB<br/>via Mongoose")]
PG[("PostgreSQL 18<br/>Drizzle repositories")]
RedisDB[("Redis<br/>cache + locks")]
Disk[("Local disk<br/>/uploads")]
end
@@ -80,10 +87,10 @@ flowchart TB
ClientJS --> REST
SocketC <--> SocketS
REST --> Auth & Market & ChatSvc & PaySvc & TelegramSvc & Disp & Points & BlogSvc & AISvc & Notif & Files
REST --> Auth & Market & TenantSvc & ChatSvc & PaySvc & TelegramSvc & Disp & Points & BlogSvc & AISvc & Notif & Files
SocketS --> ChatSvc & Notif & Market
Auth & Market & ChatSvc & PaySvc & Disp & Points & BlogSvc & TelegramSvc --> Mongo
Auth & Market & TenantSvc & ChatSvc & PaySvc & Disp & Points & BlogSvc & TelegramSvc --> PG
Auth & PaySvc & Notif --> RedisDB
Files --> Disk
@@ -94,6 +101,7 @@ flowchart TB
PaySvc -.tx fetch.-> Alchemy
TelegramSvc <--> TelegramAPI
TenantSvc <--> TelegramAPI
TelegramAPI -.webhook.-> TelegramSvc
Auth --> TelegramAPI
Notif --> SMTP
@@ -135,6 +143,7 @@ Payments are where Amn is most distinctive. The live backend has converged on **
- **Derived destination wallets** -- `/api/payment/derived-destinations` admin endpoints manage per-`(buyer, sellerOffer, chainId)` receiving addresses, sweep status, and config health.
- **Funds ledger** -- `backend/src/services/payment/ledger/` tracks payment detection, holds, releases, refunds, fees, and adjustments independently of provider metadata.
- **Release/refund orchestration** -- `/api/payment/:id/(release|refund)` builds instructions; `/confirm` records confirmed transaction hashes. Optional Trezor enforcement gates confirmation when `TREZOR_SAFEKEEPING_REQUIRED=true`.
- **Postgres migration layer** -- backend `2.6.79` includes Drizzle migrations/repos and can persist oracle quote rows to `payment_quotes` when enabled. Payment records, ledger state, wallet destinations, and marketplace entities still flow through Mongo-backed services until the cutover work in [[Postgres Runtime Cutover Status]] is completed.
Historical SHKeeper and DePay docs remain in the vault for migration context, but the current backend tree no longer has `backend/src/services/payment/shkeeper/`. The current strategic path is in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
@@ -148,7 +157,7 @@ Chat is built on Socket.IO rooms. Every entity that needs live updates gets its
- `buyer-<id>` / `seller-<id>` — marketplace-wide updates
- `sellers` / `buyers` — global broadcast pools
Messages persist to MongoDB through the `Chat` model and are rate-limited per chat (`chatRateLimiter.ts`). The frontend's `socket/` directory wraps `socket.io-client` and exposes typed event hooks to React components.
Messages persist through the backend chat repository layer and are rate-limited per chat (`chatRateLimiter.ts`). The frontend's `socket/` directory wraps `socket.io-client` and exposes typed event hooks to React components.
### Notifications — [[Notifications]]
@@ -161,7 +170,7 @@ Push and SMS are tracked as **planned** in `backend/TODO.md`.
### Disputes — [[Dispute System]]
When a deal goes wrong (see [[Glossary#Dispute]]), either party can open a dispute. The backend creates a **three-way chat** between buyer, seller, and admin, opens a `Dispute` document with a structured `timeline[]` and `evidence[]`, and can assign the dispute to an admin via `assignAdmin()`. Resolution can be `refund | replacement | compensation | warning_seller | ban_seller | no_action` in the current Mongoose model.
When a deal goes wrong (see [[Glossary#Dispute]]), either party can open a dispute. The backend creates a **three-way chat** between buyer, seller, and admin, opens a dispute record with a structured timeline/evidence model, and can assign the dispute to an admin. Resolution can be `refund | replacement | compensation | warning_seller | ban_seller | no_action` in the current service surface.
> [!note] State alignment gap
> The dispute module exists now, but its model still uses the legacy `pending | in_progress | resolved | ...` enum. [[Funds Ledger and Escrow State Machine Specification]] defines the canonical future enum and financial side effects.
@@ -223,6 +232,6 @@ OpenAI (model configurable per call) is exposed through `/api/ai/*`. The current
- [[Roles & Personas]] — who does what in the system.
- [[Glossary]] — a domain dictionary you will want open in another pane.
- [[01 - Architecture]] — service boundaries, module layout, and deployment topology.
- [[02 - Data Models]] — MongoDB collections and field-by-field schemas.
- [[02 - Data Models]] — PostgreSQL/Drizzle tables plus legacy model references where still relevant.
- [[03 - API Reference]] — every endpoint, its payload, and its auth requirements.
- [[04 - Flows]] — diagrammed user journeys for every major use case.

View File

@@ -7,11 +7,11 @@ created: 2026-05-23
# Tech Stack
> [!info] Versions
> Versions below are pulled directly from `frontend/package.json` and `backend/package.json` on the `development` branch. Where a `^` range is declared in package.json, the **declared minimum** is shown — the lockfile may have resolved a newer patch. When in doubt, check `yarn.lock` in each repo.
> Versions below are pulled from the current integration worktrees. Backend baseline: `integrate-main-into-development@3a50dc4`, package version `2.9.12`. Frontend integration worktree observed at `2.7.19`. Where a `^` range is declared in package.json, the **declared minimum** is shown — the lockfile may have resolved a newer patch.
## Frontend stack
The frontend is a Next.js 16 App Router application written in TypeScript. The build is deliberately heavy on best-in-class libraries rather than home-grown solutions: MUI for components, Wagmi for Web3, React Query / SWR for data, Zod for validation, Sentry for errors. The package is `amn-frontend@2.6.5-beta` and requires Node `>=20`.
The frontend is a Next.js 16 App Router application written in TypeScript. The build is deliberately heavy on best-in-class libraries rather than home-grown solutions: MUI for components, Wagmi for Web3, React Query / SWR for data, Zod for validation, Sentry for errors. The current integration package observed locally is `amn-frontend@2.7.19` and requires Node `>=20`.
### Core framework & language
@@ -117,7 +117,7 @@ The frontend is a Next.js 16 App Router application written in TypeScript. The b
## Backend stack
The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backed by MongoDB (Mongoose), Redis, and Socket.IO. It owns all integrations with Request Network, EVM chains, OpenAI, Google OAuth, Telegram, SMTP, and custody/signing controls.
The backend is `amn-backend@2.9.12`, an Express 5 server in TypeScript backed by PostgreSQL (Drizzle ORM), Redis, and Socket.IO. MongoDB was fully removed in v2.9.x. PostgreSQL is the sole runtime database. It owns all integrations with Request Network, AMN scanner, EVM chains, OpenAI, Google OAuth, Telegram, SMTP, and custody/signing controls.
### Core runtime & framework
@@ -145,9 +145,11 @@ The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backe
| Tool | Version | Purpose | Where used |
|---|---|---|---|
| mongoose | ^8.16.4 | MongoDB ODM | `backend/src/models/**` |
| pg | ^8.16.0 | PostgreSQL driver | `backend/src/db/client.ts`, Drizzle runtime |
| drizzle-orm | ^0.44.1 | Type-safe SQL ORM | `backend/src/db/schema/**`, repositories |
| drizzle-kit | ^0.31.1 | Migration CLI | `backend/src/db/migrations/**`, `drizzle.config.ts` |
| decimal.js | ^10.5.0 | Decimal-exact money/oracle math | payment quote engine |
| redis | ^5.6.0 | Cache, locks, rate-limit store | `services/redis/`, `app.ts:362` |
| mongodb-memory-server | ^10.2.0 (dev) | In-memory Mongo for tests | `__tests__/` |
### Auth, crypto & validation
@@ -200,7 +202,7 @@ The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backe
|---|---|---|---|
| Container engine | Docker + Docker Compose | Dev & prod deployment | `docker-compose.dev.yml`, `docker-compose.production.yml` in each repo |
| Reverse proxy | Nginx (external) | TLS termination, routing | `TRUST_PROXY=true` recognised in `app.ts:64` |
| Database | MongoDB | Primary store | Connection string via env |
| Database | PostgreSQL 18 + Drizzle | Sole runtime database | 32 tables, 19 migrations (00000019); PG_URL required |
| Cache | Redis | Sessions, locks, ephemeral data | Optional — backend boots without it |
| Object storage | Local disk `/uploads` | User uploads | `UPLOAD_PATH` env override |
| Process manager | Docker `restart: unless-stopped` (typical) | Crash recovery | Per compose file |

View File

@@ -2,14 +2,15 @@
title: Backend Architecture
tags: [architecture, backend]
created: 2026-05-23
updated: 2026-06-06
---
# Backend Architecture
Module-level architecture of the Express 5 + TypeScript + Mongoose backend at `/Users/mojtabaheidari/code/backend` (development branch).
Module-level architecture of the Express 5 + TypeScript backend. As of v2.9.12 (2026-06-06), MongoDB and Mongoose have been fully removed. PostgreSQL (Drizzle ORM) is the sole database. All 11 repository domains use DrizzleXxxRepo exclusively; no dual-write wrappers are active.
> [!info]
> Repo: `git@git.manko.yoga:222/nick/backend.git` · Branch: `development` · Version: 2.6.3-beta (`package.json:4`)
> Repo: `git@git.manko.yoga:222/nick/backend.git` · Current version: `2.9.12` · 19 migrations landed
---
@@ -21,12 +22,16 @@ backend/src/
├── config/ # Per-feature config (legacy — most moved to shared/config)
├── controllers/ # HTTP request handlers (slim — delegate to services)
├── infrastructure/
│ ├── database/ # Mongoose connection, retries, graceful shutdown
│ ├── database/ # (removed — Mongoose connection code deleted)
│ └── socket/socketService.ts # Socket.IO server, rooms, emit helpers
├── models/ # Mongoose models — see 02 - Data Models/
├── models/ # (removed — replaced by Drizzle schemas in src/db/schema/)
├── db/ # Drizzle/Postgres layer: schemas, migrations, repos, backfill, verify
│ ├── schema/ # Per-table Drizzle schema files + index.ts barrel
│ ├── migrations/ # 18 numbered SQL migration files (00000017)
│ └── repositories/ # Drizzle repos, dual-write wrappers, factory.ts
├── routes/ # Express Router definitions (mounted in app.ts)
├── scripts/ # CLI utilities (seed:users, seed:categories, ...)
├── seeds/ # Seed data fixtures
├── seeds/ # Seed data fixtures (Postgres-capable as of v2.8.47)
├── services/
│ ├── ai/ # OpenAI integration (descriptions, moderation)
│ ├── auth/ # JWT, OAuth, Passkey, password reset
@@ -71,22 +76,25 @@ The bootstrap is intentionally linear and easy to audit. Execution order:
1. **Imports & env load**`dotenv` (if used), then `import { config } from './shared/config'`.
2. **Express app construction**`const app = express();`
3. **Trust proxy**`app.set('trust proxy', config.trustProxy)` so X-Forwarded-For works behind Nginx.
3. **Trust proxy**`app.set('trust proxy', config.trustProxy)` so X-Forwarded-For works behind Traefik.
4. **Security headers**`app.use(helmet({ ... }))`.
5. **CORS**`cors({ origin: config.frontendUrl, credentials: true, methods: [...] })`.
6. **Body parsers**`express.json({ limit: '10mb' })`, `express.urlencoded({ extended: true })`.
7. **Static uploads**`app.use('/uploads', express.static(uploadDir))`.
8. **Health endpoint**`GET /health` for Docker healthcheck and external monitors.
8. **Health endpoint**`GET /health` for Docker healthcheck and external monitors. Now surfaces active Postgres store modes.
9. **Route mounting** — every `/api/*` route registered before the error handler.
10. **404 handler** — catches unmatched `/api/*`.
11. **Error handler** — central `errorHandler` middleware formats responses via `response-handler.ts`.
12. **HTTP server creation**`const server = http.createServer(app)`.
13. **Socket.IO attach**`initSocket(server, corsOptions)` (see [[Real-time Layer]]).
14. **DB connect**`await connectDatabase()`.
14. **DB connect**controlled by `MONGO_CONNECT_MODE`:
- `always` (default) — connects Mongoose (Mongo) and PostgreSQL (via `PG_URL`) on boot.
- `never` — skips Mongo entirely; Postgres is the only persistence layer. Seeds are Postgres-capable in this mode.
- `optional` — connects Postgres; Mongo is attempted but failures are non-fatal.
15. **Redis connect**`await connectRedis()`.
16. **Listen**`server.listen(config.port, ...)`.
17. **Graceful shutdown** — SIGTERM/SIGINT handlers close server, drain sockets, close Mongoose, close Redis.
18. **Optional dev seeding** — when `NODE_ENV === 'development'` and `SEED_USERS !== 'false'`, the bootstrap calls the seed scripts to provision default test users.
18. **Optional dev seeding** — when `NODE_ENV === 'development'` and `SEED_USERS !== 'false'`, the bootstrap calls the seed scripts to provision default test users. Seeds are store-aware and run correctly against both Mongo and PG.
---
@@ -100,14 +108,14 @@ The bootstrap is intentionally linear and easy to audit. Execution order:
| 4 | `morgan` (dev only) | global | HTTP request log to stdout. |
| 5 | `requestId` | global | Adds `X-Request-Id` for log correlation. |
| 6 | `authMiddleware` | per-route | Verifies JWT, attaches `req.user`. Mounted only on protected routes. |
| 7 | `roleGuard('admin'|'seller'|...)` | per-route | RBAC check after auth. |
| 7 | `roleGuard('admin'\|'seller'\|'guard'\|...)` | per-route | RBAC check after auth. Roles: `admin`, `buyer`, `seller`, `resolver`, `guard`. |
| 8 | `validate(schema)` | per-route | express-validator + zod inputs. |
| 9 | `controllerFn` | per-route | Delegates to service layer. |
| 10 | `notFound` | tail | Returns 404 envelope for unmatched routes. |
| 11 | `errorHandler` | tail | Catches thrown errors, formats response. |
> [!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.
> Rate-limit middleware is **active**: auth 10 req/15 min, payment 30/15 min, AI 20/15 min, global 100/15 min. `GET /api/payment/:id` is exempt from the payment limiter (polling route). Request Network and Telegram webhooks are exempt from the global limiter. Counters are in-memory — a Redis adapter is planned for distributed deployments.
---
@@ -124,7 +132,7 @@ The full route table mounted by `app.ts`:
| `/api/marketplace/offers` | `services/marketplace/controllerRoutes.ts` | JWT (seller) | SellerOffer CRUD |
| `/api/marketplace/templates` | `services/marketplace/controllerRoutes.ts` | JWT (seller) | RequestTemplate CRUD |
| `/api/marketplace/categories` | `services/marketplace/controllerRoutes.ts` | public read | Category list |
| `/api/marketplace/shop-settings` | `services/marketplace/shopSettingsController.ts` | JWT (seller) | Shop profile |
| `/api/marketplace/shop-settings` | `services/marketplace/shopSettingsController.ts` | JWT (seller) | Shop profile; lookup tolerant of uuid/legacy id formats |
| `/api/payment` | `services/payment/paymentControllerRoutes.ts` + `paymentRoutes.ts` | JWT | Payment CRUD, health, export |
| `/api/payment/decentralized` | `services/payment/decentralizedPaymentRoutes.ts` | mixed | Legacy/manual Web3 save, verify, receiver |
| `/api/payment/request-network` | `services/payment/requestNetwork/requestNetworkRoutes.ts` | mixed + HMAC sig on webhook | Request Network pay-in creation, in-house checkout rehydrate, webhooks |
@@ -132,12 +140,12 @@ The full route table mounted by `app.ts`:
| `/api/admin/rn/networks` | `services/payment/requestNetwork/networkRegistryRoutes.ts` | JWT (admin) | Supported RN chain/token registry |
| `/api/admin/settings/confirmation-thresholds` | `services/admin/confirmationThresholdRoutes.ts` | JWT (admin) | Runtime min-confirmation thresholds |
| `/api/admin/payments/awaiting-confirmation` | `services/admin/awaitingConfirmationRoutes.ts` | JWT (admin) | Payments blocked on safety confirmations |
| `/api/telegram` | `services/telegram/telegramRoutes.ts` | mixed (some JWT, webhook uses secret-token) | Mini App verify/session, identity link/unlink, bot webhook |
| `/api/telegram` | `services/telegram/telegramRoutes.ts` | mixed (some JWT, webhook uses secret-token) | Mini App verify/session, identity link/unlink, bot webhook; notifications delivered via Telegram as of v2.8.56 |
| `/api/chat` | `services/chat/chatRoutes.ts` | JWT | Conversations, messages |
| `/api/notification` | `services/notification/notificationRoutes.ts` + `notificationControllerRouter` | JWT | List, mark read |
| `/api/disputes` | `routes/disputeRoutes.ts` + `services/dispute/disputeRoutes.ts` | JWT | Dispute CRUD plus release-hold helpers |
| `/api/blog` | `services/blog/blogRoutes.ts` | mixed | Public reads, admin writes |
| `/api/admin/cleanup` | `services/admin/dataCleanupRoutes.ts` | JWT (admin) | Data cleanup operations |
| `/api/admin/cleanup` | `services/admin/dataCleanupRoutes.ts` | JWT (admin) | Data cleanup; scoped by provider to avoid wiping RN/multi-seller records |
| `/api/points` | `services/points/pointsRoutes.ts` | JWT | Points, levels, referrals |
| `/api/ai` | `services/ai/aiRoutes.ts` | JWT | OpenAI-backed helpers |
| `/api/files` | `services/file/fileRoutes.ts` | JWT | Multipart upload |
@@ -205,10 +213,11 @@ flowchart TB
points -.-> notify
notify --> socket
notify --> email
notify --> telegram
```
> [!note]
> `socket` and `email` are leaf services — every notification path funnels through them. Mocking these two in tests covers most side-effect verification.
> `socket`, `email`, and `telegram` are leaf notification sinks — every notification path funnels through them. Mocking these three in tests covers most side-effect verification. Telegram notification delivery was added in v2.8.56.
---
@@ -250,12 +259,26 @@ Full table in [[Environment Variables]]. Critical ones:
| Key | Default | Notes |
|---|---|---|
| `PORT` | `5001` | Listen port |
| `MONGODB_URI` | `mongodb://localhost:27017/nickapp` | Includes db name |
| `MONGODB_URI` | `mongodb://localhost:27017/nickapp` | Includes db name; not required when `MONGO_CONNECT_MODE=never` |
| `MONGO_CONNECT_MODE` | `always` | `always` \| `never` \| `optional` — controls whether Mongoose connects on boot |
| `PG_URL` | required for PG | PostgreSQL connection string for Drizzle; required when any `REPO_*=pg\|dual` |
| `REDIS_URI` | `redis://localhost:6379` | + `REDIS_PASSWORD` |
| `JWT_SECRET` | required | ≥32 chars |
| `JWT_EXPIRES_IN` | `7d` | |
| `REFRESH_TOKEN_EXPIRES_IN` | `30d` | |
| `FRONTEND_URL` | `http://localhost:3000` | CORS origin |
| `REPO_DEFAULT` | `mongo` | Global fallback store mode for all domains (`mongo` \| `dual` \| `pg`) |
| `REPO_USER` | inherits `REPO_DEFAULT` | Per-domain override for user store |
| `REPO_PAYMENT` | inherits `REPO_DEFAULT` | Per-domain override for payment store |
| `REPO_POINTS` | inherits `REPO_DEFAULT` | Per-domain override for points store |
| `REPO_MARKETPLACE` | inherits `REPO_DEFAULT` | Per-domain override for marketplace store |
| `REPO_TREZOR` | inherits `REPO_DEFAULT` | Per-domain override for trezor store |
| `REPO_DERIVED_DESTINATION` | inherits `REPO_DEFAULT` | Per-domain override for derived destination store |
| `REPO_BLOG` \| `BLOG_STORE` | inherits `REPO_DEFAULT` | Per-domain override for blog store |
| `REPO_NOTIFICATION` \| `NOTIFICATION_STORE` | inherits `REPO_DEFAULT` | Per-domain override for notification store |
| `REPO_DISPUTE` \| `DISPUTE_STORE` | inherits `REPO_DEFAULT` | Per-domain override for dispute store |
| `REPO_CHAT` \| `CHAT_STORE` | inherits `REPO_DEFAULT` | Chat dual-write not implemented; `dual` silently uses Mongo |
| `REPO_RELEASE_HOLD` \| `RELEASE_HOLD_STORE` | inherits `REPO_DEFAULT` | Release-hold dual-write not implemented; `dual` silently uses Mongo |
| `REQUEST_NETWORK_API_BASE_URL` | `https://api.request.network` | Request Network API |
| `REQUEST_NETWORK_API_KEY` | required | Request Network API credential |
| `REQUEST_NETWORK_WEBHOOK_SECRET` | required | Webhook HMAC key |
@@ -264,16 +287,77 @@ Full table in [[Environment Variables]]. Critical ones:
| `DERIVED_DESTINATION_SWEEP_SIGNER` | `build-only` | Target hardware/Safe-backed signer |
| `SMTP_*` | required | Nodemailer |
| `OPENAI_API_KEY` | required | |
| `ORACLE_QUOTING_ENABLED` | `false` | Enables oracle-based depeg-protected payment quotes; requires `PG_URL` |
---
## 9. Database & connection management
- **Mongoose** is the ODM. Connection in `src/infrastructure/database/`.
The backend runs a **dual-database architecture** during the Mongo→Postgres migration. Both stores may be active simultaneously; which one serves each domain is controlled by `REPO_*` env flags.
### MongoDB / Mongoose
- ODM: Mongoose. Connection in `src/infrastructure/database/`.
- Connection options enable retryable writes, exponential backoff on reconnect.
- Indexes are defined on each model and auto-created on connect (Mongoose `autoIndex: true` in dev, recommend `false` in prod with explicit migration).
- Indexes defined on each model and auto-created on connect (`autoIndex: true` in dev; recommend `false` in prod with explicit migration scripts).
- Remains the **authoritative read store** for all dual-write domains until read cutover is explicitly executed per domain.
- See [[Data Model Overview]] for the relational map and per-model docs.
### PostgreSQL / Drizzle
- ORM: Drizzle. Schemas in `src/db/schema/`, migrations in `src/db/migrations/` (18 migrations landed: 00000017 as of 2026-06-05).
- Managed via `drizzle-kit migrate` — never edit migration files manually.
- Connects lazily when any PG-capable store is imported, or eagerly on boot when `MONGO_CONNECT_MODE=never`.
- Every migrated table carries a `legacy_object_id text` column with a partial-unique index for idempotent backfill upserts.
- Money columns use `numeric(38,18)` (except `seller_offers`: `numeric(18,8)`). Blockchain balance columns use `numeric(78,0)` to hold uint256 without overflow.
- See [[Drizzle Schema Reference]] for the full per-table breakdown.
### Repository factory — `src/db/repositories/factory.ts`
The factory is the single routing layer between service code and the underlying store. It exposes per-domain getters and resolves the mode (`mongo` | `dual` | `pg`) in this order:
1. Per-domain env flag (e.g. `REPO_PAYMENT`)
2. `REPO_DEFAULT` (global staging-wide fallback)
3. Hardcoded default: `mongo`
Unrecognized values silently fall back to `mongo` — intentional safety net against typos on money writes.
| Domain | Getter | Dual-write | PG-only |
|---|---|---|---|
| user | `getUserRepo` | Yes (full trio) | Yes |
| payment | `getPaymentRepo` | Yes (full trio) | Yes |
| points | `getPointsRepo` | Yes (full trio) | Yes |
| marketplace | `getMarketplaceRepo` | Yes (full trio) | Yes |
| trezor | `getTrezorRepo` | Yes (full trio) | Yes |
| derivedDestination | `getDerivedDestinationRepo` | Yes (full trio) | Yes |
| blog | `getBlogRepo` | Yes (full trio) | Yes |
| notification | `getNotificationRepo` | Yes (full trio) | Yes |
| dispute | `getDisputeRepo` | Yes (full trio) | Yes |
| releaseHold | `getReleaseHoldRepo` | No — `dual` silently uses Mongo | Yes |
| chat | `getChatRepo` | No — `dual` silently uses Mongo | Yes |
> [!warning] `MONGO_CONNECT_MODE` is not handled by the factory
> `MONGO_CONNECT_MODE` is consumed by the Mongoose connection module, not by `factory.ts`. The factory only reads `REPO_*` flags. These two controls are orthogonal: `MONGO_CONNECT_MODE=never` prevents Mongoose from connecting, while `REPO_*=pg` prevents the factory from routing to Mongo. For a full PG-only boot, set **both**.
### Migration phase status (as of 2026-06-03)
| Phase | Status |
|---|---|
| Schema / migrations | Done — 18 migrations landed (00000017), all domain tables exist in PG |
| Dual-write seam | Done — active for all major domains via factory |
| Backfill tooling | Done — backfill + verification harness in `src/db/` |
| Reads cutover | Not started — all reads still served from Mongo |
| Chat normalization | Blocked — Chat stored as JSONB blobs; normalization required before PG read cutover |
| Mongo retirement | Future — blocked on per-domain read cutover completion |
### Infrastructure / bridge tables (PG-only)
- **`id_map`** — ObjectId → UUID bridge; every migrated entity upserts here during backfill/dual-write.
- **`pg_dualwrite_gaps`** — Append-only reconciliation log for failed PG dual-writes; includes severity, resolver notes, and error stack.
- **`payment_quotes`** — Oracle-based depeg-protected quote snapshots (1:1 with payments); PG-only, no Mongo equivalent. Only active when `ORACLE_QUOTING_ENABLED=true`.
### Redis
Redis client (in `src/services/redis/`) provides:
- Session caching (login attempts, lockout counters)
- Rate-limit counters (when middleware is enabled)
@@ -319,6 +403,8 @@ Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`,
| `src/shared/utils/response-handler.ts` | Standard response shape |
| `src/shared/middleware/auth.ts` | JWT verify + RBAC |
| `src/infrastructure/socket/socketService.ts` | All socket plumbing |
| `src/db/repositories/factory.ts` | Store routing — which backend each domain uses |
| `src/db/schema/index.ts` | Drizzle schema barrel — all 25+ PG tables |
| `src/services/payment/requestNetwork/requestNetworkRoutes.ts` | Request Network checkout and webhook route |
| `src/services/payment/ledger/fundsLedgerService.ts` | Immutable payment ledger writes |
| `src/services/marketplace/PurchaseRequestService.ts` | Core marketplace state machine |
@@ -334,5 +420,7 @@ Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`,
- [[Frontend Architecture]] — how the FE talks to this BE
- [[Real-time Layer]] — Socket.IO room model
- [[Security Architecture]] — JWT, passkeys, webhook HMAC
- [[Data Model Overview]] — entity-relationship map
- [[Data Model Overview]] — entity-relationship map (Mongoose)
- [[Drizzle Schema Reference]] — PostgreSQL table definitions, enums, migration status
- [[Postgres Runtime Cutover Status]] — per-domain read cutover tracker
- [[Authentication Flow]] · [[Escrow Flow]] · [[Dispute Flow]]

View File

@@ -1,16 +1,21 @@
# Database Strategy — Mongo vs Postgres Assessment
**Status:** Living assessment. Not a decision yet. Written 2026-05-28.
**Status:** RESOLVED — Full PostgreSQL migration complete as of 2026-06-06, backend v2.9.12. Document retained as historical reference.
**Owner:** nick + claude
**Decision deadline:** Open. Re-evaluate when one of the trigger conditions below fires.
**Decision:** Proceed with a staged hybrid migration, not an immediate full cutover.
> [!success] Migration Complete — 2026-06-06
> The migration to PostgreSQL is **complete** as of backend v2.9.12. MongoDB and Mongoose have been fully removed from the runtime codebase. This document is retained as historical context for the assessment and decision-making process.
---
## TL;DR
Amanat runs on MongoDB (primary store) + Redis (cache/sessions/rate limits). For an escrow product that moves money, Postgres would be the structurally better fit — FK constraints, ACID across rows, mature audit/reporting tooling. But a full migration today is a **36 month, single-engineer-equivalent project with high schedule risk** and zero user-visible value during the cutover.
Amanat still runs on MongoDB (primary store) + Redis (cache/sessions/rate limits). Backend `2.6.79` adds Postgres 18 support, Drizzle schemas/migrations, repository implementations, backfill/verify tooling, and conditional `payment_quotes` persistence, but this is **not** a full runtime cutover.
**Current recommendation:** Don't migrate. Pay down the specific weaknesses Mongo creates (cross-collection consistency, audit trails, FK-shaped bugs) with targeted in-place hardening. Revisit the decision when one of the trigger conditions below fires.
**Current recommendation:** continue the staged hybrid migration. Keep Mongo authoritative for live traffic until each domain is wired through the repository layer, backfilled, dual-written, shadow-read, and explicitly flipped.
See [[Postgres Runtime Cutover Status]] for the current line between code that can use Postgres and code that still uses Mongo.
---
@@ -18,9 +23,19 @@ Amanat runs on MongoDB (primary store) + Redis (cache/sessions/rate limits). For
| Store | Use | Notes |
|---|---|---|
| MongoDB (Mongoose 8.x) | Primary store — all domain data | 22 models, ~454 query call sites across 171 backend TS files |
| MongoDB (Mongoose 8.x) | Primary runtime store — normal domain traffic | 22 models, ~454 query call sites across 171 backend TS files |
| PostgreSQL 18 + Drizzle | Migration target and conditional oracle quote store | Schemas/migrations through `0008`, repo implementations, backfill/verify tooling; broad service wiring still pending |
| Redis | Sessions, cache, rate limits (paymentLimiter etc.) | Not in scope for any migration. Keep as-is either way. |
### Current Postgres implementation state (2026-05-31)
| Implemented | Not yet cut over |
|---|---|
| `src/db/client.ts` fail-fast PG client, Drizzle schema/index barrel, migrations through `0008`, `id_map`, `pg_dualwrite_gaps`, `payment_quotes` | Service layer still imports Mongoose models directly; no broad runtime use of `createRepositories()` / `get*Repo()` factory |
| Drizzle/Mongo/Dual repository classes for user, payment, points, marketplace | Auth, marketplace, payment, wallet, points, chat, notification, dispute, and admin paths still use Mongoose directly |
| Backfill and verification scripts guarded by `MIGRATION_PG_URL` | Backfills are not auto-run and no domain is verified as PG-authoritative |
| Oracle quote persistence can write PG `payment_quotes` when `ORACLE_QUOTING_ENABLED=true` | Payment records themselves are still created/updated in Mongo; PG quote insert depends on a resolvable PG parent row |
### Mongoose models (22)
Ranked by how naturally they map to a relational schema:

View File

@@ -2,14 +2,15 @@
title: Frontend Architecture
tags: [architecture, frontend, nextjs]
created: 2026-05-23
updated: 2026-06-03
---
# Frontend Architecture
Module-level architecture of the Next.js 16 (App Router) + TypeScript + MUI v7 frontend at `/Users/mojtabaheidari/code/frontend` (development branch).
Module-level architecture of the Next.js 16 (App Router) + TypeScript + MUI v9 frontend. The current integration worktree observed locally is on `integrate-main-into-development`.
> [!info]
> Repo: `git@git.manko.yoga:222/nick/frontend.git` · Branch: `development` · Version: 1.9.6 (`package.json:4`) · Dev port `3000`, Docker port `8083`.
> Repo: `git@git.manko.yoga:222/nick/frontend.git` · Active integration branch observed locally: `integrate-main-into-development` · Version: 2.8.94 (`package.json`) · Dev port `3000`, Docker port `8083`.
---
@@ -36,12 +37,17 @@ frontend/src/
│ │ ├── post/ # Admin blog editor
│ │ ├── shop-settings/ # Seller shop config
│ │ └── shops/ # Browse / checkout (dashboard scope)
│ ├── telegram/ # Telegram Mini App shell (see §19)
│ │ ├── layout.tsx # TMA root — TonConnectUIProvider + minimal providers
│ │ ├── shop/ # Seller list + product browsing
│ │ ├── cart/ # In-shell cart + checkout handoff
│ │ └── account/ # Account tab (dashboard parity)
│ ├── error/ # Global error page
│ └── not-found.tsx # 404
├── sections/ # Page-specific composition modules (one folder per feature)
│ └── (chat|payment|request|request-template|dispute|user|points|...)
│ └── (chat|payment|request|request-template|dispute|user|points|telegram|...)
├── components/ # Reusable UI primitives (hook-form, table, upload, editor, ...)
├── layouts/ # Page-template wrappers (auth-centered, auth-split, dashboard, main)
├── layouts/ # Page-template wrappers (auth-centered, auth-split, dashboard, main, telegram)
├── theme/ # MUI theme creation, palette, typography, overrides
├── settings/ # Settings drawer (mode, layout, direction, color, font)
├── contexts/ # React Context providers (socket-context)
@@ -80,6 +86,8 @@ flowchart TB
Order matters: theme must wrap query (because mutations show snackbars styled by theme); socket wraps snackbar (so socket-driven notifications can fire snackbars).
The Telegram Mini App shell (`app/telegram/`) uses its own slimmer layout that replaces the dashboard shell with `TonConnectUIProvider` and skips the settings drawer (see §19).
---
## 4. Route layout & guards
@@ -92,8 +100,9 @@ Order matters: theme must wrap query (because mutations show snackbars styled by
| `dashboard/user/*` | dashboard | + `role: admin` |
| `dashboard/post/*` (editor) | dashboard | + `role: admin` |
| `dashboard/shop-settings/*` | dashboard | + `role: seller` |
| `telegram/*` | `layouts/telegram` (bottom-tab shell) | Telegram `initData` token guard + role check |
Guards live in `frontend/src/auth/` (HOC + hook). They consult the JWT-derived user context and redirect unauthenticated to `/auth/jwt/sign-in?returnTo=...`.
Guards live in `frontend/src/auth/` (HOC + hook). They consult the JWT-derived user context and redirect unauthenticated to `/auth/jwt/sign-in?returnTo=...`. The Telegram guard additionally validates `window.Telegram.WebApp.initData` before issuing a session.
---
@@ -189,6 +198,8 @@ Higher-level hooks build on this:
| `use-marketplace-socket` | broad market events |
| `use-unified-real-time` | multi-event aggregator |
The Telegram Mini App shell reuses the same `SocketProvider` — live socket updates are available in the TMA shop, cart, and account tabs.
See [[Real-time Layer]] for the full event catalog.
---
@@ -213,6 +224,8 @@ const config = createConfig({
Wallet UI: connect / disconnect / show address / show balance via `use-web3-wagmi`, `use-web3-context`. The current checkout target is the Request Network in-house flow; the DePay widget package remains legacy/frontier context and should not be treated as the primary path.
TON wallet support is handled separately via `@ton/core` + `@tonconnect/ui-react` in the Telegram Mini App layer (see §19).
---
## 10. Internationalization
@@ -288,6 +301,9 @@ See [[Theme Configuration]] and [[Design System Overview]].
State persists in `localStorage` under `settings-key`.
> [!note]
> The Telegram Mini App shell does not render the settings drawer; theme and direction are inherited from the parent app's stored settings at launch.
---
## 14. Editor (TipTap)
@@ -350,6 +366,7 @@ See [[Docker Setup]], [[CI-CD Pipeline]], and [[Deployment]].
| File | Why it matters |
|---|---|
| `src/app/layout.tsx` | Provider tree |
| `src/app/telegram/layout.tsx` | TMA shell — TonConnectUIProvider + slim provider tree |
| `src/lib/axios.ts` | Every HTTP call goes through this |
| `src/contexts/socket-context.tsx` | Realtime plumbing |
| `src/theme/index.ts` | Theme creation entry |
@@ -359,6 +376,67 @@ See [[Docker Setup]], [[CI-CD Pipeline]], and [[Deployment]].
---
## 19. Telegram Mini App (TMA) layer
### Overview
The app ships a dedicated Telegram Mini App shell at `app/telegram/`. It is served from the same Next.js process and Docker image as the main web app; no separate deployment is required. The Telegram bot registers the Mini App URL pointing at `/telegram`.
### Provider tree (TMA layout)
The TMA layout replaces the full dashboard shell with a minimal provider stack:
```mermaid
flowchart TB
A[TelegramLayout]
A --> B[AppRouterCacheProvider]
B --> C[ThemeProvider]
C --> D[QueryClientProvider]
D --> E[SocketProvider]
E --> F[TonConnectUIProvider<br/>manifestUrl: /tonconnect-manifest.json]
F --> G[SnackbarProvider]
G --> H[Children — telegram routes]
```
`TonConnectUIProvider` is the only addition relative to the web tree. Settings drawer, i18n provider, and auth guards are replaced by a Telegram `initData` token guard.
### Routes and features
| Route | Description |
|---|---|
| `telegram/shop` | Seller list with product browsing; infinite scroll |
| `telegram/shop/[seller]` | Single seller's catalogue |
| `telegram/cart` | In-shell shopping cart; checkout hands off to full web checkout URL |
| `telegram/account` | Account tab with dashboard parity: profile, wallet, order history |
### Authentication flow
1. Telegram injects `window.Telegram.WebApp.initData` on launch.
2. The TMA guard sends `initData` to `/api/auth/telegram` for HMAC verification.
3. On success the backend issues a short-lived JWT that the axios instance attaches as `Bearer`.
4. Role-based access (seller vs buyer views) is honoured via the same guard mechanism used in the dashboard.
### Real-time
`SocketProvider` is reused unchanged. The TMA shop, cart, and account tabs receive live socket updates (new messages, payment status, cart changes) on the same room infrastructure as the web dashboard.
### TON Connect (Telegram Wallet)
**Dependencies added**: `@ton/core`, `@tonconnect/ui-react`.
`TonConnectUIProvider` wraps the TMA routes and exposes a `useTonConnectUI()` hook. The manifest at `public/tonconnect-manifest.json` declares the app identity to the TON Connect protocol.
Current status: the wallet connection UI is in place (connect / disconnect / show address). **Actual TON payment processing is not yet wired to the backend** — the provider is pre-positioned for a future TON payment rail on the escrow platform. When that rail is built, the checkout handoff in `telegram/cart` will be extended to emit a TON transaction instead of redirecting to the web checkout.
### Constraints and differences from web
- No settings drawer (theme follows web localStorage, defaults to light/ltr).
- No TipTap editor or file-upload dropzone in TMA routes.
- `@mui/x-date-pickers` and DataGrid are not loaded in the TMA bundle.
- COOP/COEP headers required for WalletConnect popups are relaxed for TMA routes because Telegram's WebView does not support `SharedArrayBuffer`.
---
## Related
- [[System Architecture]] — bird's-eye topology

View File

@@ -107,7 +107,7 @@ The Nginx proxy at `./nginx/nginx.conf` (mounted read-only) is responsible for:
Both `nickapp-backend` and `nickapp-frontend` carry the `watchtower.enable=true` label. Watchtower polls the container registry on its configured interval and re-pulls when the `latest` tag moves.
Release cycle:
1. Developer pushes commits to a feature branch → merged into `development`.
1. Developer pushes commits to a feature branch → merged into the active integration branch (`integrate-main-into-development` for the current dev stack; historically `development`).
2. Manual Gitea workflow `docker-build-simple.yml` builds & pushes `nickapp-backend:latest` (and a versioned tag) to `git.manko.yoga/manawenuz/escrow-backend`.
3. Within the next poll interval (default 5 min) Watchtower restarts the affected service.
@@ -121,6 +121,7 @@ Release cycle:
| Volume | What it stores | Backup priority |
|---|---|---|
| `mongodb_data` | All business data (users, requests, payments, chats, disputes...) | **Critical** — daily dump |
| `postgres_data` | Postgres 18 migration/backfill store and `payment_quotes` when enabled | **Critical after cutover; medium before cutover** — dump before/after migrations |
| `redis_data` | Cache, session, rate counters | Low — losing it logs everyone out but no data loss |
| `./uploads` (host bind) | Avatars, product images, dispute evidence, documents | **High** — daily rsync |
| `./nginx/logs` | Access / error logs | Medium |

View File

@@ -0,0 +1,180 @@
---
title: Oracle Pricing & Stablecoin Depeg Protection
status: implemented on backend integrate-main-into-development
owner: backend
created: 2026-05-31
branch: backend integrate-main-into-development at 3a50dc4
storage: conditional Postgres `payment_quotes` plus Mongo `Payment.quote` mirror during dual-write
---
# Oracle Pricing & Stablecoin Depeg Protection
## 1. Goal
Let sellers price in **any supported currency** (USD, EUR, **IRR**, **TRY**, … plus the stablecoins themselves), let the buyer settle in their chosen **stablecoin + chain** (USDC/USDT on the allow-listed chains), and compute the **on-chain amount server-side from a live price quote** so that:
- **Primary: depeg protection.** An invoice is a *value obligation* in the pricing currency. If the buyer's chosen stablecoin is off its peg (e.g. USDC @ \$0.97), the buyer pays proportionally **more** of that token so the **seller still receives the full value**.
- Buyers see a **human-readable amount** when one is within 3% of the exact figure.
- The amount is **never trusted from the client** — it is derived from the seller's offer price + oracle rates on the server.
## 2. Current state (as built)
From the promoted backend branch (`integrate-main-into-development` at `3a50dc4`):
| Concern | Where | Note |
|---|---|---|
| Seller price (invoice) | `src/models/SellerOffer.ts:9-12,53-64` | `price.amount` + `price.currency` enum `['USD','EUR','IRR','TRY','USDT','USDC']`**source of truth** |
| Buyer budget | `src/models/PurchaseRequest.ts:169-182` | now `['USDT','USDC']` only |
| Payment amount | `src/models/Payment.ts:31-40` | `amount.amount` + `amount.currency`; set at intent creation |
| Intent route | `src/services/payment/requestNetwork/requestNetworkRoutes.ts` | `/api/payment/request-network/intents` ignores client amount when `ORACLE_QUOTING_ENABLED=true`, loads the seller offer price, computes the quote, and uses `quote.settleAmount` |
| Provider dispatch | same, `:410-413` | `amn.scanner` vs Request Network |
| Token decimals | `src/services/payment/requestNetwork/tokens.ts` (`lookupTokenBySymbol`) | per-chain decimals (BSC 18, ETH 6, …) |
| Unit conversion | `src/utils/currencyUtils.ts:75,94` | `tokenToBlockchainUnits` / `blockchainUnitsToToken`; **stablecoins assumed 1:1 USD** (`:48`) |
| Seller allowlist | `src/services/payment/sellerPaymentConfig.ts:44-125` | `resolveSellerPaymentConfig` + `assertPaymentChoiceAllowed` |
**Two gaps this feature closes:** (a) no FX/oracle layer — stablecoins were hard-assumed 1:1 with USD; (b) the provider-selection settlement amount was **client-supplied** (a fund-safety hole independent of depeg).
## 3. Locked design decisions
| # | Decision | Choice |
|---|---|---|
| Depeg policy | **Protect seller, cap downside.** Token < \$1 ⇒ buyer pays more (seller made whole). Token > \$1 ⇒ par-neutral pass-through (buyer pays less). Depeg beyond a **hard cap****block + require re-confirm**, never silently overcharge. |
| Rounding | **Snap up within 3%**, or down **only if the result still fully covers** the depeg-protected obligation. Rounding may **never** leave the seller short. |
| Oracle | **Chainlink on-chain** for stablecoin/USD + major FX, with a **pluggable off-chain TS provider** fallback for exotic fiat (IRR, TRY). Cross-source agreement + staleness guard. |
| Scope/branch | Originally built on `feat/oracle-depeg-protection`, then backported onto the Postgres branch and promoted to backend `integrate-main-into-development` at `3a50dc4`. |
## 4. The quote math
All arithmetic in **decimal** (decimal.js / PG numeric) — never JS float (consistent with the money-core migration's decimal-string contract).
```
invoiceUSD = offer.amount × fxRate(offer.currency → USD) # FX oracle; for USDT/USDC pricing, ≈1 but still depeg-checked
tokenPriceUSD = depegRate(settlementToken → USD) # depeg oracle (Chainlink T/USD)
rawSettle = invoiceUSD / tokenPriceUSD # depeg protection
# premium pass-through is par-neutral: if tokenPriceUSD > 1, rawSettle < invoice (buyer benefits)
settle = niceRound(rawSettle) if niceRound(rawSettle) ≥ rawSettle*(1) AND |nice raw|/raw ≤ 0.03
= rawSettle otherwise
onChainUnits = tokenToBlockchainUnits(settle, token, chainId) # per-chain decimals
```
**niceRound ladder** (scaled to magnitude of `rawSettle`): candidates from `{1, 2, 5}×10^k` and the nearest `10^k / 100|10|1` step; pick the smallest nice number `≥ rawSettle` within 3%, else the nearest nice number `≥ obligation`. Never returns a value `< rawSettle`'s underlying fiat obligation.
**Guardrails:**
- **Staleness:** each rate has `fetchedAt`; reject/refresh if older than `ORACLE_MAX_STALENESS_S`.
- **Circuit breaker:** if `|1 tokenPriceUSD| > DEPEG_HARD_CAP_BPS` (e.g. 500 bps), **do not auto-quote** — return a `DEPEG_LIMIT_EXCEEDED` error; checkout must re-confirm or wait.
- **Cross-source agreement:** if two providers disagree by more than `ORACLE_DISAGREE_BPS`, treat as untrusted (block or fall back to the more authoritative source).
- **FX sanity bounds** per fiat (esp. IRR free-market vs official): configurable min/max plausible band.
## 5. Oracle abstraction
```ts
// src/services/payment/priceOracle/types.ts
export interface Rate { base: string; quote: string; price: string; decimals: number; fetchedAt: number; source: string }
export interface PriceProvider {
id: string
supports(base: string, quote: string): boolean
getRate(base: string, quote: string): Promise<Rate> // throws on unsupported / stale / unreachable
}
```
- `ChainlinkProvider` — reads on-chain aggregator feeds per chain (feed address registry, like `tokens.json`). Covers `USDC/USD`, `USDT/USD`, `EUR/USD`, etc.
- `OffchainFxProvider` — pluggable HTTP/TS snippet provider for fiat without on-chain feeds (IRR, TRY). Config-driven endpoint(s).
- `PriceOracle` aggregator — routes each pair to the best provider, applies fallback order, staleness + cross-source agreement, returns a single trusted `Rate` (or throws).
Providers are registered in a small registry (same pattern as the payment-provider registry) so adding a source = adding a file, no core changes — satisfies "oracle can be a TypeScript snippet."
## 6. Quote lifecycle & storage
1. At `POST …/intents`, **ignore any client `amount`**; load the `SellerOffer` price (server-authoritative).
2. Validate `(token, chain)` against the seller allowlist (`assertPaymentChoiceAllowed`).
3. `PriceOracle``fxRate`, `tokenPriceUSD`; run guardrails.
4. Compute `rawSettle``settle` (rounding) → `onChainUnits`.
5. When `ORACLE_QUOTING_ENABLED=true`, persist a **locked quote** in Postgres if the PG parent payment row exists, mirror it on the Mongo Payment, then use `settle` as the intent amount.
**Payment quote fields** (Mongo mirror plus Postgres `payment_quotes` row):
```
quote: {
quoteId, pricingCurrency, offerAmount, invoiceUSD,
fxRate, fxSource, tokenPriceUSD, depegSource,
rawSettleAmount, settleAmount, roundingBps, depegAdjustmentBps,
token, chainId, fetchedAt, expiresAt
}
```
- **Validity window** `QUOTE_VALIDITY_S` (default 60120 s). On expiry → re-quote before submit; never settle against a stale quote.
- The quote is **immutable once a payment is detected** (audit trail of exactly what rate the buyer agreed to).
## 7. Data-model changes (Postgres-capable, not full cutover)
Because the feature was promoted through the money-core migration branch, the quote can be stored **natively in Postgres** via the Drizzle schema/repos. The live payment record remains Mongo-backed until the payment service itself is wired through the PG repository path:
- **Drizzle schema**: `payment_quotes` child table keyed by `payment_id -> payments.id` — decimal columns (`numeric(38,18)`) for `offer_amount`, `invoice_usd`, `fx_rate`, `token_price_usd`, `raw_settle_amount`, `settle_amount`; text for currencies/sources; `rounding_bps`, `depeg_adjustment_bps`, `fetched_at`, `expires_at`. Additive migration `0008`, preserving every `0005`/`0006` money-safety object.
- **Pricing-currency enum**: extend `budget_currency` / the offer currency enum to add `TRY` (and any others) — additive.
- **Mongoose `Payment`**: mirror the `quote` sub-document so dual-write stays consistent during migration.
- The quote write goes through `quoteRepo.persistQuoteForMongoPayment()`, resolving the PG parent through `payments.legacy_object_id` and then `id_map`. If the PG payment row is not present yet, the backend still mirrors the quote to Mongo and records a `pg_dualwrite_gaps` row for reconciliation.
## 8. Integration seam
`src/services/payment/requestNetwork/requestNetworkRoutes.ts`, before provider dispatch — for **both** the `amn.scanner` and Request Network paths when `ORACLE_QUOTING_ENABLED=true`:
```
- amount: Number(amount) // REMOVE: client-trusted
+ const quote = await priceOracle.quote({ offer, token, network, sellerConfig })
+ // amount := quote.settleAmount ; attach quote to the Payment + intent input
```
Request Network already takes `invoiceCurrency` + `paymentCurrency` (`requestNetworkService.ts:140-149`) — we still compute and store our own quote for depeg auditing and to keep AMN/RN consistent.
## 9. Config (env)
```
ORACLE_QUOTING_ENABLED=false
PRICE_ORACLE_PROVIDERS=chainlink,offchain_fx # order = fallback order
ORACLE_MAX_STALENESS_S=120
ORACLE_DISAGREE_BPS=100
DEPEG_HARD_CAP_BPS=500 # block beyond 5% depeg
QUOTE_VALIDITY_S=90
REQUOTE_RECONFIRM_BPS=50
OFFCHAIN_FX_URL= # IRR/TRY source
OFFCHAIN_FX_REQUEST_TIMEOUT_MS=8000
CHAINLINK_RPC_1= # Ethereum Chainlink reads
CHAINLINK_RPC_56= # BSC Chainlink reads
```
## 10. Fund-safety considerations
- **Server-authoritative amount** — client `amount` is ignored; derived from offer + oracle (closes the existing trust hole at `:404`).
- **Decimal-exact** end-to-end (no float), matching the money-core contract.
- **Quote tamper / replay** — quote is server-computed, stored, time-boxed, and frozen once payment detected.
- **Oracle manipulation** — cross-source agreement, staleness, hard caps, FX sanity bands; prefer Chainlink (manipulation-resistant) for stablecoins.
- **Rounding never shorts the seller** — `settle ≥ obligation`.
- **Premium handling is par-neutral** — buyer benefits when token > \$1, seller never overcharged.
## 11. Failure modes
| Failure | Behavior |
|---|---|
| All providers down / stale | Block checkout with a clear retry error (no guessed rate) |
| Depeg > hard cap | `DEPEG_LIMIT_EXCEEDED` — require explicit re-confirm |
| Provider disagreement | Use authoritative source or block |
| Quote expired at submit | Re-quote; if materially changed, re-confirm with buyer |
## 12. Testing
- Unit: quote math (depeg up/down, premium par-neutral, rounding-up-only, never-below-obligation), niceRound ladder across magnitudes (IRR millions → USDC cents).
- Oracle: provider fallback, staleness, disagreement, hard-cap circuit breaker.
- Property: `settle × tokenPriceUSD ≥ invoiceUSD` always (seller made whole); rounding error ≤ 3%.
- Integration: both `/intents` paths produce a stored quote; client-sent amount is ignored.
## 13. Implementation status
- **Oracle core** — `PriceProvider` interface, registry, `PriceOracle` aggregator, off-chain FX provider, Chainlink provider, and env-driven provider order are implemented.
- **Quote engine** — decimal math, depeg policy, nice rounding, guardrails, `Payment.quote` mirror, `payment_quotes`, and `TRY` pricing support are implemented.
- **Seam wiring** — `/intents` computes the server-side amount for both provider paths when `ORACLE_QUOTING_ENABLED=true`.
- **Tests** — oracle/depeg, request-network pay-in, adapter, webhook, and sweep service suites passed during promotion. PG decimal integration cases require local `PG_URL` / `MIGRATION_PG_URL` to run.
## 14. Open questions
- IRR: official vs free-market rate source (and which is "truth" for invoicing)?
- Do we expose the live quote (rate, depeg %, expiry) to the buyer UI before they confirm?
- Re-confirm threshold when a re-quote moves the amount (e.g. > X bps)?
```

View File

@@ -0,0 +1,181 @@
---
title: Scanner Architecture
tags: [architecture, scanner, payment]
created: 2026-05-30
updated: 2026-06-12
---
# Scanner Architecture
AMN Pay Scanner is a standalone Go microservice that watches on-chain payment events and notifies the backend via signed webhook when a payment is confirmed. It replaces the Request Network integration with an in-house polling scanner that supports EVM chains, Tron, and TON.
> [!info]
> Repo: `scanner/` within the escrow monorepo. Binary: `scanner`. Written in Go 1.25. SQLite (WAL mode) for state. No external dependencies beyond the chain APIs.
>
> For operational how-it-works detail (API, webhook payloads, config vars, direct balance checks) see [[scanner]] in 10 - Services.
---
## 1. Responsibilities
- Accept payment **intents** from the backend (`POST /intents`)
- Watch the relevant chain for matching on-chain transfers
- Track confirmation depth (EVM) or rely on API-reported finality (Tron, TON)
- Deliver a signed webhook to the backend callback URL when confirmed
- Retry failed webhook deliveries with exponential back-off
- Expire stale pending intents on a configurable TTL
- Read an EVM ERC-20 balance on demand (`POST /balances/check`)
- Watch an EVM address/token pair for balance changes with age-decayed polling cadence (`POST /balance-watches`); checks every 5 min for the first 24 h, then 10 → 20 → 40 min as the watch ages; watches expire after 7 days
---
## 2. Supported chains
Chains are defined in `supported-chains.json`. A worker is spawned only for chains with `"verified": true` (or listed in `SCANNER_ENABLED_CHAINS`).
| Chain | Chain ID | Type | Proxy / contract address | Conf. threshold | `verified` |
|---|---|---|---|---|---|
| BNB Smart Chain | 56 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 200 blocks | **true** |
| Ethereum Mainnet | 1 | EVM | `0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C` | 50 blocks | **true** |
| BNB Smart Chain Testnet | 97 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 5 blocks | **true** (testnet) |
| Arbitrum One | 42161 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 2400 blocks | false |
| Polygon | 137 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 300 blocks | false |
| Base | 8453 | EVM | `0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814` | 300 blocks | false |
| Tron Mainnet | 728126428 | Tron | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` (USDT TRC20 contract) | 200 (API-confirmed) | false |
| TON Mainnet | 1100 | TON | `EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs` (USDT Jetton master) | 120 (API-finalized) | false |
> [!note] Proxy address variations
> Ethereum mainnet uses the v0.1.0 proxy (`0x370DE...`); a v0.2.0 proxy is also deployed on ETH but checkout still uses the v0.1.0 ABI. Base uses a non-canonical CREATE2 address (`0x189219...`). All other EVM chains use the canonical v0.2.0 address (`0x0DfbEe...`). The memory note [[RN proxy addresses per chain]] has background on why CREATE2 canonical-address claims should not be trusted without verification.
>
> Tron and TON have no fee-proxy contract. The `proxyAddress` field for those chains holds the token contract address used to filter Transfer events (Tron) or Jetton transfers (TON).
To enable a disabled chain without a rebuild: set `SCANNER_ENABLED_CHAINS=56,1,42161` (overrides the JSON `verified` flags).
---
## 3. Architecture decisions
### Why a standalone Go service
The scanner runs a tight polling loop that needs to hold open TCP connections to multiple RPC endpoints, manage per-chain checkpoints, and retry webhook delivery independently of backend restarts. A dedicated process with its own SQLite state is simpler and more reliable than embedding this into the Node.js backend.
### Why SQLite
Single-node deployment. WAL mode gives concurrent reads during writes. The state set is small (one row per intent, one checkpoint per chain). No operational overhead of a separate DB process inside the container.
### Two payment rails
The scanner supports two fundamentally different payment models:
1. **Proxy-contract rail (EVM)**: funds flow through `ERC20FeeProxy`; the scanner matches by `paymentReference` embedded in the contract event. No unique destination address required; the reference is the discriminator.
2. **Direct-address rail (Tron, TON, and EVM balance-watch)**: each payment gets a unique HD-derived destination address. The scanner matches by `to` address and validates amount. This is the only model available on Tron and TON because no fee-proxy contract exists there.
### Confirmation thresholds
EVM confirmation depths are conservative to handle reorgs:
- **BSC (200)**: BSC has had historical reorg incidents; 200 blocks (~10 min) provides a practical safety margin.
- **ETH (50)**: ~10 min at 12 s/block; Ethereum finality is probabilistic post-merge but 50 blocks is well past economic finality.
- **Arbitrum (2400)**: Arbitrum uses optimistic rollup; 2400 blocks (~54 min) covers the challenge window.
- **Polygon (300)**: polygon reorgs have occurred at depth >100; 300 blocks gives headroom.
- **Base (300)**: Base is an OP Stack chain; same rationale as Polygon.
Tron and TON do not use block-depth confirmation — TronGrid and TonCenter only surface confirmed/finalized transactions, so status goes directly to `confirmed`. The scanner reports the chain's acceptance floor (200 / 120) in the webhook for backend use.
### Reorg protection (EVM)
The EVM worker re-scans `3 × confirmationThreshold` blocks before the checkpoint (clamped 20500) on every tick. This `ReorgBuffer()` ensures that a log in a block that was reorganised off the canonical chain will be re-evaluated when the chain reorganises. The window is wide enough to cover any realistic reorg depth for the chains the scanner targets.
### Startup reconciliation
On startup, `confirmed` intents with `webhook_delivered_at IS NULL` created within the last 7 days have their webhook re-delivered. This recovers from a crash between `finalizeIntent` and `deliverWebhook` without requiring a manual retry trigger.
---
## 4. Component map
```
┌─────────────────────────────────────────────────────────┐
│ scanner binary │
│ │
│ main.go │
│ ├── loadConfig() config.go │
│ ├── initDB() intent.go (SQLite schema) │
│ ├── startup reconcile intent.go │
│ ├── newServer() api.go │
│ │ └── startWorkers() api.go │
│ │ ├── ChainWorker chain.go (EVM) │
│ │ ├── TronChainWorker tron_chain.go (Tron) │
│ │ └── TonChainWorker ton_chain.go (TON) │
│ ├── HTTP routes api.go / main.go │
│ ├── intent TTL expiry main.go + intent.go │
│ ├── webhook retry loop main.go + webhook.go │
│ └── BalanceWatchScheduler balance_watch.go │
│ │
│ reference.go — payment reference / topic hash │
│ webhook.go — delivery, HMAC signing, retry │
│ balance.go — EVM ERC-20 balanceOf reads │
│ balance_watch.go — balance_watches state + webhooks │
└─────────────────────────────────────────────────────────┘
```
One worker goroutine is spawned per active chain. All three chain types implement a common `Worker` interface (`start()`, `stop()`, `getHead()`). Workers poll on `POLL_INTERVAL_SEC` (default 15 s).
---
## 5. Backend integration points
| Direction | Endpoint | When |
|---|---|---|
| Backend → Scanner | `POST /intents` | New payment initiated; returns `checkoutBlock` with `paymentReference` and proxy address |
| Backend → Scanner | `GET /intents/{id}` | Poll intent status (optional; webhook is primary) |
| Scanner → Backend | `POST <callbackUrl>` | Payment confirmed; signed with `X-AMN-Signature` HMAC-SHA256 |
| Backend → Scanner | `POST /balances/check` | Synchronous ERC-20 balance read (direct-address rail) |
| Backend → Scanner | `POST /balance-watches` | Start async balance watch (direct-address rail) |
| Backend → Scanner | `GET /balance-watches/{id}` | Get balance-watch status |
| Scanner → Backend | `POST <callbackUrl>` | Balance changed; `eventType: balance_changed` in body |
| Backend → Scanner | `DELETE /balance-watches/{id}` or `POST /balance-watches/{id}/stop` | Stop watch after payment accepted or cancelled |
| Backend → Scanner | `GET /scanner/status` | Chain lag + pending counts (ops/monitoring) |
| Backend → Scanner | `POST /admin/webhooks/retry` | Force re-delivery of `webhook_failed` intents |
All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>`. Webhooks are HMAC-SHA256 signed; backend must verify `X-AMN-Signature` before crediting any payment.
The `amn.scanner` backend provider wires intent creation, webhook receipt, and balance-watch lifecycle. See memory note [[amn scanner pay-in wiring + env]] for the 6 required env vars and the dispatcher registration.
---
## 6. Intent lifecycle
```
pending ──(tx seen)──► confirming ──(enough blocks)──► confirmed ──(webhook ok)──► [done]
│ │ │
│ │ (deep reorg / TTL) │ (all retries fail)
└───────────────────────┴──────────► expired webhook_failed
```
- **Tron / TON** skip `confirming` and go directly to `confirmed` (API only surfaces finalized txns).
- `webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`.
- Retry schedule on first delivery attempt: 5 s → 30 s → 2 min → 10 min → 1 h → `webhook_failed`.
---
## 7. Security model
- All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>` (constant-time compare).
- Unset `SCANNER_API_KEY` logs a warning and allows all requests — local dev only.
- Webhooks signed with HMAC-SHA256: `X-AMN-Signature: hex(hmac(body, callbackSecret))`.
- `callbackSecret` stored in DB but excluded from all JSON responses (`json:"-"`).
- Request bodies limited to 64 KB.
- `SCANNER_CALLBACK_ALLOWED_HOSTS` env var restricts allowed webhook target hosts (SSRF guard).
---
## 8. Known limitations and open items
| Item | Detail |
|---|---|
| TON O(n) API calls | Per-intent polling — one TonCenter v3 call per pending TON intent per scan cycle. Fine at low volume; needs batching for scale. |
| Direct balance reads: EVM only | `POST /balances/check` and `POST /balance-watches` only support EVM ERC-20 (`eth_call` balanceOf). Tron/TON balance reads are future scope. |
| Arbitrum / Polygon / Base / Tron / TON disabled | `"verified": false` in `supported-chains.json`. Enable via `SCANNER_ENABLED_CHAINS` env var without a code change or rebuild. |
| Ethereum proxy version | Chain 1 uses the v0.1.0 proxy (`0x370DE...`). A v0.2.0 proxy is also deployed on ETH but checkout still uses the v0.1.0 ABI. Upgrading requires a coordinated frontend change. |
| BSC Testnet tokens | Test USDT on BSC Testnet: `0x109F54Dab34426D5477986b0460aE5dFBA65f022` (has public `mint()`). Faucet: `testnet.bnbchain.org/faucet-smart`. |

View File

@@ -22,7 +22,8 @@ flowchart LR
Nginx[Nginx Reverse Proxy<br/>:80/:443]
FE[Next.js Frontend<br/>standalone server<br/>:8083]
BE[Express Backend<br/>+ Socket.IO<br/>:5001]
Mongo[(MongoDB 8)]
Mongo[(MongoDB 8<br/>primary runtime store)]
PG[(PostgreSQL 18<br/>migration target / quote table)]
Redis[(Redis 8)]
RN[Request Network<br/>Pay-in + webhooks]
CFWorker[Durable webhook ingress<br/>roadmap]
@@ -37,6 +38,7 @@ flowchart LR
FE -->|REST /api/*| BE
FE -.->|Socket.IO| BE
BE --> Mongo
BE -.->|PG_URL + migration/quote paths| PG
BE --> Redis
BE -->|Pay-in intent / status| RN
RN -.->|Signed webhook| CFWorker
@@ -79,6 +81,9 @@ sequenceDiagram
FE-->>U: UI re-render
```
> [!note] Postgres status on `integrate-main-into-development`
> Backend `2.6.79` includes Drizzle schemas, migrations, repository implementations, backfill/verify tooling, and conditional oracle quote persistence to Postgres. It is not a full runtime cutover: ordinary services still call Mongoose models directly and MongoDB remains the primary store. See [[Postgres Runtime Cutover Status]] for the current boundary.
Concurrent realtime path:
```mermaid
@@ -106,6 +111,7 @@ Production runs as a single Docker Compose stack (`backend/docker-compose.produc
| App | Frontend | `nickapp-frontend:latest` | 8083 (internal) | Next.js standalone |
| App | Backend | `nickapp-backend:latest` | 5001 (internal) | Express + Socket.IO |
| Data | MongoDB | `mongo:8.0` | 27017 (internal) | Primary store |
| Data | PostgreSQL | `postgres:18` / `postgres:18-alpine` | 5432 (internal) | Migration target; required for PG backfill/verify and oracle `payment_quotes` when enabled |
| Data | Redis | `redis:8-alpine` | 6379 (internal) | Cache + sessions + rate-limit counters |
External SSL termination, DNS, and CDN are assumed to live in front of Nginx (CloudFlare / nginx-proxy / similar).
@@ -176,6 +182,7 @@ See [[PRD - Request Network In-House Checkout]] and [[Request Network Integratio
|---|---|---|
| Backend stateless? | Yes — JWT-only auth, no in-memory session | Run N replicas behind LB; use Redis pub/sub adapter for Socket.IO |
| MongoDB | Single-node | Replica set → sharding by `buyerId` |
| PostgreSQL | Dev/staging service for migration work | Managed Postgres or hardened self-hosted PG with backups/PITR before cutover |
| Redis | Single-node | Cluster mode; separate cache vs session DBs |
| Socket.IO | Single process | `@socket.io/redis-adapter` for multi-node fan-out |
| File uploads | Local `uploads/` mount | S3 / R2; multer-s3 adapter |

View File

@@ -1,6 +1,6 @@
---
title: Category
tags: [data-model, mongoose]
tags: [data-model, mongoose, postgres]
aliases: [Category Model, Taxonomy, ICategory]
---
@@ -10,14 +10,16 @@ Hierarchical taxonomy node used by [[PurchaseRequest]] and [[RequestTemplate]].
> [!note] Source
> `backend/src/models/Category.ts:15` — schema definition
> `backend/src/models/Category.ts:60` — model export
> `backend/src/models/Category.ts:64` — model export
> `backend/src/services/marketplace/categoryStore.ts:89` — Postgres runtime bootstrap and duplicate cleanup
> `backend/src/db/schema/category.ts:88` — Drizzle active normalized-name unique index
## Schema
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `name` | String | yes | — | trim | yes | Local language name. |
| `nameEn` | String | yes | — | trim | yes | English name. |
| `nameEn` | String | yes | — | trim | unique | English name. |
| `description` | String | no | — | trim | — | Description. |
| `icon` | String | no | — | trim | — | Icon identifier / URL. |
| `isActive` | Boolean | no | `true` | — | yes | Active flag. |
@@ -32,13 +34,19 @@ None defined.
## Indexes
Defined at `backend/src/models/Category.ts:55-58`:
Defined at `backend/src/models/Category.ts:55-62`:
- `{ name: 1 }`
- `{ nameEn: 1 }`
- `{ nameEn: 1 }`, unique
- `{ isActive: 1 }`
- `{ parentId: 1 }`
Postgres runtime and Drizzle additionally enforce:
- `categories_legacy_object_id_uq`: unique Mongo bridge id for idempotent backfill/upsert.
- `categories_active_name_norm_uq`: unique active category display label using `lower(btrim(name)) WHERE is_active = true`.
- Existing duplicate active PG rows are deactivated before the unique index is created; purchase-request category references and child category parents are repointed to the kept row.
## Pre/Post Hooks
None declared.
@@ -70,7 +78,7 @@ stateDiagram-v2
## Common Queries
```ts
// Top-level categories
// Top-level categories; runtime store dedupes active rows by normalized display name.
Category.find({ parentId: null, isActive: true }).sort({ order: 1 });
// Children of a category

View File

@@ -1,127 +1,146 @@
---
title: Chat
tags: [data-model, mongoose]
tags: [data-model, postgres, drizzle]
aliases: [Conversation, IChat, IMessage]
---
# Chat
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-06-06 — MongoDB fully removed; PostgreSQL + Drizzle is the sole data layer (backend v2.9.12).
Conversation container with embedded messages. Used for buyer-seller direct chats, group chats, and support tickets. Each chat carries a list of participants, an embedded `messages[]` array (with reactions, replies, edit history), a denormalised `lastMessage` snapshot for list views, and per-user `unreadCounts`. A chat can be linked to any other entity through the `relatedTo` discriminator (currently `PurchaseRequest`, `SellerOffer`, or `Transaction`).
> [!note] Source
> `backend/src/models/Chat.ts:130` — chat schema definition
> `backend/src/models/Chat.ts:69` — message subdocument schema
> `backend/src/models/Chat.ts:348` — model export
> `backend/src/db/schema/chat.ts` — PostgreSQL schema (Drizzle)
> `backend/src/repositories/drizzle/DrizzleChatRepo.ts` — repository implementation
> [!warning] Embedded messages
> Messages live inside the chat document. Very long-running chats can grow past the 16 MB document limit. Treat this as a known constraint of the current schema.
> [!warning] Embedded messages (JSONB)
> Messages and participants are stored as JSONB arrays inside the `chats` table (`messages jsonb`, `participants jsonb`), not as separate relational child tables. Very long-running chats can accumulate large blobs. Chat normalization (JSONB → relational child tables) is a **future improvement**, not yet done.
> [!warning] `relatedTo` is NOT set via `POST /api/chat`
> Although `relatedTo` exists in the schema, it is **not accepted** by the `POST /api/chat` create endpoint. Purchase-request linkage is established server-side through the dedicated `POST /api/chat/purchase-request`, not by passing `relatedTo` to the generic create endpoint.
## Schema — Chat
## Schema — `chats` table (PostgreSQL / Drizzle)
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `type` | String | yes | `direct` | enum: `direct` / `group` / `support` | yes | Conversation type. |
| `name` | String | no | — | maxlength 100 | — | Display name (group chats). |
| `description` | String | no | — | maxlength 500 | — | Optional description. |
| `participants[].userId` | ObjectId → [[User]] | yes | — | — | yes | Member id. |
| `participants[].role` | String | no | `member` | enum: `member` / `admin` / `owner` | — | Member role. |
| `participants[].joinedAt` | Date | no | `Date.now` | — | — | Join time. |
| `participants[].lastSeen` | Date | no | — | — | — | Last activity. |
| `participants[].leftAt` | Date | no | — | — | — | Set when the participant is removed (soft removal). |
| `participants[].isActive` | Boolean | no | `true` | — | — | Still a participant. Set to `false` on soft removal (subdocument is kept). |
| `messages[]` | Subdocument[] | no | `[]` | — | yes (`messages.timestamp`) | Embedded messages. |
| `relatedTo.type` | String | no | — | enum: `PurchaseRequest` / `SellerOffer` / `Transaction` | yes (compound) | Linked entity kind. **Not accepted via `POST /api/chat`** — set only via `POST /api/chat/purchase-request`. |
| `relatedTo.id` | ObjectId | no | — | — | yes (compound) | Linked entity id. |
| `lastMessage.content` | String | no | — | — | — | Snapshot for list views. |
| `lastMessage.senderId` | ObjectId → [[User]] | no | — | — | — | Last sender. |
| `lastMessage.timestamp` | Date | no | — | — | — | Last message time. |
| `lastMessage.messageType` | String | no | — | — | — | Last message type. |
| `unreadCounts[].userId` | ObjectId → [[User]] | no | — | — | — | User the counter belongs to. |
| `unreadCounts[].count` | Number | no | `0` | — | — | Number of unread messages. |
| `settings.isArchived` | Boolean | no | `false` | — | — | Archived flag. |
| `settings.isMuted` | Boolean | no | `false` | — | — | Muted flag. |
| `settings.mutedUntil` | Date | no | — | — | — | Mute expiry. |
| `settings.notifications` | Boolean | no | `true` | — | — | Per-chat notification toggle. |
| `metadata.createdBy` | ObjectId → [[User]] | yes | — | — | — | Original creator. |
| `metadata.createdAt` | Date | no | `Date.now` | — | — | Created timestamp. |
| `metadata.updatedAt` | Date | no | `Date.now` | — | — | Touched by pre-save. |
| `metadata.lastActivity` | Date | no | `Date.now` | — | yes (desc) | Sort key for chat lists. |
> Source: `backend/src/db/schema/chat.ts`
> [!note] No top-level `timestamps`
> Unlike most models, this schema does not pass `{ timestamps: true }`. It uses its own `metadata.createdAt` / `metadata.updatedAt` instead, maintained by the pre-save hook.
PostgreSQL is the **only** database layer. MongoDB and Mongoose have been completely removed from the backend runtime. Participants and messages are stored as **JSONB blobs** (`ChatParticipant[]` and `ChatMessage[]`) inside the `chats` table. Chat normalization (splitting messages and participants into separate relational child tables with proper FKs and threading support) is a known future improvement.
### Enums (declared in `_enums.ts`)
| Enum | Values |
| --- | --- |
| `chat_type` | `direct`, `group`, `support` |
| `chat_participant_role` | `member`, `admin`, `owner` |
| `chat_message_type` | `text`, `image`, `file`, `system` |
| `chat_related_to_type` | `PurchaseRequest`, `SellerOffer`, `Transaction` |
### Table: `chats`
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | NOT NULL | `gen_random_uuid()` | Primary key (PostgreSQL UUID — use `.id`, not `._id`) |
| `legacy_object_id` | `text` | nullable | — | Former Mongo ObjectId; partial-unique index WHERE NOT NULL |
| `type` | `chat_type` enum | NOT NULL | `'direct'` | |
| `name` | `text` | nullable | — | Group chat display name |
| `description` | `text` | nullable | — | |
| `participants` | `jsonb` | nullable | — | `ChatParticipant[]` blob — stored as JSONB array |
| `messages` | `jsonb` | nullable | — | `ChatMessage[]` blob — stored as JSONB array |
| `related_to` | `jsonb` | nullable | — | `{ type: chat_related_to_type, id: string }` blob |
| `last_message` | `jsonb` | nullable | — | Denormalised snapshot |
| `unread_counts` | `jsonb` | nullable | — | `{ userId, count }[]` blob |
| `settings_is_archived` | `boolean` | nullable | `false` | |
| `settings_is_muted` | `boolean` | nullable | `false` | |
| `settings_muted_until` | `timestamp with time zone` | nullable | — | |
| `settings_notifications` | `boolean` | nullable | `true` | |
| `created_by` | `text` | nullable | — | UUID string of creator |
| `created_at` | `timestamp with time zone` | NOT NULL | `now()` | |
| `updated_at` | `timestamp with time zone` | NOT NULL | `now()` | |
| `last_activity` | `timestamp with time zone` | nullable | `now()` | Sort key for chat lists |
### Indexes on `chats`
| Index | Definition | Notes |
| --- | --- | --- |
| PK | `id` | |
| partial-unique | `legacy_object_id` WHERE NOT NULL | Idempotent backfill upsert |
| regular | `type` | |
| regular | `created_by` | |
| regular | `last_activity` | |
> [!note] No FK to `users`
> `created_by` is stored as `text` (not `uuid` FK) to accommodate both legacy Mongo ObjectIds (in `legacy_object_id`) and PG UUIDs during the transition period.
## Chat Schema — participants and messages (JSONB field shapes)
### `participants` JSONB array element
| Field | Type | Description |
| --- | --- | --- |
| `userId` | string (UUID) | Member id. |
| `role` | `member` / `admin` / `owner` | Member role (default `member`). |
| `joinedAt` | ISO date string | Join time. |
| `lastSeen` | ISO date string? | Last activity. |
| `leftAt` | ISO date string? | Set on soft removal. |
| `isActive` | boolean | Still a participant (default `true`). Set to `false` on soft removal. |
> [!note] Soft removal of participants
> Removing a participant (via `DELETE /api/chat/:id/participants/:participantId`) does **not** delete the subdocument. It is a soft removal: `isActive` is set to `false` and `leftAt` is timestamped, preserving message attribution and history.
> Removing a participant does **not** delete the array element. It is a soft removal: `isActive` is set to `false` and `leftAt` is timestamped, preserving message attribution and history.
## Schema — Message (embedded)
### `messages` JSONB array element
| Field | Type | Required | Default | Validation | Description |
| --- | --- | --- | --- | --- | --- |
| `senderId` | ObjectId → [[User]] | yes | — | — | Author. |
| `senderType` | String | no | `User` | — | Currently fixed. |
| `content` | String | yes | — | **maxlength 5000** | Message body. Enforced at both schema and controller. |
| `messageType` | String | no | `text` | enum: `text` / `image` / `file` / `system` | Body kind. |
| `fileUrl` | String | no | — | — | If file/image. |
| `fileName` | String | no | — | — | Original filename. |
| `fileSize` | Number | no | — | — | Bytes. |
| `timestamp` | Date | no | `Date.now` | — | Sent time. |
| `isRead` | Boolean | no | `false` | — | Read flag. |
| `isEdited` | Boolean | no | `false` | — | Edited flag. |
| `editedAt` | Date | no | — | — | When edited. |
| `deletedAt` | Date | no | — | — | Set on soft-delete; `content` is cleared but the subdocument is kept. |
| `replyTo` | ObjectId | no | — | — | Reply target message id. |
| `reactions[].userId` | ObjectId → [[User]] | no | — | — | Reacting user. |
| `reactions[].reaction` | String | no | — | maxlength 10 | Emoji. |
| Field | Type | Description |
| --- | --- | --- |
| `senderId` | string (UUID) | Author. |
| `senderType` | string | Currently fixed to `User`. |
| `content` | string | Message body. **maxlength 5000** enforced at controller. |
| `messageType` | `text` / `image` / `file` / `system` | Body kind (default `text`). |
| `fileUrl` | string? | If file/image. |
| `fileName` | string? | Original filename. |
| `fileSize` | number? | Bytes. |
| `timestamp` | ISO date string | Sent time. |
| `isRead` | boolean | Read flag (default `false`). |
| `isEdited` | boolean | Edited flag (default `false`). |
| `editedAt` | ISO date string? | When edited. |
| `deletedAt` | ISO date string? | Set on soft-delete; `content` is cleared but the element is kept. |
| `replyTo` | string? | Reply target message id. |
| `reactions` | `{ userId: string, reaction: string }[]` | Emoji reactions. |
> [!note] Messages are soft-deleted
> Deleting a message sets `deletedAt` and clears `content` (the body becomes empty). The message subdocument is **not** physically removed from `messages[]`, and a `message-deleted` socket event is emitted.
> Deleting a message sets `deletedAt` and clears `content` (the body becomes empty). The message element is **not** physically removed from the `messages[]` JSONB array, and a `message-deleted` socket event is emitted.
## Virtuals
## ID Field
| Virtual | Returns | Definition |
| --- | --- | --- |
| `participantsCount` | Count of active participants | `backend/src/models/Chat.ts:259` |
The primary key is `id` (PostgreSQL UUID string). There is no `_id` field. The `legacy_object_id` column preserves the original MongoDB ObjectId for records migrated from Mongo, but is not used in application logic.
## Indexes
## Instance / Document Methods (removed)
Defined at `backend/src/models/Chat.ts:243-247`:
Mongoose document methods `.addMessage()`, `.pull()`, and `.markAsRead()` no longer exist. The repository layer (`DrizzleChatRepo`) performs equivalent operations using plain array operations on the JSONB blobs (read → mutate array in JS → write back).
- `{ 'participants.userId': 1 }`
- `{ 'metadata.lastActivity': -1 }`
- `{ 'relatedTo.type': 1, 'relatedTo.id': 1 }`
- `{ 'messages.timestamp': -1 }`
- `{ type: 1 }`
## Pre/Post Hooks
| Hook | Behaviour |
| Former Mongoose method | Replacement |
| --- | --- |
| `pre('save')` (`backend/src/models/Chat.ts:250`) | Updates `metadata.updatedAt` and refreshes `metadata.lastActivity` when there are messages. |
## Instance Methods
| Signature | Purpose |
| --- | --- |
| `getUnreadCount(userId: Types.ObjectId): number` | Returns the unread counter for a participant. `backend/src/models/Chat.ts:264` |
| `addMessage(messageData: Partial<IMessage>): IMessage` | Pushes a message, updates `lastMessage`, increments unread counters for everyone except the sender, and bumps `lastActivity`. `backend/src/models/Chat.ts:270` |
| `markAsRead(userId, messageIds?: Types.ObjectId[]): void` | Marks listed messages (or all when `messageIds` is empty/omitted) as read for the user, zeros their unread counter, and updates `lastSeen`. `backend/src/models/Chat.ts:308` |
## Static Methods
None defined.
| `chat.addMessage(data)` + `chat.save()` | `DrizzleChatRepo.addMessage(chatId, messageData)` — appends to JSONB array, updates `last_message`, increments unread counts, bumps `last_activity` |
| `chat.markAsRead(userId, messageIds?)` + `chat.save()` | `DrizzleChatRepo.markAsRead(chatId, userId, messageIds?)` — mutates `messages` JSONB array and zeroes `unread_counts` for that user |
| `chat.participants.pull(...)` | `DrizzleChatRepo.removeParticipant(chatId, participantId)` — soft-removes by setting `isActive: false`, `leftAt` in JSONB array |
## Relationships
- **References**: [[User]] (`participants[].userId`, `messages[].senderId`, `messages[].reactions[].userId`, `lastMessage.senderId`, `unreadCounts[].userId`, `metadata.createdBy`).
- **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `relatedTo`.
- **References**: [[User]] (`participants[].userId`, `messages[].senderId`, `messages[].reactions[].userId`, `last_message.senderId`, `unread_counts[].userId`, `created_by`).
- **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `related_to`.
## Future Work: Chat Normalization
The current JSONB-blob design unblocked the Mongo → PG migration but leaves these as known future improvements:
1. Design a `chat_messages` table with proper threading/reply support (currently `replyTo` is embedded in the JSONB blob)
2. Design a `chat_participants` table (currently a JSONB blob with soft-removal semantics)
3. Migrate reactions, edit history, and read tracking to relational rows
4. Align unread counts with the new structure
Until that work is complete, participants and messages in the `chats` table are not queryable relationally.
## State Transitions
No top-level status. Chat-level archival is a boolean flag (`settings.isArchived`):
No top-level status. Chat-level archival is a boolean flag (`settings_is_archived`):
```mermaid
stateDiagram-v2
@@ -135,21 +154,17 @@ stateDiagram-v2
## Common Queries
```ts
// A user's recent chats
Chat.find({ 'participants.userId': userId, 'participants.isActive': true })
.sort({ 'metadata.lastActivity': -1 });
// A user's recent chats (DrizzleChatRepo)
await chatRepo.findByParticipant(userId); // filters on participants JSONB, orders by last_activity desc
// Chat for a purchase request
Chat.findOne({ 'relatedTo.type': 'PurchaseRequest', 'relatedTo.id': prId });
await chatRepo.findByRelatedTo('PurchaseRequest', purchaseRequestId);
// Append a message
const chat = await Chat.findById(id);
chat.addMessage({ senderId, content: 'hi', messageType: 'text' });
await chat.save();
await chatRepo.addMessage(chatId, { senderId, content: 'hi', messageType: 'text' });
// Mark read
chat.markAsRead(userId);
await chat.save();
await chatRepo.markAsRead(chatId, userId);
```
Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Dispute]].

View File

@@ -0,0 +1,49 @@
---
title: ConfigSettingHistory
tags: [data-model, mongoose, admin, audit]
aliases: [Setting History, Threshold History, IConfigSettingHistory]
created: 2026-05-30
---
# ConfigSettingHistory
> **Added:** 2026-05-30 — introduced in commit `27fb15a` as part of Task #9 (per-chain confirmation thresholds + audit log).
Audit trail document that records every change to a runtime configuration setting. Currently used exclusively to log confirmation-threshold updates (`key` pattern: `confirmation_threshold:<chainId>`), but the schema is generic and can store other numeric runtime config changes.
> [!note] Source
> `backend/src/models/ConfigSettingHistory.ts` — schema and model export.
> Written by `backend/src/services/payment/safety/confirmationThresholdService.ts` (`setConfirmationThreshold`).
> Read by `GET /api/admin/settings/confirmation-thresholds/history` in `confirmationThresholdRoutes.ts`.
## Schema
| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `key` | String | yes | — | Setting identifier. Format: `confirmation_threshold:<chainId>` for threshold changes. Indexed. |
| `oldValue` | Number | no | `null` | Value before the change. `null` when the setting had no prior database entry. |
| `newValue` | Number | yes | — | Value after the change. |
| `changedBy` | ObjectId (ref: `User`) | no | — | Admin user who made the change. Populated by `GET …/history` via `.populate('changedBy', 'email name')`. |
| `changedAt` | Date | no | `Date.now()` | Timestamp of the change. Indexed; used for sort-descending pagination. |
> [!note] No `timestamps: false`
> The schema deliberately disables Mongoose's automatic `createdAt`/`updatedAt` fields (`timestamps: false`) because `changedAt` is the canonical timestamp.
## Example document
```json
{
"_id": "6657c3...",
"key": "confirmation_threshold:56",
"oldValue": 12,
"newValue": 6,
"changedBy": { "_id": "...", "email": "admin@amn.gg" },
"changedAt": "2026-05-30T10:22:00.000Z"
}
```
## Related
- [[Payment API]] — `GET /api/admin/settings/confirmation-thresholds/history`
- [[Admin API]] — confirmation thresholds section
- `backend/src/services/payment/safety/confirmationThresholdService.ts`

View File

@@ -1,43 +1,57 @@
---
title: Data Model Overview
tags: [data-model, mongoose, overview]
tags: [data-model, postgres, drizzle, overview]
aliases: [Models Index, Schema Overview]
---
# Data Model 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.
This section documents every Drizzle/PostgreSQL table that backs the marketplace. PostgreSQL is the primary and sole data store as of v2.9.12 (2026-06-06). The Mongo dual-write layer has been retired; all reads and writes are served from Postgres. The Drizzle schema has 17 applied migrations (00000017).
> [!note] Scope
> Twenty-two models are present in `backend/src/models/`. 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.
> Twenty-two domain entities are modelled. The "File" concept exists only at the service layer (`backend/src/services/file/`) and is not persisted as its own table, so it is not listed below.
>
> [!note] Documentation freshness
> The 2026-05-24 audit note that marked `Dispute`, `BlogPost`, `Review`, `PointTransaction`, `LevelConfig`, and `ShopSettings` as missing is now stale: schema files exist for those models. Newer operational models such as [[ConfigSetting]], [[DerivedDestination]], [[FundsLedgerEntry]], and [[TrezorAccount]] should be expanded into dedicated model pages when the docs are next deepened.
> As of 2026-06-06 (v2.9.12) the Postgres migration inventory reflects migrations 00000017. The table inventory at the bottom of this page is the authoritative schema-status reference. Individual model pages should be updated to note their PG table name and any notable constraints.
> [!info] PostgreSQL runtime status
> PostgreSQL is the sole data store for all domain tables. The Mongo dual-write layer has been fully retired. All reads and writes now go directly to Postgres. Infra/bridge tables (`id_map`, `pg_dualwrite_gaps`) and oracle quote rows (`payment_quotes`) remain PG-only as before.
## Index of Models
- [[User]] — Core identity. Stores credentials, profile, preferences, referral data, points, and WebAuthn passkeys. Every other model that records "who did what" points back at a `User._id`. Buyers, sellers, and admins all live in this collection, differentiated by a `role` enum.
- [[PurchaseRequest]] — The buyer-side document at the heart of the marketplace. Captures what a buyer wants, the budget, urgency, delivery preferences, and the full lifecycle status (`pending_payment``seller_paid`). Aggregates [[SellerOffer]] references and tracks delivery codes.
- [[SellerOffer]] — A seller's bid against a [[PurchaseRequest]]. Holds price, delivery ETA, attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`).
- [[Payment]] — Records monetary movement intent and state: buyer pay-in, seller release, and refund. The current primary provider path is Request Network plus in-house checkout, derived destinations, funds ledger entries, and Transaction Safety Provider metadata.
- [[Chat]] — Conversation container with embedded messages, participants, unread counters, and reactions. Used for direct buyer-seller chats, group chats, and support tickets. Can be linked to a [[PurchaseRequest]] or [[SellerOffer]].
- [[Notification]] — Per-user notification with category, type, and 90-day TTL for automatic cleanup. References any related entity by stringified id.
- [[RequestTemplate]] — A seller-authored, sharable template that pre-fills a [[PurchaseRequest]]. Carries a public shareable link, usage counter, and an optional default proposal.
- [[Dispute]] — Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures evidence uploads, a timeline of admin actions, deadlines, and a structured resolution.
- [[BlogPost]] — Editorial content: title, slug, rich content, media, SEO metadata, view/like counters, and a draft/published/archived workflow.
- [[Address]] — User shipping address book entry. Enforces a single primary address per user via a pre-save hook.
- [[Category]] — Hierarchical product/service taxonomy referenced by [[PurchaseRequest]] and [[RequestTemplate]]. Supports parent/child via `parentId` and bilingual `name` / `nameEn`.
- [[Review]] — Polymorphic 1-5 star review against either a seller or a [[RequestTemplate]] (`subjectType` discriminator). One review per reviewer per subject (compound unique index).
- [[PointTransaction]] — Ledger of point grants and spends per user. Sources include purchase, referral, bonus, admin grant, and redemption.
- [[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`.
- [[ConfigSetting]] — Runtime configuration persisted in MongoDB for operational knobs that need an admin surface rather than a deploy.
- [[DerivedDestination]] — Per-payment derived wallet destination records used to reduce address reuse and reconcile on-chain pay-ins.
- [[FundsLedgerEntry]] — Immutable accounting ledger rows for pay-in, hold, release, refund, fee, adjustment, and reversal events.
- [[TrezorAccount]] — Hardware-wallet/safekeeping account metadata for custody operations and staged signer hardening.
### Domain Models
- [[User]] — Core identity. Stores credentials, profile, preferences, referral data, points, and WebAuthn passkeys. Every other model that records "who did what" points back at a `User.id` (UUID). Buyers, sellers, admins, resolvers, and guards all live in this table, differentiated by a `role` enum. PG table: `users`.
- [[PurchaseRequest]] — The buyer-side record at the heart of the marketplace. Captures what a buyer wants, the budget, urgency, delivery preferences, and the full lifecycle status (`pending_payment``seller_paid`). Aggregates [[SellerOffer]] references and tracks delivery codes. PG table: `purchase_requests` + 6 child tables.
- [[SellerOffer]] — A seller's bid against a [[PurchaseRequest]]. Holds price, delivery ETA, attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). PG table: `seller_offers`.
- [[Payment]] — Records monetary movement intent and state: buyer pay-in, seller release, and refund. The current primary provider path is Request Network plus in-house checkout, derived destinations, funds ledger entries, and Transaction Safety Provider metadata. PG table: `payments`.
- [[Chat]] — Conversation container with embedded messages, participants, unread counters, and reactions. Used for direct buyer-seller chats, group chats, and support tickets. Can be linked to a [[PurchaseRequest]] or [[SellerOffer]]. PG table: `chats` (JSONB shim; Chat normalization is an open follow-up).
- [[Notification]] — Per-user notification with category, type, and 90-day TTL for automatic cleanup. References any related entity by stringified id. PG table: `notifications` (`user_id` stored as `text`, no hard FK).
- [[RequestTemplate]] — A seller-authored, sharable template that pre-fills a [[PurchaseRequest]]. Carries a public shareable link, usage counter, and an optional default proposal. PG table: `request_templates`.
- [[Dispute]] — Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures evidence uploads, a timeline of admin actions, deadlines, and a structured resolution. PG table: `disputes` (all IDs as `text` for legacy coexistence).
- [[BlogPost]] — Editorial content: title, slug, rich content, media, SEO metadata, view/like counters, and a draft/published/archived workflow. PG table: `blog_posts`.
- [[Address]] — User shipping address book entry. Enforces a single primary address per user via a partial-unique index. PG table: `addresses` (migration 0016; `addressStore.ts` reads PG directly).
- [[Category]] — Hierarchical product/service taxonomy referenced by [[PurchaseRequest]] and [[RequestTemplate]]. Supports parent/child via `parent_id` and bilingual `name` / `name_en`. PG table: `categories`.
- [[Review]] — Polymorphic 1-5 star review against either a seller or a [[RequestTemplate]] (`subject_type` discriminator). One review per reviewer per subject (compound unique index). PG table: `reviews` (schema scaffolded, no write repo yet).
- [[PointTransaction]] — Ledger of point grants and spends per user. Sources include purchase, referral, bonus, admin grant, and redemption. PG table: `point_transactions`.
- [[LevelConfig]] — Static configuration of loyalty tiers (level number, point thresholds, benefits, icon, color). Driven by admins; consumed by the `users.points_level` field. No PG table (read-only config; not yet migrated).
- [[ShopSettings]] — One-to-one storefront branding for a seller: name, description, avatar, cover image, review toggles, and social links. PG table: `shop_settings` (schema scaffolded, no write repo yet).
- [[TempVerification]] — Short-lived signup record that holds candidate user data and a verification code. Auto-purges via scheduled job when `email_verification_code_expires` passes. No PG table (TTL-only; not yet migrated).
- [[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 `user_id` and `telegram_user_id`). PG table: `telegram_links` (schema scaffolded, no write repo yet).
- [[TelegramSession]] — Short-lived Telegram Mini App session token issued when `initData` is verified. Carries the `init_data_fingerprint` for replay protection and auto-expires via scheduled cleanup on `expires_at`. PG table: `telegram_sessions` (schema scaffolded, no write repo yet).
- [[ConfigSetting]] — Runtime configuration persisted in Postgres for operational knobs that need an admin surface rather than a deploy. PG table: `config_settings` (schema scaffolded, no write repo yet).
- [[DerivedDestination]] — Per-payment derived wallet destination records used to reduce address reuse and reconcile on-chain pay-ins. PG table: `derived_destinations` + `derived_destination_sweeps`.
- [[FundsLedgerEntry]] — Immutable accounting ledger rows for pay-in, hold, release, refund, fee, adjustment, and reversal events. PG table: `funds_ledger_entries` (immutability enforced by DB trigger since migration 0015).
- [[TrezorAccount]] — Hardware-wallet/safekeeping account metadata for custody operations and staged signer hardening. PG table: `trezor_accounts` + `trezor_derived_addresses`.
- [[ConfigSettingHistory]] — Immutable audit trail of numeric runtime-config changes. Currently used for per-chain confirmation threshold change events, keyed as `confirmation_threshold:<chainId>`. Added in commit `27fb15a`. PG table: `config_setting_history` (PG-only; no legacy equivalent).
### PG-Only Tables (infrastructure / bridge)
- `id_map` — Legacy ObjectId → UUID bridge. Retained for reference during any remaining data reconciliation. Composite PK on `(collection, legacy_object_id)`, unique on `new_id`.
- `pg_dualwrite_gaps` — Append-only reconciliation gap log from the dual-write era. Tracks collection, op, payload, severity, and resolution metadata.
- `payment_quotes` — Oracle pricing quotes per payment (oracle depeg-protection feature). Stores `fx_rate`, `token_price_usd`, `depeg_adjustment_bps`, `settle_amount`, chain/token, and expiry. Requires `ORACLE_QUOTING_ENABLED=true`. 1:1 to `payments`.
- `user_passkeys` — WebAuthn credential store (child of `users`). Columns: credential id (text PK), `user_id FK→users CASCADE`, `public_key`, `counter`, `device_type`, `device_name`.
- `user_refresh_tokens` — Refresh token store (child of `users`). Columns: `token text PK`, `user_id FK→users CASCADE`.
## Relationship Diagram
@@ -55,6 +69,10 @@ erDiagram
USER ||--o{ DISPUTE : "raises as buyer"
USER ||--o{ USER : "referred by"
USER ||--o{ TREZOR_ACCOUNT : "controls custody account"
USER ||--o{ USER_PASSKEY : "authenticates with"
USER ||--o{ USER_REFRESH_TOKEN : "sessions via"
USER ||--o| TELEGRAM_LINK : "links identity"
USER ||--o{ TELEGRAM_SESSION : "session for"
PURCHASE_REQUEST }o--|| CATEGORY : "belongs to"
PURCHASE_REQUEST ||--o{ SELLER_OFFER : "receives"
@@ -70,6 +88,7 @@ erDiagram
PAYMENT }o--|| USER : "seller"
PAYMENT ||--o{ FUNDS_LEDGER_ENTRY : "accounted by"
PAYMENT ||--o| DERIVED_DESTINATION : "collects into"
PAYMENT ||--o| PAYMENT_QUOTE : "oracle-priced by"
CHAT }o--o{ USER : "participants"
CHAT ||--o{ DISPUTE : "support channel"
@@ -87,35 +106,152 @@ erDiagram
TELEGRAM_LINK }o--|| USER : "links identity"
TELEGRAM_SESSION }o--o| USER : "session for"
TELEGRAM_SESSION }o--|| TELEGRAM_LINK : "matches"
TREZOR_ACCOUNT ||--o{ TREZOR_DERIVED_ADDRESS : "issues"
DERIVED_DESTINATION ||--o{ DERIVED_DESTINATION_SWEEP : "swept by"
ID_MAP ||..|| USER : "bridges legacy id"
```
## Conventions Across All Models
### Drizzle/PostgreSQL Conventions
> [!note] Shared schema patterns
> - **Timestamps**: every model declares `{ timestamps: true }`, so `createdAt` and `updatedAt` are always present.
> - **ObjectId references**: foreign keys use `Schema.Types.ObjectId` with an explicit `ref` (e.g. `ref: 'User'`). The two exceptions are [[Notification]] and [[Payment]] which use string-typed or `Mixed` identifiers in places to support template-flow payments.
> - **Soft delete**: deletion is modelled as a `status` flag (e.g. `User.status = 'deleted'`, `BlogPost.status = 'archived'`) rather than physical removal.
> - **TTL indexes**: short-lived collections ([[Notification]], [[TempVerification]]) use `{ expireAfterSeconds: ... }` so MongoDB does the cleanup.
> - **toJSON sanitisation**: [[User]] overrides `toJSON` to strip credentials, refresh tokens, and verification codes before serialisation.
> - **Timestamps**: every table declares `created_at` and `updated_at timestamptz` with `withTimezone: true`.
> - **Primary keys**: all tables use `id uuid` (generated via `gen_random_uuid()` or application-side UUID v4). There are no integer sequences for domain tables.
> - **UUID references**: foreign keys reference the `id uuid` column of the target table (e.g. `user_id uuid REFERENCES users(id)`). The two exceptions are [[Notification]] and [[Payment]] which use `text`-typed identifiers in places to support template-flow payments.
> - **Soft delete**: deletion is modelled as a `status` flag (e.g. `users.status = 'deleted'`, `blog_posts.status = 'archived'`) rather than physical removal. `addresses` uses `deleted_at timestamptz` (nullable) with partial-unique indexes scoped to `WHERE deleted_at IS NULL`.
> - **TTL cleanup**: short-lived tables ([[TempVerification]], [[TelegramSession]]) rely on scheduled cleanup jobs rather than database-level TTL.
> - **JSON sanitisation**: [[User]] service layer strips credentials, refresh tokens, and verification codes before serialisation.
> [!warning] Index discipline
> Several schemas leave a comment noting that `unique: true` already creates an index — adding `schema.index({ field: 1 })` on top would produce a duplicate-index warning at startup. When introducing new indexes, search for `unique: true` first.
> Several tables carry both a `UNIQUE` constraint and would otherwise duplicate an index — check for existing unique constraints before adding explicit `CREATE INDEX` statements to avoid duplicate-index warnings at startup.
> [!note] PG schema patterns
> - **Legacy bridge**: `id_map` records the old ObjectId → UUID mapping for any reconciliation needs. The `legacy_object_id text` column with a partial-unique index `WHERE legacy_object_id IS NOT NULL` is retained on migrated tables for idempotent reconciliation upserts.
> - **Money columns**: `numeric(38,18)` for fiat/crypto amounts throughout, except `seller_offers` which uses `numeric(18,8)` per the Migration Guide. Blockchain balance columns use `numeric(78,0)` to hold uint256 without overflow.
> - **Polymorphic triples**: the `ref_kind` enum (`entity` | `template`) discriminator is expanded into three columns (`_ref_kind`, `_id`, `_external_ref`) with a CHECK constraint to enforce discriminator integrity. Used by `payments`, `funds_ledger_entries`, and `derived_destinations`.
> - **Immutability**: `funds_ledger_entries` has both an UPDATE-blocking and a DELETE-blocking trigger installed at the DB level (migrations 0004, 0015). A TRUNCATE trigger was added in migration 0013.
> - **user_role enum**: values are `admin`, `buyer`, `seller`, `resolver`, `guard`. The `guard` value was added in migration 0017.
## Postgres Migration Inventory
Schema entry point: `backend/src/db/schema/index.ts`
| Migration | File | Summary |
|---|---|---|
| 0000 | `0000_slimy_veda.sql` | Initial: core enums + `id_map` + `categories` |
| 0001 | `0001_wild_cargill.sql` | `trezor_accounts` + `trezor_derived_addresses` (later reset) |
| 0002 | `0002_motionless_grey_gargoyle.sql` | Schema reset: drops 0000/0001 tables to be rebuilt in 0003; adds `categories.parent_id` self-FK |
| 0003 | `0003_remarkable_retro_girl.sql` | Comprehensive rebuild: all enums + full core domain (`users`, `payments`, `funds_ledger_entries`, `derived_destinations`, `purchase_requests` + 6 children, `seller_offers`, `point_transactions`, `trezor_*`) |
| 0004a | `0004_funds_ledger_entries.sql` | UPDATE-blocking immutability trigger on `funds_ledger_entries` |
| 0004b | `0004_seller_offer.sql` | Physical FKs on `seller_offers``users` and `purchase_requests` (CASCADE) |
| 0005 | `0005_simple_champions.sql` | `pg_dualwrite_gaps`; FKs on `payments`; `legacy_object_id` unique indexes; refined pending-RN payment unique index |
| 0006 | `0006_normal_madame_hydra.sql` | CHECK: `purchase_requests.budget_currency` restricted to crypto (USDT, USDC) |
| 0007 | `0007_woozy_shaman.sql` | Drops 0006 constraint; sets `budget_currency` default to `'USDT'` |
| 0008 | `0008_giant_winter_soldier.sql` | Adds `'TRY'` to `offer_currency` enum; creates `payment_quotes` table |
| 0009 | `0009_unique_active_categories.sql` | Category deduplication; partial unique index on normalized active category name |
| 0010 | `0010_request_templates.sql` | Creates `request_templates`; deduplicates `purchase_request_specifications`; adds unique key constraint |
| 0011 | `0011_chats.sql` | Creates `chats` with JSONB participant/message storage + chat-related enums |
| 0012 | `0012_disputes.sql` | Creates `disputes` (text IDs, JSONB evidence/timeline/resolution) |
| 0013 | `0013_money_constraints.sql` | Money-integrity CHECKs on `payments`, `payment_quotes`, `point_transactions`, `users`; TRUNCATE trigger on `funds_ledger_entries`; composite PK + unique on `id_map` |
| 0014 | `0014_physical_fks.sql` | NOT VALID FKs across all major tables (validated immediately); composite indexes on `payments`, `purchase_requests`, `seller_offers` |
| 0015 | `0015_funds_ledger_immutable_trigger.sql` | Replaces/extends ledger triggers: UPDATE-block + new DELETE-block on `funds_ledger_entries` |
| 0016 | `0016_addresses_table.sql` | `address_type` enum + `addresses` table; partial-unique primary-address-per-user index |
| 0017 | `0017_user_role_guard.sql` | Adds `'guard'` to `user_role` enum (idempotent `ADD VALUE IF NOT EXISTS`) |
## Drizzle Table Inventory
### Infrastructure / Bridge
| PG Table | Schema File | Status | Notes |
|---|---|---|---|
| `id_map` | `idMap.ts` | PG-only | Legacy ObjectId → UUID bridge; composite PK + unique on `new_id` |
| `pg_dualwrite_gaps` | `pgDualwriteGaps.ts` | PG-only | Append-only reconciliation gap log from dual-write era |
### Core Domain
| PG Table | Schema File | Status | Notes |
|---|---|---|---|
| `users` | `users.ts` | Active | `DrizzleUserRepo` |
| `user_passkeys` | `users.ts` | Active (child of users) | — |
| `user_refresh_tokens` | `users.ts` | Active (child of users) | — |
| `categories` | `category.ts` | Active | `DrizzleMarketplaceRepo` |
| `purchase_requests` | `purchaseRequest.ts` | Active | `DrizzleMarketplaceRepo` |
| `purchase_request_delivery_info` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `purchase_request_delivery_address` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `purchase_request_seller_delivery_info` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `delivery_attempts` | `purchaseRequest.ts` | Active (1:N child) | — |
| `purchase_request_service_info` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `purchase_request_specifications` | `purchaseRequest.ts` | Active (1:N child) | — |
| `purchase_request_preferred_sellers` | `purchaseRequest.ts` | Active (N:M junction) | — |
| `seller_offers` | `sellerOffer.ts` | Active | `DrizzleMarketplaceRepo` |
| `payments` | `payment.ts` | Active | `DrizzlePaymentRepo` |
| `payment_quotes` | `paymentQuote.ts` | PG-only | No legacy equivalent; oracle depeg-protection feature |
| `funds_ledger_entries` | `fundsLedgerEntry.ts` | Active | `DrizzlePaymentRepo` |
| `derived_destinations` | `derivedDestination.ts` | Active | `DrizzleDerivedDestinationRepo` |
| `derived_destination_sweeps` | `derivedDestination.ts` | Active (append-only child) | — |
| `trezor_accounts` | `trezorAccount.ts` | Active | `DrizzleTrezorAccountRepo` |
| `trezor_derived_addresses` | `trezorAccount.ts` | Active (child of trezor_accounts) | — |
| `point_transactions` | `pointTransaction.ts` | Active | `DrizzlePointsRepo` |
| `request_templates` | `requestTemplate.ts` | Active | `DrizzleMarketplaceRepo` |
| `chats` | `chat.ts` | Active | `DrizzleChatRepo` |
| `blog_posts` | `blogPost.ts` | Active | `DrizzleBlogRepo` |
| `notifications` | `notification.ts` | Active | `DrizzleNotificationRepo` |
| `disputes` | `dispute.ts` | Active | `DrizzleDisputeRepo` |
| `addresses` | `address.ts` | Schema scaffolded | No write repo; `addressStore.ts` reads PG directly (migration 0016) |
| `shop_settings` | `shopSettings.ts` | Schema scaffolded | No write repo |
| `config_settings` | `configSetting.ts` | Schema scaffolded | No write repo |
| `config_setting_history` | `configSetting.ts` | PG-only | No legacy equivalent; child of `config_settings` |
| `telegram_links` | `telegramLink.ts` | Schema scaffolded | No write repo |
| `telegram_sessions` | `telegramSession.ts` | Schema scaffolded | No write repo |
| `reviews` | `review.ts` | Schema scaffolded | No write repo |
> [!note] Status key
> **Active** means reads and writes are served from Postgres. **Schema scaffolded** means the Drizzle table exists but no repo wires it into the service layer yet. **PG-only** means there is no legacy model for that data.
## Shared Enum Reference
Enums live in `backend/src/db/schema/_enums.ts` (shared) and individual schema files. Key enums:
| Enum | Values |
|---|---|
| `user_role` | admin, buyer, seller, resolver, guard |
| `auth_provider` | email, google, telegram |
| `user_status` | active, suspended, deleted |
| `purchase_request_status` | pending_payment, pending, received_offers, in_negotiation, payment_pending, payment_confirmed, in_progress, delivery, delivered, completed, disputed, refunded, seller_paid |
| `offer_status` | pending, accepted, rejected, withdrawn, active |
| `offer_currency` | USD, EUR, IRR, USDT, USDC, TRY |
| `payment_provider` | request.network, amn.scanner, shkeeper, other |
| `payment_status` | pending, processing, confirmed, completed, failed, cancelled, refunded |
| `escrow_state` | funded, releasable, released, refunded, releasing, failed, cancelled, partial |
| `funds_ledger_entry_type` | payment_detected, provider_fee, platform_fee, hold, release, refund, dispute_hold, adjustment |
| `derived_destination_status` | active, swept, sweeping, quarantined |
| `ref_kind` | entity, template |
| `chat_type` | direct, group, support |
| `review_subject_kind` | seller, template |
| `address_type` | Home, Office, Other |
| `telegram_link_source` | miniapp, bot, login_widget |
| `telegram_link_status` | active, blocked |
## Lifecycle View
The dominant happy-path flow exercises five collections in order:
The dominant happy-path flow exercises five tables in order:
1. A buyer (`User`) creates a `PurchaseRequest` with `status: 'pending'`.
2. Sellers (other `User`s) attach `SellerOffer` documents; the request transitions through `received_offers``in_negotiation` as the parties chat in a `Chat`.
3. The buyer accepts an offer; a `Payment` is opened against the Request Network provider and, once verified by webhook/reconciliation and safety checks, advances to a funded escrow state.
4. The seller marks the request `delivery``delivered`; the buyer confirms with the 6-digit `deliveryCode` and the request becomes `completed`.
5. The escrow `Payment` flips to `released` after a ledger-gated custody transfer instruction. Optionally the buyer writes a `Review` and earns a `PointTransaction`.
1. A buyer (`users`) creates a `purchase_requests` row with `status: 'pending'`.
2. Sellers (other `users` rows) attach `seller_offers` rows; the request transitions through `received_offers``in_negotiation` as the parties chat in a `chats` row.
3. The buyer accepts an offer; a `payments` row is opened against the Request Network provider and, once verified by webhook/reconciliation and safety checks, advances to a funded escrow state. If `ORACLE_QUOTING_ENABLED=true`, a `payment_quotes` row is written to PG at this point.
4. The seller marks the request `delivery``delivered`; the buyer confirms with the 6-digit `delivery_code` and the request becomes `completed`.
5. The escrow `payments` row flips to `released` after a ledger-gated custody transfer instruction. Each ledger event appends an immutable `funds_ledger_entries` row. Optionally the buyer writes a `reviews` row and earns a `point_transactions` row.
If anything goes sideways, the buyer can open a `Dispute`, which freezes release until an admin resolves it (refund, replacement, compensation, or no-action).
If anything goes sideways, the buyer can open a `disputes` row, which freezes release until an admin resolves it (refund, replacement, compensation, or no-action).
## How to Navigate
Each model has its own note in this folder. Cross-references use `[[wikilinks]]` so backlinks work in Obsidian's graph view. Schemas are documented at field-level granularity — every field is listed with its type, default, validation, and indexing decisions. Where a model carries a meaningful state machine, a Mermaid `stateDiagram-v2` accompanies the schema table.
> [!note] Source of truth
> The information below is mirrored from the TypeScript schema definitions. If a field listed here disagrees with the code, the code wins — please update the note. All source citations use the form `backend/src/models/<File>.ts:<line>`.
> The information below is mirrored from the TypeScript schema definitions. If a field listed here disagrees with the code, the code wins — please update the note. All source citations use the form `backend/src/db/schema/<File>.ts:<line>` for Drizzle/PG.
>
> Last updated: v2.9.12 / 2026-06-06

View File

@@ -1,23 +1,27 @@
---
title: Dispute
tags: [data-model, mongoose]
tags: [data-model, mongoose, postgres]
aliases: [Complaint, IDispute]
---
# Dispute
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-06-03 — added Postgres / Drizzle schema and migration status (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures the reason, priority, category, an array of evidence uploads, a chronological `timeline` of actions, an optional resolution, and SLA deadlines. An admin (`adminId`) is assigned during triage and resolves the dispute with a structured action (`refund`, `replacement`, `compensation`, `warning_seller`, `ban_seller`, or `no_action`).
> [!note] Implementation status
> `backend/src/models/Dispute.ts`, `backend/src/services/dispute/DisputeService.ts`, `backend/src/routes/disputeRoutes.ts`, and release-hold helper routes now exist. The remaining gap is canonical state alignment between the full dispute document and the lighter `PurchaseRequest`/`Payment` hold flags used by release gating.
>
> Source: `backend/src/models/Dispute.ts` — schema definition and model export.
> Sources: `backend/src/models/Dispute.ts` (Mongoose schema), `backend/src/db/schema/dispute.ts` (Drizzle/Postgres schema).
> ⚠️ **SECURITY** — The dispute `status` update endpoint and the `resolve` endpoint currently have **no role guards**. Any authenticated user (not just admins) can modify dispute status or submit a resolution. This is a known gap pending a role-guard audit.
> WARNING — The dispute `status` update endpoint and the `resolve` endpoint currently have **no role guards**. Any authenticated user (not just admins) can modify dispute status or submit a resolution. This is a known gap pending a role-guard audit.
## Schema
## Migration Status
**DUAL-WRITE**`DualWriteDisputeRepo` + `DrizzleDisputeRepo` + `MongoDisputeRepo`. Writes go to both Mongo and Postgres. Reads still come from Mongo (cutover not yet executed).
## Mongo Schema
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
@@ -57,26 +61,80 @@ Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures the reason, prior
Valid values: `product_quality` · `delivery_delay` · `wrong_item` · `payment_issue` · `seller_behavior` · `other`
**Note:** `fraud` is **not** a valid category value. Use `seller_behavior` or `other` for fraud-related complaints.
Note: `fraud` is NOT a valid category value. Use `seller_behavior` or `other` for fraud-related complaints.
### Status enum
Valid values: `pending` · `in_progress` · `waiting_response` · `resolved` · `rejected` · `closed`
**Note:** `under_review` does **not** exist in the schema. The equivalent lifecycle state is `in_progress`.
Note: `under_review` does NOT exist in the schema. The equivalent lifecycle state is `in_progress`.
### Resolution action enum
Valid values: `refund` · `replacement` · `compensation` · `warning_seller` · `ban_seller` · `no_action`
> [!note] `messages` in the interface
> The TypeScript interface mentions an optional embedded `messages[]` array, but the actual Mongoose schema does not declare it — messages live in [[Chat]] via `chatId`.
Note: The TypeScript interface mentions an optional embedded `messages[]` array, but the actual Mongoose schema does not declare it — messages live in [[Chat]] via `chatId`.
## Postgres / Drizzle Schema
Source: `backend/src/db/schema/dispute.ts` — migration 0012.
### `disputes` table
| Column | PG Type | Notes |
| --- | --- | --- |
| `id` | `uuid` PK | Generated UUID primary key. |
| `legacy_object_id` | `text` | Mongo ObjectId bridge; partial-unique WHERE NOT NULL. |
| `purchase_request_id` | `text` | Stored as text (not uuid FK) to accommodate Mongo ObjectIds and PG UUIDs during cutover. No hard FK. |
| `buyer_id` | `text` | Same cutover reason — text, no hard FK. |
| `seller_id` | `text` | Optional; text, no hard FK. |
| `admin_id` | `text` | Optional; text, no hard FK. |
| `reason` | `text` | Short reason. |
| `description` | `text` | Detailed description. |
| `priority` | `text` | No DB-level enum; app-layer validated. |
| `category` | `text` | No DB-level enum; app-layer validated. |
| `status` | `text` | No DB-level enum; app-layer validated. |
| `evidence` | `jsonb` | Array of evidence objects (serialized). |
| `chat_id` | `text` | Optional; text reference to Chat. |
| `messages` | `jsonb` | Embedded messages blob (conservative shim; normalization pending). |
| `timeline` | `jsonb` | Array of timeline action objects. |
| `resolution` | `jsonb` | Resolution object when resolved. |
| `deadline` | `timestamptz` | Overall SLA deadline. |
| `response_deadline` | `timestamptz` | Response SLA. |
| `tags` | `jsonb` | Array of tag strings. |
| `created_at` | `timestamptz` | Auto-managed. |
| `updated_at` | `timestamptz` | Auto-managed. |
| `closed_at` | `timestamptz` | Set when status reaches `closed`. |
> [!note] ID columns as `text`
> `purchase_request_id`, `buyer_id`, `seller_id`, and `admin_id` are all stored as `text` (not `uuid` with a FK) to accommodate both legacy Mongo ObjectIds and PG UUIDs transparently during the cutover window. No referential integrity constraints exist at the DB layer for these columns.
> [!note] `messages` jsonb column
> The Postgres schema includes a `messages jsonb` column that is absent from the Mongo schema (where messages live in Chat via `chatId`). This is a conservative shim added during migration scaffolding. Full normalization of chat/messages is flagged as an open blocker.
### Postgres Indexes
| Index | Type | Notes |
| --- | --- | --- |
| `(legacy_object_id)` WHERE NOT NULL | partial-unique | Idempotent backfill upserts. |
| `(purchase_request_id)` | regular | Lookup by request. |
| `(buyer_id)` | regular | Buyer's disputes. |
| `(seller_id)` | regular | Seller's disputes. |
| `(admin_id)` | regular | Admin workload. |
| `(status)` | regular | Lifecycle filtering. |
| `(priority)` | regular | Priority filtering. |
| `(category)` | regular | Category filtering. |
| `(created_at)` | regular | Time-ordered listing. |
| `(status, priority)` | compound | Admin queue sort. |
| `(admin_id, status)` | compound | Per-admin workload view. |
Mirrors the Mongo index set exactly.
## Virtuals
None defined.
## Indexes
## Mongo Indexes
Defined at `backend/src/models/Dispute.ts`:
@@ -128,21 +186,35 @@ stateDiagram-v2
## Common Queries
```ts
// Admin queue
// Admin queue (Mongo)
Dispute.find({ status: { $in: ['pending', 'in_progress', 'waiting_response'] } })
.sort({ priority: -1, createdAt: 1 });
// Buyer's disputes
// Buyer's disputes (Mongo)
Dispute.find({ buyerId }).sort({ createdAt: -1 });
// Seller's open disputes
// Seller's open disputes (Mongo)
Dispute.find({ sellerId, status: { $nin: ['resolved', 'rejected', 'closed'] } });
// Append timeline entry atomically
// Append timeline entry atomically (Mongo)
Dispute.updateOne(
{ _id },
{ $push: { timeline: { action, performedBy: adminId, performedAt: new Date(), details } } }
);
```
```sql
-- Admin queue (Postgres)
SELECT * FROM disputes
WHERE status IN ('pending', 'in_progress', 'waiting_response')
ORDER BY priority DESC, created_at ASC;
-- Buyer's disputes (Postgres)
SELECT * FROM disputes WHERE buyer_id = $1 ORDER BY created_at DESC;
-- Seller's open disputes (Postgres)
SELECT * FROM disputes
WHERE seller_id = $1 AND status NOT IN ('resolved', 'rejected', 'closed');
```
Related: [[PurchaseRequest]], [[User]], [[Chat]], [[Payment]].

View File

@@ -0,0 +1,120 @@
---
title: Money-Core Migration Workflow — Audit Report
tags: [migration, audit, workflow, postgres, drizzle]
created: 2026-05-31
target: .claude/workflows/pg-money-core-migration.js
verdict: go-with-changes (0 blockers)
companion: "[[MongoDB to PostgreSQL Migration Plan (Drizzle)]]"
updated: 2026-05-31 after backend integrate-main-into-development@3a50dc4
---
> [!info] Provenance
> Generated 2026-05-31 by a 29-agent read-only audit workflow (4 review dimensions → adversarial verification → synthesis) over the `pg-money-core-migration` workflow design + the real money-core backend code. All recommended changes below have since been **applied** to the workflow script.
# Audit Report — `pg-money-core-migration.js` Workflow
**Target:** `/Users/manwe/CascadeProjects/escrow/.claude/workflows/pg-money-core-migration.js`
**Status:** Not yet run. 8 phases (Preflight → Infra → Schema → Repos → Backfill → Tests → VerifyGate → SelfAudit).
**Adversarial verification:** Every `high`/`blocker` finding was challenged against the actual script. **All of them were refuted.** The remaining open items are `medium`/`low` enforcement-gap concerns that were not independently re-verified (`n/a`).
> [!warning] Historical scope
> This is a workflow audit, not the current runtime status. The backend branch now contains the generated Postgres layer, but Mongo remains authoritative for normal traffic and service-layer wiring is still incomplete. Use [[Postgres Runtime Cutover Status]] for the current code/runtime boundary.
---
## 1. Verdict: **GO (with recommended changes)**
The workflow **cannot itself cause fraud or fund/order dataloss**, and that is the load-bearing question. Three independent, code-level facts make it safe to *run*:
1. **It never touches production data.** SAFETY rule 1 (lines 3436, 67) binds every agent to code/SQL/test generation only; any DB the harness spins up is an ephemeral local throwaway. The backfill scripts are generated as *artifacts for humans*, explicitly never auto-run (line 160), and are gated on a non-prod `MIGRATION_PG_URL` allowlist.
2. **It cannot deploy.** It works on an isolated branch `feat/pg-money-core-migration` cut from `origin/main` (lines 5455, 91), is additive-only under `backend/src/db/**` (rule 2), never bumps version (rule 3), and never pushes / opens a PR / flips a flag (rule 4). Per the CI audit (BRANCH_001, DEPLOY_001), creating or committing to this branch fires no CI; only a human push to `main`/`master` or a `v*` tag deploys — outside the workflow's reach.
3. **The worst realistic failure is recoverable.** VerifyGate commits only on green typecheck+tests (lines 216217), and to a *local* branch. Even the post-commit SelfAudit ordering (SAFETY_001, refuted) is benign because the commit is local and reversible; the verdict still surfaces to the human before any push.
Every adversarially-tested `blocker`/`high` claim was **refuted** because the corresponding safety constraint *is* present in the prompts (SAFETY rules 17, plus per-phase reinforcement on schema indexes line 127, transactions line 145, dual-write line 146, decimals line 126, backfill guard line 160). The legitimate residual risk is that these are **prompt-level instructions, not code-enforced gates** — an agent could under-implement one and VerifyGate (which only runs typecheck+tests) would not catch it. That is a *quality/trust* gap, not a *safety* gap, and it is fully contained by the mandatory human review before cutover. Hence: go, but tighten the gates below first so the human reviewer is handed verifiable evidence instead of having to re-derive it.
**Confirmed blockers: 0.**
---
## 2. Confirmed Blockers (must fix before first run)
**None.** All `blocker`/`high`-severity findings were adversarially verified and **refuted** — the safety contract they claimed was missing is in fact specified in the workflow prompts. The workflow is safe to run as-is. The items in §3 are improvements that raise trust and catch agent under-implementation; they are not gating.
| ID | Why it is NOT a blocker |
|---|---|
| IDEMPOTENCY_INDEX_PRESERVATION / FK-010 | Line 127 explicitly mandates the partial `WHERE provider='request.network' AND direction='in' AND status='pending'` and the ledger sparse-unique. Not enforced in code, but specified + audited by SelfAudit (line 240). |
| DECIMAL_PRECISION_TRUNCATION_RISK / FK-009 | Line 126 mandates `numeric(38,18)`, never float; SelfAudit hunts float money (line 239). Token-decimal scale flagged as a risk to surface (line 131). |
| MULTI_DOC_WRITE / FK-011 | Line 145 requires `db.transaction(...)` for payment+ledger, referral reward, dispute hold/release; SAFETY rule 7 repeats it. |
| LOCKFILE_001 / BRANCH_002 / SAFETY_002 | Workflow never pushes; lockfile/`npm audit` are CI-merge-time concerns the human owns. Refuted as in-workflow blockers. |
| MIXED_ID / immutability / prod-guard / version-bump | All specified in prompts (lines 124, 48/line 70, 160, rule 3). |
---
## 3. Recommended Changes Before Running (high / medium), by phase
These convert prompt-level promises into **verifiable evidence** so the human reviewer and VerifyGate can confirm them, and they fix the two genuine structural risks (factory race, test-skip-as-pass).
| Phase | ID | Change | Severity |
|---|---|---|---|
| **Preflight** | PREFLIGHT_BRANCH_ALREADY_EXISTS_REUSE_RISK | After `git switch ${BRANCH}` (line 91, reuse path), run `git status --porcelain` on the target branch and STOP if dirty — re-running must not accumulate prior uncommitted work. | medium |
| **Repos** | PARALLEL_AGENTS_RACE / CONFLICT_001 | **Structural fix.** Four parallel domain agents all "update `factory.ts`" (lines 138, 147); last writer wins, silently dropping 3 domains' wiring. Split Repos into: (a) parallel — each agent writes only its own repo files, no `factory.ts`; (b) sequential aggregator agent that builds one unified `factory.ts`. Then have VerifyGate assert `factory.ts` imports/exports all 4 domains. | medium |
| **Repos** | REPOS_PHASE_MONGO_WRAPPER_NOT_VERIFIED | Require the MongoRepo to carry the original Mongoose call as an inline comment (or a method→source map) so the human can confirm "verbatim, no behavior change" (line 144). | medium |
| **Repos** | FK-012 | For multi-doc money writes, the dual-write "log+alert, don't throw" (line 146) can leave Mongo funds released with no PG ledger row during the dual-write window. Add an explicit escalation/alert requirement and a reconcile sweep for these gaps (covered by reconcile.ts, line 170). | medium |
| **Schema** | IDEMPOTENCY_INDEX_REPRODUCTION_NOT_TESTED / SCHEMA_AGENTS_INDEX_REPRODUCTION_NO_VERIFY / FK-001/FK-006/FK-008 | Have each schema agent emit the generated SQL (or index/CHECK list) in its return. Add a schema-audit step in VerifyGate that greps the generated migration for: (a) the partial `WHERE` on the RN idempotency index, (b) ledger `idempotency_key IS NOT NULL`, (c) each Mixed-id CHECK constraint, (d) presence of every Mongo index. Missing partial-`WHERE` or CHECK → blocker. | high (where) / medium |
| **Backfill** | NO_PROD_BACKFILL_RUNAWAY_GUARD / FK-003/FK-004/FK-005/FK-007 | Make the non-prod guard a **whitelist** (`localhost`/`127.0.0.1`/named staging hosts), not a `!includes('prod')` blacklist, and require a guard unit-test that aborts on a mock prod DSN. Add per-FK parent-completion checks (TrezorAccount `addresses[].paymentId`, PointTransaction `order`/`referredUser`, Category `parentId` string audit) before child upsert. | high (where) / medium |
| **Backfill (verify)** | VERIFICATION_CHECKSUM_PRECISION / LEDGER_IMMUTABILITY | In `checksums.ts` (line 168) compare fund sums as `::numeric` cast to **text**, not float. In `reconcile.ts` (line 170) add an assertion that ledger rows are never UPDATE/DELETE (replicating the Mongo pre-save immutability hook) — via a PG trigger or a repo guard plus a test. | medium / high (where) |
| **Tests** | TEST_SKIP_WITHOUT_TEST_PG_SILENTLY_PASSES / TEST_001 | **Structural fix.** Tests "skip if no test PG" (line 190) but VerifyGate treats skip as pass. Return `{testsPassed, testsSkipped, skipReason}`; VerifyGate must **fail the commit if `testsSkipped` is true**, so money-safety tests are never silently bypassed. | medium |
| **Tests** | PAYMENT_CONFIRM_LEDGER_ATOMICITY / MISSING_CONSTRAINT_SERIALIZATION / DUAL_WRITE idempotency | Expand the atomicity test (line 182) to a concrete template: inject a mid-transaction ledger failure → assert payment status rolled back → retry → assert success (idempotent). Add a 2-concurrent-confirm test and document the required isolation level / `FOR UPDATE` locking to prevent double-release. | high (where) / medium |
| **VerifyGate** | VERSION_BUMP_DETECTION / VERSION_001 | Add a hard diff check: `git diff ${BASE}...HEAD -- backend/package.json | grep '"version"'` → if non-empty, BLOCKER and do not commit. Cheap insurance against an accidental deploy-triggering bump. | high (where) / low |
| **SelfAudit** | SELF_AUDIT_VERDICT_NOT_GATED | After SelfAudit, gate the final return: if `verdict === 'unsafe'` or `mustFixBeforeBackfill` is non-empty, return an error/flag prominently (the commit is local and reversible, so this is advisory, but it must not be buried in the JSON). | medium |
| **New phase** | NO_MANUAL_BACKFILL_RUNBOOK_GENERATED | Add a Documentation phase that emits `backend/src/db/BACKFILL_RUNBOOK.md`: dependency order, per-script invocation + env, checksum/row-count verification, dual-write enablement, soak/monitor, rollback. This is the cutover team's source of truth. | medium |
---
## 4. What the Workflow Does Well (trust these parts)
- **Airtight non-prod / non-deploy posture.** SAFETY rules 14 are stated globally and re-stated in every phase prompt. Branch off `origin/main`, additive-only, no version bump, no push, no auto-backfill. CI audit independently confirms the branch cannot trigger a deploy.
- **Correct scope and FK ordering.** Tier A (money/orders) + Tier B parents (User, Category) migrated first; `COLLECTIONS` ordered parents-first (line 59); backfill runner enforces `User, Category → PurchaseRequest, SellerOffer → Payment, ...` (line 159). Out-of-scope set (Chat, Notification, Address, Review) is sensible.
- **Money-safety modeling is explicitly specified.** `numeric(38,18)` not float (line 126); the exact RN partial-unique `WHERE`, ledger sparse-unique, and derived-destination uniqueness (line 127); Mixed-id discriminator+typed-FK+external-ref+CHECK pattern (line 124); ledger append-only (rule 6); multi-doc writes in real PG transactions (rule 7, line 145).
- **Dual-write direction is correct.** Reads authoritative from Mongo, writes Mongo-first then PG idempotent upsert, PG failure does not block Mongo (line 146) — the safe ordering for a not-yet-cut-over store.
- **Real verification harness + reconciliation.** Row counts, fund-sum checksums, shadow-read diffing, and a money-specific reconcile (released/refunded payments have matching ledger entries, no double-release, monotonic escrow state) — lines 167170.
- **Layered safety net.** A `BULK`(sonnet)/`BRAIN`(opus) model split that reserves Opus for the commit gate and the adversarial fund-safety SelfAudit (lines 1620, 247), which hunts exactly the right failure modes (float money, dropped idempotency, non-transactional writes, collapsed Mixed ids, prod backfill).
---
## 5. Refuted / Non-Issues (do not re-raise)
All of the following were challenged and confirmed already handled by the workflow prompts or by the no-push/no-prod design. They are **not** action items:
- **Prod backfill guard, version-bump, client.ts throw-on-unset-PG_URL, ledger immutability, Mixed-id CHECK, idempotency index `WHERE`, decimal-as-numeric, multi-doc transactions** — all specified in SAFETY rules 17 and per-phase prompts (lines 105, 124, 126, 127, 145, 146, 160, rule 3/6/7). The "not code-enforced" critique is valid as a *trust* improvement (see §3) but does not make the workflow unsafe to run.
- **SAFETY_001 (self-audit after commit):** refuted — commit is to a local, reversible, non-deploying branch; the verdict still reaches the human.
- **LOCKFILE_001 / BRANCH_002 / SAFETY_002 (lockfile, npm audit):** refuted as in-workflow blockers — the workflow never installs, never pushes; these are CI-merge-time concerns owned by the human at integration. (Note: CI is Gitea Actions, not Woodpecker as the SAFETY comment says — a cosmetic doc fix only.)
- **BRANCH_001 / BRANCH_003 / DEPLOY_001 / VERSION_001 / MERGE_001 / PARALLEL_001:** refuted — branch isolation, additive package.json edits, and "never push" are operationally sound; the residual is human discipline at push time, covered by §6.
---
## 6. Pre-Run Checklist & Human-Only Gates
### Before invoking the workflow
1. **Apply the two structural §3 fixes first** — they are cheap and prevent silent breakage:
- Repos `factory.ts` race: split into parallel-repo-files + sequential-aggregator.
- Tests skip-as-pass: return `testsSkipped` and have VerifyGate fail on skip.
2. **Confirm a clean working tree** on the repo (Preflight will STOP otherwise — line 90) and that `origin` is fetched.
3. **Confirm branch state:** if `feat/pg-money-core-migration` already exists, verify it has no stray uncommitted work (PREFLIGHT_BRANCH_ALREADY_EXISTS).
4. **Provide an ephemeral local test PG** (container/throwaway) so money-safety tests actually run instead of skipping. If you cannot, accept that VerifyGate (post-fix) will refuse to commit.
5. **Verify parallel-agent scope is disjoint** — per repo memory, the moojttaba agent pushes to the same branches. Ensure it is NOT touching `user/payment/points/marketplace` domains or `backend/src/db/repositories/factory.ts`.
6. **Set no production env vars** in the harness shell. Ensure `PG_URL` (if set) and `MIGRATION_PG_URL` point only at local/staging.
### Gates enforced during the run
- VerifyGate commits **only** on green typecheck + tests; otherwise leaves work uncommitted with verbatim blockers (lines 216217). Trust this — but read the `blockers` array.
- (After §3) VerifyGate also fails on version-bump diff, skipped tests, and missing partial-`WHERE`/CHECK in generated SQL.
### After the run — human reviews REQUIRED before anything leaves the branch
1. Read `selfAudit.verdict` and `selfAudit.mustFixBeforeBackfill` (lines 222248). **Do not proceed if `unsafe` or non-empty must-fix.**
2. Code-review the generated schema for: partial-unique `WHERE` clauses, ledger immutability enforcement, Mixed-id CHECK constraints, `numeric` (no float), and `db.transaction(...)` around payment+ledger / referral / dispute writes.
3. Confirm `package.json` version is unchanged and the lockfile situation is understood (update lockfiles + run `npm/yarn audit` at integration time, not in this workflow).
### STAYS HUMAN-ONLY (workflow must never do these — and does not)
- **Running any backfill against real/staging data.** Scripts are artifacts; a human runs them, in dependency order, against a whitelisted non-prod DSN, with `--dry-run` first and checksum verification after each step (use the §3 runbook).
- **Cutover / flipping any `mongo|dual|pg` rollout flag.**
- **Pushing the branch, opening/merging a PR, tagging `v*`** — any of which can trigger CI/deploy. Cherry-pick reviewed changes into a proper feature branch if needed; never push `feat/pg-money-core-migration` to `main`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,446 @@
---
title: MongoDB → PostgreSQL Migration Plan (Drizzle)
tags: [data-model, migration, postgres, drizzle, plan, runbook]
aliases: [Drizzle Migration Plan, PG Migration Plan]
created: 2026-05-31
companion: "[[MongoDB to PostgreSQL Migration Guide]]"
updated: 2026-06-01 for backend integrate-main-into-development@2c5c3c7 backend 2.8.20 + deployment main@38cb75b
---
# MongoDB → PostgreSQL Migration Plan (Drizzle)
> [!abstract] What this is
> The **execution plan** for the recommendation in [[MongoDB to PostgreSQL Migration Guide]]: a **hybrid target** (Postgres for the money/relational core, Mongo retained for Chat/Notification/TTL-session collections) reached via the **strangler pattern with dual-write**, using **Drizzle ORM** + **drizzle-kit** migrations.
>
> It is opinionated and concrete: a repository seam, an `id_map` bridge, Drizzle schema sketches for the hard cases (Mixed ids, embedded arrays, partial-unique idempotency, TTL), per-phase backfill/verify/cutover mechanics, and a rollback runbook. Where it references fields it uses the **real schema** from `backend/src/models/`.
>
> **Scope reminder:** partial migration (Phases 05) is the recommended stopping point — ≈1628 engineer-weeks. Full migration of Chat/Notification/sessions is explicitly deferred.
> [!warning] Current implementation status
> Backend `2.8.20` has started the runtime cutover with store-specific raw Postgres facades: auth-owned users/Telegram auth records behind `AUTH_STORE=postgres`, confirmation-threshold config/history behind `CONFIG_STORE=postgres`, user address CRUD behind `ADDRESS_STORE=postgres`, and the first marketplace/reference domains behind `CATEGORY_STORE=postgres`, `LEVEL_CONFIG_STORE=postgres`, `SHOP_SETTINGS_STORE=postgres`, and `REVIEW_STORE=postgres`. Category PG mode now deactivates duplicate active names and enforces an active normalized-name unique index. It also contains the broader `src/db/` Drizzle schemas through `0010`, repository implementations/factory, id-map bridge, and backfill runner described below. RequestTemplate now has a PG table/backfill. Funds ledger appends and balance reads now route through `getPaymentRepo()` and can be controlled by `REPO_PAYMENT`, but broad marketplace/payment/points services are still mostly not wired through their factory repos. Code defaults remain Mongo unless a per-store flag is explicitly flipped; dev deployment `38cb75b` now flips the seven PG-capable store flags to Postgres by default. See [[Postgres Runtime Cutover Status]].
---
## 0. Guiding principles
1. **Never cut over without a soak.** Every collection goes through backfill → dual-write → shadow-read verify → flip reads → soak → decommission. Rollback at any point = flip reads back to Mongo.
2. **The repository layer is the only thing that knows where data lives.** Services must stop calling Mongoose directly. This seam is what makes the swap invisible and per-collection reversible.
3. **Parents before children.** FK remapping flows through `id_map`; you cannot migrate `Payment` before `User` exists in PG with stable uuids.
4. **Money correctness is the point.** The migration's payoff is real ACID transactions around payment + ledger + dispute flows that today lean on Mongo per-document atomicity. Treat every money write as transactional from day one in PG.
5. **No feature work during migration.** No new fields, no behavior changes. A migration that also ships features cannot be verified by row-count + checksum equality.
6. **Mongo stays authoritative until cutover.** Dual-write writes both; reads come from Mongo until a collection's shadow-read window is clean.
---
## 1. Target architecture
```
┌─────────────────────────────────────────────┐
│ Service layer │
│ (marketplace, payment, dispute, points, …) │
└───────────────────────┬─────────────────────┘
│ calls interfaces only
┌───────────────────────▼─────────────────────┐
│ Repository layer │
│ IUserRepo, IPaymentRepo, IPurchaseRepo, … │
│ ── feature-flagged per collection ── │
└───────┬───────────────────────────┬─────────┘
reads/writes reads/writes
│ │
┌───────────▼─────────┐ ┌───────────▼─────────┐
│ MongoRepo (today) │ │ DrizzleRepo (new) │
│ Mongoose models │ │ Postgres + Drizzle │
└─────────────────────┘ └─────────────────────┘
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ MongoDB │◄── id_map ──────►│ Postgres │
└───────────┘ (bridge) └───────────┘
Permanent on Mongo: Chat, Notification, TelegramSession,
TempVerification, TelegramLink-state. Redis untouched.
```
Each domain gets an interface (`IPaymentRepo`), a `MongoPaymentRepo` (wraps today's Mongoose calls verbatim), a `DrizzlePaymentRepo` (new), and a `DualWritePaymentRepo` (delegates reads to one, writes to both, behind a flag). A factory picks the implementation per collection from config:
```ts
// repos/factory.ts
type Mode = 'mongo' | 'dual' | 'pg';
const MODE: Record<string, Mode> = {
user: env.REPO_USER ?? 'mongo',
payment: env.REPO_PAYMENT ?? 'mongo',
// …per collection
};
export const paymentRepo: IPaymentRepo =
MODE.payment === 'pg' ? new DrizzlePaymentRepo()
: MODE.payment === 'dual' ? new DualWritePaymentRepo(new MongoPaymentRepo(), new DrizzlePaymentRepo())
: new MongoPaymentRepo();
```
A collection's migration is then just three flag flips: `mongo → dual → pg`.
---
## 2. Drizzle & infra setup (Phase 0)
### Packages
```
pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg
```
### Layout
```
backend/src/db/
schema/ # one file per table group
users.ts
payments.ts
purchaseRequests.ts
...
idMap.ts
index.ts # re-exports all tables + relations
client.ts # drizzle(pg.Pool) singleton
migrations/ # drizzle-kit generated SQL
repositories/
interfaces/ # IUserRepo, IPaymentRepo, …
mongo/ # MongoUserRepo (wraps existing Mongoose)
drizzle/ # DrizzleUserRepo
dual/ # DualWriteUserRepo
factory.ts
backfill/ # per-collection batch copiers
verify/ # row-count + checksum + shadow-read harness
drizzle.config.ts
```
### `drizzle.config.ts`
```ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: { url: process.env.PG_URL! },
strict: true,
verbose: true,
});
```
### Client
```ts
// src/db/client.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
export const pool = new Pool({ connectionString: process.env.PG_URL, max: 10 });
export const db = drizzle(pool, { schema });
```
> Mirror the current Mongo pool size (`maxPoolSize: 10` in `connection.ts`). Keep `mongoose.connect` alive in parallel — both drivers run for the whole migration.
### Migration workflow
- Author tables in `schema/*.ts``pnpm drizzle-kit generate` → review the SQL in `migrations/``pnpm drizzle-kit migrate` in CI per environment.
- **Migrations are versioned, reviewed, and reversible.** This is brand-new discipline — there is no migration framework today.
---
## 3. The `id_map` bridge
ObjectIds become uuids. Every legacy id is recorded so FKs can be remapped and dual-writes stay idempotent.
```ts
// src/db/schema/idMap.ts
import { pgTable, uuid, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
export const idMap = pgTable('id_map', {
collection: text('collection').notNull(), // 'users', 'payments', …
legacyId: text('legacy_object_id').notNull(), // 24-char hex
newId: uuid('new_id').notNull().defaultRandom(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (t) => ({
uq: uniqueIndex('id_map_collection_legacy_uq').on(t.collection, t.legacyId),
}));
```
Rules:
- Backfill allocates `new_id` once per `(collection, legacyId)` and upserts here. Re-running backfill is safe.
- Resolving a foreign reference = look up the parent's `legacyId` in `id_map` to get its `new_id`. **A child cannot backfill until its parents are mapped** (enforces parents-before-children).
- Keep `legacy_object_id` as a real column on each migrated table too, for traceability and for the dual-write path to match Mongo docs.
---
## 4. Resolving the hard data-modeling cases in Drizzle
These are the patterns from §3 of the guide, made concrete. Get these right once; they recur.
### 4.1 Mixed / polymorphic ids — `Payment`, `FundsLedgerEntry`, `DerivedDestination`
Today `Payment.purchaseRequestId`, `sellerOfferId`, `sellerId` are `Schema.Types.Mixed` — an ObjectId for normal flows, a **string** for template checkout. **Never** store "uuid-or-string" in one PG column. Split into a typed FK + a nullable free-text ref + a discriminator.
```ts
// src/db/schema/payments.ts
import { pgTable, uuid, text, numeric, boolean, timestamp, jsonb, pgEnum, index, uniqueIndex } from 'drizzle-orm/pg-core';
export const paymentProvider = pgEnum('payment_provider', ['request.network','amn.scanner','shkeeper','other']);
export const paymentDirection = pgEnum('payment_direction', ['in','out','refund']);
export const paymentStatus = pgEnum('payment_status', ['pending','processing','completed','failed','cancelled','refunded']); // confirm full enum from model
export const escrowState = pgEnum('escrow_state', ['funded','releasable','released','refunded','releasing','failed','cancelled','partial']);
export const refKind = pgEnum('ref_kind', ['entity','template']); // discriminator
export const payments = pgTable('payments', {
id: uuid('id').primaryKey().defaultRandom(),
legacyObjectId: text('legacy_object_id'),
// purchaseRequestId (Mixed) → typed FK OR free string
purchaseRequestRefKind: refKind('purchase_request_ref_kind').notNull(),
purchaseRequestId: uuid('purchase_request_id').references(() => purchaseRequests.id), // null when template
purchaseRequestExternalRef: text('purchase_request_external_ref'), // set when template
// sellerOfferId (Mixed) → same shape
sellerOfferRefKind: refKind('seller_offer_ref_kind').notNull(),
sellerOfferId: uuid('seller_offer_id').references(() => sellerOffers.id),
sellerOfferExternalRef: text('seller_offer_external_ref'),
buyerId: uuid('buyer_id').notNull().references(() => users.id),
// sellerId (Mixed)
sellerRefKind: refKind('seller_ref_kind').notNull(),
sellerId: uuid('seller_id').references(() => users.id),
sellerExternalRef: text('seller_external_ref'),
// amount subdoc → inline columns
amount: numeric('amount', { precision: 38, scale: 18 }).notNull(),
currency: text('currency').notNull().default('USDT'),
provider: paymentProvider('provider').notNull().default('request.network'),
direction: paymentDirection('direction').notNull().default('in'),
status: paymentStatus('status').notNull().default('pending'),
escrowState: escrowState('escrow_state'),
providerPaymentId: text('provider_payment_id'),
blockchain: jsonb('blockchain'), // transactionHash etc. — read-as-blob, GIN if filtered
metadata: jsonb('metadata'), // provider-specific, schema-varying
isRefunded: boolean('is_refunded').notNull().default(false),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}, (t) => ({
byStatusCreated: index('payments_status_created_idx').on(t.status, t.createdAt),
byBuyerStatus: index('payments_buyer_status_idx').on(t.buyerId, t.status),
bySellerStatus: index('payments_seller_status_idx').on(t.sellerId, t.status),
txHash: index('payments_tx_hash_idx').on(t.providerPaymentId),
// Partial-unique idempotency — the real Mongo index 'uniq_pending_request_network_by_buyer_session_offer'
pendingRnUq: uniqueIndex('uniq_pending_rn_by_buyer_offer')
.on(t.buyerId, t.purchaseRequestId, t.sellerOfferId, t.provider, t.direction)
.where(sql`provider = 'request.network' AND direction = 'in' AND status = 'pending'`),
}));
```
Add a CHECK so a discriminator always agrees with which column is populated:
```sql
ALTER TABLE payments ADD CONSTRAINT payments_pr_ref_ck CHECK (
(purchase_request_ref_kind = 'entity' AND purchase_request_id IS NOT NULL AND purchase_request_external_ref IS NULL) OR
(purchase_request_ref_kind = 'template' AND purchase_request_id IS NULL AND purchase_request_external_ref IS NOT NULL)
);
```
`FundsLedgerEntry` has the same Mixed `purchaseRequestId`/`paymentId` plus a **`idempotencyKey` sparse-unique** → partial unique index `WHERE idempotency_key IS NOT NULL`.
### 4.2 Embedded arrays → child tables
| Source (embedded) | PG | Notes |
|---|---|---|
| `PurchaseRequest.offers[]` (array of SellerOffer ids) | junction `purchase_request_offers(pr_id, offer_id)` | FK integrity; also drop the denormalized array. |
| `PurchaseRequest.preferredSellerIds[]` | junction `pr_preferred_sellers(pr_id, user_id)` | — |
| `PurchaseRequest.deliveryInfo / serviceInfo` (nested subdocs) | child tables `pr_delivery_info`, `pr_service_info` (1:1) | queried logistics; not blobbed. |
| `Dispute.evidence[]`, `Dispute.timeline[]` | `dispute_evidence`, `dispute_timeline` | timeline pre-save append → explicit INSERT. |
| `User.passkeys[]`, `User.refreshTokens[]` | `user_passkeys`, `user_refresh_tokens` | append/revoke + lookup semantics. |
| `DerivedDestination` sweep history, `TrezorAccount.addresses[]` | child tables | per-address rows referenced by payments. |
| `Payment.blockchain`, `Payment.metadata`, `Notification.metadata`, `PointTransaction.metadata` | **JSONB** | read-as-blob, never filtered/joined. |
Rule: **child table when you query/index/FK/aggregate it; JSONB when you read it whole and never filter on it.**
### 4.3 Self-referential FK — `Category`
```ts
export const categories = pgTable('categories', {
id: uuid('id').primaryKey().defaultRandom(),
legacyObjectId: text('legacy_object_id'),
name: text('name').notNull(),
nameEn: text('name_en'),
parentId: uuid('parent_id'), // self-FK, see relations
isActive: boolean('is_active').notNull().default(true),
}, (t) => ({
parentIdx: index('categories_parent_idx').on(t.parentId),
activeIdx: index('categories_active_idx').on(t.isActive),
activeNameNormUq: uniqueIndex('categories_active_name_norm_uq')
.on(sql`lower(btrim(${t.name}))`)
.where(sql`${t.isActive} = true`),
}));
// relations(): parentId → categories.id, ON DELETE SET NULL
```
`Category.parentId` is itself Mixed (ObjectId | string) in the model — verify all rows are ObjectIds during the pre-migration audit; treat stray strings as data errors to clean.
Active categories must also be unique by normalized visible name; migration `0009_unique_active_categories.sql` deactivates duplicate active rows and repoints category references before adding the unique index.
### 4.4 Sparse-unique → partial unique index — `User.email`, `User.referralCode`
The runtime code in `connection.ts` rebuilds `users.email` as unique+sparse. In PG:
```ts
emailUq: uniqueIndex('users_email_uq').on(t.email).where(sql`email IS NOT NULL`),
referralUq: uniqueIndex('users_referral_uq').on(t.referralCode).where(sql`referral_code IS NOT NULL`),
```
Reimplement `toJSON()` password/token stripping in the repository's read mapper (it deletes `refreshTokens`, `emailVerification*` before returning).
### 4.5 Atomic counter — `DerivedDestination.derivationIndex`
Today allocation relies on Mongo atomicity. In PG use a real transaction with `SELECT … FOR UPDATE` on a per-(buyer,chain) counter row, or a dedicated sequence per chain. The `uniq_destination_by_buyer_seller_chain` unique index ports directly. `status` enum `('active','swept','sweeping','quarantined')``pgEnum`.
### 4.6 TTL → `pg_cron`
`TempVerification` and `TelegramSession` stay on Mongo (ephemeral, recommended). If `Notification` (90-day TTL) ever moves: monthly range-partition + drop, or
```sql
SELECT cron.schedule('notifications_ttl', '0 3 * * *',
$$DELETE FROM notifications WHERE created_at < now() - interval '90 days'$$);
```
---
## 5. The dual-write seam (the mechanic that makes it safe)
```ts
// repositories/dual/DualWritePaymentRepo.ts
export class DualWritePaymentRepo implements IPaymentRepo {
constructor(private mongo: IPaymentRepo, private pg: IPaymentRepo) {}
// READS: source of truth = Mongo until cutover
findById(id) { return this.mongo.findById(id); }
// WRITES: both, idempotently. Mongo first (authoritative); PG must not break the request.
async create(input) {
const m = await this.mongo.create(input); // returns doc incl. _id
try {
await this.pg.upsertFromMongo(m); // keyed by legacyObjectId / idempotencyKey
} catch (e) {
metrics.dualWriteError('payments', 'create', e); // alert, do NOT throw
}
return m;
}
async update(id, patch) {
const m = await this.mongo.update(id, patch);
try { await this.pg.upsertFromMongo(m); } catch (e) { metrics.dualWriteError('payments','update',e); }
return m;
}
}
```
- **Mongo write is authoritative and must succeed**; PG write failures are logged + alerted, never surfaced to the user, during `dual` mode. (Once in `pg` mode, PG is authoritative and wrapped in real transactions.)
- All PG writes are **idempotent upserts** keyed on `legacyObjectId` (or natural idempotency keys: `Payment` partial-unique set, `FundsLedgerEntry.idempotencyKey`). This lets backfill and live dual-write overlap without double-insert.
- `$inc`/`$push` translate inside the repo: `$inc points``UPDATE … SET points = points + $1` in a transaction; `$push offers``INSERT INTO purchase_request_offers …`.
---
## 6. Phased execution
Same phases as the guide §2, here with Drizzle-concrete entry/exit gates. Each phase ends with a collection in `pg` mode and dual-write removed only after the soak.
### Phase 0 — Foundations (25 wk) — *no data moves*
- Stand up Postgres (per env), Drizzle, drizzle-kit, CI migrations. **Status 2026-05-31:** implemented in code and dev stack, but migrations must still be applied per target DB.
- Build repository interfaces + `MongoRepo` wrappers for the relational-core domains (refactor services to call repos, not Mongoose directly). **Status 2026-05-31:** repo interfaces/implementations exist; service-layer wiring remains the bulk of the cutover risk.
- Create `id_map`, the verification harness (§7), and the backfill batch runner skeleton.
- **Exit:** all relational-core services call repositories; PG reachable everywhere; `id_map` + verify harness exist; CI runs migrations.
### Phase 1 — Address pilot (12 wk)
- Smallest real domain; proves backfill → dual-write → verify → cutover end-to-end.
- **Status 2026-06-01:** `/api/addresses` has an opt-in PG runtime path through `ADDRESS_STORE=postgres`; PG writes/deletes mirror to Mongo for rollback.
- Reimplement the **one-primary-per-user** pre-save invariant as either a partial unique index `UNIQUE (user_id) WHERE primary = true` or a trigger.
- **Exit:** `addresses` in `pg` mode in prod, invariant proven under concurrent writes, verify green, dual-write removed.
### Phase 2 — Reference/config (23 wk)
- `Category` (self-FK, soft-delete), `LevelConfig`, `ConfigSetting`, `ConfigSettingHistory`, `ShopSettings`, `Review`.
- **Status 2026-06-01:** confirmation-threshold `ConfigSetting` / `ConfigSettingHistory`, categories, level config, shop settings, and reviews have opt-in PG runtime paths through their per-store flags; writes mirror back to Mongo where still-Mongo consumers need compatibility. Categories now enforce one active row per normalized visible name in PG mode.
- Port seeds to run in dependency order. Enforce `ShopSettings.sellerId` unique, Category `parentId` ON DELETE SET NULL, and Category active normalized-name uniqueness.
- **Exit:** these read from PG; seeds run in PG.
### Phase 3 — User + auth core (35 wk)
- `User` is the FK hub — **must precede the money core** so `id_map` for users is authoritative.
- **Status 2026-06-01:** auth-owned user data is opt-in PG-backed through `AUTH_STORE=postgres`, with a Mongo legacy mirror for still-Mongo consumers. Broader user consumers are not fully cut over.
- Normalize `profile`/`preferences`/`points`/`referralStats` into columns; extract `passkeys[]`, `refreshTokens[]` to child tables; partial-unique `email`/`referralCode`; reimplement `toJSON()` stripping; passkey `default: Date.now()` in app code.
- Redis session/rate-limit + in-memory passkey challenge store stay as-is.
- **Exit:** `users` in `pg` mode; referral self-FK intact; all auth flows pass; user uuids authoritative in `id_map`.
### Phase 4 — Money core (610 wk) — *the point of the project*
- `PurchaseRequest`, `SellerOffer`, `RequestTemplate`, `Payment`, `FundsLedgerEntry`, `DerivedDestination`, `TrezorAccount`, `PointTransaction`.
- **Status 2026-06-01:** Drizzle schemas and backfill scripts exist for PurchaseRequest/SellerOffer/RequestTemplate. Backend `2.8.19` hardens the marketplace-core backfill path with `npm run backfill:marketplace-core:postgres`, fixed PurchaseRequest timestamp/preferred-seller writes, a RequestTemplate backfill step, a post-SellerOffer selected-offer remap step, and category duplicate cleanup/unique active-name enforcement. Backend `2.8.20` wires the funds-ledger service through `getPaymentRepo()`, fixes Mongo/Drizzle payment-stat parity for future service wiring, and makes the repo factory lazy-load PG/dual implementations so Mongo mode does not require `PG_URL`. Runtime marketplace services still call Mongoose directly and must not be flipped with `REPO_MARKETPLACE` until service wiring plus shadow-read checks land.
- Apply §4.1 (Mixed→discriminator+FK), §4.2 (offers/preferredSellers junctions, deliveryInfo/serviceInfo child tables), §4.5 (derivation counter).
- **Wrap in real PG transactions the multi-doc writes that today have none:** `raiseDispute` (PurchaseRequest + Payment), payment confirm + `FundsLedgerEntry` AML-fee insert, referral reward (points + referralStats), PointsService flows (migrate its 2 `withTransaction` sites to PG `BEGIN/COMMIT`).
- Preserve the `Payment` partial-unique idempotency index and `FundsLedgerEntry.idempotencyKey` uniqueness.
- **Exit:** money core in `pg` mode; checksum equality on `funds_ledger_entries` sums & `payments` amounts across a full soak; idempotency + escrow-hold invariants pass concurrency tests.
### Phase 5 — Dispute + delivery (24 wk)
- `Dispute.evidence[]`/`timeline[]` → child tables; pre-save timeline-append → explicit INSERT; delivery `$set/$push` nested updates → SQL.
- `Dispute ↔ Chat` becomes a **cross-store call** (Chat stays on Mongo) — define the boundary API.
- **Exit:** dispute lifecycle in `pg` mode; release-hold sync transactional.
### Phase 6 (deferred / optional) — `BlogPost`
- Behind a search abstraction; `$regex` → PG trigram/FTS only if migrated. Otherwise leave on Mongo. RequestTemplate schema/backfill moved into Phase 4 because template checkout creates PurchaseRequest/SellerOffer rows.
### Permanent on Mongo
`Chat`, `Notification`, `TelegramSession`, `TempVerification`, `TelegramLink` link-state. Revisit only if dual-stack ops cost exceeds migration cost.
---
## 7. Verification (gate for every cutover)
Three layers, **all green before any read flip**:
1. **Row counts** — per collection and per FK relationship, Mongo vs PG. Catches dropped/dangling rows. Run continuously during dual-write.
2. **Checksums** — column-level hashes; special attention to financial sums (`SUM(funds_ledger_entries.amount)`, `SUM(payments.amount)` grouped by status/provider) and the partial-unique idempotency set.
3. **Shadow reads** — in prod, serve from Mongo, asynchronously read PG for the same key, diff, alert on mismatch. **A clean shadow-read window (e.g. 7 days, zero diffs on hot paths) is the exit criterion for cutover.**
```ts
// verify/shadow.ts — wrap a repo read in dual mode
async function shadowRead(key, mongoFn, pgFn) {
const m = await mongoFn(key);
pgFn(key).then(p => { if (!deepEqualNormalized(m, p)) metrics.shadowMismatch(key, diff(m, p)); })
.catch(e => metrics.shadowError(key, e));
return m; // user always gets Mongo result
}
```
---
## 8. Cutover & rollback runbook (per collection)
1. **Backfill** in batches with checkpointing; allocate uuids → `id_map`; remap FKs from already-migrated parents. Re-runnable (idempotent upserts).
- Marketplace-core operator path: `MIGRATION_MONGO_URL=... MIGRATION_PG_URL=... npm run backfill:marketplace-core:postgres:dry-run`, then `npm run backfill:marketplace-core:postgres` in non-prod. The group now includes RequestTemplate before PurchaseRequest/SellerOffer. Run `scripts/smoke/marketplace-core-postgres-backfill.sh` with the same DSNs to exercise the static backfill invariants and dry-run.
2. **Enable `dual`** (flag) — writes go to both; shadow-read diffing on. Backfill the delta accumulated during step 1.
3. **Soak** until row-count + checksum + shadow-read are clean for the agreed window.
4. **Flip reads to `pg`** (flag). Keep dual-write on.
5. **Soak again** (shorter). Rollback = flip reads back to `mongo`; data still mirrored, so rollback is instant.
6. **Decommission**: stop writing Mongo for that collection; archive the collection.
> Near-zero downtime: there is no global write freeze except, optionally, a brief one during final ledger reconciliation for the money core.
---
## 9. First two weeks — concrete starter checklist
- [ ] Add `drizzle-orm`, `pg`, `drizzle-kit`; create `src/db/{schema,client.ts,migrations}` + `drizzle.config.ts`.
- [x] Provision Postgres in dev (compose) + define `PG_URL`; keep Mongo running alongside. Use Postgres 18 volume mount `/var/lib/postgresql`, not `/var/lib/postgresql/data`.
- [ ] Write `id_map` schema; generate + run the first migration in CI.
- [ ] Define `IAddressRepo`; implement `MongoAddressRepo` by moving the existing Mongoose calls behind it; refactor address service to use the repo. **No behavior change** — prove the seam is invisible (existing tests pass).
- [ ] Build the verification harness (row count + checksum) against `addresses`.
- [ ] Author `addresses` Drizzle schema (incl. one-primary partial unique index) + `DrizzleAddressRepo` + `DualWriteAddressRepo`.
- [ ] Write the batch backfill for `addresses`; run dev backfill; confirm verify is green.
- [ ] Flip dev to `dual`, then `pg`; document the flag flips. This is the template for all later phases.
---
## 10. Effort recap (from the guide)
| Scope | Eng-weeks | Notes |
|---|---|---|
| **Partial — money/relational core (Phases 05 + cross-cutting)** | **~1628** | Recommended stopping point; captures ~90% of value (ACID money + relational integrity). |
| Full — all 23 collections | ~2340 | Extra 712+ wks mostly buys Chat/Notification normalization the access patterns don't reward. |
Add ~20% contingency for data-audit surprises in the Mixed-id fields. One focused engineer assumed; parallelize to compress wall-clock, not effort.
---
> [!warning] Before trusting the code sketches
> Drizzle schemas above use the real field names from `backend/src/models/` but are **first-pass sketches** — confirm the full `Payment.status` enum, the exact `amount` precision/scale your tokens need (USDT/USDC decimals), and audit which `Mixed` rows are actually strings vs ObjectIds **before** writing the money-core migration. See [[MongoDB to PostgreSQL Migration Guide]] §3/§5 for the authoritative per-field detail.

View File

@@ -1,49 +1,45 @@
---
title: Payment
tags: [data-model, mongoose]
tags: [data-model, postgresql, drizzle]
aliases: [Payment Record, Escrow, IPayment]
---
# Payment
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-06-06 — MongoDB fully removed; PostgreSQL + Drizzle ORM is the only database layer as of backend v2.9.12.
Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. The current model is centered on Request Network pay-in, in-house checkout metadata, on-chain transaction verification, escrow state, and provider request IDs. The `provider` and `direction` discriminators let one collection hold incoming buyer payments, outgoing seller releases, refunds, and legacy/other provider records.
Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. The model is centered on Request Network pay-in, in-house checkout metadata, on-chain transaction verification, escrow state, and provider request IDs. The `provider` and `direction` discriminators let one table hold incoming buyer payments, outgoing seller releases, refunds, and legacy/other provider records.
> [!note] Runtime store
> The `Payment` record is stored exclusively in PostgreSQL (`payments` table). Mongoose and MongoDB have been completely removed from the backend as of v2.9.12. The repository factory returns Drizzle repos only. `MONGO_URI` / `MONGODB_URI` / `MONGO_CONNECT_MODE` env vars are obsolete; `PG_URL` is required.
> [!note] Source
> `backend/src/models/Payment.ts:3` — schema definition
> `backend/src/models/Payment.ts:257` — model export (default export)
> `backend/src/repositories/drizzle/DrizzlePaymentRepo.ts` — Drizzle repository implementation
> `backend/src/db/schema/` — Drizzle schema definitions
> [!warning] Mixed types
> `purchaseRequestId`, `sellerOfferId`, and `sellerId` use `Schema.Types.Mixed`. They are usually `ObjectId`s, but the template-checkout flow passes string ids that do not yet exist in the database, so the schema accepts both.
> [!note] IDs
> All primary keys are PostgreSQL UUIDs (`.id` field, string). The legacy MongoDB ObjectId is preserved as `legacy_object_id` for historical lookups only. Marketplace FKs (e.g. `sellerId`) reference `user.pgId` (UUID), not the legacy `_id`.
> [!warning] `provider` values (schema enum vs reality)
> The declared schema enum for `provider` is only `['request.network', 'other']`, yet production code writes additional values. The full set of providers that actually appear is: `request.network`, `shkeeper`, `decentralized`, `test`, `other`.
> - `paymentCoordinator.ts` and `RequestTemplateService.ts` create `Payment` docs with `provider: 'shkeeper'`.
> - The decentralized/on-chain flow uses `decentralized`.
> - ⚠️ **Frontend type bug:** the frontend `PaymentProvider` TypeScript type (`frontend/src/types/payment.ts`) is `'request.network' | 'test' | 'other'` — it is **missing `shkeeper` and `decentralized`**, so the client cannot represent payments created by those providers.
> [!note] `provider` values
> The backend accepts `request.network`, `amn.scanner`, `shkeeper`, `escrow`, and `other`. `amn.scanner` is the in-house scanner provider used for direct on-chain monitoring. `escrow` is used for internal escrow-native flows. Older docs and some frontend types may still mention historical values such as `test` or `decentralized`; treat those as legacy until their active routes are audited.
> [!warning] `confirmed` vs `completed` — stats undercount
> Payment stats (`paymentService.getPaymentStats`) only increment `successfulPayments` for status **`confirmed`**:
> ```ts
> case "confirmed": stats.successfulPayments += stat.count; break;
> ```
> The terminal SHKeeper / DePay state is **`completed`**, which has no case in the switch and is therefore **not** counted as a successful payment. ⚠️ This causes successful-payment stats to undercount any payment that reached `completed`.
> [!note] `confirmed` vs `completed` — stats parity
> Payment stats count both **`confirmed`** and **`completed`** as successful.
> [!warning] `SIM_` payment-hash bypass — security concern
> In both `payment/paymentRoutes.ts` and `marketplace/routes.ts`, a `paymentHash` that starts with `SIM_` (or a short `0x...` hash under 64 chars) is treated as a simulated transaction and **skips on-chain verification entirely** (`isVerified = true`). There is **no environment guard** (e.g. no `NODE_ENV !== 'production'` check) around this branch, so the bypass is reachable in production. ⚠️ A caller can mark a payment verified without any real on-chain settlement.
## Schema
## PostgreSQL schema (Drizzle)
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `purchaseRequestId` | Mixed (ObjectId or String) | yes | — | — | yes (compound, partial) | Linked [[PurchaseRequest]] id (or template id). |
| `sellerOfferId` | Mixed (ObjectId or String) | yes | — | — | — | Linked [[SellerOffer]] id (or template offer ref). |
| `buyerId` | ObjectId → [[User]] | yes | — | — | yes (compound) | Buyer paying. |
| `sellerId` | Mixed (ObjectId or String) | yes | — | — | yes (compound) | Seller receiving (or template seller). |
| `amount.amount` | Number | yes | — | — | — | Numeric amount. |
| `amount.currency` | String | yes | `USDT` | — | — | Settlement currency. |
| `provider` | String | no | `request.network` | enum (declared): `request.network` / `other`. Values written in practice: `request.network`, `shkeeper`, `decentralized`, `request.network`, `test`, `other` | yes (compound, partial) | Payment processor. ⚠️ See provider note below — code writes `shkeeper` and `decentralized` even though they are not in the declared schema enum, and the frontend `PaymentProvider` type is missing both. |
| `id` | UUID (string) | yes | gen_random_uuid() | — | yes (PK) | Primary key. |
| `purchaseRequestId` | UUID or String | yes | — | — | yes (compound, partial) | Linked [[PurchaseRequest]] id (or template id). |
| `sellerOfferId` | UUID or String | yes | — | — | — | Linked [[SellerOffer]] id (or template offer ref). |
| `buyerId` | UUID → [[User]] | yes | — | — | yes (compound) | Buyer paying. |
| `sellerId` | UUID or String | yes | — | — | yes (compound) | Seller receiving (or template seller). References `user.pgId`. |
| `amount` | String (decimal) | yes | — | decimal string | — | Settlement amount as a decimal string (e.g. `"12.50"`). |
| `provider` | String | no | `request.network` | enum: `request.network` / `amn.scanner` / `shkeeper` / `escrow` / `other` | yes (compound, partial) | Payment processor. `amn.scanner` is the in-house scanner pay-in rail. |
| `direction` | String | no | `in` | enum: `in` / `out` / `refund` | yes (compound, partial) | Flow direction. |
| `blockchain.network` | String | no | — | — | — | Network identifier. |
| `blockchain.transactionHash` | String | no | — | — | yes (sparse) | On-chain tx hash. |
@@ -52,8 +48,11 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `blockchain.sender` | String | no | — | — | — | Source address. |
| `blockchain.receiver` | String | no | — | — | — | Destination address. |
| `blockchain.confirmedAt` | Date | no | — | — | — | When tx confirmed. |
| `blockchain.confirmations` | Number | no | `0` | — | — | Confirmation count. |
| `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. ⚠️ `confirmed` vs `completed`: only `confirmed` is counted as a successful payment in stats. See status note below. |
| `blockchain.confirmations` | Number | no | `0` | — | — | Accepted confirmation count. For settled webhooks this is capped at the effective per-chain threshold rather than an endlessly increasing live block count; payment screens render settled values with a `+` suffix. |
| `blockchain.blockNumber` | Number | no | — | — | — | Block number of the confirmed transaction. |
| `blockchain.gasUsed` | Number | no | — | — | — | Gas units consumed by the transaction. |
| `blockchain.isSimulated` | Boolean | no | — | — | — | True when the payment was created via the `SIM_` hash bypass (no real on-chain tx). |
| `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. Both `confirmed` and `completed` are counted as successful in payment stats. |
| `escrowState` | String | no | — | enum: `funded` / `releasable` / `released` / `refunded` / `releasing` / `failed` / `cancelled` / `partial` | — | Escrow lifecycle. Note the intermediate states `releasable` (delivery confirmed, ready to pay out) and `releasing` (payout in flight) between `funded` and `released`. |
| `providerPaymentId` | String | no | — | — | yes (sparse) | External provider id for idempotency. |
| `metadata.userAgent` | String | no | — | — | — | Browser UA. |
@@ -62,7 +61,7 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `metadata.paymentMethod` | String | no | — | — | — | Payment method label. |
| `metadata.shkeeperUrl` | String | no | — | — | — | Invoice URL. |
| `metadata.shkeeperInvoiceId` | String | no | — | — | — | Invoice id. |
| `metadata.shkeeperData` | Mixed | no | — | — | — | Raw provider payload. |
| `metadata.shkeeperData` | JSONB | no | — | — | — | Raw provider payload. |
| `metadata.shkeeperStatus` | String | no | — | — | — | Provider status string. |
| `metadata.balanceFiat` | String | no | — | — | — | Fiat-equivalent balance. |
| `metadata.balanceCrypto` | String | no | — | — | — | Crypto balance. |
@@ -72,56 +71,63 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `metadata.requestNetworkRequestId` | String | no | — | — | — | Request Network request id. |
| `metadata.requestNetworkPaymentReference` | String | no | — | — | — | Request Network payment reference. |
| `metadata.requestNetworkSecurePaymentUrl` | String | no | — | — | — | Request Network secure payment URL. |
| `metadata.requestNetworkData` | Mixed | no | — | — | — | Raw Request Network payload. |
| `metadata.transactionSafety` | Mixed | no | — | — | — | Last Transaction Safety Provider decision, checks, evidence, and blocker reason. |
| `metadata.derivedDestination` | Object | no | — | — | — | Snapshot of per-payment derived destination address/path/index/chain. |
| `metadata.requestNetworkData` | JSONB | no | — | — | — | Raw Request Network payload. |
| `metadata.transactionSafety` | JSONB | no | — | — | — | Last Transaction Safety Provider decision, checks, evidence, and blocker reason. |
| `metadata.derivedDestination` | JSONB | no | — | — | — | Snapshot of per-payment derived destination address/path/index/chain. |
| `metadata.lastWebhookAt` | Date | no | — | — | — | Last webhook timestamp. |
| `metadata.webhookPayload` | Mixed | no | — | — | — | Last webhook body. |
| `metadata.webhookPayload` | JSONB | no | — | — | — | Last webhook body. |
| `metadata.createdVia` | String | no | — | — | — | Origin marker. |
| `metadata.payoutType` | String | no | — | — | — | Payout sub-type. |
| `metadata.error` | String | no | — | — | — | Last error message. |
| `metadata.failedAt` | Date | no | — | — | — | When it failed. |
| `createdAt` | Date | auto | `Date.now` | — | yes (compound) | Mongoose timestamp. |
| `quote.quoteId` | String | no | — | — | — | `payment_quotes.id` (UUID) when a Postgres quote row exists. |
| `quote.pricingCurrency` | String | no | — | — | — | Seller offer currency used for the quote. |
| `quote.offerAmount` | String | no | — | decimal string | — | Seller obligation in `pricingCurrency`. |
| `quote.invoiceUSD` | String | no | — | decimal string | — | `offerAmount × fxRate` at quote time. |
| `quote.fxRate` | String | no | — | decimal string | — | Pricing currency to USD rate. |
| `quote.fxSource` | String | no | — | — | — | FX provider id. |
| `quote.tokenPriceUsd` | String | no | — | decimal string | — | Settlement token USD price used for depeg protection. |
| `quote.depegSource` | String | no | — | — | — | Depeg/token-price provider id. |
| `quote.rawSettleAmount` | String | no | — | decimal string | — | Exact `invoiceUSD / tokenPriceUsd` before rounding. |
| `quote.settleAmount` | String | no | — | decimal string | — | Final token amount after seller-protective rounding. |
| `quote.roundingBps` | Number | no | — | integer bps | — | Upward rounding applied. |
| `quote.depegAdjustmentBps` | Number | no | — | integer bps | — | Absolute deviation from USD peg. |
| `quote.token` | String | no | — | — | — | Settlement token symbol. |
| `quote.chainId` | Number | no | — | — | — | Settlement chain id. |
| `quote.fetchedAt` | Date | no | — | — | — | Oracle rate timestamp. |
| `quote.expiresAt` | Date | no | — | — | — | Quote expiry. |
| `createdAt` | Date | auto | now() | — | yes (compound) | Row creation timestamp. |
| `processedAt` | Date | no | — | — | — | When processing started. |
| `completedAt` | Date | no | — | — | — | When fully settled. |
| `notes` | String | no | — | — | — | Free-form notes. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Last update timestamp. |
| `legacy_object_id` | String | no | — | — | yes (sparse) | Original MongoDB ObjectId preserved for historical lookups during migration window. |
## Virtuals
## Virtuals / Computed
| Virtual | Returns | Definition |
| Field | Returns | Description |
| --- | --- | --- |
| `paymentRef` | `PAY-<LAST_8_OF_ID_UPPERCASE>` | `backend/src/models/Payment.ts:191` |
The schema enables `toJSON: { virtuals: true }` and `toObject: { virtuals: true }` so the ref appears in API responses.
| `paymentRef` | `PAY-<LAST_8_OF_ID_UPPERCASE>` | Derived from UUID `id`. Included in API responses. |
## Indexes
Defined at `backend/src/models/Payment.ts:174-188`:
PostgreSQL indexes on the `payments` table:
- `{ status: 1, createdAt: -1 }` — admin queues.
- `{ buyerId: 1, status: 1 }` — buyer dashboard.
- `{ sellerId: 1, status: 1 }` — seller dashboard.
- `{ 'blockchain.transactionHash': 1 }` (sparse) — webhook lookup by hash.
- `{ providerPaymentId: 1 }` (sparse) — provider idempotency.
- `{ buyerId: 1, purchaseRequestId: 1, provider: 1, direction: 1 }` (unique, partial: `provider === 'shkeeper' && direction === 'in' && status === 'pending'`, name `uniq_pending_shkeeper_by_buyer_session`) — guards against duplicate pending invoices.
- `{ status, createdAt DESC }` — admin queues.
- `{ buyerId, status }` — buyer dashboard.
- `{ sellerId, status }` — seller dashboard.
- `{ blockchain.transactionHash }` (sparse) — webhook lookup by hash.
- `{ providerPaymentId }` (sparse) — provider idempotency.
- `{ buyerId, purchaseRequestId, provider, direction }` (unique partial: `provider = 'shkeeper' AND direction = 'in' AND status = 'pending'`, name `uniq_pending_shkeeper_by_buyer_session`) — guards against duplicate pending invoices.
## Pre/Post Hooks
## Postgres Quote Table
None declared.
## Instance Methods
None defined.
## Static Methods
None defined.
Oracle quotes are stored in `payment_quotes`, a 1:1 child table keyed by `payment_id → payments.id`. Amount/rate columns use `numeric(38,18)`. The `payments.legacy_object_id` column supports lookups that originate from legacy references during the migration window.
## Relationships
- **References**: [[User]] (`buyerId`, sometimes `sellerId`), [[PurchaseRequest]] (`purchaseRequestId`), [[SellerOffer]] (`sellerOfferId`).
- **Referenced by**: Indirectly through [[PurchaseRequest]] status transitions and [[Dispute]] resolution amounts; no model holds a direct foreign key back to `Payment`.
- **References**: [[User]] (`buyerId`, `sellerId` via `pgId`), [[PurchaseRequest]] (`purchaseRequestId`), [[SellerOffer]] (`sellerOfferId`).
- **Referenced by**: Indirectly through [[PurchaseRequest]] status transitions and [[Dispute]] resolution amounts; no table holds a direct foreign key back to `payments`.
## State Transitions
@@ -160,22 +166,17 @@ stateDiagram-v2
## Common Queries
```ts
// Buyer history
Payment.find({ buyerId, direction: 'in' }).sort({ createdAt: -1 });
// Buyer history (Drizzle)
db.select().from(payments).where(and(eq(payments.buyerId, buyerId), eq(payments.direction, 'in'))).orderBy(desc(payments.createdAt));
// Seller payouts
Payment.find({ sellerId, direction: 'out', status: 'completed' });
db.select().from(payments).where(and(eq(payments.sellerId, sellerId), eq(payments.direction, 'out'), eq(payments.status, 'completed')));
// Webhook lookup
Payment.findOne({ providerPaymentId });
db.select().from(payments).where(eq(payments.providerPaymentId, providerPaymentId));
// Pending escrows ready for release
Payment.find({ direction: 'in', escrowState: 'releasable' });
// Idempotent invoice creation (will fail by unique index if a pending one exists)
Payment.create({
buyerId, purchaseRequestId, provider: 'shkeeper', direction: 'in', status: 'pending', ...
});
db.select().from(payments).where(and(eq(payments.direction, 'in'), eq(payments.escrowState, 'releasable')));
```
Related: [[PurchaseRequest]], [[SellerOffer]], [[User]], [[Dispute]].

View File

@@ -0,0 +1,203 @@
---
title: Postgres Runtime Cutover Status
tags: [data-model, postgres, migration, runtime-status]
aliases: [Postgres Status, PG Cutover Status, Mongo vs Postgres Runtime]
created: 2026-05-31
updated: 2026-06-06
source: backend integrate-main-into-development@41087c7 + deployment main@8764fdf
---
# Postgres Runtime Cutover Status
> **Current branch:** backend `integrate-main-into-development`, version `2.9.12`.
>
> **Bottom line: Migration complete as of 2026-06-06, backend v2.9.12.** MongoDB and Mongoose have been fully removed from the runtime. PostgreSQL (Drizzle ORM) is the sole database. All 11 repository domains use DrizzleXxxRepo exclusively. No dual-write wrappers are active. TypeScript compilation: 0 errors.
## Migration Status
| Phase | Status |
|---|---|
| Schema design | Complete — 32 tables, 19 migrations (00000019) |
| Drizzle repos | Complete — all 11 factory domains have a DrizzleXxxRepo |
| Dual-write wrappers | Decommissioned — removed from runtime as of v2.9.12 |
| Write cutover | Complete — all writes go to PostgreSQL only |
| Read cutover | Complete — all reads from PostgreSQL |
| Mongoose removal | Complete — no Mongoose imports in runtime src/ |
| TypeScript compilation | 0 errors |
## Schema and Repository Coverage
### Tables with Full Drizzle Schema
All tables below have a `.ts` schema file in `src/db/schema/` and are covered by at least one migration:
**Infrastructure:** `id_map`, `pg_dualwrite_gaps`
**Auth/Users:** `users`, `user_passkeys`, `user_refresh_tokens`, `telegram_links`, `telegram_sessions`
**Marketplace:** `categories`, `purchase_requests`, `purchase_request_delivery_info`, `purchase_request_delivery_address`, `purchase_request_seller_delivery_info`, `purchase_request_service_info`, `purchase_request_specifications`, `purchase_request_preferred_sellers`, `delivery_attempts`, `seller_offers`, `request_templates`
**Payments:** `payments`, `payment_quotes`, `funds_ledger_entries`, `derived_destinations`, `derived_destination_sweeps`
**Points/Wallet:** `point_transactions`, `trezor_accounts`, `trezor_derived_addresses`
**Config/Ops:** `config_settings`, `config_setting_history`, `shop_settings`, `addresses`, `reviews`
**Content/Social:** `blog_posts`, `notifications`, `disputes`, `chats`
Total: **32 tables** across 19 migrations (00000019).
### Tables with a Drizzle Repository
| Drizzle Repo | Domain |
|---|---|
| `DrizzleUserRepo` | Users, passkeys, refresh tokens |
| `DrizzlePaymentRepo` | Payments, funds ledger |
| `DrizzleMarketplaceRepo` | Categories, purchase requests, seller offers, request templates |
| `DrizzleDerivedDestinationRepo` | Derived destinations, sweeps |
| `DrizzleTrezorAccountRepo` | Trezor accounts, derived addresses |
| `DrizzlePointsRepo` | Point transactions |
| `DrizzleNotificationRepo` | Notifications |
| `DrizzleDisputeRepo` | Disputes |
| `DrizzleBlogRepo` | Blog posts |
| `DrizzleChatRepo` | Chats (JSONB shim; Chat normalization is an optional future improvement) |
| `DrizzleReleaseHoldRepo` | Release holds (bridges payments + purchase_requests) |
Tables with schema but no dedicated Drizzle repo (handled via store facades): `addresses`, `shop_settings`, `config_settings` / `config_setting_history`, `telegram_links` / `telegram_sessions`, `reviews`.
### Migration Count
19 migrations landed: **0000 through 0019**.
| Migration | Key change |
|---|---|
| 0000 | Core enums + `id_map` + `categories` |
| 0001 | `trezor_accounts` + `trezor_derived_addresses` |
| 0002 | Schema reset (drops 0000/0001 tables, adds category self-FK) |
| 0003 | Full rebuild: all core domain tables (users, payments, marketplace, funds ledger, derived destinations, points, trezor) |
| 0004 (×2) | Funds ledger immutability trigger; seller_offer physical FKs |
| 0005 | `pg_dualwrite_gaps`; payment FKs; legacy_object_id uniques; pending payment index fix |
| 0006 | budget_currency crypto-only CHECK on purchase_requests |
| 0007 | Drops 0006 constraint; sets USDT default |
| 0008 | `offer_currency` adds TRY; creates `payment_quotes` |
| 0009 | Active category deduplication; `categories_active_name_norm_uq` |
| 0010 | `request_templates`; purchase_request_specifications unique constraint |
| 0011 | `chats` + chat enums |
| 0012 | `disputes` |
| 0013 | Money-integrity CHECK constraints; ledger TRUNCATE guard; id_map composite PK |
| 0014 | Physical NOT VALID FKs across schema; validates all |
| 0015 | Ledger immutability extended: UPDATE + DELETE triggers |
| 0016 | `address_type` enum + `addresses` table |
| 0017 | `guard` value added to `user_role` enum |
| 0018 | AI request fields |
| 0019 | `payment_provider` enum: added `escrow` |
## What Uses Postgres Now
All domains are PostgreSQL-only. The table below summarises the runtime topology for reference.
| Area | Runtime status | Notes |
|---|---|---|
| Postgres connection | Required — `PG_URL` must be set | Store facades use `src/infrastructure/postgres/client.ts`; the broader `src/db/` Drizzle layer and repository factory are fully populated. |
| Runtime schema bootstrap | Implemented for auth, config, address, and reference stores | Auth tables bootstrapped from `src/services/auth/postgresAuthSchema.ts`; store facades bootstrap their own tables at startup. |
| Health observability | Implemented in `/api/health` | `checks.postgres` reports `configured`, `required`, `storeModes`, `enabledStores`, and `enabledStoreCount`. Mongoose health check is no longer present. |
| Auth-owned user store | PG-backed | Auth, passkey, Telegram auth/link/session/temp-verification, and `/api/user` profile paths use the auth-store facade pointing at Postgres. `legacy_object_id` column retained for id-map compatibility. |
| Confirmation-threshold runtime config | PG-backed | `ConfigSetting` / `ConfigSettingHistory` access routes through the config-store facade. |
| User addresses | PG-backed | `/api/addresses` CRUD uses the address-store facade. |
| Marketplace categories | PG-backed | `CategoryService` and the default `General` category path use the category-store facade. `categories_active_name_norm_uq` enforced. |
| Level configuration | PG-backed | `PointsService` level reads use the level-config facade. `LEVEL_STORE=postgres` accepted as compatibility alias. |
| Shop settings | PG-backed | Shop settings controller, seller payment rail resolution, and review enable/disable checks use the shop-settings facade. Seller shop lookup handles both uuid and legacy id formats. |
| Marketplace reviews | PG-backed | Review list/summary/create routes use the review-store facade. |
| Notifications | PG-backed | `NotificationService` uses `getNotificationRepo()` for create/list/read/delete/count paths. |
| Oracle quote persistence | PG write when `ORACLE_QUOTING_ENABLED=true` | `/api/payment/request-network/intents` writes `payment_quotes` to PG. Mongo mirror path removed. |
| Funds ledger | PG-backed | `appendFundsLedgerEntry` and `getFundsBalanceBy*` call `getPaymentRepo()` which resolves to `DrizzlePaymentRepo`. |
| Payments and escrow state | PG-backed | All payment services use Drizzle repos; Mongoose `Payment` model removed. |
| Derived destinations and sweeps | PG-backed | `getDerivedDestinationRepo()` resolves to `DrizzleDerivedDestinationRepo`. |
| Points/referrals/transactions | PG-backed | `getPointsRepo()` resolves to `DrizzlePointsRepo`. |
| Chat/messages | PG-backed (JSONB shim) | `getChatRepo()` resolves to `DrizzleChatRepo`. Participants/messages are stored as JSONB blobs; normalization into relational child tables is an optional future improvement. |
| Disputes/blog | PG-backed | Both resolve to Drizzle repos. |
| ReleaseHold | PG-backed | `getReleaseHoldRepo()` resolves to `DrizzleReleaseHoldRepo`. |
| Backfill/verify scripts | Available as operator tooling | `MIGRATION_PG_URL` drives all backfill scripts. Not run automatically at startup. |
| Guard user role | PG schema-ready | Migration 0017 adds `guard` to the `user_role` enum. |
| Seeds | Postgres-capable | Seeds in `src/seeds/*` are store-aware and idempotent under `MONGO_CONNECT_MODE=never`. |
## What Was Mongo-Backed (Historical)
All domains are now PostgreSQL-only as of v2.9.12. The following were the remaining Mongo-backed areas prior to the final cutover:
- **User reads** — Auth-owned users were PG-backed for writes but reads remained Mongo-authoritative until the full auth cutover.
- **Marketplace requests/offers/templates reads** — `REPO_MARKETPLACE` defaulted to Mongo; read cutover required smoke coverage.
- **Payments and escrow state reads** — Payment services called Mongoose documents directly for reads until the final payment-domain wiring was completed.
- **Derived destinations and sweeps** — `REPO_DERIVED_DESTINATION` defaulted to Mongo.
- **Points/referrals/transactions** — `REPO_POINTS` defaulted to Mongo.
- **Chat/messages** — `getChatRepo()` defaulted to Mongo; JSONB shim was the Drizzle path. No dual-write wrapper existed.
- **Disputes/blog** — Defaulted to Mongo until `REPO_DISPUTE`/`BLOG_STORE` were flipped.
- **ReleaseHold** — No dual-write wrapper; required explicit flip.
All of the above are now fully PostgreSQL-backed. MongoDB and Mongoose have been removed from the runtime.
## Env Flag Reality
All `*_STORE=mongo` and `REPO_*=mongo` env flags are obsolete — the repository factory only supports `postgres`/`pg` mode. `MONGO_URI` and `MONGO_CONNECT_MODE` have been removed from the runtime.
| Flag | Current meaning |
|---|---|
| `MONGO_URI` | REMOVED — MongoDB has been removed from the runtime. |
| `MONGO_CONNECT_MODE` | REMOVED — MongoDB has been removed from the runtime. |
| `AUTH_STORE` | OBSOLETE — only `postgres` is valid. Setting to `mongo` has no effect. |
| `CONFIG_STORE` | OBSOLETE — only `postgres` is valid. |
| `ADDRESS_STORE` | OBSOLETE — only `postgres` is valid. |
| `CATEGORY_STORE` | OBSOLETE — only `postgres` is valid. |
| `LEVEL_CONFIG_STORE` | OBSOLETE — only `postgres` is valid. `LEVEL_STORE=postgres` accepted as alias. |
| `SHOP_SETTINGS_STORE` | OBSOLETE — only `postgres` is valid. |
| `REVIEW_STORE` | OBSOLETE — only `postgres` is valid. |
| `NOTIFICATION_STORE` / `REPO_NOTIFICATION` | OBSOLETE — only `postgres`/`pg` is valid. |
| `PG_URL` | REQUIRED — PostgreSQL is the sole database. All store facades and repos require this. |
| `MIGRATION_PG_URL` | Used by backfill scripts and migration runbooks; not part of normal request handling. |
| `REPO_PAYMENT` | OBSOLETE — only `postgres` is valid. All payment services use `DrizzlePaymentRepo`. |
| `REPO_MARKETPLACE` | OBSOLETE — only `postgres` is valid. All marketplace writes and reads route through `DrizzleMarketplaceRepo`. |
| `REPO_USER`, `REPO_POINTS`, `REPO_DERIVED_DESTINATION`, `REPO_TREZOR` | OBSOLETE — only `postgres` is valid. All resolve to their respective Drizzle repos. |
| `REPO_DISPUTE` / `DISPUTE_STORE`, `REPO_BLOG` / `BLOG_STORE` | OBSOLETE — only `postgres` is valid. |
| `REPO_CHAT` / `CHAT_STORE` | OBSOLETE — only `postgres` is valid. `DrizzleChatRepo` is the sole chat repo. |
| `REPO_RELEASE_HOLD` / `RELEASE_HOLD_STORE` | OBSOLETE — only `postgres` is valid. |
| `ORACLE_QUOTING_ENABLED` | Enables server-side quote computation and the `payment_quotes` PG write in checkout. |
## What's Next (Post-Migration)
1. **Prod backfill** — If the production instance was running Mongo-backed data before the cutover, a one-time backfill from Mongo to Postgres under a maintenance window is required. Use `MIGRATION_PG_URL` + `MIGRATION_MONGO_URL` with the existing backfill scripts. Validate row counts before switching prod traffic.
2. **Chat normalization** — The `DrizzleChatRepo` currently stores participants and messages as JSONB blobs rather than normalized relational child tables. This is an optional future improvement; it does not block current operation but would enable richer querying and FK integrity on chat data.
3. **`payment_provider` enum `escrow` value** — Confirm migration 0019 has been applied on all target databases (adds `escrow` to the `payment_provider` enum). If not already run, apply it before using escrow-provider payment records.
## Recent Progress Since Last Update (2.8.37 → 2.9.12)
- **2.8.382.8.46:** Complete dual-write repos for all remaining domains; Drizzle migrations pipeline finalized; TTL scheduler added; shop lookup bug-fixed.
- **2.8.47:** Seeds made Postgres-capable and idempotent for PG-only boot (`MONGO_CONNECT_MODE=never`).
- **2.8.482.8.49:** Fresh-DB PG migrate + seed path corrected; 0013/0014 migrations made valid for a fresh `drizzle-kit migrate` run.
- **2.8.50:** Admin user counts routed through postgres-capable stores; admin user management works end-to-end under PG.
- **2.8.512.8.53:** PG response serialization and id resolution in marketplace purchase-request paths corrected; user creation and purchase request unblocked.
- **2.8.54:** Guard user role added across auth and user management; migration 0017 adds guard to user_role enum.
- **2.8.55:** Chat routes fixed and notifications deliver in real time.
- **2.8.56:** Seller shop lookup made tolerant of uuid/legacy id formats.
- **2.8.572.8.60:** Telegram Mini App in-shell shop, account tab parity, shopping cart.
- **2.8.61:** Direct-transfer checkout option for non-web3 users.
- **2.8.622.8.64:** Points level boundary fixes, legacy 24-hex user id support, seller ratings from real published reviews.
- **2.8.65:** Chat participant names populated on Postgres path, participant canonicalization.
- **2.8.662.8.69:** Telegram: product-style template cards, in-shell template detail, web-app-parity templates, settings/addresses in-shell, theme/dark mode, solar-style icons, avatar upload, achievements.
- **2.8.70:** Telegram in-shell settings and addresses, theme from central config.
- **2.8.712.8.73:** Telegram: solar-style icons, avatar URL fixes, inline email verify, web links keep app alive, remove escrow-states.
- **2.8.74:** Telegram chat own-message detection, read-only email field.
- **2.8.75:** Self-contained email-change flow with visible code entry.
- **2.8.76:** Telegram send-code always reveals verify panel.
- **2.8.77:** Telegram keep email code panel mounted after sending.
- **2.8.78:** Telegram system messages neutral + post-delivery seller review.
- **2.8.79:** Request template maxUsage made truly optional; template creation 500 fix.
- **2.9.x:** Full MongoDB/Mongoose removal — all Mongoose models replaced by Drizzle repos, dual-write decommissioned, TypeScript compiles with 0 errors (2026-06-06).
## Related Docs
- [[Database Strategy - Mongo vs Postgres Assessment]]
- [[MongoDB to PostgreSQL Migration Plan (Drizzle)]]
- [[Payment]]
- [[Payment API]]
- [[Environment Variables]]
- [[Database Operations]]

View File

@@ -1,133 +1,291 @@
---
title: PurchaseRequest
tags: [data-model, mongoose]
tags: [data-model, postgres, drizzle]
aliases: [Purchase Request, Buy Request, IPurchaseRequest]
---
# PurchaseRequest
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-06-06 — MongoDB/Mongoose fully removed; PostgreSQL + Drizzle ORM is the only database layer (backend v2.9.12). Removed dual-write/Mongo sections; updated IDs to UUID; clarified deliveryDate nesting and paymentId absence.
The central buyer-side document. A `PurchaseRequest` captures what a buyer wants to acquire (physical product, digital product, service, or consultation), the budget envelope, urgency, delivery details, and the entire lifecycle from creation through payment, delivery, and completion. Sellers respond by attaching [[SellerOffer]] documents; the buyer accepts one, a [[Payment]] is opened, and delivery is verified by a 6-digit code.
> [!note] Source
> `backend/src/models/PurchaseRequest.ts:95` — schema definition
> `backend/src/models/PurchaseRequest.ts:387` — model export
> [!note] Sources
> PostgreSQL schema (Drizzle): `backend/src/db/schema/purchaseRequest.ts`
> Mongoose model removed in v2.9.12 — `src/models/` directory deleted.
## Schema
## Migration Status
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `buyerId` | ObjectId → [[User]] | yes | — | — | yes | Buyer that owns the request. |
| `title` | String | yes | — | trim, maxlength 200 | — | Short headline. |
| `description` | String | yes | — | trim, minlength 5 (frontend), maxlength 2000 | — | Long form description. Frontend enforces a 5-character minimum; the field is optional in the raw schema but the form will reject shorter values. |
| `categoryId` | ObjectId → [[Category]] | yes | — | — | yes | Category the request belongs to. |
| `productType` | String | no | `physical_product` | enum: `physical_product` / `digital_product` / `service` / `consultation` | yes | What kind of fulfilment is expected. |
| `productLink` | String | no | — | trim, must match `/^https?:\/\/.+/` | — | Reference URL for the desired product. |
| `size` | String | no | — | trim, maxlength 100 | — | Product size. |
| `color` | String | no | — | trim, maxlength 100 | — | Product color. |
| `brand` | String | no | — | trim, maxlength 100 | — | Brand preference. |
| `preferredSellerIds[]` | ObjectId → [[User]] | no | `[]` | — | — | Targeted sellers for a private request. |
| `quantity` | Number | no | `1` | min 1 | — | Unit count. |
| `budget.min` | Number | no | — | min 0 | — | Lower bound. |
| `budget.max` | Number | no | — | min 0 | — | Upper bound. |
| `budget.currency` | String | no | `USD` | enum: `USD` / `EUR` / `IRR` | — | Budget currency. |
| `urgency` | String | no | `medium` | enum: `low` / `medium` / `high` / `urgent` | yes | Buyer urgency. |
| `status` | String | no | `pending` | enum (13 values — see State Transitions below) | yes | Lifecycle state. |
| `isPublic` | Boolean | no | `true` | — | — | Public marketplace listing vs. private request. |
| `tags[]` | String[] | no | — | trim | — | Free-form tags. |
| `specifications[].key` | String | yes | — | trim | — | Spec key. |
| `specifications[].value` | String | yes | — | trim | — | Spec value. |
| `specifications[].label` | String | no | — | trim | — | Human label. |
| `deliveryInfo.deliveryType` | String | yes | `physical` | enum: `physical` / `online` | — | Delivery channel. |
| `deliveryInfo.address` | String | no | — | — | — | Physical address. |
| `deliveryInfo.preferredDate` | Date | no | — | — | — | Buyer's target date. |
| `deliveryInfo.notes` | String | no | — | — | — | Free-form notes. |
| `deliveryInfo.deliveryAddress.name` | String | no | — | — | — | Recipient name. |
| `deliveryInfo.deliveryAddress.phoneNumber` | String | no | — | — | — | Recipient phone. |
| `deliveryInfo.deliveryAddress.fullAddress` | String | no | — | — | — | Full address string. |
| `deliveryInfo.deliveryAddress.addressType` | String | no | — | — | — | e.g. Home / Office. |
| `deliveryInfo.email` | String | no | — | email regex | — | For digital delivery. |
| `deliveryInfo.sellerDeliveryInfo.estimatedDeliveryDate` | Date | no | — | — | — | Seller's ETA date. |
| `deliveryInfo.sellerDeliveryInfo.estimatedDeliveryTime` | String | no | — | — | — | Seller's ETA time. |
| `deliveryInfo.sellerDeliveryInfo.trackingNumber` | String | no | — | — | — | Carrier tracking. |
| `deliveryInfo.sellerDeliveryInfo.deliveryNotes` | String | no | — | — | — | Notes from seller. |
| `deliveryInfo.sellerDeliveryInfo.shippingMethod` | String | no | — | — | — | Method label. |
| `deliveryInfo.sellerDeliveryInfo.downloadLink` | String | no | — | — | — | Download URL for digital products. |
| `deliveryInfo.sellerDeliveryInfo.digitalFiles[]` | String[] | no | — | — | — | Digital file URLs. |
| `deliveryInfo.deliveryDateTime` | Date | no | — | — | — | Confirmed delivery datetime. |
| `deliveryInfo.deliveryDate` | Date | no | — | — | — | Confirmed delivery date. |
| `deliveryInfo.shippedAt` | Date | no | — | — | — | Timestamp of shipment. |
| `deliveryInfo.deliveryCode` | String | no | — | trim, length 6 | — | 6-digit handoff code. |
| `deliveryInfo.deliveryCodeGeneratedAt` | Date | no | — | — | — | When code was issued. |
| `deliveryInfo.deliveryCodeExpiresAt` | Date | no | — | — | — | When code expires. |
| `deliveryInfo.deliveryCodeUsed` | Boolean | no | `false` | — | — | Whether the code has been redeemed. |
| `deliveryInfo.deliveryCodeUsedAt` | Date | no | — | — | — | When it was redeemed. |
| `deliveryInfo.deliveryCodeUsedBy` | ObjectId → [[User]] | no | — | — | — | Seller that redeemed. |
| `deliveryInfo.deliveredAt` | Date | no | — | — | — | Final delivery timestamp. |
| `deliveryInfo.deliveryAttempts[].sellerId` | ObjectId → [[User]] | yes | — | — | — | Seller making the attempt. |
| `deliveryInfo.deliveryAttempts[].attemptedAt` | Date | no | `Date.now` | — | — | When attempted. |
| `deliveryInfo.deliveryAttempts[].success` | Boolean | yes | — | — | — | Whether it succeeded. |
| `deliveryInfo.deliveryAttempts[].code` | String | no | — | — | — | Code entered (only stored on success). |
| `serviceInfo.duration` | Number | no | — | min 0.5 | — | Hours, only for service/consultation. |
| `serviceInfo.sessionType` | String | no | — | enum: `online` / `in_person` / `hybrid` | — | Service session type. |
| `serviceInfo.location` | String | no | — | trim, maxlength 200 | — | Service location. |
| `serviceInfo.requirements[]` | String[] | no | — | trim | — | Pre-requisites. |
| `attachments[]` | String[] | no | — | — | — | Attached file URLs. |
| `offers[]` | ObjectId → [[SellerOffer]] | no | — | — | — | Offers received. |
| `selectedOfferId` | ObjectId → [[SellerOffer]] | no | `null` | — | — | Accepted offer. |
| `rating` | Number | no | `null` | min 1, max 5 | — | Buyer's post-delivery rating. |
| `feedback` | String | no | `null` | maxlength 1000 | — | Buyer's feedback text. |
| `deliveryConfirmed` | Boolean | no | `false` | — | — | Buyer confirmation flag. |
| `deliveryConfirmedAt` | Date | no | `null` | — | — | Confirmation timestamp. |
| `metadata.source` | String | no | `manual` | enum: `manual` / `template` / `api` | — | Where the request came from. |
| `metadata.templateId` | String | no | — | trim | — | Originating [[RequestTemplate]] id. |
| `metadata.version` | String | no | — | trim | — | Schema version. |
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
**Complete.** MongoDB and Mongoose are fully removed from the backend runtime. PostgreSQL + Drizzle ORM is the only database layer. No dual-write mode; all domain stores use Postgres exclusively. 19 migrations landed (00000019), 32 tables total.
### Status enum — all valid values
---
## PostgreSQL Schema (Drizzle)
Source: `backend/src/db/schema/purchaseRequest.ts`
The PG model normalises prior embedded subdocuments into 7 tables. The `offers[]` array is not present; [[SellerOffer]] holds `purchase_request_id` as a back-reference.
> **ID note:** All primary keys are PostgreSQL UUIDs (`.id` field, `string`). There is no `_id` / ObjectId field in runtime code. A `legacy_object_id` column exists on each table solely for backfill traceability — do not use it in application logic.
> **paymentId note:** `PurchaseRequest` does **not** have a top-level `paymentId` field. Payment records reference the purchase request via `Payment.purchaseRequestId`; to find the payment for a request, query `Payment WHERE purchase_request_id = ?`.
> **preferredSellerIds note:** Stored in the `purchase_request_preferred_sellers` junction table as UUID `seller_id` references to `users(id)` (specifically `users.pgId`). They are UUID strings, not populated document objects.
> **deliveryDate note:** `deliveryDate` (and all other delivery logistics) are nested inside the `purchase_request_delivery_info` child table (`delivery_date` column). There is no top-level `deliveryDate` field on `purchase_requests`. Use `updatePurchaseRequestDeliveryInfo()` to update it.
### Enums (PG-level)
| Enum name | Values |
| --- | --- |
| `purchase_request_status` | `pending_payment`, `pending`, `active`, `received_offers`, `in_negotiation`, `payment`, `processing`, `delivery`, `delivered`, `confirming`, `completed`, `cancelled`, `seller_paid` |
| `product_type` | `physical_product`, `digital_product`, `service`, `consultation` |
| `request_urgency` | `low`, `medium`, `high`, `urgent` |
| `delivery_type` | `physical`, `online` |
| `service_session_type` | `online`, `in_person`, `hybrid` |
| `pr_metadata_source` | `manual`, `template`, `api` |
| `budget_currency` | `USD`, `EUR`, `IRR`, `USDT`, `USDC` |
### Table: `purchase_requests` (main)
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | `gen_random_uuid()` | Application primary key — use this everywhere |
| `legacy_object_id` | text | yes | — | 24-char former Mongo ObjectId; partial-unique index; traceability only |
| `buyer_id` | uuid | no | — | FK → `users(id)` |
| `category_id` | uuid | no | — | FK → `categories(id)` |
| `title` | varchar(200) | no | — | |
| `description` | text | no | — | |
| `product_type` | enum | yes | `physical_product` | |
| `product_link` | varchar(2000) | yes | — | CHECK: `^https?://.+` |
| `size` | varchar(100) | yes | — | |
| `color` | varchar(100) | yes | — | |
| `brand` | varchar(100) | yes | — | |
| `quantity` | integer | yes | `1` | CHECK ≥ 1 |
| `budget_min` | numeric(38,18) | yes | — | CHECK ≥ 0 |
| `budget_max` | numeric(38,18) | yes | — | CHECK ≥ 0 |
| `budget_currency` | enum | yes | `USDT` | |
| `urgency` | enum | no | `medium` | |
| `status` | enum | no | `pending` | 13-value escrow-critical enum |
| `is_public` | boolean | yes | `true` | |
| `tags` | text[] | yes | `'{}'` | |
| `attachments` | text[] | yes | `'{}'` | |
| `selected_offer_id` | uuid | yes | — | FK → `seller_offers(id)` |
| `rating` | smallint | yes | — | CHECK 15 or NULL |
| `feedback` | text | yes | — | CHECK length ≤ 1000 or NULL |
| `delivery_confirmed` | boolean | yes | `false` | |
| `delivery_confirmed_at` | timestamptz | yes | — | |
| `dispute_raised` | boolean | no | `false` | |
| `dispute_raised_at` | timestamptz | yes | — | |
| `dispute_resolved` | boolean | no | `false` | |
| `dispute_resolved_at` | timestamptz | yes | — | |
| `dispute_hold_reason` | text | yes | — | |
| `hold_until` | timestamptz | yes | — | Partial index WHERE NOT NULL |
| `metadata_source` | enum | yes | `manual` | |
| `metadata_template_id` | varchar(100) | yes | — | |
| `metadata_version` | varchar(50) | yes | — | |
| `created_at` | timestamptz | no | `now()` | |
| `updated_at` | timestamptz | no | `now()` | |
**Indexes on `purchase_requests`:**
| Index | Type | Columns / condition |
| --- | --- | --- |
| `idx_pr_buyer_id` | btree | `buyer_id` |
| `idx_pr_category_id` | btree | `category_id` |
| `idx_pr_product_type` | btree | `product_type` |
| `idx_pr_status` | btree | `status` |
| `idx_pr_created_at` | btree | `created_at` |
| `idx_pr_urgency` | btree | `urgency` |
| `purchase_requests_legacy_object_id_uq` | partial-unique | `legacy_object_id` WHERE NOT NULL |
| `idx_pr_product_type_status` | btree | `(product_type, status)` |
| `idx_pr_category_product_type` | btree | `(category_id, product_type)` |
| `idx_pr_hold_until` | partial btree | `hold_until` WHERE NOT NULL |
| `idx_pr_dispute_raised` | partial btree | `dispute_raised` WHERE `dispute_raised = true` |
**CHECK constraints on `purchase_requests`:**
| Name | Expression |
| --- | --- |
| `pr_rating_ck` | `rating IS NULL OR (rating >= 1 AND rating <= 5)` |
| `pr_feedback_len_ck` | `feedback IS NULL OR length(feedback) <= 1000` |
| `pr_quantity_min_ck` | `quantity IS NULL OR quantity >= 1` |
| `pr_budget_min_ck` | `budget_min IS NULL OR budget_min >= 0` |
| `pr_budget_max_ck` | `budget_max IS NULL OR budget_max >= 0` |
| `pr_product_link_ck` | `product_link IS NULL OR product_link ~ '^https?://.+'` |
---
### Table: `purchase_request_delivery_info` (1:1)
Child of `purchase_requests`. Holds all delivery logistics. **`deliveryDate` and all delivery timestamps live here, not on the parent table.** Update via `updatePurchaseRequestDeliveryInfo()`.
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | random | |
| `legacy_object_id` | text | yes | — | Parent PR's legacy ObjectId for traceability |
| `purchase_request_id` | uuid UNIQUE | no | — | FK → `purchase_requests(id)` CASCADE |
| `delivery_type` | enum | no | `physical` | |
| `address` | varchar(500) | yes | — | |
| `preferred_date` | timestamptz | yes | — | |
| `notes` | text | yes | — | |
| `email` | varchar(255) | yes | — | CHECK: email regex or NULL |
| `delivery_date_time` | timestamptz | yes | — | |
| `delivery_date` | date | yes | — | Confirmed delivery date (nested inside deliveryInfo, not top-level on PurchaseRequest) |
| `shipped_at` | timestamptz | yes | — | |
| `delivery_code` | varchar(6) | yes | — | CHECK: length = 6 or NULL |
| `delivery_code_generated_at` | timestamptz | yes | — | |
| `delivery_code_expires_at` | timestamptz | yes | — | |
| `delivery_code_used` | boolean | yes | `false` | |
| `delivery_code_used_at` | timestamptz | yes | — | |
| `delivery_code_used_by` | uuid | yes | — | FK → `users(id)` |
| `delivered_at` | timestamptz | yes | — | |
| `created_at` | timestamptz | no | `now()` | |
| `updated_at` | timestamptz | no | `now()` | |
**Indexes:** `idx_pr_delivery_info_pr_id` on `purchase_request_id`
**CHECK constraints:** `pr_di_delivery_code_len_ck` (`length = 6 or NULL`), `pr_di_email_fmt_ck` (email regex)
---
### Table: `purchase_request_delivery_address` (1:1 under delivery_info)
| Column | PG type | Nullable | Notes |
| --- | --- | --- | --- |
| `id` | uuid PK | no | |
| `legacy_object_id` | text | yes | |
| `delivery_info_id` | uuid UNIQUE | no | FK → `purchase_request_delivery_info(id)` CASCADE |
| `recipient_name` | varchar(200) | yes | |
| `phone_number` | varchar(20) | yes | |
| `full_address` | text | yes | |
| `address_type` | varchar(50) | yes | e.g. Home / Office |
**Index:** `idx_pr_delivery_addr_info_id` on `delivery_info_id`
---
### Table: `purchase_request_seller_delivery_info` (1:1 under delivery_info)
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | random | |
| `legacy_object_id` | text | yes | — | |
| `delivery_info_id` | uuid UNIQUE | no | — | FK → `purchase_request_delivery_info(id)` CASCADE |
| `estimated_delivery_date` | timestamptz | yes | — | |
| `estimated_delivery_time` | varchar(50) | yes | — | |
| `tracking_number` | varchar(100) | yes | — | |
| `delivery_notes` | text | yes | — | |
| `shipping_method` | varchar(100) | yes | — | |
| `download_link` | varchar(2000) | yes | — | |
| `digital_files` | text[] | yes | `'{}'` | |
| `created_at` | timestamptz | no | `now()` | |
| `updated_at` | timestamptz | no | `now()` | |
**Index:** `idx_pr_seller_di_info_id` on `delivery_info_id`
---
### Table: `delivery_attempts` (1:N under delivery_info)
Append-only audit log of code-entry attempts.
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | random | |
| `delivery_info_id` | uuid | no | — | FK → `purchase_request_delivery_info(id)` CASCADE |
| `seller_id` | uuid | no | — | FK → `users(id)` |
| `attempted_at` | timestamptz | no | `now()` | |
| `success` | boolean | no | — | |
| `code` | varchar(100) | yes | — | Only stored on successful attempts |
**Indexes:** `idx_delivery_attempts_info_id`, `idx_delivery_attempts_seller_id`, `idx_delivery_attempts_success`
---
### Table: `purchase_request_service_info` (1:1)
Only populated for `service` / `consultation` product types.
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | random | |
| `legacy_object_id` | text | yes | — | |
| `purchase_request_id` | uuid UNIQUE | no | — | FK → `purchase_requests(id)` CASCADE |
| `duration` | numeric(5,2) | yes | — | CHECK ≥ 0.5 |
| `session_type` | enum | yes | — | `online` / `in_person` / `hybrid` |
| `location` | varchar(200) | yes | — | |
| `requirements` | text[] | yes | `'{}'` | |
| `created_at` | timestamptz | no | `now()` | |
| `updated_at` | timestamptz | no | `now()` | |
**Index:** `idx_pr_service_info_pr_id`
**CHECK:** `pr_si_duration_min_ck` (`duration IS NULL OR duration >= 0.5`)
---
### Table: `purchase_request_specifications` (1:N)
Queryable `{key, value, label}` specs.
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | uuid PK | no | random | |
| `purchase_request_id` | uuid | no | — | FK → `purchase_requests(id)` CASCADE |
| `key` | varchar(255) | no | — | |
| `value` | text | no | — | |
| `label` | varchar(255) | yes | — | |
| `position` | integer | no | `0` | Preserves array order for round-trip fidelity |
**Indexes:** `idx_pr_specs_pr_id`, `idx_pr_specs_key`, partial-unique `purchase_request_specifications_request_key_uq` on `(purchase_request_id, key)`
---
### Table: `purchase_request_preferred_sellers` (N:M junction)
Stores the buyer's targeted seller list. Each row is a UUID reference to `users(id)` (i.e. `users.pgId`). There are no populated document objects — only UUID strings.
| Column | PG type | Nullable | Notes |
| --- | --- | --- | --- |
| `purchase_request_id` | uuid | no | FK → `purchase_requests(id)` |
| `seller_id` | uuid | no | FK → `users(id)` — matches `users.pgId` |
**Indexes:** composite unique `idx_pr_preferred_sellers_uq` on `(purchase_request_id, seller_id)`; `idx_pr_preferred_sellers_seller_id` on `seller_id`
---
### Design Notes
- **`offers[]` not present in PG.** Query `SellerOffer WHERE purchase_request_id = ?` instead.
- **`paymentId` not present.** `PurchaseRequest` has no top-level `paymentId`. Payments reference the request; query `Payment WHERE purchase_request_id = ?`.
- **`deliveryDate` is nested.** `delivery_date` lives in `purchase_request_delivery_info`, not on the main `purchase_requests` table. Update it via `updatePurchaseRequestDeliveryInfo()`.
- **Money scale.** `budget_min` / `budget_max` use `numeric(38,18)` (project-wide crypto convention) for consistency with `Payment` and `FundsLedgerEntry`.
- **`tags` / `attachments`** stored as `text[]` (not JSONB) to enable `ANY()` array queries without a child table.
- **`legacy_object_id`** on every table uses a partial-unique index (`WHERE NOT NULL`) for idempotent backfill upserts. Do not use in application logic.
- **Dispute / escrow hold fields** (`dispute_raised`, `dispute_raised_at`, `dispute_resolved`, `dispute_resolved_at`, `dispute_hold_reason`, `hold_until`) are escrow-critical and present on the main `purchase_requests` table.
---
## Status enum — all valid values
`pending_payment` · `pending` · `active` · `received_offers` · `in_negotiation` · `payment` · `processing` · `delivery` · `delivered` · `confirming` · `completed` · `seller_paid` · `cancelled`
**Note:** `finalized` and `archived` are **not** valid status values and do not appear in the `IPurchaseRequest` frontend type or the Mongoose schema enum. Using either would cause a validation error.
**Note:** `finalized` and `archived` are **not** valid status values. Using either would cause a validation error.
## Virtuals
None defined.
## Indexes
Single-field — `backend/src/models/PurchaseRequest.ts:376-381`:
- `{ buyerId: 1 }`
- `{ categoryId: 1 }`
- `{ productType: 1 }`
- `{ status: 1 }`
- `{ createdAt: -1 }`
- `{ urgency: 1 }`
Compound — `backend/src/models/PurchaseRequest.ts:384-385`:
- `{ productType: 1, status: 1 }`
- `{ categoryId: 1, productType: 1 }`
## Pre/Post Hooks
None declared at the schema level.
## Instance Methods
None defined.
## Static Methods
None defined.
---
## Relationships
- **References**: [[User]] (`buyerId`, `preferredSellerIds[]`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), [[Category]] (`categoryId`), [[SellerOffer]] (`offers[]`, `selectedOfferId`).
- **Referenced by**: [[SellerOffer]] (`purchaseRequestId`), [[Payment]] (`purchaseRequestId`), [[Dispute]] (`purchaseRequestId`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'PurchaseRequest'`), [[Review]] (`purchaseRequestId`).
- **References**: [[User]] (`buyer_id`, `preferred_sellers[].seller_id` — UUIDs, `delivery_code_used_by`, `delivery_attempts[].seller_id`), [[Category]] (`category_id`), [[SellerOffer]] (`selected_offer_id`).
- **Referenced by**: [[SellerOffer]] (`purchase_request_id`), [[Payment]] (`purchase_request_id`), [[Dispute]] (`purchase_request_id`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'PurchaseRequest'`), [[Review]] (`purchase_request_id`).
## Template Checkout Mapping
When a buyer converts a [[RequestTemplate]], the seller's template remains authoritative for delivery mode:
- `physical` templates require a buyer billing/delivery address in checkout. The generated request stores both `deliveryInfo.address` and `deliveryInfo.deliveryAddress`.
- `online` templates require a buyer email in checkout. The generated request stores it in `deliveryInfo.email`.
- Mixed carts can produce multiple requests with different delivery modes; the checkout UI asks for the union of required buyer details.
## State Transitions
@@ -158,23 +316,33 @@ stateDiagram-v2
## Common Queries
```ts
// Buyer's open requests
PurchaseRequest.find({ buyerId, status: { $in: ['pending', 'active', 'received_offers'] } });
// Buyer's open requests (Drizzle)
db.select().from(purchaseRequests)
.where(and(eq(purchaseRequests.buyerId, buyerId), inArray(purchaseRequests.status, ['pending', 'active', 'received_offers'])));
// Public marketplace feed
PurchaseRequest.find({ isPublic: true, status: 'active' }).sort({ createdAt: -1 });
db.select().from(purchaseRequests)
.where(and(eq(purchaseRequests.isPublic, true), eq(purchaseRequests.status, 'active')))
.orderBy(desc(purchaseRequests.createdAt));
// Sellers' eligible queue
PurchaseRequest.find({ productType, status: 'active', categoryId });
db.select().from(purchaseRequests)
.where(and(eq(purchaseRequests.productType, productType), eq(purchaseRequests.status, 'active'), eq(purchaseRequests.categoryId, categoryId)));
// Populate offers
PurchaseRequest.findById(id).populate('offers').populate('selectedOfferId');
// Offers for a request
// SELECT * FROM seller_offers WHERE purchase_request_id = $1;
// Redeem delivery code
PurchaseRequest.findOneAndUpdate(
{ _id: id, 'deliveryInfo.deliveryCode': code, 'deliveryInfo.deliveryCodeUsed': false },
{ $set: { 'deliveryInfo.deliveryCodeUsed': true, 'deliveryInfo.deliveryCodeUsedAt': new Date() } }
);
// Payment for a request (no paymentId on PurchaseRequest — query payments table)
// SELECT * FROM payments WHERE purchase_request_id = $1;
// Delivery info including deliveryDate
// SELECT * FROM purchase_request_delivery_info WHERE purchase_request_id = $1;
// Requests with live escrow hold
// SELECT * FROM purchase_requests WHERE hold_until IS NOT NULL AND hold_until > now();
// Preferred sellers (UUID strings)
// SELECT seller_id FROM purchase_request_preferred_sellers WHERE purchase_request_id = $1;
```
Related: [[SellerOffer]], [[Payment]], [[Chat]], [[Dispute]], [[Review]], [[RequestTemplate]], [[Category]].

View File

@@ -1,16 +1,20 @@
---
title: RequestTemplate
tags: [data-model, mongoose]
tags: [data-model, mongoose, postgres]
aliases: [Template, Request Template, IRequestTemplate]
---
# RequestTemplate
A reusable template authored by a seller. When a buyer visits the template's `shareableLink`, the front-end pre-fills a new [[PurchaseRequest]] with the template's category, urgency, specs, delivery info, and an optional default seller `proposal`. The schema mirrors `PurchaseRequest` for fast cloning, plus template-specific bookkeeping (`isActive`, `usageCount`, `maxUsage`, `expiresAt`).
> **Last updated:** 2026-06-01 — Postgres schema and backfill surface documented.
A reusable template authored by a seller. When a buyer visits the template's `shareableLink`, the front-end pre-fills a new [[PurchaseRequest]] with the template's category, urgency, specs, seller-selected delivery mode, payment rail allowlist, and an optional default seller `proposal`. The schema mirrors `PurchaseRequest` for fast cloning, plus template-specific bookkeeping (`isActive`, `usageCount`, `maxUsage`, `expiresAt`).
> [!note] Source
> `backend/src/models/RequestTemplate.ts:65` — schema definition
> `backend/src/models/RequestTemplate.ts:295` — model export
> `backend/src/models/RequestTemplate.ts:83` — Mongoose schema definition
> `backend/src/models/RequestTemplate.ts:335` — model export
> `backend/src/db/schema/requestTemplate.ts:35` — Drizzle table definition
> `backend/src/db/backfill/backfill-requestTemplates.ts:1` — Mongo → Postgres backfill
## Schema
@@ -28,15 +32,18 @@ A reusable template authored by a seller. When a buyer visits the template's `sh
| `quantity` | Number | no | `1` | min 1 | — | Default unit count. |
| `budget.min` | Number | no | — | min 0 | — | Lower bound. |
| `budget.max` | Number | no | — | min 0 | — | Upper bound. |
| `budget.currency` | String | no | `USD` | enum: `USD` / `EUR` / `IRR` | — | Currency. |
| `budget.currency` | String | no | `USDT` | enum: `USD` / `EUR` / `IRR` / `USDT` / `USDC` | — | Currency. Shared with [[PurchaseRequest]] so a template can be converted without enum drift. |
| `urgency` | String | no | `medium` | enum: `low` / `medium` / `high` / `urgent` | — | Urgency. |
| `tags[]` | String[] | no | — | trim | — | Tags. |
| `specifications[].key` | String | yes | — | trim | — | Spec key. |
| `specifications[].value` | String | yes | — | trim | — | Spec value. |
| `specifications[].label` | String | no | — | trim | — | Human label. |
| `deliveryInfo.deliveryType` | String | no | `physical` | enum: `physical` / `online` | — | Delivery channel. |
| `deliveryInfo.notes` | String | no | — | — | — | Notes. |
| `deliveryInfo.email` | String | no | — | email regex | — | Digital delivery email. |
| `deliveryInfo.deliveryType` | String | no | `physical` | enum: `physical` / `online` | — | Seller-selected delivery channel. Buyers cannot override this at checkout. |
| `deliveryInfo.notes` | String | no | — | — | — | Seller notes about delivery. |
| `deliveryInfo.email` | String | no | — | email regex when non-empty | — | Legacy/optional field. Template checkout now asks the buyer for a receiving email when `deliveryType === "online"`. |
| `paymentConfig.useShopDefault` | Boolean | no | `true` | — | — | When `false`, the template's own chain/token allowlist overrides [[ShopSettings]]. New template UI defaults this to `false` so sellers choose rails explicitly. |
| `paymentConfig.allowedChains[]` | Number[] | no | `[1, 56]` | must contain at least one positive chain id | — | Chain ids accepted for this template, e.g. `1` Ethereum, `56` BSC. Empty arrays are rejected. |
| `paymentConfig.allowedTokens[]` | String[] | no | `["USDC", "USDT"]` | must contain at least one non-empty token symbol | — | Settlement tokens accepted for this template. Empty arrays are rejected. |
| `serviceInfo.duration` | Number | no | — | min 0.5 | — | Hours. |
| `serviceInfo.sessionType` | String | no | — | enum: `online` / `in_person` / `hybrid` | — | Session type. |
| `serviceInfo.location` | String | no | — | trim, maxlength 200 | — | Location. |
@@ -78,6 +85,16 @@ Defined at `backend/src/models/RequestTemplate.ts:283-293`:
`shareableLink` and `sellerId` already get indexes from `unique: true` / field-level conventions (see source comment at line 282).
Postgres migration `0010_request_templates.sql` creates `request_templates` with:
- `request_templates_legacy_object_id_uq`: idempotent Mongo bridge for backfill.
- `request_templates_shareable_link_uq`: public slug uniqueness.
- FK columns `seller_id → users.id` and `category_id → categories.id`.
- Matching single/compound indexes for seller, category, product type, active state, expiry, and public slug lookups.
- JSONB `specifications` and scalar/array columns for delivery, service, proposal, payment rails, images, and attachments.
Runtime service wiring is not cut over yet; `RequestTemplateService` still uses Mongoose directly.
## Pre/Post Hooks
None declared.
@@ -95,6 +112,12 @@ None defined.
- **References**: [[User]] (`sellerId`), [[Category]] (`categoryId`).
- **Referenced by**: [[PurchaseRequest]] (`metadata.templateId` as string), [[Review]] (`subjectId` when `subjectType === 'template'`).
## Checkout Semantics
- The seller chooses `deliveryInfo.deliveryType` on the template. The buyer checkout step only collects the required fulfillment details: a physical address for `physical`, a receiving email for `online`, and both when a cart mixes physical and online templates.
- `batch-convert` copies the seller's delivery mode into each generated [[PurchaseRequest]] and overlays the buyer-supplied billing/email details.
- Payment checkout resolves allowed rails through `paymentConfig`: template override first, then [[ShopSettings]], then the global supported default. A template with an explicit empty chain or token list is invalid.
## State Transitions
```mermaid

View File

@@ -0,0 +1,130 @@
---
title: ScannerBalanceWatch (Scanner DB model)
tags: [data-model, scanner, payment]
created: 2026-06-03
---
# ScannerBalanceWatch
SQLite row in the AMN Pay Scanner's `balance_watches` table. One row represents a direct-address token balance watch requested by the backend for non-smart-contract payment detection.
This is scanner-internal state. It is not a Mongoose model and lives in the scanner SQLite database (`/data/scanner.db`).
---
## Schema
```sql
CREATE TABLE balance_watches (
watch_id TEXT PRIMARY KEY,
chain_id INTEGER NOT NULL,
chain_type TEXT NOT NULL DEFAULT 'evm',
token_address TEXT NOT NULL,
token_symbol TEXT,
decimals INTEGER NOT NULL DEFAULT 0,
address TEXT NOT NULL,
baseline_balance TEXT NOT NULL,
current_balance TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'watching',
callback_url TEXT NOT NULL,
callback_secret TEXT NOT NULL,
last_checked_at DATETIME,
next_check_at DATETIME NOT NULL,
change_count INTEGER NOT NULL DEFAULT 0,
last_notified_at DATETIME,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_balance_watches_status_next
ON balance_watches(status, next_check_at);
CREATE INDEX idx_balance_watches_chain_status
ON balance_watches(chain_id, status);
```
---
## Fields
| Field | Type | Description |
|---|---|---|
| `watch_id` | TEXT PK | Caller-supplied or scanner-generated idempotency key. Backend should use a payment-scoped value such as `<paymentId>-balance-c56-USDT` when it wants webhook correlation by prefix. |
| `chain_id` | INTEGER | Numeric EVM chain ID. Direct balance reads currently support EVM ERC-20 only. |
| `chain_type` | TEXT | Currently `evm` for balance watches. Kept for future Tron/TON support. |
| `token_address` | TEXT | ERC-20 token contract address, normalized to lowercase `0x` hex. |
| `token_symbol` | TEXT NULL | Optional token symbol resolved from `tokens.json`. |
| `decimals` | INTEGER | Token decimals resolved from registry when available. |
| `address` | TEXT | Watched holder address, normalized to lowercase `0x` hex. |
| `baseline_balance` | TEXT | Base-unit integer string captured or supplied when the watch is created. |
| `current_balance` | TEXT | Last scanner-accepted base-unit balance. For changed balances, this advances only after webhook delivery succeeds. |
| `status` | TEXT | Watch lifecycle state. |
| `callback_url` | TEXT | Backend webhook endpoint. Validated with the same callback URL guard as scanner intents. |
| `callback_secret` | TEXT | HMAC-SHA256 key for the balance watch webhook signature. Never returned in API responses. |
| `last_checked_at` | DATETIME NULL | Last time the scanner attempted a balance read. |
| `next_check_at` | DATETIME | Scheduler due time. |
| `change_count` | INTEGER | Count of successfully delivered balance-change notifications. |
| `last_notified_at` | DATETIME NULL | Time of the last successful balance-change notification. |
| `expires_at` | DATETIME | Hard stop timestamp, currently 7 days after creation. |
| `created_at` / `updated_at` | DATETIME | UTC timestamps. |
---
## Status values
| Status | Description |
|---|---|
| `watching` | Scheduler polls the address/token when `next_check_at` is due. |
| `stopped` | Backend explicitly stopped the watch after payment success, cancellation, or manual resolution. |
| `expired` | Scanner stopped the watch automatically after 7 days. |
---
## Polling cadence
| Age from `created_at` | Interval |
|---|---|
| `< 24h` | 5 min |
| `24h48h` | 10 min |
| `48h72h` | 20 min |
| `> 72h` | 40 min until expiry |
`BALANCE_WATCH_TICK_SEC` controls how often the scheduler queries for due watches. `BALANCE_WATCH_BATCH_SIZE` controls how many due watches are processed per tick.
---
## Webhook semantics
When `current_balance` changes, scanner sends:
```json
{
"eventType": "balance_changed",
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"chainType": "evm",
"address": "0x...",
"tokenAddress": "0x...",
"tokenSymbol": "USDT",
"decimals": 18,
"previousBalance": "25000000000000000000",
"currentBalance": "35000000000000000000",
"delta": "10000000000000000000",
"changeCount": 1,
"checkedAt": "2026-06-03T10:05:00Z",
"status": "balance_changed"
}
```
The backend must not treat this webhook alone as final escrow funding. It should compare `delta` or `(currentBalance - baselineBalance)` to the expected amount, apply token/chain/address checks, persist evidence, and stop the watch when the payment is accepted or cancelled.
---
## Related
- [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md)
- [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
- [Payment Flow - Scanner](../04%20-%20Flows/Payment%20Flow%20-%20Scanner.md)
- [ScannerIntent](ScannerIntent.md)
- [Payment](Payment.md) — backend MongoDB/DTO model that stores payment metadata

View File

@@ -0,0 +1,115 @@
---
title: ScannerIntent (Scanner DB model)
tags: [data-model, scanner, payment]
created: 2026-05-30
---
# ScannerIntent
SQLite row in the AMN Pay Scanner's `intents` table. One row per payment intent registered by the backend. This is internal scanner state — it is not a Mongoose model and lives in a separate SQLite database (`/data/scanner.db`).
---
## Schema
```sql
CREATE TABLE intents (
intent_id TEXT PRIMARY KEY,
chain_id INTEGER NOT NULL,
chain_type TEXT NOT NULL DEFAULT 'evm',
token_address TEXT NOT NULL,
destination TEXT NOT NULL,
amount TEXT NOT NULL,
payment_reference TEXT NOT NULL,
topic_ref TEXT,
status TEXT NOT NULL DEFAULT 'pending',
callback_url TEXT NOT NULL,
callback_secret TEXT NOT NULL,
confirmations_required INTEGER NOT NULL DEFAULT 12,
tx_hash TEXT,
log_index INTEGER,
block_number INTEGER,
confirmations INTEGER NOT NULL DEFAULT 0,
salt TEXT NOT NULL,
webhook_delivered_at TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
---
## Fields
| Field | Type | Description |
|---|---|---|
| `intent_id` | TEXT PK | Caller-supplied unique ID (typically the backend Payment `_id`) |
| `chain_id` | INTEGER | Numeric chain ID. EVM standard (56, 137, 1, 42161, 8453), Tron (728126428), TON (1100) |
| `chain_type` | TEXT | `evm` / `tron` / `ton`. Determines which worker handles this intent |
| `token_address` | TEXT | ERC20 / TRC20 contract address. EVM/Tron: lowercase `0x` hex. TON: exact base64url |
| `destination` | TEXT | Recipient wallet address. EVM/Tron: lowercase `0x` hex. TON: base64url (case-sensitive) |
| `amount` | TEXT | Required amount in smallest token unit (wei / 10^decimals), stored as base-10 integer string |
| `payment_reference` | TEXT | 8-byte hex EVM payment reference (`0x` + 16 hex chars). Derived as `last8(keccak256(intentId + salt + destination))` |
| `topic_ref` | TEXT | `keccak256(paymentReferenceBytes)` — matches `Topics[1]` in EVM logs. Pre-computed for indexed DB lookup. NULL for Tron/TON |
| `status` | TEXT | Intent lifecycle state (see below) |
| `callback_url` | TEXT | URL the scanner POSTs to on confirmation |
| `callback_secret` | TEXT | HMAC-SHA256 key for webhook signature. Never returned in API responses |
| `confirmations_required` | INTEGER | Accepted confirmation floor for the intent. Defaults to chain config and cannot be lowered below the chain floor by the create-intent request |
| `tx_hash` | TEXT NULL | Transaction hash once a matching transfer is detected |
| `log_index` | INTEGER NULL | Log position within the transaction (EVM only; 0 for Tron/TON) |
| `block_number` | INTEGER NULL | Block number (EVM/Tron) or Unix timestamp seconds (TON) when the tx was seen |
| `confirmations` | INTEGER | Current confirmation depth while `confirming`; capped at `confirmations_required` once the intent is `confirmed` |
| `salt` | TEXT | 32-byte random hex. Combined with `intent_id` and `destination` to derive `payment_reference`. Prevents reference collisions across retried payments |
| `webhook_delivered_at` | TEXT NULL | RFC3339 timestamp when the webhook was successfully delivered. Used for startup crash recovery |
| `created_at` / `updated_at` | DATETIME | UTC timestamps |
---
## Status values
| Status | Description |
|---|---|
| `pending` | Registered; scanner is watching for a matching on-chain transfer |
| `confirming` | EVM only — matching tx seen, waiting for `confirmations_required` blocks |
| `confirmed` | Payment confirmed; webhook delivery attempted |
| `expired` | TTL exceeded while still in `pending` or `confirming` |
| `webhook_failed` | All webhook delivery retries exhausted; manual retry or periodic auto-retry needed |
---
## Indexes
```sql
CREATE INDEX idx_intents_status ON intents(status);
CREATE INDEX idx_intents_chain_status ON intents(chain_id, status);
CREATE INDEX idx_intents_payment_ref ON intents(payment_reference);
CREATE INDEX idx_intents_topic_ref ON intents(topic_ref);
CREATE UNIQUE INDEX idx_intents_tx_log ON intents(tx_hash, log_index)
WHERE tx_hash IS NOT NULL;
```
`idx_intents_topic_ref` is the performance-critical index — the EVM scanner's inner loop does a single indexed lookup per log entry.
The unique index on `(tx_hash, log_index)` prevents two intents being confirmed from the same on-chain event (double-spend protection).
---
## Migrations
Three additive migrations run at startup (idempotent):
1. `ADD COLUMN topic_ref TEXT` — added after initial schema
2. `ADD COLUMN chain_type TEXT NOT NULL DEFAULT 'evm'` — added for Tron/TON support
3. `ADD COLUMN webhook_delivered_at TEXT` — added for crash recovery
A backfill pass recomputes `topic_ref` for existing EVM intents that had it as NULL.
---
## Related
- [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md)
- [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
- [Payment Flow - Scanner](../04%20-%20Flows/Payment%20Flow%20-%20Scanner.md)
- [ScannerBalanceWatch](ScannerBalanceWatch.md) — direct-address balance-watch model for non-smart-contract payment rails
- [Payment](Payment.md) — the backend MongoDB model that triggers intent creation

View File

@@ -1,56 +1,110 @@
---
title: SellerOffer
tags: [data-model, mongoose]
tags: [data-model, postgres]
aliases: [Seller Offer, Bid, ISellerOffer]
---
# SellerOffer
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-06-06 — MongoDB/Mongoose fully removed; PostgreSQL + Drizzle is now the only database layer.
A seller's bid against a [[PurchaseRequest]]. Stores the proposed price, the delivery time commitment, optional notes/attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). The parent `PurchaseRequest` keeps the array of offer ids in `offers[]` and the chosen one in `selectedOfferId`.
> [!note] Source
> `backend/src/models/SellerOffer.ts:24` — schema definition
> `backend/src/models/SellerOffer.ts:100` — model export
> `backend/src/db/schema/sellerOffer.ts` — PostgreSQL schema (Drizzle) definition
## Schema
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `sellerId` | ObjectId → [[User]] | yes | — | — | yes | Seller submitting the bid. |
| `purchaseRequestId` | ObjectId → [[PurchaseRequest]] | yes | — | — | yes | Parent request. |
| `title` | String | yes | — | trim, maxlength 200 | — | Offer headline. |
| `description` | String | yes | — | trim, maxlength 1000 | — | Pitch and details. |
| `price.amount` | Number | yes | — | min 0 | — | Quoted amount. |
| `price.currency` | String | yes | `USDT` | enum: `USD` / `EUR` / `IRR` / `USDT` / `USDC` | — | Quote currency. |
| `deliveryTime.amount` | Number | yes | — | min 1 | — | Numeric ETA. |
| `deliveryTime.unit` | String | yes | — | enum: `hours` / `days` / `weeks` | — | ETA unit. |
| `status` | String | no | `pending` | enum: `pending` / `accepted` / `rejected` / `withdrawn` | yes | Offer status. |
| `attachments[]` | String[] | no | — | — | — | URLs of supporting files. |
| `notes` | String | no | — | trim | — | Internal/private notes. |
| `validUntil` | Date | no | — | — | — | Expiration. |
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
### PostgreSQL schema (Drizzle) — `seller_offers`
> **Status enum note:** Valid values are `pending | accepted | rejected | withdrawn` only. `'active'` is **not** a valid status and would throw a Mongoose `ValidationError` if passed.
Table: `seller_offers` | Schema file: `backend/src/db/schema/sellerOffer.ts`
| PG Column | Drizzle Type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `uuid` PK | no | `gen_random_uuid()` | Primary key (UUID string) |
| `legacy_object_id` | `text` | yes | — | Former Mongo ObjectId; partial-unique WHERE NOT NULL |
| `seller_id` | `uuid` FK → `users` CASCADE | no | — | Maps from `sellerId` (uses user.pgId) |
| `purchase_request_id` | `uuid` FK → `purchase_requests` CASCADE | no | — | Maps from `purchaseRequestId` |
| `title` | `varchar(200)` | no | — | |
| `description` | `varchar(1000)` | no | — | |
| `price_amount` | `numeric(18,8)` | no | — | CHECK `price_amount >= 0` |
| `price_currency` | `offer_currency` enum | no | — | `USD \| EUR \| IRR \| USDT \| USDC \| TRY` |
| `delivery_time_amount` | `int` | no | — | CHECK `delivery_time_amount >= 1` |
| `delivery_time_unit` | `delivery_unit` enum | no | — | `hours \| days \| weeks` |
| `status` | `offer_status` enum | no | `pending` | `pending \| accepted \| rejected \| withdrawn \| active` |
| `attachments` | `text[]` | yes | — | |
| `notes` | `text` | yes | — | |
| `valid_until` | `timestamp with time zone` | yes | — | Maps from `validUntil` |
| `require_aml_check` | `boolean` | yes | — | |
| `aml_block_on_failure` | `boolean` | yes | — | CHECK: block requires check (AML coherence) |
| `created_at` | `timestamp with time zone` | no | `now()` | |
| `updated_at` | `timestamp with time zone` | no | `now()` | |
**Enums used:**
| Enum name | Values |
| --- | --- |
| `offer_status` | `pending`, `accepted`, `rejected`, `withdrawn`, `active` |
| `offer_currency` | `USD`, `EUR`, `IRR`, `USDT`, `USDC`, `TRY` |
| `delivery_unit` | `hours`, `days`, `weeks` |
**Constraints:**
- `CHECK (price_amount >= 0)`
- `CHECK (delivery_time_amount >= 1)`
- AML coherence check: `aml_block_on_failure = true` requires `require_aml_check = true`
**Money precision note:** `price_amount` uses `numeric(18,8)` — differs from the `numeric(38,18)` used by `payments` and `funds_ledger_entries`. This matches the Migration Guide specification for offer amounts.
**ID note:** The primary key is `id` (UUID string), not `_id`. `legacy_object_id` retains the former MongoDB ObjectId for backfill/bridging purposes only and is not used by any runtime query.
#### Postgres Indexes
| Index | Type | Notes |
| --- | --- | --- |
| `seller_id` | btree | |
| `purchase_request_id` | btree | |
| `status` | btree | |
| `created_at DESC` | btree | |
| `(purchase_request_id, seller_id)` | btree | composite |
| `legacy_object_id` | partial-unique | WHERE NOT NULL; idempotent backfill upserts |
## Domain Fields (TypeScript)
| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `string` (UUID) | yes | auto | PG primary key; replaces former `_id` ObjectId |
| `sellerId` | `string` (UUID) | yes | — | user.pgId of the submitting seller |
| `purchaseRequestId` | `string` (UUID) | yes | — | Parent request |
| `title` | `string` | yes | — | Offer headline (max 200) |
| `description` | `string` | yes | — | Pitch and details (max 1000) |
| `price.amount` | `number` | yes | — | Quoted amount (min 0) |
| `price.currency` | `string` | yes | `USDT` | `USD` / `EUR` / `IRR` / `TRY` / `USDT` / `USDC` |
| `deliveryTime.amount` | `number` | yes | — | Numeric ETA (min 1) |
| `deliveryTime.unit` | `string` | yes | — | `hours` / `days` / `weeks` |
| `status` | `string` | no | `pending` | `pending` / `accepted` / `rejected` / `withdrawn` / `active` |
| `attachments[]` | `string[]` | no | — | URLs of supporting files |
| `notes` | `string` | no | — | Internal/private notes |
| `validUntil` | `Date` | no | — | Expiration |
| `requireAmlCheck` | `boolean` | no | — | AML screening required before presenting to buyer |
| `amlBlockOnFailure` | `boolean` | no | — | Block offer on AML failure (vs. flag for review) |
| `createdAt` | `Date` | auto | — | |
| `updatedAt` | `Date` | auto | — | |
> **Status enum note:** `active` is accepted by the current backend schema for marketplace/listing flows, in addition to the negotiation statuses `pending | accepted | rejected | withdrawn`.
> **Currency note:** `TRY` is supported by the oracle/depeg path through the off-chain FX provider.
## UpdateSellerOfferInput
`UpdateSellerOfferInput` does **not** include an `updatedAt` field — the column is managed automatically by the database (`now()` default; updated by the repo layer on write).
## Virtuals
None defined.
## Indexes
Defined at `backend/src/models/SellerOffer.ts:95-98`:
- `{ sellerId: 1 }`
- `{ purchaseRequestId: 1 }`
- `{ status: 1 }`
- `{ createdAt: -1 }`
## Pre/Post Hooks
None declared.
None declared (Drizzle ORM does not use Mongoose-style lifecycle hooks).
## Instance Methods
@@ -66,14 +120,20 @@ None defined.
`createOffer` in `SellerOfferService` permits offers against a `PurchaseRequest` whose status is **`pending`**, **`received_offers`**, or **`active`**. Attempts against any other status are rejected.
### `withdrawOffer()` — dead code
### `withdrawOffer()` — frontend action available
`SellerOfferService.withdrawOffer()` exists in the source but is **not exposed via any HTTP route**. It cannot be called through the API. Any frontend references to a withdraw endpoint will receive a `404`.
`SellerOfferService.withdrawOffer()` is not a dedicated HTTP route. The correct API path is `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
The frontend exposes this via the `withdrawOffer(offerId)` action in `src/actions/marketplace.ts` (added commit 240a668). It is called from:
- `step-2-waiting-for-payment.tsx` (edit/cancel controls while `requestDetails.status === 'received_offers'`)
- `frontend/src/app/dashboard/seller/marketplace/offers/page.tsx` (Offer Management page, bulk view)
## Relationships
- **References**: [[User]] (`sellerId`), [[PurchaseRequest]] (`purchaseRequestId`).
- **References**: [[User]] (`sellerId` = user.pgId), [[PurchaseRequest]] (`purchaseRequestId`).
- **Referenced by**: [[PurchaseRequest]] (`offers[]`, `selectedOfferId`), [[Payment]] (`sellerOfferId`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'SellerOffer'`).
- **PG FKs**: `seller_offers.seller_id → users.id CASCADE`, `seller_offers.purchase_request_id → purchase_requests.id CASCADE`.
- **Referenced by (PG)**: `payments.seller_offer_id` (polymorphic triple), `payment_quotes` (via payment join).
## State Transitions
@@ -90,21 +150,36 @@ stateDiagram-v2
## Common Queries
### Postgres (Drizzle)
```ts
// Offers for a request
SellerOffer.find({ purchaseRequestId }).sort({ createdAt: -1 });
db.select().from(sellerOffers)
.where(eq(sellerOffers.purchaseRequestId, requestId))
.orderBy(desc(sellerOffers.createdAt));
// Seller's active offers
SellerOffer.find({ sellerId, status: 'pending' });
// Seller's pending offers
db.select().from(sellerOffers)
.where(and(
eq(sellerOffers.sellerId, sellerId),
eq(sellerOffers.status, 'pending')
));
// Reject siblings on accept
SellerOffer.updateMany(
{ purchaseRequestId, _id: { $ne: acceptedId }, status: 'pending' },
{ status: 'rejected' }
);
db.update(sellerOffers)
.set({ status: 'rejected' })
.where(and(
eq(sellerOffers.purchaseRequestId, purchaseRequestId),
ne(sellerOffers.id, acceptedId),
eq(sellerOffers.status, 'pending')
));
// Cleanup expired offers
SellerOffer.find({ validUntil: { $lt: new Date() }, status: 'pending' });
db.select().from(sellerOffers)
.where(and(
lt(sellerOffers.validUntil, new Date()),
eq(sellerOffers.status, 'pending')
));
```
Related: [[PurchaseRequest]], [[Payment]], [[User]].

View File

@@ -6,7 +6,9 @@ aliases: [Shop, Storefront, IShopSettings]
# ShopSettings
One-to-one storefront configuration for a seller. Holds the shop name, description, avatar, cover image, public visibility flag, review toggles (`allowSellerReviews`, `allowTemplateReviews`), and social links. The unique constraint on `sellerId` enforces the one-shop-per-seller invariant.
> **Last updated:** 2026-05-31 — store-level payment rail defaults documented.
One-to-one storefront configuration for a seller. Holds the shop name, description, avatar, cover image, public visibility flag, review toggles (`allowSellerReviews`, `allowTemplateReviews`), social links, and store-level payment rail defaults. The unique constraint on `sellerId` enforces the one-shop-per-seller invariant.
> [!note] Source
> `backend/src/models/ShopSettings.ts:22` — schema definition
@@ -28,6 +30,8 @@ One-to-one storefront configuration for a seller. Holds the shop name, descripti
| `socialLinks.instagram` | String | no | `""` | — | — | Instagram URL. |
| `socialLinks.linkedin` | String | no | `""` | — | — | LinkedIn URL. |
| `socialLinks.twitter` | String | no | `""` | — | — | Twitter / X URL. |
| `paymentConfig.allowedChains[]` | Number[] | no | `[1, 56]` | update route requires at least one chain when supplied | — | Store-level accepted chain ids used by templates with `paymentConfig.useShopDefault === true`. |
| `paymentConfig.allowedTokens[]` | String[] | no | `["USDC", "USDT"]` | update route requires at least one token when supplied | — | Store-level accepted settlement tokens. |
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
@@ -82,6 +86,9 @@ ShopSettings.findOneAndUpdate(
// Public shop directory
ShopSettings.find({ isPublic: true }).sort({ createdAt: -1 });
// Resolve seller-level payment rails for template checkout
ShopSettings.findOne({ sellerId }).select('paymentConfig');
```
> [!warning] Creating two shops will fail

289
02 - Data Models/Tenant.md Normal file
View File

@@ -0,0 +1,289 @@
---
title: Tenant
tags: [data-model, postgres, drizzle, white-label, multi-tenant]
aliases: [TenantRecord, Merchant Tenant, White-Label Shop]
---
# Tenant
> **Last updated:** 2026-06-10 — current `feature/white-label-shops` scan.
Six Drizzle/PostgreSQL tables that form the multi-tenant layer of the Amanat marketplace operating system. Introduced by [[PRD - Seller-Owned White-Label Shops and Bots]].
> [!note] Source
> All six tables live in a single file: `backend/src/db/schema/tenant.ts`
> Repositories: `backend/src/db/repositories/drizzle/DrizzleTenant*.ts`
> Services: `backend/src/services/tenant/`
---
## Table overview
| Table | Purpose | Isolation key |
| --- | --- | --- |
| `tenants` | Top-level tenant entity (one per merchant) | `id` (PK) |
| `tenant_domains` | Custom / managed hostnames | `tenant_id` FK, unique on `hostname` |
| `tenant_bots` | Telegram bot token registrations (encrypted) | `tenant_id` FK, unique on `telegram_bot_id` |
| `tenant_integrations` | Catalog / delivery / payment adapter configs | `tenant_id` FK |
| `tenant_payment_policies` | Per-tenant payment rail configuration | `tenant_id` FK, 1:1 |
| `tenant_user_roles` | User ↔ tenant role grants | composite unique `(tenant_id, user_id, role)` |
---
## `tenants`
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | Tenant identifier. |
| `owner_user_id` | `uuid` | NOT NULL, FK → `users.id` RESTRICT | — | Owner [[User]] (`pgId`). RESTRICT prevents silent orphan on user delete. |
| `slug` | `text` | NOT NULL, UNIQUE | — | URL-safe label `[a-z0-9-]{3,40}` used for `seller.amn.gg` and `/t/:slug`. |
| `type` | `tenantType` enum | NOT NULL | `hosted_seller` | Tenant tier. |
| `status` | `tenantStatus` enum | NOT NULL | `pending` | Lifecycle state. |
| `display_name` | `text` | NOT NULL | — | Human name for the shop. |
| `billing_account_id` | `text` | nullable | — | External billing reference (no FK in Phase 0/1). |
| `isolation_mode` | `tenantIsolationMode` enum | NOT NULL | `shared` | Data isolation level. |
| `shop_settings_id` | `uuid` | nullable, FK → `shop_settings.id` SET NULL | — | Link to existing [[ShopSettings]] row. |
| `brand` | `jsonb` | nullable | — | `{ name?, logoUrl?, primaryColor?, supportEmail? }` — drives bootstrap payload. |
| `features` | `jsonb` | nullable | — | `{ escrowCheckout?, directCheckout?, externalPayments?, telegramMiniApp? }` — overrides policy-derived flags. |
| `locale_defaults` | `text[]` | nullable | — | e.g. `['en', 'fa']`. |
| `legacy_object_id` | `text` | nullable | — | Convention parity field; tenants are PG-native. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
### Indexes
| Name | Columns | Type |
| --- | --- | --- |
| `tenants_slug_uq` | `slug` | UNIQUE |
| `tenants_owner_user_id_idx` | `owner_user_id` | B-tree |
| `tenants_status_idx` | `status` | B-tree |
### Enums
| Enum | Values |
| --- | --- |
| `tenantType` | `hosted_seller`, `white_label`, `isolated`, `enterprise` |
| `tenantStatus` | `pending`, `active`, `suspended`, `closed` |
| `tenantIsolationMode` | `shared`, `schema`, `database`, `stack` |
---
## `tenant_domains`
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | — |
| `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | Owning tenant. |
| `hostname` | `text` | NOT NULL, UNIQUE | — | Full hostname e.g. `shop.example.com`. Globally unique — the resolution key. |
| `mode` | `tenantDomainMode` enum | NOT NULL | `cname` | How DNS is managed. |
| `status` | `tenantDomainStatus` enum | NOT NULL | `pending` | Domain lifecycle state. |
| `verification_token` | `text` | NOT NULL | — | Random hex token for TXT/CNAME proof. |
| `tls_status` | `tenantTlsStatus` enum | NOT NULL | `pending` | TLS certificate state. |
| `last_checked_at` | `timestamptz` | nullable | — | Last validation probe. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
### Enums
| Enum | Values |
| --- | --- |
| `tenantDomainMode` | `managed_ns`, `cname` |
| `tenantDomainStatus` | `pending`, `active`, `degraded`, `suspended`, `removed` |
| `tenantTlsStatus` | `pending`, `issued`, `failed`, `expired` |
> [!warning] Hostname uniqueness is the security boundary
> A single hostname MUST map to at most one tenant. The unique index `tenant_domains_hostname_uq` enforces this. Code in `tenantResolutionMiddleware` relies on `findByHostname` returning at most one row.
---
## `tenant_bots`
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | — |
| `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | Owning tenant. |
| `telegram_bot_id` | `text` | NOT NULL, UNIQUE | — | Numeric Telegram bot id stored as text (exceeds JS safe int). |
| `username` | `text` | NOT NULL | — | Bot @username. |
| `encrypted_token` | `text` | NOT NULL | — | AES-256-GCM ciphertext of the BotFather token. |
| `encrypted_token_iv` | `text` | NOT NULL | — | GCM IV (base64). |
| `encrypted_token_tag` | `text` | NOT NULL | — | GCM auth tag (base64). |
| `webhook_secret` | `text` | NOT NULL | — | Per-bot random hex webhook path secret used by `/api/telegram/tenant-webhook/:botId`. |
| `status` | `tenantBotStatus` enum | NOT NULL | `pending` | Bot lifecycle. |
| `mini_app_url` | `text` | nullable | — | Telegram Mini App URL when configured. |
| `claim_token` | `text` | nullable | — | One-time Telegram `/start <token>` deep-link token for the first admin claim. |
| `admin_telegram_user_id` | `text` | nullable | — | Telegram user id that claimed the bot admin role. |
| `last_webhook_at` | `timestamptz` | nullable | — | Last received webhook update. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
### Enums
| Enum | Values |
| --- | --- |
| `tenantBotStatus` | `pending`, `active`, `suspended`, `revoked` |
> [!warning] Token fields
> `encrypted_token`, `encrypted_token_iv`, and `encrypted_token_tag` are AES-256-GCM fields. The repository layer **never decrypts** them. Decryption belongs exclusively to `tenantBotService`. Never include these columns or `webhook_secret` in API responses.
> [!note] Claim flow
> New bots start as `pending` with a `claim_token`. The public service response exposes only a derived `claimUrl` while the bot is pending. When Telegram sends `/start <claimToken>` to `/api/telegram/tenant-webhook/:botId` with the correct Telegram webhook secret header, `tenantBotService.claimAdmin()` stores `admin_telegram_user_id` and flips the bot to `active`.
---
## `tenant_integrations`
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | — |
| `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | — |
| `kind` | `tenantIntegrationKind` enum | NOT NULL | — | Integration category. |
| `provider` | `text` | NOT NULL | — | Free-form provider slug e.g. `shopify`, `http_json`. |
| `status` | `tenantIntegrationStatus` enum | NOT NULL | `draft` | Integration lifecycle. |
| `config` | `jsonb` | nullable | — | Non-secret config blob. |
| `encrypted_config` | `text` | nullable | — | AES-GCM ciphertext for provider keys/secrets. |
| `encrypted_config_iv` | `text` | nullable | — | GCM IV. |
| `encrypted_config_tag` | `text` | nullable | — | GCM auth tag. |
| `last_sync_at` | `timestamptz` | nullable | — | — |
| `last_error` | `text` | nullable | — | Last sync error message. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
Unique index: `tenant_integrations_tenant_kind_provider_uq` on `(tenant_id, kind, provider)`.
### Enums
| Enum | Values |
| --- | --- |
| `tenantIntegrationKind` | `catalog`, `delivery`, `payment`, `accounting`, `notification` |
| `tenantIntegrationStatus` | `draft`, `active`, `error`, `disabled` |
---
## `tenant_payment_policies`
1:1 with `tenants` (enforced by unique index on `tenant_id`). Created automatically with `amn_escrow` defaults when a tenant is created.
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | — |
| `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE, UNIQUE | — | Owning tenant (1:1). |
| `allowed_rails` | `tenantPaymentRail[]` | NOT NULL | `ARRAY['amn_escrow']` | PG enum array of permitted payment rails. |
| `default_rail` | `tenantPaymentRail` | NOT NULL | `amn_escrow` | Rail used when buyer doesn't specify. CHECK: must be in `allowed_rails`. |
| `escrow_required_above_amount` | `numeric(38,18)` | nullable | — | Orders above this amount force `amn_escrow`. Matches `payments.amount` precision. |
| `escrow_required_for_categories` | `text[]` | nullable | — | Category slugs that always require escrow. |
| `buyer_disclosure_mode` | `tenantBuyerDisclosureMode` | NOT NULL | `strict` | How prominently the non-escrow notice is shown to buyers. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
### Enums
| Enum | Values |
| --- | --- |
| `tenantPaymentRail` | `amn_escrow`, `amn_direct`, `external_provider`, `manual_invoice` |
| `tenantBuyerDisclosureMode` | `plain`, `strict` |
> [!note] CHECK constraint
> `tenant_payment_policies_default_in_allowed_ck` enforces `default_rail = ANY(allowed_rails)` at the DB level. Route validation mirrors this at the application level.
---
## `tenant_user_roles`
| Column | Type | Constraints | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | PK | `gen_random_uuid()` | — |
| `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | — |
| `user_id` | `uuid` | NOT NULL, FK → `users.id` CASCADE | — | `users.id` (Postgres UUID, i.e. `pgId`). |
| `role` | `tenantUserRole` enum | NOT NULL | — | Role within the tenant. |
| `created_at` | `timestamptz` | NOT NULL | `now()` | — |
| `updated_at` | `timestamptz` | NOT NULL | `now()` | — |
Unique index: `tenant_user_roles_tenant_user_role_uq` on `(tenant_id, user_id, role)` — a user may hold each role at most once per tenant.
### Enum
| Enum | Values |
| --- | --- |
| `tenantUserRole` | `owner`, `manager`, `finance`, `support`, `developer` |
---
## State transitions
### Tenant status
```mermaid
stateDiagram-v2
[*] --> pending : createTenant()
pending --> active : operator activateTenant()
active --> suspended : operator suspendTenant()
suspended --> active : operator activateTenant()
active --> closed : operator (Phase 2+)
pending --> closed : operator rejects
```
### Domain status
```mermaid
stateDiagram-v2
[*] --> pending : POST /domains
pending --> active : DNS verified + Caddy route added
active --> active : TLS pending/issued
pending --> degraded : Caddy provisioning fails
degraded --> active : probe recovers
active --> suspended : DELETE /domains/:domainId
suspended --> removed : future cleanup
```
---
## Key relationships
```mermaid
erDiagram
users ||--o{ tenants : "owns (ownerUserId)"
shop_settings ||--o| tenants : "linked (shopSettingsId)"
tenants ||--o{ tenant_domains : "has"
tenants ||--o{ tenant_bots : "has"
tenants ||--o{ tenant_integrations : "has"
tenants ||--|| tenant_payment_policies : "has (1:1)"
tenants ||--o{ tenant_user_roles : "grants"
users ||--o{ tenant_user_roles : "receives"
```
---
## Common queries
```ts
// Resolve tenant from HTTP Host header (via service)
const result = await tenantService.resolveTenantByHost(req.hostname);
// result?.tenant or null
// Find active domain
const domain = await getTenantDomainRepo().findByHostname('shop.example.com');
// domain.status must be 'active' before trusting
// Build bootstrap payload for public storefront
const payload = await tenantService.buildBootstrapPayload(tenant);
// Check user roles in tenant
const roles = await getTenantUserRoleRepo().findRolesForUserInTenant(tenantId, userId);
// Upsert payment policy (idempotent)
await getTenantPaymentPolicyRepo().upsertForTenant(tenantId, { allowedRails, defaultRail });
```
---
## Migration
Tables are PG-native — no Mongo backfill path. Run:
```bash
cd backend && npx drizzle-kit generate
# review the generated SQL
npx drizzle-kit migrate
```
Related: [[PRD - Seller-Owned White-Label Shops and Bots]], [[ShopSettings]], [[User]], [[Payment]].

View File

@@ -1,18 +1,118 @@
---
title: User
tags: [data-model, mongoose]
tags: [data-model, postgres, drizzle]
aliases: [User Model, IUser, Account]
---
# User
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-06-06 — MongoDB fully removed; PostgreSQL + Drizzle is the only database layer (backend v2.9.12). Previous update: 2026-06-03 (dual-write status, guard role).
The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries an `ObjectId` reference back to `User`, so this collection is the relational hub of the system.
The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries a `uuid` (Postgres) reference back to `User`, so this table is the relational hub of the system.
> [!info] Migration status: COMPLETE
> MongoDB and Mongoose have been fully removed from the backend runtime. PostgreSQL + Drizzle ORM is the sole database layer (19 migrations landed, 00000019, 32 tables).
> Repository: `DrizzleUserRepo` (returned exclusively by the repository factory)
> Postgres table: **`users`** — `backend/src/db/schema/users.ts`
---
## ID Duality
| Field | Storage | Purpose |
| --- | --- | --- |
| `id` (PG column) / `pgId` (domain object) | `uuid`, PG primary key | Used for all marketplace foreign keys: `offer.sellerId`, `purchaseRequest.buyerId`, `payment.buyerId/sellerId`, etc. |
| `legacy_object_id` (PG column) / `_id` (domain/auth tokens) | `text`, 24-hex ObjectId string | Kept for backward compatibility with socket rooms (rooms keyed by legacy id) and auth tokens issued before migration. Partial-unique index WHERE NOT NULL. |
> [!warning] Always match marketplace FKs on `pgId` (UUID), not on `legacy_object_id`. Notifications and socket rooms use the legacy id string.
---
## PostgreSQL Schema (Drizzle): `users`
> [!note] Source
> `backend/src/models/User.ts:70` — schema definition
> `backend/src/models/User.ts:257` — model export
> `backend/src/db/schema/users.ts`
### Columns
| Column | PG Type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | no | `gen_random_uuid()` | Primary key (`pgId` in domain objects) |
| `legacy_object_id` | `text` | yes | — | 24-hex ObjectId string; partial-unique index WHERE NOT NULL; kept for socket rooms and legacy auth token compatibility |
| `email` | `varchar(255)` | yes | — | Partial-unique index WHERE NOT NULL |
| `password` | `varchar(255)` | yes | — | Hashed |
| `first_name` | `text` | yes | — | — |
| `last_name` | `text` | yes | — | — |
| `role` | `user_role` enum | no | `buyer` | Values: `admin`, `buyer`, `seller`, `resolver`, `guard` (added migration 0017) |
| `is_email_verified` | `bool` | yes | `false` | — |
| `auth_provider` | `auth_provider` enum | no | `email` | Values: `email`, `google`, `telegram` |
| `telegram_verified` | `bool` | yes | `false` | — |
| `email_verification_token` | `text` | yes | — | Legacy token flow |
| `email_verification_code` | `text` | yes | — | OTP code |
| `email_verification_code_expires` | `timestamptz` | yes | — | — |
| `password_reset_token` | `text` | yes | — | — |
| `password_reset_expires` | `timestamptz` | yes | — | — |
| `password_reset_code` | `text` | yes | — | — |
| `password_reset_code_expires` | `timestamptz` | yes | — | — |
| `profile` | `jsonb` | yes | — | Stores avatar, photoURL, phone, address, bio, website, walletAddress, walletType, walletProvider, walletProofVerified, walletProofTimestamp, isPublic |
| `preferences` | `jsonb` | yes | — | Stores language, currency, notifications.{email,sms,push} |
| `status` | `user_status` enum | yes | `active` | Values: `active`, `suspended`, `deleted` |
| `last_login_at` | `timestamptz` | yes | — | — |
| `referral_code` | `varchar(255)` | yes | — | Partial-unique index |
| `referred_by_id` | `uuid` | yes | — | Self-FK → `users(id)`; index |
| `points_total` | `int` | yes | `0` | — |
| `points_available` | `int` | yes | `0` | — |
| `points_used` | `int` | yes | `0` | — |
| `points_level` | `int` | yes | `1` | Indexed |
| `referral_stats_total` | `int` | yes | `0` | — |
| `referral_stats_active` | `int` | yes | `0` | — |
| `referral_stats_total_earned` | `int` | yes | `0` | — |
| `created_at` | `timestamptz` | no | `now()` | — |
| `updated_at` | `timestamptz` | no | `now()` | — |
### Child Tables
**`user_passkeys`** — WebAuthn credentials:
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `text` (PK) | WebAuthn credential ID |
| `user_id` | `uuid FK→users CASCADE` | Owner |
| `public_key` | `text` | Stored public key |
| `counter` | `int` | Signature counter |
| `device_type` | `passkey_device_type` enum | `platform` / `cross-platform` |
| `device_name` | `text` | Optional human label |
| `created_at` | `timestamptz` | — |
**`user_refresh_tokens`** — Active JWT refresh tokens:
| Column | Type | Notes |
| --- | --- | --- |
| `token` | `text` (PK) | The refresh token string |
| `user_id` | `uuid FK→users CASCADE` | Owner |
### Indexes
| Index | Type | Condition |
| --- | --- | --- |
| `users_email_unique` | partial-unique | WHERE `email IS NOT NULL` |
| `users_referral_code_unique` | partial-unique | WHERE `referral_code IS NOT NULL` |
| `users_legacy_object_id_unique` | partial-unique | WHERE `legacy_object_id IS NOT NULL` |
| `users_role_idx` | btree | — |
| `users_status_idx` | btree | — |
| `users_auth_provider_idx` | btree | — |
| `users_referral_code_idx` | btree | — |
| `users_referred_by_id_idx` | btree | — |
| `users_points_level_idx` | btree | — |
### Relations
- Self-referential: `referred_by_id → users.id` (parent/children for referral tree)
- One-to-many: `user_passkeys.user_id`, `user_refresh_tokens.user_id`
---
## Field Reference
> [!note] Email change re-verification
> When a profile update (`PUT /api/user/profile`, `userController.updateUserProfile`) changes `email` to a new value, the controller sets `isEmailVerified = false`, generates a **6-digit** `emailVerificationCode` (valid 15 minutes), stores it on `emailVerificationCode` / `emailVerificationCodeExpires`, and emails the code to the new address. The user must then confirm via `POST /api/user/profile/email/verify` (or request a new code with `POST /api/user/profile/email/resend-verification`).
@@ -20,104 +120,92 @@ The core identity document for every actor in the marketplace: buyers, sellers,
> [!note] Wallet ownership proof
> `PATCH /api/user/wallet-address` accepts both EVM and TON wallets. EVM addresses require an EIP-191 signature (`ethers.verifyMessage`); TON addresses are format-validated and may include an optional TonProof. A successful proof sets `profile.walletProofVerified = true` and `profile.walletProofTimestamp`.
## Schema
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `email` | String | no | — | lowercase, trim | unique, sparse | Primary email login identifier. Nullable for Telegram-only accounts. |
| `password` | String | no | — | minlength 6 | — | Hashed password. Optional to support passkey-only, Google, and Telegram accounts. |
| `firstName` | String | no | `"کاربر"` | trim | — | Persian default ("user"). |
| `lastName` | String | no | `"جدید"` | trim | — | Persian default ("new"). |
| `role` | String | yes | `"buyer"` | enum: `admin` / `buyer` / `seller` | yes | Authorisation tier. |
| `isEmailVerified` | Boolean | no | `false` | — | — | Set to true after the email verification code is consumed. ⚠️ Changing the email via `PUT /api/user/profile` **resets this to `false`** and dispatches a fresh **6-digit** verification code to the new address (see Email verification note below). |
| `authProvider` | String | yes | `"email"` | enum: `email` / `google` / `telegram` | yes | Provider used to create the account. Existing email/password accounts remain `email`; Telegram-only users are `telegram`. |
| `telegramVerified` | Boolean | no | `false` | — | — | Set when Telegram identity has been signature-verified and linked through `TelegramLink`. |
| `emailVerificationToken` | String | no | — | — | — | Legacy token-based email verification. |
| `emailVerificationCode` | String | no | — | — | — | OTP code for email verification. |
| `emailVerificationCodeExpires` | Date | no | — | — | — | Expiry for `emailVerificationCode`. |
| `passwordResetToken` | String | no | — | — | — | Token for reset link flow. |
| `passwordResetExpires` | Date | no | — | — | — | Expiry of `passwordResetToken`. |
| `passwordResetCode` | String | no | — | — | — | OTP reset code. |
| `passwordResetCodeExpires` | Date | no | — | — | — | Expiry for OTP reset code. |
| `passkeys[]` | Subdocument array | no | `[]` | — | — | WebAuthn credentials (see below). |
| `passkeys[].id` | String | yes | — | — | — | Credential ID. |
| `passkeys[].publicKey` | String | yes | — | — | — | Stored public key. |
| `passkeys[].counter` | Number | yes | `0` | — | — | Signature counter. |
| `passkeys[].deviceType` | String | yes | — | enum: `platform` / `cross-platform` | — | Authenticator class. |
| `passkeys[].deviceName` | String | no | — | — | — | Optional human label. |
| `passkeys[].createdAt` | Date | no | `Date.now` | — | — | Registration timestamp. |
| `profile.avatar` | String | no | — | — | — | Avatar URL. |
| `profile.photoURL` | String | no | — | — | — | Alternative photo URL. |
| `profile.phone` | String | no | — | — | — | Contact phone. |
| `profile.address.street` | String | no | — | — | — | Inline address (separate from [[Address]] book). |
| `profile.address.city` | String | no | — | — | — | — |
| `profile.address.state` | String | no | — | — | — | — |
| `profile.address.zipCode` | String | no | — | — | — | — |
| `profile.address.country` | String | no | — | — | — | — |
| `profile.bio` | String | no | — | — | — | Free-form bio. |
| `profile.website` | String | no | — | — | — | Personal website URL. |
| `profile.walletAddress` | String | no | — | — | — | On-chain wallet address (EVM `0x…` or TON). Set via `PATCH /api/user/wallet-address`. |
| `profile.walletType` | String | no | — | enum: `evm` / `ton` | — | Which chain family the stored `walletAddress` belongs to. |
| `profile.walletProvider` | String | no | — | — | — | Wallet provider label (e.g. `evm`, `telegram-wallet`). Defaults to `telegram-wallet` for TON, `evm` otherwise. |
| `profile.walletProofVerified` | Boolean | no | — | — | — | True when ownership was proven — EIP-191 signature for EVM, or a verified TonProof for TON. |
| `profile.walletProofTimestamp` | Date | no | — | — | — | When the wallet proof was last verified (only set when `walletProofVerified` is true). |
| `profile.isPublic` | Boolean | no | `false` | — | — | Whether the profile is publicly visible. |
| `preferences.language` | String | no | `"en"` | — | — | UI language. |
| `preferences.currency` | String | no | `"USD"` | — | — | Display currency. |
| `preferences.notifications.email` | Boolean | no | `true` | — | — | Opt-in for email notifications. |
| `preferences.notifications.sms` | Boolean | no | `false` | — | — | Opt-in for SMS notifications. |
| `preferences.notifications.push` | Boolean | no | `true` | — | — | Opt-in for push notifications. |
| `status` | String | no | `"active"` | enum: `active` / `suspended` / `deleted` | yes | Soft-delete and moderation flag. |
| `lastLoginAt` | Date | no | — | — | — | Updated by auth middleware. |
| `refreshTokens[]` | String[] | no | `[]` | — | — | Array of currently active JWT refresh tokens. ⚠️ Reset to `[]` on password change and on password reset, which invalidates every outstanding session and forces re-login everywhere. |
| `referralCode` | String | no | — | — | unique, sparse | **Not yet implemented** in `User.ts` — planned for referral programme. |
| `referredBy` | ObjectId → User | no | — | — | yes | **Not yet implemented** in `User.ts` — planned for referral programme. |
| `points.total` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts` — planned for loyalty system. |
| `points.available` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts`. |
| `points.used` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts`. |
| `points.level` | Number | no | `1` | — | yes (`points.level`) | **Not yet implemented** in `User.ts` — planned for [[LevelConfig]] lookup. |
| `referralStats.totalReferrals` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts`. |
| `referralStats.activeReferrals` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts`. |
| `referralStats.totalEarned` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts`. |
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
## Virtuals
| Virtual | Returns | Definition |
| Field (domain / camelCase) | PG Column | Notes |
| --- | --- | --- |
| `fullName` | `${firstName} ${lastName}` | `backend/src/models/User.ts:238` |
| `id` / `pgId` | `id` (uuid PK) | Used for all marketplace FKs |
| `_id` / `legacyObjectId` | `legacy_object_id` | 24-hex string; socket rooms + legacy auth tokens |
| `email` | `email` | Primary email login; nullable for Telegram-only accounts |
| `password` | `password` | Hashed; optional for passkey/Google/Telegram accounts |
| `firstName` | `first_name` | Persian default "کاربر" |
| `lastName` | `last_name` | Persian default "جدید" |
| `role` | `role` | enum: `admin` / `buyer` / `seller` / `resolver` / `guard` |
| `isEmailVerified` | `is_email_verified` | Reset to false on email change |
| `authProvider` | `auth_provider` | enum: `email` / `google` / `telegram` |
| `telegramVerified` | `telegram_verified` | Set after Telegram signature-verify + link |
| `emailVerificationToken` | `email_verification_token` | Legacy token flow |
| `emailVerificationCode` | `email_verification_code` | OTP code |
| `emailVerificationCodeExpires` | `email_verification_code_expires` | — |
| `passwordResetToken` | `password_reset_token` | Token for reset link flow |
| `passwordResetExpires` | `password_reset_expires` | — |
| `passwordResetCode` | `password_reset_code` | OTP reset code |
| `passwordResetCodeExpires` | `password_reset_code_expires` | — |
| `passkeys[]` | `user_passkeys` child table | WebAuthn credentials |
| `passkeys[].id` | `user_passkeys.id` | Credential ID (PK) |
| `passkeys[].publicKey` | `user_passkeys.public_key` | Stored public key |
| `passkeys[].counter` | `user_passkeys.counter` | Signature counter |
| `passkeys[].deviceType` | `user_passkeys.device_type` | enum: `platform` / `cross-platform` |
| `passkeys[].deviceName` | `user_passkeys.device_name` | Optional human label |
| `passkeys[].createdAt` | `user_passkeys.created_at` | Registration timestamp |
| `profile.avatar` | `profile` jsonb | Avatar URL |
| `profile.photoURL` | `profile` jsonb | Alternative photo URL |
| `profile.phone` | `profile` jsonb | Contact phone |
| `profile.address.*` | `profile` jsonb | street, city, state, zipCode, country |
| `profile.bio` | `profile` jsonb | Free-form bio |
| `profile.website` | `profile` jsonb | Personal website URL |
| `profile.walletAddress` | `profile` jsonb | EVM `0x…` or TON address; set via `PATCH /api/user/wallet-address` |
| `profile.walletType` | `profile` jsonb | enum: `evm` / `ton` |
| `profile.walletProvider` | `profile` jsonb | e.g. `evm`, `telegram-wallet` |
| `profile.walletProofVerified` | `profile` jsonb | True when ownership proven (EIP-191 or TonProof) |
| `profile.walletProofTimestamp` | `profile` jsonb | Last verified timestamp |
| `profile.isPublic` | `profile` jsonb | Whether profile is publicly visible |
| `preferences.language` | `preferences` jsonb | UI language; default `"en"` |
| `preferences.currency` | `preferences` jsonb | Display currency; default `"USD"` |
| `preferences.notifications.email` | `preferences` jsonb | Opt-in email notifications; default `true` |
| `preferences.notifications.sms` | `preferences` jsonb | Opt-in SMS notifications; default `false` |
| `preferences.notifications.push` | `preferences` jsonb | Opt-in push notifications; default `true` |
| `status` | `status` | enum: `active` / `suspended` / `deleted` |
| `lastLoginAt` | `last_login_at` | Updated by auth middleware |
| `refreshTokens[]` | `user_refresh_tokens` child table | Active JWT refresh tokens; reset on password change/reset |
| `referralCode` | `referral_code` | Planned referral programme |
| `referredBy` | `referred_by_id` (uuid FK) | Planned referral programme |
| `points.total` | `points_total` | Planned loyalty system |
| `points.available` | `points_available` | Planned loyalty system |
| `points.used` | `points_used` | Planned loyalty system |
| `points.level` | `points_level` | Planned LevelConfig lookup |
| `referralStats.totalReferrals` | `referral_stats_total` | Planned |
| `referralStats.activeReferrals` | `referral_stats_active` | Planned |
| `referralStats.totalEarned` | `referral_stats_total_earned` | Planned |
| `createdAt` | `created_at` | Drizzle timestamp |
| `updatedAt` | `updated_at` | Drizzle timestamp |
## Indexes
### Computed / Virtual
Defined explicitly:
| Virtual | Returns | Notes |
| --- | --- | --- |
| `fullName` | `${firstName} ${lastName}` | Computed in domain layer (was Mongoose virtual) |
- `{ email: 1 }` unique sparse — allows multiple Telegram-only users without email while preserving uniqueness for email-bearing users.
- `{ role: 1 }``backend/src/models/User.ts:178`
- `{ status: 1 }``backend/src/models/User.ts:179`
- `{ authProvider: 1 }` — supports provider-level account reporting and cleanup.
### Serialisation
> [!warning] Missing indexes
> The schema currently defines only `role` and `status` indexes. The `referralCode`, `referredBy`, and `points.level` indexes documented below are **not yet present** in `User.ts`:
`toJSON()` strips `password`, `refreshTokens`, all `emailVerification*` and `passwordReset*` fields before serialisation.
## Pre/Post Hooks
---
None declared at the schema level.
## Roles
## Instance Methods
| Role | Added | Capabilities |
| --- | --- | --- |
| `admin` | original | Full platform access |
| `buyer` | original | Place purchase requests, confirm delivery |
| `seller` | original | Submit offers, manage shop |
| `resolver` | commit `fce8a19` | View/resolve disputes; bypass chat membership checks; no other admin privileges |
| `guard` | migration 0017 | Defined in `user_role` PG enum; purpose TBD |
| Signature | Purpose |
| --- | --- |
| `toJSON(): object` | Strips `password`, `refreshTokens`, all `emailVerification*` and `passwordReset*` fields before serialisation. Defined at `backend/src/models/User.ts:243`. |
## Static Methods
None defined on the schema.
---
## Relationships
- **References**: [[User]] (self, via `referredBy`).
- **Referenced by**: [[PurchaseRequest]] (`buyerId`, `preferredSellerIds`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), [[SellerOffer]] (`sellerId`), [[Payment]] (`buyerId`, `sellerId`), [[Chat]] (`participants[].userId`, `messages[].senderId`, `metadata.createdBy`), [[Notification]] (`userId` as string), [[RequestTemplate]] (`sellerId`), [[Dispute]] (`buyerId`, `sellerId`, `adminId`), [[BlogPost]] (`author.id`), [[Address]] (`userId`), [[Review]] (`sellerId`, `reviewerId`), [[PointTransaction]] (`user`, `referredUser`), [[ShopSettings]] (`sellerId`).
- **References**: User (self, via `referred_by_id`).
- **Referenced by**: PurchaseRequest (`buyerId`, `preferredSellerIds`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), SellerOffer (`sellerId`), Payment (`buyerId`, `sellerId`), Chat (`participants[].userId`, `messages[].senderId`, `metadata.createdBy`), Notification (`userId` as string), RequestTemplate (`sellerId`), Dispute (`buyerId`, `sellerId`, `adminId`), BlogPost (`author.id`), Address (`userId`), Review (`sellerId`, `reviewerId`), PointTransaction (`user`, `referredUser`), ShopSettings (`sellerId`).
## State Transitions
@@ -133,21 +221,24 @@ stateDiagram-v2
## Common Queries
```ts
// Find by email (login)
User.findOne({ email: email.toLowerCase() });
```sql
-- Find by email (login)
SELECT * FROM users WHERE email = lower($1) AND email IS NOT NULL;
// Active sellers
User.find({ role: 'seller', status: 'active' });
-- Active sellers
SELECT * FROM users WHERE role = 'seller' AND status = 'active';
// Validate referral
User.findOne({ referralCode: code });
-- Validate referral code
SELECT * FROM users WHERE referral_code = $1 AND referral_code IS NOT NULL;
// Leaderboard by points
User.find({ status: 'active' }).sort({ 'points.total': -1 }).limit(10);
-- Leaderboard by points
SELECT * FROM users WHERE status = 'active' ORDER BY points_total DESC LIMIT 10;
// Promote level
User.updateOne({ _id: id }, { $set: { 'points.level': newLevel } });
-- Promote level
UPDATE users SET points_level = $1, updated_at = now() WHERE id = $2;
-- Lookup by legacy ObjectId (socket rooms / auth token migration)
SELECT * FROM users WHERE legacy_object_id = $1;
```
Related: [[TempVerification]], [[LevelConfig]], [[PointTransaction]], [[ShopSettings]].
Related: TempVerification, LevelConfig, PointTransaction, ShopSettings.

View File

@@ -34,7 +34,9 @@ This page is the entry point for the API. See the individual service pages for e
The base port is set via `PORT` env var; in `development` it defaults to `5001`. CORS is restricted to `process.env.FRONTEND_URL` and credentials are allowed (`cors({ origin, credentials: true })` in `app.ts`).
Health check (not under `/api`): `GET /health``{ success, message, timestamp, environment, version }`.
Health checks:
- `GET /health` (not under `/api`) → `{ success, message, timestamp, environment, version }` — used by Docker and Gatus.
- `GET /api/health` (added in commit `44579d6`, backend v2.6.49) → deeper JSON with MongoDB, Postgres, Redis, Request Network registry/API connectivity status, plus the version string. Used by Gatus monitoring. Postgres is marked `required` when any `*_STORE=postgres` flag is enabled.
API discovery endpoint: `GET /api` → returns a map of available service prefixes.

View File

@@ -5,18 +5,21 @@ tags: [api, admin, reference]
# Admin API
> **Last updated:** 2026-05-29aligned with code (see Doc vs Code Audit Report)
> **Last updated:** 2026-05-30break-glass endpoints added, scanner/status auth fixed, reload/probe routes now implemented, confirmation threshold history implemented, resolver role added
There is no single `/api/admin` namespace — admin-only endpoints are scattered across the service routers. This page catalogs them in one place. All require `Bearer JWT` with `req.user.role === 'admin'` unless explicitly noted otherwise. The two enforcement patterns are:
- Middleware: `authorizeRoles('admin')` after `authenticateToken` (used by the dispute, data-cleanup, blog routers).
- Inline check inside the handler: `if (req.user.role !== 'admin') return 403` (used by user, points, payment routes).
> [!note] Resolver role
> The `resolver` role was added (commit `fce8a19`). Resolvers have access to the dispute-triage endpoints (`assign`, `status`, `resolve`, `statistics`) only. All other admin endpoints remain `admin`-only.
## User management
See full descriptions in [[User API]].
> **Path note:** The frontend and backend both use `/api/users/admin/*` (plural). The singular `/api/user/admin/*` paths for create/delete/status/role/list are **unreachable** — they are not mounted in the backend. Use `/api/users/admin/*` for all user-management calls.
> **Path note:** The frontend uses `/api/users/admin/*` (plural — legacy `userRoutes`). The singular `/api/user/admin/*` group (new `userController`) **is also mounted** (`app.ts`). Since backend `14c231e` (v2.8.50) the plural group delegates `toggle-status` and `dependencies` to the new controller so every frontend call routes. Prefer `/api/users/admin/*` for user-management calls.
| Endpoint | Action |
| --- | --- |
@@ -36,11 +39,9 @@ See full descriptions in [[User API]].
> **Verification code length:** The endpoint `POST /api/users/admin/:userId/resend-verification` is served by the legacy userRoutes and generates **8-digit** codes. The new userController generates 6-digit codes and is reached via a different path. Both coexist; the legacy route takes precedence for this path.
**⚠️ KNOWN BUG — HTTP verb mismatch (status/role updates):** The frontend Redux actions for `updateUserStatus` and `updateUserRole` send `PUT` requests, but the backend registers these handlers under `PATCH`. These calls will receive `404 Method Not Found` responses until the frontend is corrected to use `PATCH`.
**✅ FIXED (frontend `d7a2a86` / `6fe1328`, v2.8.5051):** the old PUT-verb and status-value mismatches are gone — `updateUserStatus` sends `PATCH` with `{ isActive: boolean }` (the field the legacy plural route reads).
**⚠️ KNOWN BUG — Status value mismatch:** The frontend sends `'inactive'` and `'pending'` as status values when updating user status. The backend only accepts `'active'`, `'suspended'`, or `'deleted'`. Sending `'inactive'` or `'pending'` will be rejected or silently ignored.
**Hard vs. soft delete note:** The legacy route `DELETE /users/admin/:id` performs a **hard delete** (`findByIdAndDelete`). The current route `DELETE /api/users/admin/:userId` performs a **soft delete** (sets `status='deleted'`). Always use the current `/api/users/admin/:userId` route to preserve data integrity.
**Soft delete + email release (backend `378f8f6`, v2.8.51):** `DELETE /api/users/admin/:userId` soft-deletes (sets `status='deleted'`) **and releases the email** (renamed to `deleted_<legacyId>_<email>`) so the address can be reused. Create/register also lazily free emails still held by accounts deleted before this fix. Soft-deleted users are excluded from the admin list and all stats (backend `14c231e`).
## Listing / marketplace moderation
@@ -159,9 +160,14 @@ Router: [`backend/src/services/admin/dataCleanupRoutes.ts`](../../backend/src/se
### GET /api/admin/scanner/status
**Description:** Returns the current state of the blockchain scanner / wallet monitor.
**Description:** Returns the current state of the AMN Pay Scanner. Proxies to `AMN_SCANNER_URL/scanner/status`.
**Auth required:** Bearer JWT (`admin`) — `authenticateToken` + `authorizeRoles('admin')` were added in commit `1d881c5`. The previously documented unauthenticated access gap (ISSUE-006) is closed.
> **⚠️ SECURITY BUG — NO AUTHENTICATION:** Despite being mounted under `/api/admin/`, this endpoint has **no** `authenticateToken` or `authorizeRoles` guard. Any unauthenticated request can read scanner state.
### POST /api/admin/scanner/webhooks/retry
**Description:** Trigger a retry of failed/pending scanner webhooks.
**Auth required:** Bearer JWT (`admin`)
**Request body:** `{ intentId?: string }` — omit to retry all pending.
## Settings
@@ -174,6 +180,13 @@ Router: [`backend/src/services/admin/dataCleanupRoutes.ts`](../../backend/src/se
| `GET /api/admin/settings/aml` | admin | Read current AML settings |
| `PATCH /api/admin/settings/aml` | admin | Update AML settings (runtime only — not persisted to disk or DB) |
**AML providers available:**
- **Chainalysis** — cloud API provider (requires `CHAINALYSIS_API_KEY`). Enabled via `AML_PROVIDER=chainalysis`.
- **OFAC SDN local** — downloads the US Treasury SDN XML list once per 24 hours and checks addresses locally. No API key required. Enabled via `AML_PROVIDER=ofac`. Added in commit `31343d1` (Task #10). List is fetched from `OFAC_SDN_URL` (defaults to `https://www.treasury.gov/ofac/downloads/sdn.xml`).
The active provider is selected at startup via `AML_PROVIDER`. `PATCH /api/admin/settings/aml` can switch the provider at runtime but the change is not persisted.
### Confirmation thresholds
Frontend page exists. Endpoints require admin auth.
@@ -182,8 +195,22 @@ Frontend page exists. Endpoints require admin auth.
| --- | --- |
| `GET /api/admin/settings/confirmation-thresholds` | Get current confirmation thresholds for all chains |
| `PATCH /api/admin/settings/confirmation-thresholds/:chainId` | Update threshold for a specific chain |
| `GET /api/admin/settings/confirmation-thresholds/history` | Last 50 threshold change events (populated with `changedBy` user email/name) |
> **Not implemented:** `GET /api/admin/settings/confirmation-thresholds/history` — history endpoint does not exist. `POST /api/admin/rn/networks/reload` and `POST /api/admin/rn/networks/probe/:chainId` do not exist.
> **History route:** `GET /api/admin/settings/confirmation-thresholds/history` is now implemented (commit `27fb15a`). It reads from the `ConfigSettingHistory` collection, keyed as `confirmation_threshold:<chainId>`.
### Break-glass (Trezor bypass)
Three endpoints manage the break-glass mode, which disables the Trezor safekeeping requirement for escrow release/refund for up to 1 hour. All changes fire a Telegram alert.
| Endpoint | Action |
| --- | --- |
| `GET /api/admin/settings/break-glass` | Read current break-glass status (active, expiresAt, activatedBy) |
| `POST /api/admin/settings/break-glass` | Activate break-glass for 1 hour |
| `DELETE /api/admin/settings/break-glass` | Cancel break-glass before it expires |
> [!warning] In-memory state
> Break-glass state is stored in-memory only (`breakGlassRoutes.ts`). A server restart always clears it, which is intentional. The `isBreakGlassActive()` helper is exported and consumed by the Trezor safekeeping middleware.
## Payments awaiting confirmation
@@ -200,6 +227,10 @@ Frontend page exists.
| Endpoint | Auth | Action |
| --- | --- | --- |
| `GET /api/admin/rn/networks` | admin | List all registered RN networks |
| `POST /api/admin/rn/networks/reload` | admin | Reload chain + token registries from disk (no restart needed) |
| `POST /api/admin/rn/networks/probe/:chainId` | admin | On-demand on-chain probe: RPC reachability, proxy bytecode, dummy-call validity |
> All three routes are implemented (commit `5681abf`). Previous docs listed reload and probe as not implemented.
## Blog admin

View File

@@ -5,7 +5,7 @@ tags: [api, auth, reference]
# Authentication API
> **Last updated:** 2026-05-29aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-05-30Cloudflare Turnstile CAPTCHA added after 3 failed logins (commit `b8edbbf`)
All endpoints are mounted under `/api/auth/*` in `backend/src/app.ts`. The routes file is [`backend/src/services/auth/authRoutes.ts`](../../backend/src/services/auth/authRoutes.ts) and the WebAuthn sub-routes are in [`passkeyRoutes.ts`](../../backend/src/services/auth/passkeyRoutes.ts). Controller logic lives in [`authController.ts`](../../backend/src/services/auth/authController.ts) and [`authService.ts`](../../backend/src/services/auth/authService.ts).
@@ -121,6 +121,12 @@ Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[Temp
- `403` email not verified
- `423` account locked (after repeated failures, tracked in Redis via `rateLimitService`)
**Cloudflare Turnstile CAPTCHA:** After **3 failed login attempts** from the same IP within 15 minutes the `captchaGate` middleware requires a valid `cf-turnstile-response` token in the request body. Responses when CAPTCHA is required but missing:
```json
{ "success": false, "captchaRequired": true, "message": "..." }
```
HTTP status: `429`. When `TURNSTILE_SECRET_KEY` is not set (local dev) the gate is skipped.
**⚠️ Rate limiter behaviour:** The attempt counter increments on **every** attempt (before password validation), not only on failures. 5 total attempts within 15 minutes triggers lockout — a user burning 5 attempts with typos will be locked out even if they never had a valid password.
**Side effects:**

View File

@@ -5,10 +5,13 @@ tags: [api, chat, reference]
# Chat API
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-05-30 — admin and resolver roles can now read and send messages in any chat (commit `766a9a2`)
All chat endpoints live under `/api/chat/*`. The router is [`backend/src/services/chat/chatRoutes.ts`](../../backend/src/services/chat/chatRoutes.ts), controller is `chatController`, service is `ChatService`. Every endpoint requires `Bearer JWT` — the router applies `authenticateToken` globally.
> [!note] Admin and resolver chat access
> Users with role `admin` or `resolver` can **read messages and send messages in any chat** without being a listed participant (`ChatService` checks `canBypassMembership = senderRole === 'admin' || senderRole === 'resolver'`). This applies to `GET /api/chat/:id/messages`, `GET /api/chat/:id/info`, and `POST /api/chat/:id/messages`. Dispute-chat monitoring for resolvers was the primary driver (commit `766a9a2`).
Model: [[Chat]]. Real-time delivery happens over Socket.IO rooms named `chat-<chatId>`. Clients must call `join-chat-room` after connecting. See [[Socket Events]] for `new-message`, `messages-read`, `message-edited`, `message-deleted`, `participants-added`, `participant-removed`, and `user-typing` payloads.
## Rate limits and constraints

View File

@@ -5,18 +5,21 @@ tags: [api, dispute, reference]
# Dispute API
> **Last updated:** 2026-05-29aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-05-30resolver role added, role guards applied to assign/status/resolve (commits b9e0f6a, 1d881c5)
> [!note] Current implementation
> The Dispute module now has a Mongoose model, controller routes, dashboard routes, and release-hold helper routes mounted under `/api/disputes`. Keep this page aligned with both `backend/src/routes/disputeRoutes.ts` and `backend/src/services/dispute/disputeRoutes.ts`.
> The Dispute module has two distinct router families. Keep this page aligned with both `backend/src/routes/disputeRoutes.ts` and `backend/src/services/dispute/disputeRoutes.ts`.
Endpoints live under `/api/disputes/*`. `backend/src/routes/disputeRoutes.ts` delegates to `DisputeController` (`backend/src/controllers/disputeController.ts`) for CRUD/triage. `backend/src/services/dispute/disputeRoutes.ts` provides lightweight release-hold endpoints (`raise`, `resolve`, `status`) used by escrow release gating. The routers apply `authenticateToken` globally — every endpoint requires `Bearer JWT`.
Endpoints live under two prefixes:
> [!warning] Route shadowing — both dispute routers are mounted at `/api/disputes`
> The dashboard router is mounted **first** in `app.ts`. Its `POST /:id/resolve` intercepts requests before the admin-guarded release-hold router's resolve handler. Confirm which handler will run before wiring automation to either resolve endpoint.
- `/api/disputes/*``backend/src/routes/disputeRoutes.ts` delegates to `DisputeController` (`backend/src/controllers/disputeController.ts`) for CRUD/triage. All routes apply `authenticateToken` globally.
- `/api/disputes/pr/*``backend/src/services/dispute/disputeRoutes.ts` provides lightweight release-hold endpoints (`raise`, `resolve`, `status`) used by escrow release gating. Previously mounted at `/api/disputes`, causing route shadowing (ISSUE-003). **Remounted at `/api/disputes/pr` in commit `1d881c5`** — all release-hold calls must use this new prefix.
> [!danger] Security issues — see individual endpoint notes below
> Several endpoints that are documented as admin-only have **no role guard** in the current codebase. Any authenticated user can call them. These are noted per-endpoint.
> [!success] Route shadowing resolved (ISSUE-003)
> The release-hold router was remounted from `/api/disputes` to `/api/disputes/pr`. Both routers now have independent paths and neither shadows the other.
> [!note] Resolver role
> A new `resolver` role was added (commit `fce8a19`). Resolvers can view and resolve disputes but have no other platform privileges. They are granted the same access as `admin` on all dispute-triage operations listed below.
> [!note] Real-time events
> All socket events from `DisputeService` are currently **TODO stubs**. No real-time events fire from dispute mutations. Notifications are delivered via `POST /api/notifications` → `new-notification` socket event only.
@@ -48,16 +51,18 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
- Notifies the counter-party via `POST /api/notifications` (`new-notification` socket event).
- Pauses any in-flight payout (sets a hold flag on the related [[Payment]]).
### POST /api/disputes/:purchaseRequestId/raise
### POST /api/disputes/pr/:purchaseRequestId/raise
**Description:** Lightweight release-hold endpoint that marks a purchase request and related payments as disputed. Exists in the backend but has no corresponding frontend action.
**Description:** Lightweight release-hold endpoint that marks a purchase request and related payments as disputed. No corresponding frontend UI action.
**Auth required:** Bearer JWT (buyer who owns the request or admin)
**Request body:** `{ reason?: string }`
**Response 200:** `{ success, message, data }`
### GET /api/disputes/:purchaseRequestId/status
> **Path note:** Previously served at `/api/disputes/:purchaseRequestId/raise`. Moved to `/api/disputes/pr/:purchaseRequestId/raise` in commit `1d881c5` (ISSUE-003 fix).
**Description:** Returns release-hold flags for a purchase request, including whether release is currently blocked. Exists in the backend but has no corresponding frontend action.
### GET /api/disputes/pr/:purchaseRequestId/status
**Description:** Returns release-hold flags for a purchase request, including whether release is currently blocked. No corresponding frontend UI action.
**Auth required:** Bearer JWT (buyer, preferred seller, or admin)
## Read
@@ -79,7 +84,7 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
### GET /api/disputes/statistics
**Description:** Aggregated counts (open, by reason, average resolution time) for admin dashboards.
**Auth required:** Bearer JWT (any authenticated user — backend applies `authenticateToken` only, no role restriction)
**Auth required:** Bearer JWT (`admin` or `resolver``authorizeRoles('admin', 'resolver')` is applied)
**Response 200:** `{ success, data: { open, byReason, avgResolutionHours, ... } }`
### GET /api/disputes/:id
@@ -92,10 +97,8 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
### POST /api/disputes/:id/assign
**Description:** Assign an admin moderator to the dispute. Sets `assignedAdminId` and transitions status to `in_progress`.
**Auth required:** Bearer JWT
> ⚠️ **SECURITY — NO ROLE GUARD:** Despite being documented as admin-only, there is no role guard on this endpoint. Any authenticated user can self-assign as mediator on any dispute.
**Description:** Assign an admin or resolver moderator to the dispute. Sets `assignedAdminId` and transitions status to `in_progress`.
**Auth required:** Bearer JWT (`admin` or `resolver`)
**Request body:** `{ adminId: string }`
**Side effects:** Notifies all participants.
@@ -103,18 +106,14 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
### PATCH /api/disputes/:id/status
**Description:** Generic status update (e.g. close without resolution).
**Auth required:** Bearer JWT
> ⚠️ **SECURITY — NO ROLE GUARD:** There is no role guard on this endpoint. Any authenticated user can change dispute status despite documentation claiming admin-only access.
**Auth required:** Bearer JWT (`admin` or `resolver`)
**Request body:** `{ status: string; note?: string }`
### POST /api/disputes/:id/resolve
**Description:** Final adjudication. Records the decision and triggers the appropriate escrow action.
**Auth required:** Bearer JWT
> ⚠️ **SECURITY — NO ROLE GUARD:** This is the dashboard router's resolve handler (mounted first). There is no role guard. Any authenticated user can resolve a dispute, including issuing `action=ban_seller`.
**Auth required:** Bearer JWT (`admin` or `resolver`)
> ⚠️ **ROUTE SHADOWING:** Because the dashboard router is mounted before the admin-guarded release-hold router, this handler intercepts all `POST /api/disputes/:id/resolve` requests. The admin-guarded release-hold resolve endpoint is unreachable at this path.
@@ -131,13 +130,14 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
- `action === "refund"` → create/approve the corresponding refund instruction through the ledger-gated payment release/refund flow.
- `action === "no_action"` or seller-favorable outcome → clear hold only after release checks pass.
- Notifies both participants and updates [[PurchaseRequest]] status to `disputed_resolved`.
- **ISSUE-004 fix (commit `1d881c5`):** `DisputeService.resolveDispute` now calls `releaseHoldResolve()` on the linked `purchaseRequestId`, clearing the escrow hold so payment release is unblocked automatically after resolution.
### POST /api/disputes/:purchaseRequestId/resolve
### POST /api/disputes/pr/:purchaseRequestId/resolve
**Description:** Lightweight release-hold endpoint that clears the disputed hold flags on a purchase request and related payments.
**Auth required:** Bearer JWT (admin)
> ⚠️ **ROUTE SHADOWING:** This endpoint is on the release-hold router which is mounted **after** the dashboard router. The dashboard router's `POST /:id/resolve` matches first, making this handler unreachable in practice. See the route shadowing warning at the top of this page.
> **Path note:** Previously unreachable due to route shadowing. Moved to `/api/disputes/pr/:purchaseRequestId/resolve` (commit `1d881c5`, ISSUE-003 fix). This endpoint is now reachable.
**Response 200:** `{ success, message, data }`

View File

@@ -5,7 +5,7 @@ tags: [api, marketplace, reference]
# Marketplace API
> **Last updated:** 2026-05-29aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-05-31request-template delivery mode and payment rail validation updated.
All marketplace endpoints live under `/api/marketplace/*`. The router is composed of several files mounted from `app.ts`:
@@ -71,8 +71,8 @@ The buyer-facing CRUD plus seller-side workflow endpoints. Model: [[PurchaseRequ
size?: string;
color?: string;
quantity?: number; // default 1
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" };
urgency?: "low" | "medium" | "high";
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" | "USDT" | "USDC" };
urgency?: "low" | "medium" | "high" | "urgent";
deliveryInfo?: {
deliveryType: "physical" | "online";
addressId?: string; // when physical
@@ -239,7 +239,7 @@ Valid `status` values: `pending | accepted | rejected | withdrawn`
**Request body:**
```ts
{
price: { amount: number; currency: "USD" | "EUR" | "IRR" };
price: { amount: number; currency: "USDT" }; // USDT only for escrow MVP
deliveryEstimate: { days: number; note?: string };
notes?: string;
attachments?: string[];
@@ -248,6 +248,8 @@ Valid `status` values: `pending | accepted | rejected | withdrawn`
**Response 201:** `{ success, data: { offer } }`
**Side effects:** Emits `new-offer` to `buyer-<buyerId>` and `seller-offer-update` to `seller-<sellerId>`.
> **Note:** Currency is locked to `USDT` for the escrow MVP (commit 3aaa2fe). The frontend `CURRENCY_SYMBOLS` map in `src/sections/request/constants.ts` exposes only `USDT`.
### PUT /api/marketplace/purchase-requests/:id/offers (legacy)
**Description:** Older offer-update endpoint kept for compatibility.
@@ -271,16 +273,19 @@ Valid `status` values: `pending | accepted | rejected | withdrawn`
This endpoint does not exist. Use `GET /api/marketplace/purchase-requests/:id/offers` instead.
### ⚠️ NOT IMPLEMENTED: GET /api/marketplace/offers/seller/:sellerId
### GET /api/marketplace/offers/seller/:sellerId
This endpoint does not exist. `getOffersBySeller()` is an internal service method and is not exposed via HTTP.
**Description:** Returns all offers submitted by the given seller, across all purchase requests. Used by the Offer Management dashboard page (`/dashboard/seller/marketplace/offers`).
**Auth required:** Bearer JWT (seller, own `:sellerId` only)
**Response 200:** `{ data: [SellerOffer, ...] }`
**Frontend action:** `getSellerOffers(sellerId)` in `src/actions/marketplace.ts` (added commit 240a668)
### PATCH /api/marketplace/offers/:id
**Description:** Seller edits their pending offer (price, delivery estimate, notes).
**Auth required:** Bearer JWT (offer owner)
> ⚠️ **KNOWN BUG:** The frontend sends `PUT` but the backend registers `PATCH`. Requests from clients using `PUT` will receive `404`. Use `PATCH`.
> **Fixed (commit 240a668):** The frontend `updateOffer` and `acceptOffer` actions now correctly send `PATCH`.
### DELETE /api/marketplace/offers/:id
@@ -293,9 +298,14 @@ This endpoint does not exist. `getOffersBySeller()` is an internal service metho
**Auth required:** Bearer JWT
**Request body:** `{ status: "pending" | "accepted" | "rejected" | "withdrawn" }`
### ⚠️ NOT IMPLEMENTED: POST /api/marketplace/offers/:id/withdraw
### POST /api/marketplace/offers/:id/withdraw
This endpoint does not exist. To withdraw an offer use `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
**Description:** Seller withdraws their offer. Sets offer status to `withdrawn` using `sellerOfferService.withdrawOffer()`. Only the offer owner may call this.
**Auth required:** Bearer JWT (offer owner)
**Response 200:** `{ success: true, data: { /* updated offer */ } }`
**Errors:** `403` not the offer owner, `404` offer not found.
> **Note:** This endpoint was previously documented as NOT IMPLEMENTED. It was added to `backend/src/services/marketplace/routes.ts` (commit `3e47713`).
### POST /api/marketplace/purchase-requests/:id/select-offer
@@ -303,7 +313,8 @@ This endpoint does not exist. To withdraw an offer use `PUT /api/marketplace/off
**Auth required:** Bearer JWT (buyer)
**Request body:** `{ offerId: string }`
**Side effects:**
- Updates [[PurchaseRequest]] `selectedOfferId`, status moves toward `payment`.
- Persists `selectedOfferId` on [[PurchaseRequest]] (commit `023255f` — previously this field was not saved, causing it to be lost). Status moves toward `payment`.
- Rejects all **losing** offers (sets their status to `rejected`) when payment is confirmed (commit `023255f`).
- Emits `seller-offer-update` to all sellers for the request.
### POST /api/marketplace/offers/:id/accept (legacy)
@@ -332,15 +343,25 @@ A [[RequestTemplate]] is a re-usable "shop product" a seller can publish. Buyers
size?: string; // <=100
color?: string; // <=100
quantity?: number; // 1-10000
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" };
urgency?: "low" | "medium" | "high";
deliveryInfo?: { deliveryType: "physical" | "online"; email?: string };
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" | "USDT" | "USDC" };
urgency?: "low" | "medium" | "high" | "urgent";
deliveryInfo?: {
deliveryType: "physical" | "online"; // seller-selected; buyer cannot override at checkout
notes?: string;
email?: string; // optional legacy field; empty string is accepted
};
paymentConfig?: {
useShopDefault: boolean; // false = template override, true = shop defaults
allowedChains: number[]; // at least one positive chain id when paymentConfig is sent
allowedTokens: string[]; // at least one non-empty token symbol when paymentConfig is sent
};
maxUsage?: number | null; // 0/null = unlimited
expiresAt?: string | null; // ISO date
images?: string[]; // URLs from [[File API]]
}
```
**Response 201:** `{ data: { template } }` with a generated `shareableLink`.
**Validation:** If `paymentConfig` is present, both `allowedChains` and `allowedTokens` must be non-empty. The UI now defaults new templates to explicit template rails, so a seller must choose at least one chain and one token before publishing.
### GET /api/marketplace/request-templates
@@ -388,7 +409,7 @@ A [[RequestTemplate]] is a re-usable "shop product" a seller can publish. Buyers
### POST /api/marketplace/request-templates/batch-convert
**Description:** Convert several templates at once (cart checkout).
**Description:** Convert several templates at once (cart checkout). The seller's template delivery mode is preserved; buyer-supplied checkout details are only overlaid where that mode requires them.
**Auth required:** Bearer JWT (buyer)
**Request body:**
```ts
@@ -399,8 +420,25 @@ A [[RequestTemplate]] is a re-usable "shop product" a seller can publish. Buyers
sellerId: string; // MongoId
}>;
status?: "pending" | "pending_payment" | "active";
paymentConfirmed?: boolean;
paymentData?: Record<string, unknown>;
deliveryInfo?: {
email?: string; // copied to generated online requests
billing?: {
name?: string;
phoneNumber?: string;
address?: string;
city?: string;
state?: string;
country?: string;
zipCode?: string;
addressType?: string;
fullAddress?: string; // copied to generated physical requests
};
};
}
```
**Delivery mapping:** `online` templates use `deliveryInfo.email`; `physical` templates use `deliveryInfo.billing` to fill `deliveryInfo.address` and `deliveryInfo.deliveryAddress` on the generated [[PurchaseRequest]].
### POST /api/marketplace/request-templates/complete-payment

View File

@@ -5,7 +5,7 @@ tags: [api, payment, reference, request-network, escrow]
# Payment API
> **Last updated:** 2026-05-29aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-05-31Postgres integration promotion, oracle quote persistence, AMN scanner rail-switch fix, capped webhook confirmation persistence, seller/template payment rail options, and partial gasless permit endpoints.
The payment surface is split across provider-neutral payment routers, Request Network checkout/webhook routes, derived-destination custody routes, and admin safety routes:
@@ -16,11 +16,15 @@ The payment surface is split across provider-neutral payment routers, Request Ne
| `/api/payment/request-network/*` | [`requestNetwork/requestNetworkRoutes.ts`](../../backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts) | Request Network intent creation, in-house checkout payloads, webhook processing |
| `/api/payment/derived-destinations/*` | [`wallets/derivedDestinationRoutes.ts`](../../backend/src/services/payment/wallets/derivedDestinationRoutes.ts) | Derived destination inspection, balance checks, and sweeping |
| `/api/payment/decentralized/*` | [`decentralizedPaymentRoutes.ts`](../../backend/src/services/payment/decentralizedPaymentRoutes.ts) | Legacy wallet-direct confirmations |
| `/api/payment/amn-scanner/*` | [`routes/amnScannerWebhookRoutes.ts`](../../backend/src/routes/amnScannerWebhookRoutes.ts) | AMN Pay Scanner webhook receiver |
| `/api/admin/rn/networks/*` | [`requestNetwork/networkRegistryRoutes.ts`](../../backend/src/services/payment/requestNetwork/networkRegistryRoutes.ts) | Request Network chain/token registry |
| `/api/admin/payments/awaiting-confirmation/*` | `awaitingConfirmationRoutes.ts` | Admin queue for payments waiting on confirmation/safety checks |
Core model: [[Payment]]. Coordination logic to avoid race conditions when multiple sources update the same payment is in `paymentCoordinator.ts`.
> [!warning] Persistence status
> Payment APIs still create/read/update Mongo `Payment` documents on backend `2.6.83`. The Postgres branch adds schemas, repos, migrations, and optional quote persistence, but it is not a full payment-domain cutover. `/api/payment/request-network/intents` can write `payment_quotes` only when `ORACLE_QUOTING_ENABLED=true`; the payment record itself remains Mongo-backed unless future service wiring changes that boundary.
## Configuration / health
### POST /api/payment/configuration
@@ -52,7 +56,7 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### POST /api/payment
**Description:** Create a payment record manually. Normal buyer checkout should use `POST /api/payment/request-network/intents`.
**Description:** Create a payment record manually. Normal buyer checkout should use `POST /api/payment/request-network/pay-in`.
**Auth required:** Bearer JWT
**Request body:**
```ts
@@ -90,7 +94,7 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### GET /api/payment/:id
**Description:** Fetch a payment by id.
**Description:** Fetch a payment by id. For payments with `provider: 'request.network'` that are still `pending`, this endpoint also performs an **on-demand RN reconcile**: it queries the Request Network node live, and if RN reports the request as paid it immediately marks the payment `completed`, advances the purchase request to `processing`, persists `selectedOfferId`, and accepts the winning offer while rejecting all others. This reconcile path exists because RN webhooks cannot reach a local dev server and the reconcile cron is not started there; the same logic fires in production as a safety net.
**Auth required:** Bearer JWT
**Errors:** `404` not found.
@@ -126,19 +130,19 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### POST /api/payment/payments/:id/fetch-tx
**Description:** Re-queries the blockchain to fetch the missing `transactionHash` for a completed payment.
**⚠️ SECURITY — NO AUTHENTICATION:** This endpoint has no authentication guard. Any unauthenticated caller can trigger a blockchain re-query for any payment ID.
**Auth required:** Bearer JWT (admin) — `authenticateToken` + `authorizeRoles('admin')` added in commit `1d881c5` (ISSUE-005 fix).
**Response 200:** `{ success, transactionHash, network, source, message }`
### POST /api/payment/payments/auto-fetch-missing
**Description:** Batch tx-hash backfill across the database.
**⚠️ SECURITY — NO AUTHENTICATION:** This endpoint has no authentication guard. Any unauthenticated caller can trigger a full database backfill scan.
**Auth required:** Bearer JWT (admin) — `authenticateToken` + `authorizeRoles('admin')` added in commit `1d881c5` (ISSUE-005 fix).
**Request body:** `{ limit?: number }` (default 10)
### GET /api/payment/payments/:id/debug
**Description:** Debug bundle including the raw payment, blockchain metadata, and wallet-monitor status. Intended for admin / development.
**⚠️ SECURITY — NO AUTHENTICATION:** Despite exposing full payment data, this endpoint has no authentication guard. Any unauthenticated caller can retrieve complete payment details for any payment ID.
**Auth required:** Bearer JWT (admin) — `authenticateToken` + `authorizeRoles('admin')` added in commit `1d881c5` (ISSUE-005 fix).
### POST /api/payment/callback
@@ -153,9 +157,9 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
## Request Network - Pay-in
### POST /api/payment/request-network/intents
### POST /api/payment/request-network/pay-in
**Description:** Creates a Request Network pay-in intent and stores a [[Payment]] with `provider: "request.network"`. The service can attach a per-payment derived destination before creating the provider request.
**Description:** Creates a plain Request Network pay-in intent and stores a [[Payment]] with `provider: "request.network"`. The service can attach a per-payment derived destination before creating the provider request. This route stays available at `/api/payment/request-network/pay-in`, but new provider-selection checkout integrations should prefer `/api/payment/request-network/intents`.
**Auth required:** Bearer JWT (buyer)
**Request body:**
```ts
@@ -171,6 +175,53 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
```
**Response 200:** `{ success: true, data: { paymentId, paymentUrl, providerPaymentId, raw, ... } }`
### GET /api/payment/request-network/options
**Description:** Resolves the chain/token rails a buyer may use for a seller or template checkout. Precedence is template override (`RequestTemplate.paymentConfig.useShopDefault === false`), then store defaults (`ShopSettings.paymentConfig`), then the global supported default.
**Auth required:** Bearer JWT
**Query params:** `sellerId?`, `templateId?`
**Response 200:**
```ts
{
success: true;
data: {
allowedChains: number[];
allowedTokens: string[];
source: "item" | "store" | "default";
chains: Array<{
chainId: number;
name: string;
shortName: string;
tokens: Array<{ symbol: string; address: string; decimals: number }>;
}>;
};
}
```
**Frontend use:** Template checkout calls this with both `sellerId` and the real `templateId` before creating payment intents, then defaults to BSC/USDT when allowed or the first returned rail otherwise.
### POST /api/payment/request-network/intents
**Description:** Richer buyer intent endpoint used by the provider-selection checkout. It can dispatch either to `request.network` or `amn.scanner`, validates the seller's allowed chain/token choices, and re-points an existing pending intent when the buyer changes rail. When `ORACLE_QUOTING_ENABLED=true`, the backend ignores client-supplied `amount`, loads the seller offer price, computes a depeg-protected quote, and uses the computed settlement amount for the provider intent.
**Auth required:** Bearer JWT (buyer)
**Request body additions:** `provider?: "request.network" | "amn.scanner"`, `token`, `network`, `metadata.templateId?`.
**Response 200:** `{ success: true, data, quote? }`. `quote` includes `settleAmount`, `token`, `tokenPriceUSD`, `depegAdjustmentBps`, `roundingBps`, and `expiresAt` when oracle quoting is enabled.
**Errors:** `400` for unsupported/disallowed chain-token choice, `422 DEPEG_LIMIT_EXCEEDED` when the settlement token exceeds the depeg hard cap, `503 ORACLE_UNAVAILABLE` when rates are stale or unavailable.
### GET /api/payment/request-network/permit-availability
**Description:** Checks whether the backend relayer can sponsor an EIP-2612 `permit()` transaction for a chain/token. This is partial gasless support: it removes the approval transaction gas only; the buyer still sends the final payment transaction.
**Auth required:** Bearer JWT
**Query params:** `chainId`, `token`
**Response 200:** `{ success: true, data: { available, reason?, relayer?, balanceWei?, requiredWei? } }`
### POST /api/payment/request-network/:paymentId/permit
**Description:** Broadcasts a buyer-signed EIP-2612 permit through the backend relayer. The route validates the permit against the payment's actual in-house checkout block so the relayer only sponsors real pending payments and the expected fee-proxy spender.
**Auth required:** Bearer JWT (buyer who owns the payment)
**Request body:** `{ owner, spender, value, deadline, v, r, s }`
**Response 200:** `{ success: true, data: { txHash, allowance } }`
**Limitations:** Only permit-capable tokens/chains qualify. Mainnet USDT is not permit-capable; full gasless payment still requires a forwarder or account-abstraction/paymaster design.
### GET /api/payment/request-network/:paymentId/checkout
**Description:** Rehydrates the in-house checkout payload for an existing Request Network payment so the frontend can build the on-chain approval/payment transaction without relying on the hosted RN page.
@@ -181,8 +232,37 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
**Description:** Request Network posts settlement updates here. The route verifies `x-request-network-signature` over the raw body, deduplicates delivery IDs, evaluates the Transaction Safety Provider, and coordinates the payment/ledger update.
**Auth required:** No (signature-protected)
**Response:** `200` when processed or duplicate; `202` when accepted but safety checks are pending; `401` for invalid signature.
**Side effects:** For confirmed/completed events, `blockchain.confirmations` is stored as the accepted confirmation count capped at the effective per-chain threshold before the payment/ledger update is emitted.
> ⚠️ **NOT IMPLEMENTED:** `POST /api/payment/request-network/:id/payout/initiate`, `POST /api/payment/request-network/:id/payout/confirm`, `POST /api/payment/request-network/:id/release/confirm`, and `POST /api/payment/request-network/:id/refund/confirm` do not exist in the codebase. Do not call these paths.
> [!note] RN payout/release/refund routes
> `POST /api/payment/request-network/:paymentId/payout/initiate`, `POST /api/payment/request-network/:paymentId/payout/confirm`, `POST /api/payment/request-network/:paymentId/release/confirm`, and `POST /api/payment/request-network/:paymentId/refund/confirm` are registered in `requestNetworkRoutes.ts` but are stub-level implementations. They accept the request and return a 200 but do not yet drive the ledger-gated release/refund orchestration. Use `POST /api/payment/:id/release` and `POST /api/payment/:id/refund` for actual escrow releases.
## AMN Pay Scanner - Pay-in
AMN Pay Scanner is a custom in-house blockchain scanner that replaces the hosted Request Network page for payment monitoring. It speaks the same `PaymentProviderAdapter` interface as the RN adapter.
### POST /api/payment/amn-scanner/webhook
**Description:** AMN Pay Scanner posts settlement confirmations here. The route verifies a `webhookSecret`-based HMAC signature, then runs the Transaction Safety Provider and `PaymentCoordinator` pipeline identical to the RN webhook path.
**Auth required:** No (signature-protected via `AMN_SCANNER_WEBHOOK_SECRET`)
**Request body:** `{ intentId, status, txHash?, transactionHash?, chainId?, confirmations?, ... }` — scanner-specific envelope. Current scanner payloads usually use `txHash`; `confirmations` may be omitted once the scanner has already waited for the configured threshold.
**Response:** `200` processed; `401` bad signature; `400` missing `intentId` or unknown format; `404` payment not found.
**Side effects:** Same as the RN webhook — updates [[Payment]], advances [[PurchaseRequest]], accepts/rejects offers, emits socket events when safety checks pass. Backend `2.6.82+` treats scanner `status: "confirmed"` as a settlement status for Transaction Safety Provider evaluation and confirmation persistence; if neither verifier evidence nor payload `confirmations` exists, it stores the effective chain threshold so the dashboard does not show a paid scanner transaction with `0` confirmations. Settled confirmation counts are capped at the accepted threshold instead of continuing to grow.
> [!note] Provider value
> Payments created via the AMN Pay Scanner have `provider: 'amn.scanner'` in the database. This is distinct from `request.network` and `shkeeper`.
### GET /api/admin/scanner/status
**Description:** Proxies to `AMN_SCANNER_URL/scanner/status` and returns the scanner's internal state.
**Auth required:** Bearer JWT (`admin`) — `authenticateToken` + `authorizeRoles('admin')` are now applied (the previously documented security gap — unauthenticated access — has been fixed in commit `1d881c5`).
**Response 200:** Scanner status JSON forwarded from the upstream service.
### POST /api/admin/scanner/webhooks/retry
**Description:** Triggers a manual retry of failed/pending scanner webhooks.
**Auth required:** Bearer JWT (`admin`)
**Request body:** `{ intentId?: string }` — omit to retry all pending.
## Legacy SHKeeper - Pay-in
@@ -535,8 +615,8 @@ Escrow state (`escrowState`): `unfunded` → `funded` → `released` | `refunded
{
"success": true,
"data": [
{ "chainId": 56, "threshold": 12, "source": "default" },
{ "chainId": 1, "threshold": 3, "source": "config" }
{ "chainId": 56, "threshold": 200, "source": "default" },
{ "chainId": 1, "threshold": 50, "source": "config" }
]
}
```
@@ -544,18 +624,24 @@ Escrow state (`escrowState`): `unfunded` → `funded` → `released` | `refunded
### `PATCH /api/admin/settings/confirmation-thresholds/:chainId`
**Auth:** Admin only
**Body:** `{ "threshold": 3 }`
**Description:** Updates the runtime confirmation threshold for a chain. The in-memory cache is invalidated immediately so the next `TransactionSafetyProvider` evaluation uses the new value.
**Body:** `{ "threshold": 250 }`
**Description:** Updates the runtime confirmation threshold for a chain. Values below the chain's built-in acceptance floor are clamped to that floor; higher values are allowed. The in-memory cache is invalidated immediately so the next `TransactionSafetyProvider` evaluation uses the effective value.
**Response 200:**
```json
{
"success": true,
"message": "Confirmation threshold for chain 56 updated to 3",
"data": { "chainId": 56, "threshold": 3 }
"message": "Confirmation threshold for chain 56 updated to 250",
"data": { "chainId": 56, "threshold": 250 }
}
```
> ⚠️ **NOT IMPLEMENTED:** `GET /api/admin/settings/confirmation-thresholds/history` does not exist. Only the current-values GET and per-chain PATCH endpoints are implemented.
### `GET /api/admin/settings/confirmation-thresholds/history`
**Auth:** Admin only
**Description:** Returns paginated audit log of past confirmation threshold changes. Each entry records the admin who made the change, old/new threshold values, chain ID, and timestamp. Backed by the `ConfigSettingHistory` Mongoose model added in commit `27fb15a` (task #9).
**Response 200:** `{ success: true, data: [{ chainId, oldThreshold, newThreshold, changedBy, changedAt }] }`
> **Note:** This endpoint was previously documented as NOT IMPLEMENTED. It was added in commit `27fb15a` and is now live at `/api/admin/settings/confirmation-thresholds/history`.
## Payments awaiting confirmation (admin)
@@ -602,7 +688,7 @@ Escrow state (`escrowState`): `unfunded` → `funded` → `released` | `refunded
"blockExplorer": "https://bscscan.com",
"proxyAddress": "0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9",
"nativeCurrency": { "name": "BNB", "symbol": "BNB", "decimals": 18 },
"confirmationThreshold": 12,
"confirmationThreshold": 200,
"tokens": [
{ "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", "symbol": "USDC", "decimals": 18, "name": "Binance-Peg USD Coin" },
{ "address": "0x55d398326f99059ff775485246999027b3197955", "symbol": "USDT", "decimals": 18, "name": "Binance-Peg BSC-USD" }
@@ -613,7 +699,33 @@ Escrow state (`escrowState`): `unfunded` → `funded` → `released` | `refunded
}
```
> ⚠️ **NOT IMPLEMENTED:** `POST /api/admin/rn/networks/reload` and `POST /api/admin/rn/networks/probe/:chainId` do not exist in the codebase.
### `POST /api/admin/rn/networks/reload`
**Auth:** Admin only
**Description:** Reloads the chain and token registries from disk (`supportedChains.json` and `tokens.json`). Returns `{ success: true, message: 'Registry reloaded from disk' }`. Use this after updating the JSON files without restarting the server.
> **Note:** This route is now implemented (commit `5681abf`). Earlier docs incorrectly listed it as not implemented.
### `POST /api/admin/rn/networks/probe/:chainId`
**Auth:** Admin only
**Description:** Performs a live on-chain probe for the specified chain: verifies RPC reachability, checks for deployed proxy contract bytecode (`eth_getCode`), and test-calls the proxy with a dummy payload to confirm it reverts meaningfully. Returns:
```json
{
"success": true,
"data": {
"chainId": 56,
"reachable": true,
"hasCode": true,
"callValid": true,
"blockNumber": "0x...",
"latencyMs": 120
}
}
```
Errors: `400` if `chainId` is not a number; `404` if the chain is not in the registry; `500` on RPC failure.
> **Note:** This route is now implemented (commit `5681abf`). Earlier docs incorrectly listed it as not implemented.
## Related

View File

@@ -0,0 +1,453 @@
---
title: Scanner API
tags: [api, scanner, payment]
created: 2026-05-30
---
# Scanner API
HTTP API exposed by the AMN Pay Scanner microservice. Default port: `8080`.
All endpoints except `/health` require `Authorization: Bearer <SCANNER_API_KEY>` when the key is configured in the environment (production). In dev mode (key not set) all requests are allowed.
Scanner `0.1.8` adds direct-address EVM ERC-20 balance checks and balance watches for non-smart-contract payment rails. Tron/TON direct balance watches are future scope; their existing intent scanners still work through `/intents`.
Base URL (dev): `http://localhost:8080`
---
## Authentication
```
Authorization: Bearer <SCANNER_API_KEY>
```
- Uses constant-time comparison to prevent timing attacks.
- Returns `401 {"error":"unauthorized"}` on failure.
- `/health` is explicitly excluded from auth — always open.
---
## POST /intents
Register a new payment intent. The scanner will watch the specified chain for a matching transfer and call back to `callbackUrl` when confirmed.
**Request body** (`application/json`):
| Field | Type | Required | Notes |
|---|---|---|---|
| `intentId` | string | yes | Caller-supplied unique ID (UUID recommended) |
| `chainId` | integer | yes | Numeric chain ID (e.g. 56, 137, 728126428) |
| `tokenAddress` | string | yes | Token contract address. EVM/Tron: lowercase 0x hex. TON: exact base64url or raw format |
| `destination` | string | yes | Receiving wallet address. EVM/Tron: 0x hex. TON: base64url |
| `amount` | string | yes | Amount in smallest unit (wei / token decimals) as a base-10 integer string |
| `callbackUrl` | string | yes | URL the scanner POSTs to on confirmation |
| `callbackSecret` | string | yes | HMAC key for `X-AMN-Signature` verification |
| `confirmations` | integer | no | Requested confirmation count. The scanner raises it to the chain acceptance floor if lower. |
**Example request:**
```json
{
"intentId": "a1b2c3d4-...",
"chainId": 56,
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"destination": "0xAbCd1234...",
"amount": "10000000000000000000",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"callbackSecret": "abc123...",
"confirmations": 200
}
```
**Response `200 OK`:**
```json
{
"intentId": "a1b2c3d4-...",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"checkoutBlock": {
"destination": "0xabcd1234...",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"chainId": 56,
"proxyAddress": "0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"feeAmount": "0",
"feeAddress": "0x000000000000000000000000000000000000dEaD",
"amountWei": "10000000000000000000"
}
}
```
**Confirmation floor**: Built-in accepted thresholds are currently BSC `200`, Ethereum `50`, Polygon `300`, Arbitrum `2400`, Base `300`, Tron `200`, and TON `120`. Callers may raise a requirement but cannot lower an intent below the chain floor.
**Idempotency**: If `intentId` already exists the existing intent's checkout block is returned (no error).
**Error cases:**
| Status | Body | Cause |
|---|---|---|
| 400 | `{"error":"intentId is required"}` | Missing field |
| 400 | `{"error":"amount must be a positive integer string (base-10 wei)"}` | Non-numeric or zero amount |
| 400 | `{"error":"unsupported chainId: 999"}` | Chain not in supported-chains.json |
| 500 | `{"error":"internal error"}` | DB write failure |
---
## GET /intents/{intentId}
Fetch the current state of a payment intent.
**Response `200 OK`:** Full `Intent` object (see Data Models below).
`callbackSecret` is excluded from the response regardless of auth state.
**Error cases:**
| Status | Body | Cause |
|---|---|---|
| 404 | `{"error":"intent not found"}` | Unknown intentId |
---
## POST /balances/check
Read the current ERC-20 token balance for a public EVM address. The backend uses this for direct-address payment rails, including an initial baseline read when the address is shown to the buyer and a second read when the buyer clicks "I paid".
**Request body** (`application/json`):
| Field | Type | Required | Notes |
|---|---|---|---|
| `chainId` | integer | yes | EVM chain ID configured in `supported-chains.json` |
| `address` | string | yes | Holder address to read |
| `tokenAddress` | string | conditional | ERC-20 contract address. Required unless `token`/`tokenSymbol` resolves in `tokens.json` |
| `token` | string | conditional | Token symbol alias, e.g. `USDT`; same meaning as `tokenSymbol` |
| `tokenSymbol` | string | conditional | Token symbol alias, e.g. `USDT` |
**Example request:**
```json
{
"chainId": 56,
"address": "0x1111111111111111111111111111111111111111",
"token": "USDT"
}
```
**Response `200 OK`:**
```json
{
"chainId": 56,
"chainType": "evm",
"address": "0x1111111111111111111111111111111111111111",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"balance": "25000000000000000000",
"checkedAt": "2026-06-03T10:00:00Z"
}
```
`balance` is a base-unit integer string. It is not formatted into human token units.
**Error cases:**
| Status | Body | Cause |
|---|---|---|
| 400 | `{"error":"chainId is required"}` | Missing chain |
| 400 | `{"error":"balance checks are currently supported for evm chains only"}` | Non-EVM chain |
| 400 | `{"error":"tokenAddress or token is required"}` | No token selector |
| 400 | `{"error":"unsupported token USDT on chainId 999"}` | Token symbol not registered |
| 502 | `{"error":"balance check failed: ..."}` | RPC read failed |
---
## POST /balance-watches
Create or replay a direct-address balance watch. A watch stores the current token balance and polls for changes. When the balance changes, the scanner sends a signed `balance_changed` webhook to `callbackUrl`.
**Request body** (`application/json`):
| Field | Type | Required | Notes |
|---|---|---|---|
| `watchId` | string | no | Caller-supplied idempotency key. If omitted, scanner generates `bw_<hex>` |
| `chainId` | integer | yes | EVM chain ID |
| `address` | string | yes | Address to watch |
| `tokenAddress` | string | conditional | ERC-20 contract address unless `token`/`tokenSymbol` resolves |
| `token` / `tokenSymbol` | string | conditional | Token symbol from `tokens.json` |
| `callbackUrl` | string | yes | Backend webhook URL |
| `callbackSecret` | string | yes | HMAC key for `X-AMN-Signature` |
| `baselineBalance` | string | no | Optional base-unit integer baseline. If omitted, scanner uses the initial balance read |
**Example request:**
```json
{
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"address": "0x1111111111111111111111111111111111111111",
"token": "USDT",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"callbackSecret": "abc123...",
"baselineBalance": "25000000000000000000"
}
```
**Response `200 OK`:**
```json
{
"watch": {
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"chainType": "evm",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"address": "0x1111111111111111111111111111111111111111",
"baselineBalance": "25000000000000000000",
"currentBalance": "25000000000000000000",
"status": "watching",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"nextCheckAt": "2026-06-03T10:05:00Z",
"changeCount": 0,
"expiresAt": "2026-06-10T10:00:00Z",
"createdAt": "2026-06-03T10:00:00Z",
"updatedAt": "2026-06-03T10:00:00Z"
}
}
```
**Idempotency**: Reusing the same `watchId` with the same chain, address, token, and callback returns the existing watch. Reusing it with different parameters returns `409`.
**Cadence**: checks every 5 minutes during the first 24 hours, then 10 minutes until 48 hours, 20 minutes until 72 hours, and 40 minutes until the watch expires after 7 days.
---
## GET /balance-watches/{watchId}
Fetch the current watch state. `callbackSecret` is excluded from the response.
**Response `200 OK`:** `{ "watch": BalanceWatch }`
---
## DELETE /balance-watches/{watchId}
Stop a watch after the backend accepts, cancels, or times out the payment. Stopped watches are not polled.
`POST /balance-watches/{watchId}/stop` is accepted as an equivalent stop command.
**Response `200 OK`:** `{ "watch": BalanceWatch }` with `status: "stopped"`.
---
## GET /scanner/status
Returns scan progress for all verified chains.
**Response `200 OK`:**
```json
{
"chains": [
{
"chainId": 56,
"name": "BSC",
"chainType": "evm",
"lastScannedBlock": 39000000,
"chainHead": 39000015,
"lag": 15,
"pendingIntents": 3,
"activeBalanceWatches": 2
},
{
"chainId": 728126428,
"name": "TRX",
"chainType": "tron",
"lastScannedBlock": 1748500000000,
"chainHead": 1748500015000,
"lag": 15000,
"pendingIntents": 1,
"activeBalanceWatches": 0
},
{
"chainId": 1100,
"name": "TON",
"chainType": "ton",
"lastScannedBlock": 1748500000,
"chainHead": 1748500015,
"lag": 15,
"pendingIntents": 0,
"activeBalanceWatches": 0
}
]
}
```
**Note on lag units**: For EVM and Tron chains, `lag` is in blocks (or ms-timestamp difference). For TON, `lag` is in seconds (Unix timestamps).
---
## POST /admin/webhooks/retry
Immediately trigger a re-delivery attempt for all `webhook_failed` intents. Normally the scanner retries automatically every `WEBHOOK_RETRY_HOURS`; this endpoint forces an immediate pass.
**Response `200 OK`:**
```json
{ "queued": 2 }
```
Each retry is dispatched in a separate goroutine. Success resets the intent status to `confirmed` and records `webhook_delivered_at`.
---
## GET /health
Health check. No authentication required.
**Response `200 OK`:**
```json
{ "status": "ok", "time": "2026-05-30T12:00:00Z" }
```
Used by Docker `HEALTHCHECK` and upstream load balancers / Gatus monitoring.
---
## Webhook delivery (outbound)
When an intent is confirmed the scanner POSTs to `callbackUrl`:
**Headers:**
| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `X-AMN-Signature` | `hex(HMAC-SHA256(body, callbackSecret))` |
| `X-AMN-Delivery-ID` | intentId |
| `X-AMN-Retry` | `true` (only on manual retry via /admin/webhooks/retry) |
**Body:**
```json
{
"intentId": "a1b2c3d4-...",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"txHash": "0xdeadbeef...",
"blockNumber": 39000010,
"confirmations": 200,
"amount": "10000000000000000000",
"token": "0x55d398326f99059ff775485246999027b3197955",
"chainId": 56,
"status": "confirmed"
}
```
`confirmations` is the accepted confirmation count. Once the intent is `confirmed`, the scanner caps this value at `confirmationsRequired`; it does not keep reporting a live, ever-growing block count.
**Retry schedule** (on non-2xx or network error): 5 s → 30 s → 2 min → 10 min → 1 h → `webhook_failed`.
The backend should verify `X-AMN-Signature` to reject forged callbacks:
```js
const expected = createHmac('sha256', callbackSecret).update(rawBody).digest('hex');
if (!timingSafeEqual(Buffer.from(received), Buffer.from(expected))) reject();
```
### Balance watch webhook
When a balance watch observes a changed balance, the scanner POSTs to the watch `callbackUrl`.
**Headers:**
| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `X-AMN-Signature` | `hex(HMAC-SHA256(body, callbackSecret))` |
| `X-AMN-Delivery-ID` | watchId |
| `X-AMN-Event-Type` | `balance_changed` |
**Body:**
```json
{
"eventType": "balance_changed",
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"chainType": "evm",
"address": "0x1111111111111111111111111111111111111111",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"previousBalance": "25000000000000000000",
"currentBalance": "35000000000000000000",
"delta": "10000000000000000000",
"changeCount": 1,
"checkedAt": "2026-06-03T10:05:00Z",
"status": "balance_changed"
}
```
The scanner retries the changed-balance webhook inside the same due-check pass with short backoffs. If delivery still fails, it does not advance `currentBalance`; the same change is retried on the next scheduled due check.
---
## Data models
### Intent object
```json
{
"intentId": "string",
"chainId": 56,
"chainType": "evm",
"tokenAddress": "0x...",
"destination": "0x...",
"amount": "10000000000000000000",
"paymentReference": "0x1a2b3c4d",
"topicRef": "0xdeadbeef...",
"status": "pending | confirming | confirmed | expired | webhook_failed",
"confirmationsRequired": 200,
"txHash": null,
"logIndex": null,
"blockNumber": null,
"confirmations": 0,
"salt": "hex64chars",
"webhookDeliveredAt": null,
"createdAt": "2026-05-30T10:00:00Z",
"updatedAt": "2026-05-30T10:00:00Z"
}
```
Note: `callbackUrl` and `callbackSecret` are present in the DB but `callbackSecret` is always omitted from API responses.
### BalanceWatch object
```json
{
"watchId": "string",
"chainId": 56,
"chainType": "evm",
"tokenAddress": "0x...",
"tokenSymbol": "USDT",
"decimals": 18,
"address": "0x...",
"baselineBalance": "25000000000000000000",
"currentBalance": "25000000000000000000",
"status": "watching | stopped | expired",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"lastCheckedAt": null,
"nextCheckAt": "2026-06-03T10:05:00Z",
"changeCount": 0,
"lastNotifiedAt": null,
"expiresAt": "2026-06-10T10:00:00Z",
"createdAt": "2026-06-03T10:00:00Z",
"updatedAt": "2026-06-03T10:00:00Z"
}
```

View File

@@ -0,0 +1,459 @@
---
title: Tenant API
tags: [api, tenant, white-label, storefront, reference]
aliases: [White-Label API, Storefront API, Merchant API]
---
# Tenant API
> **Last updated:** 2026-06-10 — current `feature/white-label-shops` scan.
> Related: [[Tenant]], [[PRD - Seller-Owned White-Label Shops and Bots]], [[Authentication API]]
Three route groups:
| Mount | File | Auth | Description |
| --- | --- | --- | --- |
| `/api/tenants` | `backend/src/routes/tenantRoutes.ts` | Required (JWT) | Admin + tenant-owner management surface. |
| `/api/storefront` | `backend/src/routes/storefrontRoutes.ts` | None (public) | Public storefront surface — tenant resolved from `Host` header. |
| `/api/telegram` | `backend/src/routes/tenantWebhookRoutes.ts` | Telegram webhook secret header | Tenant-bot webhook receiver for claim activation. |
---
## Authentication and authorization
All `/api/tenants/*` routes require `Authorization: Bearer <jwt>` via `authenticateToken`.
Three authorization tiers:
| Tier | How enforced | Who |
| --- | --- | --- |
| Platform admin | `authorizeRoles('admin')` middleware | Users with `role = 'admin'` |
| Tenant role | `requireTenantRole(...roles)` middleware | Users with a matching row in `tenant_user_roles` for the target tenant |
| Self (tenant creation) | Inline check | Any authenticated user (creates for themselves) |
`requireTenantRole` looks up `tenant_user_roles` by `(tenantId, req.user.id)`. It passes if the user's role is in the allowed list **or** if the user is a platform admin.
`/api/storefront/*` routes are fully public — no `authenticateToken`. Tenant identity comes from the `Host` header only, never from the request body.
---
## Storefront routes — `GET /api/storefront/...`
### `GET /api/storefront/bootstrap`
Resolves the tenant from the `Host` header and returns the bootstrap payload for the frontend `TenantProvider`.
**Auth:** None.
**Middleware:** `tenantResolutionMiddleware``storefrontRateLimiter` (120 req/min per tenant+IP).
**Response 200:**
```json
{
"success": true,
"data": {
"tenantId": "uuid",
"slug": "myshop",
"shopId": "uuid",
"brand": {
"name": "My Shop",
"logoUrl": "https://cdn.example.com/logo.png",
"primaryColor": "#1F6FEB",
"supportEmail": "support@example.com"
},
"features": {
"escrowCheckout": true,
"directCheckout": false,
"externalPayments": false,
"telegramMiniApp": false
},
"paymentRails": ["amn_escrow"],
"localeDefaults": ["en", "fa"]
}
}
```
**Response 404:** Host does not match any active tenant or domain.
> [!note] Amanat default
> The frontend `TenantProvider` treats a 404 as "no tenant for this host" and falls back to Amanat platform defaults — this is not an error condition for the frontend.
---
### `GET /api/storefront/t/:slug/bootstrap`
Preview-only bootstrap by tenant slug. Allowed only when the request arrives from the platform base domain (`amn.gg`, `localhost`). Owner can preview a `pending` tenant this way.
**Auth:** None.
**Restrictions:** Returns `403 PREVIEW_FORBIDDEN` if the `Host` is not the platform base domain.
---
### `GET /api/storefront/catalog` *(Phase 2 stub)*
### `POST /api/storefront/checkout` *(Phase 2 stub)*
### `GET /api/storefront/orders/:orderId` *(Phase 2 stub)*
All return `501 NOT_IMPLEMENTED`. Namespace reserved.
---
## Tenant management routes — `/api/tenants/...`
### `POST /api/tenants` — create tenant
Any authenticated user may create a tenant for themselves (`ownerUserId = req.user.id`). Only platform admins may supply a different `ownerUserId`.
Created tenants start with `status = 'pending'`. A platform admin must call `POST /activate` before the tenant becomes publicly accessible.
**Auth:** `authenticateToken`.
**Request body:**
```json
{
"slug": "myshop",
"displayName": "My Shop",
"type": "hosted_seller",
"brand": { "name": "My Shop", "primaryColor": "#1F6FEB" },
"features": { "escrowCheckout": true },
"localeDefaults": ["en"]
}
```
| Field | Required | Description |
| --- | --- | --- |
| `slug` | yes | URL-safe identifier `[a-z0-9-]{3,40}`. Lowercased automatically. |
| `displayName` | yes | Human-readable shop name. |
| `type` | no | One of `hosted_seller`, `white_label`, `isolated`, `enterprise`. Default `hosted_seller`. |
| `brand` | no | `{ name?, logoUrl?, primaryColor?, supportEmail? }` |
| `features` | no | Feature flag overrides. |
| `localeDefaults` | no | Default `['en']`. |
| `ownerUserId` | no | Admin-only override. |
**Response 201:** Created tenant record.
**Response 409 `TENANT_SLUG_TAKEN`:** Slug already in use.
Side effects on create:
1. Auto-grants `owner` role to the creating user (`tenant_user_roles`).
2. Seeds a default `tenant_payment_policies` row with `allowedRails: ['amn_escrow']`.
---
### `GET /api/tenants` — list tenants
Platform admins only.
**Auth:** `authenticateToken` + `authorizeRoles('admin')`.
**Query params:**
| Param | Description |
| --- | --- |
| `status` | Filter by `tenantStatus` enum value. |
| `type` | Filter by `tenantType` enum value. |
| `page` | Page number (default 1). |
| `limit` | Page size (default 20). |
**Response 200:** `{ data: { tenants: Tenant[], total: number } }`
---
### `GET /api/tenants/:tenantId` — get tenant
**Auth:** `authenticateToken` + any tenant role.
**Response 200:** Full tenant record (no secrets).
**Response 404 `TENANT_NOT_FOUND`**
---
### `GET /api/tenants/:tenantId/bootstrap` — authenticated bootstrap
Same payload shape as the storefront route, but requires authentication and a tenant role. Used by the merchant admin dashboard.
**Auth:** `authenticateToken` + any tenant role.
---
### `PATCH /api/tenants/:tenantId` — update tenant
**Auth:** `authenticateToken` + tenant role `owner`.
**Request body** (all optional):
```json
{
"displayName": "Updated Shop Name",
"brand": { "primaryColor": "#FF6B35" },
"features": { "telegramMiniApp": true },
"localeDefaults": ["en", "fa"]
}
```
**Response 200:** Updated tenant record.
---
### `POST /api/tenants/:tenantId/suspend` — suspend tenant
Platform admins only. Sets `status = 'suspended'`.
**Auth:** `authenticateToken` + `authorizeRoles('admin')`.
---
### `POST /api/tenants/:tenantId/activate` — activate tenant
Platform admins only. Sets `status = 'active'`. A new tenant must be activated before it is publicly accessible.
**Auth:** `authenticateToken` + `authorizeRoles('admin')`.
---
### `POST /api/tenants/:tenantId/domains` — add custom domain
**Auth:** `authenticateToken` + tenant role `owner`.
**Request body:**
```json
{
"hostname": "shop.example.com",
"mode": "cname"
}
```
| Field | Required | Description |
| --- | --- | --- |
| `hostname` | yes | Full hostname to register. Must be globally unique. |
| `mode` | no | `cname` (default) or `managed_ns`. |
**Response 201:** Domain record including `verificationToken` (the merchant uses this for DNS proof).
**Response 400:** `hostname` missing.
Domain starts with `status = 'pending'`. The tenant admin can trigger verification manually, and the backend domain poller retries pending domains on an interval. DNS can point either directly at the configured server IP or by CNAME to the configured Caddy target.
---
### `POST /api/tenants/:tenantId/domains/:domainId/verify` — verify DNS and provision route
Checks whether the hostname resolves to the multi-stack ingress. If DNS passes, the backend adds an idempotent Caddy Admin API route for the hostname and marks the domain `active` with `tlsStatus = 'pending'`.
**Auth:** `authenticateToken` + tenant role `owner` or `developer`.
**Response 200:**
```json
{
"success": true,
"data": { "...": "updated domain" },
"meta": { "dnsVerified": true },
"statusCode": 200
}
```
If DNS is not ready yet, the response still succeeds with `dnsVerified: false` and the domain remains `pending`.
---
### `POST /api/tenants/:tenantId/domains/:domainId/tls-check` — check TLS status
Probes HTTPS for an active domain and updates `tlsStatus` to `issued`, `pending`, or `failed`.
**Auth:** `authenticateToken` + tenant role `owner` or `developer`.
**Response 400 `DOMAIN_NOT_ACTIVE`:** Domain must be `active` before TLS can be checked.
---
### `DELETE /api/tenants/:tenantId/domains/:domainId` — remove domain
Deprovisions the Caddy route and marks the domain `suspended` with `tlsStatus = 'expired'`.
**Auth:** `authenticateToken` + tenant role `owner`.
**Response 200:** `{ "data": { "removed": true } }`
---
### `GET /api/tenants/:tenantId/domains` — list domains
**Auth:** `authenticateToken` + any tenant role.
**Response 200:** Array of `TenantDomain` records.
---
### `POST /api/tenants/:tenantId/telegram/bot` — register Telegram bot
Stores the encrypted bot token, derives `telegramBotId` from the token prefix, resolves the username via Telegram `getMe` when not supplied, registers the tenant webhook when `APP_URL` or `FRONTEND_URL` is configured, and attempts to set the bot chat menu button to the tenant Mini App URL.
**Auth:** `authenticateToken` + tenant role `owner` or `developer`.
**Request body:**
```json
{
"botToken": "123456789:AAABB...",
"username": "MyShopBot",
"miniAppUrl": "https://myshop.amn.gg"
}
```
| Field | Required | Description |
| --- | --- | --- |
| `botToken` | yes | BotFather token. Must use `<numeric_id>:<secret>` format. Stored AES-256-GCM encrypted — never returned in responses. |
| `username` | no | Bot @username without `@`. If omitted, backend calls Telegram `getMe` and stores the returned username when available. |
| `miniAppUrl` | no | Tenant storefront base URL. If omitted, backend derives `https://<tenantSlug>.<TENANT_BASE_DOMAIN>`. The menu button opens `<miniAppUrl>/telegram/`. |
**Response 201:** Public bot record (no encrypted token fields and no webhook secret). Pending bots include `claimUrl`.
> [!warning] Token handling
> `botToken` in the request body is write-only. The API never returns it. Keep it out of logs.
---
### `GET /api/tenants/:tenantId/telegram/bot/:botId/claim-link` — get bot claim URL
Returns the pending bot's Telegram deep link:
```json
{ "success": true, "data": { "claimUrl": "https://t.me/MyShopBot?start=..." } }
```
**Auth:** `authenticateToken` + tenant role `owner` or `developer`.
---
### `DELETE /api/tenants/:tenantId/telegram/bot/:botId` — remove bot
Physically deletes the bot row after verifying it belongs to the tenant.
**Auth:** `authenticateToken` + tenant role `owner` or `developer`.
**Response 200:** `{ "data": { "removed": true } }`
---
### `POST /api/telegram/tenant-webhook/:botId` — tenant Telegram webhook
Unauthenticated public endpoint mounted before global auth/rate-limit middleware. Telegram must send `X-Telegram-Bot-Api-Secret-Token`; the route compares it to the stored `webhookSecret`.
Current handled update:
| Update | Behavior |
| --- | --- |
| `/start <claimToken>` on a pending bot | Calls `tenantBotService.claimAdmin()`, stores `adminTelegramUserId`, flips bot status to `active`, and sends a confirmation message to the claimant. |
| Any other valid update | Acknowledged with `200 { ok: true }` and ignored for now. |
---
### `GET /api/tenants/:tenantId/telegram/bots` — list bots
**Auth:** `authenticateToken` + any tenant role.
**Response 200:** Array of public bot records. Secret fields are excluded. Each pending bot may include `claimUrl`.
| Public bot field | Description |
| --- | --- |
| `id` | Internal bot row id. |
| `tenantId` | Owning tenant id. |
| `telegramBotId` | Numeric Telegram bot id as text. |
| `username` | Bot username without `@`. |
| `status` | `pending`, `active`, `suspended`, or `revoked`. |
| `miniAppUrl` | Stored Mini App base URL, if supplied. |
| `claimUrl` | Derived Telegram deep link while pending; `null` after claim. |
| `adminTelegramUserId` | Telegram user id that claimed admin control, if any. |
> [!warning] Bot token handling
> `encryptedToken`, `encryptedTokenIv`, `encryptedTokenTag`, and `webhookSecret` never appear in route responses.
---
### `PUT /api/tenants/:tenantId/payment-policy` — upsert payment policy
Idempotent — creates or replaces the single policy row for the tenant.
**Auth:** `authenticateToken` + tenant role `owner` or `finance`.
**Request body:**
```json
{
"allowedRails": ["amn_escrow", "amn_direct"],
"defaultRail": "amn_escrow",
"buyerDisclosureMode": "strict",
"escrowRequiredAboveAmount": "500.000000000000000000",
"escrowRequiredForCategories": ["digital-goods"]
}
```
`defaultRail` must be a member of `allowedRails` — returns `400 VALIDATION_ERROR` if not.
**Response 200:** Policy record.
---
### `GET /api/tenants/:tenantId/payment-policy` — get payment policy
**Auth:** `authenticateToken` + any tenant role.
**Response 200:** Policy record or `null` if none exists yet.
---
### `POST /api/tenants/:tenantId/roles` — grant role
**Auth:** `authenticateToken` + tenant role `owner`.
**Request body:**
```json
{ "userId": "uuid", "role": "manager" }
```
**Response 201:** Role grant record.
---
### `DELETE /api/tenants/:tenantId/roles` — revoke role
**Auth:** `authenticateToken` + tenant role `owner`.
**Request body:**
```json
{ "userId": "uuid", "role": "manager" }
```
**Response 200:** `{ "data": { "removed": true } }`
---
## Error codes
| Code | HTTP | Meaning |
| --- | --- | --- |
| `TENANT_SLUG_TAKEN` | 409 | Slug already registered. |
| `TENANT_SLUG_INVALID` | 400 | Slug does not match `[a-z0-9-]{3,40}`. |
| `TENANT_NOT_FOUND` | 404 | No tenant with that id / slug / host. |
| `PREVIEW_FORBIDDEN` | 403 | Slug preview requested from a non-platform host. |
| `DOMAIN_NOT_FOUND` | 404 | Domain id does not exist or is not owned by the tenant. |
| `DOMAIN_NOT_ACTIVE` | 400 | TLS check requested before domain status is `active`. |
| `VALIDATION_ERROR` | 400 | Missing required field or invalid value (e.g. `defaultRail ∉ allowedRails`). |
| `RATE_LIMIT_EXCEEDED` | 429 | Storefront rate limiter: 120 req/min per tenant+IP. |
---
## Tenant resolution middleware
`tenantResolutionMiddleware` (`backend/src/shared/middleware/tenantResolution.ts`) runs on every storefront route. It attaches `req.tenant` and `req.tenantDomain` (when a custom domain matched).
Resolution order:
1. **Preview** — Host is platform base (`amn.gg`, `localhost`) **and** `req.params.slug` or `req.query.t` is present → `resolveTenantBySlug(slug, { previewOnly: true })`.
2. **Subdomain** — Host ends with `.amn.gg` (single label only, e.g. `seller.amn.gg`) → `resolveTenantByHost` → slug lookup.
3. **Custom domain** — Any other host → `resolveTenantByHost``findByHostname`.
On no match, `req.tenant` is `undefined` and the route handler returns 404.
> [!important] Security invariants
> - Never reads `X-Tenant-ID` or any client-supplied header.
> - Only resolves preview by slug when the `Host` is the platform base.
> - Fail-open: resolution errors call `next()` without crashing the request.
Related: [[Tenant]], [[Authentication API]], [[PRD - Seller-Owned White-Label Shops and Bots]].

View File

@@ -3,7 +3,7 @@ title: Trezor API
tags: [api, payments, trezor, safekeeping]
---
> **Last updated:** 2026-05-29aligned with code (see Doc vs Code Audit Report)
> **Last updated:** 2026-05-30break-glass mode added (commit `b21df25`)
# Trezor API
@@ -17,6 +17,12 @@ TREZOR_SAFEKEEPING_REQUIRED=false
Only the literal value `true` makes Trezor proof mandatory during release/refund confirmation. When unset or `false`, release/refund flows continue without Trezor proof.
## Break-glass mode
When `TREZOR_SAFEKEEPING_REQUIRED=true` and the Trezor is unavailable (lost, dead battery, etc.), an admin can activate break-glass mode to bypass Trezor for up to 1 hour. Break-glass state is in-memory only and resets on server restart.
See [[Admin API]] — _Break-glass (Trezor bypass)_ section for the three management endpoints (`GET`, `POST`, `DELETE /api/admin/settings/break-glass`). Activating break-glass fires an immediate Telegram alert via `tgNotify`.
## GET /api/trezor/registration-message
Builds the exact message the user must sign to register a Trezor xpub.

View File

@@ -179,11 +179,9 @@ The legacy alias `PATCH /api/users/wallet-address` performs the same logic.
>
> ⚠️ **The frontend consistently calls the PLURAL `/api/users/admin/*`** (see `frontend/src/lib/axios.ts`, all paths under `endpoints.users.admin.*`). So the singular create/delete/status/role/list paths below are *documented*, but in practice the frontend hits the legacy plural group. Both are listed; treat the plural group as the frontend-effective reality.
>
> ⚠️ **Note on HTTP verbs (KNOWN BUG):** The frontend `updateUserStatus` and `updateUserRole` calls (`frontend/src/actions/user.ts`) use **`PUT`** (`PUT /api/users/admin/:id/status`, `PUT /api/users/admin/:id/role`). The backend registers these as **`PATCH`** only (both the legacy and new routers). The verbs do not match — treat `PATCH` as the authoritative backend verb; the `PUT` calls will not route.
> **Since backend `14c231e` (v2.8.50):** `toggle-status` and `dependencies` are also reachable under the plural prefix (`/api/users/admin/:userId/toggle-status`, `/api/users/admin/:userId/dependencies`) — the legacy router delegates them to the new controller, so the frontend's plural calls now route.
>
> ⚠️ **Note on status values (KNOWN BUG):** The frontend `updateUserStatus` TypeScript type is `'active' | 'inactive' | 'pending'`. The backend `User.status` enum is `'active' | 'suspended' | 'deleted'`. So:
> - `'inactive'` and `'pending'` are **rejected/ignored** by the backend (the new controller only applies `status` when it is one of `active`/`suspended`/`deleted`).
> - `'suspended'` — the actually-usable suspend value — is **missing from the frontend type**, so the admin UI cannot send it.
> **Fixed (frontend `d7a2a86`, v2.8.50):** the old PUT-verb and `{status: 'inactive'}` mismatches are gone — `updateUserStatus` now sends `PATCH` with `{ isActive: boolean }`, which is what the legacy plural status route reads.
### POST /api/user/admin/create

View File

@@ -37,7 +37,8 @@ End-to-end specification for **email + password** authentication, JWT issuance,
2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error.
3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout.
4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`.
5. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). The counter is incremented on **every login attempt** (before password comparison), not only on failures. Once 5 total attempts accumulate within a 15-minute window, the endpoint returns `429 TOO_MANY_ATTEMPTS`. The counter is reset upon a fully successful login (step 9). Counters live in Redis so they survive restarts.
5. **Cloudflare Turnstile CAPTCHA gate** (`captchaGate` middleware, commit `b8edbbf`): Before the rate-limiter runs, `captchaGate` checks the in-memory failure counter for the caller's IP. If that IP has accumulated **3 or more failed login attempts** within 15 minutes, a valid `cf-turnstile-response` token must be present in the request body. Without it the endpoint returns `429 { captchaRequired: true }`. If `TURNSTILE_SECRET_KEY` is not set (local dev), the gate is skipped. On CAPTCHA pass, the middleware calls Cloudflare's `siteverify` endpoint to validate the token before proceeding.
5a. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). The counter is incremented on **every login attempt** (before password comparison), not only on failures. Once 5 total attempts accumulate within a 15-minute window, the endpoint returns `429 TOO_MANY_ATTEMPTS`. The counter is reset upon a fully successful login (step 9). Counters live in Redis so they survive restarts.
6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")``password` is `select: false` by default in the schema and must be explicitly projected.
7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design.
8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`.

View File

@@ -7,7 +7,7 @@ related_apis: ["POST /api/marketplace/purchase-requests/:id/delivery-code/genera
# Delivery Confirmation Flow
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-06-06 — buyer fast-track confirmation is buyer/admin-only and uses cross-store id matching.
After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escrow Flow]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **generates and reads out the delivery code**, the seller **verifies the code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]).
@@ -40,7 +40,7 @@ After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escr
- On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`.
- Emits `purchase-request-update` `status-changed`.
- Sends delivery-confirmed notifications to both buyer and seller directly within `DeliveryService.verifyDeliveryCode`.
6. **Alternative path — buyer fast-track** — the buyer can also call `PATCH .../confirm-delivery` to set status to `delivered` without any code (used when the code path fails, e.g. code expired or lost). This endpoint emits only `purchase-request-update` with `status-changed` — it does **not** send delivery-specific notifications to either party. **⚠️ Authorization gap:** this endpoint currently has no authorization check; any authenticated user can call it.
6. **Alternative path — buyer fast-track** — the buyer can also call `PATCH .../confirm-delivery` to set status to `delivered` without any code (used when the code path fails, e.g. code expired or lost). The endpoint is buyer/admin-only and uses the same cross-store `sameUser()` id comparison as the seller delivery gates, so legacy ObjectId sessions and Postgres UUID request rows compare correctly. It emits only `purchase-request-update` with `status-changed` — it does **not** send delivery-specific notifications to either party.
7. **Optional auto-release timer** — once `status === 'delivered'`, a scheduled job can flip the request to `confirming` and then to `seller_paid` after a grace period (e.g. 48h). The auto-release worker is not yet implemented; today an admin completes the chain via [[Payout Flow]].
## Sequence diagram
@@ -87,7 +87,7 @@ sequenceDiagram
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/generate` | Buyer generates delivery code (buyer only) |
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/verify` | Seller verifies code (seller only) |
| `GET` | `/api/marketplace/purchase-requests/:id/delivery-code/status` | Check code status (buyer + seller) |
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) — ⚠️ no auth check, no delivery notifications |
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer/admin fast-track confirm (no code); no delivery-specific notifications |
### Phantom frontend actions (routes do NOT exist on backend)
@@ -102,7 +102,7 @@ These Redux/API actions exist in the frontend but call endpoints that return 404
## Two paths to `delivered` status
1. **Code path** — seller calls `POST .../delivery-code/verify` with the correct, unexpired code → status becomes `delivered`. Both buyer and seller receive delivery-confirmed notifications (sent by `DeliveryService.verifyDeliveryCode`).
2. **Fast-track path** — buyer calls `PATCH .../confirm-delivery` (no code required) → also becomes `delivered`. ⚠️ Currently no authorization check on this endpoint, and no delivery-specific notifications are sent to either party.
2. **Fast-track path** — buyer calls `PATCH .../confirm-delivery` (no code required) → also becomes `delivered`. Authorization is buyer/admin-only, with cross-store id matching for legacy ObjectId/PG UUID seams. No delivery-specific notifications are sent to either party.
## Database writes

View File

@@ -12,7 +12,8 @@ audit: "2026-05-29 — corrected against source: Dispute.ts, DisputeService.ts,
When something goes wrong (item not delivered, wrong item, seller misbehaviour), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — selecting an action such as refund, replacement, compensation, warning, or ban.
> [!danger] SECURITY — Three open privilege-escalation bugs exist as of this audit. See [Security Gaps](#security-gaps) below.
> [!success] Security fixes applied (2026-05-30)
> The three privilege-escalation bugs documented in the original Security Gaps section were fixed in commit `1d881c5` (ISSUE-003, ISSUE-004) and `fce8a19` (resolver role). Role guards are now enforced on assign/status/resolve; route shadowing is eliminated by remounting the release-hold router at `/api/disputes/pr`. See [Security Gaps](#security-gaps) for the historical record and current state.
> [!warning] Real-time events not implemented
> Every Socket.IO emit in `DisputeService` is currently commented out. No `dispute-updated`, `new-notification`, or any other socket event fires for dispute creation, admin assignment, status changes, evidence uploads, or resolution. The dispute feature is CRUD-only at this stage.
@@ -23,7 +24,8 @@ When something goes wrong (item not delivered, wrong item, seller misbehaviour),
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
- **Admin / Mediator** — assigned to investigate.
- **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard.
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts` (mounted first at `/api/disputes`), and release-hold helpers in `backend/src/services/dispute/disputeRoutes.ts` (mounted second at `/api/disputes`).
- **Admin / Mediator** — assigned to investigate (role `admin` or `resolver`).
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts` (mounted at `/api/disputes`), and release-hold helpers in `backend/src/services/dispute/disputeRoutes.ts` (mounted at `/api/disputes/pr` since commit `1d881c5`).
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
- **Socket.IO** — no events fire today; all emits are TODO stubs (see warning above).
@@ -78,59 +80,40 @@ Valid values: `product_quality | delivery_delay | wrong_item | payment_issue | s
---
## Security Gaps
## Security Gaps (Historical — All Closed as of 2026-05-30)
### 1. `PATCH /api/disputes/:id/status` — no role guard
The following bugs were identified in the 2026-05-29 audit and fixed in commits `1d881c5` and `fce8a19`. The descriptions below are preserved for historical reference and audit trail.
**File:** `backend/src/routes/disputeRoutes.ts` line 26
### 1. `PATCH /api/disputes/:id/status` — no role guard ✅ FIXED
```ts
router.patch('/:id/status', DisputeController.updateStatus);
```
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can change dispute status.
Despite comments in the router saying "admin only", there is **no `authorizeRoles` middleware**. Any authenticated buyer or seller can call this endpoint and change a dispute's status to `resolved` or `closed`, bypassing the admin resolution flow entirely. This is an open privilege-escalation bug.
### 2. `POST /api/disputes/:id/resolve` (dashboard router) — no role guard ✅ FIXED
### 2. `POST /api/disputes/:id/resolve` (dashboard router) — no role guard
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can resolve disputes.
**File:** `backend/src/routes/disputeRoutes.ts` line 29
**Additional fix (ISSUE-004, commit `1d881c5`):** `DisputeService.resolveDispute` now calls `releaseHoldResolve()` on the linked `purchaseRequestId`, clearing the escrow hold automatically so the payment release is unblocked after resolution.
```ts
router.post('/:id/resolve', DisputeController.resolveDispute);
```
### 3. `POST /api/disputes/:id/assign` — no role guard ✅ FIXED
No role guard. Any authenticated user can post a resolution — including `action: 'ban_seller'`. Note that the **release-hold router's** `POST /:purchaseRequestId/resolve` (`backend/src/services/dispute/disputeRoutes.ts` line 77) **does** correctly apply `authorizeRoles('admin')`. The dashboard router's resolve endpoint does not.
### 3. `POST /api/disputes/:id/assign` — no role guard
**File:** `backend/src/routes/disputeRoutes.ts` line 23
```ts
router.post('/:id/assign', DisputeController.assignAdmin);
```
Any authenticated user can call this with their own user ID in `{ adminId }` and self-assign as mediator for any dispute.
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can assign mediators.
---
## Route Shadowing
## Route Shadowing (Historical — Resolved as of 2026-05-30)
Both routers are mounted at `/api/disputes` in `app.ts`:
Previously both routers were mounted at `/api/disputes`, causing the dashboard router to intercept release-hold requests. Fixed in commit `1d881c5` (ISSUE-003):
```ts
// app.ts line 521 — mounted FIRST
// app.ts — current state
app.use("/api/disputes", dashboardDisputeRoutes); // src/routes/disputeRoutes.ts
// app.ts line 585 — mounted SECOND
app.use("/api/disputes", disputeRoutes); // src/services/dispute/disputeRoutes.ts
app.use("/api/disputes/pr", disputeRoutes); // src/services/dispute/disputeRoutes.ts — new prefix
```
Express evaluates routes in registration order. This creates two concrete hazards:
1. **`POST /api/disputes/:id/resolve`** — the dashboard router (mounted first) exposes `POST /:id/resolve` with no role guard. A request intended for the release-hold router's `POST /:purchaseRequestId/resolve` (which **does** require admin) will be intercepted and handled by the wrong, unguarded handler when a matching dispute `_id` is supplied.
2. **`POST /api/disputes/:purchaseRequestId/raise`** — this route exists only in the second (release-hold) router. It will be reached correctly only if the dashboard router does not first match the path. Since the dashboard router has no `/raise` route, requests pass through. However, as more routes are added to either router, collisions will grow silently.
**Recommendation:** Separate the two routers onto distinct path prefixes (e.g. `/api/disputes` for the dashboard controller, `/api/disputes/hold` for the release-hold service).
Release-hold endpoints now use the `/api/disputes/pr/` prefix:
- `POST /api/disputes/pr/:purchaseRequestId/raise`
- `GET /api/disputes/pr/:purchaseRequestId/status`
- `POST /api/disputes/pr/:purchaseRequestId/resolve`
---
@@ -171,7 +154,7 @@ Express evaluates routes in registration order. This creates two concrete hazard
9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence``DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`. **No socket event fires for evidence uploads.**
10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`. **No socket event fires.**
> [!danger] `PATCH /api/disputes/:id/status` has no role guard — any authenticated user can change dispute status (see [Security Gaps](#security-gaps)).
> [!note] `PATCH /api/disputes/:id/status` now requires `admin` or `resolver` role (`authorizeRoles('admin', 'resolver')`, commit `fce8a19`). The previously open privilege-escalation gap is closed.
### Phase 4 — Resolution
@@ -190,10 +173,11 @@ Express evaluates routes in registration order. This creates two concrete hazard
- `dispute.closedAt = now`
- Appends `timeline` entry `dispute_resolved`.
- Saves.
- **Calls `releaseHoldResolve(purchaseRequestId)`** — this clears the escrow hold automatically so the payment release is unblocked (ISSUE-004 fix, commit `1d881c5`).
- **No socket event fires.** (`// TODO: Send notifications via Socket.IO`)
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **release** ([[Payout Flow]] / [[Escrow Flow]]) or the **refund** as a separate step. The dispute service records the resolution; full automatic dispatch through the release/refund policy engine is still a hardening item.
13. **Financial side-effect:** as of commit `1d881c5` the escrow hold is cleared automatically on resolution. The admin still needs to separately trigger the ledger-gated release ([[Payout Flow]] / [[Escrow Flow]]) or refund for actual fund movement.
> [!danger] `POST /api/disputes/:id/resolve` (dashboard router) has no role guard — any authenticated user can post any resolution action including `ban_seller` (see [Security Gaps](#security-gaps)).
> [!note] `POST /api/disputes/:id/resolve` now requires `admin` or `resolver` role (`authorizeRoles('admin', 'resolver')`, commit `fce8a19`). The previously open privilege-escalation gap is closed.
---

View File

@@ -0,0 +1,292 @@
---
title: Payment Flow - Scanner (In-House)
tags: [flow, scanner, payment]
created: 2026-05-30
---
# Payment Flow — AMN Pay Scanner (In-House)
> **Last updated:** 2026-06-06 — documented frontend/backend `2.8.118` BSC Testnet checkout UI support.
End-to-end payment flow using the in-house AMN Pay Scanner, replacing the Request Network integration. The scanner is a separate microservice; the backend talks to it over an internal HTTP API.
See also: [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md), [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md), [PRD - Direct Address Token Payments via Scanner Balance Watches](../PRD%20-%20Direct%20Address%20Token%20Payments%20via%20Scanner%20Balance%20Watches.md)
---
## 1. High-level sequence
```
Buyer Backend Scanner Chain
│ │ │ │
│ initiate payment │ │ │
│────────────────────►│ │ │
│ │ POST /intents │ │
│ │───────────────────►│ │
│ │ 200 checkoutBlock │ │
│ │◄───────────────────│ │
│ checkoutBlock │ │ │
│◄────────────────────│ │ │
│ │ │ │
│ sign + submit tx ──────────────────────────────────────►│
│ │ │ (polling) │
│ │ │◄────────────────│
│ │ │ log matched │
│ │ │ confirmations… │
│ │◄───────────────────│ │
│ │ POST callbackUrl │ │
│ │ (webhook) │ │
│ │ │ │
│ payment confirmed │ │ │
│◄────────────────────│ │ │
```
---
## 2. Step-by-step
### Step 1 — Backend creates an intent
When the buyer chooses a payment method (e.g. USDT on BSC), the backend calls:
```
POST http://scanner:8080/intents
Authorization: Bearer <SCANNER_API_KEY>
{
"intentId": "<payment._id>",
"chainId": 56,
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"destination": "0xSellerWalletAddress",
"amount": "10000000000000000000",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"callbackSecret": "<per-intent HMAC secret stored in payment doc>",
"confirmations": 200
}
```
The scanner responds with a `checkoutBlock` that the backend passes to the frontend.
#### BSC Testnet test rail
For dev end-to-end testing, backend and scanner must keep the chain 97 registry in sync:
| Field | Value |
|---|---|
| Chain id | `97` |
| Network aliases | `bsc-testnet`, `bnb-testnet`, `bsctest`, `bsc_testnet`, `binance-testnet`, `bnbt`, numeric string `97` |
| RPC fallback | `https://bsc-testnet-rpc.publicnode.com` |
| USDT test token | `0x109F54Dab34426D5477986b0460aE5dFBA65f022` |
| USDC test token | `0x64544969ed7EBf5f083679233325356EbE738930` |
| Token decimals | `18` for USDT and USDC |
| Default confirmation floor | `5` |
Backend `2.8.116+` uses this same token address in both the Request Network/scanner intent registry and the legacy `BSCTransactionVerifier` path. This matters because `/api/payment/request-network/intents` resolves the buyer's selected chain/token before asking scanner to watch, while older wallet-direct verification endpoints call `BSCTransactionVerifier.verifyTransfer()` directly. A mismatch between these two registries will either create scanner intents for a token that the buyer does not pay, or verify the wrong ERC-20 contract after payment.
Backend `2.8.116+` also passes an explicit `scannerContext` (`paymentId`, `chainId`, `tokenSymbol`, `tokenAddress`, `destination`) into AMN scanner intent registration. This prevents PG-only or partially hydrated payment reads from falling back to the global merchant reference and creating mainnet/default-style scanner intents such as `undefined-c56-USDC`.
If a live dev stack still waits for `200` confirmations on chain 97, check the admin runtime setting `confirmation_threshold:97`. Built-in default is now `5`, but a previously persisted admin value above the floor still wins until updated.
### Step 2 — Frontend shows checkout
The `checkoutBlock` contains everything the frontend needs to build the `ERC20FeeProxy.transferWithReferenceAndFee` calldata:
| Field | Used for |
|---|---|
| `proxyAddress` | contract to call |
| `tokenAddress` | ERC20 token |
| `destination` | `_to` param |
| `paymentReference` | `_paymentReference` param (8-byte reference) |
| `amountWei` | `_amount` param |
| `feeAmount` | `_feeAmount` param (always `"0"` currently) |
| `feeAddress` | `_feeAddress` param (always dead address) |
For Tron/TON the buyer sends a plain TRC20/Jetton transfer to `destination`; there is no proxy contract.
Frontend `2.8.118+` includes BSC Testnet (`97`) in the Wagmi chain config, so wallet switching can target the same chain id returned in the scanner checkout block. The checkout summary renders `BSC Testnet (97)`, shows the exact `tokenAddress` supplied by the backend (for dev tUSDT this is `0x109F54Dab34426D5477986b0460aE5dFBA65f022`), and sends chain 97 address/tx links to `testnet.bscscan.com` instead of mainnet BscScan.
### Step 3 — Buyer submits transaction
The buyer signs and broadcasts the transaction using their wallet. The scanner independently monitors the chain and does not require the transaction hash.
### Step 4 — Scanner detects and confirms
**EVM path:**
1. `eth_getLogs` returns a `TransferWithReferenceAndFee` log matching `topicRef`
2. `validateLogMatchesIntent` verifies token address, destination, and amount
3. Intent moves to `confirming`; scanner waits for N blocks
4. Once `confirmationsRequired` blocks have been built on top, intent moves to `confirmed`. The scanner stores and reports the accepted threshold count, not an ever-growing live count.
**Tron path:**
1. TronGrid `Transfer` event matches `destination` (EVM-hex normalized)
2. Amount validated ≥ intent amount
3. Intent goes directly to `confirmed` (TronGrid returns only confirmed txs)
**TON path:**
1. TonCenter Jetton transfer matches `destination` (exact base64url) and `jetton_master_address`
2. Amount validated ≥ intent amount
3. Intent goes directly to `confirmed`
### Step 5 — Webhook delivery
The scanner POSTs to `callbackUrl` with:
```json
{
"intentId": "...",
"paymentReference": "0x...",
"txHash": "0x...",
"blockNumber": 39000010,
"amount": "10000000000000000000",
"token": "0x55d...",
"chainId": 56,
"confirmations": 200,
"status": "confirmed"
}
```
Header `X-AMN-Signature` = `HMAC-SHA256(body, callbackSecret)`.
The backend verifies the signature, matches the `intentId` to a Payment record, and marks it paid. Backend `2.6.82+` treats scanner `status: "confirmed"` as final enough to run Transaction Safety Provider checks and persist `blockchain.confirmations`. The stored confirmation count comes from verifier evidence first, then the webhook payload, then the configured per-chain threshold fallback, but settled counts are capped at the accepted threshold so the UI can show values like `200+` instead of chasing the live chain height forever.
### Step 6 — Backend acknowledges
Backend returns a 2xx response. Scanner records `webhook_delivered_at` and the intent lifecycle ends.
---
## 3. Direct-address payment mode
Scanner `0.1.8` adds a non-smart-contract rail for cases where the buyer transfers tokens directly to a backend-assigned address instead of calling `ERC20FeeProxy`.
This rail is currently EVM ERC-20 only. Tron/TON direct balance reads are future scope.
### Mode A — synchronous balance check
```
Buyer Backend Scanner EVM RPC
│ │ │ │
│ open checkout │ │ │
│────────────────────►│ │ │
│ │ POST /balances/check │
│ │───────────────────►│ eth_call balanceOf │
│ │◄───────────────────│◄────────────────── │
│ address + amount │ │ │
│◄────────────────────│ │ │
│ direct token transfer ──────────────────────────────────────►│
│ click "I paid" │ │ │
│────────────────────►│ │ │
│ │ POST /balances/check │
│ │───────────────────►│ eth_call balanceOf │
│ │◄───────────────────│◄────────────────── │
│ payment accepted if delta >= expected amount │
```
Backend responsibilities:
1. Allocate or select the payment address.
2. Call scanner `POST /balances/check` to store a base-unit `baselineBalance`.
3. Show the address/token/amount to the buyer.
4. When buyer clicks "I paid", call `POST /balances/check` again.
5. Compare `(currentBalance - baselineBalance)` to the expected base-unit amount.
6. Persist evidence and run the normal payment/ledger transition only after chain, token, address, and amount checks pass.
### Mode B — balance watch
```
Backend Scanner EVM RPC
│ POST /balance-watches │ │
│──────────────────────►│ initial balanceOf │
│◄──────────────────────│ │
│ │ every 5m, then 10/20/40m
│ │───────────────────►│
│ │ balance changed │
│◄──────────────────────│ signed webhook │
│ payment accepted │ │
│ DELETE /balance-watches/{watchId} │
│──────────────────────►│ status=stopped │
```
Backend `2.8.60` exposes scanner helper functions in `amnPayAdapter.ts`:
| Helper | Scanner endpoint |
|---|---|
| `checkScannerTokenBalance` | `POST /balances/check` |
| `createScannerBalanceWatch` | `POST /balance-watches` |
| `stopScannerBalanceWatch` | `DELETE /balance-watches/{watchId}` |
Backend `2.8.60` also accepts signed `balance_changed` scanner webhooks on the existing AMN scanner webhook route. The current webhook handler records `amnScannerBalanceWatch` metadata and returns `202`; it does not yet mark the payment funded on balance change alone. The product decision rule still needs to be implemented by the backend work described in the PRD.
Recommended watch ID shape: `<paymentId>-balance-c<chainId>-<TOKEN>`. The webhook handler maps this back to the payment ID prefix.
Scanner cadence:
| Age | Interval |
|---|---|
| First 24h | 5 min |
| 2448h | 10 min |
| 4872h | 20 min |
| 72h7d | 40 min |
| After 7d | `expired` |
Backend must stop a watch when payment is accepted, cancelled, manually resolved, or no longer relevant.
---
## 4. Failure paths
### Webhook delivery failure
If the backend returns non-2xx or is unreachable, the scanner retries:
```
attempt 1: after 5 s
attempt 2: after 30 s
attempt 3: after 2 min
attempt 4: after 10 min
attempt 5: after 1 h
→ status = webhook_failed
```
`webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`.
On startup the scanner reconciles any `confirmed` intents with `webhook_delivered_at IS NULL` (crash recovery).
### Intent expiry
Intents in `pending` or `confirming` status older than `INTENT_TTL_HOURS` (default 24 h) are moved to `expired` by a background ticker running every hour.
`confirming` intents can get stuck if a transaction is deep-reorganised and never re-included; the TTL frees the destination address for reuse.
### Amount underpayment
Transfers where the on-chain amount is less than `intent.Amount` are silently skipped. The intent remains `pending` until the TTL.
### Wrong token or destination
The EVM log decoder validates all three fields (token, destination, amount). Mismatches are logged as `REJECT` and skipped. The intent remains `pending`.
### Balance watch change but payment not complete
`balance_changed` means the watched balance changed; it is not a final paid signal by itself. Backend must reject or keep waiting when:
- `delta` is less than the expected payment amount.
- The balance decreased or moved by an unrelated amount.
- The watch address/token/chain do not match the payment metadata.
- The payment was already completed, cancelled, refunded, or superseded.
---
## 5. Key differences from Request Network integration
| Dimension | Request Network | AMN Pay Scanner |
|---|---|---|
| Dependency | RN SDK + API | None (direct RPC) |
| Payment reference | RN-generated | Internal HMAC derivation |
| EVM matching | By reference hash (RN) | By Topics[1] / topicRef (indexed) |
| Tron | Not supported | TRC20 Transfer events via TronGrid |
| TON | Not supported | Jetton transfers via TonCenter v3 |
| Confirmations | RN handled | Per-chain configurable |
| Webhook | RN webhook → backend adapter | Scanner → backend directly |
| State store | External (RN cloud) | Internal SQLite |
| Direct address payments | Not supported | EVM ERC-20 balance check/watch rail |

View File

@@ -5,7 +5,7 @@ related_models: ["[[PurchaseRequest]]", "[[Category]]", "[[Address]]", "[[Seller
related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"]
---
> **Last updated:** 2026-05-29aligned with code (see Doc vs Code Audit Report)
> **Last updated:** 2026-05-31template checkout delivery/payment rail behavior added.
> [!warning] Audit — 2026-05-29
> This document was corrected against the live codebase. Key changes: status enum updated (added `pending_payment`, `active`; removed undocumented `finalized`/`archived`); urgency values expanded to include `urgent`; sellers endpoint corrected; attachment upload endpoint corrected; `request-cancelled` socket event removed (non-existent); `new-purchase-request` fan-out target corrected to shared `sellers` room; socket room join/leave events documented; description minimum corrected to 5 chars; PUT vs PATCH mismatch flagged as known bug; two frontend actions hitting non-existent backend endpoints flagged as not implemented.
@@ -212,6 +212,7 @@ sequenceDiagram
## Linked flows
- [[RequestTemplate]] checkout — seller chooses physical vs online delivery on the template; buyer checkout collects only the required address/email details and `batch-convert` creates one [[PurchaseRequest]] per seller/template group.
- [[Seller Offer Flow]] — sellers respond to the published request.
- [[Negotiation Flow]] — counter-offer mechanics in `in_negotiation`.
- [[PRD - Request Network In-House Checkout]] — buyer pays for the accepted offer.

View File

@@ -5,7 +5,7 @@ related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"]
related_apis: ["POST /api/marketplace/purchase-requests/:id/offers", "GET /api/marketplace/purchase-requests/:id/offers", "PATCH /api/marketplace/offers/:id"]
---
> **Last updated:** 2026-05-29aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-05-30updated for offer-management page, `withdrawOffer` action, edit-while-pending, `getSellerOffers` API (commits 240a668e7d1375)
# Seller Offer Flow
@@ -90,24 +90,22 @@ The valid `SellerOffer` statuses are `pending | accepted | rejected | withdrawn`
- Notifications: `notifyOfferAccepted` to the winning seller, generic rejection notifications to the others (`SellerOfferService.acceptOffer` does the same in the manual path).
- Socket events notify the winner and reject/close competing offers.
### Withdrawal
### Edit / withdrawal while awaiting buyer acceptance
17. ⚠️ **`POST /api/marketplace/offers/:id/withdraw` does NOT exist as an HTTP route.** The `SellerOfferService.withdrawOffer()` service method exists but is dead code — it is not wired to any controller endpoint.
17. While a request is in `received_offers` status (buyer has not yet accepted), the seller may **edit** their pending offer or **withdraw** it entirely from the request-detail step-2 card (`step-2-waiting-for-payment.tsx`).
The only supported HTTP way to withdraw an offer is:
- **Edit**: toggles `mode` to `'edit'` inside `Step2WaitingForPayment`, re-mounts `Step1SendProposal` pre-populated with the existing offer values. On save, calls `PATCH /api/marketplace/offers/:id` (via `updateOffer` action, which now correctly uses `PATCH` instead of the old `PUT`).
- **Withdraw**: opens a `ConfirmDialog`, then calls `withdrawOffer(offerId)` in `src/actions/marketplace.ts` which uses `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
```
PUT /api/marketplace/offers/:id
Body: { status: 'withdrawn' }
```
`canManageOffer` is only `true` when `requestDetails?.status === 'received_offers'`; once the buyer accepts and the status advances, both buttons are hidden.
Note also that the frontend page `/dashboard/seller/marketplace/offers` (a "My Offers" listing) **does not exist**. Withdrawal must be triggered from the individual request detail page.
The DB filter `{ status: 'pending' }` inside `SellerOfferService.withdrawOffer` means withdrawal is impossible once `accepted` or `rejected`.
The DB filter `{ status: 'pending' }` inside `withdrawOffer` means withdrawal is impossible once `accepted` or `rejected`.
> ⚠️ `POST /api/marketplace/offers/:id/withdraw` still does **not** exist as an HTTP route. Always use `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
### Offer update — method mismatch
### Offer update — method mismatch resolved
> ⚠️ **Known mismatch**: The frontend sends `PUT /marketplace/offers/:id` to update an offer, but the backend route is registered as `PATCH /api/marketplace/offers/:id` (`marketplaceControllerRoutes.ts`). Depending on whether a proxy or middleware normalises the method, one of these may fail. Verify end-to-end and align to a single method.
> ✅ **Fixed (commit 240a668)**: The frontend `updateOffer` action now sends `PATCH /api/marketplace/offers/:id`, matching the backend. The `acceptOffer` action was also corrected from `PUT` to `PATCH`.
## Sequence diagram
@@ -157,10 +155,10 @@ sequenceDiagram
| `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer | `purchaseRequestId` is a path param |
| `GET` | `/api/marketplace/purchase-requests/:id/offers` | Buyer view of offers on a request | |
| `GET` | `/api/marketplace/offers/:id` | Single offer details | |
| `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) | ⚠️ Frontend sends `PUT`; backend registers `PATCH` — method mismatch |
| `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) | Fixed: frontend now sends `PATCH` |
| `POST` | `/api/marketplace/offers/:id/accept` | Manual acceptance (when not via webhook) | |
| ~~`GET /api/marketplace/offers/seller/:sellerId`~~ | — | ~~Seller's own offer history~~ | ⚠️ NOT IMPLEMENTED — `getOffersBySeller()` service method exists but has no HTTP route |
| ~~`POST /api/marketplace/offers/:id/withdraw`~~ | — | ~~Seller withdraws~~ | ⚠️ NOT IMPLEMENTED — use `PATCH /api/marketplace/offers/:id` with `{ status: 'withdrawn' }` instead |
| `GET` | `/api/marketplace/offers/seller/:sellerId` | All offers by this seller (used by Offer Management page) | Implemented via `getSellerOffers` frontend action (commit 240a668) |
| `PUT` | `/api/marketplace/offers/:id/status` | Status mutation — use `{ status: 'withdrawn' }` to withdraw | The only HTTP withdraw path; `POST /api/marketplace/offers/:id/withdraw` does **not** exist |
## Database writes
@@ -211,6 +209,9 @@ sequenceDiagram
- Backend: `backend/src/services/marketplace/marketplaceController.ts`
- Backend: `backend/src/models/SellerOffer.ts`
- Backend: `backend/src/services/payment/paymentCoordinator.ts` (payment-state cascade)
- Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx`
- Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx` — proposal form (also re-used for edit)
- Frontend: `frontend/src/sections/request/components/seller-steps/step-2-waiting-for-payment.tsx` — awaiting-buyer card with edit/withdraw actions
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx`
- Frontend: `frontend/src/app/dashboard/seller/marketplace/`
- Frontend: `frontend/src/app/dashboard/seller/marketplace/` — seller marketplace browse
- Frontend: `frontend/src/app/dashboard/seller/marketplace/offers/page.tsx` — Offer Management page (all offers, status filter, withdraw)
- Frontend: `frontend/src/actions/marketplace.ts``withdrawOffer`, `getSellerOffers` actions

View File

@@ -0,0 +1,788 @@
---
title: Telegram Mini App Flow
tags: [flow, telegram, mini-app, auth, bilingual, RTL, shop, cart, payment]
related_models: ["[[User]]"]
related_apis: ["POST /api/auth/telegram", "[[Auth API]]"]
task: "5.4"
---
> **Last updated:** 2026-06-12
> **Status:** LARGELY COMPLETE — Task 5.4 core implementation done; open items are `startapp` deep-link auto-routing, backend Socket.IO room scoping, archived-chat surfacing, review-prompt integration, and cross-platform QA.
> **Frontend branch:** `integrate-main-into-development` · v2.8.94+
> **Entry point:** `src/sections/telegram/` · route `/telegram`
# Telegram Mini App Flow
End-to-end specification for the **Amaneh Telegram Mini App** — a fully self-contained marketplace shell surfaced inside Telegram's in-app browser via the WebApp SDK. Buyers and sellers can browse requests, create new escrow requests, shop seller templates, manage a cart, review offer state, follow payments, and message each other without leaving Telegram.
> **Two separate Mini Apps exist on this platform.** This document covers the **main marketplace Mini App** (`amn.gg/telegram`) built inside the primary Next.js frontend. For the AI-assisted request-creation Mini App, see [[amanat-assist]].
---
## 1. Architecture Overview
```
Telegram Client
└─ Mini App iframe (https://amn.gg/telegram)
└─ TelegramMiniAppView ← shell orchestrator
├─ useTelegramLiveContext ← SDK probe + polling
├─ useTelegramLanguage ← EN / FA detection
├─ useTelegramAutoSignIn ← silent JWT exchange
├─ useTelegramMainButton ← native chrome sync (disabled)
├─ useTelegramBackButton ← native chrome sync
├─ useTelegramHaptic ← haptic wrapper
├─ useTelegramCart ← shared localStorage cart
├─ useTelegramNotifications ← unread badge count
├─ [state: loading] → TelegramLoadingState
├─ [state: unsupported] → TelegramUnsupportedState
├─ [state: unlinked] → TelegramUnlinkedState
└─ [state: linked]
├─ TelegramHeader
├─ TelegramTabBar (Home / Shop / Requests / Chat / Account)
├─ [drilldown] TelegramPaymentView ← highest priority
├─ [drilldown] TelegramChatThreadView
├─ [drilldown] TelegramRequestDetailView
├─ [drilldown] TelegramTemplateDetailView
├─ [drilldown] TelegramSellerShopView
├─ [overlay] TelegramPointsView
├─ [overlay] TelegramSettingsView
├─ [overlay] TelegramAddressesView
├─ [overlay] TelegramCartView
├─ [overlay] TelegramCheckoutView
├─ [overlay] TelegramNotificationsView
├─ [overlay] TelegramNewRequestView
├─ TelegramHomeView
├─ TelegramShopView → TelegramSellerShopView
├─ TelegramRequestsView → TelegramRequestDetailView
├─ TelegramChatView → TelegramChatThreadView
└─ TelegramAccountView
```
The shell is a **single-page, no-router** design: all navigation (tabs, overlays, detail drilldowns) is pure React state in `TelegramMiniAppView`. `window.location.assign` is used only as a final escape hatch to external URLs. `openTelegramExternalLink` is used for deep links into the web dashboard, which opens inside Telegram's WebView or an external browser depending on the Telegram client.
---
## 2. Launch Points
| Entry | Mechanism | `startapp` context | Result |
|---|---|---|---|
| Bot profile | User opens bot → taps "Open App" | none | Shell loads at Home tab |
| Menu button | Pinned button in any chat with the bot | none | Shell loads at Home tab |
| Inline button | Bot sends a message card with an embedded button | `req_<requestId>` | Shell loads; request deep-link (see below) |
| Direct deep link | `https://t.me/AmanehBot/app?startapp=req_<id>` | `req_<requestId>` | Shell loads; request deep-link (see below) |
| Web fallback | Browser at `/telegram` (no Telegram SDK) | none | `TelegramUnsupportedState` — "Open in Telegram" prompt + web dashboard link |
### 2.1 startapp Context Parsing
`startapp` / `tgWebAppStartParam` is read from two sources in priority order:
1. `window.Telegram.WebApp.initDataUnsafe.start_param` — primary source when the SDK is injected.
2. URL query/hash params (`tgWebAppStartParam`) — fallback for older Telegram clients that append params directly to the URL.
Both are normalised into `context.startParam` by `getTelegramContext()` in `src/utils/telegram-webapp.ts`.
### 2.2 startapp Deep-Link Routing (Partial)
When `context.startParam` matches `req_<requestId>`, the intent is to auto-open `TelegramRequestDetailView` for that request on first render. **This routing is not yet wired**`startParam` is parsed and available in context but the shell does not yet act on it. This is open item #1 in section 16.
---
## 3. amanat-assist vs Main Mini App
Two distinct Telegram Mini Apps exist for this platform:
| Property | Main Mini App (this doc) | amanat-assist |
|---|---|---|
| URL | `amn.gg/telegram` | `assist.amn.gg` |
| Bot | AmanehBot | AmanehBot (same) |
| Codebase | `frontend/` (Next.js, `src/sections/telegram/`) | `/amanat-assist` (React + Vite, separate repo) |
| Purpose | Full marketplace shell: browse, buy, sell, chat, manage account | Conversational LLM wizard to create one purchase request |
| LLM | None | Mistral → DeepSeek fallback (via `amanat-llm-proxy` on port 3001) |
| Backend access | Direct calls to `api.amn.gg` | Proxied through `amanat-llm-proxy` which holds the LLM API keys |
| Auth | Telegram `initData``POST /api/auth/telegram` | Same endpoint; also supports web redirect via `?access_token=` |
| Deep links between apps | Main Mini App has "New Request" overlay with an "Open Assist" CTA that navigates `window.location.href` to `assist.amn.gg?access_token=...` | Assist submits the finished request then the user returns to the main app |
| Status | In production | Live at `assist.amn.gg` v1.1.0 |
**Hand-off from main app to assist:** `handleOpenAssist()` in `TelegramMiniAppView` constructs a URL to `https://assist.amn.gg` with `access_token`, `user_json`, `theme`, and `source=miniapp` query params. `window.location.href` is used (not `openLink`) to keep the navigation inside Telegram's WebView rather than opening Safari on iOS.
---
## 4. SDK Initialisation & Context Probe
**File:** `src/utils/telegram-webapp.ts` · `getTelegramContext()`
The function assembles a `TelegramContext` object from:
1. `window.Telegram.WebApp` — primary SDK surface (available when the app is opened inside Telegram).
2. URL query/hash fallback — `tgWebAppStartParam`, `tgWebAppData`, `tgWebAppVersion`, `tgWebAppPlatform` — used by older clients or during dev testing.
**Fields extracted:**
| Field | Source | Notes |
|---|---|---|
| `isMiniApp` | Any Telegram signal present | Drives unsupported vs unlinked state |
| `initData` | `webApp.initData` or `tgWebAppData` URL param | HMAC-signed payload sent to `/api/auth/telegram` |
| `initDataUnsafe` | `webApp.initDataUnsafe` | Client-side user identity (not trusted) |
| `safeArea` | `contentSafeAreaInset` or `safe_area_insets` | Parsed to `{top, right, bottom, left}` in px |
| `theme` | `webApp.themeParams` | Both camelCase and snake_case normalised |
| `platform` | `webApp.platform` or URL param | e.g. `ios`, `android`, `tdesktop` |
| `startParam` | `startapp` / `tgWebAppStartParam` / `start_param` | Deep-link context |
| `isUnsupported` | `!webApp && Boolean(startParam)` | Partial signal — no SDK but has URL param |
**Polling on mount** (`useTelegramLiveContext`): Telegram sometimes finishes injecting the WebApp object after the first React render. The hook re-probes at 0 ms, 100 ms, 500 ms, and 1000 ms after mount, and also re-probes on `hashchange` events (triggered by the native back-button on some platforms).
---
## 5. Shell State Machine
`getTelegramStatus(context, hasWebAccount)` returns one of three states:
```
unsupported ─── !context.isMiniApp
(opened in browser, not Telegram)
unlinked ─────── isMiniApp && (!user || !telegramUser.id)
(inside Telegram but no JWT session linked)
linked ──────── isMiniApp && user && telegramUser.id
(authenticated, full shell rendered)
```
State transitions occur on:
- Auth session check completing (`loading → false`)
- Telegram auto sign-in completing (`tgAuthLoading → false`)
- Manual sign-in button tap (unlinked → linked)
---
## 6. Authentication Flow
### 6.1 Silent Auto Sign-In
**Hook:** `useTelegramAutoSignIn` · **File:** `hooks/use-telegram-auto-sign-in.ts`
On mount, if `context.isMiniApp && context.initData && !user`:
1. Exchange `initData` for a JWT by calling `signInWithTelegram({ initData })``POST /api/auth/telegram`.
2. On success, call `checkUserSession()` to refresh the auth context.
3. If the backend returns `isNewUser: true`, show `TelegramOnboardingSheet`.
4. A `useRef` deduplication guard (`attemptedInitDataRef`) prevents re-runs under React Strict Mode's double-effect behaviour.
### 6.2 Manual Sign-In (Unlinked State)
When `initData` is present but auto sign-in failed (or hasn't run yet), `TelegramUnlinkedState` renders:
- **Continue with Telegram** — calls the same `signIn()` function from `useTelegramAutoSignIn`.
- **Sign in with email** — `window.location.assign(paths.auth.jwt.signIn)`.
- **Create an account** — `window.location.assign(paths.auth.jwt.register)`.
When `initData` is absent (accessed via a path that skips Telegram context), only the email/register buttons appear.
### 6.3 Backend Endpoint
`POST /api/auth/telegram` — expects `{ initData: string }`.
**Verification steps (backend):**
1. Parse the `initData` query string into key-value pairs.
2. Extract `hash` from the pairs; remove it from the set.
3. Build the data-check string: sort remaining pairs alphabetically, join as `key=value\n`.
4. Compute `HMAC-SHA256(data_check_string, HMAC-SHA256("WebAppData", TELEGRAM_BOT_TOKEN))`.
5. Compare computed hash with the extracted `hash` — reject with 401 on mismatch.
6. Parse `user` JSON from `initDataUnsafe`; upsert `User` record with `telegramId`, `telegramVerified: true`.
7. Issue JWT + refresh token. Return `{ token, refreshToken, isNewUser }`.
Registered at `authRoutes.ts` line 24: `router.post("/telegram", ctrl.telegramAuth.bind(ctrl))` — public route, no auth middleware required (HMAC is the authentication proof).
### 6.4 Session Linking (Telegram ↔ Amaneh Account)
The `POST /api/auth/telegram` endpoint both creates and links accounts:
- **New Telegram user, no existing Amanat account:** a new `User` is created with `telegramId` set; `isNewUser: true` is returned and the onboarding sheet is shown.
- **Existing Amanat account with the same `telegramId`:** the existing user is returned; session continues.
- **Existing Amanat account that has never used Telegram:** `telegramId` and `telegramVerified: true` are written onto the existing record (matched by Telegram user id).
After the JWT is issued the standard `checkUserSession()` re-hydrates the React auth context. The Mini App shell reads `user.telegramVerified` and `user.isEmailVerified` from this context to render verification chips in the Account tab.
---
## 7. Navigation Model
All navigation is in-shell React state — no Next.js router is involved.
```
activeTab : 'home' | 'shop' | 'requests' | 'chat' | 'account'
overlayScreen : 'new-request' | 'notifications' | 'cart' | 'checkout'
| 'points' | 'settings' | 'addresses' | null
openConversationId : string | null
openRequestId : string | null
openPaymentRequestId : string | null ← payment drilldown (highest priority)
paymentCheckoutFlow : boolean ← true when reached from shop checkout
openSellerId : string | null
openTemplate : { template, seller } | null
```
**Priority rendering** (first match wins):
1. `openPaymentRequestId``TelegramPaymentView` ← new, highest priority
2. `openConversationId``TelegramChatThreadView`
3. `openRequestId``TelegramRequestDetailView`
4. `openTemplate``TelegramTemplateDetailView` ← new
5. `openSellerId``TelegramSellerShopView`
6. `overlayScreen === 'points'``TelegramPointsView` ← new
7. `overlayScreen === 'settings'``TelegramSettingsView` ← new
8. `overlayScreen === 'addresses'``TelegramAddressesView` ← new
9. `overlayScreen === 'cart'``TelegramCartView`
10. `overlayScreen === 'checkout'``TelegramCheckoutView` ← new (replaces web handoff)
11. `overlayScreen === 'notifications'``TelegramNotificationsView`
12. `overlayScreen === 'new-request'``TelegramNewRequestView`
13. `activeTab` → appropriate tab view
**Back button** (Telegram native `BackButton`) dismisses in reverse priority order:
- Payment drilldown → if `paymentCheckoutFlow`, steps back to cart; otherwise clears payment state.
- Chat thread → clears `openConversationId`.
- Request detail → clears `openRequestId`.
- Template detail → clears `openTemplate`.
- Seller shop → clears `openSellerId`.
- Overlay (`checkout` steps back to `cart`) → clears `overlayScreen`.
- Non-home tab → returns to `home`.
`BackButton` visibility: shown whenever `state === 'linked'` and either an overlay/drilldown is active, or `activeTab !== 'home'`.
`MainButton` visibility: **intentionally disabled** (`isReady: false`) — the native Telegram MainButton cannot use the project font and duplicates in-shell CTAs, so it is kept hidden. All primary actions live inside the shell UI itself.
Both chrome buttons retain the amaneh saffron palette (`color: #C2410C`, `text_color: #FFFFFF`) via `setParams` (WebApp SDK >= 6.1) as a fallback should the MainButton ever be re-enabled.
---
## 8. Tab Structure
The shell has **five bottom tabs** rendered by `TelegramTabBar`:
| Tab | Icon | View | Purpose |
|---|---|---|---|
| Home | house | `TelegramHomeView` | Welcome banner, quick-action cards, escrow-state chips |
| Shop | storefront | `TelegramShopView` | Sellers list; drill into seller store; add templates to cart |
| Requests | list | `TelegramRequestsView` | User's escrow requests with status stepper |
| Chat | speech bubble | `TelegramChatView` | Conversation list + support entry |
| Account | person | `TelegramAccountView` | Profile, preferences, links to web dashboard sections |
`handleTabSelect` clears all overlays and drill-down IDs before switching tab.
---
## 9. Supported Flows
### 9.1 Home Tab
`TelegramHomeView` is the landing screen shown on first open. It contains:
- **Welcome banner** (`TelegramWelcomeBanner`): escrow account summary, primary CTA.
- **Quick-action cards** (`TelegramQuickActions`): shortcuts to Requests, Payments, Chat.
- **Escrow state chips** (`TelegramEscrowStateChips`): legend of status values visible in the platform.
- **"New Request" CTA** → opens `overlayScreen = 'new-request'`.
- **"Open Assist" CTA** → calls `handleOpenAssist()` to navigate to `assist.amn.gg` in the same WebView (see section 3).
### 9.2 Shop Tab — Sellers List
**`TelegramShopView`** (`telegram-shop-view.tsx`):
- Fetches all sellers via `useTelegramShops()` → SWR wrapping `getTemplateSellers()``GET /api/request-templates/sellers`.
- Renders `TelegramShopRow` per seller: avatar, name, rating, template count, sales count.
- Shows a floating cart badge button (`TelegramCartFab`) in the header when `totalItems > 0`; tap opens `overlayScreen = 'cart'`.
- Tap a seller row → sets `openSellerId` → navigates to `TelegramSellerShopView`.
### 9.3 Shop Tab — Seller Store
**`TelegramSellerShopView`** (`telegram-seller-shop-view.tsx`):
- Fetches seller + active templates via `useTelegramSellerShop(sellerId)``GET /api/request-templates/sellers/:id`.
- Dark header: seller avatar, name, rating, template count, description.
- Each template card shows: image, title, 2-line description, budget range, usage count.
- **Two actions per template:**
- **Add to cart / Remove from cart** — toggles item in `useTelegramCart` (localStorage, no API).
- **View template details** — sets `openTemplate` → navigates to `TelegramTemplateDetailView`.
- Floating "Cart · N templates" sticky button at bottom when `totalItems > 0`; tap calls `onOpenCart()`.
### 9.4 Shop Tab — Template Detail
**`TelegramTemplateDetailView`** (`telegram-template-detail-view.tsx`):
- Full-screen view of a single template.
- Shows full description, seller info, price, delivery info, usage/capacity counters.
- Add/remove cart action; direct "Order this template" link to `/dashboard/request/from-template?shareableLink=...` (exits to web dashboard).
- Back button returns to the seller store (`openTemplate` cleared, `openSellerId` retained).
### 9.5 Shopping Cart Overlay
**`TelegramCartView`** (`telegram-cart-view.tsx`):
- Rendered as `overlayScreen = 'cart'`; dismissed by Telegram BackButton.
- Lists each cart item: image, name, seller name, USDT price × quantity, +/ quantity controls, remove button.
- Subtotal/total in USDT, locale-formatted (`fa-IR` for Persian, `en-US` for English); amounts always `dir="ltr"`.
- **"Continue to payment"** → calls `onCheckout()` which sets `overlayScreen = 'checkout'` (in-shell checkout, replacing the previous web handoff).
**Cart storage (`useTelegramCart`):**
- Reads/writes `localStorage` key **`app-request-template-checkout`** — the same key the web `RequestTemplateCheckoutProvider` reads. This enables the web dashboard checkout to hydrate the same cart.
- Dispatches a custom `tg-cart-changed` DOM event on every write; listens on both that event and the native `storage` event so all open tabs stay in sync.
- Operations: `addTemplate(template, seller)`, `removeItem(itemId)`, `changeQuantity(itemId, qty)`, `isInCart(templateId)`.
- No API calls — cart is purely client-side until checkout.
### 9.6 In-Shell Checkout Overlay
**`TelegramCheckoutView`** (`telegram-checkout-view.tsx`):
- Rendered as `overlayScreen = 'checkout'`; BackButton steps back to `overlayScreen = 'cart'`.
- A 3-step stepper running entirely inside the Mini App shell:
- **Step 0 (Cart review):** item list, quantities, totals, discount.
- **Step 1 (Address):** physical address or online delivery email.
- **Step 2 (Payment):** wallet-based payment execution.
- On successful order (`onPlaced(reqId)` callback):
- If a `reqId` is returned, sets `paymentCheckoutFlow = true` and `openPaymentRequestId = reqId` → immediately opens the payment view.
- If no `reqId`, switches `activeTab` to `'requests'`.
- Stock validation clamps or removes items exceeding `remainingCapacity` before payment.
- Integrates with `onManageAddresses()` to open the `addresses` overlay mid-flow.
### 9.7 Payment View (In-Shell)
**`TelegramPaymentView`** (`telegram-payment-view.tsx`):
- Highest-priority drilldown (rendered before all other overlays).
- Loaded for a specific `requestId`. Used from two entry points:
- **Shop checkout flow** (`paymentCheckoutFlow = true`): after `TelegramCheckoutView` creates the requests. Shows a 3-step progress header (cart → address → payment).
- **Requests tab** (`paymentCheckoutFlow = false`): buyer taps "Pay" on an existing request. No progress header.
- Fetches request details via `useTelegramRequest`.
- Fetches offers via `useTelegramOffers`.
- Calls `getPaymentOptions()``GET /api/payment/options` and `createDirectBalanceIntent()``POST /api/payment/direct-balance`.
- Polls `checkDirectBalancePayment()` for confirmation.
- On successful payment: calls `onPaid()` → clears `openPaymentRequestId`, switches to `activeTab = 'requests'`.
- Back button: if `paymentCheckoutFlow`, steps back to `overlayScreen = 'cart'`; otherwise clears the payment state.
### 9.8 Browse Requests (Requests Tab)
- `TelegramRequestsView` fetches the user's purchase requests via `useTelegramMyRequests` (GET `/api/requests`).
- Displays a skeleton loader, then a scrollable list of `TelegramRequestRow` items.
- Each row shows: title, status chip, budget, creation date.
- Tap → sets `openRequestId` → renders `TelegramRequestDetailView`.
### 9.9 Request Detail with Stepper and Offers
- `TelegramRequestDetailView` fetches a single request via `useTelegramRequest`.
- Renders `TelegramRequestStepper` — a visual timeline of the escrow status flow from `pending_payment``completed`.
- `determineCurrentStepFromStatus` maps the current `status` to a step index.
- Also renders: budget, description, creation date, category, urgency.
- **Offer review:** fetches offers via `useTelegramOffers`; renders offer cards with seller info, price, and accept/reject actions.
- **Pay action:** renders a "Pay" button when request is in a payable state → calls `onPay(id)` → sets `openPaymentRequestId`.
- **Web fallback:** "View full details" → `openTelegramExternalLink(context.webApp, path)`.
- **Chat seller:** taps the seller chat icon → calls `onChatSeller(sellerId)``createConversation` + sets `openConversationId`.
- Role-aware: `role` prop is `'seller'` or `'buyer'` based on `user.role`.
- Dates formatted via `toLocaleDateString` with `fa-IR` locale for Persian.
### 9.10 Create New Request
- `TelegramNewRequestView` is a full-screen overlay (not a routed page).
- Form fields: title, description, category (fetched from `/api/categories`), budget min/max, urgency.
- Includes an **"Open Assist"** button that delegates to `handleOpenAssist()` for users who prefer the conversational LLM flow.
- On submit: calls `createPurchaseRequest()` → POST `/api/purchase-requests`.
- On success: closes overlay, switches `activeTab` to `'requests'`.
### 9.11 Chat Tab
- `TelegramChatView` shows the user's active conversations via `useTelegramConversations`.
- Includes a Support row that calls `createSupportChat()``POST /api/chat/support`, then opens `TelegramChatThreadView` with the returned conversation ID.
- Tap a conversation row → sets `openConversationId` → renders `TelegramChatThreadView`.
- `TelegramChatThreadView` loads messages via `useTelegramChatThread`, renders `TelegramChatBubble` items, and includes `TelegramChatComposer` for sending.
- Optimistic send: message appears immediately, confirmed/rolled back on API response.
- Real-time updates via Socket.IO events; SWR is mutated on `new-notification` and `unread-count-update` events.
### 9.12 Account Tab
**`TelegramAccountView`** (`telegram-account-view.tsx`):
**Profile header:**
- Avatar (from `user.profile.avatar`, falls back to initials), full name, Telegram `@username`, role chip (buyer / seller / admin / resolver / guard).
- Verification chips: "Telegram Verified" (if `user.telegramVerified`) and "Email Verified" (if `user.isEmailVerified`).
**Preferences section:**
- Language toggle (FA / EN, in-shell via `TelegramLanguageToggle`).
- **Settings** → opens `overlayScreen = 'settings'` (in-shell `TelegramSettingsView`).
- **Points** → opens `overlayScreen = 'points'` (in-shell `TelegramPointsView`).
- Wallet → truncated address (`0x1234…abcd`) or "not connected" → `/dashboard/account/wallet` (web via `openTelegramExternalLink`).
- Notifications → opens `TelegramNotificationsView` overlay in-shell.
- **Addresses** → opens `overlayScreen = 'addresses'` (in-shell `TelegramAddressesView`).
- Passkey → `/dashboard/account/passkey` (web).
**Help section:**
- Support → `createSupportChat()` → opens `TelegramChatThreadView` in-shell.
- Terms & Conditions → placeholder, "coming soon".
**Session section:**
- Sign Out → `TelegramBottomSheet` confirmation dialog → `authSignOut()` + `window.location.assign(paths.auth.jwt.signIn)`.
### 9.13 Settings Overlay
**`TelegramSettingsView`** (`telegram-settings-view.tsx`):
- Rendered as `overlayScreen = 'settings'`.
- Allows editing profile fields (name, bio) in-shell.
- On save: calls `onSaved()` which triggers `checkUserSession()` to refresh the auth context.
### 9.14 Addresses Overlay
**`TelegramAddressesView`** (`telegram-addresses-view.tsx`):
- Rendered as `overlayScreen = 'addresses'`.
- Fetches addresses via `use-telegram-addresses.ts`.
- Used both from the Account tab and as a mid-flow step from `TelegramCheckoutView`.
### 9.15 Points Overlay
**`TelegramPointsView`** (`telegram-points-view.tsx`):
- Rendered as `overlayScreen = 'points'`.
- Fetches user points via `use-telegram-points.ts`.
- Shows points balance and transaction history.
### 9.16 Dispute Surface
The Mini App does not yet have a dedicated dispute-filing view. Dispute access is handled via two escape hatches:
- **Request detail "View full details" link** (`openTelegramExternalLink`) — opens the web dashboard request detail page where dispute filing is available.
- **Support chat** — buyer or seller can reach a support agent from the Account tab or the Home tab quick-action cards; the support agent can escalate to a formal dispute.
A native in-shell dispute flow (matching the web dashboard `DisputeView`) is planned but not yet implemented. This is a known gap for the Task 5.4 feature surface.
### 9.17 Notifications Overlay
- `TelegramNotificationsView` is rendered as `overlayScreen = 'notifications'`.
- Fetches via `useTelegramNotifications``getNotifications(userId, 1, 50)``GET /api/notifications?userId=...&page=1&limit=50`.
- Real-time updates: Socket.IO events `new-notification`, `unread-count-update` trigger SWR mutate.
- "Mark all read" calls `markAllNotificationsAsRead(userId)``PATCH /api/notifications/mark-all-read`.
- Unread count is also surfaced in the `TelegramHeader` bell icon badge.
---
## 10. API Calls
| Action | Hook / call | Backend endpoint |
|---|---|---|
| Auto sign-in | `useTelegramAutoSignIn``signInWithTelegram({initData})` | `POST /api/auth/telegram` |
| Sellers list | `useTelegramShops``getTemplateSellers()` | `GET /api/request-templates/sellers` |
| Seller + templates | `useTelegramSellerShop``getSellerWithTemplates(id)` | `GET /api/request-templates/sellers/:id` |
| Marketplace sellers | `useTelegramSellers``getSellers()` | `GET /api/marketplace/sellers` |
| My requests | `useTelegramMyRequests` | `GET /api/requests` |
| Single request | `useTelegramRequest` | `GET /api/purchase-requests/:id` |
| Create request | shell → `createPurchaseRequest()` | `POST /api/purchase-requests` |
| Offers for request | `useTelegramOffers``getOffers(requestId)` | `GET /api/marketplace/offers?requestId=...` |
| Payment options | `getPaymentOptions()` | `GET /api/payment/options` |
| Create payment intent | `createDirectBalanceIntent()` | `POST /api/payment/direct-balance` |
| Poll payment status | `checkDirectBalancePayment()` | `GET /api/payment/:id` |
| Update request status | `updateRequestStatus()` | `PATCH /api/marketplace/requests/:id/status` |
| Conversations | `useTelegramConversations` | `GET /api/chat/conversations` |
| Chat thread | `useTelegramChatThread` | `GET /api/chat/:id` + Socket.IO real-time |
| Support chat | `createSupportChat()` | `POST /api/chat/support` |
| Direct conversation | `createConversation({ type: 'direct', participantIds })` | `POST /api/chat/conversations` |
| Notifications | `useTelegramNotifications` | `GET /api/notifications?userId=...&page=1&limit=50` |
| Mark all read | `markAllNotificationsAsRead(userId)` | `PATCH /api/notifications/mark-all-read` |
| Auth sign-out | `authSignOut()` | JWT sign-out endpoint |
| Addresses | `use-telegram-addresses.ts` | `GET /api/user/addresses` |
| Points | `use-telegram-points.ts` | `GET /api/user/points` |
Cart operations (add/remove/quantity) are **pure localStorage** — no API calls until checkout.
Dispute endpoints (`POST /api/disputes`, `GET /api/disputes/:id`) are not yet called from the Mini App shell — dispute access is delegated to the web dashboard via `openTelegramExternalLink`.
---
## 11. Bilingual Support (EN / FA)
**Language detection priority** (`useTelegramLanguage`):
1. `?lang=` URL query param — dev preview override.
2. `localStorage` key `amn_tg_lang` — user's persisted manual selection.
3. `initDataUnsafe.user.language_code` — Telegram-reported language (`"fa"` or `"fa-IR"` → Persian).
4. Fallback → English.
**Language toggle:** `TelegramLanguageToggle` in the header — two buttons `[ EN | فا ]`. On tap: haptic light + language switch + persist to `localStorage`.
**RTL layout:**
| Element | EN (LTR) | FA (RTL) |
|---|---|---|
| Root `dir` attribute | `ltr` | `rtl` |
| Font family | IBM Plex Sans | Vazirmatn |
| Arrow icons | `→` | `←` |
| Text alignment | left | right (inherits from `dir`) |
| Chip list wrap | left-to-right | right-to-left |
| Amounts | always `dir="ltr"` | always `dir="ltr"` |
Font size bumps for Persian: body 13 px → 14 px, labels 10 px → 11 px (Vazirmatn renders optically smaller).
**Translation structure:**
```ts
// src/sections/telegram/locales/en.ts + fa.ts
const TR = {
en: { loading, unsupported, unlinked, header, home, shop, requests,
chat, account, newRequest, tabs, main, onboarding, errors, displayName, dir },
fa: { /* same keys, Farsi strings, dir: 'rtl' */ },
};
```
All JSX uses `t.<section>.<key>` — no inline strings in components.
---
## 12. Design System
**File:** `src/sections/telegram/constants.ts` · `src/sections/telegram/telegram-shell-css.ts`
The Mini App has a distinct visual identity (cream/saffron Persian palette) that does not inherit from the main dashboard theme. All tokens are feature-scoped.
**Palette:** `TG_PALETTE`
| Token | Hex | Usage |
|---|---|---|
| `cream50` | `#FBF6EB` | Page background |
| `ink900` | `#1C1410` | Primary text |
| `ink600` | `#6B5D4E` | Secondary text / labels |
| `saffron600` | `#C2410C` | Primary action |
| `saffron500` | `#D97757` | Hover states |
| `pistachio700` | `#3D6B4F` | Success / released states |
| `pomegranate700` | `#8E2424` | Error / disputed states |
| `bgPage` | `#E7DFCB` | Shell outer background |
**Fonts:** `TG_FONTS` — Source Serif 4 (headings), IBM Plex Sans (body LTR), Vazirmatn (body RTL), IBM Plex Mono (amounts/addresses).
**CSS:** `buildTelegramShellCss()` injects a `<style>` tag at shell root with all class utilities (`.tg-chip`, `.tg-shell`, `.tg-tab-bar`, `.tg-header`, etc.). Theme CSS variables (`--cream-50`, `--ink-900`, etc.) are set on `.tg-shell` root. Dark mode: `.tg-shell--dark` class toggled from `themeScheme`.
**Safe area:** `getTelegramSafeAreaStyle(safeArea)` maps the Telegram-reported safe area insets to CSS padding using `max(${px}px, env(safe-area-inset-*))` to handle both Telegram-native and iOS/Android safe areas.
---
## 13. Telegram SDK Usage Patterns
### 13.1 Safe-Area Inset
```ts
// TelegramContext.safeArea = { top, right, bottom, left } (px)
// Source: webApp.contentSafeAreaInset || webApp.safe_area_insets
// Normalised to number via parseNumber() — rejects non-finite strings
const topInset = (context.safeArea?.top ?? 0) as number;
```
All views receive `topInset` / `bottomInset` props and add them as explicit `paddingTop` / `paddingBottom` to avoid content being obscured by the Telegram chrome.
### 13.2 Haptic Feedback
```ts
// useTelegramHaptic(webApp) → haptic('light' | 'medium')
webApp?.HapticFeedback?.impactOccurred?.(type)
```
Used on: tab switches (light), new-request CTA (medium), language toggle (light), back button (light), payment actions (medium). All calls are wrapped in try/catch — the API may be absent on older clients.
### 13.3 Back Button
```ts
useTelegramBackButton({ webApp, isVisible, onClick })
// Calls webApp.BackButton.show() / hide() and registers onClick handler
// Cleanup: offClick() on unmount / visibility change
```
### 13.4 Main Button
```ts
useTelegramMainButton({ webApp, isReady: false, text: '', onClick: mainButtonAction })
// isReady is always false — MainButton is intentionally kept hidden.
// The hook is retained so it can be re-enabled without structural changes.
```
### 13.5 External Links
```ts
openTelegramExternalLink(context.webApp, path)
// Uses webApp.openLink() for fully external URLs (opens browser).
// Uses window.location.href for same-origin navigation that must stay in WebView.
```
### 13.6 Theme Integration
Telegram's `themeParams` is normalised (both camelCase and snake_case accepted) and injected as CSS custom properties on the shell root. The amaneh palette overrides these for the Mini App's own UI.
---
## 14. Edge Cases
| Scenario | Detection | Handling |
|---|---|---|
| Opened in browser (not Telegram) | `context.isMiniApp === false` | `TelegramUnsupportedState` — shows "Open in Telegram" badge, web dashboard link |
| Partial Telegram signal (URL params but no SDK) | `!webApp && Boolean(startParam)``isUnsupported: true` | Same unsupported state |
| Telegram SDK injected late | `useTelegramLiveContext` polls at 0/100/500/1000 ms | Re-probes until SDK is ready; seed context bypasses polling |
| `initData` absent (no auth data) | `!context.initData` in unlinked state | Sign-in button triggers error string `t.errors.no_init_data`; email/create buttons remain available |
| Auto sign-in replay (React Strict Mode) | `attemptedInitDataRef.current === context.initData` | Deduplication ref — second effect is a no-op |
| Backend sign-in failure | Catch block in `useTelegramAutoSignIn` | Error string displayed in `TelegramUnlinkedState`; retry via "Continue with Telegram" |
| New user first login | `result.isNewUser === true` | `TelegramOnboardingSheet` shown over the shell; dismissed to account settings or "Later" |
| Expired session inside Mini App | Auth context `user === null` after session check | Shell falls back to `unlinked` state |
| Old Telegram client (< 6.1) | `setParams` throws | Try/catch silences it; button shows without saffron colour |
| RTL + keyboard overlap | Viewport shrinks on soft keyboard open | `flex: 1` + `overflowY: auto` on content area; bottom safe-area inset on tab bar |
| Persian locale date formatting | `lang === 'fa'` | `toLocaleDateString('fa-IR', ...)` in `formatDate` helper |
| Cart cross-tab sync | Multiple tabs / Mini App + web | `tg-cart-changed` DOM event + `storage` event both trigger re-render |
| Template at capacity | `remainingCapacity === 0` at checkout | Stock validation clamps/removes over-capacity items before payment |
| Payment from shop checkout | `paymentCheckoutFlow === true` | BackButton steps back to cart; progress header shows 3-step flow |
| Display name resolution | User may have no name set in DB | Falls back to Telegram profile name (`first_name` / `last_name`), then generic label |
| Seller chat from request detail | `onChatSeller(sellerId)` | `createConversation({ type: 'direct', participantIds: [sellerId] })` → opens chat thread in-shell |
| Assist hand-off on iOS | `webApp.openLink()` opens Safari | `window.location.href` used instead to keep navigation in the Telegram WebView |
---
## 15. File Map
```
src/
app/telegram/page.tsx # Next.js route (thin shell, no auth guard)
utils/telegram-webapp.ts # SDK probe, context types, shell style helpers
sections/telegram/
constants.ts # TG_PALETTE, TG_FONTS, TG_EASE, status maps
telegram-shell-css.ts # buildTelegramShellCss() — inlined CSS blob
avatar-url.ts # avatar URL helper
index.ts # barrel
locales/
types.ts # TelegramDict, TelegramLang, TelegramTabId
en.ts # English strings
fa.ts # Persian strings
index.ts # getTelegramDict(lang)
hooks/
use-telegram-live-context.ts # SDK polling
use-telegram-language.ts # EN/FA detection + ?lang= + localStorage persist
use-telegram-auto-sign-in.ts # initData → JWT exchange
use-telegram-main-button.ts # MainButton lifecycle (kept, isReady=false)
use-telegram-back-button.ts # BackButton lifecycle
use-telegram-haptic.ts # HapticFeedback wrapper
use-telegram-cart.ts # localStorage cart (shared with web checkout)
use-telegram-theme.ts # dark/light theme detection
use-telegram-realtime.ts # shared Socket.IO real-time helper
use-telegram-shops.ts # GET /api/request-templates/sellers
use-telegram-seller-shop.ts # GET /api/request-templates/sellers/:id
use-telegram-sellers.ts # GET /api/marketplace/sellers
use-telegram-my-requests.ts # GET /api/requests
use-telegram-request.ts # GET /api/purchase-requests/:id
use-telegram-offers.ts # GET /api/marketplace/offers?requestId=...
use-telegram-conversations.ts # Chat conversation list
use-telegram-chat-thread.ts # Chat thread + optimistic send
use-telegram-notifications.ts # GET /api/notifications
use-telegram-addresses.ts # GET /api/user/addresses
use-telegram-points.ts # GET /api/user/points
index.ts
view/
telegram-mini-app-view.tsx # Shell orchestrator (all state lives here)
telegram-home-view.tsx # Home tab
telegram-shop-view.tsx # Shop tab — sellers list
telegram-seller-shop-view.tsx # Seller store drill-down + cart actions
telegram-template-detail-view.tsx # Template full detail + cart/order actions
telegram-cart-view.tsx # Cart overlay
telegram-checkout-view.tsx # In-shell 3-step checkout overlay
telegram-payment-view.tsx # In-shell payment drilldown
telegram-requests-view.tsx # Requests list tab
telegram-request-detail-view.tsx # Request drilldown + stepper + offers
telegram-new-request-view.tsx # New request overlay form + Assist CTA
telegram-chat-view.tsx # Chat conversation list tab
telegram-chat-thread-view.tsx # Chat thread drilldown
telegram-archived-chats-view.tsx # Archived conversations
telegram-account-view.tsx # Account + preferences + sign-out tab
telegram-notifications-view.tsx # Notifications overlay
telegram-settings-view.tsx # In-shell profile/settings overlay
telegram-addresses-view.tsx # In-shell address management overlay
telegram-points-view.tsx # In-shell points/loyalty overlay
index.ts
components/
telegram-header.tsx # AMN logo + subtitle + language toggle + bell
telegram-tab-bar.tsx # Bottom tab bar (5 tabs)
telegram-welcome-banner.tsx # Home: escrow account banner + CTA
telegram-quick-actions.tsx # Home: action cards (Requests / Payments / Chat)
telegram-escrow-state-chips.tsx # Home: status chip legend
telegram-shop-row.tsx # Shop: seller list row
telegram-request-row.tsx # Requests: list row
telegram-request-stepper.tsx # Detail: visual escrow timeline
telegram-list-row.tsx # Generic list row primitive
telegram-list-skeleton.tsx # Skeleton loader for lists
telegram-list-controls.tsx # List sort/filter controls
telegram-chat-row.tsx # Chat: conversation list row
telegram-chat-bubble.tsx # Chat: message bubble
telegram-chat-composer.tsx # Chat: message input
telegram-review-prompt.tsx # Post-transaction review prompt
telegram-loading-state.tsx # Loading spinner state
telegram-unlinked-state.tsx # Unlinked / sign-in prompt state
telegram-unsupported-state.tsx # Not-in-Telegram fallback state
telegram-onboarding-sheet.tsx # New-user onboarding bottom sheet
telegram-empty-state.tsx # Generic empty list state
telegram-language-toggle.tsx # EN | FA header toggle
telegram-theme-toggle.tsx # Dark / light theme toggle
telegram-bottom-sheet.tsx # Generic bottom sheet primitive
telegram-form-field.tsx # Form field + input style helper
telegram-cart-fab.tsx # Floating cart badge button
telegram-support-fab.tsx # Floating support chat button
telegram-seal-mark.tsx # SealMark logo component
telegram-icons.tsx # Telegram-scoped icon set
index.ts
```
---
## 16. Current Implementation Status
| Area | Status | Notes |
|---|---|---|
| Shell + state machine | Done | `TelegramMiniAppView` — all states wired |
| SDK probe + live context | Done | Polling + hashchange listener |
| Auto sign-in | Done | Deduped initData exchange |
| Manual sign-in (unlinked) | Done | Email + create account fallbacks |
| Bilingual EN/FA | Done | Full string inventory, RTL layout, Vazirmatn font |
| Language toggle | Done | Header toggle + localStorage persist |
| `?lang=` dev preview param | Done | URL param override |
| Dark mode | Done | `.tg-shell--dark` class, `use-telegram-theme` |
| Home tab | Done | Banner + quick actions + state chips + Assist CTA |
| Shop tab — sellers list | Done | API-backed with skeleton + empty states, cart FAB |
| Shop tab — seller store | Done | Templates list, add/remove cart, template detail drilldown |
| Template detail drilldown | Done | Full detail, cart/order actions |
| Shopping cart (localStorage) | Done | Shared key with web checkout; cross-tab sync |
| Cart overlay | Done | Quantity controls, remove, total, in-shell checkout CTA |
| In-shell checkout | Done | 3-step cart→address→payment; replaces web handoff |
| In-shell payment view | Done | Direct balance intent + polling; checkout-flow back-nav |
| Requests list | Done | API-backed with skeleton + empty states |
| Request detail + stepper | Done | Status timeline, budget, dates with fa-IR locale |
| Offer review in request detail | Done | Offers fetched via `useTelegramOffers`; accept/reject |
| New request form | Done | In-shell overlay, category fetch, validation, Assist CTA |
| Chat list | Done | API-backed conversation list + support row |
| Chat thread | Done | Messages + optimistic send + Socket.IO real-time |
| Direct seller chat | Done | `createConversation` from request detail |
| Account tab | Done | Profile, preferences, help, sign-out |
| Settings overlay | Done | In-shell profile editing |
| Addresses overlay | Done | In-shell address management; reachable from checkout |
| Points overlay | Done | In-shell points/loyalty |
| Notifications overlay | Done | API-backed; Socket.IO real-time; mark-all-read |
| Notifications unread badge | Done | Bell icon in header |
| Telegram chrome (BackButton) | Done | Full back-stack with checkout flow awareness |
| Telegram MainButton | Disabled | Intentionally hidden (`isReady: false`); hook retained |
| Haptic feedback | Done | All tap interactions |
| Safe area insets | Done | Normalised from SDK + CSS env() fallback |
| amanat-assist integration | Done | "Open Assist" CTA in Home + New Request; window.location hand-off with access_token |
| Deep link `startapp` routing | Partial | `startParam` parsed; auto-navigation to specific request not yet wired |
| Backend room-scoped Socket.IO | Partial | Global socket broadcast fixed client-side (v2.8.4); server-side room scoping is a follow-up |
| Dispute filing (in-shell) | Not started | Escape hatch via web dashboard link + support chat; native view planned |
| Review prompt integration | Partial | `TelegramReviewPrompt` component exists; trigger point (post-payment/delivery) not yet wired |
| Archived chats | Partial | `TelegramArchivedChatsView` exists; not yet surfaced in navigation |
| Client matrix QA (iOS/Android/Desktop) | Pending | Needs cross-platform testing pass |
### Open Items
1. **`startapp` deep link routing:** if `context.startParam` matches `req_<id>`, auto-open `TelegramRequestDetailView` on first render. `startParam` is already parsed and available in context; the shell needs a one-time effect on mount to act on it.
2. **Backend room-scoped Socket.IO:** server-side scoping for real-time chat and notification events (follow-up from client-side provider gate in v2.8.4 that fixed global cart-wipe).
3. **In-shell dispute filing:** add `TelegramDisputeView` matching the web dashboard dispute surface; currently only accessible via `openTelegramExternalLink` escape hatch.
4. **Review prompt:** wire `TelegramReviewPrompt` to trigger after payment confirmation or delivery acknowledgement.
5. **Archived chats:** surface `TelegramArchivedChatsView` from `TelegramChatView` (e.g. an "Archived" row at the bottom of the conversation list).
6. **Client matrix QA:** iOS Telegram, Android Telegram, Telegram Desktop, and web clients all need a full feature pass with particular attention to safe-area insets and BackButton behaviour on each platform.
---
## 17. Related Documents
- [[amanat-assist]] — the separate AI-driven Mini App for LLM-assisted request creation
- [[PRD - Telegram Mini App Bilingual (EN + FA)]] — bilingual string inventory and RTL layout spec
- [[PRD - Telegram Phone Number Authentication]] — phone-number auth as a future sign-in path
- [[Authentication Flow]] — JWT lifecycle shared with the Mini App auth
- [[Purchase Request Flow]] — escrow state machine surfaced in the stepper
- [[Chat Flow]] — real-time messaging that the Mini App embeds
- [[Request Template Checkout]] — web checkout flow; the Mini App now has its own in-shell checkout, but the localStorage cart key is shared

View File

@@ -0,0 +1,215 @@
---
title: Tenant Storefront Flow
tags: [flow, tenant, white-label, storefront, multi-tenant]
---
# Tenant Storefront Flow
> **Last updated:** 2026-06-10 — current `feature/white-label-shops` scan.
> Related: [[Tenant]], [[Tenant API]], [[PRD - Seller-Owned White-Label Shops and Bots]]
Describes how a merchant tenant is created, approved, and how buyers land on a tenant storefront.
---
## 1. Tenant onboarding (operator-assisted, Phase 1)
```mermaid
sequenceDiagram
actor Seller
actor Operator
participant API as Backend /api/tenants
participant DB as PostgreSQL
Seller->>API: POST /api/tenants { slug, displayName, brand }
API->>DB: INSERT tenants (status=pending)
API->>DB: INSERT tenant_user_roles (role=owner)
API->>DB: INSERT tenant_payment_policies (default amn_escrow)
API-->>Seller: 201 { tenant, status: "pending" }
Note over Seller,Operator: Operator reviews in admin panel
Operator->>API: POST /api/tenants/:id/activate
API->>DB: UPDATE tenants SET status='active'
API-->>Operator: 200 { tenant, status: "active" }
```
Tenants start as `pending` and are not publicly accessible until a platform admin activates them. This prevents self-provisioning of white-label storefronts.
---
## 2. Domain registration and provisioning
Tenants are accessible at `<slug>.amn.gg` automatically once active. Custom domains are now implemented through DNS verification plus dynamic Caddy Admin API routes in the multi-stack.
```mermaid
sequenceDiagram
actor Seller
participant API as Backend
participant DNS as Seller DNS
participant Caddy as infra-caddy
participant DB as PostgreSQL
Seller->>API: POST /api/tenants/:id/domains { hostname: "shop.example.com" }
API->>DB: INSERT tenant_domains status=pending tlsStatus=pending
API-->>Seller: 201 { domain, status: "pending", verificationToken }
Seller->>DNS: Add CNAME shop.example.com -> multi.amn.gg
Seller->>API: POST /api/tenants/:id/domains/:domainId/verify
API->>DNS: resolve A/CNAME
DNS-->>API: hostname points to configured ingress
API->>Caddy: add route for hostname
API->>DB: UPDATE status=active, tlsStatus=pending
API-->>Seller: 200 { dnsVerified: true }
Seller->>API: POST /api/tenants/:id/domains/:domainId/tls-check
API->>Caddy: HTTPS probe
API->>DB: UPDATE tlsStatus=issued | pending | failed
```
The background poller also runs `verifyAndProvision()` for pending domains and re-checks active domains whose TLS status is still pending. On backend startup, `syncActiveDomains()` replays active domain routes into Caddy because API-injected routes are not the source of truth.
---
## 3. Buyer landing — storefront bootstrap
The frontend fetches `/api/storefront/bootstrap` on every page load. The tenant is resolved entirely server-side from the `Host` header — the browser supplies no tenant hint.
```mermaid
sequenceDiagram
actor Buyer
participant FE as Frontend (TenantProvider)
participant API as GET /api/storefront/bootstrap
participant MW as tenantResolutionMiddleware
participant DB as PostgreSQL
Buyer->>FE: Opens shop.example.com (or seller.amn.gg)
FE->>API: GET /api/storefront/bootstrap
Note right of API: Host: shop.example.com
API->>MW: tenantResolutionMiddleware
MW->>DB: SELECT * FROM tenant_domains WHERE hostname='shop.example.com' AND status='active'
DB-->>MW: domain row
MW->>DB: SELECT * FROM tenants WHERE id=domain.tenantId AND status='active'
DB-->>MW: tenant row
MW-->>API: req.tenant = tenant
API->>DB: SELECT * FROM tenant_payment_policies WHERE tenant_id=...
DB-->>API: policy row
API-->>FE: 200 { tenantId, slug, brand, features, paymentRails, localeDefaults }
FE->>FE: TenantProvider stores bootstrap
FE->>FE: useTenantTheme() derives CSS vars from brand.primaryColor
FE-->>Buyer: Branded storefront renders
```
**Fallback:** If `GET /api/storefront/bootstrap` returns 404 (no tenant for this host), `TenantProvider` uses `AMANAT_DEFAULTS` with `isAmanatDefault: true`. The frontend renders unchanged Amanat branding.
---
## 4. Tenant resolution paths
Three resolution paths are supported simultaneously:
| Host pattern | Example | Resolution method |
| --- | --- | --- |
| `<slug>.amn.gg` | `myshop.amn.gg` | Slug extracted from subdomain label → `findBySlug` |
| Custom CNAME | `shop.example.com` | `findByHostname``findById` |
| Preview (platform only) | `amn.gg/t/:slug/bootstrap` | Slug from URL param, host must be `amn.gg` / `localhost` |
```mermaid
flowchart TD
A[HTTP Request] --> B{Is host platform base?\namn.gg / localhost}
B -- yes + slug param --> C[resolveTenantBySlug\npreviewOnly=true]
B -- yes, no slug --> D[req.tenant = undefined\nAmanat default]
B -- no --> E{Ends with .amn.gg?}
E -- yes, single label --> F[resolveTenantByHost\nfindBySlug]
E -- no --> G[resolveTenantByHost\nfindByHostname]
C --> H{Found?}
F --> H
G --> H
H -- yes --> I[req.tenant = TenantRecord]
H -- no --> D
I --> J[Route handler]
D --> J
```
---
## 5. Telegram bot registration and claim
```mermaid
sequenceDiagram
actor Developer
participant API as POST /api/tenants/:id/telegram/bot
participant BotSvc as tenantBotService
participant TG as Telegram Bot API
participant DB as PostgreSQL
Developer->>API: { botToken, username?, miniAppUrl? }
Note right of Developer: botToken is write-only
API->>BotSvc: registerBot(tenantId, { botToken, username?, miniAppUrl? })
BotSvc->>TG: getMe when username omitted
BotSvc->>BotSvc: AES-256-GCM encrypt(botToken, TENANT_SECRET_KEY)
BotSvc->>BotSvc: generate webhookSecret + claimToken
BotSvc->>DB: INSERT tenant_bots (status=pending, encryptedToken, webhookSecret, claimToken)
BotSvc->>TG: setWebhook /api/telegram/tenant-webhook/:botId
API->>BotSvc: configureBotMenu(bot.id, shopUrl)
BotSvc->>TG: setChatMenuButton -> shopUrl/telegram/
BotSvc-->>API: public bot record with claimUrl
API-->>Developer: 201 { id, telegramBotId, username, status: "pending", claimUrl }
Developer->>TG: Open claimUrl and send /start <claimToken>
TG->>API: POST /api/telegram/tenant-webhook/:botId with secret header
API->>BotSvc: claimAdmin(botId, claimToken, telegramUserId)
BotSvc->>DB: UPDATE status=active, adminTelegramUserId
BotSvc->>TG: send confirmation message
```
---
## 6. Payment policy
Payment rails available to a tenant's buyers are controlled by `tenant_payment_policies`.
```mermaid
flowchart LR
PP[tenant_payment_policies] -->|allowedRails| R{Buyer checkout}
R -->|amn_escrow| E[Amanat escrow — full protection]
R -->|amn_direct| D[Amanat scanner — no escrow hold\nstrict buyer disclosure required]
R -->|external_provider| X[External processor — Amanat records evidence only]
R -->|manual_invoice| M[Operator / merchant confirms payment]
```
`buyerDisclosureMode = 'strict'` (default) mandates a prominent "not escrow protected" notice when `amn_direct` or external rails are used. The frontend reads `features.escrowCheckout` / `features.directCheckout` from the bootstrap payload to decide which checkout paths to expose.
---
## 7. Frontend context tree
```
<TenantProvider> ← fetches bootstrap, provides useTenant()
<ThemeProvider> ← existing MUI theme
<App>
useTenant() ← brand, features, paymentRails
useTenantTheme() ← primaryColor, cssVars (--tenant-primary)
```
`TenantProvider` wraps the application shell. All downstream components read tenant context via `useTenant()`. No tenant-specific props need to be threaded through the component tree.
---
## Phase roadmap
| Phase | What ships | Status |
| --- | --- | --- |
| 0 | Drizzle schema (6 tables), enums, repositories, tenant auth roles | ✅ `feature/white-label-shops` |
| 1 | Hosted subdomain (`seller.amn.gg`), tenant bootstrap endpoint, `TenantProvider`, admin tenant UI | ✅ `feature/white-label-shops` |
| 2 | Custom domain + DNS verification + Caddy route + TLS status checks | ✅ `feature/white-label-shops` |
| 3 | Tenant Telegram bot token storage, webhook registration, menu button, admin claim link | Partial — implemented for claim activation; multi-bot notification routing still planned |
| 4 | `amn_direct` payment rail + buyer disclosure | ⬜ Planned |
| 5 | Catalog / delivery / external payment adapters, billing events, stronger isolation | ⬜ Planned |
Related: [[Tenant]], [[Tenant API]], [[PRD - Seller-Owned White-Label Shops and Bots]], [[Escrow Flow]], [[Telegram Mini App]].

View File

@@ -112,6 +112,24 @@ TREZOR_SAFEKEEPING_REQUIRED=false
Default is permissive so existing Request Network release/refund flows continue to work. Set it to `true` only after registering the operating admin's Trezor account (the frontend signing flow via `TrezorSignDialog` is already implemented). Any value other than the literal string `true` is treated as disabled.
## Break-Glass Mode (Emergency Bypass)
When `TREZOR_SAFEKEEPING_REQUIRED=true` but the Trezor device is unavailable (lost, hardware fault, key-holder absent), an admin can activate **break-glass mode** to temporarily bypass the safekeeping requirement:
| Endpoint | Action |
|---|---|
| `GET /api/admin/settings/break-glass` | Read current status (`active`, `expiresAt`, `activatedBy`) |
| `POST /api/admin/settings/break-glass` | Activate for **1 hour** — fires a Telegram alarm immediately |
| `DELETE /api/admin/settings/break-glass` | Cancel before expiry |
**Properties:**
- State is in-memory only (resets on server restart — intentional).
- Activation fires a Telegram alert via `tgNotify` regardless of `TG_NOTIFY_BOT_TOKEN` set status.
- The exported `isBreakGlassActive()` helper is called by `assertTrezorSignatureForOperation` — when `true`, the signature check is skipped.
- Maximum duration: 1 hour. After expiry the guard is automatically re-enabled.
**Source:** `backend/src/services/admin/breakGlassRoutes.ts` (commit `b21df25`).
## Safety Rules
- Never store Trezor seed words, private keys, or xprv/tprv values.

View File

@@ -2,12 +2,26 @@
title: Colors
tags: [design-system, colors, palette]
created: 2026-05-23
updated: 2026-05-30
---
# Colors
The palette is built from semantic groups (`primary`, `secondary`, `info`, `success`, `warning`, `error`, plus a 9-step `grey` scale) and exposed via the MUI theme. **Never hard-code hex values in components.**
> [!info] Amaneh Design System v2.7.0 (commit 56fc84e)
> As of v2.7.0 the active palette is the **Amaneh warm-earth** preset. The color presets menu in the settings drawer has been simplified to a single Amaneh entry; the multi-swatch picker was removed. The canonical palette names are:
> - **Saffron** — `primary` (golden-amber)
> - **Pistachio** — `success` (soft green)
> - **Persian Blue** — `info` (deep indigo-blue)
> - **Honey** — `warning` (amber-gold)
> - **Pomegranate** — `error` (deep red)
> - **Cream paper** — `background.paper`
> - **Parchment** — `background.default`
> - **Warm Ink** — `text.primary`
>
> CSS custom properties under `--amn-*` are defined in `src/app/global.css` and mirror these tokens for non-MUI elements.
> [!warning]
> Hardcoded colors break dark mode and any future preset switch. Use `sx={{ color: 'primary.main' }}` or `theme.palette.primary.main`.

View File

@@ -2,10 +2,14 @@
title: Design System Overview
tags: [design-system, ui, mui]
created: 2026-05-23
updated: 2026-05-30
---
# Design System Overview
> [!info] Current version: **Amaneh v2.7.0** (commit 56fc84e, 2026-05-29)
> Major full-app redesign. Key changes: warm-earth palette (Saffron / Pistachio / Persian Blue / Honey / Pomegranate), three-font stack (Source Serif 4 italic / IBM Plex Sans / IBM Plex Mono), SealMark SVG logo (saffron octagon + serif italic wordmark), CSS custom properties (`--amn-*`) in `global.css`, settings-drawer preset picker simplified to single Amaneh entry.
The frontend design system is built on **Material-UI v7** with project-specific tokens, an LTR + RTL-aware emotion cache, and a user-controllable settings drawer (mode, layout, color preset, font, direction).
> [!info]

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<clipPath id="circle-clip">
<circle cx="256" cy="256" r="256"/>
</clipPath>
</defs>
<!-- Background -->
<circle cx="256" cy="256" r="256" fill="#ffffff"/>
<!-- Outer group: clip in SVG coordinates. Inner group: transform logo paths. -->
<g clip-path="url(#circle-clip)">
<g transform="translate(64,194) scale(0.557)">
<rect x="220" y="-1" width="55" height="223" fill="#105E51"/>
<path d="M98.5 12.5L4 223H49.5L56 221L63 218L68 214.5L72.5 210L162 8.5L154 5L150 3.5L146 2L141.5 1H135H123.5L113 3.5L104 7.5L98.5 12.5Z" fill="#125B50"/>
<path d="M98.5 12.5L4 222.5L15.5 206.5L19.5 202L28.5 194.5L36.5 190L45 186.5L54.5 183.5L61 181.5L68 180.5L74.5 180H181L158 124H110L128.743 83L98.5 12.5Z" fill="#238E77"/>
<path d="M192 212.5L107.5 6L114.5 3L124 1L133.5 0L142.5 1L152 4L161.5 8.5L170.5 16L177 23L254.5 204L258.5 211L263.5 215.5L268.5 220.5L275 223H210L200.5 219.5L192 212.5Z" fill="#2A9F86"/>
<path d="M4 223L18 203.5L28.5 195L38.5 189L50 185L61.5 181.5L74 180H86L80.5 191.5L61.5 193L51.5 195L41 198.5L20.5 209L4 223Z" fill="#093F35"/>
<path d="M473.5 172V0H417L416 0.5V221.5H432.5L442.5 219.5L449.5 216.5L457.5 209.5L464.5 202.5L469.5 192.5L472.5 182.5L473.5 172Z" fill="#2A9F86"/>
<path d="M335.5 221H358.5L365 217.5L371.5 213L380 202L452 19L459 9L464 4L468.5 1.5L473.5 -1H418.5L407 4L395.5 11.5L387 22L317 206L324 214.5L335.5 221Z" fill="#218D78"/>
<path d="M358 222H335L328.5 218.5L322 214L313.5 203L241.5 20L234.5 10L229.5 5L225 2.5L220 0H275L286.5 5L298 12.5L306.5 23L376.5 207L369.5 215.5L358 222Z" fill="#29A086"/>
<path d="M568 48.5V217L579 220.5H625.5V-1H609L599 1L592 4L584 11L577 18L572 28L569 38L568 48.5Z" fill="#2A9F86"/>
<path d="M498 14.366L625.5 220.866H579L567 216.866L559.5 212.866L553 208.366L547 199.866L473 82.866V-5.20115e-06L481 2.36602L486 4.86602L493 9.5L498 14.366Z" fill="#186D5D"/>
<circle cx="659.5" cy="195.5" r="26.5" fill="#299B84"/>
<path d="M155.5 124L178.5 180H164L141.5 124H155.5Z" fill="#093C31"/>
<path d="M142 124L165 180H150.5L128 124H142Z" fill="#125B50"/>
<path d="M220 123V109L268.5 215L271 218.5L275 222H272L266 220.5L261 217L257.5 211L220 123Z" fill="#093C31"/>
<path d="M275 105V119L226.5 8L224 4.5L220 1H223L229 2.5L234 6L239 13.5L275 105Z" fill="#093C31"/>
<path d="M351.5 115.5L384 192L380.5 200.5L376.5 206.5L347 128L351.5 115.5Z" fill="#19725F"/>
<path d="M473 0.5V83.5L483.5 99.5V3.5L479 1.5L473 0.5Z" fill="#093C31"/>
<path d="M483.5 3.5V99.5L494.5 117L493.5 10L489 6.5L483.5 3.5Z" fill="#125B50"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,37 @@
<svg width="700" height="231" viewBox="0 0 700 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#amn-full-clip)" filter="url(#amn-full-shadow)">
<rect x="220" y="-1" width="55" height="223" fill="#105E51"/>
<path d="M98.5 12.5L4 223H49.5L56 221L63 218L68 214.5L72.5 210L162 8.5L154 5L150 3.5L146 2L141.5 1H135H123.5L113 3.5L104 7.5L98.5 12.5Z" fill="#125B50"/>
<path d="M98.5 12.5L4 222.5L15.5 206.5L19.5 202L28.5 194.5L36.5 190L45 186.5L54.5 183.5L61 181.5L68 180.5L74.5 180H181L158 124H110L128.743 83L98.5 12.5Z" fill="#238E77"/>
<path d="M192 212.5L107.5 6L114.5 3L124 1L133.5 0L142.5 1L152 4L161.5 8.5L170.5 16L177 23L254.5 204L258.5 211L263.5 215.5L268.5 220.5L275 223H210L200.5 219.5L192 212.5Z" fill="#2A9F86"/>
<path d="M4 223L18 203.5L28.5 195L38.5 189L50 185L61.5 181.5L74 180H86L80.5 191.5L61.5 193L51.5 195L41 198.5L20.5 209L4 223Z" fill="#093F35"/>
<path d="M473.5 172V0H417L416 0.5V221.5H432.5L442.5 219.5L449.5 216.5L457.5 209.5L464.5 202.5L469.5 192.5L472.5 182.5L473.5 172Z" fill="#2A9F86"/>
<path d="M335.5 221H358.5L365 217.5L371.5 213L380 202L452 19L459 9L464 4L468.5 1.5L473.5 -1H418.5L407 4L395.5 11.5L387 22L317 206L324 214.5L335.5 221Z" fill="#218D78"/>
<path d="M358 222H335L328.5 218.5L322 214L313.5 203L241.5 20L234.5 10L229.5 5L225 2.5L220 0H275L286.5 5L298 12.5L306.5 23L376.5 207L369.5 215.5L358 222Z" fill="#29A086"/>
<path d="M568 48.5V217L579 220.5H625.5V-1H609L599 1L592 4L584 11L577 18L572 28L569 38L568 48.5Z" fill="#2A9F86"/>
<path d="M498 14.366L625.5 220.866H579L567 216.866L559.5 212.866L553 208.366L547 199.866L473 82.866V-5.20115e-06L481 2.36602L486 4.86602L493 9.5L498 14.366Z" fill="#186D5D"/>
<circle cx="659.5" cy="195.5" r="26.5" fill="#299B84"/>
<path d="M155.5 124L178.5 180H164L141.5 124H155.5Z" fill="#093C31"/>
<path d="M142 124L165 180H150.5L128 124H142Z" fill="#125B50"/>
<path d="M220 123V109L268.5 215L271 218.5L275 222H272L266 220.5L261 217L257.5 211L220 123Z" fill="#093C31"/>
<path d="M275 105V119L226.5 8L224 4.5L220 1H223L229 2.5L234 6L239 13.5L275 105Z" fill="#093C31"/>
<path d="M351.5 115.5L384 192L380.5 200.5L376.5 206.5L347 128L351.5 115.5Z" fill="#19725F"/>
<path d="M473 0.5V83.5L483.5 99.5V3.5L479 1.5L473 0.5Z" fill="#093C31"/>
<path d="M483.5 3.5V99.5L494.5 117L493.5 10L489 6.5L483.5 3.5Z" fill="#125B50"/>
</g>
<defs>
<filter id="amn-full-shadow" x="0" y="0" width="704" height="235" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.22 0 0 0 0 0.12 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<clipPath id="amn-full-clip">
<rect width="696" height="223" fill="white" transform="translate(4)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,28 @@
<svg width="285" height="228" viewBox="0 0 285 228" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="285" height="228" rx="22" fill="#FBF6EB"/>
<g clip-path="url(#amn-single-clip)" filter="url(#amn-single-shadow)">
<path d="M98.5 12.5L4 223H49.5L56 221L63 218L68 214.5L72.5 210L162 8.5L154 5L150 3.5L146 2L141.5 1H135H123.5L113 3.5L104 7.5L98.5 12.5Z" fill="#7A1D00"/>
<path d="M98.5 12.5L4 222.5L15.5 206.5L19.5 202L28.5 194.5L36.5 190L45 186.5L54.5 183.5L61 181.5L68 180.5L74.5 180H181L158 124H110L128.743 83L98.5 12.5Z" fill="#B04010"/>
<path d="M192 212.5L107.5 6L114.5 3L124 1L133.5 0L142.5 1L152 4L161.5 8.5L170.5 16L177 23L254.5 204L258.5 211L263.5 215.5L268.5 220.5L275 223H210L200.5 219.5L192 212.5Z" fill="#C2410C"/>
<path d="M4 223L18 203.5L28.5 195L38.5 189L50 185L61.5 181.5L74 180H86L80.5 191.5L61.5 193L51.5 195L41 198.5L20.5 209L4 223Z" fill="#3D0B00"/>
<path d="M155.5 124L178.5 180H164L141.5 124H155.5Z" fill="#3D0B00"/>
<path d="M142 124L165 180H150.5L128 124H142Z" fill="#7A1D00"/>
<path d="M220 123V109L268.5 215L271 218.5L275 222H272L266 220.5L261 217L257.5 211L220 123Z" fill="#3D0B00"/>
<path d="M275 105V119L226.5 8L224 4.5L220 1H223L229 2.5L234 6L239 13.5L275 105Z" fill="#3D0B00"/>
</g>
<defs>
<filter id="amn-single-shadow" x="-4" y="-4" width="300" height="240" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.24 0 0 0 0 0.11 0 0 0 0 0 0 0 0 0.3 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<clipPath id="amn-single-clip">
<rect x="0" y="0" width="285" height="228" rx="22"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,286 @@
---
title: Oracle Depeg Checkout — UI Implementation Guide
status: implementation-ready with backend-contract caveats (reconciled with backend integrate-main-into-development@3a50dc4)
audience: frontend
stack: Next.js 16 (App Router) · React 19 · TypeScript · MUI 7 + Emotion · SWR · axios · Socket.io · i18next (fa default, RTL)
related: ../01 - Architecture/Oracle Pricing & Stablecoin Depeg Protection.md
---
# Oracle Depeg Checkout — UI Implementation Guide
> Goal: a frontend dev can implement the depeg-protected checkout step **straight from this doc**. It maps every piece to the real codebase (file:line), gives the API contract + TypeScript types, the component tree (MUI), the state machine, display/formatting/RTL rules, error UX with copy (en + fa), wireframes, and acceptance criteria.
>
> **Backend reality 2026-05-31:** there is no separate read-only quote preview endpoint yet. The committed backend computes and returns a quote from `POST /api/payment/request-network/intents` when `ORACLE_QUOTING_ENABLED=true`; it also writes `payment_quotes` only if a PG parent payment row can be resolved. Mongo remains the runtime source for the Payment itself. See [[Postgres Runtime Cutover Status]].
## 0. What the buyer experiences (plain English)
The seller priced the item in some currency (e.g. **IRR / TRY / USD**). The buyer picks a **settlement stablecoin + chain** (USDC/USDT on an allow-listed chain). The app fetches a **live quote**: it converts the invoice to the token at the oracle rate, **adds depeg protection** (if USDC trades at \$0.97 the buyer pays ~3% more so the seller is made whole), and **snaps to a human-readable amount** when one is within 3%. The buyer sees exactly **what they pay, in what token, ≈ the invoice currency**, the depeg adjustment, and a short **expiry countdown**. On confirm, the payment intent is created against that locked quote.
## 1. Where it slots into the existing checkout
Existing flow (`src/sections/request-template/`): **cart → billing → payment → complete** (`view/request-template-checkout-view.tsx:20-77`, provider `context/request-template-checkout-provider.tsx`).
**Recommended:** insert a **Quote** sub-step at the top of the existing **payment** step (Option B from the explore — least restructuring). The token/chain selector already lives in the payment step (`request-template-checkout-payment.tsx``ProviderPayment`); we add the quote panel directly above the pay button and **block payment until a valid (non-expired, non-blocked) quote exists**.
| Touch point | File | Change |
|---|---|---|
| Token/chain selection | `request-template-checkout-payment.tsx` (ProviderPayment child) | On (token, chain) change → fetch quote |
| Order summary | `request-template-checkout-payment.tsx:985-1050` | Add quote card + depeg banner + expiry timer above pay button |
| Checkout state | `context/types.ts` | Add `oracleQuote` + `quoteStatus` fields |
| API endpoints | `src/lib/axios.ts` (`endpoints` object, ~188-511) | Add `endpoints.payments.quote` |
| Types | `src/types/payment.ts` | Add `IPaymentQuote` (below) |
| Currency format | `src/utils/currencyUtils.ts` | Add fiat formatting (IRR/TRY) + dual-amount helper |
## 2. API contract
> The amount is computed **server-side**; the UI never sends or trusts a money amount. Field names below include the target preview contract plus the currently committed `/intents` behavior.
### 2.1 Future `POST /payment/quote` — preview a quote (read-only, no Payment created)
> [!warning] Not implemented in backend `3a50dc4`
> Build the first frontend integration against `POST /api/payment/request-network/intents` for now. Add this preview endpoint later if the UX needs live quotes before creating/updating a pending Payment.
Request:
```jsonc
{
"purchaseRequestId": "…", // or sellerOfferId / template session id, matching current /intents inputs
"sellerOfferId": "…",
"token": "USDC", // buyer's chosen settlement token
"network": "bsc" // chain key (must be in the seller allowlist)
}
```
Success `200`:
```jsonc
{
"quote": {
"quoteId": "q_…",
"pricingCurrency": "IRR", // the invoice/obligation currency
"offerAmount": "1500000.00", // decimal STRING, in pricingCurrency
"invoiceUsd": "35.50", // decimal string
"token": "USDC",
"chainId": 56,
"tokenPriceUsd": "0.971", // depeg oracle price (decimal string)
"fxRate": "0.0000236", // pricingCurrency → USD (decimal string)
"rawSettleAmount": "36.56", // exact depeg-protected token amount (decimal string)
"settleAmount": "37.00", // amount the buyer pays (after snap-up rounding) — decimal string
"settleAmountOnChain": "37000000000000000000", // base units (per-chain decimals) — string
"depegAdjustmentBps": 299, // +2.99% vs par (number)
"roundingBps": 120, // rounding delta vs rawSettle (number, >=0)
"fxSource": "offchain_fx",
"depegSource": "chainlink",
"fetchedAt": "2026-05-31T12:00:00.000Z",
"expiresAt": "2026-05-31T12:01:30.000Z" // QUOTE_VALIDITY_S after fetchedAt
}
}
```
Error `4xx` (typed `code`):
```jsonc
{ "error": { "code": "DEPEG_LIMIT_EXCEEDED", "message": "…", "details": { "tokenPriceUsd": "0.93", "capBps": 500 } } }
```
### 2.2 Intent creation (committed route, now quote-capable)
`POST /api/payment/request-network/intents` and the amn.scanner path (`src/lib/axios.ts``endpoints.payments.requestNetwork.intents`). Current committed behavior:
- The server **recomputes/validates the amount** from the offer + a fresh quote; any client `amount` is ignored.
- The response can include `quote` fields when `ORACLE_QUOTING_ENABLED=true`.
- Quote persistence is best-effort PG + Mongo mirror. If the PG payment row is not present yet, the quote is mirrored to Mongo and a `pg_dualwrite_gaps` row is recorded.
- Binding a separately previewed `quoteId` is future work, because the read-only preview endpoint is not committed yet.
### 2.3 Error codes the UI must handle
| `code` | Meaning | UI behavior |
|---|---|---|
| `DEPEG_LIMIT_EXCEEDED` | Stablecoin off peg beyond hard cap | Block pay; show warning banner; offer "try another token/chain" or "retry later" |
| `ORACLE_UNAVAILABLE` | No provider could price the pair | Block pay; "pricing temporarily unavailable, retry"; auto-retry w/ backoff |
| `ORACLE_STALE` | Rate too old | Same as unavailable; auto-refetch |
| `QUOTE_EXPIRED` / `QUOTE_MOVED` | Locked quote no longer valid at submit | Re-quote; if amount moved > threshold, require explicit re-confirm |
| `PAYMENT_CHOICE_NOT_ALLOWED` | token/chain not in seller allowlist | Disable that option in the selector |
## 3. TypeScript types (add to `src/types/payment.ts`)
```ts
export type QuoteErrorCode =
| 'DEPEG_LIMIT_EXCEEDED' | 'ORACLE_UNAVAILABLE' | 'ORACLE_STALE'
| 'QUOTE_EXPIRED' | 'QUOTE_MOVED' | 'PAYMENT_CHOICE_NOT_ALLOWED';
export interface IPaymentQuote {
quoteId: string;
pricingCurrency: string; // 'IRR' | 'TRY' | 'USD' | 'EUR' | 'USDT' | 'USDC'
offerAmount: string; // decimal string, in pricingCurrency
invoiceUsd: string;
token: 'USDC' | 'USDT';
chainId: number;
tokenPriceUsd: string;
fxRate: string;
rawSettleAmount: string;
settleAmount: string; // what the buyer pays (display this)
settleAmountOnChain: string; // base units
depegAdjustmentBps: number; // + = buyer pays more (depeg), - = buyer pays less (premium)
roundingBps: number;
fxSource: string;
depegSource: string;
fetchedAt: string; // ISO
expiresAt: string; // ISO
}
export type QuoteStatus =
| 'idle' | 'loading' | 'quoted' | 'expired' | 'requoting' | 'blocked' | 'unavailable';
```
> **All money/rate fields are decimal strings.** Never `parseFloat` them for math — only for display formatting. If you must do arithmetic (you shouldn't on the client), use a decimal lib; the authoritative amount is always `settleAmount` from the server.
## 4. Data layer (SWR + axios)
Add to the `endpoints` object in `src/lib/axios.ts`:
```ts
payments: {
// …existing…
quote: '/payment/quote',
}
```
Hook (`src/actions/payment-quote.ts`), mirroring the existing SWR convention:
```ts
import useSWR from 'swr';
import { axiosInstance, endpoints, fetcher } from 'src/lib/axios';
import type { IPaymentQuote } from 'src/types/payment';
export function usePaymentQuote(args: { purchaseRequestId?: string; sellerOfferId?: string; token?: string; network?: string } | null) {
// POST-based quote: use a tuple key + a custom fetcher (SWR mutate on (token,network) change)
const key = args && args.token && args.network ? ['payment-quote', args] as const : null;
const { data, error, isLoading, mutate } = useSWR(key, async ([, body]) => {
const res = await axiosInstance.post(endpoints.payments.quote, body);
return res.data.quote as IPaymentQuote;
}, {
refreshInterval: 0, // we drive refresh off the expiry timer, not polling
revalidateOnFocus: false,
shouldRetryOnError: false, // typed errors are handled by the caller, not retried blindly
});
return { quote: data, error, isLoading, refetch: mutate };
}
```
- **Refetch on:** (token, chain) change, manual "refresh rate", and on expiry (timer hits 0 → `refetch()``requoting`).
- **Map axios errors** to `QuoteErrorCode` via `err.response?.data?.error?.code`.
## 5. Component tree (MUI)
```
<OracleQuotePanel> // new — src/sections/request-template/checkout-oracle-quote.tsx
├─ <TokenChainSelector/> // reuse/extend ProviderPayment's Select; disable disallowed (allowlist)
├─ <QuoteSummaryCard quote=… status=…>// the headline: "You pay 37.00 USDC ≈ ﷼1,500,000"
│ ├─ dual amount (token primary, pricingCurrency secondary)
│ ├─ <DepegBadge bps=…/> // "+2.99% depeg protection" (Chip)
│ ├─ rounding note ("rounded up 0.44 to 37.00")
│ └─ <QuoteExpiryTimer expiresAt=… onExpire=refetch/>
├─ <DepegWarningBanner code=…/> // Alert when blocked/unavailable
└─ used by the pay button: disabled unless status==='quoted'
```
### 5.1 `QuoteSummaryCard` (MUI `Card`)
Props: `{ quote: IPaymentQuote; status: QuoteStatus }`.
- Primary line (`Typography variant="h5"`, `dir="ltr"`): **`{settleAmount} {token}`**.
- Secondary (`body2`, muted): **`≈ {offerAmount} {pricingCurrency}`** (formatted, RTL-aware — see §6).
- Row of `Chip`s: depeg badge, network, "rate locked · {countdown}".
- If `status==='loading'|'requoting'` → MUI `Skeleton` rows.
- If `roundingBps>0` → small caption: "Rounded up to a round number (within 3%)".
### 5.2 `DepegBadge` (MUI `Chip`)
- `depegAdjustmentBps > 0` → color `warning`, label "Depeg protection +{bps/100}%", tooltip "Your stablecoin trades below \$1; you pay slightly more so the seller receives the full {pricingCurrency} value."
- `depegAdjustmentBps < 0` → color `success`, label "Premium {|bps|/100}%", tooltip "Your stablecoin trades above \$1; you pay slightly less."
- `=== 0` → hide or neutral "At peg".
### 5.3 `QuoteExpiryTimer`
- Counts down to `expiresAt`. At `T-15s` turn amber; at `0` call `onExpire()` (sets `requoting`, refetches). Show "Refresh rate" button always.
### 5.4 `DepegWarningBanner` (MUI `Alert`)
- `DEPEG_LIMIT_EXCEEDED` → severity `error`, "We can't price {token} safely right now (it's {x}% off peg). Try another token/chain or retry shortly." + retry button.
- `ORACLE_UNAVAILABLE`/`ORACLE_STALE` → severity `warning` + auto-retry spinner.
## 6. Formatting, RTL & i18n
- **Amounts are LTR even in RTL layouts** — wrap every number/token/hash in `dir="ltr"` (project convention, see explore §4 + CLAUDE.md). The card layout flips with the theme (`stylis-plugin-rtl`), but the numerals don't.
- Extend `src/utils/currencyUtils.ts`:
```ts
// fiat display: IRR/TRY have no decimals typically; group thousands per locale
export function formatFiat(amount: string, currency: string, locale?: string): string;
// dual display helper used by the card
export function formatPayLine(quote: IPaymentQuote): { primary: string; secondary: string };
```
- IRR: symbol ﷼, 0 decimals, fa-IR grouping. TRY: ₺, 2 decimals. USDT/USDC: 2 decimals.
- **i18n keys** (add to `src/locales/langs/{en,fa}/messages.json`), e.g.:
```
checkout.quote.youPay = "You pay {{amount}} {{token}}"
checkout.quote.approx = "≈ {{amount}} {{currency}}"
checkout.quote.depegProtection = "Depeg protection +{{pct}}%"
checkout.quote.premium = "Premium {{pct}}%"
checkout.quote.roundedUp = "Rounded up to {{amount}} {{token}}"
checkout.quote.expiresIn = "Rate locked · {{seconds}}s"
checkout.quote.refresh = "Refresh rate"
checkout.quote.err.depegCap = "Can't price {{token}} safely ({{pct}}% off peg). Try another token or retry."
checkout.quote.err.unavailable = "Pricing temporarily unavailable. Retrying…"
checkout.quote.err.expired = "Rate updated — please review the new amount."
```
Persian (`fa`) is the default locale — provide fa strings too.
## 7. State machine
```
idle ──(token+chain chosen)──► loading ──ok──► quoted ──(timer 0)──► requoting ──ok──► quoted
│ │ │
└─err─► unavailable/blocked └─err─► unavailable/blocked
quoted ──(buyer confirms)──► [POST intents] ──QUOTE_EXPIRED/MOVED──► requoting (then re-confirm if moved > 50 bps)
└─ok──► existing payment-pending flow (Socket.io)
```
- **Pay button** enabled **only** in `quoted`. In `blocked`/`unavailable` it's disabled with the banner explaining why.
- After intent creation succeeds, hand off to the **existing** Socket.io payment-status flow (`request-template-checkout-payment.tsx:457-736`) — unchanged.
## 8. Wireframe (quoted, depeg case)
```
┌─────────────────────────────────────────────┐
│ Pay with: [ USDC ▾ ] on [ BSC ▾ ] │
├─────────────────────────────────────────────┤
│ You pay │
│ 37.00 USDC ← h5, dir=ltr │
│ ≈ ﷼1,500,000 ← muted, RTL-aware │
│ │
│ [ +2.99% depeg protection ] [ BSC ] │
│ Rounded up to a round number (within 3%) │
│ Rate locked · 01:18 [ Refresh rate ] │
├─────────────────────────────────────────────┤
│ [ Pay 37.00 USDC ] │
└─────────────────────────────────────────────┘
Blocked (DEPEG_LIMIT_EXCEEDED):
┌─────────────────────────────────────────────┐
│ ⚠ Can't price USDC safely (7% off peg). │
│ Try another token/chain or retry shortly. │
│ [ Retry ] [ Change token ] │
└─────────────────────────────────────────────┘
```
## 9. Edge cases
- **Token/chain not allowed** → disable the option (`PAYMENT_CHOICE_NOT_ALLOWED`), don't even quote.
- **Quote expires while buyer idles** → auto-`requoting`; if `settleAmount` moves > 50 bps, surface "Rate updated — review new amount" before re-enabling pay.
- **Network flip mid-quote** → cancel in-flight quote (SWR key change handles it), show skeleton.
- **Premium (token > \$1)** → show green "Premium x%", buyer pays less; never below the obligation.
- **Decimal precision** → display rounds for humans, but submit/track uses the server `settleAmount`/`settleAmountOnChain` strings verbatim.
- **Slow oracle** → skeleton + "fetching live rate"; don't show a stale amount.
## 10. Acceptance criteria
1. Changing token or chain refetches a quote; pay is disabled until `quoted`.
2. Displayed amount always equals server `settleAmount`; the client never computes or sends an amount.
3. Depeg up shows a warning-colored badge and a higher token amount; premium shows a success badge and a lower amount; seller is never shorted.
4. Beyond the hard cap, pay is blocked with a clear banner (no silent overcharge).
5. Expiry countdown works; on expiry it re-quotes; a > 50 bps move forces re-confirm.
6. Amounts render `dir="ltr"` inside the RTL (fa) layout; fa + en strings present.
7. Successful confirm transitions into the existing Socket.io payment-status UI unchanged.
## 11. Backend dependencies to confirm (with backend `integrate-main-into-development@3a50dc4`)
- [ ] `POST /payment/quote` preview endpoint exists and returns the shape in §2.1 (add it if the build only wired the `/intents` seam).
- [ ] Intent route accepts/validates `quoteId` and returns `QUOTE_EXPIRED`/`QUOTE_MOVED`.
- [ ] Typed error `code`s in §2.3 are emitted.
- [ ] `TRY` (and any other) pricing currencies enabled.
- [ ] Final field names match §3 (this doc will be reconciled to the committed code).
```

View File

@@ -21,7 +21,7 @@ A drawer-based UI lets the end user toggle visual preferences. Settings persist
| **Contrast** | `default` · `bold` | `default` | localStorage |
| **Layout** | `vertical` · `mini` · `horizontal` | `vertical` | localStorage |
| **Direction** | `ltr` · `rtl` | derived from locale | localStorage (overrides locale default) |
| **Color preset** | one of `default`, `purple`, `cyan`, `blue`, `orange`, `red` | `default` | localStorage |
| **Color preset** | `amaneh` (warm-earth) — multi-swatch picker removed in v2.7.0 | `amaneh` | localStorage |
| **Font family** | `Public Sans Variable`, `DM Sans Variable`, `Inter Variable`, `Nunito Sans Variable` | `Public Sans Variable` | localStorage |
| **Compact navigation** | boolean | `false` | localStorage |
| **Border radius** | 024 | 8 | localStorage |

View File

@@ -2,10 +2,14 @@
title: Theme Configuration
tags: [design-system, theme, mui]
created: 2026-05-23
updated: 2026-05-30
---
# Theme Configuration
> [!info] Amaneh v2.7.0 (commit 56fc84e)
> The active theme now uses the Amaneh warm-earth palette and the three-font stack (Source Serif 4 / IBM Plex Sans / IBM Plex Mono). MUI component overrides were updated for `Button`, `Card`, `Paper`, `AppBar`, `Chip`, and `Label`. The settings-drawer color-preset swatch picker was simplified to a single Amaneh entry.
The MUI theme is constructed in `frontend/src/theme/index.ts` and composed from option modules in `frontend/src/theme/options/`. The resulting theme is provided at the root layout, wrapped by an RTL-aware emotion cache.
---

View File

@@ -2,39 +2,45 @@
title: Typography
tags: [design-system, typography, fonts]
created: 2026-05-23
updated: 2026-05-30
---
# Typography
The system uses **Public Sans Variable** as the primary face with **Barlow** as a secondary (display) face, plus locale-specific Persian/Arabic faces loaded when the active language requires them.
> [!info] Amaneh Design System v2.7.0 (commit 56fc84e)
> The font stack changed in v2.7.0 from Public Sans + Barlow to a **three-font purposeful stack**:
> - **Source Serif 4** — headings in italic; editorial, humanist character
> - **IBM Plex Sans** — body and UI text; technical clarity, RTL-compatible
> - **IBM Plex Mono** — amounts, wallet addresses, tx hashes; monospaced, tabular-nums built-in
The system uses a three-font purposeful stack for the Amaneh design. Locale-specific Persian/Arabic faces are loaded when the active language requires them.
---
## 1. Font stack
Loaded via `@fontsource-variable` (variable fonts streamed at build) plus `@fontsource/barlow`. Confirm in `frontend/package.json`:
Loaded via `@fontsource-variable`. Current active fonts (`frontend/package.json`):
```jsonc
"@fontsource-variable/public-sans": "^5.2.5", // Primary
"@fontsource-variable/dm-sans": "^5.2.5", // Optional preset
"@fontsource-variable/inter": "^5.2.5", // Optional preset
"@fontsource-variable/nunito-sans": "^5.2.5", // Optional preset
"@fontsource/barlow": "^5.2.5", // Secondary (display)
"@fontsource-variable/source-serif-4": "...", // Headings (italic)
"@fontsource/ibm-plex-sans": "...", // UI / body
"@fontsource/ibm-plex-mono": "...", // Amounts, addresses, hashes
```
Imported in `frontend/src/app/layout.tsx` (or a fonts module) so Next can fingerprint and preload them.
The settings drawer still lists alternative fonts (DM Sans, Inter, Nunito Sans, Public Sans) for user override.
Default font-family stack in the theme:
```css
font-family: "Public Sans Variable", "Helvetica", "Arial", sans-serif;
/* Headings */
font-family: "Source Serif 4 Variable", Georgia, serif;
/* UI / body */
font-family: "IBM Plex Sans", "Helvetica", "Arial", sans-serif;
/* Monospaced (amounts / addresses) */
font-family: "IBM Plex Mono", "Courier New", monospace;
```
Display-only headings (banners, hero) may override with Barlow via the `sx` prop:
```tsx
<Typography variant="h1" sx={{ fontFamily: '"Barlow", serif' }}>Welcome</Typography>
```
Use `sx={{ fontFamily: 'IBMPlexMono' }}` (theme alias) for any USDT amounts, contract addresses, or transaction hashes.
---

View File

@@ -69,6 +69,14 @@ Either path requires:
> [!warning]
> Your payout address is the **single source of funds out of escrow**. Triple-check it. If it's wrong, funds can be irretrievably lost.
### 2.5 Payment methods
**Shop Settings → Payments** controls the default payment rails for templates that inherit shop settings:
- Choose at least one supported network, such as Ethereum or BSC.
- Choose at least one supported token, such as USDT or USDC.
- Individual request templates can override these defaults with their own accepted network/token list.
---
## 3. Request Templates
@@ -83,15 +91,19 @@ Templates are pre-defined product/service offerings. Buyers can create a request
2. **Category** — primary category.
3. **Description** — rich text, use images.
4. **Pricing** — fixed price or "starts at" range. Specify currency.
5. **Delivery window**typical days from acceptance.
6. **Customisations** — list of options (size, color, quantity) buyers can choose.
7. **Videos** (optional) — embed up to N video URLs.
8. **Default proposal** — your standard offer text that auto-populates when a buyer creates from this template.
9. **Expiration** — leave blank for evergreen; set a date for limited-time offers.
10. **Visibility** — public (anyone can use) or unlisted (shareable URL only).
5. **Delivery method**choose either physical delivery or online/email delivery. Buyers cannot override this at checkout.
6. **Payment methods** — choose at least one network and one token for the template, or explicitly inherit shop defaults.
7. **Delivery window** — typical days from acceptance.
8. **Customisations** — list of options (size, color, quantity) buyers can choose.
9. **Videos** (optional) — embed up to N video URLs.
10. **Default proposal** — your standard offer text that auto-populates when a buyer creates from this template.
11. **Expiration** — leave blank for evergreen; set a date for limited-time offers.
12. **Visibility** — public (anyone can use) or unlisted (shareable URL only).
Click **Publish**. You'll get a shareable URL: `https://amn.gg/shop/{seller}/{templateId}`.
For physical templates, checkout asks the buyer for a delivery/billing address. For online templates, checkout asks the buyer for the email that should receive the digital item.
### 3.2 Manage templates
**Dashboard → Request Templates** shows all templates with:

View File

@@ -117,8 +117,7 @@ Both repos use Prettier defaults from the local config:
| React component | PascalCase | `RequestCard` |
| Hook | camelCase starting with `use` | `useSocket`, `useAuthContext` |
| Constant | SCREAMING_SNAKE | `MAX_FILE_SIZE` |
| Mongoose model | PascalCase singular | `User`, `PurchaseRequest` |
| Mongo collection | lowercase plural (auto) | `users`, `purchaserequests` |
| Drizzle table | camelCase (schema) / snake_case (SQL) | `purchaseRequests` / `purchase_requests` |
| Route handler | `<verb><Noun>` | `getRequestById`, `createOffer` |
| Express route file | `<domain>Routes.ts` | `paymentRoutes.ts` |
@@ -133,8 +132,7 @@ src/services/marketplace/
├── index.ts # Barrel — only public exports
├── marketplaceRoutes.ts # Router (express.Router) — auth middleware, validation, controller calls
├── marketplaceController.ts # HTTP layer — parses req, calls service, formats response envelope
── marketplaceService.ts # Business logic — talks to models, throws domain errors
└── marketplaceRepository.ts # Optional Mongoose query helpers (when service grows)
── marketplaceService.ts # Business logic — calls repository layer, throws domain errors
```
### Response envelope
@@ -195,6 +193,44 @@ logError("Request Network webhook verification failed", err);
Never use raw `console.error` in service code — it bypasses Sentry breadcrumbs.
### Database access
PostgreSQL + Drizzle ORM is the **only** database layer. MongoDB and Mongoose have been completely removed from the runtime.
Rules:
- Always access data through the repository layer (`src/db/repositories/`). Call `getXxxRepo()` from the factory (`src/db/repositories/factory.ts`).
- Never import `mongoose` or reference Mongoose models — they no longer exist. All `src/models/` Mongoose model files have been deleted.
- Never use raw Drizzle `db` queries in service or controller code; wrap them in a repository method.
- `PG_URL` is a required environment variable. The old `MONGO_URI` / `MONGODB_URI` / `MONGO_CONNECT_MODE` vars are obsolete and must not be added back.
```ts
// ✅ Correct
import { getOfferRepo } from "@db/repositories/factory";
const repo = getOfferRepo();
const offer = await repo.findById(offerId);
// ❌ Wrong — Mongoose is gone
import { Offer } from "@models/offer";
const offer = await Offer.findById(offerId);
```
### ID conventions
All primary keys are **PostgreSQL UUIDs** (`string`).
- Use `.id` to read an entity's primary key — never `._id`.
- The `users` table retains a `legacy_object_id` column (the old MongoDB ObjectId string) for backward compatibility only. Do not use `legacy_object_id` in new code; use `user.pgId` (UUID) for foreign-key references to users (e.g. `offer.sellerId`).
- Marketplace FKs such as `offer.sellerId` are `user.pgId` (UUID), **not** `user._id` (legacy ObjectId).
```ts
// ✅ Correct
const id: string = entity.id; // Postgres UUID
// ❌ Wrong — _id is a legacy ObjectId string, not a Postgres UUID
const id = entity._id;
```
---
## 5. Frontend — UI standards
@@ -377,4 +413,7 @@ Before requesting review:
| `useState` for global state that 3+ components need | a context in `src/contexts/` or a custom hook |
| Direct `axios.create` calls in components | use `src/lib/axios.ts` or an action in `src/actions/` |
| Hard-coded URLs | constants in `src/routes/paths.ts` (frontend) or env vars (backend) |
| Schema changes without a migration | add a migration script in `src/scripts/` and document it |
| Schema changes without a migration | add a Drizzle migration (`drizzle-kit generate`) and document it |
| `import mongoose` / Mongoose models | `getXxxRepo()` from `src/db/repositories/factory` |
| `entity._id` for Postgres entities | `entity.id` (UUID string) |
| `MONGO_URI` / `MONGO_CONNECT_MODE` env vars | `PG_URL` (required) |

View File

@@ -32,10 +32,14 @@ Next.js auto-loads `.env`, `.env.local`, `.env.development`, `.env.production` i
| Name | Repo | Required | Default | Example | Purpose |
|------|------|----------|---------|---------|---------|
| `MONGODB_URI` | backend | ✅ | — | `mongodb://mongodb:27017` | Mongo connection string (no auth in dev) |
| `DB_NAME` | backend | ✅ | — | `marketplace` | Database name appended to the URI |
| ~~`MONGODB_URI`~~ | ~~backend~~ | **REMOVED** | — | — | **REMOVED** MongoDB has been completely removed from the backend (v2.9.12). Do not set this variable. |
| ~~`DB_NAME`~~ | ~~backend~~ | **REMOVED** | — | — | **REMOVED** — Was the Mongo database name; no longer used. |
| `PG_URL` | backend | ✅ **REQUIRED** | — | `postgres://amanat:...@postgres:5432/amanat_dev` | Drizzle runtime DSN. PostgreSQL is the only database layer; this must be set for the backend to start. |
| `MIGRATION_PG_URL` | backend | migration only | — | `postgres://amanat:...@postgres:5432/amanat_dev` | DSN used by backfill/migration scripts. Guarded by non-prod host allowlist. |
In `docker-compose.production.yml` the Mongo service is `mongodb` and is reachable as `mongodb://mongodb:27017` from the backend container.
PostgreSQL (Drizzle ORM) is the **only** database layer as of v2.9.12. MongoDB and Mongoose have been completely removed. 19 migrations (00000019) have landed covering 32 tables.
The following variables are also **REMOVED** and must not be set: `MONGO_CONNECT_MODE`, `MONGO_URL`, `MONGODB_URI`. Any `.env` file referencing them can have those lines deleted.
---
@@ -120,7 +124,9 @@ Request Network is the current primary payment provider. See [[PRD - Request Net
| `TRANSACTION_SAFETY_ENABLED` | backend | optional | `true` | `true` | Enables the Transaction Safety Provider gate before Request Network pay-ins are marked completed. |
| `TRANSACTION_SAFETY_REQUIRE_TX_HASH` | backend | optional | `true` | `true` | Blocks completion when provider evidence does not include a transaction hash. |
| `TRANSACTION_SAFETY_REQUIRE_TRANSFER_MATCH` | backend | optional | `true` | `true` | Requires on-chain token/recipient/amount evidence to match the expected payment. |
| `TRANSACTION_SAFETY_MIN_CONFIRMATIONS` | backend | optional | `12` | `12` | Minimum chain confirmations required by the Transaction Safety Provider. |
| `TRANSACTION_SAFETY_MIN_CONFIRMATIONS` | backend | optional | `12` | chain floor | Fallback minimum confirmations for unknown chains. Known chains use built-in acceptance floors unless a higher admin-configured value exists. |
| `RPC_URL_CHAIN_97` | backend | optional | `https://bsc-testnet-rpc.publicnode.com` | `https://bsc-testnet-rpc.publicnode.com` | Overrides the backend verifier RPC for BSC Testnet (`bsc-testnet`, `bnb-testnet`, numeric `97`). |
| `BSC_TESTNET_RPC_URL` / `BNB_TESTNET_RPC_URL` | backend | optional | `RPC_URL_CHAIN_97` fallback | `https://...` | Alternate BSC Testnet RPC override names consumed by the legacy verifier path. |
| `TRANSACTION_SAFETY_AML_PROVIDER` | backend | optional | `none` | `none` | AML/sanctions provider adapter name. Non-`none` values should block until implemented/configured. |
| `PAYMENT_LEDGER_ENFORCEMENT` | backend | optional | `false` | `true` | Enforce ledger gates for release/refund |
| `PAYMENT_RECONCILIATION_ENABLED` | backend | optional | `false` | `true` | Enable scheduled provider reconciliation jobs |
@@ -128,6 +134,66 @@ Request Network is the current primary payment provider. See [[PRD - Request Net
---
## Payments — AMN Pay Scanner
Backend scanner settings:
| Name | Repo | Required | Default | Example | Purpose |
|------|------|----------|---------|---------|---------|
| `AMN_SCANNER_URL` | backend | required when `amn.scanner` is enabled | — | `http://amn-scanner:8080` | Internal scanner service base URL used by `amnPayAdapter` helpers. |
| `AMN_SCANNER_API_KEY` | backend | prod | — | 64 hex chars | Bearer token sent to scanner when scanner `SCANNER_API_KEY` is configured. |
| `AMN_SCANNER_WEBHOOK_SECRET` | backend | required when `amn.scanner` is enabled | — | 64 hex chars | Shared HMAC key used to verify scanner intent and balance-watch webhooks. |
| `AMN_SCANNER_DEFAULT` | backend | optional | `false` | `true` | Makes AMN scanner the default pay-in provider where provider selection allows it. |
Scanner service settings:
| Name | Repo | Required | Default | Example | Purpose |
|------|------|----------|---------|---------|---------|
| `SCANNER_API_KEY` | scanner | prod | — | 64 hex chars | Bearer token required for all scanner endpoints except `/health`. |
| `BALANCE_WATCH_TICK_SEC` | scanner | optional | `60` | `60` | How often the balance-watch scheduler queries for due watches. |
| `BALANCE_WATCH_BATCH_SIZE` | scanner | optional | `50` | `50` | Max due balance watches processed per scheduler tick. |
| `DB_PATH` | scanner | optional | `./scanner.db` | `/data/scanner.db` | SQLite state path for intents, checkpoints, and balance watches. |
| `CHAINS_JSON_PATH` | scanner | optional | `./supported-chains.json` | `/app/supported-chains.json` | Chain registry path. |
| `TOKENS_JSON_PATH` | scanner | optional | `./tokens.json` | `/app/tokens.json` | Token registry path used for `token`/`tokenSymbol` balance requests. |
| `SCANNER_ENABLED_CHAINS` | scanner | optional | all configured chains | `56,1,97` | Restricts scanner startup to selected chain ids; dev includes chain 97 for BSC Testnet testing. |
| `RPC_BSC` / `RPC_ETH` / `RPC_POLYGON` / `RPC_ARB` / `RPC_BASE` | scanner | optional | chain config | provider URL | EVM RPC overrides used by intent scanners and `balanceOf` reads. |
Direct-address balance checks and watches currently support EVM ERC-20 only. Backend code should use `checkScannerTokenBalance`, `createScannerBalanceWatch`, and `stopScannerBalanceWatch` from `amnPayAdapter.ts`.
---
## Repository Mode Flags (Migration Layer)
> [!warning] These flags are **obsolete** as of v2.9.12. MongoDB and Mongoose have been completely removed. The repository factory returns Drizzle (PostgreSQL) repos exclusively. All domain stores are Postgres-only. The values `mongo`, `dual`, and `DualWrite*` are no longer valid — **only `postgres` is valid**, and it is the hardcoded default. These env vars are ignored at runtime and should not be set.
| Name | Repo | Required | Default | Example | Purpose |
|------|------|----------|---------|---------|---------|
| ~~`REPO_DEFAULT`~~ | ~~backend~~ | **OBSOLETE** | — | — | **REMOVED** — Only `postgres` is valid; the factory always returns Drizzle repos. |
| ~~`REPO_USER`~~ | ~~backend~~ | **OBSOLETE** | — | — | **REMOVED**`mongo` and `dual` modes no longer exist. |
| ~~`REPO_PAYMENT`~~ | ~~backend~~ | **OBSOLETE** | — | — | **REMOVED**`mongo` and `dual` modes no longer exist. |
| ~~`REPO_POINTS`~~ | ~~backend~~ | **OBSOLETE** | — | — | **REMOVED**`mongo` and `dual` modes no longer exist. |
| ~~`REPO_MARKETPLACE`~~ | ~~backend~~ | **OBSOLETE** | — | — | **REMOVED**`mongo` and `dual` modes no longer exist. |
---
## Payments — Oracle Quoting / Depeg Protection
| Name | Repo | Required | Default | Example | Purpose |
|------|------|----------|---------|---------|---------|
| `ORACLE_QUOTING_ENABLED` | backend | optional | `false` | `true` | Enables server-authoritative seller-offer quoting on `/api/payment/request-network/intents`. |
| `PRICE_ORACLE_PROVIDERS` | backend | optional | `chainlink,offchain_fx` | `chainlink,offchain_fx` | Ordered provider list used by the quote engine. |
| `ORACLE_MAX_STALENESS_S` | backend | optional | `120` | `120` | Rejects stale FX/token rates. |
| `ORACLE_DISAGREE_BPS` | backend | optional | `100` | `100` | Maximum allowed provider disagreement before the quote is blocked. |
| `DEPEG_HARD_CAP_BPS` | backend | optional | `500` | `500` | Blocks automatic quoting beyond this stablecoin depeg. |
| `QUOTE_VALIDITY_S` | backend | optional | `90` | `90` | Quote expiry window. |
| `REQUOTE_RECONFIRM_BPS` | backend | optional | `50` | `50` | Frontend/backend threshold for buyer reconfirmation after a material re-quote. |
| `OFFCHAIN_FX_URL` | backend | conditional | — | `https://fx.example/rates` | Required when `offchain_fx` is enabled for fiat currencies without Chainlink coverage. |
| `OFFCHAIN_FX_REQUEST_TIMEOUT_MS` | backend | optional | `8000` | `8000` | HTTP timeout for the off-chain FX provider. |
| `CHAINLINK_RPC_1` | backend | conditional | — | `https://...` | Ethereum RPC for Chainlink stablecoin/USD reads. |
| `CHAINLINK_RPC_56` | backend | conditional | — | `https://...` | BSC RPC for Chainlink stablecoin/USD reads. |
---
## Payments — Wallet UI (frontend)
| Name | Repo | Required | Default | Example | Purpose |
@@ -173,7 +239,7 @@ Request Network is the current primary payment provider. See [[PRD - Request Net
| `TRUST_PROXY` | backend | optional | auto-on in prod | `true` | Enables `app.set('trust proxy', 1)` for Nginx |
| `NEXT_PUBLIC_APP_URL` | frontend | ✅ | — | `http://localhost:8083` | Self-URL used in metadata + OG tags |
| `NEXT_PUBLIC_APP_NAME` | frontend | optional | `AMN` | `ایسکرو آنلاین` | Display name in nav / titles |
| `NEXT_PUBLIC_APP_VERSION` | frontend | optional | `package.json` | `1.0.2` | Shown in the version logger |
| `NEXT_PUBLIC_APP_VERSION` | frontend | optional | `package.json` | `2.8.94` | Shown in the version logger |
| `NEXT_PUBLIC_API_URL` | frontend | ✅ | — | `http://localhost:5001/api` | Axios base URL |
| `NEXT_PUBLIC_API_BASE_URL` | frontend | optional | derived | `http://localhost:5001` | Used by a few legacy callers |
| `NEXT_PUBLIC_BACKEND_URL` | frontend | ✅ | — | `http://localhost:5001` | Used by file URL builders |
@@ -242,9 +308,8 @@ NODE_ENV=development
PORT=5001
TRUST_PROXY=false
# Database
MONGODB_URI=mongodb://mongodb:27017
DB_NAME=marketplace
# Database (PostgreSQL only — MongoDB removed in v2.9.12)
PG_URL=postgres://amanat:secret@postgres:5432/amanat_dev
# Cache
REDIS_URI=redis://redis:6379
@@ -328,9 +393,24 @@ SWEEP_GAS_TOP_UP_BNB=0.002
# AMN Pay Scanner (replaces Request Network for pay-in detection)
AMN_SCANNER_URL=
AMN_SCANNER_API_KEY=
AMN_SCANNER_WEBHOOK_SECRET=
AMN_SCANNER_DEFAULT=false
# Oracle quoting / stablecoin depeg protection
# Keep disabled until feeds and the off-chain FX source are configured.
ORACLE_QUOTING_ENABLED=false
PRICE_ORACLE_PROVIDERS=chainlink,offchain_fx
ORACLE_MAX_STALENESS_S=120
ORACLE_DISAGREE_BPS=100
DEPEG_HARD_CAP_BPS=500
QUOTE_VALIDITY_S=90
REQUOTE_RECONFIRM_BPS=50
OFFCHAIN_FX_URL=
OFFCHAIN_FX_REQUEST_TIMEOUT_MS=8000
CHAINLINK_RPC_1=
CHAINLINK_RPC_56=
# OAuth
GOOGLE_CLIENT_ID=
```

View File

@@ -7,10 +7,10 @@ tags: [development]
This guide walks you through running both repositories of the marketplace stack on your workstation. The platform is split into two services:
- **Backend** — Node.js 22+ / Express 5 / MongoDB 8 / Redis 8 / Socket.IO, served on port `5001`.
- **Backend** — Node.js 22+ / Express 5 / PostgreSQL 16 / Redis 8 / Socket.IO, served on port `5001`.
- **Frontend** — Next.js 16 / React 19 / MUI v7, served on port `8083` (or `3000` in Docker dev).
By the end of this page you will have the API running locally with MongoDB + Redis containers, a seeded set of test accounts, and the Next.js dashboard talking to it through your browser. For ongoing reference see [[Environment Variables]], [[Project Structure]], and [[Scripts]].
By the end of this page you will have the API running locally with PostgreSQL + Redis containers, a seeded set of test accounts, and the Next.js dashboard talking to it through your browser. For ongoing reference see [[Environment Variables]], [[Project Structure]], and [[Scripts]].
---
@@ -22,7 +22,7 @@ Install the following before you start:
|------|---------|-----|
| Node.js | `>= 22` (backend), `>= 20` (frontend) | Runtime |
| Yarn | `1.22.22` (Classic) | Pinned via `packageManager` field |
| Docker Desktop | latest | Runs MongoDB + Redis + (optionally) backend/frontend |
| Docker Desktop | latest | Runs PostgreSQL + Redis + (optionally) backend/frontend |
| Git | `>= 2.40` | SSH-based clone from Gitea |
| OpenSSL | system default | For generating local secrets |
| `ngrok` (optional) | latest | For webhook testing — see [[Scripts#start-ngrok-sh]] |
@@ -60,11 +60,11 @@ git clone ssh://git@git.manko.yoga:222/nick/backend.git
git clone ssh://git@git.manko.yoga:222/nick/frontend.git
```
Switch each repo to the `development` branch:
Switch each repo to the active integration branch for the stack you are testing. As of 2026-05-31, the dev stack work is on `integrate-main-into-development`:
```bash
cd ~/code/backend && git checkout development
cd ~/code/frontend && git checkout development
cd ~/code/backend && git checkout integrate-main-into-development
cd ~/code/frontend && git checkout integrate-main-into-development
```
> [!warning] `main`/`master` is the production branch and is consumed by the Watchtower auto-update flow. Never push WIP commits there. See [[Git Workflow]].
@@ -100,8 +100,7 @@ Each repo ships example files. Copy them and fill in secrets — full reference
```bash
NODE_ENV=development
PORT=5001
MONGODB_URI=mongodb://mongodb:27017
DB_NAME=marketplace
PG_URL=postgresql://postgres:postgres@postgres:5432/marketplace
REDIS_URI=redis://redis:6379
JWT_SECRET=$(openssl rand -hex 32)
JWT_EXPIRES_IN=1h
@@ -113,6 +112,8 @@ RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
```
> [!note] `MONGODB_URI` / `MONGO_URI` / `MONGO_CONNECT_MODE` are **no longer used**. MongoDB has been fully removed from the backend runtime (v2.9.12+). The only database layer is PostgreSQL + Drizzle ORM. `PG_URL` is required.
For payments, OpenAI, SMTP, etc., refer to [[Environment Variables]].
### Frontend
@@ -135,7 +136,7 @@ You have two equivalent paths.
### Option A — All-in-Docker (recommended)
Builds the backend image, brings up MongoDB + Redis + backend on `nickapp-network`, and mounts `./src` for hot reload:
Builds the backend image, brings up PostgreSQL + Redis + backend on `nickapp-network`, and mounts `./src` for hot reload:
```bash
cd ~/code/backend
@@ -160,19 +161,34 @@ Run only the datastores in Docker and the API on the host:
```bash
cd ~/code/backend
docker compose -f docker-compose.dev.yml up -d mongodb redis
docker compose -f docker-compose.dev.yml up -d postgres redis
npm run dev # ts-node + nodemon on port 5001
```
Override `MONGODB_URI=mongodb://localhost:27017` in `.env` if you take this route, since `mongodb` only resolves inside the compose network.
Override `PG_URL=postgresql://postgres:postgres@localhost:5432/marketplace` in `.env` if you take this route, since `postgres` only resolves inside the compose network.
> [!tip] If port `5001` is already in use, set `PORT=5002` in `.env.local` and update `NEXT_PUBLIC_API_URL` in the frontend env to match.
---
## 5a. Apply database migrations
After starting the PostgreSQL container (and before seeding), apply all Drizzle migrations to create the 32-table schema:
```bash
cd ~/code/backend
npx drizzle-kit migrate
```
This runs the 19 migration files (00000019) and brings the database schema up to date. You only need to run this once on a fresh database, or after pulling commits that include new migration files.
> [!note] If you are using Option A (All-in-Docker), run this from the host after the `postgres` container is healthy but before the backend service connects.
---
## 6. Seed test data
Once MongoDB is healthy, populate it with default users, categories, addresses, and templates:
Once PostgreSQL is healthy and migrations have been applied, populate it with default users, categories, addresses, and templates:
```bash
cd ~/code/backend
@@ -189,7 +205,7 @@ npm run seed:categories # marketplace taxonomy
| Seller | `seller@marketplace.com` |
| Seller (alt) | `seller2@marketplace.com` |
You can also enable auto-seeding on container start by adding `AUTO_SEED_ON_START=true` to `.env.local`. Auto-seed runs only when the `users` collection has no non-admin entries — safe to leave on.
You can also enable auto-seeding on container start by adding `AUTO_SEED_ON_START=true` to `.env.local`. Auto-seed runs only when the `users` table has no non-admin entries — safe to leave on.
See [[Scripts#seed-scripts]] for the full list (`seed:users`, `seed:addresses`, `seed:categories`, `seed:all`, plus `createSupportUser.ts`, `createTestRequest.ts`, etc.).
@@ -231,7 +247,7 @@ curl -s -X POST http://localhost:5001/api/auth/login \
In the browser, open http://localhost:8083, log in with `admin@marketplace.com / Moji6364`, and confirm the dashboard loads. If chat or notification badges show up, sockets connected too.
> [!tip] Tail backend logs in a separate terminal: `npm run docker:dev:logs`. Look for `Connected to MongoDB`, `🔌 User connected`, and `🚀 Server running on port 5001`.
> [!tip] Tail backend logs in a separate terminal: `npm run docker:dev:logs`. Look for `Connected to PostgreSQL`, `User connected`, and `Server running on port 5001`.
---
@@ -240,8 +256,9 @@ In the browser, open http://localhost:8083, log in with `admin@marketplace.com /
| Symptom | Fix |
|---------|-----|
| `EADDRINUSE :::5001` | Another process owns the port — `lsof -i :5001` then `kill`, or change `PORT`. |
| `MongoServerError: Authentication failed` | The compose file does **not** set Mongo auth in dev; remove any `user:pass@` prefix from `MONGODB_URI`. |
| `ECONNREFUSED 127.0.0.1:5432` | PostgreSQL container is down — `docker compose -f docker-compose.dev.yml ps` to check. |
| `ECONNREFUSED 127.0.0.1:6379` | Redis container is down — `docker compose -f docker-compose.dev.yml ps` to check. |
| `relation "users" does not exist` | Migrations have not been applied — run `npx drizzle-kit migrate` from the backend folder. |
| CORS errors in the browser | `FRONTEND_URL` in backend `.env.local` must exactly match the origin you open in the browser (scheme + host + port). |
| `yarn install` hangs on `sharp` | Run `yarn config set network-timeout 600000` and retry. |
| `next dev` fails with module-not-found after a `git pull` | Run `yarn install` again — Next 16 is sensitive to drift in `react`/`react-dom`. |
@@ -258,9 +275,9 @@ cd ~/code/backend
./scripts/reset-server.sh
```
This stops the dev compose stack, restarts it, runs health checks against MongoDB / Redis / `/health`, and probes the login endpoint with the seeded admin user. Output is colourised and ends with the canonical test credentials. See [[Scripts#reset-server-sh]] for details.
This stops the dev compose stack, restarts it, runs health checks against PostgreSQL / Redis / `/health`, and probes the login endpoint with the seeded admin user. Output is colourised and ends with the canonical test credentials. See [[Scripts#reset-server-sh]] for details.
> [!warning] `reset-server.sh` does **not** drop volumes by default. To wipe the database, uncomment the `down -v` line in the script or run `docker compose -f docker-compose.dev.yml down -v` first.
> [!warning] `reset-server.sh` does **not** drop volumes by default. To wipe the database, uncomment the `down -v` line in the script or run `docker compose -f docker-compose.dev.yml down -v` first. You will need to re-run `npx drizzle-kit migrate` and `npm run seed:all` after a volume wipe.
---

View File

@@ -11,7 +11,7 @@ A bird's-eye view of both repos. For deep dives, follow the cross-links to [[Bac
## Backend — `/Users/mojtabaheidari/code/backend`
A service-oriented Express 5 app. Each business domain owns a folder under `src/services/` containing its routes, controllers, services, and (sometimes) its own models. Cross-cutting concerns live in `src/shared/` and `src/infrastructure/`.
A service-oriented Express 5 app. Each business domain owns a folder under `src/services/` containing its routes, controllers, services, and repositories. Cross-cutting concerns live in `src/shared/` and `src/infrastructure/`. PostgreSQL + Drizzle ORM is the sole database layer as of v2.9.12 (Mongoose fully removed).
```
backend/
@@ -20,9 +20,13 @@ backend/
│ ├── config/ # Sentry init (loaded before anything else)
│ ├── controllers/ # Thin HTTP controllers for orphan endpoints (disputes, points)
│ ├── routes/ # Router exports for orphan controllers above
│ ├── models/ # Mongoose schemas (single source of truth for data)
│ ├── models/ # (removed — Mongoose models deleted; schemas now in src/db/schema/)
│ ├── db/ # PostgreSQL + Drizzle ORM — SOLE database layer (19 migrations, 32 tables)
│ │ ├── schema/ # Drizzle table definitions (single source of truth for data)
│ │ ├── migrations/ # SQL migration files (00000019)
│ │ └── repositories/ # Drizzle-backed repository implementations
│ ├── infrastructure/
│ │ ├── database/ # Mongo connection + admin bootstrap
│ │ ├── database/ # (removed — Mongoose connection code deleted)
│ │ └── socket/ # Socket.IO server adapter & emitter helpers
│ ├── services/ # Domain services — see breakdown below
│ ├── shared/
@@ -36,12 +40,11 @@ backend/
├── __tests__/ # Jest suites (see Testing)
├── scripts/ # Shell scripts (build/push, version, ngrok, reset)
├── nginx/ # Nginx conf (production compose)
├── mongo-init/ # Mongo initdb.d JS (one-time bootstrap)
├── uploads/ # User uploads — mounted as volume
├── Dockerfile.dev # Hot-reload image (ts-node + nodemon)
├── Dockerfile.prod # Multi-stage build image (compiled JS, non-root user)
├── docker-compose.dev.yml # Local stack: backend + mongo + redis
├── docker-compose.production.yml # Prod stack: nginx + backend + frontend + mongo + redis
├── docker-compose.dev.yml # Local stack: backend + postgres + redis
├── docker-compose.production.yml # Prod stack: nginx + backend + frontend + postgres + redis
├── .gitea/workflows/ # Gitea Actions CI
├── healthcheck.js # Container HEALTHCHECK probe
├── eslint.config.js # Flat ESLint config (TS strict)
@@ -73,14 +76,19 @@ Each service folder follows the same shape: `<service>Routes.ts`, `<service>Cont
| `redis/` | Redis client wrapper (caching, rate counters) |
| `user/` | Profile, settings, role management |
### `src/models/`
### `src/models/` (removed)
Each `.ts` file is a Mongoose model — see [[Data Models]] for full schema docs. Highlights:
This directory no longer exists. All Mongoose models have been deleted. Data schemas are now defined as Drizzle table objects in `src/db/schema/`. See [[Data Models]] for the current PostgreSQL schema docs.
- `User`, `Address`, `Category` — identity & taxonomy
- `PurchaseRequest`, `SellerOffer`, `RequestTemplate` — marketplace core
- `Payment`, `PointTransaction`, `LevelConfig` — money + reputation
- `Chat`, `Notification`, `Dispute`, `Review`, `BlogPost`, `ShopSettings`, `TempVerification` — supporting domains
### `src/db/`
PostgreSQL + Drizzle ORM — the **sole** database layer (no Mongoose, no dual-write, no Mongo fallback). Highlights:
- `schema/` — Drizzle table definitions covering all 32 tables across 19 migrations (00000019)
- `migrations/` — SQL migration files applied via `drizzle-kit`
- `repositories/` — Drizzle-backed repository implementations returned exclusively by the repository factory
- All domain stores use `PG_URL` (required); `MONGO_URI` / `MONGODB_URI` / `MONGO_CONNECT_MODE` are obsolete
- IDs are PostgreSQL UUIDs (`.id` string field); `legacy_object_id` column preserves the original MongoDB ObjectId for `User` only
### `src/seeds/`
@@ -189,7 +197,7 @@ The production `docker-compose.yml` lives in `backend/` but references `../front
| You want to add… | Put it under… |
|---|---|
| A new public API route | `backend/src/services/<domain>/<domain>Routes.ts` (or a new domain folder) |
| A new Mongo schema | `backend/src/models/<Name>.ts` + export from `models/index.ts` |
| A new database table | `backend/src/db/schema/<name>.ts` (Drizzle) + add a migration via `drizzle-kit generate` |
| A reusable UI component | `frontend/src/components/<kebab-name>/` with `index.ts` + `component.tsx` + `types.ts` |
| A page-specific block | `frontend/src/sections/<domain>/` |
| A new dashboard page | `frontend/src/app/dashboard/<route>/page.tsx` |

View File

@@ -7,6 +7,9 @@ tags: [development]
Both repos use **Jest** as the unit/integration runner. The frontend additionally uses **React Testing Library** for component tests and **Playwright** for end-to-end browser tests. This page covers what exists today, how to run it, and how to add new tests.
For cross-service procedures, live-dev E2E scenarios, scanner payment runs, CI
verification, and release-blocking test gaps, see [[11 - Testing/Testing Overview]].
---
## Backend testing

View File

@@ -0,0 +1,168 @@
---
title: Workflow — Full Codebase Audit and Remediation
tags: [development, audit, security, performance, automation, workflow]
created: 2026-05-30
status: living
---
# Workflow — Full Codebase Audit and Remediation
A periodic, multi-agent health pass over the whole platform. Run it *from time to time*
to keep docs honest, surface security / functionality / performance issues, fix the
obvious ones automatically, and hand the judgement calls back to a human.
It is implemented as a **Claude Code workflow** (deterministic orchestration of many
subagents) and lives at:
```
escrow/.claude/workflows/full-codebase-audit.js
```
Because it is a *named* workflow, it can be launched by name from any session rooted at
`escrow/`:
```
Workflow({ name: 'full-codebase-audit' })
```
This document explains the flow, the design decisions baked into it, how to run it, and
includes the **full source** so it can be recreated from scratch if the file is ever lost.
---
## 1. What it does (the flow)
```
Sync ─▶ Doc Sync ─▶ Audit ─▶ Verify ─▶ Strategy ─▶ Mitigate ─▶ Report
```
| # | Phase | Model | What happens |
|---|-------|-------|--------------|
| 1 | **Sync** | Sonnet | `git fetch` all 4 repos; `git pull --ff-only` only when the tree is clean. Never touches uncommitted work. |
| 2 | **Doc Sync** | Sonnet | One agent per repo updates docs to match recent code changes. **scanner** gets a heavy doc-generation mandate (it is the least mature project and has zero markdown docs). |
| 3 | **Audit** | Sonnet | Fan-out of `repo × dimension` agents (security / functionality / performance / supply-chain) producing structured findings. |
| 4 | **Verify** | Sonnet | Each finding is adversarially re-checked against the code to kill false positives. Pipelined with Audit — a finding verifies as soon as its slice is found. |
| 5 | **Strategy** | **Opus** | The lead-architect agent clusters findings into systemic themes and splits them into a **no-brainer** queue (safe to auto-fix) and a **decision** queue (needs human judgement). |
| 6 | **Mitigate** | Sonnet | Applies the no-brainers, grouped one agent per repo. **Working-tree only — no commit, no push** (see §2). |
| 7 | **Report** | Sonnet | Writes the audit report under `09 - Audits/`, creates `ISSUE-###` files for the decision queue + any skipped fix, and updates the audit index. |
The workflow **returns** a `decisionQueue` to the calling assistant. Workflows run in the
background and cannot prompt interactively, so the assistant presents that queue to you
with `AskUserQuestion` — that is the "allow the user to decide about the non-critical /
non-trivial ones" step.
---
## 2. Design decisions baked in
These are the defaults; each is overridable via `args` (§4).
- **Workers are Sonnet, design/decision is Opus.** Cheap, parallel grunt work (syncing,
doc-writing, finding, verifying, applying fixes, scribing) runs on `sonnet`. The two
jobs that need judgement — strategy/triage — run on `opus`.
- **Fixes are working-tree only.** The Mitigate phase applies changes but never
`git add/commit/push`. Rationale: the repos are frequently dirty and a parallel agent
(`moojttaba`) pushes to the same branches, so auto-committing risks collisions and
mixing unrelated work. You review the diff, then commit yourself.
- **Pull is fetch + ff-only, skip-if-dirty.** The Sync phase never stashes or merges over
uncommitted changes; on a dirty tree it just reports behind/ahead counts.
- **Conservative triage.** When in doubt, a finding goes to the *decision* queue, not the
*no-brainer* queue. Anything that changes business logic, data shape, or could break
callers is never auto-applied.
- **scanner is the doc priority.** It is the youngest service with no docs, so Doc Sync
spends its biggest effort generating architecture / API / flow / ops docs for it in the
nick-doc vault plus a `README.md` in the repo.
---
## 3. How to run it
From a Claude Code session whose working directory is `escrow/`:
1. **Trigger** — ask Claude to run the `full-codebase-audit` workflow (the word
"workflow" opts into multi-agent orchestration), or it can be invoked directly:
`Workflow({ name: 'full-codebase-audit' })`.
2. **Watch**`/workflows` shows the live phase tree.
3. **Decide** — when it finishes, Claude reads the returned `decisionQueue` and asks you
about each non-trivial item via `AskUserQuestion`. Approved items become a follow-up
change set; the rest stay as `ISSUE-###` files.
4. **Review & commit** — inspect `git diff` in each repo for the auto-applied no-brainers,
then commit them yourself.
It is **expensive** (dozens of agents across 4 repos). Run it periodically, not on every
change.
---
## 4. Overriding behaviour (`args`)
Pass an `args` object to scope or change the run:
```js
Workflow({ name: 'full-codebase-audit', args: {
repos: ['backend', 'scanner'], // subset; default = all 4
fixMode: 'working-tree', // | 'commit' | 'commit-push'
pullMode: 'fetch-ff-skip-dirty', // | 'stash-pull' | 'hard'
dryRun: false, // true => audit + report only, zero fixes
date: '2026-05-30', // optional; agents otherwise read `date +%F`
}})
```
- `dryRun: true` is the safest way to get a fresh audit + report without any file changes.
- `fixMode: 'commit'` / `'commit-push'` only if you accept the collision risk on shared
branches.
---
## 5. Outputs
- **Docs** — updated/created markdown across repos and the nick-doc vault (scanner-heavy).
- **Audit report** — `nick-doc/09 - Audits/Full Codebase Audit - <date>.md`.
- **Issues** — `nick-doc/Issues/ISSUE-###-*.md` for every decision item and skipped fix,
in the existing issue frontmatter format.
- **Working-tree fixes** — uncommitted no-brainer remediations in each repo.
- **Return value** — `{ summary, systemicThemes, decisionQueue, mitigation, docSync,
report }`, consumed by the assistant to drive the `AskUserQuestion` step.
---
## 6. Recreating the workflow from scratch
If `escrow/.claude/workflows/full-codebase-audit.js` is ever lost, recreate it with the
source below (it is the complete, self-contained script). Save it to that path and it is
runnable again by name.
```js
export const meta = {
name: 'full-codebase-audit',
description: 'Sync repos, refresh docs, audit (security/logic/perf), strategize, auto-fix no-brainers, queue the rest for the user',
whenToUse: 'Periodic full-system health pass across frontend/backend/nick-doc/scanner. Run from time to time.',
phases: [
{ title: 'Sync', detail: 'fetch + ff-only pull (skip if dirty) across all 4 repos' },
{ title: 'Doc Sync', detail: 'update docs from recent code changes; scanner gets heavy doc generation', model: 'sonnet' },
{ title: 'Audit', detail: 'security / functionality / performance / supply-chain findings per repo', model: 'sonnet' },
{ title: 'Verify', detail: 'adversarial verification of each finding', model: 'sonnet' },
{ title: 'Strategy', detail: 'design remediation + triage no-brainer vs needs-user-decision', model: 'opus' },
{ title: 'Mitigate', detail: 'apply no-brainer fixes to the working tree only', model: 'sonnet' },
{ title: 'Report', detail: 'write audit report, ISSUE files, audit index, export doc', model: 'sonnet' },
],
}
// See escrow/.claude/workflows/full-codebase-audit.js for the full body.
// The body is reproduced verbatim there; this guide and that file must stay in sync.
```
> The authoritative, always-current source is the file itself
> (`escrow/.claude/workflows/full-codebase-audit.js`). Treat this section as the recovery
> pointer; if you change the workflow, update the file and bump this doc's notes.
---
## 7. Maintenance notes
- Keep `meta.phases` titles identical to the `phase('…')` calls — they are matched by
string to group progress.
- `Date.now()` / `Math.random()` are unavailable inside workflow scripts; the Report phase
reads the date via `date +%F` from a Bash call instead.
- The dedup key is `repo::file::title-prefix`; widen it if you see near-duplicate findings.
- If false positives creep in, raise the Verify bar (it already drops `confidence: 'low'`).

View File

@@ -5,16 +5,143 @@ tags: [operations]
# Database Operations
Day-to-day operations for the two stateful services: **MongoDB 8.2** (primary data store) and **Redis 8** (cache, rate-limit counters, ephemeral session data).
> [!important] MongoDB Removed (2026-06-06 / v2.9.12) — PostgreSQL is the sole database. MongoDB operational procedures below are retained as historical reference.
Day-to-day operations for stateful services: **PostgreSQL** (sole runtime data store as of v2.9.12), and **Redis 8** (cache, rate-limit counters, ephemeral session data).
For schema details see [[Data Models]]. For backup procedures and disaster recovery see [[Backup & Recovery]].
---
## PostgreSQL Operations
### Connection
`PG_URL` env var is **required**. MongoDB env vars (`MONGO_URI`, `MONGODB_URI`, `MONGO_CONNECT_MODE`) are obsolete and ignored.
| Env | Example DSN |
|-----|-------------|
| Dev | `postgres://amanat:<password>@postgres:5432/amanat_dev` |
| Prod | `postgres://amanat:<password>@postgres:5432/amanat` |
Connect from a shell:
```bash
docker exec -it amanat-postgres psql -U amanat -d amanat_dev
```
### Run migrations
```bash
cd backend && npx drizzle-kit migrate
```
19 migrations have landed (00000019), covering 32 tables. Application startup does **not** apply migrations automatically — run them explicitly before starting the backend after a version upgrade.
### Schema files
```
backend/src/db/schema/*.ts
```
Each file declares one or more Drizzle table definitions. Migrations in `backend/drizzle/` are generated from these schema files via `npx drizzle-kit generate`.
### Repositories
```
backend/src/db/repositories/drizzle/Drizzle*.ts
```
All domain repositories are Drizzle-backed. The repository factory returns Drizzle repos exclusively; there is no runtime fallback to MongoDB.
Key facts:
- IDs are PostgreSQL UUIDs (`.id` string field), not MongoDB ObjectIds
- `User._id` is kept as `legacy_object_id` column for backwards-compat; marketplace FKs use `user.pgId` (UUID)
- Chat is stored in the `chats` table with `messages`/`participants` as JSONB arrays
- `PaymentDTO.amount` is a decimal string
- `PurchaseRequest` does **not** have a top-level `paymentId` field
### Docker volume layout
```yaml
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: amanat_dev
POSTGRES_USER: amanat
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /var/data/escrowDev/postgres_data:/var/lib/postgresql
```
Mount at `/var/lib/postgresql` (not `/var/lib/postgresql/data`) — Postgres 18 stores data under a version-specific subdirectory.
For a disposable dev reset:
```bash
docker rm -f amanat-postgres 2>/dev/null || true
rm -rf /var/data/escrowDev/postgres_data
mkdir -p /var/data/escrowDev/postgres_data
```
### Backup
Standard PostgreSQL tooling:
```bash
docker exec amanat-postgres pg_dump -U amanat -d amanat_dev --format=custom \
> backups/amanat_dev_pg_$(date +%F).dump
```
Restore:
```bash
docker exec -i amanat-postgres pg_restore -U amanat -d amanat_dev --clean \
< backups/amanat_dev_pg_2026-06-06.dump
```
For production use managed backups or WAL archiving/PITR. See [[Backup & Recovery]].
### Seeding
Seeds are Postgres-only, store-aware, and idempotent. Run against a running backend container:
```bash
docker exec -it nickapp-backend node -e "require('./dist/seeds/seedCategories.js')"
docker exec -it nickapp-backend node -e "require('./dist/seeds/seedLevels.js')"
```
> [!warning] **Never** run `seed:all` or `seed:users` against production. These are destructive.
### Common admin queries
```sql
-- Row counts
SELECT schemaname, relname, n_live_tup
FROM pg_stat_user_tables ORDER BY n_live_tup DESC;
-- Active connections
SELECT count(*), state FROM pg_stat_activity GROUP BY state;
-- Slow queries (requires pg_stat_statements)
SELECT query, mean_exec_time, calls
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
-- Table sizes
SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC;
```
---
## 1. MongoDB
> [!note] Historical — MongoDB has been removed. The content below is retained as a reference for data archaeology, incident retrospectives, or backfill tooling. Do not use these procedures against the live application.
### 1.1 Connection
> [!note] Historical — MongoDB has been removed.
| Env | URI in compose | Auth |
|-----|---------------|------|
| Dev | `mongodb://mongodb:27017` | none |
@@ -44,6 +171,8 @@ docker exec -it nickapp-mongodb mongosh \
### 1.2 Init scripts (`mongo-init/`)
> [!note] Historical — MongoDB has been removed.
The production compose bind-mounts `./mongo-init` into `/docker-entrypoint-initdb.d`. Mongo runs `*.js` and `*.sh` from this folder **only on a fresh datadir** (first boot of a new volume). Use this to:
- Create application users (`db.createUser({...})`)
@@ -64,7 +193,9 @@ db.createUser({
### 1.3 Indexes
Indexes are declared in Mongoose schemas under `backend/src/models/`. The app calls `Model.createIndexes()` on connection (via the model's `syncIndexes`/`ensureIndexes` lifecycle). Highlights:
> [!note] Historical — MongoDB has been removed. Indexes are now declared in Drizzle schema files under `backend/src/db/schema/`.
Indexes were declared in Mongoose schemas under `backend/src/models/`. The app called `Model.createIndexes()` on connection. Highlights:
| Collection | Key indexes |
|------------|-------------|
@@ -77,30 +208,16 @@ Indexes are declared in Mongoose schemas under `backend/src/models/`. The app ca
| `notifications` | `userId` + `read`, `createdAt` |
| `tempverifications` | TTL on `expiresAt` (auto-deletes expired OTPs) |
To verify a specific collection:
```js
db.payments.getIndexes()
```
To add a new index without code-gen — preferred path is to declare it in the Mongoose schema and ship a deploy. For emergency hotfixes:
```js
db.payments.createIndex({ providerPaymentId: 1 }, { unique: true, sparse: true });
```
### 1.4 TTL indexes
Currently used on `tempverifications.expiresAt` (5-minute auto-purge of email OTPs / passkey challenges). Mongo's TTL monitor runs every 60 seconds — purge isn't immediate.
> [!note] Historical — MongoDB has been removed.
If you add more TTL indexes:
```js
db.notifications.createIndex({ createdAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 90 }); // 90 days
```
Used on `tempverifications.expiresAt` (5-minute auto-purge of email OTPs / passkey challenges). Mongo's TTL monitor ran every 60 seconds.
### 1.5 Backup with `mongodump`
> [!note] Historical — MongoDB has been removed.
```bash
# Connect into the container, dump locally, copy out
docker exec nickapp-mongodb sh -c \
@@ -117,6 +234,8 @@ For full details (retention, RTO/RPO, offsite copies) see [[Backup & Recovery]].
### 1.6 Restore
> [!note] Historical — MongoDB has been removed.
```bash
# Restore an archive to an empty database
docker exec -i nickapp-mongodb \
@@ -130,21 +249,17 @@ docker exec -i nickapp-mongodb \
### 1.7 Migrations
There is no formal migration framework. Two patterns are used:
> [!note] Historical — MongoDB has been removed. Drizzle migrations are now used exclusively (`npx drizzle-kit migrate`).
- **Mongoose schema changes** are forward-compatible (new optional fields default to `undefined`). Older documents will still load.
- **Data backfills** are one-shot scripts in `backend/src/scripts/` (e.g. `migrateUserPoints.ts`, `fix-transaction-hashes.js`, `fix-dispute-sellers.js`).
There was no formal migration framework. Two patterns were used:
Pattern for a new migration:
1. Add a `src/seeds/migrate<Thing>.ts` script that is idempotent (use `$exists: false` guards).
2. Run on staging, confirm.
3. Take a backup ([[Backup & Recovery]]).
4. Run in production: `docker exec -it nickapp-backend node dist/seeds/migrate<Thing>.js`.
5. Commit the script (it serves as a record of what changed).
- **Mongoose schema changes** were forward-compatible (new optional fields default to `undefined`). Older documents would still load.
- **Data backfills** were one-shot scripts in `backend/src/scripts/` (e.g. `migrateUserPoints.ts`, `fix-transaction-hashes.js`, `fix-dispute-sellers.js`).
### 1.8 Common admin queries
> [!note] Historical — MongoDB has been removed.
```js
// Count by collection
db.users.countDocuments({ role: 'buyer' })
@@ -162,24 +277,57 @@ db.serverStatus().locks
### 1.9 Seeding production safely
Seed scripts are designed to be idempotent for **categories** but **destructive** for users/addresses. Don't run `seed:all` in production.
> [!note] Historical — MongoDB has been removed. Seeds are now Postgres-only and idempotent; see the PostgreSQL Operations section above.
Safe in production:
```bash
docker exec -it nickapp-backend node dist/seeds/seedCategories.js
docker exec -it nickapp-backend node dist/seeds/seedLevels.js
```
Optional auto-seed on startup: set `AUTO_SEED_ON_START=true` in `.env`. The bootstrap code only seeds when no non-admin users exist — safe to leave on.
Seed scripts were designed to be idempotent for **categories** but **destructive** for users/addresses.
> [!warning] **Never** run `seed:all` or `seed:users` against production. They drop the existing `users` and `addresses` collections.
---
## 2. Redis
## 2. PostgreSQL 18 (legacy section — superseded by PostgreSQL Operations above)
### 2.1 Connection
> [!note] Historical — This section documented the partial migration era. PostgreSQL is now the sole database; see the PostgreSQL Operations section at the top of this document.
### 2.1 Runtime role
~~Postgres is present in the current dev/integration stack, but MongoDB remains the primary runtime store.~~
As of v2.9.12, PostgreSQL is the **only** runtime store. All domain repositories use Drizzle. There is no dual-write mode.
### 2.2 Docker volume layout for Postgres 18
See the Docker volume layout subsection in PostgreSQL Operations above.
### 2.3 Apply migrations
```bash
cd backend && npx drizzle-kit migrate
```
19 migrations (00000019) covering 32 tables. See PostgreSQL Operations above.
### 2.4 Backfill and verification
> [!note] Historical — Mongo→Postgres backfill tooling is no longer needed. The migration is complete.
Backfills used `MIGRATION_PG_URL` (not `PG_URL`) and enforced a host allowlist:
```bash
MIGRATION_MONGO_URL=mongodb://mongodb:27017/marketplace \
MIGRATION_PG_URL=postgres://amanat:...@postgres:5432/amanat_dev \
node dist/db/backfill/run-backfill.js --dry-run
```
### 2.5 Backup
See the Backup subsection in PostgreSQL Operations above.
---
## 3. Redis
### 3.1 Connection
Dev: `redis://redis:6379` (no password).
Prod: `redis://:<REDIS_PASSWORD>@redis:6379`. The compose command line is `redis-server --requirepass "$REDIS_PASSWORD"`.
@@ -193,7 +341,7 @@ docker exec -it nickapp-redis redis-cli -a "$REDIS_PASSWORD"
> KEYS * # prod-unsafe on large datasets, use SCAN
```
### 2.2 What we store
### 3.2 What we store
- **Rate-limit counters** for `express-rate-limit`
- **Session data** for refresh-token tracking and revocation lists
@@ -203,7 +351,7 @@ docker exec -it nickapp-redis redis-cli -a "$REDIS_PASSWORD"
Key prefixes follow `<service>:<entity>:<id>`. E.g. `payment:idem:<requestId>`, `auth:refresh:<userId>`.
### 2.3 Persistence
### 3.3 Persistence
Redis 8 defaults to **RDB snapshots** + optional **AOF**. Our compose uses the default config:
@@ -220,7 +368,7 @@ redis:
`appendfsync everysec` is the common compromise: at most 1 second of writes lost on crash, with negligible perf impact.
### 2.4 Eviction policy
### 3.4 Eviction policy
Default is `noeviction` — Redis refuses writes when memory is full. For our use (caches that can be regenerated), set:
@@ -233,7 +381,7 @@ docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" \
Persist by adding to a custom `redis.conf` mounted at `/usr/local/etc/redis/redis.conf` (then change the compose `command:` to `["redis-server","/usr/local/etc/redis/redis.conf","--requirepass",...]`).
### 2.5 Backup
### 3.5 Backup
Redis backups are usually unnecessary (the data is regeneratable) but still cheap:
@@ -245,7 +393,7 @@ docker cp nickapp-redis:/data/dump.rdb ./backups/redis-$(date +%F).rdb
`BGSAVE` is non-blocking (forks). For AOF, copy `/data/appendonly.aof` too.
### 2.6 Cache flush
### 3.6 Cache flush
When deploying breaking changes to cached schemas:
@@ -261,7 +409,7 @@ docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" \
> [!warning] `FLUSHALL` will sign out every user with an active refresh token and reset every rate-limit counter. Avoid in production unless that is what you want.
### 2.7 Monitoring
### 3.7 Monitoring
```bash
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" INFO stats
@@ -273,18 +421,18 @@ Watch `evicted_keys`, `keyspace_misses`, `rejected_connections` — see [[Monito
---
## 3. Maintenance windows
## 4. Maintenance windows
For both DBs, schedule a window when:
Schedule a window when:
- Bumping major version (Mongo 8 → 9, Redis 8 → 9)
- Bumping major version (PostgreSQL, Redis 8 → 9)
- Restoring from backup
- Running a destructive migration
Suggested checklist:
1. Announce in #ops Slack / status page.
2. Trigger `mongodump` (see [[Backup & Recovery]]).
2. Trigger `pg_dump` backup (see [[Backup & Recovery]]).
3. Stop the backend container so writes stop: `docker compose stop nickapp-backend`.
4. Perform the operation.
5. Restart backend: `docker compose start nickapp-backend`.
@@ -293,9 +441,10 @@ Suggested checklist:
---
## 4. Cross-links
## 5. Cross-links
- [[Backup & Recovery]] — formal backup/restore procedures, RTO/RPO targets, offsite storage.
- [[Monitoring]] — what metrics to watch (slow queries, evictions, replication lag).
- [[Incident Response]] — runbooks for "MongoDB unreachable" and "Redis unreachable".
- [[Data Models]] — schema details for every collection.
- [[Incident Response]] — runbooks for database unreachable scenarios.
- [[Data Models]] — schema details for every table.
- [[Postgres Runtime Cutover Status]] — migration history and current state.

View File

@@ -67,10 +67,10 @@ The `GET /api/health` endpoint was shipped in backend 2.6.49. It is public, rate
**Shape of the endpoint:**
```ts
// GET /api/health (public, rate-limited but not auth-gated)
// GET /api/health (public, skipped by the global rate limiter)
{
"status": "ok" | "degraded" | "down",
"version": "2.6.48",
"version": "2.6.84",
"uptimeSec": 12345,
"checks": {
"db": { "ok": true, "latencyMs": 4 },
@@ -82,7 +82,7 @@ The `GET /api/health` endpoint was shipped in backend 2.6.49. It is public, rate
}
```
Each `checks.*.ok` must reflect the actual current state, not a cached one. If any check fails, `status` flips to `degraded`. If `db.ok === false`, `status` flips to `down`.
Each `checks.*.ok` reflects the current backend state, except `rnApi`, which is cached for 60 seconds as of backend `2.6.84` to avoid monitoring-induced upstream rate limits. `rnApi.status === 429` is treated as reachable because Request Network answered; 5xx/timeouts still degrade the report. If any non-DB check fails, `status` flips to `degraded`. If `db.ok === false`, `status` flips to `down`.
**Why this shape rather than per-check endpoints:**
- One probe, all invariants — cheaper for Gatus and clearer in the dashboard.
@@ -91,6 +91,8 @@ Each `checks.*.ok` must reflect the actual current state, not a cached one. If a
**Backend work:** ✅ Complete (2.6.49). Includes `healthCheckService` with 5 checks, route wired in `app.ts`, rate-limiter + logging skip, and 5 route-level unit tests.
**Postgres cutover monitoring:** As of deployment `38cb75b`, the live dev config also asserts `checks.postgres.enabledStoreCount >= 7` plus the individual `checks.postgres.storeModes.* == "postgres"` values for auth, config, address, category, level config, shop settings, and reviews.
---
## Proposed Gatus config
@@ -135,8 +137,19 @@ endpoints:
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == ok"
- "[BODY].status == \"ok\""
- "[BODY].checks.db.ok == true"
- "[BODY].checks.postgres.ok == true"
- "[BODY].checks.postgres.configured == true"
- "[BODY].checks.postgres.required == true"
- "[BODY].checks.postgres.enabledStoreCount >= 7"
- "[BODY].checks.postgres.storeModes.auth == \"postgres\""
- "[BODY].checks.postgres.storeModes.config == \"postgres\""
- "[BODY].checks.postgres.storeModes.address == \"postgres\""
- "[BODY].checks.postgres.storeModes.category == \"postgres\""
- "[BODY].checks.postgres.storeModes.levelConfig == \"postgres\""
- "[BODY].checks.postgres.storeModes.shopSettings == \"postgres\""
- "[BODY].checks.postgres.storeModes.review == \"postgres\""
- "[BODY].checks.redis.ok == true"
- "[BODY].checks.rnChainRegistry.ok == true"
- "[BODY].checks.rnChainRegistry.chainCount >= 1"
@@ -163,8 +176,9 @@ endpoints:
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == ok"
- "[BODY].status == \"ok\""
- "[BODY].checks.db.ok == true"
- "[BODY].checks.postgres.ok == true"
- "[BODY].checks.redis.ok == true"
- "[BODY].checks.rnChainRegistry.chainCount >= 1"
- "[BODY].checks.rnTokenRegistry.tokenCount >= 1"

View File

@@ -0,0 +1,454 @@
# MongoDB Runtime Removal Handoff
Date: 2026-06-02
Workspace: `/Users/manwe/CascadeProjects/escrow`
Goal: remove MongoDB as a runtime dependency by migrating remaining Mongo-backed backend domains and cutover paths to Postgres-compatible repositories.
## Current State
No commits or pushes were made for the current WIP. The work is local only.
Repo heads at handoff time:
| Repo | Branch | HEAD | State |
| --- | --- | --- | --- |
| `backend` | `integrate-main-into-development` | `cf59726` | dirty WIP |
| `frontend` | `integrate-main-into-development` | `a2b972b` | dirty version bump only |
| `nick-doc` | `main` | `345c585` | dirty unrelated local docs/profile files |
| `deployment` | `main` | `8764fdf` | dirty unrelated `.env` and `docker-compose.yml` |
Backend/frontend package versions: backend at `2.8.79`, frontend at `2.8.94`.
Important repo rules:
- Any backend/frontend product change requires patch version bump in both repos.
- Before any backend push, run the relevant focused tests and smoke script.
- After every backend push, sync `nick-doc`:
- append `09 - Audits/Activity Log.md`
- update relevant data/architecture docs
- commit as `docs: sync from backend <short-sha> — <summary>`
- push `nick-doc`
- Do not stage unrelated dirty files in `nick-doc` or `deployment`.
## Dirty Files
Backend dirty files:
```text
package-lock.json
package.json
src/db/repositories/drizzle/DrizzleMarketplaceRepo.ts
src/db/repositories/drizzle/DrizzlePaymentRepo.ts
src/db/repositories/dual/DualWriteMarketplaceRepo.ts
src/db/repositories/dual/DualWritePaymentRepo.ts
src/db/repositories/interfaces/IMarketplaceRepo.ts
src/db/repositories/interfaces/IPaymentRepo.ts
src/db/repositories/mongo/MongoMarketplaceRepo.ts
src/db/repositories/mongo/MongoPaymentRepo.ts
src/services/auth/authStore.ts
src/services/user/userController.ts
__tests__/auth-store-pg-query.test.ts
__tests__/user-dependencies-repo.test.ts
scripts/smoke/user-admin-postgres.sh
scripts/smoke/user-dependencies.sh
```
Frontend dirty files:
```text
package.json
```
## What This WIP Changes
### 1. Admin User Dependencies Endpoint
Endpoint affected:
```text
GET /api/user/admin/:userId/dependencies
```
Before this WIP, `src/services/user/userController.ts` dynamically imported Mongo models and counted dependencies directly:
- `RequestTemplate.countDocuments(...)`
- `PurchaseRequest.countDocuments(...)`
- `Payment.countDocuments(...)`
- `Chat.countDocuments(...)`
This WIP replaces that direct model access with repository calls:
- `getMarketplaceRepo().getUserDependencyCounts(userId)`
- `getPaymentRepo().countByParticipant(userId)`
- `getChatRepo().count({ 'participants.userId': userId, 'participants.isActive': true })`
New/extended repository contract methods:
- `IMarketplaceRepo.getUserDependencyCounts(userId)`
- `IPaymentRepo.countByParticipant(userId)`
Implementations added:
- `MongoMarketplaceRepo.getUserDependencyCounts`
- `DrizzleMarketplaceRepo.getUserDependencyCounts`
- `DualWriteMarketplaceRepo.getUserDependencyCounts`
- `MongoPaymentRepo.countByParticipant`
- `DrizzlePaymentRepo.countByParticipant`
- `DualWritePaymentRepo.countByParticipant`
Behavior note:
- Postgres counts seller-side marketplace dependencies by joining `purchase_requests.selected_offer_id` to `seller_offers.id` and checking `seller_offers.seller_id`.
- Mongo implementation supports selected-offer-id style and also keeps compatibility with legacy embedded `selectedOffer.sellerId`.
New test:
```text
__tests__/user-dependencies-repo.test.ts
```
New smoke script:
```text
scripts/smoke/user-dependencies.sh
```
Smoke usage:
```bash
BASE_URL=https://dev.amn.gg ADMIN_TOKEN=<admin-jwt> USER_ID=<target-user-id> scripts/smoke/user-dependencies.sh
```
The smoke script checks:
- HTTP 200
- `success === true`
- `data.user` exists
- dependency counters are non-negative numbers
- `total` equals the sum of component counters
### 2. AuthUser Postgres Query Facade Hardening
Files affected:
```text
src/services/auth/authStore.ts
__tests__/auth-store-pg-query.test.ts
```
The mounted user/admin list and stats routes already use `AuthUser` from `authStore`, not raw Mongoose imports. However, the Postgres `PgQuery` wrapper only sorted arrays by `createdAt`; admin list accepts arbitrary `sortBy`.
This WIP hardens the Postgres query wrapper so `AuthUser.find(...).select(...).sort(...).skip(...).limit(...).lean()` behaves more like the existing Mongoose chain:
- generic sorting by requested field
- nested path sorting support, e.g. `profile.avatar`
- date, number, boolean, and string comparison
- multi-field sort support
- keeps existing skip/limit/select/lean chain behavior
It also adds alias support in `buildUserWhere`:
- `isActive: true` maps to `status = 'active'`
- `isActive: false` maps to `status <> 'active'`
- `isVerified` maps to `is_email_verified`
This matters because:
- `src/services/user/userController.ts` builds filters with `isActive` / `isVerified`
- `src/services/user/userRoutes.ts` builds filters with `status` / `isEmailVerified`
- both now work in Postgres auth mode
New test:
```text
__tests__/auth-store-pg-query.test.ts
```
New smoke script:
```text
scripts/smoke/user-admin-postgres.sh
```
Smoke usage:
```bash
BASE_URL=https://dev.amn.gg ADMIN_TOKEN=<admin-jwt> scripts/smoke/user-admin-postgres.sh
```
The smoke script checks:
- `GET /api/user/admin/list?page=1&limit=5&sortBy=firstName&sortOrder=asc`
- `GET /api/users/admin/stats`
- response shape and numeric stats
## Verification Already Run
Passed:
```bash
npm test -- --runTestsByPath __tests__/auth-store-pg-query.test.ts __tests__/user-dependencies-repo.test.ts __tests__/repository-factory-modes.test.ts __tests__/health-check-service.test.ts __tests__/marketplace-runtime-import-surface.test.ts --runInBand
```
Result:
```text
Test Suites: 5 passed, 5 total
Tests: 11 passed, 11 total
```
Passed:
```bash
npm run typecheck
```
Passed:
```bash
git diff --check
```
for both backend and frontend.
Passed syntax checks:
```bash
bash -n scripts/smoke/user-admin-postgres.sh
bash -n scripts/smoke/user-dependencies.sh
```
Not run end-to-end:
- `scripts/smoke/user-dependencies.sh`
- `scripts/smoke/user-admin-postgres.sh`
Reason: both require an `ADMIN_TOKEN`; `user-dependencies.sh` also requires `USER_ID`.
## How To Pick This Up
Start with:
```bash
cd /Users/manwe/CascadeProjects/escrow/backend
git status --short --branch
git diff --stat
```
Review the exact WIP:
```bash
git diff -- src/services/user/userController.ts src/services/auth/authStore.ts
git diff -- src/db/repositories/interfaces/IMarketplaceRepo.ts src/db/repositories/interfaces/IPaymentRepo.ts
git diff -- src/db/repositories/mongo/MongoMarketplaceRepo.ts src/db/repositories/drizzle/DrizzleMarketplaceRepo.ts src/db/repositories/dual/DualWriteMarketplaceRepo.ts
git diff -- src/db/repositories/mongo/MongoPaymentRepo.ts src/db/repositories/drizzle/DrizzlePaymentRepo.ts src/db/repositories/dual/DualWritePaymentRepo.ts
git diff -- __tests__/auth-store-pg-query.test.ts __tests__/user-dependencies-repo.test.ts
git diff -- scripts/smoke/user-admin-postgres.sh scripts/smoke/user-dependencies.sh
```
Re-run local verification:
```bash
npm test -- --runTestsByPath __tests__/auth-store-pg-query.test.ts __tests__/user-dependencies-repo.test.ts __tests__/repository-factory-modes.test.ts __tests__/health-check-service.test.ts __tests__/marketplace-runtime-import-surface.test.ts --runInBand
npm run typecheck
git diff --check
```
If you have a dev admin token:
```bash
BASE_URL=https://dev.amn.gg ADMIN_TOKEN=<admin-jwt> USER_ID=<target-user-id> scripts/smoke/user-dependencies.sh
BASE_URL=https://dev.amn.gg ADMIN_TOKEN=<admin-jwt> scripts/smoke/user-admin-postgres.sh
```
If you commit this WIP, suggested commit shape:
Backend:
```bash
git add package.json package-lock.json \
src/db/repositories/interfaces/IMarketplaceRepo.ts \
src/db/repositories/interfaces/IPaymentRepo.ts \
src/db/repositories/mongo/MongoMarketplaceRepo.ts \
src/db/repositories/drizzle/DrizzleMarketplaceRepo.ts \
src/db/repositories/dual/DualWriteMarketplaceRepo.ts \
src/db/repositories/mongo/MongoPaymentRepo.ts \
src/db/repositories/drizzle/DrizzlePaymentRepo.ts \
src/db/repositories/dual/DualWritePaymentRepo.ts \
src/services/user/userController.ts \
src/services/auth/authStore.ts \
__tests__/auth-store-pg-query.test.ts \
__tests__/user-dependencies-repo.test.ts \
scripts/smoke/user-admin-postgres.sh \
scripts/smoke/user-dependencies.sh
git commit -m "fix: route admin user counts through postgres-capable stores"
```
Frontend:
```bash
cd ../frontend
git add package.json
git commit -m "chore: sync frontend version to 2.8.38"
```
Do not push without doing the `nick-doc` sync afterward.
## Remaining Runtime Mongo Scan
Latest scan command:
```bash
rg -n --pcre2 "^import (?!type).*from ['\"]mongoose['\"]|^import (?!type).*from ['\"][^'\"]*models/|await import\(['\"][^'\"]*models/|countDocuments\(|deleteMany\(|findByIdAndDelete\(" \
src/services src/routes src/app.ts src/infrastructure src/db/repositories/factory.ts \
--glob '!**/*.test.ts' \
--glob '!src/services/marketplace/routes.ts'
```
Important results and interpretation:
### Auth/User Routes
Paths still visible in scans:
```text
src/app.ts:672 AuthUser.countDocuments({ role: { $ne: 'admin' } })
src/services/user/userController.ts:306 User.countDocuments(filter)
src/services/user/userRoutes.ts multiple User.countDocuments(...)
src/services/auth/authStore.ts AuthUser.countDocuments implementation
```
Interpretation:
- These are mostly through the `AuthUser` facade, not direct Mongoose imports.
- In `AUTH_STORE=postgres` mode, `AuthUser.countDocuments` uses Postgres.
- This WIP improved the Postgres query-chain behavior and filter aliases.
- Future work should reduce the noisy model-shaped facade API over time, but these are not necessarily active Mongo runtime blockers when auth store is Postgres and Mongo fallback/mirroring are disabled.
Recommended follow-up:
- Add a `UserAdminRepo` or explicit auth-store helper methods for admin list/stats to replace model-shaped route code.
- After that, route `src/services/user/userController.ts` and `src/services/user/userRoutes.ts` through helper methods and remove the remaining `User.countDocuments` call sites from route/controller code.
### Admin Data Cleanup Service
High-priority blocker:
```text
src/services/admin/dataCleanupService.ts
```
Scan hits include dynamic model counting/deleting:
```text
Model.countDocuments(query)
Model.deleteMany(query)
User.countDocuments()
PurchaseRequest.countDocuments()
SellerOffer.countDocuments()
Payment.countDocuments()
Chat.countDocuments()
Notification.countDocuments()
RequestTemplate.countDocuments()
Address.countDocuments()
Category.countDocuments()
TempVerification.countDocuments()
User.findByIdAndDelete(userId)
```
Interpretation:
- This is the biggest remaining direct runtime Mongo/model surface.
- It likely imports or dynamically resolves models and is unsuitable for `MONGO_CONNECT_MODE=never`.
Recommended migration:
- Replace cleanup stats with repo-backed counts:
- auth/user counts from AuthUser/Postgres helper
- marketplace counts from MarketplaceRepo
- payments from PaymentRepo
- notifications from NotificationRepo
- chat from ChatRepo
- addresses/categories/reviews/temp verification through their Postgres-capable stores
- For destructive cleanup operations, either:
- implement explicit Postgres cleanup repo methods with strong safety guards, or
- disable Mongo-only cleanup actions when Mongo is disabled and return a clear `501`/unsupported result.
### Store Facades Still Exposing Model-Style Methods
Scan hits:
```text
src/services/points/levelConfigStore.ts deleteMany(...)
src/services/address/addressStore.ts findByIdAndDelete(...), countDocuments(...)
src/services/marketplace/reviewStore.ts countDocuments(...)
src/services/auth/authController.ts TempVerification.findByIdAndDelete(...)
src/services/auth/authStore.ts TempVerification findByIdAndDelete implementation
```
Interpretation:
- Some of these are behind Postgres-capable store facades.
- They still show up because they preserve a Mongoose-shaped API.
- For full Mongo removal, these facades must be audited under:
- `MONGO_CONNECT_MODE=never`
- store env set to `postgres`
- fallback/mirror disabled where relevant
Recommended migration:
- Convert each store facade from "model-like object with Mongo fallback" to explicit repository functions.
- Add tests that set the store env to `postgres` and assert no Mongo model getter is called.
### Payment Coordinator
Scan hit:
```text
src/services/payment/paymentCoordinator.ts:510 paymentRepo.deleteMany({ idIn: duplicateIds })
```
Interpretation:
- This is already repository-routed, not raw Mongoose.
- Confirm `DrizzlePaymentRepo.deleteMany` exists and works for the duplicate cleanup path.
## Suggested Next Work Order
1. Finish and commit this WIP after review.
2. Run the two new smoke scripts with real dev admin credentials.
3. Update `nick-doc` after pushing backend/frontend.
4. Migrate `src/services/admin/dataCleanupService.ts`.
5. Replace user/admin list/stats route `User.countDocuments` calls with explicit auth/user helper methods so scans no longer flag route-level model-shaped calls.
6. Audit `addressStore`, `reviewStore`, `levelConfigStore`, and `TempVerification` under `MONGO_CONNECT_MODE=never`.
7. Once all runtime paths are Postgres-capable, set health logic so Mongo is optional and then remove startup Mongo requirement.
8. Final pass:
- scan for non-test runtime `mongoose` / `models` imports
- run typecheck
- run focused Jest suites
- run smoke scripts against local/dev
- verify `/api/health` reports Mongo optional or absent and Postgres healthy
## Environment Notes For Cutover Testing
Useful envs for a no-Mongo runtime test:
```text
MONGO_CONNECT_MODE=never
AUTH_STORE=postgres
USER_STORE=postgres
AUTH_FALLBACK_MONGO=false
AUTH_MIRROR_MONGO=false
```
Also ensure all existing repo/store envs that support Postgres are set to `postgres` or `pg` consistently, including marketplace/payment/dispute/release-hold/notification/blog/address/category/review/level-config/shop-settings style stores.
Do not rely only on `rg` results: some model-shaped methods are already routed through Postgres facades. Prove no-Mongo runtime by running the backend with `MONGO_CONNECT_MODE=never` and exercising the API smoke scripts.
## Known Caveats
- The new smoke scripts need real admin credentials and were not executed end-to-end.
- The current WIP is not pushed and has no `nick-doc` sync yet.
- `nick-doc` and `deployment` have unrelated dirty files; do not stage them accidentally.
- The full goal is not complete. MongoDB is still a runtime dependency until the remaining service/store paths above are migrated and verified under `MONGO_CONNECT_MODE=never`.

View File

@@ -11,24 +11,54 @@ What's instrumented today and what to watch. Today's stack is intentionally lean
## 1. Health endpoint
Path: `GET /health` (backend, port `5001`).
Two paths are registered (both are public, rate-limited, not auth-gated):
Defined in `backend/src/app.ts`:
- `GET /health` — simple ping used by Docker healthchecks. Returns `200 { success, message, timestamp, environment, version }`. Does **not** probe MongoDB or Redis.
- `GET /api/health` — deep health check added in commit `44579d6` (backend v2.6.49). Calls `runHealthChecks` from `backend/src/services/health/healthCheckService.ts`. Probes MongoDB, Postgres, Redis, Request Network registry data, and Request Network API reachability. Returns `503` only when `report.status === 'down'`. As of backend `2.8.79`, Postgres is a hard dependency only when at least one `*_STORE=postgres` flag is enabled; otherwise an unconfigured Postgres check is reported as skipped. The Postgres check also reports active store modes so monitoring can distinguish "PG is reachable" from "this runtime is actually using PG-backed stores". As of deployment `38cb75b`, dev Gatus requires all seven PG-capable store modes to be `postgres` and `enabledStoreCount >= 7`.
```ts
app.get("/health", (req, res) => {
res.json({
success: true,
message: "Marketplace Backend API is running",
timestamp: new Date().toISOString(),
environment: config.nodeEnv,
version: packageJson.version,
});
});
`GET /api/health` response shape (from `healthCheckService`):
```json
{
"status": "ok",
"version": "2.8.79",
"uptimeSec": 662,
"checks": {
"db": { "ok": true, "latencyMs": 4 },
"postgres": {
"ok": true,
"latencyMs": 5,
"configured": true,
"required": true,
"storeModes": {
"auth": "postgres",
"config": "postgres",
"address": "postgres",
"category": "postgres",
"levelConfig": "postgres",
"shopSettings": "postgres",
"review": "postgres"
},
"enabledStores": [
"auth",
"config",
"address",
"category",
"levelConfig",
"shopSettings",
"review"
],
"enabledStoreCount": 7,
"database": "amanat_dev",
"user": "amanat"
},
"redis": { "ok": true, "latencyMs": 1 },
"rnChainRegistry": { "ok": true, "latencyMs": 0, "chainCount": 7 },
"rnTokenRegistry": { "ok": true, "latencyMs": 0, "tokenCount": 12 },
"rnApi": { "ok": true, "latencyMs": 134, "status": 401 }
}
}
```
Returns `200` with a JSON envelope as soon as Express is up. Does **not** currently probe MongoDB or Redis — they are checked via separate Docker healthchecks. If you want deep health, extend the endpoint to ping both data stores and return `503` on failure.
Public URL behind Nginx: `https://amn.gg/api/health`.
---

View File

@@ -0,0 +1,268 @@
---
title: Scanner Operations
tags: [operations, scanner, deployment]
created: 2026-05-30
---
# Scanner Operations
Runbook for deploying, configuring, monitoring, and troubleshooting the AMN Pay Scanner microservice.
---
## 1. Configuration reference
All configuration via environment variables. See `.env.example` in the scanner repo.
| Variable | Default | Required | Description |
|---|---|---|---|
| `PORT` | `8080` | no | HTTP listen port |
| `DB_PATH` | `./scanner.db` | no | SQLite database path |
| `CHAINS_JSON_PATH` | `./supported-chains.json` | no | Supported chains config |
| `TOKENS_JSON_PATH` | `./tokens.json` | no | Token registry |
| `SCANNER_API_KEY` | _(none)_ | **yes (prod)** | Bearer token for all non-health endpoints. Generate with `openssl rand -hex 32` |
| `POLL_INTERVAL_SEC` | `15` | no | Chain poll interval in seconds |
| `INTENT_TTL_HOURS` | `24` | no | Pending/confirming intents older than this are expired (0 = disabled) |
| `WEBHOOK_RETRY_HOURS` | `6` | no | Interval between automatic webhook_failed re-delivery passes (0 = disabled) |
| `BALANCE_WATCH_TICK_SEC` | `60` | no | Scheduler tick for due direct-address balance watches |
| `BALANCE_WATCH_BATCH_SIZE` | `50` | no | Max due balance watches processed per scheduler tick |
| `TRONGRID_API_KEY` | _(none)_ | recommended | TronGrid API key; without it rate limits are very low |
| `TONCENTER_API_KEY` | _(none)_ | recommended | TonCenter API key |
| `RPC_BSC` | _(chain config)_ | no | Override BSC RPC URL (chain 56) |
| `RPC_ARB` | _(chain config)_ | no | Override Arbitrum RPC URL (chain 42161) |
| `RPC_ETH` | _(chain config)_ | no | Override Ethereum RPC URL (chain 1) |
| `RPC_POLYGON` | _(chain config)_ | no | Override Polygon RPC URL (chain 137) |
| `RPC_BASE` | _(chain config)_ | no | Override Base RPC URL (chain 8453) |
> [!warning]
> If `SCANNER_API_KEY` is not set, the scanner logs a warning and accepts all requests. Never run this way in production.
---
## 2. Docker deployment
The scanner ships as a single Docker image. The Dockerfile uses a two-stage build (Go 1.25 builder → Alpine 3.21 runtime).
### Quick start (dev)
```bash
cd scanner/
cp .env.example .env
# edit .env — set SCANNER_API_KEY, RPC overrides, etc.
docker build -t amn-scanner:dev .
docker run -d \
--name amn-scanner \
-p 8080:8080 \
-v $(pwd)/data:/data \
--env-file .env \
amn-scanner:dev
```
### Production (via arcane-cli / Watchtower)
The scanner is deployed manually via `arcane-cli` (not gitops). Watchtower does NOT manage it automatically. After pushing a new image, redeploy with:
```bash
arcane-cli project redeploy --json <project-id>
```
The SQLite database is stored on a named Docker volume (`/data`). Do not recreate the volume between deploys — it holds the checkpoint and intent state.
---
## 3. Health check
```bash
curl http://localhost:8080/health
# {"status":"ok","time":"2026-05-30T12:00:00Z"}
```
Docker `HEALTHCHECK` is already configured in the Dockerfile (30 s interval, 5 s timeout, 3 retries).
---
## 4. Monitoring
### Scanner status endpoint
```bash
curl -H "Authorization: Bearer $SCANNER_API_KEY" \
http://localhost:8080/scanner/status | jq .
```
Check:
- `lag` — should be near 0 for healthy chains (blocks behind for EVM, seconds for TON)
- `pendingIntents` — number of unresolved intents per chain
- `activeBalanceWatches` — number of direct-address watches in `watching` status per chain
- `lastScannedBlock` — should advance each poll
### Logs
The scanner uses Go's `log/slog` structured logger with level prefixes. Key log patterns:
| Pattern | Meaning |
|---|---|
| `[scanner] worker started` | Worker goroutine began for this chain |
| `[evm] intent confirming` | EVM tx seen, waiting for confirmations |
| `[evm] intent confirmed` | EVM: N confirmations reached |
| `[tron] MATCH` / `[ton] MATCH` | Transfer matched, going to confirmed |
| `[webhook] delivered` | Webhook POST succeeded |
| `[webhook] non-2xx response` | Backend returned error (will retry) |
| `[webhook] all retries exhausted` | Intent moved to webhook_failed |
| `[scanner] reconciling confirmed intents` | Startup crash recovery in progress |
| `[evm] scanner lag` | Chain lag > 100 blocks (investigate RPC) |
| `[scanner] balance watch scheduler started` | Balance watch polling loop started |
| `[api] balance watch created` | Backend registered a direct-address watch |
| `[balance-watch] balance read error` | RPC failed while reading a watched balance |
| `[balance-watch-webhook] delivered` | Changed-balance webhook POST succeeded |
| `[balance-watch-webhook] non-2xx response` | Backend rejected changed-balance webhook; scanner will retry the change later |
---
## 5. Adding / modifying chains
Edit `supported-chains.json`. Fields:
| Field | Notes |
|---|---|
| `chainId` | Numeric EIP-155 chain ID (arbitrary int for Tron/TON) |
| `chainType` | `"evm"` (default) / `"tron"` / `"ton"` |
| `rpcUrl` | Primary RPC endpoint |
| `publicRpcUrl` | Fallback RPC (EVM only) |
| `proxyAddress` | ERC20FeeProxy address (EVM); USDT contract (Tron); USDT Jetton master (TON) |
| `confirmationThreshold` | Chain acceptance floor. EVM workers wait this many blocks; Tron/TON use it as the accepted confirmation count reported to backend |
| `verified` | `true` to activate the worker; `false` to disable without deleting |
> [!important]
> Changing `proxyAddress` for an EVM chain only affects new scans. Existing pending intents will still be matched against the old address until they expire or are confirmed.
After editing, restart the scanner container to pick up the new config.
---
## 6. Adding tokens to the registry
Edit `tokens.json`. Each entry:
```json
{ "chainId": 56, "address": "0x...", "symbol": "USDC", "decimals": 18, "name": "USD Coin" }
```
Token registry is used only for populating `tokenSymbol` and `decimals` in the `checkoutBlock` response. Omitting a token does not break scanning — it just leaves those fields empty.
For dev BSC Testnet, chain `97` symbol `USDT` must point at the deployed tUSDT contract:
```json
{ "chainId": 97, "address": "0x109F54Dab34426D5477986b0460aE5dFBA65f022", "symbol": "USDT", "decimals": 18, "name": "Test USDT (BSC Testnet)" }
```
After a registry change, restart/redeploy the scanner and verify through `POST /balances/check` by symbol, not only by explicit `tokenAddress`.
---
## 7. Manual webhook retry
Force immediate re-delivery of all `webhook_failed` intents:
```bash
curl -X POST -H "Authorization: Bearer $SCANNER_API_KEY" \
http://localhost:8080/admin/webhooks/retry
# {"queued": N}
```
---
## 8. Database inspection
The SQLite database (`/data/scanner.db`) can be inspected with the `sqlite3` CLI inside the container:
```bash
docker exec -it amn-scanner sqlite3 /data/scanner.db
# Check stuck intents
SELECT intent_id, chain_id, status, created_at, webhook_delivered_at
FROM intents
WHERE status NOT IN ('confirmed', 'expired')
ORDER BY created_at DESC;
# Check chain checkpoints
SELECT chain_id, last_scanned_block, updated_at FROM checkpoints;
# Count by status
SELECT status, count(*) FROM intents GROUP BY status;
# Check active direct-address watches
SELECT watch_id, chain_id, token_symbol, address, current_balance, next_check_at, expires_at
FROM balance_watches
WHERE status = 'watching'
ORDER BY next_check_at ASC;
# Count watches by status
SELECT status, count(*) FROM balance_watches GROUP BY status;
```
---
## 9. Troubleshooting
### Intent stuck in `pending`
1. Check `/scanner/status` — is the chain worker running and advancing (`lag` > 0 for a long time = RPC issue)?
2. Check that `chainId` and `tokenAddress` match exactly what is in `supported-chains.json` and `tokens.json`.
3. For EVM: verify the `proxyAddress` matches the contract the buyer is calling.
4. For Tron: confirm the destination address is stored in EVM-hex (0x) format in the DB.
5. Check scanner logs for `REJECT` messages around the expected tx time.
### Webhook never received by backend
1. Check `webhook_delivered_at` in the DB — if not null, the scanner delivered successfully and the backend side is the issue.
2. If null and status is `webhook_failed`: check backend logs for the incoming POST; verify `X-AMN-Signature` validation code.
3. If status is `confirmed` but `webhook_delivered_at` is null: startup reconciliation may re-deliver on next restart.
4. Use `POST /admin/webhooks/retry` to trigger immediate retry.
### High lag on EVM chain
1. Check RPC endpoint availability and rate limits.
2. Consider setting a `RPC_*` env override to a premium RPC (Alchemy, Infura, QuickNode).
3. The scanner falls back to `publicRpcUrl` if the primary fails but public nodes have lower limits.
### Intent confirmed but amount looks wrong
The scanner accepts any amount **>=** `intent.Amount`. Overpayments are not flagged. Underpayments result in the intent staying pending until TTL expiry.
### Direct balance watch is not firing
1. Confirm the target chain is EVM. Scanner `0.1.8` direct balance checks use ERC-20 `balanceOf(address)` and do not yet support Tron/TON balance reads.
2. Check `/scanner/status` for `activeBalanceWatches` on the expected chain.
3. Inspect `balance_watches.next_check_at`; if it is in the future, the scheduler is waiting according to the decay cadence.
4. Check logs for `[balance-watch] balance read error`; RPC failures reschedule the watch without notifying backend.
5. Confirm `callbackUrl` and `callbackSecret` match backend `AMN_SCANNER_WEBHOOK_SECRET`.
6. If `[balance-watch-webhook] non-2xx response` appears, inspect backend logs for the AMN scanner webhook route. The scanner keeps `current_balance` unchanged and retries the same balance change on the next due check.
### Direct balance watch should stop
Use either stop form:
```bash
curl -X DELETE -H "Authorization: Bearer $SCANNER_API_KEY" \
http://localhost:8080/balance-watches/<watchId>
curl -X POST -H "Authorization: Bearer $SCANNER_API_KEY" \
http://localhost:8080/balance-watches/<watchId>/stop
```
Backend should stop a watch after payment acceptance, cancellation, manual resolution, or when the payment is no longer payable.
---
## 10. CI/CD notes
- Woodpecker CI pipeline is in `.woodpecker/`.
- Telegram notify steps were removed (no TG secrets configured).
- Deploy step was removed — the scanner is deployed manually via `arcane-cli`.
- The CI pipeline builds and pushes the Docker image to the Gitea registry.
- Image tag format: `dev-<VERSION>` (from the `VERSION` file).
> [!tip]
> After CI completes, verify the image is in the registry before redeploying. Silent CI failures can leave a stale image tagged. Check the registry tag timestamp, not just the CI green light.

View File

@@ -0,0 +1,105 @@
---
title: Secret Rotation Runbook — 2026-05-30
tags: [operations, security, secrets, incident]
created: 2026-05-30
status: action-required
source: Full Codebase Audit - 2026-05-30
---
# Secret Rotation Runbook — 2026-05-30
The 2026-05-30 full codebase audit found live credentials committed to the repos and, in
some cases, baked into container images. The audit's no-brainer fixes **replaced the
committed values with placeholders in the working tree**, but the *real* credentials are
still valid and must be **rotated by a human** — replacing a string in git does not
invalidate a leaked key.
> Treat every credential below as **compromised**. Anyone with repo (or image) access has
> had these values. Rotate first, then scrub history.
Related issues: ISSUE-074, ISSUE-075, ISSUE-079, ISSUE-115 and decisions DEC-49, DEC-50,
DEC-56, DEC-74, DEC-75, DEC-78.
---
## Order of operations (per credential)
1. **Rotate** — generate a new value at the provider.
2. **Inject at runtime** — put the new value in the deployment secret store (Arcane env /
compose secrets), **never** back into a committed file.
3. **Deploy** — roll the new value out and confirm the service is healthy.
4. **Revoke** — invalidate the old value at the provider.
5. **Scrub** — remove the secret from git history (see "History scrub" at the bottom).
Do these one credential at a time and verify the dependent service after each.
---
## Credentials to rotate
| # | Credential | Where it leaked | Blast radius | How to rotate |
|---|-----------|-----------------|--------------|---------------|
| 1 | **Telegram bot token** | `backend/.env.development`, `backend/.env.example`, `frontend/.gitleaks.toml` | Full control of the bot: read/send messages, hijack the login widget, phish users | BotFather → `/revoke` → new token. Update `TELEGRAM_BOT_TOKEN`. |
| 2 | **Resend SMTP / API key** | `backend/.env.development`, `backend/.env.example` | Send email as the platform (phishing, OTP spoofing), read sending logs | Resend dashboard → API Keys → delete + create. Update `RESEND_API_KEY` / SMTP creds. |
| 3 | **JWT signing secret** | `backend/.env.example` | Forge **any** user/admin session token — critical | Generate 32+ random bytes (`openssl rand -hex 32`). Update `JWT_SECRET`. **Rotating invalidates all sessions** (users re-login). Consider also adding a separate `REFRESH_TOKEN_SECRET` (see DEC-26). |
| 4 | **Admin bootstrap password** | `backend/.env.example`, was also a hardcoded fallback in `init-admin.ts` (removed by NB-20) | Direct admin login | Set a strong `ADMIN_PASSWORD` secret; change the admin account password in-app; confirm `init-admin` no longer has a fallback. |
| 5 | **Request Network API key** | `backend/.env.example` | Act against the RN account; manipulate payment intents | RN dashboard → rotate key. Update `REQUEST_NETWORK_API_KEY`. |
| 6 | **Request Network webhook secret** | `backend/.env.example` | Forge RN webhooks → mark payments paid (this is the HMAC secret the backend verifies) | Rotate at RN; update `REQUEST_NETWORK_WEBHOOK_SECRET`. |
| 7 | **Telegram webhook secret token** | `backend/.env.example` | Forge Telegram webhook calls | Reset via `setWebhook` with a new `secret_token`; update the env var. |
| 8 | **Google OAuth client secret** | `backend/.env.example` | Impersonate the OAuth app | Google Cloud Console → Credentials → reset client secret. Update `GOOGLE_CLIENT_SECRET`. |
| 9 | **Alchemy API key(s)** | `frontend/Dockerfile` ARG defaults (removed by NB-10) | Quota theft / RPC abuse on your account | Alchemy dashboard → rotate app key. Supply via CI build-arg / runtime, not a default. |
| 10 | **TG_NOTIFY_BOT_TOKEN** (ops alert bot) | backend startup notification (committed env) | Spoof ops alerts; spam the ops channel | BotFather → revoke → new token. Update `TG_NOTIFY_BOT_TOKEN`. See [[telegram_notify_no_parse_mode]]. |
| 11 | **Frontend test account password** (`Moji6364`) | `frontend/scripts/show-credentials.sh` (DEC-75) | Login as that test user if it exists in any real env | Delete the script (or env-prompt it); rotate the account password if real. |
### Public-by-design (lower priority, but make explicit)
- **WalletConnect project ID**, **Google OAuth *client ID*** — `frontend/Dockerfile` ARG
defaults (DEC-74). These are public values, but remove the baked defaults and pass them
via CI build-args so forks don't reuse the production IDs.
---
## Stop re-leaking (pairs with rotation)
These are the structural fixes (tracked as decisions) that stop the secrets coming back:
- **DEC-50 / ISSUE-075** — `backend/.dockerignore` whitelists `.env.development` *into the
prod image*. Remove the `!.env.development` line so no env file is ever copied into an
image; inject secrets at runtime.
- **DEC-49 / ISSUE-101** — `backend/src/shared/config/index.ts` loads `.env.development`
unconditionally. Load `.env.<NODE_ENV>` (or nothing in production) and never fall back to
the dev file.
- **DEC-56 / ISSUE-074** — untrack `backend/.env.development` entirely (`git rm --cached`)
and add it to `.gitignore`.
- **DEC-78 / ISSUE-079** — `frontend/.gitleaks.toml` allowlists the bot token *by value*.
Switch to a path/fingerprint-based allowlist after scrubbing, so gitleaks stops
"approving" the secret. See the `handle-gitleaks` skill.
Runtime injection point for this stack: the **Arcane** env / project config (see
[[arcane_dev_stack]], [[arcane_cli_usage]]) for dev, and the production secret store for
prod. After changing any backend secret, remember the dev redeploy caveat:
restart `nickDev-nginx` (see [[devEscrow_nginx_after_redeploy]]).
---
## History scrub (after rotation + revocation)
Only after the old values are revoked, purge them from history so they can't be mined from
old commits:
1. Use `git filter-repo` (preferred) or BFG to remove the affected files/blobs from each
repo's history: `backend/.env.development`, the historical `backend/.env.example`,
`frontend/.gitleaks.toml` values, `frontend/scripts/show-credentials.sh`.
2. Force-push the rewritten history and have all collaborators re-clone. **Coordinate**
per [[parallel_agents_on_escrow]] another agent pushes to these branches; a history
rewrite mid-flight will conflict badly. Pick a quiet window.
3. Re-run gitleaks to confirm the working tree and history are clean.
---
## Verification checklist
- [ ] Each credential rotated at the provider and old value **revoked**.
- [ ] New values present only in the runtime secret store (no committed file holds a real value).
- [ ] Backend boots; `/api/health` green; login, email send, Telegram login, and an RN webhook all succeed with new secrets.
- [ ] `.env.development` untracked; `.dockerignore` no longer whitelists it; config no longer loads it in prod.
- [ ] gitleaks passes on working tree; history scrubbed and force-pushed in a coordinated window.

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,8 @@ Full-system audit triggered by completion of Telegram first-class auth, Request
| [[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 |
| [[Multi-Shop Branch Project Scan - 2026-06-10]] | Full nested-repo scan plus `feature/white-label-shops` documentation sync |
| [[Comprehensive Workspace Audit - 2026-06-10]] | Full all-repo security, frontend/backend, deployment, scanner, assist, dependency, and quality audit |
---

View File

@@ -0,0 +1,76 @@
---
title: C1 Secrets Rotation Checklist - 2026-06-10
tags: [audit, security, secrets, rotation, c1]
created: 2026-06-10
status: in-progress
---
# C1 Secrets Rotation Checklist - 2026-06-10
## 1. Tracked env files
deployment/.env and deployment/.env.dev are tracked in git.
- [ ] Rotate ALL credential values via provider dashboards first
- [ ] Create deployment/.env.example and deployment/.env.dev.example with placeholders
- [ ] Add deployment/.env and deployment/.env.dev to .gitignore
- [ ] Run: git rm --cached deployment/.env deployment/.env.dev
- [ ] Commit the removal
- [ ] History cleanup only after rotation confirmed
## 2. Test and source files with key-shaped material — triage each
For each, triage as real vs fake test fixture:
- backend/__tests__/decentralized-payment-verifier.test.ts
- backend/__tests__/payment-edge-cases.test.ts
- backend/__tests__/payment-integration.test.ts
- backend/__tests__/request-network-webhook.test.ts
- backend/__tests__/sweep-service.test.ts
- backend/__tests__/transaction-safety-provider.test.ts
- backend/src/services/payment/decentralizedPaymentService.ts
- backend/usdt-reset-test-report.md
- scanner/balance_test.go
- scanner/config.go
- nick-doc/01 - Architecture/Request Network Integration Constraints.md
- nick-doc/08 - Operations/Handoff - RN Multichain Probe - 2026-05-28.md
- nick-doc/10 - Services/scanner.md
- nick-doc/11 - Testing/Escrow Marketplace E2E Procedure.md
For real keys: rotate → replace with process.env.VAR_NAME → add to .env.example
For test fixtures: replace with obviously-fake value, add // test fixture comment
- [ ] backend/__tests__/decentralized-payment-verifier.test.ts
- [ ] backend/__tests__/payment-edge-cases.test.ts
- [ ] backend/__tests__/payment-integration.test.ts
- [ ] backend/__tests__/request-network-webhook.test.ts
- [ ] backend/__tests__/sweep-service.test.ts
- [ ] backend/__tests__/transaction-safety-provider.test.ts
- [ ] backend/src/services/payment/decentralizedPaymentService.ts
- [ ] backend/usdt-reset-test-report.md
- [ ] scanner/balance_test.go
- [ ] scanner/config.go
- [ ] nick-doc/01 - Architecture/Request Network Integration Constraints.md
- [ ] nick-doc/08 - Operations/Handoff - RN Multichain Probe - 2026-05-28.md
- [ ] nick-doc/10 - Services/scanner.md
- [ ] nick-doc/11 - Testing/Escrow Marketplace E2E Procedure.md
## 3. Documentation files
- [ ] Replace any key values in nick-doc/ with [REDACTED] or truncated form (0xfcE8...CdbA)
## 4. Git history cleanup (ONLY after rotation confirmed)
- [ ] All rotated credentials live and all code instances replaced
- [ ] Notify ALL contributors — history rewrite requires re-cloning
- [ ] Use git filter-repo or BFG Repo Cleaner
- [ ] Force-push all affected branches (requires explicit user approval)
## 5. Prevention
- [ ] Verify .gitignore blocks .env variants
- [ ] Confirm deployment/.gitleaks.toml is active
- [ ] Add gitleaks pre-commit hook: gitleaks protect --staged --config deployment/.gitleaks.toml
- [ ] Add gitleaks scan to Woodpecker CI pipeline
- [ ] Add to AGENTS.md: test keys must use process.env references, never inline values

View File

@@ -0,0 +1,30 @@
# C2: DrizzleChatRepo.findRows - Closeout Report
**File:** `src/db/repositories/drizzle/DrizzleChatRepo.ts`
**Method:** `findRows()`
**Status:** Fixed in `backend@8835068` / v2.9.35.
---
## What Was Finished
The earlier C2 fix moved the hot participant/archive/unread predicates into SQL, but this report correctly identified that edge paths could still fetch too much data. Commit `8835068` closes those remaining gaps:
| Gap from the partial report | Final behavior |
|---|---|
| Empty `findRows({})` fetched the whole chat table | All chat row reads now go through a bounded query builder with a 1000-row max cap |
| `findOne({})` could fetch many rows before returning one | `findOne()` now uses `LIMIT 1`; id-only queries use the existing `findById()` fast path |
| `findForUser()` applied `skip`/`limit` after fetching all matching rows | SQL-pushable predicates now use DB-side `offset`/`limit` |
| Search/fallback predicates still used in-memory matching | Fallback matching remains for unsupported predicates, but only after a bounded 1000-row scan |
| Chat `type` was left as a fallback predicate | `type` is now pushed to SQL for `direct`, `group`, and `support` |
| `settings.isArchived` had no matching B-tree index in the current schema/migrations | Added schema index and migration `0026_chat_settings_archived_idx.sql` for `chats_settings_is_archived_idx` |
## Verification
- `npm run typecheck` - passed.
- `npm test -- --runTestsByPath __tests__/drizzle-chat-repo.test.ts __tests__/db-audit-high-indexes.test.ts --runInBand` - passed, 2 suites / 9 tests.
- `scripts/smoke/db-audit-service-regressions.sh` - passed, 18 suites / 73 tests.
## Remaining Long-Term Schema Work
The C2 unbounded-fetch and pagination issue is closed. The larger chat storage design remains a separate schema project: moving `messages`, `participants`, and `unreadCounts` out of JSONB arrays into relational tables would enable exact indexed deep search, precise fallback pagination, and targeted message updates without rewriting large JSON blobs.

View File

@@ -0,0 +1,457 @@
---
title: Comprehensive Workspace Audit - 2026-06-10
tags: [audit, security, frontend, backend, deployment, scanner, dependencies, multi-shop]
created: 2026-06-10
updated: 2026-06-10
status: open
---
# Comprehensive Workspace Audit - 2026-06-10
Full workspace audit across nested Git repositories under `/Users/manwe/CascadeProjects/escrow`.
Primary product focus was the multi-shop branch:
- `frontend/`: `feature/white-label-shops`
- `backend/`: `feature/white-label-shops`
No code, build, deployment, pipeline, Docker, or secret files were changed during this audit.
## Scope
| Repo | Branch audited | Status at audit time | Notes |
|---|---|---|---|
| `frontend/` | `feature/white-label-shops` | Dirty worktree, ahead of remote | Multi-shop UI, tenant admin UI, Telegram Mini App, wallet/payment flows. |
| `backend/` | `feature/white-label-shops` | Clean worktree, ahead of remote | Tenant routes, storefront routes, payment services, file services, webhooks, scanner integration. |
| `deployment/` | `main` | Dirty worktree, ahead of remote | `escrow-multi` stack and environment material. |
| `scanner/` | `development` | Clean worktree, ahead of remote | Go payment scanner and balance-watch service. |
| `amanat-assist/` | `main` | Dirty worktree | Assist frontend plus local LLM proxy. |
| `nick-doc/` | `main` | Dirty worktree | Documentation vault, tenant docs, prior audits. |
Related lighter repo/documentation scan: [[Multi-Shop Branch Project Scan - 2026-06-10]].
## Method
- Read project instructions from root `AGENTS.md`, root `RTK.md`, and `nick-doc/AGENTS.md`.
- Enumerated all nested Git repositories.
- Confirmed frontend/backend were on `feature/white-label-shops`.
- Reviewed mounted backend routes and service boundaries for auth, tenant isolation, file access, payment state, webhooks, and scanner integration.
- Reviewed frontend app routes, auth/token storage, debug surfaces, API proxying, and dependency/runtime quality.
- Reviewed deployment compose files and tracked environment-file posture without printing secret values.
- Ran a sanitized secret scan that reported only file/path/line/pattern class, never values.
- Ran available read-only verification commands.
## Executive Summary
The most urgent risks are not cosmetic. They are live operational/security risks:
1. Tracked deployment `.env` files and additional key-shaped literals need immediate secret rotation and history cleanup.
2. The frontend-to-assist LLM path is unauthenticated and can proxy arbitrary model calls.
3. Tenant bot claim URLs are returned from a broad bot-list route and can leak capability tokens to lower tenant roles.
4. Generic file delete/info routes authorize only "logged in", not file ownership.
5. The Request Network intent route still defaults to trusting client-supplied payment amount unless oracle quoting is explicitly enabled.
6. Several payment routes compare JWT user ids directly against Postgres UUID payment fields, causing false-deny and inconsistent checkout/payment behavior.
## Finding Register
| ID | Severity | Area | Status |
|---|---|---|---|
| C1 | Critical | Secrets and credentials | Open |
| C2 | Critical | LLM proxy exposure | Open |
| H1 | High | Tenant bot claim authorization | Open |
| H2 | High | File delete/info authorization | Open |
| H3 | High | Client-trusted payment amount | Open |
| H4 | High | Payment UUID/JWT identity mismatch | Open |
| H5 | High | Dependency advisories | Open |
| M1 | Medium | Frontend typecheck bypass in builds | Open |
| M2 | Medium | Browser token storage | Open |
| M3 | Medium | Permit relay ownership/rate limit | Open |
| M4 | Medium | Production debug surface | Open |
| M5 | Medium | Scanner operational auth footgun | Open |
| M6 | Medium | Backend/frontend lint health | Open |
| L1 | Low | Deployment/dev defaults | Open |
| L2 | Low | File upload reliability and MIME hardening | Open |
## Critical Findings
### C1 - Tracked env files and key-shaped material require rotation
**Evidence**
- `deployment/.env` is tracked.
- `deployment/.env.dev` is tracked.
- `deployment/escrow-multi/docker-compose.yml` loads `.env` directly.
- Sanitized scan found token-shaped assignments in tracked deployment env files.
- Sanitized scan found private-key-shaped or key-like hex material in backend tests/reports/source, scanner tests/config/comments, and docs.
Representative locations, values intentionally omitted:
- `deployment/.env`
- `deployment/.env.dev`
- `backend/__tests__/decentralized-payment-verifier.test.ts`
- `backend/__tests__/payment-edge-cases.test.ts`
- `backend/__tests__/payment-integration.test.ts`
- `backend/__tests__/request-network-webhook.test.ts`
- `backend/__tests__/sweep-service.test.ts`
- `backend/__tests__/transaction-safety-provider.test.ts`
- `backend/src/services/payment/decentralizedPaymentService.ts`
- `backend/usdt-reset-test-report.md`
- `scanner/balance_test.go`
- `scanner/config.go`
- `nick-doc/01 - Architecture/Request Network Integration Constraints.md`
- `nick-doc/08 - Operations/Handoff - RN Multichain Probe - 2026-05-28.md`
- `nick-doc/10 - Services/scanner.md`
- `nick-doc/11 - Testing/Escrow Marketplace E2E Procedure.md`
**Impact**
Tracked env files and any real private-key material must be treated as exposed. If these values were ever valid, repository history, backups, forks, and local clones can retain them.
**Recommendation**
- Rotate all credentials found in tracked env files.
- Triage key-shaped literals into fake/test fixture vs real. Rotate any value that was ever used.
- Replace test keys with generated fixtures or env-var references.
- Move env files to untracked local templates: `.env.example`, `.env.dev.example`.
- Add ignore rules and pre-commit/CI secret scanning.
- History-clean only after rotation plan is agreed, because rewrite affects every clone.
### C2 - Public unauthenticated LLM proxy path
**Evidence**
- `frontend/src/app/api/llm/route.ts:8` accepts POST requests with no auth, rate limit, schema check, or body-size cap, then forwards arbitrary JSON to `LLM_PROXY_URL`.
- `amanat-assist/llm-proxy/index.mjs:73` defaults CORS to all origins when `ALLOWED_ORIGINS` is empty.
- `amanat-assist/llm-proxy/index.mjs:96` reads the full request body without a hard cap.
- `amanat-assist/llm-proxy/index.mjs:128` accepts caller-chosen `provider` and `model`.
- `amanat-assist/llm-proxy/index.mjs:180` logs upstream error data.
**Impact**
Any unauthenticated internet client that can reach the frontend route can spend provider quota, probe internal proxy behavior, and send unbounded payloads. If prompts include sensitive user data, the route also becomes an ungoverned data egress path.
**Recommendation**
- Require authenticated Amanat session or service-to-service token on `/api/llm`.
- Add per-user and per-IP rate limits.
- Validate request schema and allowlist provider/model.
- Enforce body-size caps at the Next.js route and proxy.
- Restrict CORS to known origins.
- Redact/log only status, provider, model class, and request id.
## High Findings
### H1 - Tenant bot claim URL leaks through broad bot listing
**Evidence**
- `backend/src/services/tenant/tenantBotService.ts:75-89` returns `claimUrl` for pending bots.
- `backend/src/services/tenant/tenantBotService.ts:268-270` maps all tenant bot rows through that public serializer.
- `backend/src/routes/tenantRoutes.ts:510-518` allows `owner`, `manager`, `finance`, `support`, and `developer` to list bots.
**Impact**
The claim URL contains a capability token. A lower-privileged tenant role that can list bots can obtain a pending bot claim link and potentially claim Telegram admin control for the bot.
**Recommendation**
- Remove `claimUrl` from the generic bot-list response.
- Keep claim URLs behind the existing owner/developer claim-link route or create a dedicated high-privilege capability endpoint.
- Store only a hashed claim token if practical.
- Add tests for support/finance/manager not receiving claim material.
### H2 - Generic file delete/info routes do not enforce ownership
**Evidence**
- `backend/src/services/file/fileRoutes.ts:71-82` exposes delete routes to any authenticated user.
- `backend/src/services/file/fileRoutes.ts:86-88` exposes file-info route to any authenticated user.
- `backend/src/services/file/fileController.ts:247-275` checks only that a user is authenticated before deleting.
- `backend/src/services/file/fileController.ts:278-299` checks only that a user is authenticated before returning file info.
- `backend/src/services/file/fileService.ts:30-50` safely confines paths to upload root, so this is an authorization issue rather than arbitrary filesystem traversal.
**Impact**
Any logged-in user can target public-upload files under the upload root if they know or guess the path. That can delete avatars, product/request-template/blog assets, or query metadata for files they do not own.
**Recommendation**
- Replace path-based mutation with file ids tied to an owner/resource.
- Require owner, admin, or resource participant checks before delete/info.
- Keep the existing upload-root confinement.
- Add tests for cross-user delete/info denial.
### H3 - Payment intent route defaults to client-trusted amount
**Evidence**
- `backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts:41-42` enables oracle quoting only when `ORACLE_QUOTING_ENABLED` is exactly `true`.
- `backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts:599-678` computes amount server-side only when the flag is enabled.
- `backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts:680-689` legacy path trusts client `amount`.
**Impact**
If the flag is absent or false in production, a buyer can submit a lower amount than the seller offer requires. The code comment itself marks this as a risk and says to remove the branch after cut-over.
**Recommendation**
- Make server-side quoting the default and remove the client-trusted fallback.
- Fail closed if seller offer/profile cannot be loaded.
- Persist quote inputs and outputs for auditability.
- Add regression tests that client amount is ignored or rejected.
### H4 - Payment route authorization mixes legacy ids and Postgres UUIDs
**Evidence**
- `backend/src/db/repositories/drizzle/DrizzlePaymentRepo.ts:915-932` resolves and stores `buyerId` as a Postgres UUID.
- `backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts:390-392` compares `payment.buyerId` directly to JWT user id.
- `backend/src/services/payment/paymentRoutes.ts:417-418`, `440-442`, and `464-467` compare direct payment ids to JWT user id.
**Impact**
Legitimate buyers can be denied checkout reload, status, or confirmation when JWT ids are legacy ObjectIds but payment rows store UUIDs. Inconsistent route behavior also makes payment support and debugging brittle.
**Recommendation**
- Reuse the canonical access helper already present in `paymentController.ts`.
- Normalize user/payment participant checks through one identity utility.
- Add regression tests for legacy JWT id against UUID-backed payment rows.
### H5 - Production dependency advisories remain open
**Evidence**
Frontend production dependency audit:
- 2 critical
- 16 high
- 39 moderate
- 8 low
- Largest critical/high cluster: `protobufjs` via Trezor dependencies in `frontend/yarn.lock`.
Backend production dependency audit:
- 7 high
- 7 moderate
- Notable packages: `axios`, `jws`, `lodash`, `path-to-regexp`, `socket.io-parser`, `validator`, `ws`.
Amanat Assist production dependency audit:
- 0 vulnerabilities reported.
**Impact**
Wallet, websocket, HTTP, validation, and JWT-adjacent packages are part of high-risk surfaces. Some advisories are transitive and may require careful upgrade testing, but they should not stay invisible in release planning.
**Recommendation**
- Update lockfiles in a controlled dependency-hardening branch.
- Prioritize frontend `protobufjs`/Trezor path and backend `axios`, `jws`, `socket.io-parser`, `validator`, `path-to-regexp`.
- Run payment, wallet, Telegram, socket, and checkout smoke tests after upgrades.
## Medium Findings
### M1 - Frontend builds skip TypeScript errors
**Evidence**
- `frontend/next.config.ts:27-29` sets `typescript: { ignoreBuildErrors: true }`.
- Frontend lint also reports `@ts-nocheck` in payment components.
**Impact**
Production builds can ship TypeScript failures. This is especially risky while multi-shop, Telegram Mini App, and payment code are changing quickly.
**Recommendation**
- Remove `ignoreBuildErrors` once current type issues are cleaned.
- Add a separate `tsc --noEmit` CI gate if Next build must stay fast.
### M2 - Browser token storage increases XSS blast radius
**Evidence**
- `frontend/src/auth/context/jwt/auth-provider.tsx:35` reads `accessToken` from localStorage.
- `frontend/src/lib/axios.ts:69-72` sends localStorage token as bearer auth.
- `frontend/src/lib/axios.ts:127-141` still supports legacy localStorage refresh token cleanup.
- `amanat-assist/src/services/auth.ts:38-57` persists access/refresh token state to localStorage.
- `amanat-assist/src/services/auth.ts:152-163` accepts OAuth tokens from URL query params.
**Impact**
Any XSS can extract bearer tokens. Query-param token handoff can also leak through browser history, analytics, referrers, or logs before cleanup.
**Recommendation**
- Move toward httpOnly session cookies or a backend-for-frontend pattern for browser sessions.
- Stop carrying access/refresh tokens in URL query params.
- Add strict CSP and minimize inline script risk.
- Keep localStorage only for non-sensitive UI state.
### M3 - Permit relay route lacks ownership check and rate limit
**Evidence**
- `backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts:257-324` relays a signed permit for a payment after validating rail/spender/value.
- The route is authenticated but does not verify the requester owns the payment.
- The code comment at `requestNetworkRoutes.ts:255-256` notes it should be rate-limited per buyer.
**Impact**
This can leak pending payment existence and can cause relayer gas spend attempts for payments the requester does not own, provided they have a valid permit payload.
**Recommendation**
- Require buyer/admin access before permit validation and relay.
- Add per-buyer/per-payment rate limiting.
- Return indistinguishable 404/403 behavior if needed to reduce enumeration.
### M4 - Telegram debug panel can show production operational/user data
**Evidence**
- `frontend/src/components/debug/telegram-debug-panel.tsx:44-56` shows the panel in production for Mini App context or explicit debug request.
- `frontend/src/components/debug/telegram-debug-panel.tsx:73-96` displays API/socket URLs, email, role, Telegram platform/version, and initData presence/length.
**Impact**
This is not direct token leakage, but it exposes user and operational diagnostics in production UI.
**Recommendation**
- Gate production debug panels behind admin/developer role and signed debug mode.
- Hide email and internal URLs unless explicitly needed.
- Consider stripping the panel from production builds.
### M5 - Scanner endpoints are unauthenticated if `SCANNER_API_KEY` is missing
**Evidence**
- `scanner/config.go:115-127` reads `SCANNER_API_KEY` and logs that endpoints are unauthenticated when missing.
- `scanner/api.go:111-126` allows all requests when the key is empty.
**Positive controls observed**
- `scanner/api.go:135-136` caps create-intent body size.
- `scanner/security.go:58-99` validates callback URLs against SSRF rules.
- `scanner/security.go:102-127` adds dial-time protection for public callback mode.
**Impact**
The scanner is safe only if production always sets `SCANNER_API_KEY` and ingress does not expose it unintentionally.
**Recommendation**
- Fail startup in production when `SCANNER_API_KEY` is missing.
- Keep the current dev-mode behavior only for explicit local mode.
- Add deployment checks for scanner auth.
### M6 - Lint health is currently failing in frontend and backend
**Evidence**
Backend `npm run lint`:
- 29 errors
- 996 warnings
- Error classes include forbidden `require()` imports, empty blocks, and namespace usage.
Frontend `npx yarn@1.22.22 lint`:
- 83 errors
- 65 warnings
- Notable correctness errors include conditional React hooks in `frontend/src/sections/telegram/view/telegram-points-view.tsx:59-61` and `@ts-nocheck` in payment components.
**Impact**
Lint is not just style here. Hook ordering and `@ts-nocheck` can hide runtime failures in Telegram/payment flows.
**Recommendation**
- Fix hook-rule and `@ts-nocheck` violations first.
- Then decide whether import-sort failures should block release or be auto-fixed.
- Keep lint gating focused enough that teams do not normalize red builds.
## Low Findings
### L1 - Deployment/dev defaults are footguns
**Evidence**
- `deployment/docker-compose.yml` and `deployment/dev-amn/docker-compose.yml` include hardcoded dev/default database or Redis passwords.
- `deployment/escrow-multi/migrate/migrations/0018_db_privilege_isolation.sql` contains role password literal `undefined`.
**Impact**
These are not necessarily live production secrets, but defaults can become real accidentally when copied between stacks.
**Recommendation**
- Replace hardcoded dev credentials with env references and clear examples.
- Fix or remove copied migration files that embed `undefined` password literals.
### L2 - Upload reliability and MIME hardening gaps
**Evidence**
- General upload flows rely primarily on MIME validation.
- Chat attachment handling has stronger magic-byte validation, but generic uploads are less strict.
- Non-image multi-file upload code constructs output paths for documents but needs verification that files are moved/copied as expected.
**Impact**
Potential broken uploads for documents and weaker file-type assurance outside chat.
**Recommendation**
- Reuse chat attachment magic-byte validation for all user-controlled file uploads where practical.
- Add focused tests for multi-file document upload persistence.
## Positive Observations
- Backend mounts raw webhook body parsing before global JSON parsing for Request Network, AMN scanner, and Telegram tenant webhooks.
- Socket.IO connection auth rejects refresh tokens and verifies room membership for chat/request scoped rooms.
- Tenant resolution uses host/slug context rather than trusting arbitrary caller headers.
- Scanner has meaningful SSRF defenses for callback URLs and dial-time checks in the public-callback mode.
- Markdown rendering uses `rehypeRaw` followed by sanitization and protocol restrictions.
- Backend `tsc --noEmit -p tsconfig.json` passed.
- Scanner `go test ./...` passed.
- Amanat Assist `npm run build` passed.
- Amanat Assist `npm audit --omit=dev` reported no production vulnerabilities.
## Verification Commands
| Repo | Command | Result |
|---|---|---|
| `backend/` | `npm run typecheck` | Passed |
| `backend/` | `npm run lint` | Failed: 29 errors, 996 warnings |
| `backend/` | `npm audit --omit=dev --json` | Failed advisories: 7 high, 7 moderate |
| `frontend/` | `npx -y yarn@1.22.22 lint` | Failed: 83 errors, 65 warnings |
| `frontend/` | `npx -y yarn@1.22.22 audit --groups dependencies --json` | Failed advisories: 2 critical, 16 high, 39 moderate, 8 low |
| `scanner/` | `go test ./...` | Passed |
| `amanat-assist/` | `npm run build` | Passed |
| `amanat-assist/` | `npm audit --omit=dev --json` | Passed: 0 vulnerabilities |
Frontend repo declares Yarn 1 and this shell did not have a global `yarn` binary, so frontend commands were run through `npx -y yarn@1.22.22`.
## Recommended Remediation Order
1. Rotate and scrub tracked secret material.
2. Lock down `/api/llm` and `amanat-assist/llm-proxy`.
3. Remove claim URLs from broad tenant bot listing.
4. Add file ownership/resource checks to delete/info routes.
5. Force server-side payment pricing and remove client-trusted amount fallback.
6. Normalize payment participant authorization across UUID and legacy id paths.
7. Upgrade vulnerable dependency clusters.
8. Fix frontend hook-rule and `@ts-nocheck` lint failures.
9. Re-enable strict frontend type/build checks.
10. Harden scanner production startup around `SCANNER_API_KEY`.
## Notes and Guardrails
- Do not print, paste, or document actual secret values while remediating C1.
- Do not change Woodpecker pipelines, Dockerfiles, deploy commands, cache/prune behavior, or production build procedure without explicit approval.
- Frontend/backend code changes require coordinated patch version bumps before build/deploy. This documentation-only audit does not require a version bump.
- Treat `feature/white-label-shops` work as isolated from `escrow-dev`/`dev-amn`; target `escrow-multi` for multi-shop deployment work.

View File

@@ -0,0 +1,272 @@
# DB Migration Audit Report — Amanat Escrow (Mongo→PG)
**Date:** 2026-06-02 | **Scope:** Full Mongo→PG migration audit — schemas, indexes, constraints, dual-write coverage, backfill, verify harness, and service-layer Mongo-idiomatic patterns
---
## Executive Summary
The migration is **~50% complete** and **NOT ready for PG-primary cutover**. Schema and backfill scaffolding are mature (all 13 in-scope Mongo collections have Drizzle tables and backfill scripts), but three categories block cutover:
1. **Migration correctness**`0004_funds_ledger_entries.sql` is unjournaled (silently skips `funds_ledger_entries` on fresh DBs); `shadowRead()` exists but is never called from any read path so the soak window is completely blind.
2. **Financial integrity gaps** — missing `CHECK (amount > 0)` / `fx_rate > 0` constraints, ~20 FK columns declared in `relations()` only (never as physical FKs), backfill that silently writes `amount = '0'` for NULL Mongo amounts.
3. **Service-layer rework is far bigger than the schema work** — the factory (`createRepositories`) has **zero callers**; 30+ services still import Mongoose directly and contain ~50 Mongo-idiomatic patterns (N+1 loops, full-fetch+JS-filter, read-modify-write without locking, multi-table writes with no transaction) that will cause real money errors and lost updates under concurrent load.
---
## Critical Issues (must fix before cutover)
| # | Dimension | Table/File → Issue | Fix |
|---|---|---|---|
| CR-1 | Migration | `0004_funds_ledger_entries.sql` has NO `_journal.json` entry; `funds_ledger_entries` DDL conflicts between 0003 and 0004 — silently skipped on fresh DB, `ALTER` fails with "constraint already exists" if run | Reduce 0004 to trigger-only DDL; register in journal |
| CR-2 | Arch | `shadowRead.ts` exists and is complete, but no `DualWrite*Repo` read method ever calls it — soak window measures zero signal | Wire `shadowRead()` + `ShadowReadMetrics` into all 4 DualWrite read paths |
| CR-3 | Arch | Factory `createRepositories` has **zero callers** outside `src/db/repositories/` — every `REPO_*=dual` env flag changes env but routes zero traffic | Inject factory into 30+ service files (`paymentController`, `paymentCoordinator`, marketplace/user/points services) |
| CR-4 | Backfill | `backfill-payments.ts`: `extractDecimalString(null)` returns `'0'` — NULL Mongo payment amount silently inserted as `amount = 0` (money integrity violation) | Make `extractDecimalString(null)` throw, or skip + warn |
| CR-5 | Backfill | `backfill-derivedDestinations.ts`: `require()` failure falls back to `strict:false` model — all fields become `undefined`, all rows skipped silently, exits 0 | Make import failure `throw` and exit 1; never fall back to schema-less model |
| CR-6 | Backfill | `backfill-fundsLedger.ts` + `backfill-pointTransactions.ts`: `upsertIdMap` + `INSERT` not in one transaction — interruption leaves orphan id_map rows; re-run `DO NOTHING` never inserts data row → unrecoverable | Wrap `upsertIdMap` + data INSERT in one PG transaction |
| CR-7 | Schema | `payment_quotes`: no `CHECK (offer_amount > 0 AND fx_rate > 0 AND token_price_usd > 0)` — zero/negative FX rate → divide-by-zero in settlement | Add three CHECK constraints |
| CR-8 | Schema | `payments`: no `CHECK (amount > 0)` | `ALTER TABLE payments ADD CONSTRAINT ck_payments_amount_pos CHECK (amount > 0)` |
| CR-9 | Verify | `checksums.ts`: `.catch(() => [])` silently returns `[]` on DB connection failure → `hasMismatch=false`, gate passes green | Propagate errors; never swallow in comparison path |
| CR-10 | Verify | `shadowRead.ts`: Decimal128 detection (`constructor.name === 'Decimal128'`) breaks on `.lean()` POJO results (`{$numberDecimal:...}`) — every numeric field appears equal, silent false-negative | Normalize amounts to strings before compare; detect `$numberDecimal` key |
| CR-11 | Verify | `migration-fk-idmap.test.ts`: `skipIfUnreachable` returns early without `test.skip()` — money-safety tests PASS when DB is unreachable; CI exit 0 | Call `test.skip()` when `!isReachable`; CI must assert `MONEY_SAFETY_TESTS_SKIPPED` absence |
| CR-12 | Verify | `rowCounts.ts`: id_map coverage check exists for `payments` only — dropped id_map entry for other collections → dangling FK silently | Add id_map coverage checks for users/purchaseRequests/sellerOffers/fundsLedger/pointTransactions |
| CR-13 | Code | `paymentCoordinator.ts` `executePaymentUpdate`: read `status` → JS guard → write — two concurrent webhooks both read `pending`, both write (lost update) | `UPDATE payments SET status=... WHERE id=... AND status NOT IN ('completed','cancelled','refunded') RETURNING *`; 0 rows → abort |
| CR-14 | Code | `paymentCoordinator.ts` dispute gate: `isReleaseBlockedById(prId)` read, then payment update — dispute raised in the gap bypasses gate | `SELECT ... FOR UPDATE` on PR row + payment update in one transaction |
| CR-15 | Code | `paymentCoordinator.ts` `executePaymentUpdate`: payment update + ledger append + PR backfill + `acceptOffer` + duplicate cancel + template delete run with **NO transaction** — step 3/4 failure leaves payment completed but offer not accepted | Wrap all side effects in one DB transaction |
| CR-16 | Code | `DisputeService.ts` `createDispute`: dispute create → chat create → save → `setChatId` with no transaction — partial failure → orphaned dispute/chat, UI crashes | Wrap all four ops in one PG transaction |
| CR-17 | Code | `SellerOfferService.ts` `withdrawOffer` + `marketplaceController.ts` `validateStatusTransition`: read-validate-write status machine with no atomic guard | `UPDATE ... WHERE id=... AND status=... RETURNING *`; 0 rows → 409 Conflict |
| CR-18 | Code | `PointsService.ts` `getReferrals`/`collectDeliveredReferralOrders`: per-referred-user `while(true)` skip/limit loop + per-row offer lookup → 510+ queries/user; 14+ s per leaderboard on WAN | Replace with single CTE: `LEFT JOIN purchase_requests/seller_offers/point_transactions ... GROUP BY u.id` |
| CR-19 | Code | `PurchaseRequestService.ts` `searchPurchaseRequests`: `findPurchaseRequests({limit:100})` then JS `.filter().slice(0,20)` — catastrophic at 10k rows | `WHERE title ILIKE $s OR description ILIKE $s LIMIT 20`, or `tsvector` generated column + GIN |
| CR-20 | Code | `Chat` model: `messages[]`/`participants[]`/`unreadCounts[]` as JSONB — no FK integrity, unbounded row bloat, non-indexable | Child tables `chat_messages`, `chat_participants`, `chat_message_reactions`; rewrite `ChatService` as SQL |
---
## High-Priority Issues
### Schema — Missing Physical FKs
All declared via Drizzle `relations()` only, never as `foreignKey()`/`.references()`. Zero referential integrity enforcement in DB.
- `users.referred_by_id` → add FK `ON DELETE SET NULL`
- `purchase_requests.buyer_id`, `category_id`, `selected_offer_id`
- All PR child tables (`purchase_request_delivery_info`, `_delivery_address`, `_seller_delivery_info`, `_service_info`, `_specifications`, `_preferred_sellers`) — `purchase_request_id`/`delivery_info_id`
- `delivery_attempts.delivery_info_id`, `seller_id`
- `derived_destinations.buyer_id`, `seller_id`, `seller_offer_id`
- `derived_destination_sweeps.destination_id`
- `trezor_accounts.user_id`; `trezor_derived_addresses.trezor_account_id`
- `funds_ledger_entries.purchase_request_id`, `payment_id` (deferred since 0003, never added)
- `point_transactions.user_id`, `referred_user_id`
### Schema — Other HIGH
- `users`: no `CHECK (points_available >= 0 AND points_available <= points_total)`
- `point_transactions`: no `CHECK (balance >= 0)`
- `payment_quotes`: no `CHECK (settle_amount >= raw_settle_amount)` (snap-up invariant unenforced)
- `purchase_request_preferred_sellers`: no composite PK, only uniqueIndex
- `seller_offers.price_amount numeric(18,8)` vs project-wide `numeric(38,18)` (precision gap in settlement)
### Migration — HIGH
- All 70+ `CREATE INDEX` are non-`CONCURRENTLY` (blocking SHARE lock on live data for all)
- All FK `ADD CONSTRAINT` run validating (no `NOT VALID` + later `VALIDATE`) — prolonged ACCESS EXCLUSIVE lock
- `blog_posts` and `notifications` exported from schema barrel but **no migration creates them**
- `disputes`/`chats` use `text` (not `uuid`) for FK columns — zero referential integrity
- Migration 0009: three sequential `UPDATE` DML steps not in `BEGIN/COMMIT` — partial failure leaves inconsistent category re-parenting
### Backfill — HIGH
- `String(number)` for `numeric` columns risks scientific notation in `backfill-purchaseRequests.ts` (budget), `backfill-sellerOffers.ts` (price.amount), `backfill-requestTemplates.ts` (budget, proposal.price)
- `backfill-users.ts`: `email ?? null` fails if `users.email` is NOT NULL for OAuth-only users
- `backfill-fundsLedger.ts`: missing `d.entryType` (no default) → NOT NULL violation
- `run-backfill.ts`: `requestTemplates` runs in Tier B but runbook documents it last (inconsistency)
### Verify — HIGH
- `reconcile.ts`: no double-refund detection; no `escrow_state`↔last-ledger-entry check; `LIMIT 1000` silently truncates
- `rowCounts.ts`: `estimatedDocumentCount()` is approximate — use `countDocuments({})`
- `checksums.ts`: no Mongo-side per-user points balance comparison during dual-write window
- `ledgerImmutability.ts`: `TRUNCATE` bypasses row-level trigger — add `BEFORE TRUNCATE` statement-level trigger
- Enum-value completeness verified nowhere
### Code — HIGH
- `SellerOfferService.ts` `acceptOffer`: per-rejected-seller `createNotification` loop (use `createNotificationsBulk`); multi-UPDATE repo needs transaction
- `RequestTemplateService.ts` `batchConvertTemplates`: ~50 sequential queries per 10-item cart; no transaction per item → orphan PRs with no offer, oversold templates
- `paymentService.ts` `createPaymentRecord`: `String(metadata?.sellerId || createLegacyObjectIdString())` injects random fake ObjectIds as FKs → PG FK violation
- `userController.ts` `getUsersList`: `$regex` on name/email → PG seqscan; needs `pg_trgm` GIN index + `ILIKE`
- `PurchaseRequestService.ts` `updatePurchaseRequestStatus` (completed): non-idempotent double-points risk; no transaction
---
## Medium Issues
**Schema:** dual unique indexes on `categories.name` (drop raw, keep partial `WHERE is_active`); missing `payments(purchase_request_id, status)` composite index; missing `seller_offers(seller_id,status)`, `derived_destinations(address, chain_id)`, `trezor_derived_addresses.address` indexes; `id_map` no PK and `new_id` no unique constraint; `request_templates` no `CHECK (usage_count <= max_usage)`.
**Migration:** `wallet_type` enum created but used in no column (dead DDL); `ALTER TYPE offer_currency ADD VALUE 'TRY'` requires PG 12+ in-transaction; `ck_pr_budget_currency_crypto` add(0006)/drop(0007) round-trip fails on rows with non-crypto values; `chats.participants` JSONB has no GIN index.
**Backfill:** enum default mismatches (`provider:'request.network'` vs `request_network`); `escrow_state ?? null` may hit NOT NULL; `derivedDestinations.lastKnownBalance` via JS Number loses precision above 2^53 for wei.
**Code:** `dataCleanupService.getCollectionStats` — 13 sequential `countDocuments()` (should be single subselect); `userController.updateUserProfile` writes arbitrary `profile.${key}` (whitelist needed); `paymentCoordinator` metadata read-spread-write overwrites concurrent keys (use `metadata || jsonb_build_object(...)`); skip/limit pagination in `getOffersBySeller`/`getUsersList`.
---
## Index & Constraint Punch List
| Table | Missing | Recommended DDL |
|---|---|---|
| payments | CHECK amount > 0 | `ALTER TABLE payments ADD CONSTRAINT ck_payments_amount_pos CHECK (amount > 0);` |
| payments | (purchase_request_id, status) | `CREATE INDEX CONCURRENTLY idx_payments_pr_status ON payments (purchase_request_id, status);` |
| payments | disputed partial | `CREATE INDEX CONCURRENTLY idx_payments_disputed ON payments (id) WHERE disputed = true;` |
| payment_quotes | CHECK money fields | `ALTER TABLE payment_quotes ADD CONSTRAINT ck_pq_pos CHECK (offer_amount > 0 AND fx_rate > 0 AND token_price_usd > 0);` |
| payment_quotes | CHECK snap-up | `ALTER TABLE payment_quotes ADD CONSTRAINT ck_pq_settle CHECK (settle_amount >= raw_settle_amount);` |
| users | referred_by_id FK | `ALTER TABLE users ADD CONSTRAINT users_referred_by_fk FOREIGN KEY (referred_by_id) REFERENCES users(id) ON DELETE SET NULL NOT VALID;` then `VALIDATE` |
| users | CHECK points | `ALTER TABLE users ADD CONSTRAINT ck_users_points CHECK (points_available >= 0 AND points_used >= 0 AND points_total >= 0 AND points_available <= points_total);` |
| point_transactions | CHECK balance | `ALTER TABLE point_transactions ADD CONSTRAINT ck_pt_balance CHECK (balance >= 0);` |
| funds_ledger_entries | FK pr + payment | `ALTER TABLE funds_ledger_entries ADD CONSTRAINT fle_pr_fk FOREIGN KEY (purchase_request_id) REFERENCES purchase_requests(id) NOT VALID;` then `VALIDATE` |
| funds_ledger_entries | TRUNCATE trigger | `CREATE TRIGGER funds_ledger_no_truncate BEFORE TRUNCATE ON funds_ledger_entries FOR EACH STATEMENT EXECUTE FUNCTION funds_ledger_immutable_fn();` |
| trezor_accounts | user_id FK | `ALTER TABLE trezor_accounts ADD CONSTRAINT ta_user_fk FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;` then `VALIDATE` |
| derived_destinations | buyer/seller/offer FK + (address,chain_id) | add 3 FKs `NOT VALID`; `CREATE INDEX CONCURRENTLY idx_dd_addr_chain ON derived_destinations (address, chain_id);` |
| purchase_requests | buyer/category/offer FK + (status,created_at) | add 3 FKs `NOT VALID`; `CREATE INDEX CONCURRENTLY idx_pr_status_created ON purchase_requests (status, created_at DESC);` |
| seller_offers | (seller_id,status) + (purchase_request_id,status) | `CREATE INDEX CONCURRENTLY idx_so_seller_status ON seller_offers (seller_id, status);` |
| id_map | PK + new_id unique | `ALTER TABLE id_map ADD PRIMARY KEY (collection, legacy_object_id); CREATE UNIQUE INDEX id_map_new_id_uq ON id_map (new_id);` |
| users (search) | trigram | `CREATE INDEX CONCURRENTLY idx_users_name_trgm ON users USING GIN (lower(first_name\|\|' '\|\|last_name\|\|' '\|\|coalesce(email,'')) gin_trgm_ops);` |
| purchase_requests | tags GIN | `CREATE INDEX CONCURRENTLY idx_pr_tags ON purchase_requests USING GIN (tags);` |
---
## Repository Coverage Matrix
| Interface | Drizzle Impl | Dual-Write | Status |
|---|---|---|---|
| PaymentRepo | Yes | Yes (shadow read NOT wired) | PARTIAL |
| UserRepo | Yes | Yes (shadow read NOT wired) | PARTIAL |
| MarketplaceRepo | Yes | Yes (shadow read NOT wired) | PARTIAL |
| PointsRepo | Yes | Yes (shadow read NOT wired) | PARTIAL |
| ReleaseHoldRepo | Yes | — | No dual-write |
| TrezorAccountRepo | Yes | — | No dual-write |
| DerivedDestinationRepo | Yes | — | No dual-write |
**Factory has zero application callers (CR-3) — most critical architecture gap.**
---
## Backfill Coverage Matrix
| Mongo Collection | Backfill Script | Ordering | Status |
|---|---|---|---|
| users | backfill-users.ts | Tier A | OK (email NOT NULL risk) |
| categories | backfill-categories.ts | Tier A | OK |
| requestTemplates | backfill-requestTemplates.ts | Tier B | OK (String() decimals; runbook order mismatch) |
| purchaseRequests | backfill-purchaseRequests.ts (2-pass) | Tier B | OK (String() decimals; silent preferred-seller skips) |
| sellerOffers | backfill-sellerOffers.ts | Tier B | OK (String() price.amount) |
| payments | backfill-payments.ts | Tier C | **RISK** — NULL amount → '0' (CR-4) |
| fundsLedger | backfill-fundsLedger.ts | Tier C | **RISK** — non-txn idMap (CR-6); entryType NOT NULL |
| derivedDestinations | backfill-derivedDestinations.ts | Tier C | **RISK** — schema-less fallback (CR-5); wei precision |
| trezorAccounts | backfill-trezorAccounts.ts | Tier C | OK |
| pointTransactions | backfill-pointTransactions.ts | Tier C | **RISK** — non-txn idMap (CR-6); String() decimals |
| id_map | (infra — `_idMap.ts`) | n/a | CORRECT |
| payment_quotes | (none — runtime-generated) | n/a | EXPECTED |
| pg_dualwrite_gaps | (none — operational log) | n/a | EXPECTED |
---
## Verification Coverage Matrix
| Concern | Covered By | Gap |
|---|---|---|
| Row-count parity | rowCounts.ts (9/~23 collections) | `estimatedDocumentCount()` approximate; id_map not counted |
| ID-mapping completeness | rowCounts.ts (payments only) | **CRITICAL** — no check for users/PR/sellerOffers/FLE/pointTransactions |
| FK integrity | rowCounts.ts (7 pairs) | `seller_offers.seller_id`, `trezor_accounts→users` missing |
| Money sum accuracy | checksums.ts | `.catch(()=>[])` silent pass on conn failure (CR-9) |
| Ledger reconciliation | reconcile.ts | No double-refund; no `escrow_state`↔last-entry; LIMIT 1000 truncation |
| Ledger immutability | ledgerImmutability.ts | TRUNCATE bypass; no schema filter on `pg_proc` |
| Shadow read fidelity | shadowRead.ts | Decimal128 lean false-negative (CR-10); not wired (CR-2) |
| Enum completeness | — | **Not covered anywhere** |
| Timestamp precision/TZ | — | Not covered |
| CI gate output | boolean only | No JSON stdout; tests pass-not-skip on unreachable DB (CR-11) |
---
## Models Not Yet in PG Schema
| Mongo Model | Fields | Actively Used | Effort | Notes |
|---|---|---|---|---|
| Dispute | ~15 + 3 embedded arrays | Yes (DisputeService, releaseHoldService) | **L** | `evidence[]`/`timeline[]`/`messages[]` → child tables; pre-save timeline hook → service |
| Notification | 11 | Yes (all services, high frequency) | S schema / M migration | `userId` as text → uuid FK backfill; TTL index → pg_cron |
| ShopSettings | ~12 | Yes (marketplace template pages) | S | `paymentConfig.allowedChains int[]`, `socialLinks` → 4 columns |
| ConfigSetting (+History) | 4 (+audit) | Yes (walletMonitor, scanner threshold) | S | key-value; history child table |
| LevelConfig | ~10 | Yes (PointsService) | S | flatten `benefits{}` to 4 columns |
| Address | 10 | Yes (dataCleanup, delivery flows) | S | `addressType` pgEnum; one-primary partial unique |
| Review | 9 | Admin CMS only | S | polymorphic `subjectId` → ref_kind discriminator |
| TelegramLink | ~12 | Yes (auth) | S | two unique constraints; (userId, status) idx |
| TelegramSession | ~10 | Yes (auth middleware) | S | TTL `expiresAt` → pg_cron |
| BlogPost | ~20 | Admin CMS only | S | `videos[]` child table; slug/publishedAt pre-save → service |
| TempVerification | 8 | Registration only | S | TTL cleanup |
---
## Mongo-Idiomatic Code Refactoring Tracker
| Pattern | File | Function | Severity | Fix |
|---|---|---|---|---|
| N+1 | PointsService.ts | getReferrals / collectDeliveredReferralOrders | CRITICAL | Single CTE with LEFT JOINs + GROUP BY |
| N+1 | SellerOfferService.ts | acceptOffer | HIGH | `createNotificationsBulk` + single seller-id query |
| N+1 | RequestTemplateService.ts | batchConvertTemplates | HIGH | Batch SELECT ANY($links), batch INSERT…VALUES, single usage UPDATE |
| N+1 | dataCleanupService.ts | getCollectionStats | MEDIUM | Single subselect count query |
| Full-fetch+filter | PurchaseRequestService.ts | searchPurchaseRequests | CRITICAL | ILIKE/tsvector WHERE + LIMIT 20 |
| Full-fetch+filter | PurchaseRequestService.ts | createPurchaseRequest (dup detect) | HIGH | WHERE buyer/title/description/created_at LIMIT 1 |
| Full-fetch+filter | paymentCoordinator.ts | executePaymentUpdate (template cleanup) | HIGH | Push JSONB conditions into WHERE/DELETE |
| Full-fetch+filter | userController.ts | getUsersList | HIGH | pg_trgm GIN + ILIKE |
| JSONB no join table | Chat | messages/participants/unreadCounts | CRITICAL | 3 child tables (CR-20) |
| JSONB no join table | Dispute | evidence/timeline/messages | HIGH | 3 child tables on migration |
| JSONB schemaless | Payment | metadata | HIGH | Promote `is_template_checkout`, `rn_request_id` to typed columns |
| In-memory agg | PointsService.ts | sumDeliveredReferralSpend | CRITICAL | SUM in CTE |
| In-memory agg | SellerOfferService.ts | getOfferStatistics | MEDIUM | COUNT(*) OVER() / ROLLUP |
| Lost update | paymentCoordinator.ts | executePaymentUpdate | CRITICAL | UPDATE…WHERE status NOT IN (terminal) RETURNING |
| Lost update | SellerOfferService.ts | updateOffer | HIGH | UPDATE…WHERE status='pending' RETURNING |
| TOCTOU | SellerOfferService.ts | withdrawOffer | CRITICAL | UPDATE…WHERE id AND seller AND status='pending' |
| TOCTOU | marketplaceController.ts | validateStatusTransition | CRITICAL | UPDATE…WHERE status=$expected; 0 rows → 409 |
| TOCTOU | paymentCoordinator.ts | dispute gate | CRITICAL | FOR UPDATE on PR + same txn |
| TOCTOU | PurchaseRequestService.ts | updatePurchaseRequestStatus | HIGH | UPDATE…WHERE status=$old RETURNING |
| Missing txn | paymentCoordinator.ts | executePaymentUpdate | CRITICAL | One txn for all side effects |
| Missing txn | DisputeService.ts | createDispute | CRITICAL | One txn for dispute+chat+link |
| Missing txn | SellerOfferService.ts | acceptOffer (repo) | HIGH | Txn for accept/reject/PR update |
| Missing txn | RequestTemplateService.ts | batchConvertTemplates | HIGH | Txn (or savepoint) per cart item |
| Missing txn | PurchaseRequestService.ts | updatePurchaseRequestStatus (completed) | HIGH | Txn or outbox for referral reward |
| Schemaless write | paymentService.ts | createPaymentRecord | HIGH | Remove fake-ObjectId FK fallback |
| Schemaless write | userController.ts | updateUserProfile | MEDIUM | Whitelist + jsonb \|\| merge |
| Skip/limit pagination | PointsService.ts | collectDeliveredReferralOrders | CRITICAL | Replace loop with aggregate |
| Skip/limit pagination | PurchaseRequestService.ts | searchPurchaseRequests | HIGH | Keyset on (created_at, id) |
| Skip/limit pagination | SellerOfferService.ts / userController.ts | getOffersBySeller / getUsersList | MEDIUM | Keyset + cap limit 100 |
| Virtual/hook | Chat | addMessage/markAsRead/getUnreadCount | CRITICAL | SQL ops in ChatRepository |
| Pre-save hook | FundsLedgerEntry | immutability | HIGH | Apply trigger DDL now |
---
## Migration Completion Assessment
| Layer | % |
|---|---|
| Schema (Drizzle tables vs Mongo collections) | 90% |
| Repository layer | 70% |
| Backfill scripts | 85% |
| Verification harness | 75% |
| **Service layer (Mongo→RDBMS patterns)** | **5%** |
| **Overall** | **~50%** |
### Top 5 Blockers for PG-Primary Cutover
1. **Service-layer rework not started + factory uncalled (CR-3)** — flag flips route zero traffic; ~50 patterns including lost-update/missing-txn money bugs
2. **Transaction + locking defects on payment/escrow paths (CR-1317)** — real money errors and lost updates under concurrent webhooks
3. **Shadow read unwired (CR-2)** — soak window is blind; cutover decision would be based on no signal
4. **Migration correctness: 0004 unjournaled + duplicate ledger DDL (CR-1)** — fresh-DB apply silently omits `funds_ledger_entries`
5. **Money-integrity gaps + verification silent-passes (CR-4/7/8/9/10/11/12)** — corruption can occur and pass green
---
## Recommended Next Actions
| # | Action | Files | Effort |
|---|---|---|---|
| 1 | Fix 0004 journal collision | `0004_funds_ledger_entries.sql`, `_journal.json` | S |
| 2 | Add money CHECK constraints + apply ledger TRUNCATE trigger | new migration on payments/payment_quotes/users/point_transactions/funds_ledger_entries | S |
| 3 | Fix backfill money/integrity defects (NULL amount, schema-less fallback, non-txn idMap, `_decimal.ts`) | backfill-payments/derivedDestinations/fundsLedger/pointTransactions/purchaseRequests | M |
| 4 | Close verification silent-passes (checksums, shadowRead, test.skip, id-map/enum/FK coverage, `--json` gate) | checksums.ts, shadowRead.ts, reconcile.ts, rowCounts.ts, migration-fk-idmap.test.ts | M |
| 5 | Add all deferred physical FKs `NOT VALID` + `VALIDATE`; rebuild blocking indexes `CONCURRENTLY` | new migration | M |
| 6 | Wire shadow reads into all 4 DualWrite read paths | DualWritePayment/User/Marketplace/PointsRepo | M |
| 7 | Inject factory into services + fix money/escrow concurrency (txn + `UPDATE…WHERE…RETURNING`) | paymentCoordinator, DisputeService, SellerOfferService, marketplaceController, PurchaseRequestService | **L** |
| 8 | Eliminate N+1 / full-fetch / skip-limit hotpaths | PointsService, searchPurchaseRequests, batchConvertTemplates, getUsersList | L |
| 9 | Schema + backfill for unmodeled active models | Dispute (L), Notification (M), ShopSettings/ConfigSetting/Address/Telegram* (S each) | L |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,268 @@
---
title: Full Codebase Audit — 2026-05-30
tags: [audit, index, security, logic, performance]
created: 2026-05-30
status: open
---
# Full Codebase Audit — 2026-05-30
Full-system audit across all three repos (frontend, backend, scanner) triggered as a periodic health pass. 134 findings across security, logic, performance, and supply-chain dimensions. 49 no-brainers were applied automatically; 1 was skipped (requires new persistence layer); 80 decision items were queued for human review.
---
## Findings Summary by Severity and Dimension
| Severity | Security | Logic | Performance | Supply-Chain | Total |
|----------|----------|-------|-------------|--------------|-------|
| Critical | 3 | 2 | 0 | 1 | 6 |
| High | 18 | 12 | 8 | 4 | 42 |
| Medium | 14 | 12 | 8 | 6 | 40 |
| Low | 10 | 6 | 10 | 20 | 46 |
| **Total** | **45** | **32** | **26** | **31** | **134** |
### By Repo
| Repo | Findings | No-Brainers Applied | Skipped | Decision Items |
|------|----------|---------------------|---------|----------------|
| frontend | 49 | 18 (NB-1 NB-17, NB-49) | 0 | 31 (DEC-121, DEC-7480) |
| backend | 55 | 21 (NB-18 NB-38) | 1 (NB-27) | 33 (DEC-2256) |
| scanner | 30 | 10 (NB-39 NB-48) | 0 | 20 (DEC-5773) |
---
## Systemic Themes
Eight root-cause patterns cut across most findings. Addressing these themes eliminates whole clusters at once.
### 1. Missing Authorization on Payment and Admin Endpoints (Broken Access Control)
Routes are gated only by `authenticateToken`/`AuthGuard` with no role or ownership check. Payment status writes, exports, stats, user-payment listings, file deletion, delivery updates, offer selection, dispute evidence, and the entire admin UI tree all trust authentication alone. **Root fix:** a shared `requireAdmin` middleware + ownership-check helper + centralized status-transition validator applied consistently.
### 2. Payment Status State Machine Is Inconsistent and Corruptible
Non-enum statuses (`'released'`, `'funded'`) are written and silently dropped, the provider enum omits `'shkeeper'`, transition guards check fields never set (`escrowState:'funded'`), the transition map omits `'in_negotiation'`, and amount-mismatch is checked after side-effects commit. **Root cause:** schema enums and state machine drifted from the code that writes them.
### 3. Secrets Committed to the Repo and Baked into Images
Telegram bot token, Resend SMTP key, Google secret, JWT secret, admin password, Alchemy keys, and RN secrets appear across `.env.example`, `.env.development`, `.gitleaks.toml`, Dockerfiles, and committed scripts — and `.dockerignore` whitelists `.env.development` into prod images. **Root fix:** placeholder all committed files, remove env files from images, inject at runtime, rotate every exposed credential.
### 4. Test/Debug Bypasses Reachable in Production
Test-payment mode, `force-verify-user`, RN test-webhook signature bypass, the debug panel, and console-suppression hacks all rely on weak runtime `NODE_ENV` checks (or none). **Root fix:** gate on `NODE_ENV` at registration/build time; never honour bypass flags in production.
### 5. N+1 Queries, Unbounded Fan-Out, and Chatty Polling
Per-row DB lookups in `getPurchaseRequestsByBuyer` and `getReferrals`, unbounded notification/seller fan-out, redundant polling alongside sockets, full-collection loads, and per-intent HTTP fan-out in the scanner. **Root fix:** batch with `$in`/aggregation, bound concurrency, replace redundant polling with socket-driven or visibility-gated updates.
### 6. Float Math and Weak Randomness in Money/Crypto Paths
USDT wei conversion via IEEE-754 floats risks under-payment; verification codes use `Math.random` instead of a CSPRNG. **Root fix:** use `parseUnits` for token amounts and `crypto.randomInt` for codes (both already available in the codebase).
### 7. Unhardened Outbound HTTP and Webhook Handling (SSRF / OOM / Retry Leaks)
Scanner accepts arbitrary `callbackUrl` (SSRF), follows third-party `next`-URLs unvalidated, reads RPC/API bodies without size limits (OOM), overrides confirmation thresholds, and spawns unbounded sleeping retry goroutines. **Root fix:** URL allowlisting + private-range blocking at dial time, `io.LimitReader` caps, threshold floors, bounded persisted retry queues.
### 8. CI/CD Supply-Chain Hygiene Gaps
Floating/unpinned images, missing lint/type/test/audit gates on production and manual pipelines, privileged buildx, dual lockfiles, no `engines` pin, and untested manual builds. **Root fix:** digest-pin all CI images, enforce quality gate on every pipeline, unify lockfiles, add audit/vuln scanning.
---
## No-Brainers Applied (49 fixes)
All 49 no-brainers were applied. NB-13/NB-14: lockfile not regenerated (no `yarn install` run per instructions — leave uncommitted for human review). NB-7: requires backend to expose `GET /chat/unread-count` returning `{ data: { count: number } }`. NB-29: depends on DEC-32 outcome; applied `status:'completed'` as interim until enum decision is made.
| ID | Repo | Title | Files |
|----|------|-------|-------|
| NB-1 | frontend | USDT amount-to-wei uses floating-point arithmetic | `src/web3/context/action.ts` |
| NB-2 | frontend | Email verification logs full form data including password | `src/auth/view/jwt/jwt-verify-view.tsx` |
| NB-3 | frontend | Hardcoded Telegram bot ID fallback in widget loader | `src/auth/utils/telegram-login-widget.ts` |
| NB-4 | frontend | releasePayment returns fake success with hardcoded tx hash | `src/actions/payment.ts` |
| NB-5 | frontend | signUp/verifyEmailWithCode bypass StorageUtils.safeSet | `src/auth/context/jwt/action.ts` |
| NB-6 | frontend | Redundant 30s polling on buyer request details page | `src/sections/request/view/buyer/buyer-request-details-view.tsx` |
| NB-7 | frontend | getUnreadCount fetches entire conversation list | `src/actions/chat.ts`, `src/lib/axios.ts` |
| NB-8 | frontend | Debug new Error().stack capture on every step-change | `src/sections/request/view/buyer/buyer-request-details-view.tsx` |
| NB-9 | frontend | transformMessage logs two info calls per message | `src/actions/chat.ts` |
| NB-10 | frontend | Alchemy API keys hardcoded as Dockerfile ARG defaults | `Dockerfile` |
| NB-11 | frontend | Escrow wallet address hardcoded across multiple files | `src/web3/decentralizedPayment.ts`, `step-6-buyer-confirmed.tsx`, `manual-payout.tsx` |
| NB-12 | frontend | NEXT_PUBLIC_MAPBOX_API_KEY missing from Dockerfile ARGs/docs | `Dockerfile` |
| NB-13 | frontend | google-auth-library and @google-cloud/local-auth unused | `package.json` |
| NB-14 | frontend | @depay/widgets unused dependency | `package.json` |
| NB-15 | frontend | MockedUser (demo@minimals.cc) rendered in production nav | `src/layouts/components/nav-upgrade.tsx` |
| NB-16 | frontend | WEB3_PROVIDER_URL declared but never used | `src/global-config.ts` |
| NB-17 | frontend | google-oauth.ts.backup committed to source tree | `src/auth/services/google-oauth.ts.backup` |
| NB-18 | backend | Verification/reset codes logged to server console | `src/services/auth/authController.ts`, `src/services/delivery/DeliveryService.ts` |
| NB-19 | backend | Verification code uses Math.random() | `src/services/auth/authService.ts` |
| NB-20 | backend | Admin password hardcoded fallback in init-admin.ts | `src/infrastructure/database/init-admin.ts` |
| NB-21 | backend | force-verify-user route registered unconditionally | `src/services/auth/authRoutes.ts` |
| NB-22 | backend | getUserPayments queries non-existent 'userId' field | `src/services/payment/paymentService.ts` |
| NB-23 | backend | getPaymentStats sums object-typed amount field | `src/services/payment/paymentService.ts` |
| NB-24 | backend | GET /api/payment/export endpoints lack admin guard | `src/services/payment/paymentControllerRoutes.ts` |
| NB-25 | backend | getUserPayments route lacks ownership check (IDOR) | `src/services/payment/paymentControllerRoutes.ts` |
| NB-26 | backend | GET /api/files/stats missing admin guard | `src/services/file/fileRoutes.ts` |
| NB-28 | backend | updateDeliveryInfo does not enforce seller ownership | `src/services/marketplace/marketplaceController.ts` |
| NB-29 | backend | payout/confirm and release/confirm set non-enum 'released' status | `src/services/payment/requestNetwork/requestNetworkRoutes.ts` |
| NB-30 | backend | N+1 per-request Payment lookup in getPurchaseRequestsByBuyer | `src/services/marketplace/PurchaseRequestService.ts` |
| NB-31 | backend | Full unpaginated load in getPayments admin endpoint | `src/services/marketplace/marketplaceController.ts` |
| NB-32 | backend | 13 sequential countDocuments in getCollectionStats | `src/services/admin/dataCleanupService.ts` |
| NB-33 | backend | Real credentials committed in tracked .env.example | `.env.example` |
| NB-34 | backend | Dockerfile.dev runs --frozen-lockfile before copying yarn.lock | `Dockerfile.dev` |
| NB-35 | backend | Deprecated npm 'crypto' shim in production deps | `package.json` |
| NB-36 | backend | body-parser redundant with Express 5 | `package.json` |
| NB-37 | backend | manual.yml CI missing typecheck gate | `.woodpecker/manual.yml` |
| NB-38 | backend | No engines field / .nvmrc for Node version | `package.json`, `.nvmrc` |
| NB-39 | scanner | Scanner Dockerfile runs as root (no USER) | `Dockerfile` |
| NB-40 | scanner | cleanup.yml uses alpine:latest | `.woodpecker/cleanup.yml` |
| NB-41 | scanner | scanner buildx plugin not pinned | `.woodpecker/development.yml`, `.woodpecker/manual.yml`, `.woodpecker/production.yml` |
| NB-42 | scanner | Scanner RPC/API bodies read without size limit | `chain.go`, `tron_chain.go`, `ton_chain.go` |
| NB-43 | scanner | Scanner manual.yml has no test step | `.woodpecker/manual.yml` |
| NB-44 | scanner | No govulncheck/gosec in scanner CI | `.woodpecker/development.yml`, `.woodpecker/production.yml` |
| NB-45 | scanner | No RPC_TRON/RPC_TON override env vars | `config.go` |
| NB-46 | scanner | EVM scan lag warning uses reorgBuf-adjusted checkpoint | `chain.go` |
| NB-47 | scanner | handleScannerStatus loads full intent rows to count pending | `api.go`, `intent.go` |
| NB-48 | scanner | SQLite no connection pool limit set | `intent.go` |
| NB-49 | frontend | Admin route polling paused when tab hidden | `payments-awaiting-confirmation-list-view.tsx` |
### Skipped No-Brainers
| ID | Reason | Issue Filed |
|----|--------|-------------|
| NB-27 (DELETE /api/files/delete ownership check) | `fileService.deleteFile()` is a pure filesystem path operation with no DB ownership record — no `File` model, no `createdBy`/`owner` field stored anywhere. Adding an ownership check requires creating a new persistence layer, which is a larger-than-mechanical change. | [[ISSUE-055-delete-api-files-delete-has-no-ownership-check-requires-new-pe|ISSUE-055]] |
---
## Decision Queue (80 items)
These items require human judgment before implementation. Each has a corresponding issue file.
### Critical
| Issue | Title | Repo | Recommendation |
|-------|-------|------|----------------|
| [[ISSUE-056-backend-verifypayment-and-paymentcallback-routes-unauthenticat|ISSUE-056]] | verifyPayment and paymentCallback routes unauthenticated | backend | Auth + HMAC on callback; remove isWeb3Payment bypass |
### High
| Issue | Title | Repo |
|-------|-------|------|
| [[ISSUE-057-frontend-admin-ui-routes-lack-role-based-authorization-guard|ISSUE-057]] | Admin UI routes lack role-based authorization guard | frontend |
| [[ISSUE-058-frontend-test-payment-mode-enablable-in-production-via-env-var|ISSUE-058]] | Test payment mode enablable in production via NEXT_PUBLIC env var | frontend |
| [[ISSUE-059-frontend-auth-provider-clears-tokens-on-any-non-403-error|ISSUE-059]] | Auth provider clears tokens on any non-403 error including network failures | frontend |
| [[ISSUE-060-frontend-contacts-popover-reads-userid-from-non-existent-local|ISSUE-060]] | contacts-popover reads userId from non-existent localStorage 'user' key | frontend |
| [[ISSUE-061-frontend-socket-context-helpers-accumulate-listeners-without-d|ISSUE-061]] | Socket context helpers accumulate listeners without dedup | frontend |
| [[ISSUE-062-backend-payment-update-routes-lack-ownership-role-guards|ISSUE-062]] | Backend payment update routes lack ownership/role guards | backend |
| [[ISSUE-063-backend-legacy-marketplace-patch-payments-id-lets-any-user-set|ISSUE-063]] | Legacy marketplace PATCH /payments/:id lets buyer/seller set any status | backend |
| [[ISSUE-064-backend-request-network-allow-test-webhooks-bypasses-signature|ISSUE-064]] | REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS bypasses signature verification | backend |
| [[ISSUE-065-backend-rn-webhook-advances-purchaserequest-to-non-existent-fu|ISSUE-065]] | RN webhook advances PurchaseRequest to non-existent 'funded' status | backend |
| [[ISSUE-066-backend-payout-and-release-confirm-set-non-enum-status|ISSUE-066]] | payout/confirm and release/confirm set non-enum status 'released' | backend |
| [[ISSUE-067-backend-amount-mismatch-check-runs-after-payment-saved-and-offe|ISSUE-067]] | amount-mismatch check runs after payment saved and offers accepted | backend |
| [[ISSUE-068-backend-datacleanuservice-deletes-payments-without-provider-sco|ISSUE-068]] | dataCleanupService deletes Payments without provider scoping | backend |
| [[ISSUE-069-backend-cleanupoldpendingpayments-deletes-pending-rn-payments-m|ISSUE-069]] | cleanupOldPendingPayments deletes pending RN payments mid-flow | backend |
| [[ISSUE-070-backend-notifyallsellersaboutnewrequest-unbounded-fan-out|ISSUE-070]] | notifyAllSellersAboutNewRequest unbounded fan-out | backend |
| [[ISSUE-071-backend-getreferrals-n-plus-1-purchaserequest-and-pointtransac|ISSUE-071]] | getReferrals N+1 (PurchaseRequest + PointTransaction per referral) | backend |
| [[ISSUE-072-backend-chat-messages-stored-as-embedded-array-unbounded-growth|ISSUE-072]] | Chat messages stored as embedded array (unbounded document growth) | backend |
| [[ISSUE-073-backend-payment-provider-enum-missing-shkeeper|ISSUE-073]] | Payment provider enum missing 'shkeeper' | backend |
| [[ISSUE-074-backend-env-development-committed-with-live-telegram-and-smtp-s|ISSUE-074]] | Backend Telegram bot token + SMTP key committed in .env.development | backend |
| [[ISSUE-075-backend-dockerignore-whitelists-env-development-into-prod-image|ISSUE-075]] | .dockerignore whitelists .env.development into prod image | backend |
| [[ISSUE-076-scanner-ssrf-via-unvalidated-callbackurl|ISSUE-076]] | Scanner: SSRF via unvalidated callbackUrl | scanner |
| [[ISSUE-077-scanner-caller-can-override-confirmation-threshold-down-to-1|ISSUE-077]] | Scanner: caller can override confirmation threshold down to 1 | scanner |
| [[ISSUE-078-scanner-idempotency-path-ignores-mismatched-parameters|ISSUE-078]] | Scanner: idempotency path ignores mismatched parameters | scanner |
| [[ISSUE-079-frontend-telegram-bot-token-committed-in-gitleaks-toml-allowli|ISSUE-079]] | Frontend: Telegram bot token committed in .gitleaks.toml allowlist | frontend |
### Medium
| Issue | Title | Repo |
|-------|-------|------|
| [[ISSUE-080-frontend-open-redirect-via-unvalidated-returnto-in-guestguard|ISSUE-080]] | Open redirect via unvalidated returnTo in GuestGuard | frontend |
| [[ISSUE-081-frontend-tokens-stored-in-localstorage-xss-accessible|ISSUE-081]] | Tokens stored in localStorage (XSS-accessible) | frontend |
| [[ISSUE-082-frontend-wallet-ownership-signature-verification-is-a-no-op|ISSUE-082]] | Wallet ownership signature verification is a no-op on frontend | frontend |
| [[ISSUE-083-frontend-no-content-security-policy-header-in-next-config|ISSUE-083]] | No Content-Security-Policy header in Next.js config | frontend |
| [[ISSUE-084-frontend-console-error-warn-suppression-masks-prod-errors|ISSUE-084]] | console.error/warn suppression masks prod errors | frontend |
| [[ISSUE-085-frontend-token-refresh-queue-dispatches-with-undefined-authori|ISSUE-085]] | Token refresh queue dispatches with undefined Authorization | frontend |
| [[ISSUE-086-frontend-paymentdetailsview-status-dropdown-exposed-to-all-use|ISSUE-086]] | PaymentDetailsView status dropdown exposed to all users | frontend |
| [[ISSUE-087-frontend-getpaymentstatus-and-checkpaymentstatus-hit-different|ISSUE-087]] | getPaymentStatus and checkPaymentStatus hit different endpoints | frontend |
| [[ISSUE-088-frontend-adminwalletpayout-falls-back-to-literal-admin-string|ISSUE-088]] | adminWalletPayout falls back to literal 'admin' adminUserId | frontend |
| [[ISSUE-089-frontend-admin-payments-awaiting-confirmation-polls-every-12s|ISSUE-089]] | Admin payments-awaiting-confirmation polls every 12s unconditionally | frontend |
| [[ISSUE-090-frontend-chat-views-re-fetch-full-conversation-on-every-new-me|ISSUE-090]] | Chat views re-fetch full conversation on every new-message event | frontend |
| [[ISSUE-091-frontend-dual-socket-connections-socketprovider-and-socketserv|ISSUE-091]] | Dual socket connections (SocketProvider + socketService singleton) | frontend |
| [[ISSUE-092-backend-jwt-refresh-and-access-tokens-share-same-secret|ISSUE-092]] | JWT refresh and access tokens share the same secret; middleware skips type check | backend |
| [[ISSUE-093-backend-addevidence-no-participant-ownership-check-on-disputes|ISSUE-093]] | addEvidence: no participant ownership check on disputes | backend |
| [[ISSUE-094-backend-selectoffer-does-not-verify-buyer-owns-purchase-request|ISSUE-094]] | selectOffer does not verify buyer owns the purchase request | backend |
| [[ISSUE-095-backend-getuserstats-no-ownership-admin-check-idor|ISSUE-095]] | getUserStats: no ownership/admin check (IDOR) | backend |
| [[ISSUE-096-backend-validatestatustransition-requires-escrowstate-funded-n|ISSUE-096]] | validateStatusTransition requires escrowState 'funded' never set on completed payments | backend |
| [[ISSUE-097-backend-validtransitions-map-missing-in-negotiation-key|ISSUE-097]] | validTransitions map missing 'in_negotiation' key | backend |
| [[ISSUE-098-backend-in-memory-seendeliveryids-resets-on-restart|ISSUE-098]] | validateStatusTransition: in-memory seenDeliveryIds resets on restart | backend |
| [[ISSUE-099-backend-on-demand-rn-reconciliation-in-getpaymentbyid-can-race|ISSUE-099]] | On-demand RN reconciliation in getPaymentById can race | backend |
| [[ISSUE-100-backend-updatepurchaserequest-does-findbyid-then-findbyidandupd|ISSUE-100]] | updatePurchaseRequest does findById then findByIdAndUpdate | backend |
| [[ISSUE-101-backend-config-loads-env-development-unconditionally|ISSUE-101]] | Backend config loads .env.development unconditionally | backend |
| [[ISSUE-102-backend-14-high-severity-npm-vulns-no-audit-step-in-ci|ISSUE-102]] | 14 high-severity npm vulns, no audit step in CI | backend |
| [[ISSUE-103-backend-react-react-dom-in-backend-production-dependencies|ISSUE-103]] | react/react-dom in backend production dependencies | backend |
| [[ISSUE-104-backend-bcrypt-native-addon-alongside-used-bcryptjs|ISSUE-104]] | bcrypt native addon present alongside used bcryptjs | backend |
| [[ISSUE-105-backend-no-startup-validation-of-required-env-vars|ISSUE-105]] | No startup validation of required env vars | backend |
| [[ISSUE-106-backend-dual-lockfiles-yarn-lock-and-package-lock-json-diverge|ISSUE-106]] | Dual lockfiles (yarn.lock + package-lock.json) diverge | backend |
| [[ISSUE-107-scanner-tronGrid-pagination-next-url-used-unvalidated|ISSUE-107]] | Scanner: TronGrid pagination next-URL used unvalidated | scanner |
| [[ISSUE-108-scanner-unauthenticated-startup-when-scanner-api-key-unset|ISSUE-108]] | Scanner: unauthenticated startup when SCANNER_API_KEY unset | scanner |
| [[ISSUE-109-scanner-tron-lag-metric-reported-in-ms-not-blocks|ISSUE-109]] | Scanner: Tron lag metric reported in ms, not blocks | scanner |
| [[ISSUE-110-scanner-ton-worker-on-http-fan-out-per-scan-cycle|ISSUE-110]] | Scanner: TON worker O(N) HTTP fan-out per scan cycle | scanner |
| [[ISSUE-111-scanner-deliverwebhook-goroutines-use-blocking-time-sleep|ISSUE-111]] | Scanner: deliverWebhook goroutines use blocking time.Sleep (leak risk) | scanner |
| [[ISSUE-112-scanner-unbounded-goroutine-fan-out-for-webhook-retries|ISSUE-112]] | Scanner: unbounded goroutine fan-out for webhook retries | scanner |
| [[ISSUE-113-scanner-rpc-response-bodies-read-without-size-limit-oom|ISSUE-113]] | Scanner/backend: RPC response bodies read without size limit (OOM) | scanner |
| [[ISSUE-114-frontend-walletconnect-google-client-ids-hardcoded-dockerfile|ISSUE-114]] | Frontend: WalletConnect/Google client IDs hardcoded as Dockerfile ARG defaults | frontend |
| [[ISSUE-115-frontend-real-plaintext-credentials-in-committed-scripts|ISSUE-115]] | Frontend: real plaintext credentials in committed scripts | frontend |
| [[ISSUE-116-frontend-backend-scanner-ci-images-not-pinned-to-digests|ISSUE-116]] | Frontend/scanner/backend: CI images not pinned to digests | frontend |
| [[ISSUE-117-frontend-backend-scanner-production-manual-ci-pipelines-lack-g|ISSUE-117]] | Frontend/scanner/backend: production/manual CI pipelines lack lint/type/test/audit gates | frontend |
### Low
| Issue | Title | Repo |
|-------|-------|------|
| [[ISSUE-118-frontend-notification-title-rendered-via-dangerouslysetinnerht|ISSUE-118]] | Notification title rendered via dangerouslySetInnerHTML | frontend |
| [[ISSUE-119-frontend-telegramdebugpanel-exposed-in-production-via-url-flag|ISSUE-119]] | TelegramDebugPanel exposed in production via URL/localStorage flag | frontend |
| [[ISSUE-120-frontend-50ms-setinterval-console-suppression-script-in-root-l|ISSUE-120]] | 50ms setInterval console-suppression script in root layout | frontend |
| [[ISSUE-121-frontend-transferfunds-and-createpayment-post-to-same-endpoint|ISSUE-121]] | transferFunds and createPayment POST to the same endpoint | frontend |
| [[ISSUE-122-backend-missing-compound-index-for-seller-visibility-purchase-r|ISSUE-122]] | Missing compound index for seller-visibility purchase-request query | backend |
| [[ISSUE-123-backend-notification-unread-count-chatty-db-access|ISSUE-123]] | Notification unread-count chatty DB access | backend |
| [[ISSUE-124-backend-per-seller-socket-emit-loop-in-updatepurchaserequeststatu|ISSUE-124]] | Per-seller socket emit loop in updatePurchaseRequestStatus | backend |
| [[ISSUE-125-backend-getcategorypath-unbounded-sequential-findbyid-loop|ISSUE-125]] | getCategoryPath unbounded sequential findById loop | backend |
| [[ISSUE-126-backend-getuserpoints-writes-full-user-document-on-read|ISSUE-126]] | getUserPoints writes full User document on read when fields missing | backend |
| [[ISSUE-127-scanner-get-intents-id-exposes-salt-and-callbackurl|ISSUE-127]] | Scanner: GET /intents/:id exposes salt and callbackUrl | scanner |
| [[ISSUE-128-scanner-post-intents-returns-200-instead-of-201|ISSUE-128]] | Scanner: POST /intents returns 200 instead of 201 | scanner |
| [[ISSUE-129-scanner-ton-processTransfer-doesnt-verify-jettonmasteraddress|ISSUE-129]] | Scanner: TON processTransfer doesn't verify JettonMasterAddress vs intent.TokenAddress | scanner |
| [[ISSUE-130-scanner-config-getchaingettokengetrpc-on-linear-scans|ISSUE-130]] | Scanner: Config.GetChain/GetToken/GetRPC O(N) linear scans | scanner |
| [[ISSUE-131-scanner-tron-ton-workers-dont-share-http-transport|ISSUE-131]] | Scanner: Tron/TON workers don't share HTTP transport | scanner |
| [[ISSUE-132-scanner-evm-checkpoint-saved-every-2000-block-chunk|ISSUE-132]] | Scanner: EVM checkpoint saved every 2000-block chunk | scanner |
| [[ISSUE-133-scanner-ci-buildx-steps-run-privileged-true|ISSUE-133]] | Scanner: CI buildx steps run privileged: true | scanner |
| [[ISSUE-134-frontend-sentry-source-map-upload-configured-but-no-auth-token|ISSUE-134]] | Frontend: Sentry source-map upload configured but no auth token injected | frontend |
| [[ISSUE-135-backend-uploads-directory-served-without-authentication|ISSUE-135]] | Backend uploads directory served without authentication | backend |
---
## Documentation Gaps Identified (Doc Sync)
The following gaps were identified but not filled during this audit pass. They should be tracked as separate doc tasks:
- **Frontend:** Admin dashboard sub-pages (confirmation-thresholds, networks, payments-awaiting-confirmation, trezor) missing from Admin API doc.
- **Frontend:** Trezor registration and break-glass UI (commit c9ce345) not reflected in Trezor API or Trezor Safekeeping Flow docs.
- **Frontend:** Cloudflare Turnstile/CAPTCHA behavior (3 failed logins) not documented in Authentication Flow or Authentication API docs.
- **Frontend:** AMN Pay Scanner lag column and per-row probe button have no dedicated flow or operations doc.
- **Frontend:** Telegram startup notification (TG_NOTIFY_BOT_TOKEN) not in Operations/Environment Variables doc.
- **Frontend:** Amaneh UI variant toggle — state key and exact behavior not fully described in Settings & Theming.
- **Frontend:** `productLink` made truly optional; `deliveryType` required marker dropped — Purchase Request Flow wizard narrative needs update.
- **Backend:** Sweep signer strategy (PermitPullSweepSigner + GasTopUpSweepSigner) has no operations runbook.
- **Backend:** Native token sweep (BNB/ETH to derived destinations) not reflected in Payment API or sweep operations runbook.
- **Backend:** AML screening (OFAC SDN provider) has no dedicated flow doc covering when screening fires, seller opt-in, fee deduction.
- **Backend:** GET /api/health response field names not verified against live `healthCheckService` output.
- **Backend:** RequestTemplate budget currency restriction (USDT/USDC only) not reflected in Marketplace API or RequestTemplate model docs.
- **Backend:** Sweep integration tests (Anvil + INTEGRATION_TEST=1) not covered in Testing.md.
- **Backend:** Telegram startup notification (app startup `tgNotify`) not in Monitoring.md.
- **Backend:** AMN Pay Scanner adapter internals (amnPayAdapter, amnScannerWebhookRoutes) have no doc.
- **Backend:** New env vars (OFAC_SDN_URL, TURNSTILE_SECRET_KEY, TURNSTILE_SITE_KEY, AMN_SCANNER_URL, AMN_SCANNER_WEBHOOK_SECRET) may not be in Environment Variables doc.
- **Backend:** Seller Offer Flow does not reflect selectedOfferId persistence fix and atomic offer rejection on payment.
- **Backend:** ISSUE-021 (POST /api/marketplace/offers/:id/withdraw) should be marked resolved (implemented in commit 3e47713).
- **Scanner:** No doc for CI pipeline structure (.woodpecker/ steps, secrets, image push flow).
- **Scanner:** No doc for test suite (chain_validate_test.go / reference_test.go / tron_chain_test.go) and how to extend it.
- **Scanner:** Multi-chain reorg edge cases and exact ReorgBuffer formula not in troubleshooting doc.
- **Scanner:** TON scaling limitation (O(pending intents) API calls per cycle) noted but no mitigation/batching design documented.
- **Scanner:** RN proxy address discrepancy in supported-chains.json (ETH v0.1.0 vs v0.2.0) not documented.
---
## References
- [[Security Audit - 2026-05-24]]
- [[Logic Audit - 2026-05-24]]
- [[Performance Audit - 2026-05-24]]
- [[Doc vs Code Audit Report - 2026-05-29]]

View File

@@ -0,0 +1,221 @@
---
title: Mistral Outsource Package — Audit Remediation 2026-06-10
tags: [outsource, audit, remediation, mistral]
created: 2026-06-10
status: ready-to-send
---
# Mistral Outsource Package — Audit Remediation 2026-06-10
Self-contained task package for an external AI agent (Mistral) working against the Amanat escrow codebase.
**Repo root:** `/Users/manwe/CascadeProjects/escrow`
**Active branch for frontend/backend:** `feature/white-label-shops`
**Active branch for scanner:** `development`
**Active branch for deployment:** `main`
Each task is independent. Complete them in any order. Do not touch files outside the listed scope. Do not print secret values from `.env` files — reference only by variable name.
---
## Task 1 — M5: Scanner must fail startup when SCANNER_API_KEY is missing in production
**File:** `scanner/config.go`
**Context:** Lines 128131 print a warning when `SCANNER_API_KEY` is empty but let the process start anyway. In production this means the scanner exposes all endpoints unauthenticated.
**What to do:**
Add an environment-gated hard-fail. If `SCANNER_API_KEY` is empty **and** `APP_ENV` is `production` (or `SCANNER_REQUIRE_AUTH=true`), call `log.Fatal(...)` / `os.Exit(1)` instead of `slog.Warn(...)`.
Keep existing dev-mode behaviour: if `APP_ENV` is not `production` and `SCANNER_REQUIRE_AUTH` is not `true`, keep the warn-only path.
Example shape (adapt to actual Go idioms used in the file):
```go
if cfg.APIKey == "" {
if os.Getenv("APP_ENV") == "production" || os.Getenv("SCANNER_REQUIRE_AUTH") == "true" {
log.Fatal("[scanner] SCANNER_API_KEY must be set in production — refusing to start unauthenticated")
}
slog.Warn("[scanner] SCANNER_API_KEY is not set — all endpoints are unauthenticated (dev mode only)")
}
```
**Verification:** `go build ./...` and `go test ./...` must still pass.
---
## Task 2 — M4: Telegram debug panel must not show in production without explicit admin/developer role
**File:** `frontend/src/components/debug/telegram-debug-panel.tsx`
**Context:** Lines 4456 set `showPanel = true` whenever `NODE_ENV !== 'production'` OR the page is opened inside a Telegram Mini App context OR `debug=1` / `amn-debug=1` is present in the URL/localStorage. This means the panel is always visible inside the Mini App in production, exposing user email, wallet address, internal API/socket URLs, and Telegram platform/version to any user.
**What to do:**
Extend the `showPanel` logic so that in production it only activates when **both** the debug request is present **and** the authenticated user has role `admin` or `developer`.
The component already has access to `user` from `useAuthContext()`:
```tsx
const { user, authenticated, loading } = useAuthContext();
```
Replace the `setShowPanel(...)` call inside the `useEffect` with:
```tsx
const isPrivileged = user?.role === 'admin' || user?.role === 'developer';
setShowPanel(
process.env.NODE_ENV !== 'production' ||
(nextContext.isMiniApp && isPrivileged && debugRequested) ||
(debugRequested && isPrivileged)
);
```
Remove the bare `nextContext.isMiniApp` condition that shows the panel to all Mini App users.
Also update the initial `useState` default so it reads from user context properly — or just default to `false` and let the effect set it (safe since the effect runs on mount).
**Verification:** TypeScript compile (`npx tsc --noEmit`) must pass for this file.
---
## Task 3 — L1a: Remove hardcoded `password123` from deployment docker-compose files
**Files:**
- `deployment/docker-compose.yml` line 152: `MONGO_INITDB_ROOT_PASSWORD=password123`
- `deployment/dev-amn/docker-compose.yml` line 101: `MONGO_INITDB_ROOT_PASSWORD=password123`
**What to do:**
Replace each hardcoded `password123` with an env-var reference:
```yaml
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD:-changeme_local}
```
Use `changeme_local` as the fallback, not `password123`, so it is obvious this is a placeholder that must be replaced in real deploys.
Add a comment above each block:
```yaml
# Set MONGO_INITDB_ROOT_PASSWORD in your .env — default is local-only placeholder
```
**Verification:** `docker compose config` must not error (dry-run parse only — do not start containers).
---
## Task 3b — L1b: Fix `undefined` password literals in migration SQL
**File:** `deployment/escrow-multi/migrate/migrations/0018_db_privilege_isolation.sql`
**Context:** Lines 11 and 19 create Postgres roles with `PASSWORD 'undefined'` — this is a literal string `undefined`, not a variable substitution. These were almost certainly written by accident.
**What to do:**
Replace the two `PASSWORD 'undefined'` literals:
```sql
-- Before
CREATE ROLE escrow_vital_user WITH LOGIN PASSWORD 'undefined';
CREATE ROLE escrow_nonvital_user WITH LOGIN PASSWORD 'undefined';
-- After
CREATE ROLE escrow_vital_user WITH LOGIN PASSWORD :'escrow_vital_password';
CREATE ROLE escrow_nonvital_user WITH LOGIN PASSWORD :'escrow_nonvital_password';
```
If psql variable syntax is not appropriate for the migration runner in use, use a clearly wrong placeholder value that can never accidentally work:
```sql
CREATE ROLE escrow_vital_user WITH LOGIN PASSWORD 'REPLACE_ME_escrow_vital';
CREATE ROLE escrow_nonvital_user WITH LOGIN PASSWORD 'REPLACE_ME_escrow_nonvital';
```
Add a comment: `-- TODO: inject real passwords via migration runner env — do not commit real credentials`.
**Verification:** SQL must parse (`psql --dry-run` or equivalent syntax check).
---
## Task 4 — M6 Backend: Fix ESLint errors in backend (auto-fix pass + manual cleanup)
**Directory:** `backend/`
**Context:** `npm run lint` reports 29 errors including forbidden `require()` imports, empty catch blocks, and TypeScript namespace usage. 996 warnings exist but are lower priority.
**What to do:**
1. Run `cd backend && npm run lint -- --fix` to auto-fix all auto-fixable issues.
2. Manually fix remaining errors in these categories:
- **Forbidden `require()` imports**: Replace `const x = require('y')` with `import x from 'y'` (or `import * as x from 'y'` for namespace imports). Do not change the runtime behaviour.
- **Empty catch blocks**: Add a minimal comment `// intentional` or add `_err` as the parameter and log it if it looks like it should be logged. Do not silently swallow errors that would hide real bugs.
- **TypeScript namespace usage**: If a `namespace Foo {}` can be a plain `module` or `interface`/`type` grouping, convert it. If the namespace is part of a declaration file or ambient module, keep it.
3. After manual fixes, run `npm run lint` again and confirm error count is 0 (warnings are acceptable).
4. Run `npm run typecheck` to ensure no regressions.
**Verification:** `npm run lint` exits 0 errors. `npm run typecheck` passes.
---
## Task 5 — M1: Remove `ignoreBuildErrors` from frontend Next.js config and fix resulting TS errors
**File:** `frontend/next.config.ts`
**Context:** Line 29 sets `typescript: { ignoreBuildErrors: true }`, masking type errors that reach production builds. The pre-push `tsc` hook is supposed to catch these, but production builds currently silently swallow them.
**What to do:**
Remove the `ignoreBuildErrors: true` line (or change to `ignoreBuildErrors: false`). Update the comment to reflect this:
```ts
// TypeScript errors are caught here (Next.js build) and by the tsc-guard pre-push hook.
typescript: { ignoreBuildErrors: false },
```
Then run `npx yarn lint` and `npx tsc --noEmit -p tsconfig.json` inside `frontend/`. Fix any type errors that surface. Common patterns expected:
- Components with `@ts-nocheck` at the top — remove the suppression and fix the underlying type.
- `any` casts that can be narrowed.
- Missing `key` props on lists.
**Do not** fix type errors in payment or wallet components without reading the code carefully. If a type error in those files requires understanding complex payment domain logic, leave a `// TODO(audit): type error — needs domain review` comment and move on.
**Verification:** `npx tsc --noEmit` exits 0. `npx yarn build` completes without TypeScript errors.
---
## Task 6 — L2: Extend magic-byte validation to generic file upload routes
**Files:**
- `backend/src/services/file/fileController.ts` — generic upload handler
- `backend/src/services/file/chatAttachmentController.ts` (or similar) — reference: this file already has magic-byte validation
**Context:** The chat attachment upload path validates file magic bytes (file signatures) to ensure the actual content matches the declared MIME type. Generic uploads (product images, request templates, blog images) rely only on the MIME type declared by the client, which can be spoofed.
**What to do:**
1. Find the magic-byte validation function in the chat attachment controller. It likely reads the first N bytes of the upload buffer and compares against known signatures.
2. Extract or re-use that function in a shared utility: `backend/src/services/file/fileMagicBytes.ts` (or add it to `fileService.ts` if that's the right home).
3. Call the magic-byte check in `fileController.ts` for **all** upload routes that accept user-controlled files. Reject with HTTP 415 if the magic bytes do not match the declared MIME type.
4. Do not change the existing chat attachment path — it already works correctly.
**Verification:** `npm run typecheck` passes. Add or update a test in `backend/__tests__/` that uploads a file with a mismatched MIME/magic-byte pair and asserts HTTP 415.
---
## Notes for the executing agent
- **Never print** the contents of `.env`, `.env.dev`, or any variable containing `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, or `PRIVATE`.
- All changes must be on the branches specified at the top of this document.
- Frontend changes: `feature/white-label-shops`. Backend changes: `feature/white-label-shops`. Scanner: `development`. Deployment: `main`.
- Do not bump `package.json` version numbers — the orchestrating agent handles version bumps before any deploy.
- Do not modify Woodpecker pipeline files, Dockerfiles, or CI configuration.
- Each task's verification command must pass before marking the task done.

View File

@@ -0,0 +1,872 @@
{
"generatedAt": "2026-05-31T14:29:51.927Z",
"config": {
"baseUrl": "https://dev.manwe.qzz.io",
"sshHost": "root@5.78.213.189",
"mongoContainer": "amanat-dev-mongodb",
"mongoDb": "marketplace",
"mongoAuthDb": "admin",
"backendContainer": "amanat-dev-backend",
"resetBackendLimiter": true,
"containers": [
"amanat-dev-nginx",
"amanat-dev-backend",
"amanat-dev-frontend",
"amanat-dev-postgres",
"amanat-dev-mongodb",
"amanat-dev-redis",
"amanat-dev-scanner"
],
"templateShareableLink": "logo-design-template",
"outputDir": "/Users/manwe/CascadeProjects/escrow/nick-doc/09 - Audits/Mongo API Profiles/2026-05-31T14-26-19-969Z"
},
"results": [
{
"name": "health",
"method": "GET",
"path": "/api/health",
"requestCount": 5,
"rps": 2.5,
"latency": {
"averageMs": 327.2,
"p50Ms": 233,
"p90Ms": 707,
"p95Ms": 707,
"p99Ms": 707,
"maxMs": 707
},
"non2xx": 0,
"statusCodeStats": {
"200": {
"count": 5
}
},
"mongoProfile": {
"totalOperations": 0,
"totalMillis": 0,
"groups": []
},
"blockIoDelta": {
"amanat-dev-nginx": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-backend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-frontend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-postgres": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-mongodb": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-redis": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-scanner": {
"readBytes": 0,
"writeBytes": 10000
}
}
},
{
"name": "categories",
"method": "GET",
"path": "/api/marketplace/categories",
"requestCount": 10,
"rps": 3.34,
"latency": {
"averageMs": 390.6,
"p50Ms": 232,
"p90Ms": 731,
"p95Ms": 1308,
"p99Ms": 1308,
"maxMs": 1308
},
"non2xx": 0,
"statusCodeStats": {
"200": {
"count": 10
}
},
"mongoProfile": {
"totalOperations": 0,
"totalMillis": 0,
"groups": []
},
"blockIoDelta": {
"amanat-dev-nginx": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-backend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-frontend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-postgres": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-mongodb": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-redis": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-scanner": {
"readBytes": 0,
"writeBytes": 0
}
}
},
{
"name": "categories_tree",
"method": "GET",
"path": "/api/marketplace/categories/tree",
"requestCount": 10,
"rps": 5,
"latency": {
"averageMs": 342.5,
"p50Ms": 240,
"p90Ms": 742,
"p95Ms": 752,
"p99Ms": 752,
"maxMs": 752
},
"non2xx": 0,
"statusCodeStats": {
"200": {
"count": 10
}
},
"mongoProfile": {
"totalOperations": 10,
"totalMillis": 0,
"groups": [
{
"namespace": "marketplace.categories",
"operation": "query",
"command": "find",
"collection": "categories",
"planSummary": "IXSCAN { isActive: 1 }",
"queryHash": "35A725FF",
"planCacheKey": "80333596",
"queryShape": "filter={isActive:boolean} sort={name:number,order:number}",
"count": 10,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 240,
"keysExamined": 240,
"nreturned": 240,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 65670,
"numYield": 0
}
]
},
"blockIoDelta": {
"amanat-dev-nginx": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-backend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-frontend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-postgres": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-mongodb": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-redis": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-scanner": {
"readBytes": 0,
"writeBytes": 0
}
}
},
{
"name": "sellers",
"method": "GET",
"path": "/api/marketplace/sellers",
"requestCount": 10,
"rps": 5,
"latency": {
"averageMs": 341.6,
"p50Ms": 245,
"p90Ms": 729,
"p95Ms": 733,
"p99Ms": 733,
"maxMs": 733
},
"non2xx": 0,
"statusCodeStats": {
"200": {
"count": 10
}
},
"mongoProfile": {
"totalOperations": 10,
"totalMillis": 0,
"groups": [
{
"namespace": "marketplace.users",
"operation": "query",
"command": "find",
"collection": "users",
"planSummary": "IXSCAN { role: 1 }",
"queryHash": "BA1E76D1",
"planCacheKey": "0CB19E91",
"queryShape": "filter={isEmailVerified:boolean,role:string} projection={_id:number,email:number,firstName:number,lastName:number,profile.avatar:number}",
"count": 10,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 20,
"keysExamined": 20,
"nreturned": 20,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 3610,
"numYield": 0
}
]
},
"blockIoDelta": {
"amanat-dev-nginx": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-backend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-frontend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-postgres": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-mongodb": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-redis": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-scanner": {
"readBytes": 0,
"writeBytes": 20000
}
}
},
{
"name": "template_public",
"method": "GET",
"path": "/api/marketplace/request-templates/public/logo-design-template",
"requestCount": 10,
"rps": 5,
"latency": {
"averageMs": 340.3,
"p50Ms": 241,
"p90Ms": 734,
"p95Ms": 740,
"p99Ms": 740,
"maxMs": 740
},
"non2xx": 0,
"statusCodeStats": {
"200": {
"count": 10
}
},
"mongoProfile": {
"totalOperations": 30,
"totalMillis": 0,
"groups": [
{
"namespace": "marketplace.requesttemplates",
"operation": "query",
"command": "find",
"collection": "requesttemplates",
"planSummary": "IXSCAN { shareableLink: 1 }",
"queryHash": "69A943C9",
"planCacheKey": "7C668FB5",
"queryShape": "filter={$or:[{expiresAt:null},{expiresAt:{$gt:{}}}],isActive:boolean,shareableLink:string}",
"count": 10,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 10,
"keysExamined": 10,
"nreturned": 10,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 15470,
"numYield": 0
},
{
"namespace": "marketplace.users",
"operation": "query",
"command": "find",
"collection": "users",
"planSummary": "IXSCAN { _id: 1 }",
"queryHash": "39E03FF8",
"planCacheKey": "AED36A0D",
"queryShape": "filter={_id:{$in:[ObjectId]}} projection={email:number,firstName:number,lastName:number}",
"count": 10,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 10,
"keysExamined": 10,
"nreturned": 10,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 2180,
"numYield": 0
},
{
"namespace": "marketplace.categories",
"operation": "query",
"command": "find",
"collection": "categories",
"planSummary": "IXSCAN { _id: 1 }",
"queryHash": "ABAD6477",
"planCacheKey": "E494D204",
"queryShape": "filter={_id:{$in:[ObjectId]}} projection={name:number,nameEn:number}",
"count": 10,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 10,
"keysExamined": 10,
"nreturned": 10,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 1890,
"numYield": 0
}
]
},
"blockIoDelta": {
"amanat-dev-nginx": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-backend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-frontend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-postgres": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-mongodb": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-redis": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-scanner": {
"readBytes": 0,
"writeBytes": 0
}
}
},
{
"name": "payment_options_template",
"method": "GET",
"path": "/api/payment/request-network/options?currency=USD&amount=0.01&sellerId=6a1bfd1400e8b8205e86db9e&templateId=6a1c4512d07eb576c3509690",
"requestCount": 50,
"rps": 12.5,
"latency": {
"averageMs": 303.52,
"p50Ms": 255,
"p90Ms": 273,
"p95Ms": 753,
"p99Ms": 758,
"maxMs": 758
},
"non2xx": 0,
"statusCodeStats": {
"200": {
"count": 50
}
},
"mongoProfile": {
"totalOperations": 100,
"totalMillis": 0,
"groups": [
{
"namespace": "marketplace.requesttemplates",
"operation": "query",
"command": "find",
"collection": "requesttemplates",
"planSummary": "IDHACK",
"queryHash": "3B008735",
"planCacheKey": "",
"queryShape": "filter={_id:ObjectId} projection={paymentConfig:number}",
"count": 50,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 50,
"keysExamined": 50,
"nreturned": 50,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 12850,
"numYield": 0
},
{
"namespace": "marketplace.shopsettings",
"operation": "query",
"command": "find",
"collection": "shopsettings",
"planSummary": "IXSCAN { sellerId: 1 }",
"queryHash": "BF51CF8A",
"planCacheKey": "9CF87C58",
"queryShape": "filter={sellerId:ObjectId} projection={paymentConfig:number}",
"count": 50,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 0,
"keysExamined": 0,
"nreturned": 0,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 5650,
"numYield": 0
}
]
},
"blockIoDelta": {
"amanat-dev-nginx": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-backend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-frontend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-postgres": {
"readBytes": 100000,
"writeBytes": 0
},
"amanat-dev-mongodb": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-redis": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-scanner": {
"readBytes": 0,
"writeBytes": 0
}
}
},
{
"name": "addresses_me",
"method": "GET",
"path": "/api/addresses",
"requestCount": 10,
"rps": 5,
"latency": {
"averageMs": 330.9,
"p50Ms": 239,
"p90Ms": 707,
"p95Ms": 715,
"p99Ms": 715,
"maxMs": 715
},
"non2xx": 0,
"statusCodeStats": {
"200": {
"count": 10
}
},
"mongoProfile": {
"totalOperations": 10,
"totalMillis": 0,
"groups": [
{
"namespace": "marketplace.addresses",
"operation": "query",
"command": "find",
"collection": "addresses",
"planSummary": "IXSCAN { userId: 1 }",
"queryHash": "6935090D",
"planCacheKey": "C80BED60",
"queryShape": "filter={userId:ObjectId} sort={createdAt:number,primary:number}",
"count": 10,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 30,
"keysExamined": 30,
"nreturned": 30,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 13800,
"numYield": 0
}
]
},
"blockIoDelta": {
"amanat-dev-nginx": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-backend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-frontend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-postgres": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-mongodb": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-redis": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-scanner": {
"readBytes": 0,
"writeBytes": 0
}
}
},
{
"name": "purchase_requests_my",
"method": "GET",
"path": "/api/marketplace/purchase-requests/my",
"requestCount": 10,
"rps": 5,
"latency": {
"averageMs": 353.3,
"p50Ms": 256,
"p90Ms": 747,
"p95Ms": 753,
"p99Ms": 753,
"maxMs": 753
},
"non2xx": 0,
"statusCodeStats": {
"200": {
"count": 10
}
},
"mongoProfile": {
"totalOperations": 30,
"totalMillis": 1,
"groups": [
{
"namespace": "marketplace.purchaserequests",
"operation": "query",
"command": "find",
"collection": "purchaserequests",
"planSummary": "IXSCAN { createdAt: -1 }",
"queryHash": "6F3C3F41",
"planCacheKey": "A22CDD0E",
"queryShape": "filter={buyerId:ObjectId} sort={createdAt:number}",
"count": 10,
"millisTotal": 1,
"millisAverage": 0.1,
"millisP50": 0,
"millisP95": 1,
"millisMax": 1,
"docsExamined": 0,
"keysExamined": 0,
"nreturned": 0,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 1170,
"numYield": 0
},
{
"namespace": "marketplace.purchaserequests",
"operation": "command",
"command": "aggregate",
"collection": "purchaserequests",
"planSummary": "COUNT_SCAN { buyerId: 1 }",
"queryHash": "C22625EF",
"planCacheKey": "BD75157B",
"queryShape": "pipeline=[{$match:{buyerId:ObjectId}},{$group:{_id:number,n:{$sum:number}}}]",
"count": 10,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 0,
"keysExamined": 10,
"nreturned": 0,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 1170,
"numYield": 0
},
{
"namespace": "marketplace.payments",
"operation": "query",
"command": "find",
"collection": "payments",
"planSummary": "IXSCAN { status: 1, createdAt: -1 }, IXSCAN { status: 1, createdAt: -1 }, IXSCAN { status: 1, createdAt: -1 }, IXSCAN { status: 1, createdAt: -1 }",
"queryHash": "3B29FB2B",
"planCacheKey": "8762DEE5",
"queryShape": "filter={purchaseRequestId:{$in:[]},status:{$in:[string,string,string,string]}} sort={createdAt:number}",
"count": 10,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 0,
"keysExamined": 0,
"nreturned": 0,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 1090,
"numYield": 0
}
]
},
"blockIoDelta": {
"amanat-dev-nginx": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-backend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-frontend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-postgres": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-mongodb": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-redis": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-scanner": {
"readBytes": 0,
"writeBytes": 20000
}
}
},
{
"name": "auth_login",
"method": "POST",
"path": "/api/auth/login",
"requestCount": 5,
"rps": 1.25,
"latency": {
"averageMs": 724.2,
"p50Ms": 636,
"p90Ms": 1090,
"p95Ms": 1090,
"p99Ms": 1090,
"maxMs": 1090
},
"non2xx": 0,
"statusCodeStats": {
"200": {
"count": 5
}
},
"mongoProfile": {
"totalOperations": 15,
"totalMillis": 0,
"groups": [
{
"namespace": "marketplace.users",
"operation": "query",
"command": "find",
"collection": "users",
"planSummary": "IXSCAN { email: 1 }",
"queryHash": "106ECB7C",
"planCacheKey": "AB4716E0",
"queryShape": "filter={email:string,status:string}",
"count": 5,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 5,
"keysExamined": 5,
"nreturned": 5,
"ninserted": 0,
"nMatched": 0,
"nModified": 0,
"responseLength": 17735,
"numYield": 0
},
{
"namespace": "marketplace.users",
"operation": "update",
"command": "q",
"collection": "users",
"planSummary": "IDHACK",
"queryHash": "",
"planCacheKey": "",
"queryShape": "",
"count": 5,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 5,
"keysExamined": 5,
"nreturned": 0,
"ninserted": 0,
"nMatched": 5,
"nModified": 5,
"responseLength": 0,
"numYield": 0
},
{
"namespace": "marketplace.users",
"operation": "update",
"command": "q",
"collection": "users",
"planSummary": "IXSCAN { _id: 1 }",
"queryHash": "E515C562",
"planCacheKey": "5EA96075",
"queryShape": "",
"count": 5,
"millisTotal": 0,
"millisAverage": 0,
"millisP50": 0,
"millisP95": 0,
"millisMax": 0,
"docsExamined": 5,
"keysExamined": 5,
"nreturned": 0,
"ninserted": 0,
"nMatched": 5,
"nModified": 5,
"responseLength": 0,
"numYield": 0
}
]
},
"blockIoDelta": {
"amanat-dev-nginx": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-backend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-frontend": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-postgres": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-mongodb": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-redis": {
"readBytes": 0,
"writeBytes": 0
},
"amanat-dev-scanner": {
"readBytes": 0,
"writeBytes": 0
}
}
}
]
}

View File

@@ -0,0 +1,119 @@
# Mongo API Query Profile
Generated: 2026-05-31T14:29:51.927Z
Base URL: `https://dev.manwe.qzz.io`
Mongo: `amanat-dev-mongodb/marketplace`
This is a query-shape profile, not a max-throughput test. Request counts are intentionally small so the backend rate limiter does not dominate the profile.
## Endpoint Summary
| Endpoint | Requests | Avg | P95 | P99 | Non-2xx | Mongo ops | Top Mongo query |
|---|---:|---:|---:|---:|---:|---:|---|
| `GET /api/health` | 5 | 327.2ms | 707ms | 707ms | 0 | 0 | - |
| `GET /api/marketplace/categories` | 10 | 390.6ms | 1308ms | 1308ms | 0 | 0 | - |
| `GET /api/marketplace/categories/tree` | 10 | 342.5ms | 752ms | 752ms | 0 | 10 | `categories` find (10x, IXSCAN { isActive: 1 }) |
| `GET /api/marketplace/sellers` | 10 | 341.6ms | 733ms | 733ms | 0 | 10 | `users` find (10x, IXSCAN { role: 1 }) |
| `GET /api/marketplace/request-templates/public/logo-design-template` | 10 | 340.3ms | 740ms | 740ms | 0 | 30 | `requesttemplates` find (10x, IXSCAN { shareableLink: 1 }) |
| `GET /api/payment/request-network/options?currency=USD&amount=0.01&sellerId=6a1bfd1400e8b8205e86db9e&templateId=6a1c4512d07eb576c3509690` | 50 | 303.52ms | 753ms | 758ms | 0 | 100 | `requesttemplates` find (50x, IDHACK) |
| `GET /api/addresses` | 10 | 330.9ms | 715ms | 715ms | 0 | 10 | `addresses` find (10x, IXSCAN { userId: 1 }) |
| `GET /api/marketplace/purchase-requests/my` | 10 | 353.3ms | 753ms | 753ms | 0 | 30 | `purchaserequests` find (10x, IXSCAN { createdAt: -1 }) |
| `POST /api/auth/login` | 5 | 724.2ms | 1090ms | 1090ms | 0 | 15 | `users` find (5x, IXSCAN { email: 1 }) |
## Query Groups
### health
Path: `GET /api/health`
Status codes: `{"200":{"count":5}}`
No Mongo operations captured in this endpoint window.
### categories
Path: `GET /api/marketplace/categories`
Status codes: `{"200":{"count":10}}`
No Mongo operations captured in this endpoint window.
### categories_tree
Path: `GET /api/marketplace/categories/tree`
Status codes: `{"200":{"count":10}}`
| Collection | Command | Count | Total ms | Avg ms | P95 ms | Plan | Docs | Keys | Returned | Shape |
|---|---|---:|---:|---:|---:|---|---:|---:|---:|---|
| `categories` | `find` | 10 | 0 | 0 | 0 | `IXSCAN { isActive: 1 }` | 240 | 240 | 240 | `filter={isActive:boolean} sort={name:number,order:number}` |
### sellers
Path: `GET /api/marketplace/sellers`
Status codes: `{"200":{"count":10}}`
| Collection | Command | Count | Total ms | Avg ms | P95 ms | Plan | Docs | Keys | Returned | Shape |
|---|---|---:|---:|---:|---:|---|---:|---:|---:|---|
| `users` | `find` | 10 | 0 | 0 | 0 | `IXSCAN { role: 1 }` | 20 | 20 | 20 | `filter={isEmailVerified:boolean,role:string} projection={_id:number,email:number,firstName:number,lastName:number,profile.avatar:number}` |
### template_public
Path: `GET /api/marketplace/request-templates/public/logo-design-template`
Status codes: `{"200":{"count":10}}`
| Collection | Command | Count | Total ms | Avg ms | P95 ms | Plan | Docs | Keys | Returned | Shape |
|---|---|---:|---:|---:|---:|---|---:|---:|---:|---|
| `requesttemplates` | `find` | 10 | 0 | 0 | 0 | `IXSCAN { shareableLink: 1 }` | 10 | 10 | 10 | `filter={$or:[{expiresAt:null},{expiresAt:{$gt:{}}}],isActive:boolean,shareableLink:string}` |
| `users` | `find` | 10 | 0 | 0 | 0 | `IXSCAN { _id: 1 }` | 10 | 10 | 10 | `filter={_id:{$in:[ObjectId]}} projection={email:number,firstName:number,lastName:number}` |
| `categories` | `find` | 10 | 0 | 0 | 0 | `IXSCAN { _id: 1 }` | 10 | 10 | 10 | `filter={_id:{$in:[ObjectId]}} projection={name:number,nameEn:number}` |
### payment_options_template
Path: `GET /api/payment/request-network/options?currency=USD&amount=0.01&sellerId=6a1bfd1400e8b8205e86db9e&templateId=6a1c4512d07eb576c3509690`
Status codes: `{"200":{"count":50}}`
| Collection | Command | Count | Total ms | Avg ms | P95 ms | Plan | Docs | Keys | Returned | Shape |
|---|---|---:|---:|---:|---:|---|---:|---:|---:|---|
| `requesttemplates` | `find` | 50 | 0 | 0 | 0 | `IDHACK` | 50 | 50 | 50 | `filter={_id:ObjectId} projection={paymentConfig:number}` |
| `shopsettings` | `find` | 50 | 0 | 0 | 0 | `IXSCAN { sellerId: 1 }` | 0 | 0 | 0 | `filter={sellerId:ObjectId} projection={paymentConfig:number}` |
### addresses_me
Path: `GET /api/addresses`
Status codes: `{"200":{"count":10}}`
| Collection | Command | Count | Total ms | Avg ms | P95 ms | Plan | Docs | Keys | Returned | Shape |
|---|---|---:|---:|---:|---:|---|---:|---:|---:|---|
| `addresses` | `find` | 10 | 0 | 0 | 0 | `IXSCAN { userId: 1 }` | 30 | 30 | 30 | `filter={userId:ObjectId} sort={createdAt:number,primary:number}` |
### purchase_requests_my
Path: `GET /api/marketplace/purchase-requests/my`
Status codes: `{"200":{"count":10}}`
| Collection | Command | Count | Total ms | Avg ms | P95 ms | Plan | Docs | Keys | Returned | Shape |
|---|---|---:|---:|---:|---:|---|---:|---:|---:|---|
| `purchaserequests` | `find` | 10 | 1 | 0.1 | 1 | `IXSCAN { createdAt: -1 }` | 0 | 0 | 0 | `filter={buyerId:ObjectId} sort={createdAt:number}` |
| `purchaserequests` | `aggregate` | 10 | 0 | 0 | 0 | `COUNT_SCAN { buyerId: 1 }` | 0 | 10 | 0 | `pipeline=[{$match:{buyerId:ObjectId}},{$group:{_id:number,n:{$sum:number}}}]` |
| `payments` | `find` | 10 | 0 | 0 | 0 | `IXSCAN { status: 1, createdAt: -1 }, IXSCAN { status: 1, createdAt: -1 }, IXSCAN { status: 1, createdAt: -1 }, IXSCAN { status: 1, createdAt: -1 }` | 0 | 0 | 0 | `filter={purchaseRequestId:{$in:[]},status:{$in:[string,string,string,string]}} sort={createdAt:number}` |
### auth_login
Path: `POST /api/auth/login`
Status codes: `{"200":{"count":5}}`
| Collection | Command | Count | Total ms | Avg ms | P95 ms | Plan | Docs | Keys | Returned | Shape |
|---|---|---:|---:|---:|---:|---|---:|---:|---:|---|
| `users` | `find` | 5 | 0 | 0 | 0 | `IXSCAN { email: 1 }` | 5 | 5 | 5 | `filter={email:string,status:string}` |
| `users` | `q` | 5 | 0 | 0 | 0 | `IDHACK` | 5 | 5 | 0 | `-` |
| `users` | `q` | 5 | 0 | 0 | 0 | `IXSCAN { _id: 1 }` | 5 | 5 | 0 | `-` |
## Block I/O Deltas
- health: amanat-dev-scanner: read 0 B, write 10 KB
- categories: no container block I/O delta
- categories_tree: no container block I/O delta
- sellers: amanat-dev-scanner: read 0 B, write 20 KB
- template_public: no container block I/O delta
- payment_options_template: amanat-dev-postgres: read 100 KB, write 0 B
- addresses_me: no container block I/O delta
- purchase_requests_my: amanat-dev-scanner: read 0 B, write 20 KB
- auth_login: no container block I/O delta

View File

@@ -0,0 +1,64 @@
---
title: Multi-Shop Branch Project Scan - 2026-06-10
tags: [audit, repo-scan, multi-shop, white-label, documentation-sync]
created: 2026-06-10
---
# Multi-Shop Branch Project Scan - 2026-06-10
> Scope: full workspace scan of nested Git repositories under `/Users/manwe/CascadeProjects/escrow`, with special focus on `frontend/` and `backend/` `feature/white-label-shops`.
## Repository snapshot
| Repo | Branch | Head | Status summary | Notes |
| --- | --- | --- | --- | --- |
| `frontend/` | `feature/white-label-shops` | `df679a4` | Ahead of `forgejo/feature/white-label-shops` by 43 commits; dirty worktree | Version `2.11.49`. Multi-shop frontend, admin tenants UI, `WEBAPP_ENABLED` gate, many untracked E2E specs/report artifacts. |
| `backend/` | `feature/white-label-shops` | `ce06f47` | Ahead of `forgejo/feature/white-label-shops` by 35 commits; clean | Version `2.11.49`. Tenant services, storefront routes, tenant bot webhook, custom-domain/Caddy provisioning. |
| `deployment/` | `main` | `08fca31` | Ahead of `origin/main` by 2 commits; dirty worktree | Adds `escrow-multi` stack for `multi.amn.gg`; `escrow-multi/docker-compose.yml` modified; `dev-amn/` untracked. |
| `scanner/` | `development` | `1911c3a` | Ahead of `origin/development` by 8 commits; clean | Version `0.1.10`. Recent BSC Testnet/tUSDT alignment. |
| `amanat-assist/` | `main` | `821601a` | Dirty worktree | Version `1.1.0`. Recent Telegram theme/auth/review UX work; local `docker-compose.yml` modified and `nginx.conf` untracked. |
| `nick-doc/` | `main` | `6724422` | Dirty worktree | Existing tenant docs were untracked before this sync; `.obsidian/graph.json` already modified. |
## Multi-shop branch summary
The active multi-shop implementation is split across `frontend/`, `backend/`, and `deployment/`:
- `backend/src/db/schema/tenant.ts` defines six PG-native tenant tables: `tenants`, `tenant_domains`, `tenant_bots`, `tenant_integrations`, `tenant_payment_policies`, and `tenant_user_roles`.
- `backend/src/routes/tenantRoutes.ts` exposes tenant CRUD, activation/suspension, domains, bot registration/deletion/claim links, payment policies, and tenant roles.
- `backend/src/routes/storefrontRoutes.ts` exposes public tenant bootstrap and reserved catalog/checkout/order stubs.
- `backend/src/routes/tenantWebhookRoutes.ts` handles tenant Telegram bot webhooks and `/start <claimToken>` admin activation.
- `backend/src/services/tenant/domainProvisioningService.ts` verifies DNS, provisions Caddy routes, checks TLS, syncs active routes at startup, and runs a polling loop.
- `frontend/src/contexts/TenantContext.tsx` fetches `/api/storefront/bootstrap` and falls back to Amanat defaults on expected tenant misses.
- `frontend/src/app/dashboard/admin/tenants` and `frontend/src/sections/admin/tenants` provide tenant list/detail UI, DNS/TLS controls, bot activation links, payment policy editing, and member role controls.
- `deployment/escrow-multi/docker-compose.yml` defines the isolated `escrow-multi` stack with `:multi` frontend/backend images, one-shot migrations, isolated Postgres/Redis, and `shared-web` ingress.
## Documentation updated in this sync
| Doc | Update |
| --- | --- |
| [[System Overview]] | Reframed the platform as a multi-repo workspace and added the active multi-shop branch role. |
| [[10 - Services/README]] | Added tenant/white-label service row and `multi.amn.gg` routing. |
| [[frontend]] | Updated version/status/remote and noted tenant admin UI plus `WEBAPP_ENABLED`. |
| [[backend]] | Updated version/status and added tenant/storefront/tenant-webhook route groups. |
| [[deployment]] | Added `escrow-multi` stack details and branch isolation warning. |
| [[Tenant]] | Added bot claim fields and current domain lifecycle. |
| [[Tenant API]] | Added domain verify/TLS/delete routes, bot claim/delete/webhook routes, and current request/response behavior. |
| [[Tenant Storefront Flow]] | Updated domain provisioning and Telegram bot claim sequences. |
| [[tenant]] | Added Caddy/domain services, tenant webhook route, current env vars, and frontend/backend member-route mismatch. |
## Open findings
| Priority | Finding | Evidence | Suggested next step |
| --- | --- | --- | --- |
| P1 | Tenant member UI and backend route names do not match. | Frontend Members tab calls `/tenants/:tenantId/members` and `/tenants/:tenantId/members/:memberId`; backend exposes `POST /tenants/:tenantId/roles` and `DELETE /tenants/:tenantId/roles`. | Align frontend hooks/UI to backend routes or add backend member aliases before relying on tenant member management. |
| P2 | `useTenantDomains().addDomain()` sends `mode: "primary"` when `isPrimary` is true, but backend/domain enum accepts `cname` or `managed_ns`. | `frontend/src/hooks/use-tenants.ts` maps `isPrimary` to `"primary"`; `tenantDomainMode` enum is `managed_ns`, `cname`. | Remove `isPrimary` mapping or introduce a separate primary-domain model. |
| P2 | Tenant API docs and code now show bot webhook auto-registration, but production readiness depends on correct public `APP_URL`/`FRONTEND_URL`, Telegram secret header delivery, and tenant bot notification routing. | `tenantBotService.registerBot()` fire-and-forgets `setWebhook`; non-claim updates are currently acknowledged and ignored. | Add smoke tests for bot claim and document how tenant seller notifications will route after claim. |
| P3 | The docs vault now reflects Postgres/Drizzle as current runtime, but older pages still contain Mongo-era language. | `System Overview` was corrected; deeper flow/data pages may still mention legacy Mongo models. | Run a later doc-audit pass focused on Mongo/Mongoose references after code migration status is final. |
## Guardrails confirmed
- No frontend/backend code changes were made in this documentation sync, so no version bump is required.
- Do not touch the `escrow-dev` / `dev-amn` stack while working on `feature/white-label-shops`; target only `escrow-multi`.
- Do not print or copy `.env` contents, BotFather tokens, private keys, database credentials, or Woodpecker agent tokens into docs or chat.
Related: [[Tenant]], [[Tenant API]], [[Tenant Storefront Flow]], [[tenant]], [[deployment]], [[PRD - Seller-Owned White-Label Shops and Bots]].

View File

@@ -0,0 +1,215 @@
---
title: Security, DB Performance, and Logic Audit - 2026-06-07
tags: [audit, security, database, performance, logic, findings]
created: 2026-06-07
updated: 2026-06-07
status: closed
---
# Security, DB Performance, and Logic Audit - 2026-06-07
Fresh post-remediation audit track after the DB Query & Schema Audit was closed. This pass targets security, authorization, sensitive data handling, DB/performance regressions outside the closed report, and business-logic/state-machine correctness.
> [!info] Scope
> Initial source pass covered mounted backend routes, auth/ownership middleware, uploads/static file serving, dispute controllers/services, delivery-code flows, frontend token handling, and template checkout batch paths. This document is the starting register; expand it as each wave verifies more surfaces.
## Method
- Review only code that is mounted or imported by mounted routes, unless the finding explicitly calls out legacy/dead code.
- Separate confirmed findings from follow-up scan queue items.
- Use the same severity posture as the DB audit:
- Critical: cross-tenant privilege bypass, funds/control-plane compromise, or exploitable unauthenticated secret exposure.
- High: authenticated cross-tenant mutation/read, sensitive token/code leakage, unbounded user-triggered load, or logic that can corrupt user-visible state.
- Medium: broken route behavior, false-deny authorization, latent risk, degraded performance, or cleanup needed before the next refactor.
- Low: hardening and maintainability issues with limited user impact.
## Remediation Summary
| Severity | Open | Fixed |
|---|---:|---:|
| Critical | 0 | 0 |
| High | 0 | 6 |
| Medium | 0 | 4 |
| Low | 0 | 0 |
| Total | 0 | 10 |
All initial findings were remediated in backend `c0e80a7` and frontend `38ff0db`.
| Finding | Status | Fixed in | Notes |
|---|---|---|---|
| H1 | Fixed | backend `c0e80a7` | Dashboard dispute creation now checks canonical purchase-request ownership before creating dispute/chat state. |
| H2 | Fixed | backend `c0e80a7` | Delivery-code generation and verification logs no longer print code values or expected values. |
| H3 | Fixed | backend `c0e80a7`, frontend `38ff0db` | Google OAuth/bearer logs no longer print token material; frontend logger redacts sensitive object keys. |
| H4 | Fixed | backend `c0e80a7` | File delete/info routes use upload-relative paths resolved under the upload root. |
| H5 | Fixed | backend `c0e80a7` | Template batch endpoints cap array sizes and reject duplicate payment-completion ids. |
| H6 | Fixed | backend `c0e80a7` | Broad legacy user list is admin-only; contacts/search no longer expose email/phone. |
| M1 | Fixed | backend `c0e80a7` | Purchase-request dispute-hold routes use canonical `sameUser` checks. |
| M2 | Fixed | backend `c0e80a7` | Dashboard dispute participant checks use canonical identity comparison. |
| M3 | Fixed | backend `c0e80a7` | Temp/document uploads are blocked from public static serving. |
| M4 | Fixed | backend `c0e80a7` | Audited list endpoints use the shared `parsePagination` helper. |
## High Findings
### H1 - Dashboard dispute creation does not verify purchase-request ownership
**Files:** `backend/src/routes/disputeRoutes.ts:10-11`, `backend/src/controllers/disputeController.ts:9-31`, `backend/src/services/dispute/DisputeService.ts:96-170`
**Status:** Fixed in backend `c0e80a7`
`POST /api/disputes` is mounted under `/api/disputes` and only requires authentication. The controller correctly ignores the client-submitted `buyerId` and passes `req.user.id`, but `DisputeService.createDispute` only verifies that `purchaseRequestId` exists. It does not verify that the authenticated user is the purchase request buyer, seller, admin, or resolver before creating a dispute and dispute chat tied to that purchase request.
An authenticated user can therefore create a dashboard dispute for another user's purchase request if they know or can infer the request id. The new dispute stores the attacker as `buyerId` and can pull the real selected/preferred seller into a chat.
**Impact:** Cross-tenant state mutation, support queue pollution, seller notification/chat creation for unrelated requests, and possible confusion in admin dispute handling.
**Fix:** Before creating the chat or dispute, load the purchase request context and require one of:
- requester is the request buyer;
- requester is the selected/preferred seller, if seller-initiated disputes are intentionally allowed;
- requester is `admin` or `resolver`.
Add a regression test where user B attempts to dispute user A's purchase request and receives 403.
### H2 - Delivery codes are printed in backend logs
**Files:** `backend/src/services/marketplace/marketplaceController.ts:2022-2025`, `backend/src/services/delivery/DeliveryService.ts:133-147`, legacy `backend/src/services/marketplace/routes.ts:2941-2947`
**Status:** Fixed in backend `c0e80a7`
Delivery codes are sensitive one-time confirmation values. The mounted controller logs the full generated code:
```ts
console.log(`Delivery code generated for request ${id}: ${deliveryCode}`);
```
`DeliveryService.verifyDeliveryCode` also logs failed attempts with both the submitted value and the expected code.
**Impact:** Anyone with backend log access can recover or validate a delivery code and influence delivery confirmation. The failed-attempt log is especially risky because it turns an invalid guess into the correct code in logs.
**Fix:** Never log the code or expected value. Log only request id, actor id, success/failure reason, and an event id. If auditability is needed, store a salted hash or redacted code suffix only.
### H3 - Google OAuth and bearer-token debug logs expose access-token material in development logs
**Files:** `frontend/src/auth/services/google-oauth.ts:117-121`, `frontend/src/auth/services/google-oauth.ts:185-193`, `frontend/src/lib/axios.ts:69-76`, `frontend/src/utils/logger.ts:29-34`
**Status:** Fixed in backend `c0e80a7`, frontend `38ff0db`
The frontend logger is development-enabled by default. Google signup logs the full OAuth `tokenResponse` and then the full `access_token`. Google signin logs token metadata and a token prefix. The axios request interceptor logs a bearer-token preview and localStorage key names.
**Impact:** Dev/local screenshots, browser consoles, test logs, or shared debugging sessions can leak usable OAuth or API bearer material. Production `info` logs are disabled, so this is not currently a production console leak, but it violates the "never log tokens" rule and is easy to re-enable accidentally.
**Fix:** Remove token value logging entirely. Keep boolean/length-only diagnostics if required, and add a small frontend logger redaction helper for keys containing `token`, `authorization`, `secret`, `password`, or `code`.
### H4 - Generic file delete/info routes are broken and the service path handling is unsafe for a future repair
**Files:** `backend/src/services/file/fileRoutes.ts:71-80`, `backend/src/services/file/fileController.ts:247-280`, `backend/src/services/file/fileService.ts:225-268`
**Status:** Fixed in backend `c0e80a7`
`DELETE /api/files/delete` never supplies `req.params.filename`, while the controller requires it. `GET /api/files/info/:filePath` provides `filePath`, but the controller also reads `filename`. These endpoints currently fail validation rather than doing useful work.
The latent security issue is in the service: `deleteFile(filePath)` and `getFileInfo(filePath)` accept arbitrary raw filesystem paths when the input does not start with `/uploads`. A future route-param fix could accidentally expose arbitrary file stat/delete behavior to any authenticated caller.
**Impact:** Broken file-management functionality now; high-risk latent arbitrary file access/delete if the route is repaired by simply passing request params through.
**Fix:** Replace path-based delete/info with file identifiers or strict allow-listed upload-relative paths. Resolve with `path.resolve`, require the final path to stay under the configured upload directory, reject `..`, reject absolute paths, and add route tests for traversal inputs.
### H5 - Template batch endpoints accept unbounded arrays and perform per-id/per-item work
**Files:** `backend/src/services/marketplace/requestTemplateRoutes.ts:267-325`, `backend/src/services/marketplace/RequestTemplateService.ts:472-663`
**Status:** Fixed in backend `c0e80a7`
`batch-convert` validates that `items` is an array with `min: 1`, but no maximum. Each item can request `quantity` up to 100 and the service loops items sequentially, creates requests/offers, increments usage, re-fetches populated purchase requests, and schedules notifications.
`complete-payment` validates `requestIds` with `min: 1`, but no maximum, then runs:
```ts
await Promise.all(requestIds.map((id) => this.marketplaceRepo.findPurchaseRequestById(id)))
```
before the bulk status update.
**Impact:** An authenticated caller can submit large arrays within the request body limit and fan out many DB reads/writes or a large concurrent read burst. This is a DB/performance and abuse-control issue.
**Fix:** Add explicit max lengths for `items` and `requestIds` (for example 50 or the intended cart maximum), reject duplicate request ids, and move the ownership check to a single repository method that fetches all requested rows by id.
### H6 - User-facing legacy user list exposes broad user data to any authenticated account
**Files:** `backend/src/app.ts:591-592`, `backend/src/services/user/userRoutes.ts:830-879`, `backend/src/services/user/userRoutes.ts:988-1006`
**Status:** Fixed in backend `c0e80a7`
The legacy `/api/users` route is still mounted. `GET /api/users` requires authentication but no role restriction, allows `limit` up to 1000, and returns email, role, status, profile fields, and last login metadata for matching active users. The search endpoint also returns email and profile contact metadata to any authenticated caller with a two-character search term.
**Impact:** Broad authenticated user enumeration and privacy exposure. This also creates avoidable DB load because the route still uses Mongoose `User.find`/`countDocuments` rather than the newer repository path.
**Fix:** Decide whether non-admin user discovery is still a product requirement. If not, make `/api/users` admin-only. If yes, narrow returned fields, cap limits much lower, remove email unless explicitly needed, and move it behind a purpose-specific contacts/search endpoint.
## Medium Findings
### M1 - Purchase-request dispute-hold route uses raw ID string comparisons
**Files:** `backend/src/services/dispute/disputeRoutes.ts:44-50`, `backend/src/services/dispute/disputeRoutes.ts:146-153`
**Status:** Fixed in backend `c0e80a7`
The `/api/disputes/pr/:purchaseRequestId/...` route compares `request.buyerId.toString()` and preferred seller ids directly against `req.user.id`. In this codebase, many repositories now resolve UUID and legacy ObjectId forms. Raw comparison can false-deny legitimate users when one side is UUID and the other is legacy id.
**Impact:** Compatibility/logic failure, especially during mixed legacy/UUID flows. This looks more like false-deny than privilege bypass.
**Fix:** Reuse the existing `sameUser`/canonical-id helper or expose canonical participant checks from the repository.
### M2 - Dashboard dispute participant checks assume populated `_id` shapes
**Files:** `backend/src/controllers/disputeController.ts:125-130`, `backend/src/controllers/disputeController.ts:253-258`, `backend/src/db/repositories/drizzle/DrizzleDisputeRepo.ts:271-278`
**Status:** Fixed in backend `c0e80a7`
The controller checks `dispute.buyerId._id.toString()` and `dispute.sellerId?._id.toString()`. Drizzle dispute records currently map these references into `{ _id: displayId }`, so this works today, but the controller is tightly coupled to a Mongoose-like shape rather than using canonical participant helpers.
**Impact:** Future repository shape changes can break dispute reads/evidence with 500s or false denials.
**Fix:** Add a small `isDisputeParticipant(dispute, user)` helper using `toIdString` plus canonical user comparison, and use it for details/evidence access.
### M3 - Public `/uploads` serving exposes all non-chat uploaded files cross-origin
**Files:** `backend/src/app.ts:527-551`, `backend/src/services/file/fileRoutes.ts:21-69`, `backend/src/services/file/fileService.ts:75-109`
**Status:** Fixed in backend `c0e80a7`
Chat attachments are explicitly blocked from static serving, but all other uploads are served through `/uploads` with `Cross-Origin-Resource-Policy: cross-origin`. Generic upload accepts images, PDFs, and Word documents by MIME type and returns a public URL.
**Impact:** This may be intended for avatars, blog images, and product/request-template images, but generic temp/documents uploads become public once uploaded. There is no ownership check, no signed URL, no magic-byte validation for generic documents, and no malware/content scanning.
**Fix:** Split public media uploads from private documents. Keep only intentional public media under static `/uploads`; serve private documents through authenticated file routes with ownership checks. Validate file signatures, not only MIME.
### M4 - Several list endpoints parse user-supplied limits without consistent normalization
**Files:** `backend/src/controllers/disputeController.ts:70-71`, `backend/src/controllers/pointsController.ts:46-78`, `backend/src/services/payment/paymentRoutes.ts:171-174`, `backend/src/services/chat/chatController.ts:301-355`
**Status:** Fixed in backend `c0e80a7`
The DB audit fixed many concrete query caps, but the current codebase still has route handlers that call `parseInt(limit)` and pass the result onward without a shared normalizer. Some downstream repos cap values, some do not, and malformed/negative values can produce inconsistent behavior.
**Impact:** Performance variability and route-specific edge cases. This is lower priority than H5 because it requires endpoint-specific downstream confirmation.
**Fix:** Add a shared `parsePagination` helper with min/max/defaults and replace local ad hoc parsing.
## Follow-up Scan Queue
1. Security wave: fix/verify H1-H4 first, then re-scan authz on mounted marketplace/payment routes.
2. DB/performance wave: quantify H5 with expected max cart size; inspect remaining route-level `limit` parsing and user-route Mongoose paths.
3. Logic wave: review delivery-code state transitions, dispute creation/resolution side effects, and payment status mutation routes.
4. Privacy wave: decide intended visibility for `/api/users`, generic uploaded documents, and dispute evidence URLs.
5. Regression tests: add at least one failing test per high finding before code fixes where practical.
## Already Checked in This Pass
| Area | Result |
|---|---|
| Payment callback auth | `paymentCallbackAuth` fails closed when secret is unset and uses timing-safe comparison. |
| Chat attachment serving | Static `/uploads/chat` is blocked; authenticated chat attachment route checks membership and path traversal. |
| Dashboard dispute body `buyerId` | Controller ignores body `buyerId` and uses `req.user.id`; the missing check is purchase-request ownership, not body trust. |
| Legacy marketplace route module | `backend/src/services/marketplace/routes.ts` still contains old route code, but `app.ts` mounts `marketplaceControllerRouter`, not the legacy router. |
| Taskmaster | `task-master next` reported no available tasks. |

View File

@@ -0,0 +1,88 @@
---
title: Workflow Remediation Plan — 2026-06-10 Audit
tags: [audit, workflow, plan, remediation]
created: 2026-06-10
status: draft
---
# Workflow Remediation Plan — 2026-06-10 Audit
## Division of Labour
| Finding | Severity | Assignee | Rationale |
|---|---|---|---|
| C1 (secrets rotation) | Critical | Mistral → rotation doc (Haiku writes checklist) | Rotation is human action; doc is mechanical |
| C2 (LLM proxy auth) | Critical | Sonnet | Auth pattern integration needs codebase knowledge |
| H1 (bot claim URL) | High | Haiku | Mechanical serializer split — no domain logic |
| H2 (file ownership) | High | Sonnet | Needs to read ownership model from DB schema |
| H3 (oracle quoting) | High | Sonnet (grouped with H4+M3) | Same file, complex payment logic |
| H4 (UUID/JWT mismatch) | High | Sonnet (grouped with H3+M3) | Same file, identity normalization |
| M3 (permit relay) | Medium | Sonnet (grouped with H3+H4) | Same file, rate-limit implementation |
| M4 (debug panel) | Medium | Mistral | Simple role-gating change |
| M5 (scanner startup) | Medium | Mistral | One Go startup guard |
| M6 (lint errors) | Medium | Mistral | Auto-fix pass + manual cleanup |
| L1 (deployment defaults) | Low | Mistral | Replace hardcoded strings |
| L2 (MIME hardening) | Low | Mistral | Reuse existing magic-byte validator |
| M1 (ignoreBuildErrors) | Medium | Mistral | Config change + TS cleanup |
## Workflow Phase Design
### Phase 1 — Haiku (parallel)
Two agents run simultaneously:
**H1-fix**: `tenantBotService.ts`
- Create `toPublicBotList()` — identical to `toPublicBot()` but always returns `claimUrl: null`
- Replace usage in the list/map path with the new function
- Keep `toPublicBot()` for the dedicated claim-link endpoint
**C1-doc**: Write `C1-Secrets-Rotation-Checklist-2026-06-10.md`
- Rotation steps per category (env files, test fixtures, docs)
- History cleanup instructions (git filter-repo, coordinate clones)
- Prevention checklist (gitleaks hook, CI scan)
### Phase 2 — Sonnet (parallel, non-overlapping files)
Three agents run simultaneously:
**C2-fix**: `frontend/src/app/api/llm/route.ts` + `amanat-assist/llm-proxy/index.mjs`
- Add session/JWT auth check to the Next.js route (401 if not authenticated)
- Add 64KB body size guard to route
- Flip CORS default from wildcard to closed in proxy
- Add 256KB body cap to proxy
- Restrict provider to ALLOWED_PROVIDERS env var
- Redact error logging (status + truncated message only)
**H2-fix**: `backend/src/services/file/fileController.ts` + `fileRoutes.ts`
- Read ownership model from upload code to understand user → file path mapping
- Add ownership check before delete: file must belong to user or user must be admin
- Add ownership check before info: same rule
- Return 403 on unauthorized access
**Payment-fix** (H3 + H4 + M3 combined — single agent to avoid same-file conflicts):
- H3: Remove `ORACLE_QUOTING_ENABLED` flag-gated fallback; always use server-side oracle path; fail 422 if offer not loadable
- H4: Replace raw `payment.buyerId !== userId.toString()` comparisons with canonical helper that checks both legacy ObjectId and pgId UUID (3 sites in `requestNetworkRoutes.ts` + 3 in `paymentRoutes.ts`)
- M3: Add buyer ownership check to permit relay route; add in-memory rate limiter (5 relay attempts/payment/minute)
### Phase 3 — Haiku (parallel verification)
- `cd backend && npx tsc --noEmit -p tsconfig.json` — report pass/fail + errors
- `cd scanner && go build ./...` — report pass/fail
### Phase 4 — Opus (final review)
Read all 6 changed files, assess:
- Is each fix correct and complete?
- Are there bypass vectors?
- Regressions in legitimate flows?
- TypeScript type safety?
Return a structured PASS/NEEDS_FIX verdict per file + overall READY/NEEDS_WORK.
## Findings NOT covered by this workflow (human action required)
- **C1 rotation**: The checklist is generated, but actual key rotation is a human action (BotFather, provider dashboards, re-deployment with new values, then git history rewrite after rotation confirmed).
- **H5 dependencies**: Upgrade lockfiles needs careful testing — separate controlled branch recommended.
- **M2 browser tokens**: Moving to httpOnly cookies is a large auth refactor — tracked as a separate initiative.
## Estimated output
- ~6 file edits across frontend, backend, amanat-assist
- 1 new doc (C1 rotation checklist)
- Typecheck passes expected (Opus review will catch regressions if any)
- Backend tsc was already passing before this workflow — must stay passing

55
10 - Services/README.md Normal file
View File

@@ -0,0 +1,55 @@
# 10 - Services
This section documents every deployable service (sub-project) in the Amanat / Escrow platform. Each page covers the service's purpose, tech stack, configuration, and operational notes.
---
## Service Directory
| Service | Language / Framework | Status | URL | Doc |
|---|---|---|---|---|
| Backend API | Node.js / TypeScript (Express) | Live | `dev.amn.gg/api`, `multi.amn.gg/api` | [[backend]] |
| Frontend | Next.js 14 / React / TypeScript | Live | `dev.amn.gg`, `multi.amn.gg` | [[frontend]] |
| Scanner | Go | Live | internal | [[scanner]] |
| Amanat Assist | Node.js / TypeScript + Telegram Bot API | Live | `assist.dev.amn.gg` | [[amanat-assist]] |
| Deployment | Docker Compose + Caddy + Watchtower | Live | `arcane.tbs.amn.gg` | [[deployment]] |
---
## Architecture Overview
```
Browser / Telegram Mini App
infra-caddy (reverse proxy, TLS, ports 80/443)
├── dev.amn.gg / multi.amn.gg → [[frontend]] (Next.js SSR)
├── */api → [[backend]] (Express REST + WebSocket)
└── assist.dev.amn.gg → [[amanat-assist]] (LLM proxy + Telegram mini-app)
[[backend]]
├── PostgreSQL (Drizzle ORM — primary store)
├── MongoDB (legacy read path, being retired)
├── Redis (sessions, rate-limit, pub-sub)
└── emits payment events
[[scanner]] (Go — watches EVM chains for on-chain payments)
│ HTTP webhook on confirmation
└──────────────▶ [[backend]] POST /api/payment/callback
```
Integration points:
- **[[frontend]] → [[backend]]**: REST `/api/*` and WebSocket via infra-caddy
- **[[scanner]] → [[backend]]**: webhook POST on each confirmed on-chain payment
- **[[amanat-assist]] → [[backend]]**: reads offers/requests; sends AI-generated replies via Telegram Bot API
- **[[backend]] → Telegram**: step notifications for buyer/seller workflow
- All services run as Docker containers in Arcane-managed projects on `89.58.32.32`; see [[deployment]] for compose files, env vars, and the `shared-web` network.
---
## Related Sections
- [[01 - Architecture]] — system-wide design decisions, data model, and sequence diagrams
- [[03 - API Reference]] — full REST endpoint and WebSocket event reference
- [[08 - Operations]] — runbooks, monitoring, secrets management, backup

View File

@@ -0,0 +1,306 @@
# amanat-assist — AI Request Assistant
**Status:** Live at `assist.amn.gg` (v1.1.1)
**Repo:** `/amanat-assist` (separate repo, no Amanat DB or internal-service access)
**Owner:** Amanat Platform
**PRD:** [PRD — AI Request Assistant Mini App](../PRD%20-%20AI%20Request%20Assistant%20Mini%20App.md)
---
## 1. Overview
`amanat-assist` is a Telegram Mini App **and** standalone web app that guides buyers through creating a purchase request on the Amanat escrow marketplace using a conversational LLM interface. The user describes what they want in plain language; the assistant asks clarifying questions, suggests price and delivery windows, then with one tap posts the structured request to the Amanat backend.
The user never sees a form. The LLM handles categorisation, field normalisation, and the API call.
---
## 2. Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Telegram / Browser │
│ assist.amn.gg (nginx, static React/Vite bundle) │
│ → auth (Telegram SSO or web redirect to dev.amn.gg) │
│ → UI: multi-turn chat, photo upload, review card │
└──────────────────────┬──────────────────────────────────┘
│ POST /api/llm
┌─────────────────────────────────────────────────────────┐
│ amanat-llm-proxy (Node.js 18+, port 3001) │
│ Providers: Mistral → fallback DeepSeek on 429 │
│ Also: Kimi, OpenCode proxy │
│ API keys server-side only, never in browser │
└──────────────────────┬──────────────────────────────────┘
│ Bearer JWT
┌─────────────────────────────────────────────────────────┐
│ Amanat Backend (api.amn.gg / dev.amn.gg) │
│ /api/auth/telegram /api/categories /api/requests │
└─────────────────────────────────────────────────────────┘
```
### Docker Compose
| Service | Image | Container | Notes |
|---|---|---|---|
| `frontend` | `nginx:alpine` | `amanat-frontend` | Serves `dist/` — static bundle |
| `llm-proxy` | Built from `./llm-proxy/` | `amanat-llm-proxy` | Port 3001 |
Both services join the external `escrow-dev_default` docker network (alias `escrow_net`).
---
## 3. Tech Stack
| Layer | Tech |
|---|---|
| Frontend | React 18, TypeScript, Vite 5 |
| Styling | CSS variables + Telegram theme tokens |
| LLM Proxy | Plain Node.js 18+ (`http` module, native `fetch`) — zero npm deps |
| State | React state machine + `useSlotFilling` hook |
| Persistence | `localStorage` via `useChatSessions` hook |
| Auth (Telegram) | `window.Telegram.WebApp.initData``/api/auth/telegram` |
| Auth (Web) | Redirect to `dev.amn.gg``?access_token=...` callback |
| CI | Woodpecker CI on ARM64 agent co-located with `assist.amn.gg` |
---
## 4. State Machine
```mermaid
stateDiagram-v2
[*] --> INIT
INIT --> AUTH : Telegram initData present
INIT --> GREETING : Dev mode (skip auth)
INIT --> GREETING : Web — stored session valid
INIT --> GREETING : Web — OAuth callback received
AUTH --> GREETING : silentSSO success
AUTH --> ERROR : silentSSO failure
GREETING --> COLLECT : user sends first message
COLLECT --> COLLECT : LLM asks follow-up
COLLECT --> REVIEW : all required slots filled
REVIEW --> SUBMITTING : user taps Submit
REVIEW --> COLLECT : user taps Edit
SUBMITTING --> DONE : POST /api/requests 200
SUBMITTING --> ERROR : submit failed
ERROR --> AUTH : retry (Telegram)
ERROR --> GREETING : retry (dev/web)
COLLECT --> HISTORY : user taps History
HISTORY --> COLLECT : user loads session
HISTORY --> GREETING : new chat
```
---
## 5. Auth
### 5.1 Telegram Mini App (primary)
```
User opens bot
→ window.Telegram.WebApp.initData (injected by Telegram)
→ POST https://dev.amn.gg/api/auth/telegram
{ initData: "<raw string>", role: "buyer" }
← { data: { tokens: { accessToken, refreshToken }, user, isNewUser } }
→ Store accessToken in memory (not localStorage — ephemeral session)
```
On any `401`, the app transparently POSTs `/api/auth/refresh-token` and retries.
### 5.2 Web Browser
1. Check for `?access_token=...` in URL (OAuth callback redirect from `dev.amn.gg`)
2. Check `localStorage` for a stored valid session (calls `/api/auth/me` to verify)
3. If no session → redirect to `dev.amn.gg?redirect_uri=<current-origin>` for login
### 5.3 Development Mode
Skips all auth, uses mock tokens + mock user.
---
## 6. LLM Service
### 6.1 Providers
| Provider | Model | Key env var | Notes |
|---|---|---|---|
| `mistral` | `mistral-large-latest` | `MISTRAL_API_KEY` | Primary |
| `mistral` (vision) | `pixtral-12b-2409` | `MISTRAL_API_KEY` | Image analysis |
| `kimi` | `moonshot-v1-8k` | `KIMI_API_KEY` | Optional |
| `deepseek` | `deepseek-chat` | `DEEPSEEK_API_KEY` | Auto-fallback on 429 |
| `opencode` | `claude-3-sonnet` | — | OpenCode local proxy |
### 6.2 Proxy API
```
POST /api/llm
Content-Type: application/json
{
"messages": [{ "role": "user", "content": "..." }, ...],
"provider": "mistral", // optional, defaults to mistral
"model": "mistral-large-latest" // optional
}
Response: { "content": "...", "model": "..." }
| { "content": "...", "model": "...", "fallback": true } // on auto-failover
```
### 6.3 Slot Filling
The system prompt instructs the LLM to:
1. Extract ALL info from the user's message before asking anything
2. Ask at most **one question** at a time
3. When all required slots are filled, output a fenced JSON block:
````
```request
{ "title": "...", "description": "...", "categoryId": "...", ... }
```
````
**Required fields:** `title`, `description`, `categoryId`, `urgency`, `deliveryInfo.deliveryType`
**Optional:** `productLink`, `attachments[]`, `budget{min,max,currency}`, `quantity`, `size`, `color`
The app detects the ` ```request ` fence, parses the JSON, and transitions to `REVIEW`.
### 6.4 Vision (Image Upload)
Uses `pixtral-12b-2409`. The user uploads a photo; the LLM returns structured JSON with `name`, `category`, `color`, `description`, `quantity`. Result is merged into slots; `categoryId` is never set from vision (names aren't valid ObjectIds).
### 6.5 Price Suggestion
If `slots.budget` is unset at REVIEW time, the app calls the LLM with a structured price-suggestion prompt. Result tagged `high`/`medium`/`low` confidence; only `high` and `medium` are auto-applied.
---
## 7. Request Slots Schema
```typescript
interface RequestSlots {
title?: string;
description?: string;
categoryId?: string; // must be a valid ObjectId from /api/categories
productLink?: string;
attachments?: string[]; // image URLs or base64 (base64 stripped before storage)
budget?: { min?: number; max?: number; currency: string };
urgency?: 'low' | 'medium' | 'high' | 'urgent';
quantity?: number;
size?: string;
color?: string;
deliveryInfo?: {
deliveryType: 'physical' | 'online';
email?: string;
};
}
```
---
## 8. Frontend Components
| Component | Description |
|---|---|
| `App.tsx` | State machine root — renders one screen per state |
| `ChatUI` | Scrollable message list + text/photo input + category chips |
| `ChatHistory` | localStorage-persisted past sessions list |
| `ReviewCard` | Final structured view of filled slots + Submit/Edit buttons |
| `AuthScreen` | Loading spinner shown during SSO |
| `ErrorScreen` | Error message + Retry button |
### Hooks
| Hook | Description |
|---|---|
| `useSlotFilling` | Manages LLM conversation, slot extraction, greeting, session load |
| `useChatSessions` | Read/write/delete chat sessions from `localStorage` |
---
## 9. Amanat API Calls
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| `POST` | `/api/auth/telegram` | — | Exchange Telegram initData for JWT |
| `POST` | `/api/auth/refresh-token` | — | Refresh expired access token |
| `GET` | `/api/auth/me` | Bearer | Validate stored session |
| `GET` | `/api/categories` | Bearer | Load category list for slot filling |
| `POST` | `/api/requests` | Bearer | Submit completed purchase request |
All requests from `src/services/api.ts` use `amanatApi()` from `auth.ts`, which auto-refreshes on 401.
Submitted requests include `aiGenerated: true`.
---
## 10. Deployment
### CI Pipeline (`.woodpecker/ci.yml`)
```yaml
trigger: push/manual to main
agent: linux/arm64 (same host as assist.amn.gg)
steps:
1. build-frontend (node:22-alpine):
- npm ci + npm run build (Vite)
- Bakes VITE_ env vars into the static bundle at build time
2. deploy (docker:27-cli, docker socket volume-mounted — no registry push):
- Copy dist/ to /opt/amanat-assist/dist/ (nginx bind-mount)
- Sync docker-compose.yml to /opt/amanat-assist/
- Rebuild `amanat-assist-llm-proxy` Docker image in-place (locally, never pushed)
- docker compose up -d llm-proxy (recreates llm-proxy container only)
3. notify (node:22-alpine):
- Runs scripts/ci/tg-notify.cjs on success or failure
- Uses TG_TOKEN + TG_USERS secrets
```
Nginx picks up new static files from the bind-mount without restart.
The proxy container is recreated with the new image.
### Environment Variables
| Variable | Scope | Description |
|---|---|---|
| `VITE_AMANAT_API_BASE` | Frontend build-time | Backend URL (e.g. `https://dev.amn.gg`) |
| `VITE_LLM_PROVIDER` | Frontend build-time | Default LLM provider (`mistral`) |
| `VITE_LLM_API_URL` | Frontend build-time | Proxy URL (e.g. `https://assist.amn.gg/api/llm`) |
| `MISTRAL_API_KEY` | llm-proxy runtime | Mistral API key (server-side only) |
| `KIMI_API_KEY` | llm-proxy runtime | Optional Kimi API key |
| `DEEPSEEK_API_KEY` | llm-proxy runtime | Optional DeepSeek API key (auto-fallback) |
| `OPENCODE_PROXY_URL` | llm-proxy runtime | OpenCode local proxy URL (default `http://127.0.0.1:3456`) |
| `ALLOWED_ORIGINS` | llm-proxy runtime | CORS whitelist (comma-separated) |
| `PORT` | llm-proxy runtime | Port (default 3001) |
---
## 11. Integration with dev.amn.gg Frontend
The `dev.amn.gg` frontend (Next.js) includes a native AI Assistant page at `/dashboard/assist` that:
- Proxies `/api/llm` calls to `amanat-llm-proxy` via an internal Next.js API route
- Uses the existing `dev.amn.gg` session (no re-auth needed)
- Allows buyers to start an AI-assisted request flow from within the main dashboard
- The "New Request" page includes a button to launch the AI assistant
See `src/sections/assist/` in the frontend repo for the implementation.
---
## 12. Known Limitations / Open Items
- **No voice input** — text and photo only (MVP)
- **Single-item only** — one purchase request per conversation
- **No post-submit editing** — requests posted via the assistant cannot be edited through the assistant
- **Session storage is local only** — history lives in `localStorage`, not synced to backend
- **Vision model not streaming** — responses may feel slow for image analysis
- **categoryId from vision disabled** — vision returns category names, not ObjectIds; name→ID matching is left to the LLM in the follow-up turn
- **llm-proxy is zero-dependency** — `llm-proxy/index.mjs` uses only Node.js built-ins (`http`, native `fetch`); no npm packages. Logs rotate at 10 MB.
- **No registry push** — CI builds the llm-proxy image directly on the host via a docker socket volume mount; `docker pull` will always fail (intentional — image is local-only)
- **Telegram theme override is --primary accent only** — applying full Telegram theme tokens causes invisible text on cream backgrounds; only the primary accent colour is overridden from the Telegram theme
- **iframe auth handoff** — when embedded via iframe, auth is delivered via `access_token` + `user_json` URL params; the app decodes the JWT client-side as a fallback when the backend `/api/auth/me` call is not possible
- **slotsRef stale-closure guard** — review submit uses a ref (`slotsRef`) instead of state directly to avoid a stale-closure bug that could cause the wrong slot values to be submitted

Some files were not shown because too many files have changed in this diff Show More