Compare commits
171 Commits
dceaf82934
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a8ff2ba1fe | |||
|
|
e52ffce48a | ||
|
|
18073afb52 | ||
|
|
efc3af71d8 | ||
|
|
cc3df36631 | ||
|
|
d63b3441b6 | ||
|
|
035dbfeb6a | ||
|
|
c5fa6516e8 | ||
|
|
481de7301d | ||
|
|
3f86929654 | ||
|
|
d124a2a60c | ||
|
|
bb3b9c7456 | ||
|
|
eee43f632c | ||
|
|
67244223ec | ||
|
|
181e8e9c2f | ||
|
|
024a41f125 | ||
|
|
29d21204b1 | ||
|
|
727207d949 | ||
|
|
0771b3d163 | ||
|
|
dabad19b03 | ||
|
|
5cb9354f9f | ||
|
|
74a7e71969 | ||
|
|
ffc0d83c60 | ||
|
|
af9bce3967 | ||
|
|
a97097020d | ||
|
|
4879046f44 | ||
|
|
c5aecb4130 | ||
|
|
d93b48916d | ||
|
|
ea038b5ed2 | ||
|
|
f974f88ab9 | ||
|
|
dd45528f58 | ||
|
|
aac297d241 | ||
|
|
ae10a16481 | ||
|
|
7bbb3d6d6b | ||
|
|
cc6395f75b | ||
|
|
3362e2e1b8 | ||
|
|
4ba2a556f7 | ||
|
|
0b78ad2e74 | ||
|
|
beec0c0a59 | ||
|
|
a49d6ebfc4 | ||
|
|
0bb60dbc98 | ||
|
|
a2967ec594 | ||
|
|
311e44aac2 | ||
|
|
5352fa1b08 | ||
|
|
b14c9fb314 | ||
|
|
b651753125 | ||
|
|
822cc4e1d5 | ||
|
|
9f246be2b8 | ||
|
|
d8121b549b | ||
|
|
dd23f013ad | ||
|
|
58c613af3a | ||
|
|
bac1ae3986 | ||
|
|
01aad6977a | ||
|
|
df8eba1233 | ||
|
|
942e8d60a5 | ||
|
|
32e1acc5ef | ||
|
|
79dab07243 | ||
|
|
bee91dd01f | ||
|
|
9267961909 | ||
|
|
b3eea176d5 | ||
|
|
f4fad02e1c | ||
|
|
641334a2e5 | ||
|
|
cafef04a75 | ||
|
|
e9bb2211b5 | ||
|
|
8c31e23a94 | ||
|
|
03f952628a | ||
|
|
b462623f78 | ||
|
|
14d9b7388e | ||
|
|
a4ded94ae2 | ||
|
|
a1a042ffcd | ||
|
|
1e914a4c37 | ||
|
|
69d95113f0 | ||
|
|
d117aa9c18 | ||
|
|
0a7527ef79 | ||
|
|
c98c31dc24 | ||
|
|
010bf2be1e | ||
|
|
e51236af91 | ||
|
|
a5d71bcc05 | ||
|
|
de3d61b11d | ||
|
|
25abdbffe4 | ||
|
|
e848be0a90 | ||
|
|
5998acabdd | ||
|
|
e83942413c | ||
|
|
209c6f03da | ||
|
|
39e2cd18e2 | ||
|
|
3982a167ac | ||
|
|
9d36e8dc88 | ||
|
|
02d933d737 | ||
|
|
b509d8403d | ||
|
|
cd8991e821 | ||
|
|
5523bf7774 | ||
|
|
485929509c | ||
|
|
824663bcaa | ||
|
|
98fa717f7d | ||
|
|
eafa3941b4 | ||
|
|
8f865c97a7 | ||
|
|
fef46f2f96 | ||
|
|
59670f7c74 | ||
|
|
ec6e9022dd | ||
|
|
524dd52cf5 | ||
|
|
c3a4fdf204 | ||
|
|
5b43a9b783 | ||
|
|
37c7159801 | ||
|
|
8253ec0659 | ||
|
|
43ae7f39e1 | ||
|
|
5ec1b6d55b | ||
|
|
18c280cbb0 | ||
|
|
9823194125 | ||
|
|
afc0c81c2d | ||
|
|
7b99c66348 | ||
|
|
f35ab6cbe7 | ||
|
|
9dcdb420fc | ||
|
|
d2cfe63132 | ||
|
|
c2b6adcf64 | ||
|
|
d962807c4f | ||
|
|
d6628db325 | ||
|
|
17755b97e0 | ||
|
|
22b1ad4f2b | ||
|
|
128945f0e9 | ||
|
|
c39e89b266 | ||
|
|
32a033869b | ||
|
|
773e2e23c4 | ||
|
|
a28bcff4f4 | ||
|
|
f68b54a9ef | ||
|
|
23c4a717ba | ||
|
|
c61028f880 | ||
|
|
c663c657e2 | ||
|
|
3cac5bd45e | ||
|
|
7b727cec53 | ||
|
|
4b1d8ea36d | ||
|
|
d072238fe8 | ||
|
|
6f13903644 | ||
|
|
a283f0ef21 | ||
|
|
27da7e18e6 | ||
|
|
2c27a7e58d | ||
|
|
49e7d614ce | ||
|
|
af7459e4dd | ||
|
|
8e71f629d4 | ||
|
|
bbb16fb2a6 | ||
|
|
4d8aea38fd | ||
|
|
92d3307f55 | ||
|
|
476aac2b08 | ||
|
|
4196c119ea | ||
|
|
345c58542e | ||
|
|
85fe50aca0 | ||
|
|
bf82e7d628 | ||
|
|
c90f286b12 | ||
|
|
1a59dacf87 | ||
|
|
1d983c8bfa | ||
|
|
e908cfce63 | ||
|
|
8a90bb69be | ||
|
|
02641e1333 | ||
|
|
78707c11a7 | ||
|
|
5352a78e96 | ||
|
|
7b5dbb2683 | ||
|
|
e8a1bba471 | ||
|
|
35640e38cc | ||
|
|
9f8cc104c7 | ||
|
|
798fa2f48e | ||
|
|
0bd3fe5598 | ||
|
|
773f5db454 | ||
|
|
622dbe4dcb | ||
|
|
90c2588b9b | ||
|
|
061e797544 | ||
|
|
3e063cbf88 | ||
|
|
1925469311 | ||
|
|
7b011270d6 | ||
|
|
41cb3604df | ||
|
|
389ee2453c | ||
|
|
adba5c95c9 | ||
|
|
c8cee4a733 |
3
.obsidian/app.json
vendored
Normal file
3
.obsidian/app.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"promptDelete": false
|
||||
}
|
||||
1
.obsidian/appearance.json
vendored
Normal file
1
.obsidian/appearance.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
3
.obsidian/community-plugins.json
vendored
Normal file
3
.obsidian/community-plugins.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"obsidian-git"
|
||||
]
|
||||
33
.obsidian/core-plugins.json
vendored
Normal file
33
.obsidian/core-plugins.json
vendored
Normal 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
22
.obsidian/graph.json
vendored
Normal 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
|
||||
}
|
||||
65
.obsidian/plugins/obsidian-git/data.json
vendored
Normal file
65
.obsidian/plugins/obsidian-git/data.json
vendored
Normal 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
413
.obsidian/plugins/obsidian-git/main.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
.obsidian/plugins/obsidian-git/manifest.json
vendored
Normal file
10
.obsidian/plugins/obsidian-git/manifest.json
vendored
Normal 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"
|
||||
}
|
||||
705
.obsidian/plugins/obsidian-git/styles.css
vendored
Normal file
705
.obsidian/plugins/obsidian-git/styles.css
vendored
Normal 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;
|
||||
}
|
||||
@@ -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]].
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (0000–0019); 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 |
|
||||
|
||||
@@ -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 (0000–0017)
|
||||
│ └── 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: 0000–0017 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 (0000–0017), 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]]
|
||||
|
||||
@@ -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 **3–6 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 60–120 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)?
|
||||
```
|
||||
@@ -2,29 +2,97 @@
|
||||
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 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.
|
||||
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)
|
||||
- Accept payment **intents** from the backend (`POST /intents`)
|
||||
- Watch the relevant chain for matching on-chain transfers
|
||||
- Track confirmation depth (EVM) or rely on finality from the chain API (Tron, TON)
|
||||
- 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
|
||||
- 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. Component map
|
||||
## 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 20–500) 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
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
@@ -41,91 +109,42 @@ AMN Pay Scanner is a standalone Go microservice that watches on-chain payment ev
|
||||
│ │ └── 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 │
|
||||
│ ├── webhook retry loop main.go + webhook.go │
|
||||
│ └── BalanceWatchScheduler balance_watch.go │
|
||||
│ │
|
||||
│ reference.go — payment reference / topic hash math │
|
||||
│ webhook.go — delivery, HMAC signing, retry │
|
||||
│ 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).
|
||||
|
||||
---
|
||||
|
||||
## 3. Chain worker model
|
||||
## 5. Backend integration points
|
||||
|
||||
All three chain types implement the `Worker` interface:
|
||||
|
||||
```go
|
||||
type Worker interface {
|
||||
start()
|
||||
stop()
|
||||
getHead(ctx context.Context) (int64, error)
|
||||
}
|
||||
```
|
||||
|
||||
One worker goroutine is spawned per chain marked `"verified": true` in `supported-chains.json`. Workers are selected by `chainType`:
|
||||
|
||||
| chainType | Worker struct | API used |
|
||||
| Direction | Endpoint | When |
|
||||
|---|---|---|
|
||||
| `evm` (default) | `ChainWorker` | JSON-RPC 2.0 (`eth_getLogs`, `eth_blockNumber`) |
|
||||
| `tron` | `TronChainWorker` | TronGrid REST (`/v1/contracts/{contract}/events`) |
|
||||
| `ton` | `TonChainWorker` | TonCenter v3 REST (`/jetton/transfers`) |
|
||||
| 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 |
|
||||
|
||||
Workers poll on `POLL_INTERVAL_SEC` (default 15 s). On first run, each worker starts scanning from the current chain head minus a small buffer (10 blocks for EVM, 24 h for Tron/TON).
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 4. EVM scanning detail
|
||||
|
||||
```
|
||||
for each tick:
|
||||
head = eth_blockNumber
|
||||
from = max(checkpoint − ReorgBuffer(), 0)
|
||||
chunks = split [from..head] into 2000-block ranges
|
||||
for each chunk:
|
||||
logs = eth_getLogs(proxyAddress, EventTopic, from, to)
|
||||
for each log:
|
||||
topicRef = Topics[1] (keccak256 of paymentReference — pre-indexed)
|
||||
intent = DB lookup by topicRef WHERE status='pending'
|
||||
validate(log.Data, intent) ← token + destination + amount check
|
||||
confirmIntentPending() ← status → 'confirming'
|
||||
saveCheckpoint(to)
|
||||
checkConfirmations():
|
||||
for each confirming intent:
|
||||
confs = head - blockNumber + 1
|
||||
if confs >= required: finalizeIntent() + deliverWebhook()
|
||||
```
|
||||
|
||||
**Reorg protection**: `ReorgBuffer()` re-scans `3 × confirmationThreshold` blocks before the checkpoint (clamped 20–500). This catches any log that appeared in a block that was later reorganised off the canonical chain.
|
||||
|
||||
**Event signature**: `TransferWithReferenceAndFee` keccak256 = `0xbc8b749850bdc0c5e4a49d50f87ec40dca2acdb70a84e7c6823ccdc7b3b3d4b3`
|
||||
|
||||
---
|
||||
|
||||
## 5. Tron scanning detail
|
||||
|
||||
TronGrid does not expose a fee-proxy contract. Each intent is assigned a unique HD-derived destination address. The scanner watches TRC20 `Transfer` events on the USDT contract and matches by `to` address.
|
||||
|
||||
- Checkpoint: block timestamp in milliseconds (`last_scanned_block` column)
|
||||
- TronGrid addresses arrive as `41xxxx` hex (21 bytes); normalized to `0x` (20 bytes EVM style)
|
||||
- Tron transactions reported by TronGrid are already confirmed; status goes directly to `confirmed` (no multi-block wait)
|
||||
- Pagination follows `meta.links.next` until empty
|
||||
|
||||
---
|
||||
|
||||
## 6. TON scanning detail
|
||||
|
||||
TON uses TonCenter v3. Per-intent polling: for each pending TON intent, a separate HTTP call fetches incoming Jetton transfers to that destination since the checkpoint.
|
||||
|
||||
- Checkpoint: Unix timestamp in seconds
|
||||
- TON addresses are base64url (`EQ…`/`UQ…`) — case-sensitive, never lowercased
|
||||
- `proxyAddress` = USDT Jetton master address (`EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs`)
|
||||
- TonCenter returns only finalized transactions; status goes directly to `confirmed`
|
||||
- Lag is reported in seconds, not blocks
|
||||
- Known scaling limitation: O(pending intents) API calls per scan cycle
|
||||
|
||||
---
|
||||
|
||||
## 7. Intent lifecycle
|
||||
## 6. Intent lifecycle
|
||||
|
||||
```
|
||||
pending ──(tx seen)──► confirming ──(enough blocks)──► confirmed ──(webhook ok)──► [done]
|
||||
@@ -134,66 +153,29 @@ pending ──(tx seen)──► confirming ──(enough blocks)──► confi
|
||||
└───────────────────────┴──────────► expired webhook_failed
|
||||
```
|
||||
|
||||
- **Tron / TON** skip `confirming` and jump directly to `confirmed`.
|
||||
- **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`.
|
||||
- **Startup reconciliation**: on startup, `confirmed` intents with `webhook_delivered_at IS NULL` and created in the last 7 days have their webhook re-delivered. This recovers from a crash between `finalizeIntent` and `deliverWebhook`.
|
||||
- Retry schedule on first delivery attempt: 5 s → 30 s → 2 min → 10 min → 1 h → `webhook_failed`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Payment reference math (EVM)
|
||||
|
||||
```
|
||||
paymentReference = last8Bytes(keccak256(lower(intentId + salt + destination)))
|
||||
topicRef (index) = keccak256(paymentReferenceBytes)
|
||||
```
|
||||
|
||||
The ERC20FeeProxy indexes `paymentReference` so `Topics[1]` in the log is `topicRef`, not the raw reference. The DB stores `topic_ref` pre-computed per intent so the scan loop is a single indexed SQL lookup instead of O(n) hashing.
|
||||
|
||||
---
|
||||
|
||||
## 9. Database schema (SQLite WAL)
|
||||
|
||||
Two tables:
|
||||
|
||||
**`intents`** — one row per payment intent
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `intent_id` | TEXT PK | caller-supplied UUID |
|
||||
| `chain_id` | INTEGER | numeric chain ID |
|
||||
| `chain_type` | TEXT | `evm` / `tron` / `ton` |
|
||||
| `token_address` | TEXT | EVM/Tron: lowercase 0x hex; TON: base64url |
|
||||
| `destination` | TEXT | receiving address |
|
||||
| `amount` | TEXT | base-10 wei / token smallest unit |
|
||||
| `payment_reference` | TEXT | 8-byte hex (EVM only) |
|
||||
| `topic_ref` | TEXT | keccak256 of paymentReference (EVM index) |
|
||||
| `status` | TEXT | `pending` / `confirming` / `confirmed` / `expired` / `webhook_failed` |
|
||||
| `callback_url` | TEXT | backend webhook endpoint |
|
||||
| `callback_secret` | TEXT | HMAC key (not returned in GET) |
|
||||
| `confirmations_required` | INTEGER | from chain config or caller override |
|
||||
| `tx_hash` | TEXT NULL | transaction hash once seen |
|
||||
| `log_index` | INTEGER NULL | log position within tx (EVM) |
|
||||
| `block_number` | INTEGER NULL | block / timestamp when seen |
|
||||
| `confirmations` | INTEGER | current confirmation depth |
|
||||
| `salt` | TEXT | 32-byte random hex for reference derivation |
|
||||
| `webhook_delivered_at` | TEXT NULL | RFC3339 timestamp of successful delivery |
|
||||
| `created_at` / `updated_at` | DATETIME | |
|
||||
|
||||
Unique index on `(tx_hash, log_index)` prevents duplicate intent confirmation.
|
||||
|
||||
**`checkpoints`** — one row per chain, tracks scan progress
|
||||
|
||||
| Column | Notes |
|
||||
|---|---|
|
||||
| `chain_id` | PK |
|
||||
| `last_scanned_block` | block number (EVM), ms timestamp (Tron), unix seconds (TON) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Security model
|
||||
## 7. Security model
|
||||
|
||||
- All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>` (constant-time compare).
|
||||
- If `SCANNER_API_KEY` is unset the server logs a warning and allows all requests — intended for local dev only.
|
||||
- Webhooks are signed with HMAC-SHA256: `X-AMN-Signature: hex(hmac(body, callbackSecret))`.
|
||||
- The `callbackSecret` is stored in the DB but excluded from all JSON responses (`json:"-"` tag).
|
||||
- Request bodies are limited to 64 KB.
|
||||
- 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`. |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]].
|
||||
|
||||
@@ -1,44 +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 (0000–0017).
|
||||
|
||||
> [!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 0000–0017. 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.
|
||||
- [[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`.
|
||||
### 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
|
||||
|
||||
@@ -56,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"
|
||||
@@ -71,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"
|
||||
@@ -88,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
|
||||
|
||||
@@ -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]].
|
||||
|
||||
120
02 - Data Models/Money-Core Migration Workflow — Audit Report.md
Normal file
120
02 - Data Models/Money-Core Migration Workflow — Audit Report.md
Normal 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 34–36, 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 54–55, 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 216–217), 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 1–7, 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 1–4 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 167–170.
|
||||
- **Layered safety net.** A `BULK`(sonnet)/`BRAIN`(opus) model split that reserves Opus for the commit gate and the adversarial fund-safety SelfAudit (lines 16–20, 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 1–7 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 216–217). 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 222–248). **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`.
|
||||
4875
02 - Data Models/MongoDB to PostgreSQL Migration Guide.md
Normal file
4875
02 - Data Models/MongoDB to PostgreSQL Migration Guide.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 0–5) is the recommended stopping point — ≈16–28 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 (2–5 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 (1–2 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 (2–3 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 (3–5 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 (6–10 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 (2–4 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 0–5 + cross-cutting)** | **~16–28** | Recommended stopping point; captures ~90% of value (ACID money + relational integrity). |
|
||||
| Full — all 23 collections | ~23–40 | Extra 7–12+ 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.
|
||||
@@ -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]].
|
||||
|
||||
203
02 - Data Models/Postgres Runtime Cutover Status.md
Normal file
203
02 - Data Models/Postgres Runtime Cutover Status.md
Normal 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 (0000–0019) |
|
||||
| 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 (0000–0019).
|
||||
|
||||
### 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.38–2.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.48–2.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.51–2.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.57–2.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.62–2.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.66–2.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.71–2.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]]
|
||||
@@ -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-30 — `budget.currency` locked to USDT; `categoryId` added to `IRequestTableItem`
|
||||
> **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 | `USDT` | enum: `USDT` (escrow MVP — USD/EUR/IRR removed in commit 3aaa2fe) | — | 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 (0000–0019), 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 1–5 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]].
|
||||
|
||||
@@ -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
|
||||
|
||||
130
02 - Data Models/ScannerBalanceWatch.md
Normal file
130
02 - Data Models/ScannerBalanceWatch.md
Normal 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 |
|
||||
| `24h–48h` | 10 min |
|
||||
| `48h–72h` | 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
|
||||
@@ -54,11 +54,11 @@ CREATE TABLE intents (
|
||||
| `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 | Number of blocks required before confirmation (EVM). Defaults to chain config |
|
||||
| `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. Incremented each scan cycle for `confirming` intents |
|
||||
| `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 |
|
||||
@@ -111,4 +111,5 @@ A backfill pass recomputes `topic_ref` for existing EVM intents that had it as N
|
||||
- [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
|
||||
|
||||
@@ -1,58 +1,110 @@
|
||||
---
|
||||
title: SellerOffer
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, postgres]
|
||||
aliases: [Seller Offer, Bid, ISellerOffer]
|
||||
---
|
||||
|
||||
# SellerOffer
|
||||
|
||||
> **Last updated:** 2026-05-30 — added AML fields (`requireAmlCheck`, `amlBlockOnFailure`)
|
||||
> **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. |
|
||||
| `requireAmlCheck` | Boolean | no | — | — | — | If true, AML screening must pass before the offer is presented to the buyer. |
|
||||
| `amlBlockOnFailure` | Boolean | no | — | — | — | If true and AML screening fails, the offer is blocked. Otherwise it is flagged for manual review. |
|
||||
| `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
|
||||
|
||||
@@ -78,8 +130,10 @@ The frontend exposes this via the `withdrawOffer(offerId)` action in `src/action
|
||||
|
||||
## 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
|
||||
|
||||
@@ -96,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]].
|
||||
|
||||
@@ -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
289
02 - Data Models/Tenant.md
Normal 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]].
|
||||
@@ -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, 0000–0019, 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` / `resolver` | yes | Authorisation tier. `resolver` was added in commit `fce8a19` — can view and resolve disputes, and bypass chat membership checks, but has no other admin privileges. |
|
||||
| `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.
|
||||
|
||||
@@ -36,7 +36,7 @@ The base port is set via `PORT` env var; in `development` it defaults to `5001`.
|
||||
|
||||
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 database and Redis connectivity status, plus the version string. Used by Gatus monitoring.
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ There is no single `/api/admin` namespace — admin-only endpoints are scattered
|
||||
|
||||
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 |
|
||||
| --- | --- |
|
||||
@@ -39,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.50–51):** 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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: [api, marketplace, reference]
|
||||
|
||||
# Marketplace 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-31 — request-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,7 +71,7 @@ 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: "USDT" | "USDC" }; // restricted to escrow-compatible stablecoins (commit d52feb7)
|
||||
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" | "USDT" | "USDC" };
|
||||
urgency?: "low" | "medium" | "high" | "urgent";
|
||||
deliveryInfo?: {
|
||||
deliveryType: "physical" | "online";
|
||||
@@ -343,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
|
||||
|
||||
@@ -399,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
|
||||
@@ -410,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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: [api, payment, reference, request-network, escrow]
|
||||
|
||||
# Payment API
|
||||
|
||||
> **Last updated:** 2026-05-30 — AMN Pay Scanner integration, on-demand RN reconcile in GET /payment/:id, pay-in route renamed, reload/probe routes now implemented
|
||||
> **Last updated:** 2026-05-31 — Postgres 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:
|
||||
|
||||
@@ -22,6 +22,9 @@ The payment surface is split across provider-neutral payment routers, Request Ne
|
||||
|
||||
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
|
||||
@@ -156,7 +159,7 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
|
||||
|
||||
### 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. This is the **current active route** (mounted at `/api/payment/request-network/pay-in`). The `/intents` path listed in older docs is an alias; use `pay-in` for new integrations.
|
||||
**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
|
||||
@@ -172,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.
|
||||
@@ -182,6 +232,7 @@ 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.
|
||||
|
||||
> [!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.
|
||||
@@ -194,9 +245,9 @@ AMN Pay Scanner is a custom in-house blockchain scanner that replaces the hosted
|
||||
|
||||
**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, transactionHash?, chainId?, ... }` — scanner-specific envelope
|
||||
**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.
|
||||
**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`.
|
||||
@@ -564,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" }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -573,14 +624,14 @@ 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 }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -637,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" }
|
||||
|
||||
@@ -10,6 +10,8 @@ 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`
|
||||
|
||||
---
|
||||
@@ -41,7 +43,7 @@ Register a new payment intent. The scanner will watch the specified chain for a
|
||||
| `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 | Override chain default confirmation count (0 = use chain default) |
|
||||
| `confirmations` | integer | no | Requested confirmation count. The scanner raises it to the chain acceptance floor if lower. |
|
||||
|
||||
**Example request:**
|
||||
|
||||
@@ -52,9 +54,9 @@ Register a new payment intent. The scanner will watch the specified chain for a
|
||||
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
|
||||
"destination": "0xAbCd1234...",
|
||||
"amount": "10000000000000000000",
|
||||
"callbackUrl": "https://api.amn.gg/api/payment/scanner-callback",
|
||||
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
|
||||
"callbackSecret": "abc123...",
|
||||
"confirmations": 12
|
||||
"confirmations": 200
|
||||
}
|
||||
```
|
||||
|
||||
@@ -79,6 +81,8 @@ Register a new payment intent. The scanner will watch the specified chain for a
|
||||
}
|
||||
```
|
||||
|
||||
**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:**
|
||||
@@ -108,6 +112,139 @@ Fetch the current state of a payment intent.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -124,7 +261,8 @@ Returns scan progress for all verified chains.
|
||||
"lastScannedBlock": 39000000,
|
||||
"chainHead": 39000015,
|
||||
"lag": 15,
|
||||
"pendingIntents": 3
|
||||
"pendingIntents": 3,
|
||||
"activeBalanceWatches": 2
|
||||
},
|
||||
{
|
||||
"chainId": 728126428,
|
||||
@@ -133,7 +271,8 @@ Returns scan progress for all verified chains.
|
||||
"lastScannedBlock": 1748500000000,
|
||||
"chainHead": 1748500015000,
|
||||
"lag": 15000,
|
||||
"pendingIntents": 1
|
||||
"pendingIntents": 1,
|
||||
"activeBalanceWatches": 0
|
||||
},
|
||||
{
|
||||
"chainId": 1100,
|
||||
@@ -142,7 +281,8 @@ Returns scan progress for all verified chains.
|
||||
"lastScannedBlock": 1748500000,
|
||||
"chainHead": 1748500015,
|
||||
"lag": 15,
|
||||
"pendingIntents": 0
|
||||
"pendingIntents": 0,
|
||||
"activeBalanceWatches": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -201,6 +341,7 @@ When an intent is confirmed the scanner POSTs to `callbackUrl`:
|
||||
"paymentReference": "0x1a2b3c4d5e6f7a8b",
|
||||
"txHash": "0xdeadbeef...",
|
||||
"blockNumber": 39000010,
|
||||
"confirmations": 200,
|
||||
"amount": "10000000000000000000",
|
||||
"token": "0x55d398326f99059ff775485246999027b3197955",
|
||||
"chainId": 56,
|
||||
@@ -208,6 +349,8 @@ When an intent is confirmed the scanner POSTs to `callbackUrl`:
|
||||
}
|
||||
```
|
||||
|
||||
`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:
|
||||
@@ -217,6 +360,42 @@ const expected = createHmac('sha256', callbackSecret).update(rawBody).digest('he
|
||||
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
|
||||
@@ -234,7 +413,7 @@ if (!timingSafeEqual(Buffer.from(received), Buffer.from(expected))) reject();
|
||||
"paymentReference": "0x1a2b3c4d",
|
||||
"topicRef": "0xdeadbeef...",
|
||||
"status": "pending | confirming | confirmed | expired | webhook_failed",
|
||||
"confirmationsRequired": 12,
|
||||
"confirmationsRequired": 200,
|
||||
"txHash": null,
|
||||
"logIndex": null,
|
||||
"blockNumber": null,
|
||||
@@ -247,3 +426,28 @@ if (!timingSafeEqual(Buffer.from(received), Buffer.from(expected))) reject();
|
||||
```
|
||||
|
||||
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"
|
||||
}
|
||||
```
|
||||
|
||||
459
03 - API Reference/Tenant API.md
Normal file
459
03 - API Reference/Tenant API.md
Normal 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]].
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ 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)
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
@@ -57,14 +59,34 @@ Authorization: Bearer <SCANNER_API_KEY>
|
||||
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
|
||||
"destination": "0xSellerWalletAddress",
|
||||
"amount": "10000000000000000000",
|
||||
"callbackUrl": "https://api.amn.gg/api/payment/scanner-callback",
|
||||
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
|
||||
"callbackSecret": "<per-intent HMAC secret stored in payment doc>",
|
||||
"confirmations": 12
|
||||
"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:
|
||||
@@ -81,6 +103,8 @@ The `checkoutBlock` contains everything the frontend needs to build the `ERC20Fe
|
||||
|
||||
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.
|
||||
@@ -91,7 +115,7 @@ The buyer signs and broadcasts the transaction using their wallet. The scanner i
|
||||
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`
|
||||
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)
|
||||
@@ -116,13 +140,14 @@ The scanner POSTs to `callbackUrl` with:
|
||||
"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.
|
||||
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
|
||||
|
||||
@@ -130,7 +155,85 @@ Backend returns a 2xx response. Scanner records `webhook_delivered_at` and the i
|
||||
|
||||
---
|
||||
|
||||
## 3. Failure paths
|
||||
## 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 |
|
||||
| 24–48h | 10 min |
|
||||
| 48–72h | 20 min |
|
||||
| 72h–7d | 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
|
||||
|
||||
@@ -163,9 +266,18 @@ Transfers where the on-chain amount is less than `intent.Amount` are silently sk
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Key differences from Request Network integration
|
||||
## 5. Key differences from Request Network integration
|
||||
|
||||
| Dimension | Request Network | AMN Pay Scanner |
|
||||
|---|---|---|
|
||||
@@ -177,3 +289,4 @@ The EVM log decoder validates all three fields (token, destination, amount). Mis
|
||||
| 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 |
|
||||
|
||||
@@ -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-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
> **Last updated:** 2026-05-31 — template 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.
|
||||
|
||||
788
04 - Flows/Telegram Mini App.md
Normal file
788
04 - Flows/Telegram Mini App.md
Normal 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
|
||||
215
04 - Flows/Tenant Storefront Flow.md
Normal file
215
04 - Flows/Tenant Storefront Flow.md
Normal 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]].
|
||||
BIN
05 - Design System/Logos/amn-telegram-avatar.png
Normal file
BIN
05 - Design System/Logos/amn-telegram-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
34
05 - Design System/Logos/amn-telegram-avatar.svg
Normal file
34
05 - Design System/Logos/amn-telegram-avatar.svg
Normal 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 |
BIN
05 - Design System/Logos/logo-full.png
Normal file
BIN
05 - Design System/Logos/logo-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
37
05 - Design System/Logos/logo-full.svg
Normal file
37
05 - Design System/Logos/logo-full.svg
Normal 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 |
BIN
05 - Design System/Logos/logo-single.png
Normal file
BIN
05 - Design System/Logos/logo-single.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
28
05 - Design System/Logos/logo-single.svg
Normal file
28
05 - Design System/Logos/logo-single.svg
Normal 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 |
@@ -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).
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 (0000–0019) 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=
|
||||
```
|
||||
|
||||
@@ -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 (0000–0019) 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 (0000–0019)
|
||||
│ │ └── 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 (0000–0019)
|
||||
- `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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (0000–0019), 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 (0000–0019) 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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
454
08 - Operations/MONGODB_REMOVAL_HANDOFF_2026-06-02.md
Normal file
454
08 - Operations/MONGODB_REMOVAL_HANDOFF_2026-06-02.md
Normal 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`.
|
||||
@@ -14,15 +14,48 @@ What's instrumented today and what to watch. Today's stack is intentionally lean
|
||||
Two paths are registered (both are public, rate-limited, not auth-gated):
|
||||
|
||||
- `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 and Redis, collects memory/uptime stats, and returns a structured report. Returns `503` when `report.status === 'down'`.
|
||||
- `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`.
|
||||
|
||||
`GET /api/health` response shape (from `healthCheckService`):
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "2.6.xx",
|
||||
"timestamp": "...",
|
||||
"checks": { "mongodb": "ok", "redis": "ok", "uptime": 3600, "memoryMB": 120 }
|
||||
"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 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ All configuration via environment variables. See `.env.example` in the scanner r
|
||||
| `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) |
|
||||
@@ -92,6 +94,7 @@ curl -H "Authorization: Bearer $SCANNER_API_KEY" \
|
||||
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
|
||||
@@ -109,6 +112,11 @@ The scanner uses Go's `log/slog` structured logger with level prefixes. Key log
|
||||
| `[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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -123,7 +131,7 @@ Edit `supported-chains.json`. Fields:
|
||||
| `rpcUrl` | Primary RPC endpoint |
|
||||
| `publicRpcUrl` | Fallback RPC (EVM only) |
|
||||
| `proxyAddress` | ERC20FeeProxy address (EVM); USDT contract (Tron); USDT Jetton master (TON) |
|
||||
| `confirmationThreshold` | Blocks required (EVM); ignored for Tron/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]
|
||||
@@ -143,6 +151,14 @@ Edit `tokens.json`. Each entry:
|
||||
|
||||
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
|
||||
@@ -175,6 +191,15 @@ 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;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -206,6 +231,29 @@ SELECT status, count(*) FROM intents GROUP BY status;
|
||||
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
76
09 - Audits/C1-Secrets-Rotation-Checklist-2026-06-10.md
Normal file
76
09 - Audits/C1-Secrets-Rotation-Checklist-2026-06-10.md
Normal 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
|
||||
30
09 - Audits/C2-DrizzleChatRepo-Partial-Fix-Report.md
Normal file
30
09 - Audits/C2-DrizzleChatRepo-Partial-Fix-Report.md
Normal 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.
|
||||
457
09 - Audits/Comprehensive Workspace Audit - 2026-06-10.md
Normal file
457
09 - Audits/Comprehensive Workspace Audit - 2026-06-10.md
Normal 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.
|
||||
|
||||
272
09 - Audits/DB Migration Audit Report (2026-06-02).md
Normal file
272
09 - Audits/DB Migration Audit Report (2026-06-02).md
Normal 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-13–17)** — 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 |
|
||||
1180
09 - Audits/DB Query & Schema Audit - 2026-06-06.md
Normal file
1180
09 - Audits/DB Query & Schema Audit - 2026-06-06.md
Normal file
File diff suppressed because it is too large
Load Diff
221
09 - Audits/Mistral-Outsource-Package-2026-06-10.md
Normal file
221
09 - Audits/Mistral-Outsource-Package-2026-06-10.md
Normal 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 128–131 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 44–56 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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
64
09 - Audits/Multi-Shop Branch Project Scan - 2026-06-10.md
Normal file
64
09 - Audits/Multi-Shop Branch Project Scan - 2026-06-10.md
Normal 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]].
|
||||
215
09 - Audits/Security DB Performance Logic Audit - 2026-06-07.md
Normal file
215
09 - Audits/Security DB Performance Logic Audit - 2026-06-07.md
Normal 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. |
|
||||
88
09 - Audits/Workflow-Remediation-Plan-2026-06-10.md
Normal file
88
09 - Audits/Workflow-Remediation-Plan-2026-06-10.md
Normal 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
55
10 - Services/README.md
Normal 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
|
||||
306
10 - Services/amanat-assist.md
Normal file
306
10 - Services/amanat-assist.md
Normal 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
|
||||
425
10 - Services/backend.md
Normal file
425
10 - Services/backend.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Backend Service — amn-backend
|
||||
|
||||
## 1. Overview
|
||||
|
||||
`amn-backend` is the Express 5 / TypeScript API server that powers the Amanat escrow marketplace. It is the single authoritative backend for the dev.amn.gg (escrow-dev) and multi.amn.gg (escrow-multi) stacks.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Current version | **2.11.43** |
|
||||
| Status | Production — receiving active feature development |
|
||||
| Runtime | Node ≥ 22 |
|
||||
| Framework | Express 5 (TypeScript) |
|
||||
| Primary DB | PostgreSQL via Drizzle ORM |
|
||||
| Mongo status | **Removed** — Mongoose was fully stripped; PostgreSQL is the sole persistence layer |
|
||||
| Repo | `git@git.tbs.amn.gg:escrow/backend.git` |
|
||||
| Dev stack host | `root@89.58.32.32` — Arcane project `escrow-dev` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Tech Stack
|
||||
|
||||
| Layer | Technology | Notes |
|
||||
|---|---|---|
|
||||
| HTTP framework | Express 5 | Async error propagation built in |
|
||||
| Language | TypeScript (strict) | tsc gate on every CI push |
|
||||
| Runtime | Node ≥ 22 | Also used for CI typecheck step |
|
||||
| Database | PostgreSQL 15 via Drizzle ORM (`drizzle-orm ^0.45.2`, `pg ^8.21.0`) | Single source of truth; 19+ migrations landed |
|
||||
| Auth | JWT (access + refresh) + WebAuthn/Passkey + Google OAuth | `JWT_SECRET`, `REFRESH_TOKEN_EXPIRES_IN` |
|
||||
| Session / Mini App | Telegram Mini App `initData` verification | `TELEGRAM_WEBAPP_URL` |
|
||||
| Realtime | Socket.IO with Redis adapter (`@socket.io/redis-adapter`) | Room-scoped events |
|
||||
| Cache / Pub-Sub | Redis | `REDIS_URI` |
|
||||
| Rate limiting | express-rate-limit (in-memory; Redis adapter planned) | Auth 10/15 min, payment 30/15 min, AI 20/15 min, global 100/15 min |
|
||||
| Security headers | Helmet | CSP, X-Frame-Options, etc. |
|
||||
| File uploads | Multer | MIME validation, `UPLOAD_PATH` |
|
||||
| Email | Nodemailer (SMTP) + Resend | `SMTP_*` / `RESEND_API_KEY` |
|
||||
| Price oracle | Chainlink + OffchainFX | Depeg protection, `ORACLE_MAX_STALENESS_S` |
|
||||
| AML | Chainalysis + OFAC SDN | `CHAINALYSIS_API_KEY`, `OFAC_SDN_URL` |
|
||||
| AI | OpenAI | Descriptions, moderation |
|
||||
| CI | Woodpecker CI | `.woodpecker/*.yml` |
|
||||
| Process model | Node cluster | `CLUSTER_WORKERS` workers + master |
|
||||
|
||||
---
|
||||
|
||||
## 3. Directory Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── app.ts # Express bootstrap: middleware chain, route registration, server creation
|
||||
│ ├── cluster.ts # Node cluster master — forks CLUSTER_WORKERS child processes
|
||||
│ ├── controllers/ # Thin HTTP handlers that delegate to services
|
||||
│ ├── db/ # Drizzle/Postgres layer
|
||||
│ │ ├── schema/ # Per-table Drizzle schema files + index.ts barrel
|
||||
│ │ ├── migrations/ # Numbered SQL migration files (0000–0018+)
|
||||
│ │ └── repositories/ # DrizzleXxxRepo classes + factory.ts
|
||||
│ ├── infrastructure/
|
||||
│ │ └── socket/ # Socket.IO server init, room helpers, emit wrappers
|
||||
│ ├── models/ # Legacy placeholder (Mongoose removed; schemas now in db/schema/)
|
||||
│ ├── routes/ # Standalone Express Router files (dispute, blog, points, amn-scanner webhook)
|
||||
│ ├── scripts/ # CLI utilities — seed:users, seed:categories, tg-notify.cjs (CI)
|
||||
│ ├── seeds/ # Fixture data for local dev (Postgres-capable, idempotent)
|
||||
│ ├── services/ # Domain service modules (see §4)
|
||||
│ ├── shared/
|
||||
│ │ ├── config/index.ts # Typed env-var loader — single import for all config
|
||||
│ │ ├── middleware/ # authMiddleware, roleGuard, errorHandler, validators
|
||||
│ │ ├── types/ # Cross-cutting TypeScript types and enums
|
||||
│ │ └── utils/response-handler.ts # Standard success/error envelope
|
||||
│ └── utils/ # Pure utility functions: logger, currencyUtils, etc.
|
||||
├── .woodpecker/ # CI pipeline definitions (cleanup, development, manual, production)
|
||||
├── Dockerfile.prod # Multi-stage production image
|
||||
└── drizzle.config.ts # Drizzle Kit configuration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Services / Modules
|
||||
|
||||
| Service path | Description |
|
||||
|---|---|
|
||||
| `services/auth/` | JWT issue/refresh, Google OAuth, WebAuthn/Passkey registration and assertion, password reset |
|
||||
| `services/user/` | User profile CRUD, preferences, address book |
|
||||
| `services/marketplace/` | PurchaseRequest, SellerOffer, RequestTemplate, ShopSettings — core escrow marketplace |
|
||||
| `services/payment/` | Payment orchestration: provider adapters, internal ledger (available/held/releasable), reconciliation, safety confirmations |
|
||||
| `services/payment/adapters/` | Provider-neutral adapter interface + registry; plugs in DePay, SHKeeper, amn.scanner, Request Network |
|
||||
| `services/payment/requestNetwork/` | Request Network pay-in creation, in-house checkout rehydration, HMAC-verified webhook |
|
||||
| `services/payment/wallets/` | HD-derived destination addresses, sweep orchestration, gas top-up |
|
||||
| `services/payment/ledger/` | Funds ledger tracking available / held / releasable balances per payment |
|
||||
| `services/payment/safety/` | Transaction Safety Provider: AML screening, min-confirmation thresholds |
|
||||
| `services/blockchain/` | Web3 read helpers: balance checks, tx verification across ETH / BSC / Base / TON |
|
||||
| `services/chat/` | Conversations, messages, attachments |
|
||||
| `services/dispute/` | Dispute lifecycle: open, evidence upload, mediator assignment, release-hold |
|
||||
| `services/notification/` | Template-based notification delivery (in-app + Telegram); mark-as-read |
|
||||
| `services/telegram/` | Bot webhook handler, Mini App `initData` verification, identity link/unlink, seller notifications |
|
||||
| `services/points/` | Loyalty points accrual, levels, referrals, redemption |
|
||||
| `services/blog/` | Blog posts, categories, comments (public read / admin write) |
|
||||
| `services/digital-goods/` | Encrypted digital-goods delivery; key stored under `DIGITAL_GOODS_ENC_KEY` |
|
||||
| `services/file/` | Multer multipart upload, MIME validation, static serving under `/uploads` |
|
||||
| `services/email/` | Nodemailer SMTP + Resend transport, templated emails |
|
||||
| `services/ai/` | OpenAI-backed request description generation and content moderation |
|
||||
| `services/redis/` | Redis client singleton, cache helpers, pub-sub wrappers |
|
||||
| `services/admin/` | Admin-only endpoints: data cleanup (provider-scoped), confirmation thresholds, awaiting-confirmation view |
|
||||
| `services/collection/` | Collection management (multi-seller feature) |
|
||||
| `services/delivery/` | Delivery tracking and status |
|
||||
| `infrastructure/socket/` | Socket.IO server attached to HTTP server; Redis adapter for multi-process pub-sub |
|
||||
|
||||
---
|
||||
|
||||
## 5. API Surface Summary
|
||||
|
||||
All API routes are mounted under `/api/`. The table below lists top-level route groups.
|
||||
|
||||
| Mount path | Service module | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/api/auth` | `services/auth/authRoutes.ts` | mixed | Login, register, refresh, OAuth, passkey |
|
||||
| `/api/user` / `/api/users` | `services/user/userRoutes.ts` | JWT | Profile, preferences |
|
||||
| `/api/address` | `services/user/addressRoutes.ts` | JWT | Address CRUD |
|
||||
| `/api/marketplace/requests` | `services/marketplace/` | JWT | PurchaseRequest CRUD |
|
||||
| `/api/marketplace/offers` | `services/marketplace/` | JWT (seller) | SellerOffer CRUD |
|
||||
| `/api/marketplace/templates` | `services/marketplace/` | JWT (seller) | RequestTemplate CRUD |
|
||||
| `/api/marketplace/categories` | `services/marketplace/` | public read | Category list |
|
||||
| `/api/marketplace/shop-settings` | `services/marketplace/shopSettingsController.ts` | JWT (seller) | Shop profile |
|
||||
| `/api/payment` | `services/payment/paymentControllerRoutes.ts` | JWT | Payment CRUD, status, export |
|
||||
| `/api/payment/decentralized` | `services/payment/decentralizedPaymentRoutes.ts` | mixed | Legacy/manual Web3 save and verify |
|
||||
| `/api/payment/request-network` | `services/payment/requestNetwork/` | mixed + HMAC | RN pay-in, checkout rehydrate, webhook |
|
||||
| `/api/payment/derived-destinations` | `services/payment/wallets/` | JWT (admin) | HD address list, sweeps, cron config |
|
||||
| `/api/telegram` | `services/telegram/telegramRoutes.ts` | mixed | Mini App session, bot webhook, identity link |
|
||||
| `/api/chat` | `services/chat/chatRoutes.ts` | JWT | Conversations, messages |
|
||||
| `/api/notification` | `services/notification/` | JWT | List, mark-as-read |
|
||||
| `/api/disputes` | `services/dispute/` | JWT | Dispute CRUD, evidence, release-hold |
|
||||
| `/api/blog` | `services/blog/blogRoutes.ts` | mixed | Public reads, admin writes |
|
||||
| `/api/points` | `services/points/pointsRoutes.ts` | JWT | Points, levels, referrals |
|
||||
| `/api/ai` | `services/ai/aiRoutes.ts` | JWT | OpenAI helpers |
|
||||
| `/api/files` | `services/file/fileRoutes.ts` | JWT | Multipart upload |
|
||||
| `/api/email` | `services/email/emailRoutes.ts` | JWT | Email dispatch |
|
||||
| `/api/trezor` | `services/trezor/trezorRoutes.ts` | JWT | Trezor hardware-wallet ops |
|
||||
| `/api/admin/cleanup` | `services/admin/dataCleanupRoutes.ts` | JWT (admin) | Data cleanup (must be provider-scoped) |
|
||||
| `/api/admin/rn/networks` | `services/payment/requestNetwork/networkRegistryRoutes.ts` | JWT (admin) | RN chain/token registry |
|
||||
| `/api/admin/settings/confirmation-thresholds` | `services/admin/confirmationThresholdRoutes.ts` | JWT (admin) | Runtime confirmation thresholds |
|
||||
| `/health` | `app.ts` | public | Docker healthcheck; surfaces active Postgres store modes |
|
||||
|
||||
Full per-endpoint details: [[03 - API Reference/API Overview]]
|
||||
|
||||
---
|
||||
|
||||
## 6. Database
|
||||
|
||||
### PostgreSQL (primary)
|
||||
|
||||
- Driver: `pg ^8.21.0` via Drizzle ORM (`drizzle-orm ^0.45.2`)
|
||||
- Connection: `PG_URL` (primary pool); `PG_VITAL_URL` / `PG_NONVITAL_URL` for split-pool configuration
|
||||
- Pool tuning: `PG_POOL_MAX`, `PG_POOL_SIZE`, `PG_NONVITAL_POOL_MAX`
|
||||
- Migrations: numbered SQL files in `src/db/migrations/` (0000–0018+), applied via Drizzle Kit (`npx drizzle-kit migrate`)
|
||||
- Repositories: `DrizzleXxxRepo` classes in `src/db/repositories/`; factory pattern via `factory.ts`
|
||||
- Seeds: idempotent Postgres-capable seed scripts under `src/seeds/`; auto-run on start when `AUTO_SEED_ON_START=true`
|
||||
|
||||
### MongoDB (retired)
|
||||
|
||||
MongoDB and Mongoose have been **fully removed** from the runtime. The `MONGODB_URI` and `MIGRATION_MONGO_URL` env vars exist only for optional data backfill tooling in `src/db/repositories/migration/`. No Mongo connection is established at server boot. `MONGO_CONNECT_MODE=never` is the effective runtime mode.
|
||||
|
||||
The `DATABASE_URL` / `POSTGRES_URL` aliases are accepted for compatibility; prefer `PG_URL`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Auth Model
|
||||
|
||||
### JWT
|
||||
|
||||
- Access tokens signed with `JWT_SECRET`; expiry controlled by `JWT_EXPIRES_IN`
|
||||
- Refresh tokens with `REFRESH_TOKEN_EXPIRES_IN`; stored and rotated server-side
|
||||
- `authMiddleware` in `shared/middleware/` verifies tokens and attaches `req.user`
|
||||
- Role-based access via `roleGuard('admin' | 'seller' | 'buyer' | 'resolver' | 'guard')`
|
||||
|
||||
### WebAuthn / Passkey
|
||||
|
||||
- Passkey registration and assertion handled in `services/auth/`
|
||||
- Enables passwordless login on supported clients
|
||||
|
||||
### Google OAuth
|
||||
|
||||
- `GOOGLE_CLIENT_ID` enables Google OAuth 2.0 sign-in
|
||||
|
||||
### Telegram Mini App
|
||||
|
||||
- Mini App sessions verified via Telegram `initData` HMAC in `services/telegram/`
|
||||
- Identity linking ties a Telegram user to a platform account
|
||||
- `TELEGRAM_WEBAPP_URL` controls the allowed Mini App origin
|
||||
|
||||
### Rate limits on auth endpoints
|
||||
|
||||
- Login: 10 requests per 15-minute window (`LOGIN_RATE_LIMIT_ENABLED` to toggle)
|
||||
- Cloudflare Turnstile CAPTCHA support: `TURNSTILE_SECRET_KEY`
|
||||
|
||||
---
|
||||
|
||||
## 8. Realtime (Socket.IO)
|
||||
|
||||
- Socket.IO server is attached to the HTTP server at bootstrap (`infrastructure/socket/socketService.ts`)
|
||||
- Redis adapter (`@socket.io/redis-adapter`) enables pub-sub across Node cluster workers
|
||||
- **Room conventions:**
|
||||
- `user:<userId>` — personal notifications, payment status updates
|
||||
- `payment:<paymentId>` — scoped payment lifecycle events (added in v2.8.4 to prevent global cart-wipe)
|
||||
- `dispute:<disputeId>` — dispute chat and status
|
||||
- `chat:<conversationId>` — chat messages
|
||||
- **Key emitted events:** `payment:update`, `notification:new`, `dispute:update`, `chat:message`, `offer:update`
|
||||
- Server verifies JWT on `connection` and room join; frontend must join the correct room after authenticating
|
||||
|
||||
---
|
||||
|
||||
## 9. Payment Providers
|
||||
|
||||
| Provider | Type | Chains / Tokens | Notes |
|
||||
|---|---|---|---|
|
||||
| **amn.scanner** | In-house on-chain scanner | ETH, BSC (USDT/USDC) | Bearer auth via `AMN_SCANNER_API_KEY`; webhook secret `AMN_SCANNER_WEBHOOK_SECRET`; provider tag `"amn.scanner"` |
|
||||
| **Request Network** | Decentralized invoicing | ETH, Base (USDC/DAI) | `REQUEST_NETWORK_*` env block; HMAC webhook signature; canonical proxy addresses differ per chain (ETH `0x370DE2…`, Base `0x189219…`) |
|
||||
| **SHKeeper** | Self-hosted crypto gateway | BTC, ETH, BNB, USDT, others | `SHKEEPER_NETWORK`, `SHKEEPER_NETWORKS`, `SHKEEPER_ALLOWED_TOKENS` |
|
||||
| **DePay** | Web3 payment widget | EVM chains | Legacy path; `PAYMENT_CALLBACK_SECRET` |
|
||||
| **Derived Destinations** | HD-wallet receive addresses | ETH / BSC | `DERIVED_DESTINATION_XPUB/XPRIV`; sweep orchestration runs on configurable interval |
|
||||
|
||||
### Payment orchestration
|
||||
|
||||
- `PAYMENT_PROVIDER_MODE` selects active provider(s) at runtime
|
||||
- Internal ledger tracks `available`, `held`, and `releasable` balances per payment record
|
||||
- Transaction Safety Provider: AML screening (Chainalysis / OFAC SDN), minimum on-chain confirmation thresholds configurable at runtime (`TRANSACTION_SAFETY_MIN_CONFIRMATIONS`, `TRANSACTION_SAFETY_AML_PROVIDER`)
|
||||
- `GET /api/payment/:id` is exempt from the payment rate limiter (polling-safe)
|
||||
- Cleanup endpoints must always be scoped by `provider:` to avoid wiping unrelated payment records
|
||||
|
||||
### Price Oracle
|
||||
|
||||
- Chainlink + OffchainFX feeds; `ORACLE_MAX_STALENESS_S` sets maximum acceptable quote age
|
||||
- Depeg protection rejects or flags stablecoin payments when peg deviation exceeds threshold
|
||||
- `ORACLE_BYPASS_ENABLED=true` disables staleness check (dev/test only)
|
||||
|
||||
---
|
||||
|
||||
## 10. CI/CD (Woodpecker)
|
||||
|
||||
Four Woodpecker pipeline files under `.woodpecker/`:
|
||||
|
||||
| File | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
| `production.yml` | push to `main` / `master` | Typecheck → build Docker image locally on host → `docker compose up -d backend` |
|
||||
| `development.yml` | cron (parked) | Was the dev-stack auto-deploy; currently inactive |
|
||||
| `manual.yml` | manual trigger | Builds image to `git.tbs.amn.gg` registry (escrow-dev stack ignores registry pulls) |
|
||||
| `cleanup.yml` | scheduled | Housekeeping tasks (prune old images, stale data) |
|
||||
|
||||
### Production pipeline steps
|
||||
|
||||
1. **get-version** — reads `package.json` version, writes `dev-<version>` to `.tags`
|
||||
2. **typecheck** — `npm ci` (cached at `/opt/woodpecker-cache/backend-npm`) then `npm run typecheck`; push is blocked if tsc errors exist
|
||||
3. **build-and-deploy** — `docker build -f Dockerfile.prod -t escrow-backend-local:dev .` on the agent co-located with the stack; then `docker compose up -d --no-deps --pull never backend`
|
||||
4. **notify** — `node scripts/ci/tg-notify.cjs` posts success/failure to Telegram (no `parse_mode` to avoid HTML/Markdown breakage)
|
||||
|
||||
### escrow-multi stack
|
||||
|
||||
The `escrow-multi` stack (branch `feature/white-label-shops`) uses `.woodpecker/multi.yml`. Always deploy via `git push forgejo feature/white-label-shops` — never via manual SSH or rsync. Woodpecker CLI credentials are in `~/CascadeProjects/escrow/.env`.
|
||||
|
||||
### Version bump requirement
|
||||
|
||||
Bump `package.json` version before every CI-triggering push, or the deployed image will not be distinguishable from the previous build. See memory note `version_bump_before_ci.md`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Local Development Quick-Start
|
||||
|
||||
```bash
|
||||
# 1. Clone
|
||||
git clone git@git.tbs.amn.gg:escrow/backend.git
|
||||
cd backend
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Copy and populate env
|
||||
cp .env.example .env.development
|
||||
# Edit .env.development — minimum required: PG_URL, REDIS_URI, JWT_SECRET, FRONTEND_URL
|
||||
|
||||
# 4. Start Postgres and Redis (Docker)
|
||||
docker compose up -d postgres redis
|
||||
|
||||
# 5. Run migrations
|
||||
npx drizzle-kit migrate
|
||||
|
||||
# 6. Start dev server (seeds run automatically if SEED_USERS=true)
|
||||
npm run dev
|
||||
# Server starts on process.env.PORT
|
||||
|
||||
# 7. Type-check only (no run)
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
> The pre-push git hook runs a full `tsc` check. If a parallel agent's mid-refactor tree is checked out, this hook may block your push. Stage only your specific files — never `git add -A` blindly. See memory note `backend_prepush_tsc_hook.md`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `PORT` | HTTP listen port |
|
||||
| `NODE_ENV` | `development` / `production` / `test` |
|
||||
| `FRONTEND_URL` | Allowed CORS origin (frontend base URL) |
|
||||
| `BACKEND_URL` | Self-referential base URL (used for webhook callback construction) |
|
||||
| `PG_URL` | Primary Postgres connection string |
|
||||
| `PG_VITAL_URL` | Postgres connection for vital (write-path) pool |
|
||||
| `PG_NONVITAL_URL` | Postgres connection for non-vital (read-path) pool |
|
||||
| `PG_POOL_MAX` | Max connections in primary pool |
|
||||
| `PG_POOL_SIZE` | Pool size alias |
|
||||
| `PG_NONVITAL_POOL_MAX` | Max connections in non-vital pool |
|
||||
| `DATABASE_URL` / `POSTGRES_URL` | Compatibility aliases for `PG_URL` |
|
||||
| `REDIS_URI` | Redis connection string (sessions, pub-sub, Socket.IO adapter) |
|
||||
| `JWT_SECRET` | HMAC secret for JWT signing |
|
||||
| `JWT_EXPIRES_IN` | Access token TTL (e.g. `15m`) |
|
||||
| `REFRESH_TOKEN_EXPIRES_IN` | Refresh token TTL (e.g. `7d`) |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID |
|
||||
| `TELEGRAM_WEBAPP_URL` | Allowed Telegram Mini App origin |
|
||||
| `TG_NOTIFY_BOT_TOKEN` | Telegram bot token for CI/admin notifications |
|
||||
| `TG_NOTIFY_CHATS` | Comma-separated Telegram chat IDs for notifications |
|
||||
| `SMTP_HOST` | SMTP server hostname |
|
||||
| `SMTP_PORT` | SMTP port |
|
||||
| `SMTP_SECURE` | `true` for TLS |
|
||||
| `SMTP_USER` | SMTP auth username |
|
||||
| `SMTP_PASS` | SMTP auth password |
|
||||
| `SMTP_FROM` | From address for outbound email |
|
||||
| `RESEND_API_KEY` | Resend email API key |
|
||||
| `RESEND_WEBHOOK_SECRET` | Resend webhook signature secret |
|
||||
| `PAYMENT_PROVIDER_MODE` | Active payment provider(s) |
|
||||
| `PAYMENT_CALLBACK_SECRET` | DePay callback HMAC secret |
|
||||
| `AMN_SCANNER_URL` | amn.scanner service base URL |
|
||||
| `AMN_SCANNER_API_KEY` | Bearer token for amn.scanner API |
|
||||
| `AMN_SCANNER_WEBHOOK_SECRET` | HMAC secret for amn.scanner webhook verification |
|
||||
| `REQUEST_NETWORK_API_BASE_URL` | Request Network API base URL |
|
||||
| `REQUEST_NETWORK_API_KEY` | Request Network API key |
|
||||
| `REQUEST_NETWORK_CLIENT_ID` | RN client identifier |
|
||||
| `REQUEST_NETWORK_NETWORK` | RN chain name (`mainnet` / `sepolia` / etc.) |
|
||||
| `REQUEST_NETWORK_RECEIVER_ADDRESS` | Merchant wallet for RN payments |
|
||||
| `REQUEST_NETWORK_PAYMENT_CURRENCY` | Payment token symbol |
|
||||
| `REQUEST_NETWORK_PAYMENT_TOKEN_ADDRESS` | Payment token contract address |
|
||||
| `REQUEST_NETWORK_INVOICE_CURRENCY` | Invoice denomination currency |
|
||||
| `REQUEST_NETWORK_WEBHOOK_CALLBACK_URL` | RN webhook delivery URL |
|
||||
| `REQUEST_NETWORK_WEBHOOK_SECRET` | HMAC secret for RN webhook |
|
||||
| `REQUEST_NETWORK_MERCHANT_REFERENCE` | RN merchant reference string |
|
||||
| `REQUEST_NETWORK_ORIGIN` | RN request origin header |
|
||||
| `RN_API_KEY` | Alias for `REQUEST_NETWORK_API_KEY` |
|
||||
| `RN_API_URL` | Alias for `REQUEST_NETWORK_API_BASE_URL` |
|
||||
| `RN_CLIENT_ID` | Alias for `REQUEST_NETWORK_CLIENT_ID` |
|
||||
| `RN_WEBHOOK_SECRET` | Alias for `REQUEST_NETWORK_WEBHOOK_SECRET` |
|
||||
| `SHKEEPER_NETWORK` | SHKeeper primary network identifier |
|
||||
| `SHKEEPER_NETWORKS` | SHKeeper supported networks (comma-separated) |
|
||||
| `SHKEEPER_ALLOWED_TOKENS` | Token allowlist for SHKeeper |
|
||||
| `DERIVED_DESTINATION_XPUB` | HD wallet extended public key for address derivation |
|
||||
| `DERIVED_DESTINATION_XPRIV` | HD wallet extended private key (for sweep signing) |
|
||||
| `DERIVED_DESTINATION_BASE_PATH` | BIP-44 derivation base path |
|
||||
| `DERIVED_DESTINATION_CHAIN_ID` | EVM chain ID for derived address sweeps |
|
||||
| `DERIVED_DESTINATION_MIN_SWEEP_AMOUNT` | Minimum balance to trigger a sweep |
|
||||
| `DERIVED_DESTINATION_SWEEP_INTERVAL_MS` | Sweep polling interval in milliseconds |
|
||||
| `DERIVED_DESTINATION_SWEEP_BALANCE_CONCURRENCY` | Parallel balance-check concurrency |
|
||||
| `DERIVED_DESTINATION_SWEEP_SIGNER` | Sweep transaction signing mode |
|
||||
| `DERIVED_DESTINATION_SWEEP_AUTOSTART` | Auto-start sweep cron on boot |
|
||||
| `SWEEP_MASTER_PRIVKEY` | Master private key for sweep gas top-up |
|
||||
| `SWEEP_GAS_MIN_BNB` | Minimum BNB balance before gas top-up is triggered |
|
||||
| `SWEEP_GAS_TOP_UP_BNB` | Amount of BNB to top up for sweep gas |
|
||||
| `ESCROW_WALLET_ADDRESS` | Platform escrow wallet address |
|
||||
| `RECEIVER_WALLET_ADDRESS` | Platform receiver wallet address |
|
||||
| `INFURA_KEY` | Infura RPC key (ETH mainnet) |
|
||||
| `BSC_RPC_URL` | BSC mainnet RPC endpoint |
|
||||
| `BSC_TESTNET_RPC_URL` | BSC testnet RPC endpoint |
|
||||
| `BNB_TESTNET_RPC_URL` | BNB testnet RPC endpoint |
|
||||
| `RPC_URL_CHAIN_56` | BSC mainnet RPC (chain ID 56) |
|
||||
| `RPC_URL_CHAIN_97` | BSC testnet RPC (chain ID 97) |
|
||||
| `ENABLE_TESTNET_CHAINS` | Enable testnet chain support |
|
||||
| `TRANSACTION_SAFETY_AML_PROVIDER` | AML provider: `chainalysis` / `ofac` / `none` |
|
||||
| `TRANSACTION_SAFETY_MIN_CONFIRMATIONS` | Default minimum on-chain confirmations |
|
||||
| `CHAINALYSIS_API_KEY` | Chainalysis KYT API key |
|
||||
| `OFAC_SDN_URL` | OFAC SDN list endpoint |
|
||||
| `AML_CHECK_COST_USD` | Cost per AML check (for billing/reporting) |
|
||||
| `ORACLE_MAX_STALENESS_S` | Maximum age (seconds) for oracle price quotes |
|
||||
| `ORACLE_BYPASS_ENABLED` | Disable oracle staleness check (`true` in dev/test only) |
|
||||
| `DIGITAL_GOODS_ENC_KEY` | AES encryption key for digital goods delivery |
|
||||
| `TREZOR_SAFEKEEPING_REQUIRED` | Require Trezor safekeeping confirmation |
|
||||
| `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile CAPTCHA secret |
|
||||
| `RATE_LIMIT_WINDOW_MS` | Rate limit window in milliseconds |
|
||||
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window (global limiter) |
|
||||
| `RATE_LIMIT_BYPASS_IPS` | Comma-separated IPs exempt from rate limiting |
|
||||
| `LOGIN_RATE_LIMIT_ENABLED` | Enable/disable login rate limiter |
|
||||
| `TRUST_PROXY_HOPS` | `trust proxy` hop count for X-Forwarded-For behind Traefik |
|
||||
| `UPLOAD_PATH` | Filesystem path for uploaded files (default `/app/uploads`) |
|
||||
| `MAX_FILE_SIZE` | Maximum upload size in bytes |
|
||||
| `CLUSTER_WORKERS` | Number of Node cluster worker processes |
|
||||
| `SEED_USERS` | Seed default dev users on start |
|
||||
| `AUTO_SEED_ON_START` | Auto-run all seeds on process start |
|
||||
| `SEED_DIGITAL_GOODS_ON_START` | Seed digital goods fixtures on start |
|
||||
| `SEED_MOCK_SHOPS_ON_START` | Seed mock shop fixtures on start |
|
||||
| `FORCE_SEED_TEMPLATES` | Force re-seed request templates even if already present |
|
||||
| `SEED_PASSWORD_SELLER` | Password for seeded seller account |
|
||||
| `SEED_PASSWORD_MOCK_SELLER` | Password for seeded mock seller account |
|
||||
| `SEED_PASSWORD_SUPPORT` | Password for seeded support account |
|
||||
| `ADMIN_EMAIL` | Seeded admin user email |
|
||||
| `ADMIN_PASSWORD` | Seeded admin user password |
|
||||
| `ADMIN_FIRST_NAME` | Seeded admin first name |
|
||||
| `ADMIN_LAST_NAME` | Seeded admin last name |
|
||||
| `MIGRATION_MONGO_URL` | Mongo URL used only by migration/backfill tooling (not runtime) |
|
||||
| `MIGRATION_PG_URL` | Postgres URL used by migration tooling (may differ from `PG_URL`) |
|
||||
| `MONGODB_URI` | Legacy Mongo URI retained for backfill scripts only |
|
||||
| `DB_NAME` | Database name (legacy config field) |
|
||||
|
||||
> Store-mode env vars (`AUTH_STORE`, `USER_STORE`, `BLOG_STORE`, etc.) were part of the dual-write migration scaffolding. All domains are now Postgres-only; these can be left unset or set to `postgres`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Known Issues / Open Items
|
||||
|
||||
| Issue | Status | Reference |
|
||||
|---|---|---|
|
||||
| Rate limit counters are in-memory | Not multi-process safe across cluster workers; Redis adapter planned | `backend_rate_limits.md` |
|
||||
| `pgId` vs legacy `_id` mismatch | Auth `_id` is a legacy ObjectId; marketplace FKs use Postgres UUID (`pgId`); match offers on `pgId` | `pgid_vs_legacy_id.md` |
|
||||
| Socket.IO room scoping for payments | Backend room-scoping for payment events is an open follow-up (frontend gate added in v2.8.4) | `cart_wipe_global_socket_events.md` |
|
||||
| Performance is WAN-bound | Profiling shows 300–800ms on external routes = WAN RTT (~235ms); server-side is 3–12ms; PG migration does not fix this | `perf_is_network_bound_not_db.md` |
|
||||
| RN webhook `event` field | Request Network sends discriminator as `payload.event` not `eventType`; parser must include `event` in fallback chain | `rn_webhook_event_field.md` |
|
||||
| RN canonical proxy addresses per chain | ETH `0x370DE2…`, Base `0x189219…` — not the same CREATE2 address; always probe before using hardcoded addresses | `rn_proxy_addresses_per_chain.md` |
|
||||
| JSON assets not copied to dist | `tsc` does not copy `.json` files; any `fs.readFileSync` on JSON needs explicit `postbuild` copy step | `feedback_json_assets_copy_to_dist.md` |
|
||||
| Woodpecker `${VAR}` template collision | Woodpecker eats `${VAR}` in commands; use `$VAR` or `$$VAR` | `woodpecker_template_collision.md` |
|
||||
| CI silent build fail | Green CI does not guarantee image was pushed to registry; verify `dev-<version>` tag exists before trusting | `woodpecker_silent_build_fail.md` |
|
||||
| Admin cleanup must be provider-scoped | Any payment cleanup query must filter by `provider:` or it silently destroys multi-seller/RN records | `feedback_payment_cleanup_provider_filter.md` |
|
||||
| Store-mode env vars | Legacy dual-write `*_STORE` vars still present in codebase but are no-ops; can be pruned in a future cleanup | — |
|
||||
| Mongo backfill tooling | `MIGRATION_MONGO_URL` / `MONGODB_URI` retained for backfill scripts only; server never connects to Mongo at runtime | `mongo_retirement_status.md` |
|
||||
745
10 - Services/deployment.md
Normal file
745
10 - Services/deployment.md
Normal file
@@ -0,0 +1,745 @@
|
||||
---
|
||||
title: Deployment
|
||||
tags: [services, deployment, infrastructure, docker]
|
||||
---
|
||||
|
||||
# Deployment
|
||||
|
||||
The `deployment/` sub-project contains Docker Compose definitions, reverse-proxy configs, Gatus monitoring, and migration bundles for running the Amanat escrow platform. It covers three distinct stacks: a **legacy compose** (reference only), the **dev-amn active dev stack** (`dev.amn.gg`), and the **escrow-multi white-label stack** (`multi.amn.gg`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
| File | Status | Host | Notes |
|
||||
|---|---|---|---|
|
||||
| `deployment/docker-compose.yml` | **Legacy / reference** | Any | nginx + traefik_public network; images from `git.manko.yoga` registry. Do not deploy from this. |
|
||||
| `deployment/dev-amn/docker-compose.yml` | **Active** | `89.58.32.32` | `shared-web` + infra-caddy ingress; images from `git.tbs.amn.gg/escrow` |
|
||||
| `deployment/escrow-multi/docker-compose.yml` | **Active multi-shop** | `89.58.32.32` | Isolated stack for `multi.amn.gg`; images tagged `:multi`; fresh Postgres/Redis; Drizzle migrations |
|
||||
|
||||
The `dev-amn` stack is the authoritative dev deployment. The `escrow-multi` stack is the only valid target for `feature/white-label-shops` branch work.
|
||||
|
||||
> [!warning] Branch / stack isolation
|
||||
> Work on `feature/white-label-shops` must NEVER touch `escrow-dev` — no restart, redeploy, or env change. Work on `main` must NEVER touch `escrow-multi`. Each stack must have its own `TELEGRAM_BOT_TOKEN` (different bots). See [[deploy_architecture_two_stacks]].
|
||||
|
||||
---
|
||||
|
||||
## 2. Services
|
||||
|
||||
### 2.1 dev-amn stack (active, `dev.amn.gg`)
|
||||
|
||||
| Service | Image | Internal Port | Role |
|
||||
|---|---|---|---|
|
||||
| `backend` | `git.tbs.amn.gg/escrow/backend:dev` | 5001 | Express 5 API + Socket.IO + admin seed |
|
||||
| `frontend` | `git.tbs.amn.gg/escrow/frontend:dev` | 8083 | Next.js SSR app |
|
||||
| `refscanner` | `git.tbs.amn.gg/escrow/scanner:dev` | 8080 | In-house AMN payment scanner (SQLite) |
|
||||
| `postgres` | `postgres:18-alpine` | 5432 | Primary datastore (auth + all 8 Postgres domain stores) |
|
||||
| `redis` | `redis:8-alpine` | 6379 | Session cache, rate-limit counters, pub/sub |
|
||||
| `mongodb` | `mongo:8.0-noble` | 27017 | Legacy datastore — dev boot parity only; retire once remaining reads migrated |
|
||||
|
||||
### 2.2 escrow-multi stack (`multi.amn.gg`)
|
||||
|
||||
| Service | Image | Internal Port | Role |
|
||||
|---|---|---|---|
|
||||
| `migrate` | `node:22-alpine` | n/a | One-shot Drizzle migration runner |
|
||||
| `backend` | `git.tbs.amn.gg/escrow/backend:multi` | 5001 | Express API, tenant services, storefront API, tenant bot webhook |
|
||||
| `frontend` | `git.tbs.amn.gg/escrow/frontend:multi` | 8083 | Next.js for `multi.amn.gg`, tenant subdomains, dashboards |
|
||||
| `postgres` | `postgres:18-alpine` | 5432 | Isolated multi-stack database (`escrow_multi`) |
|
||||
| `redis` | `redis:8-alpine` | 6379 | Isolated multi-stack cache/session/pub-sub |
|
||||
|
||||
### 2.3 Legacy compose services (`deployment/docker-compose.yml`)
|
||||
|
||||
> These are documented for reference only. Do not deploy from this file.
|
||||
|
||||
| Service | Image | Host Port | Role |
|
||||
|---|---|---|---|
|
||||
| `nginx` | `nginx:alpine` | 80 (via Traefik) | Reverse proxy in front of backend and frontend |
|
||||
| `nickDev-marketplace` | `git.manko.yoga/manawenuz/escrow-backend:dev` | — | Backend (legacy registry) |
|
||||
| `mongodb` | `mongo:8.0-noble` | — | Mongo datastore |
|
||||
| `postgres` | `postgres:18-alpine` | — | Postgres datastore |
|
||||
| `redis` | `redis:8-alpine` | — | Cache/sessions |
|
||||
| `nickDev-frontend` | `git.manko.yoga/manawenuz/escrow-frontend:dev` | 8083 | Frontend (legacy registry) |
|
||||
| `gatus` | `twinproduction/gatus:latest` | 8084→8080 | Uptime monitoring + Telegram alerting |
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture Diagram
|
||||
|
||||
### dev-amn (active)
|
||||
|
||||
```
|
||||
Internet (HTTPS 443 / HTTP 80)
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Cloudflare CDN / Proxy │
|
||||
│ amn.gg / dev.amn.gg │
|
||||
└─────────────┬─────────────────┘
|
||||
│ (origin request)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Host: 89.58.32.32 │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ infra-caddy (Arcane project "infra") │ │
|
||||
│ │ ports 80:80, 443:443 bound to host │ │
|
||||
│ │ Caddyfile: /opt/arcane/data/projects/ │ │
|
||||
│ │ infra/Caddyfile │ │
|
||||
│ └───────┬─────────────────────────┬────────────┘ │
|
||||
│ │ /api/* /socket.io/* │ /* │
|
||||
│ │ /uploads/* │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌───────────────┐ ┌────────────────────┐ │
|
||||
│ │ backend │ │ frontend │ │
|
||||
│ │ :5001 │ │ :8083 │ │
|
||||
│ │ shared-web │ │ shared-web │ │
|
||||
│ └──┬──┬────┬────┘ └────────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ │ └────────────────────┐ │
|
||||
│ │ │ ▼ │
|
||||
│ │ │ ┌──────────────────────┐ │
|
||||
│ │ │ │ refscanner │ │
|
||||
│ │ │ │ :8080 │ │
|
||||
│ │ │ │ (default only) │ │
|
||||
│ │ │ └──────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
|
||||
│ │ postgres │ │ redis │ │ mongodb │ │
|
||||
│ │ :5432 │ │ :6379 │ │ :27017 │ │
|
||||
│ │ (default │ │ (default │ │ (default only, │ │
|
||||
│ │ only) │ │ only) │ │ legacy) │ │
|
||||
│ └──────────┘ └──────────┘ └────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
Networks:
|
||||
shared-web (external) ─ backend + frontend (reachable by infra-caddy)
|
||||
default (bridge) ─ all containers on the stack
|
||||
```
|
||||
|
||||
### Legacy compose (reference only)
|
||||
|
||||
```
|
||||
Internet (HTTPS 443)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Traefik (external) │
|
||||
│ escrowdev.ch.manko. │
|
||||
│ yoga → nginx:80 │
|
||||
│ gatus.ch.manko.yoga → │
|
||||
│ gatus:8080 │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────┐
|
||||
│ nginx (traefik_public + default) │
|
||||
│ nickDev-nginx │
|
||||
│ conf: /var/data/escrowDev/nginx/ │
|
||||
└────────┬──────────────────┬────────────┘
|
||||
│ /api /socket.io │ /*
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌────────────────────┐
|
||||
│ nickDev- │ │ nickDev-frontend │
|
||||
│ marketplace │ │ :8083 │
|
||||
│ backend │ │ (watchtower) │
|
||||
│ (watchtower) │ └────────────────────┘
|
||||
└──┬──┬──────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌────────────┐
|
||||
│ mongodb │ │ postgres │ │ redis │
|
||||
│ :27017 │ │ :5432 │ │ :6379 │
|
||||
└──────────┘ └──────────┘ └────────────┘
|
||||
|
||||
┌────────────────────────────────────────┐
|
||||
│ gatus :8084→:8080 (traefik_public) │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Networks
|
||||
|
||||
| Network | Type | Present in | Services Attached | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| `default` (bridge) | Internal auto | dev-amn, legacy | All services | Container-to-container communication |
|
||||
| `shared-web` | External (pre-existing) | dev-amn, escrow-multi | `backend`, `frontend` | Allows infra-caddy to proxy by container name |
|
||||
| `traefik_public` | External (pre-existing) | Legacy compose only | `nginx`, `gatus` | Old Traefik-based ingress on `git.manko.yoga` host |
|
||||
|
||||
**Key rules:**
|
||||
- `postgres`, `redis`, `mongodb` are on `default` only — never externally reachable.
|
||||
- `refscanner` is on `default` only; backend reaches it via alias `refscanner:8080`.
|
||||
- Any new public-facing service must join `shared-web` AND get a Caddyfile vhost block.
|
||||
- `shared-web` must exist on the host before `docker compose up` — it is created by the Arcane `infra` project.
|
||||
|
||||
---
|
||||
|
||||
## 5. Volumes and Bind Mounts
|
||||
|
||||
### dev-amn stack
|
||||
|
||||
All data volumes use relative bind mounts under `./data/` (resolved to `/opt/arcane/data/projects/escrow-dev/data/` on the server):
|
||||
|
||||
| Service | Host Path | Container Path | Notes |
|
||||
|---|---|---|---|
|
||||
| `backend` | `./data/uploads` | `/app/uploads` | User-uploaded files (served via `/uploads/*`) |
|
||||
| `refscanner` | `./data/scanner` | `/data` | SQLite DB at `/data/scanner.db` |
|
||||
| `postgres` | `./data/postgres` | `/var/lib/postgresql/data` | `PGDATA=/var/lib/postgresql/data/pgdata` (subdir workaround) |
|
||||
| `redis` | `./data/redis` | `/data` | RDB persistence dump |
|
||||
| `mongodb` | `./data/mongo` | `/data/db` | Legacy; can be removed once Mongo retired |
|
||||
|
||||
The Gatus config (`deployment/gatus/config.yaml`) is bind-mounted read-only into the gatus container at `/config/config.yaml`. It lives in the repo, not in `./data/`.
|
||||
|
||||
> **Postgres volume note:** `postgres:18` uses a version-scoped data directory layout and refuses to init into a volume root that already contains files from a different layout. `PGDATA` is set to a subdirectory (`/var/lib/postgresql/data/pgdata`) inside the mount to avoid init conflicts.
|
||||
|
||||
### Legacy compose bind mounts (`/var/data/escrowDev/`)
|
||||
|
||||
| Service | Host Path | Container Path |
|
||||
|---|---|---|
|
||||
| `nginx` | `/var/data/escrowDev/nginx/nginx.conf` | `/etc/nginx/nginx.conf` (ro) |
|
||||
| `nginx` | `/var/data/escrowDev/nginx/logs` | `/var/log/nginx` |
|
||||
| `nginx` / `backend` | `/var/data/escrowDev/uploads` | `/uploads` / `/app/uploads` |
|
||||
| `mongodb` | `/var/data/escrowDev/mongodb_data` | `/data/db` |
|
||||
| `mongodb` | `/var/data/escrowDev/mongo-init` | `/docker-entrypoint-initdb.d` |
|
||||
| `postgres` | `/var/data/escrowDev/postgres_data` | `/var/lib/postgresql` |
|
||||
| `redis` | `/var/data/escrowDev/redis_data` | `/data` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Reverse Proxy (infra-caddy) Integration
|
||||
|
||||
Ingress for `89.58.32.32` is handled exclusively by **infra-caddy** — the Caddy container in the Arcane project `infra`. It owns host ports 80 and 443. No service should bind those ports directly.
|
||||
|
||||
### Caddyfile block for dev.amn.gg
|
||||
|
||||
Live location on server: `/opt/arcane/data/projects/infra/Caddyfile`
|
||||
Reference copy in repo: `deployment/dev-amn/Caddyfile`
|
||||
|
||||
```caddy
|
||||
{
|
||||
email manwe@manko.yoga
|
||||
auto_https disable_redirects
|
||||
}
|
||||
|
||||
dev.amn.gg {
|
||||
encode zstd gzip
|
||||
|
||||
@backend path /api/* /socket.io/* /uploads/*
|
||||
reverse_proxy @backend backend:5001
|
||||
|
||||
reverse_proxy frontend:8083
|
||||
}
|
||||
```
|
||||
|
||||
- `auto_https disable_redirects` — Cloudflare proxy sits in front; Caddy must not force HTTP→HTTPS redirects at origin.
|
||||
- Routes by path prefix: `/api/*`, `/socket.io/*`, `/uploads/*` → `backend:5001`; everything else → `frontend:8083`.
|
||||
- Container names resolve via the `shared-web` network (both `backend` and `frontend` join it).
|
||||
|
||||
### Adding a new public service
|
||||
|
||||
1. Add the service to `deployment/dev-amn/docker-compose.yml` with `networks: shared-web: {}`.
|
||||
2. Edit `/opt/arcane/data/projects/infra/Caddyfile` on the server — add a new vhost block or path matcher.
|
||||
3. Reload Caddy without restarting:
|
||||
```bash
|
||||
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
|
||||
"docker exec infra-caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||
```
|
||||
4. Verify: `curl -I https://dev.amn.gg/<new-path>`
|
||||
|
||||
See also: [[Shared Infra (89.58.32.32)]]
|
||||
|
||||
### Legacy compose: Traefik labels
|
||||
|
||||
In the legacy compose, nginx and gatus expose themselves to Traefik via Docker labels:
|
||||
|
||||
```yaml
|
||||
# nginx
|
||||
traefik.http.routers.escrowDev.rule=Host(`escrowdev.ch.manko.yoga`)
|
||||
traefik.http.routers.escrowDev.entrypoints=https
|
||||
traefik.http.services.escrowDev.loadbalancer.server.port=80
|
||||
|
||||
# gatus
|
||||
traefik.http.routers.gatus.rule=Host(`gatus.ch.manko.yoga`)
|
||||
traefik.http.routers.gatus.entrypoints=https
|
||||
traefik.http.services.gatus.loadbalancer.server.port=8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Gatus Monitoring
|
||||
|
||||
Gatus runs as a sidecar in the `default` network. Config is at `deployment/gatus/config.yaml` (bind-mounted read-only). Alerts are delivered via Telegram to `GATUS_TELEGRAM_CHAT_ID`.
|
||||
|
||||
In the legacy compose, Gatus is exposed on host port `8084` (mapped from container `:8080`) and publicly accessible via Traefik at `gatus.ch.manko.yoga`.
|
||||
|
||||
### Alert policy
|
||||
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
| Default failure threshold | 3 consecutive failures |
|
||||
| Default success threshold | 2 consecutive successes |
|
||||
| Send on resolved | Yes |
|
||||
| Alert channel | Telegram (`GATUS_TELEGRAM_BOT_TOKEN` / `GATUS_TELEGRAM_CHAT_ID`) |
|
||||
|
||||
Prod endpoints use `failure-threshold: 2` (faster alerting).
|
||||
|
||||
### Monitored endpoints
|
||||
|
||||
| Name | Group | URL | Interval | Key Conditions |
|
||||
|---|---|---|---|---|
|
||||
| `backend-dev-version` | backend-dev | `https://dev.amn.gg/api/version` | 60s | HTTP 200, `body.version` not empty |
|
||||
| `backend-dev-health` | backend-dev | `https://dev.amn.gg/api/health` | 30s | HTTP 200, all 8 PG store modes = postgres, redis ok, RN chain+token registry loaded |
|
||||
| `backend-prod-version` | backend-prod | `https://amn.gg/api/version` | 60s | HTTP 200, `body.version` not empty |
|
||||
| `backend-prod-health` | backend-prod | `https://amn.gg/api/health` | 30s | HTTP 200, db/postgres/redis/RN registries ok |
|
||||
| `frontend-dev` | frontend | `https://dev.amn.gg/` | 60s | HTTP 200, response time < 3000ms |
|
||||
| `frontend-prod` | frontend | `https://amn.gg/` | 60s | HTTP 200, response time < 3000ms |
|
||||
| `rn-api-reachable` | external | `https://api.request.network/v2/health` | 5m | HTTP 200/401/404 (checks reachability only) |
|
||||
| `chainalysis-public-api` | external | `https://public.chainalysis.com/api/v1/address/0x000…` | 5m | HTTP 200 or 404 |
|
||||
| `bsc-rpc-publicnode` | external | `https://bsc-rpc.publicnode.com` (POST) | 2m | HTTP 200, `result == "0x38"` (BSC mainnet chain ID) |
|
||||
|
||||
The `backend-dev-health` endpoint validates **all 8 domain stores running on Postgres**: `auth`, `config`, `address`, `category`, `levelConfig`, `shopSettings`, `review`, `notification`. A failure here means a store mode regression or a broken `PG_URL`.
|
||||
|
||||
Gatus dashboard: `:8084` on the host locally (not publicly proxied by default — access via SSH tunnel, or add a Caddyfile block if public exposure is needed).
|
||||
|
||||
---
|
||||
|
||||
## 8. Environment Variables
|
||||
|
||||
All vars are injected via `.env` at the stack root. The server file is `chmod 600` and never committed. The `deployment/.env` in the repo serves as the live dev reference / template.
|
||||
|
||||
### 8.1 Runtime / Node
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `NODE_ENV` | Runtime environment | `production` |
|
||||
| `PORT` | Express listen port | `5001` |
|
||||
| `TRUST_PROXY` | Express trust-proxy (required behind Caddy/nginx) | `true` |
|
||||
| `DEBUG` | Debug namespaces | _(empty)_ |
|
||||
| `LOG_LEVEL` | Winston log level | `info` |
|
||||
|
||||
### 8.2 Database — Postgres
|
||||
|
||||
| Variable | Description | Default in compose |
|
||||
|---|---|---|
|
||||
| `PG_URL` | Postgres DSN | `postgres://amanat:amanat_local@postgres:5432/amanat_dev` |
|
||||
| `POSTGRES_USER` | Postgres superuser | `amanat` |
|
||||
| `POSTGRES_PASSWORD` | Postgres superuser password | — |
|
||||
| `POSTGRES_DB` | Postgres database name | `amanat_dev` |
|
||||
| `AUTO_SEED_ON_START` | Run seed on boot | `true` |
|
||||
|
||||
### 8.3 Database — Mongo (legacy)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `MONGODB_URI` | Mongo connection string | `mongodb://admin:pass@mongodb:27017/marketplace?authSource=admin` |
|
||||
| `MONGO_INITDB_ROOT_USERNAME` | Mongo root user | `admin` |
|
||||
| `MONGO_INITDB_ROOT_PASSWORD` | Mongo root password | `changeme_local` |
|
||||
| `MONGO_INITDB_DATABASE` | Mongo init DB | `marketplace` |
|
||||
| `DB_NAME` | Mongo database name used by app | `amn-db` |
|
||||
|
||||
### 8.4 Store modes (dual-write seam)
|
||||
|
||||
All default to `postgres` in the dev-amn compose. Changing any to `mongo` re-routes that domain's reads/writes to MongoDB.
|
||||
|
||||
| Variable | Domain | Default |
|
||||
|---|---|---|
|
||||
| `AUTH_STORE` | Auth / user accounts | `postgres` |
|
||||
| `CONFIG_STORE` | App config | `postgres` |
|
||||
| `ADDRESS_STORE` | User addresses | `postgres` |
|
||||
| `CATEGORY_STORE` | Marketplace categories | `postgres` |
|
||||
| `LEVEL_CONFIG_STORE` | Gamification level config | `postgres` |
|
||||
| `SHOP_SETTINGS_STORE` | Per-shop settings | `postgres` |
|
||||
| `REVIEW_STORE` | Product / seller reviews | `postgres` |
|
||||
| `NOTIFICATION_STORE` | User notifications | `postgres` |
|
||||
|
||||
See [[mongo_retirement_status]] and [[mongo-to-pg-migration-guide]].
|
||||
|
||||
### 8.5 Auth / Sessions
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `JWT_SECRET` | JWT signing secret |
|
||||
| `JWT_EXPIRES_IN` | Access token TTL (e.g. `7d`) |
|
||||
| `REFRESH_TOKEN_EXPIRES_IN` | Refresh token TTL (e.g. `30d`) |
|
||||
|
||||
### 8.6 Redis
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `REDIS_URI` | Redis connection string (includes password) |
|
||||
| `REDIS_PASSWORD` | Redis auth password (standalone form) |
|
||||
|
||||
### 8.7 URLs / CORS
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `BASE_URL` | Canonical origin (`https://dev.amn.gg`) |
|
||||
| `API_URL` | API base URL |
|
||||
| `FRONTEND_URL` | Frontend origin |
|
||||
| `BACKEND_URL` | Backend origin |
|
||||
| `CORS_ORIGIN` | Allowed CORS origin |
|
||||
|
||||
### 8.8 File uploads
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `UPLOAD_PATH` | Upload directory inside container | `/app/uploads` |
|
||||
| `MAX_FILE_SIZE` | Max upload bytes | `52428800` (50 MB) |
|
||||
|
||||
### 8.9 Rate limiting
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `RATE_LIMIT_WINDOW_MS` | Window for rate limiter | `900000` (15 min) |
|
||||
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window | `100` |
|
||||
|
||||
> GET `/api/payment/:id` must bypass `paymentLimiter` (30 req/15 min) — see [[backend_rate_limits]].
|
||||
|
||||
### 8.10 SMTP
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `SMTP_HOST` | SMTP server hostname |
|
||||
| `SMTP_PORT` | SMTP port |
|
||||
| `SMTP_SECURE` | TLS (`true`/`false`) |
|
||||
| `SMTP_USER` | SMTP username |
|
||||
| `SMTP_PASS` | SMTP password |
|
||||
| `SMTP_FROM` | From address |
|
||||
|
||||
### 8.11 WebAuthn (Passkeys)
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `WEBAUTHN_RP_ID` | Relying party ID (domain) |
|
||||
| `WEBAUTHN_RP_NAME` | Relying party display name |
|
||||
| `WEBAUTHN_RP_ORIGIN` | Relying party origin URL |
|
||||
|
||||
### 8.12 Admin seed
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `ADMIN_EMAIL` | Bootstrap admin email |
|
||||
| `ADMIN_PASSWORD` | Bootstrap admin password |
|
||||
| `ADMIN_FIRST_NAME` | Admin first name |
|
||||
| `ADMIN_LAST_NAME` | Admin last name |
|
||||
|
||||
### 8.13 Google OAuth
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
|
||||
### 8.14 OpenAI
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `OPENAI_API_KEY` | OpenAI API key |
|
||||
| `OPENAI_DEFAULT_MODEL` | Default model (e.g. `gpt-4`) |
|
||||
| `OPENAI_MAX_TOKENS` | Max tokens per request |
|
||||
| `OPENAI_TEMPERATURE` | Sampling temperature |
|
||||
|
||||
### 8.15 Sentry
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `SENTRY_DSN` | Sentry ingest DSN |
|
||||
|
||||
### 8.16 Wallets / Blockchain
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `ESCROW_WALLET_ADDRESS` | Platform escrow wallet |
|
||||
| `BSC_USDT_CONTRACT` | BSC USDT token contract address |
|
||||
| `ADMIN_PAYOUT_WALLET_ADDRESS` | Admin payout destination |
|
||||
| `RECEIVER_WALLET_ADDRESS` | Default receiver wallet |
|
||||
|
||||
### 8.17 DePay
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DEPAY_INTEGRATION_ID` | DePay integration UUID |
|
||||
| `DEPAY_WEBHOOK_SECRET` | Webhook verification secret |
|
||||
| `DEPAY_NETWORKS` | Enabled chains (e.g. `bsc`) |
|
||||
| `DEPAY_ALLOWED_TOKENS` | Allowed payment tokens |
|
||||
| `DEPAY_PUBLIC_KEY` | DePay public key (PEM) |
|
||||
|
||||
### 8.18 SHKeeper
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `SHKEEPER_API_KEY` | SHKeeper API key |
|
||||
| `SHKEEPER_BASE_URL` | SHKeeper service base URL |
|
||||
| `SHKEEPER_API_URL` | Payment request endpoint |
|
||||
| `SHKEEPER_ENVIRONMENT` | `production` or `sandbox` |
|
||||
| `SHKEEPER_WALLET_ID` | Destination wallet |
|
||||
| `SHKEEPER_NETWORKS` | Enabled chains |
|
||||
| `SHKEEPER_ALLOWED_TOKENS` | Allowed tokens |
|
||||
| `SHKEEPER_FORCE_REAL` | Bypass test mode |
|
||||
| `SHKEEPER_TOKEN` | Token type (e.g. `USDT`) |
|
||||
| `SHKEEPER_CALLBACK_SECRET` | Callback verification secret |
|
||||
| `SHKEEPER_WEBHOOK_SECRET` | Webhook verification secret |
|
||||
|
||||
### 8.19 Request Network
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `REQUEST_NETWORK_ENABLED` | Enable RN provider | — |
|
||||
| `REQUEST_NETWORK_WEBHOOK_SECRET` | Webhook signature secret | — |
|
||||
| `REQUEST_NETWORK_WEBHOOK_CALLBACK_URL` | Public callback URL | — |
|
||||
| `REQUEST_NETWORK_PAYMENT_CURRENCY` | Currency (e.g. `USDC`) | — |
|
||||
| `REQUEST_NETWORK_NETWORK` | Chain (e.g. `bsc`) | — |
|
||||
| `REQUEST_NETWORK_MERCHANT_REFERENCE` | Merchant address reference | — |
|
||||
| `REQUEST_NETWORK_API_BASE_URL` | RN API root | — |
|
||||
| `REQUEST_NETWORK_API_KEY` | RN API key | — |
|
||||
| `REQUEST_NETWORK_ORIGIN` | Origin header sent to RN | — |
|
||||
| `REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS` | Allow test events | `false` |
|
||||
|
||||
> RN webhook discriminator is `payload.event` (not `eventType`) — see [[rn_webhook_event_field]].
|
||||
> RN proxy addresses differ per chain (not canonical CREATE2 addresses) — see [[rn_proxy_addresses_per_chain]].
|
||||
|
||||
### 8.20 Transaction safety
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `TRANSACTION_SAFETY_ENABLED` | Enable on-chain verification | `true` |
|
||||
| `TRANSACTION_SAFETY_REQUIRE_TX_HASH` | Require tx hash | `true` |
|
||||
| `TRANSACTION_SAFETY_REQUIRE_TRANSFER_MATCH` | Require transfer match | `true` |
|
||||
| `TRANSACTION_SAFETY_MIN_CONFIRMATIONS` | Min block confirmations | `12` |
|
||||
| `TRANSACTION_SAFETY_AML_PROVIDER` | AML provider (`none`, `chainalysis`) | `none` |
|
||||
|
||||
### 8.21 Payment routing
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `PAYMENT_PROVIDER` | Active provider |
|
||||
| `PAYMENT_ENABLED_PROVIDERS` | Comma-separated enabled providers |
|
||||
| `PAYMENT_PROVIDER_MODE` | `live` or `test` |
|
||||
| `PAYMENT_ROLLBACK_PROVIDER` | Fallback provider |
|
||||
|
||||
### 8.22 Telegram
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `TELEGRAM_FEATURE_ENABLED` | Enable Telegram integration |
|
||||
| `TELEGRAM_MINIAPP_ENABLED` | Enable Mini App |
|
||||
| `TELEGRAM_WEBHOOK_ENABLED` | Enable webhook receiver |
|
||||
| `TELEGRAM_BOT_TOKEN` | Main bot token (`@amnescrow_Bot` for dev) |
|
||||
| `TELEGRAM_WEBHOOK_SECRET_TOKEN` | Webhook secret for validation |
|
||||
| `TELEGRAM_INITDATA_MAX_AGE_SEC` | Max age for initData |
|
||||
| `TELEGRAM_INITDATA_REPLAY_WINDOW_MS` | Replay protection window |
|
||||
| `TELEGRAM_WEBHOOK_REPLAY_WINDOW_MS` | Webhook replay protection window |
|
||||
| `TELEGRAM_SESSION_TTL_SEC` | Session TTL |
|
||||
| `TG_NOTIFY_BOT_TOKEN` | Ops/monitoring bot token (`amnGG_MonitorBot`) |
|
||||
| `TG_NOTIFY_CHATS` | Comma-separated chat IDs for ops notifications |
|
||||
|
||||
> Each stack (dev, multi) must have a **different `TELEGRAM_BOT_TOKEN`** — sharing a bot token kills one stack's webhook when the other registers. See [[escrow_multi_woodpecker_deploy]] and stack isolation warning above.
|
||||
|
||||
### 8.23 Pangolin / Newt (optional VPN mesh)
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `PANGOLIN_ENDPOINT` | Pangolin tunnel endpoint |
|
||||
| `NEWT_ID` | Newt node ID |
|
||||
| `NEWT_SECRET` | Newt node secret |
|
||||
|
||||
### 8.24 Frontend (NEXT_PUBLIC_*)
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `NEXT_PUBLIC_API_URL` | Backend API URL (browser-visible) |
|
||||
| `NEXT_PUBLIC_SOCKET_URL` | Socket.IO server URL |
|
||||
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | WalletConnect project ID |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_MAINNET` | Alchemy mainnet key |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_SEPOLIA` | Alchemy Sepolia key |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_POLYGON` | Alchemy Polygon key |
|
||||
| `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS` | Escrow wallet (shown in UI) |
|
||||
| `NEXT_PUBLIC_APP_NAME` | App display name |
|
||||
| `NEXT_PUBLIC_APP_VERSION` | App version string |
|
||||
| `NEXT_PUBLIC_MAPBOX_API_KEY` | Mapbox key (address autocomplete) |
|
||||
| `NEXT_PUBLIC_PASSKEY_RP_NAME` | WebAuthn RP name |
|
||||
| `NEXT_PUBLIC_PASSKEY_RP_ID` | WebAuthn RP ID |
|
||||
| `NEXT_PUBLIC_PASSKEY_ORIGIN` | WebAuthn origin |
|
||||
| `NEXT_PUBLIC_BACKEND_URL` | Backend origin (direct calls) |
|
||||
| `NEXT_PUBLIC_DEPAY_INTEGRATION_ID` | DePay integration ID |
|
||||
| `NEXT_PUBLIC_IS_DEVELOPMENT` | Development flag |
|
||||
| `NEXT_PUBLIC_ENABLE_DEBUG` | Enable client debug logging |
|
||||
| `NEXT_PUBLIC_APP_URL` | Canonical app URL |
|
||||
| `NEXT_PUBLIC_TELEGRAM_BOT_ID` | Telegram bot numeric ID |
|
||||
| `BUILD_STATIC_EXPORT` | Enable `next export` mode (`false` for SSR) |
|
||||
|
||||
> `NEXT_PUBLIC_*` vars are baked into the frontend bundle at build time. Never put secrets in them. Frontend env changes require a fresh image build and redeploy.
|
||||
|
||||
### 8.25 Gatus
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GATUS_TELEGRAM_BOT_TOKEN` | Telegram bot for alert delivery |
|
||||
| `GATUS_TELEGRAM_CHAT_ID` | Target chat ID for alerts |
|
||||
|
||||
---
|
||||
|
||||
## 9. Deploy Workflow
|
||||
|
||||
### 9.1 Normal image update (CI-driven — dev-amn)
|
||||
|
||||
Woodpecker CI builds backend and frontend images, pushes to `git.tbs.amn.gg/escrow/`, then triggers an Arcane GitOps sync which pulls the new image and recreates the container.
|
||||
|
||||
```
|
||||
git push origin dev
|
||||
└─► Woodpecker build pipeline
|
||||
└─► docker push git.tbs.amn.gg/escrow/backend:dev
|
||||
└─► docker push git.tbs.amn.gg/escrow/frontend:dev
|
||||
└─► arcane-cli gitops sync cf6c9eab… (or watchtower polls)
|
||||
└─► escrow-backend restarted with new image
|
||||
└─► escrow-frontend restarted with new image
|
||||
```
|
||||
|
||||
> Always bump `package.json` version before pushing. See [[version_bump_before_ci]].
|
||||
|
||||
### 9.2 escrow-multi deploys (white-label stack)
|
||||
|
||||
**Always use Woodpecker. Never use manual rsync/docker-build/ssh for escrow-multi.**
|
||||
|
||||
```bash
|
||||
# 1. Make changes, bump version in package.json
|
||||
# 2. Commit
|
||||
git commit -m "fix: description (vX.Y.Z)"
|
||||
# 3. Push to Forgejo (remote is "forgejo", not "origin")
|
||||
git push forgejo feature/white-label-shops
|
||||
# 4. Monitor Woodpecker pipeline
|
||||
source ~/CascadeProjects/escrow/.env
|
||||
WOODPECKER_SERVER=$WOODPECKER_SERVER WOODPECKER_TOKEN=$WOODPECKER_TOKEN \
|
||||
woodpecker-cli pipeline ls escrow/backend
|
||||
```
|
||||
|
||||
Frontend is a separate Woodpecker project (`escrow/frontend`). Both push targets trigger their respective pipelines.
|
||||
|
||||
### 9.3 Manual hotfix deploy (backend only — no registry cycle)
|
||||
|
||||
For urgent fixes without a full CI cycle, build locally on the server:
|
||||
|
||||
```bash
|
||||
# 1. Copy changed files to build tree
|
||||
scp -i ~/CascadeProjects/wzp src/services/auth/authRoutes.ts \
|
||||
root@89.58.32.32:/tmp/escrow-backend-build/src/services/auth/
|
||||
|
||||
# 2. Rebuild image on server (~3 min, ARM64)
|
||||
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
|
||||
"cd /tmp/escrow-backend-build && docker build -f Dockerfile.prod \
|
||||
-t escrow-backend-local:dev ."
|
||||
|
||||
# 3. Restart the backend container
|
||||
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
|
||||
"cd /opt/arcane/data/projects/escrow-dev && docker compose up -d backend"
|
||||
```
|
||||
|
||||
The `docker-compose.override.yml` at `/opt/arcane/data/projects/escrow-dev/docker-compose.override.yml` sets `pull_policy: never` for `escrow-backend-local:dev` so watchtower never clobbers it.
|
||||
|
||||
### 9.4 Bringing the stack up/down
|
||||
|
||||
```bash
|
||||
# via Arcane CLI (preferred)
|
||||
arcane-cli project start devEscrow
|
||||
arcane-cli project stop devEscrow
|
||||
|
||||
# via SSH + docker compose (direct)
|
||||
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
|
||||
"cd /opt/arcane/data/projects/escrow-dev && docker compose up -d"
|
||||
|
||||
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
|
||||
"cd /opt/arcane/data/projects/escrow-dev && docker compose down"
|
||||
```
|
||||
|
||||
### 9.5 Reloading Caddy after Caddyfile edits
|
||||
|
||||
```bash
|
||||
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
|
||||
"docker exec infra-caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||
```
|
||||
|
||||
No container restart needed.
|
||||
|
||||
### 9.6 Updating env vars
|
||||
|
||||
1. Edit `.env` on the server: `/opt/arcane/data/projects/escrow-dev/.env`
|
||||
2. Restart affected container:
|
||||
```bash
|
||||
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
|
||||
"cd /opt/arcane/data/projects/escrow-dev && docker compose up -d backend"
|
||||
```
|
||||
3. Frontend `NEXT_PUBLIC_*` vars are baked at build time — they require a fresh image build and full redeploy via CI.
|
||||
|
||||
### 9.7 Verifying a deploy
|
||||
|
||||
```bash
|
||||
# Check running containers
|
||||
arcane-cli project status devEscrow
|
||||
|
||||
# Check backend version
|
||||
curl https://dev.amn.gg/api/version
|
||||
|
||||
# Check health (all stores + RN registries)
|
||||
curl https://dev.amn.gg/api/health | jq .
|
||||
|
||||
# Tail backend logs
|
||||
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
|
||||
"docker logs -f escrow-backend --tail 100"
|
||||
```
|
||||
|
||||
> CI ✓ green does NOT guarantee the new image was pushed. Always verify `curl /api/version` returns the expected version. See [[woodpecker_silent_build_fail]].
|
||||
|
||||
---
|
||||
|
||||
## 10. Dev vs Prod Differences
|
||||
|
||||
| Aspect | dev-amn (`dev.amn.gg`) | Prod (`amn.gg`) |
|
||||
|---|---|---|
|
||||
| Compose file | `deployment/dev-amn/docker-compose.yml` | Separate prod stack (not in this repo) |
|
||||
| Image registry | `git.tbs.amn.gg/escrow` | Same registry, prod tags |
|
||||
| Image tag | `:dev` | `:latest` or versioned |
|
||||
| MongoDB | Present (dev parity — retired in prod) | Not present |
|
||||
| `ENABLE_TESTNET_CHAINS` | `true` (compose override) | Not set / `false` |
|
||||
| `NODE_ENV` | `production` | `production` |
|
||||
| `NEXT_PUBLIC_IS_DEVELOPMENT` | `false` | `false` |
|
||||
| `PAYMENT_PROVIDER_MODE` | `live` | `live` |
|
||||
| `REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS` | May be `true` for RN testing | `false` |
|
||||
| `TRANSACTION_SAFETY_MIN_CONFIRMATIONS` | `12` (default) | `12` (default) |
|
||||
| Gatus monitoring | Monitors both dev + prod endpoints | Shared Gatus instance |
|
||||
| TLS | Cloudflare proxy → Caddy (`disable_redirects`) | Same |
|
||||
| Version bump | Required before CI push | Required |
|
||||
| Watchtower labels | Present in legacy compose | Prod stack may differ |
|
||||
|
||||
---
|
||||
|
||||
## 11. Secret Management
|
||||
|
||||
The `.env` file on the server is the single source of runtime secrets. It is never committed.
|
||||
|
||||
- **Server location:** `/opt/arcane/data/projects/escrow-dev/.env`
|
||||
- **Permissions:** `chmod 600`, owned by root
|
||||
- **Repo template:** `deployment/.env` — contains live dev values, treated as low-sensitivity dev config; rotate all values before using in production
|
||||
|
||||
### Rules
|
||||
|
||||
1. Never commit `.env` or any file containing real tokens, passwords, or private keys.
|
||||
2. Never pass secrets as Dockerfile `ARG`/`ENV` at build time — they appear in image layers. All secrets are runtime-injected via `env_file`.
|
||||
3. `NEXT_PUBLIC_*` vars are baked into the frontend bundle. Never place secrets in them.
|
||||
4. Wallet addresses are public on-chain but still kept out of the repo for operational hygiene.
|
||||
5. For new deployments: copy `deployment/.env` to the server, fill in real values, `chmod 600`.
|
||||
6. Gatus vars (`GATUS_TELEGRAM_BOT_TOKEN`, `GATUS_TELEGRAM_CHAT_ID`) go into the same `.env`.
|
||||
7. Telegram bot tokens are high-value — rotate immediately if accidentally pushed.
|
||||
|
||||
### Sensitive variable groups
|
||||
|
||||
| Group | Variables | Risk if leaked |
|
||||
|---|---|---|
|
||||
| JWT | `JWT_SECRET` | Full session forgery |
|
||||
| DB credentials | `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, `MONGO_INITDB_ROOT_PASSWORD` | Database access |
|
||||
| Payment webhook secrets | `REQUEST_NETWORK_WEBHOOK_SECRET`, `DEPAY_WEBHOOK_SECRET`, `SHKEEPER_CALLBACK_SECRET`, `SHKEEPER_WEBHOOK_SECRET` | Fake payment injection |
|
||||
| Bot tokens | `TELEGRAM_BOT_TOKEN`, `TG_NOTIFY_BOT_TOKEN` | Bot takeover / webhook hijack |
|
||||
| OAuth secrets | `GOOGLE_CLIENT_SECRET` | OAuth impersonation |
|
||||
| API keys | `OPENAI_API_KEY`, `REQUEST_NETWORK_API_KEY`, `SHKEEPER_API_KEY` | Billing / data access |
|
||||
| Sentry DSN | `SENTRY_DSN` | Error data exfiltration |
|
||||
418
10 - Services/frontend.md
Normal file
418
10 - Services/frontend.md
Normal file
@@ -0,0 +1,418 @@
|
||||
---
|
||||
title: Frontend Service — amn-frontend
|
||||
tags: [service, frontend, nextjs, react, web3, telegram]
|
||||
created: 2026-06-08
|
||||
updated: 2026-06-12
|
||||
---
|
||||
|
||||
# Frontend Service — amn-frontend
|
||||
|
||||
## 1. Overview
|
||||
|
||||
`amn-frontend` is the primary user-facing application for the Amanat (AMN) escrow marketplace. It serves buyers, sellers, and admins through a unified Next.js 16 App Router application with a Persian-first (RTL) UI.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Package name | `amn-frontend` |
|
||||
| Version | **2.11.89** |
|
||||
| Status | Active — `main` deployed to `dev.amn.gg`; `feature/white-label-shops` deployed to `multi.amn.gg` |
|
||||
| Framework | Next.js 16 (App Router + Turbopack), React 19, TypeScript strict |
|
||||
| Dev port | `8083` (both local and Docker) |
|
||||
| Package manager | `yarn@1.22.22` |
|
||||
| Node requirement | `>=20` |
|
||||
| Repo | `git@git.tbs.amn.gg:escrow/frontend.git` |
|
||||
|
||||
The app covers the full escrow lifecycle: request creation, multi-seller offer collection, negotiation, on-chain payment (BSC/ETH/Base/TON), delivery confirmation, dispute handling, loyalty points, tenant admin for white-label shops, public seller-shop browsing, and a Telegram Mini App shell for mobile-native access.
|
||||
|
||||
> [!note] Multi-shop branch
|
||||
> `feature/white-label-shops` adds `TenantProvider`, `/dashboard/admin/tenants`, custom-domain controls, bot activation links, and the `WEBAPP_ENABLED` middleware gate that keeps `multi.amn.gg` Mini App-first while leaving dashboard/auth routes reachable.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tech Stack
|
||||
|
||||
| Layer | Library / Version | Notes |
|
||||
|---|---|---|
|
||||
| Framework | `next@^16.1.1` | App Router, Turbopack dev server |
|
||||
| UI runtime | `react@^19.1.0`, `react-dom@^19.1.0` | Server + Client Components |
|
||||
| Component library | `@mui/material@^9.0.1` | MUI v9 with Emotion; `@mui/lab`, `@mui/x-data-grid`, `@mui/x-date-pickers`, `@mui/x-tree-view` |
|
||||
| Styling engine | `@emotion/react`, `@emotion/styled`, `stylis-plugin-rtl` | RTL support via stylis |
|
||||
| State / data fetching | `@tanstack/react-query@^5.83.0`, `swr@^2.3.3` | TanStack Query is primary; SWR used in some legacy paths |
|
||||
| Real-time | `socket.io-client@^4.8.1` | Bidirectional events; custom `SocketContext` |
|
||||
| Forms | `react-hook-form@^7.77.0`, `@hookform/resolvers@^5.0.1`, `zod@^4.0.10` | Schema validation via Zod v4 |
|
||||
| i18n | `i18next@^26.3.0`, `react-i18next@^17.0.8` | 6 locales (en, fa, ar, fr, cn, vi); RTL for fa/ar |
|
||||
| Web3 — EVM | `wagmi@^2.19.5`, `viem@^2.31.7`, `ethers@^6.15.0` | WalletConnect + MetaMask + Trezor |
|
||||
| Web3 — TON | `@tonconnect/ui-react@^2.4.4`, `@ton/core@^0.63.1` | TON wallet payments |
|
||||
| Hardware wallet | `@trezor/connect-web@^9.7.3` | Trezor signing flow |
|
||||
| Chain indexing | `alchemy-sdk@^3.6.1` | Alchemy for multi-chain queries |
|
||||
| Rich text editor | `@tiptap/react@^3.23.6` + extensions | Used in post/blog editor |
|
||||
| Charts | `apexcharts@^5.10.1`, `react-apexcharts@^2.1.0` | Dashboard KPI charts |
|
||||
| Animation | `framer-motion@^12.13.0` | Page transitions and UI motion |
|
||||
| Carousel | `embla-carousel-react@8.6.0` | Product / shop carousels |
|
||||
| Maps | `mapbox-gl@^3.12.0`, `react-map-gl@^8.0.4` | Address / location pickers |
|
||||
| HTTP client | `axios@^1.11.0` | Centralised instance with auth interceptors in `src/lib/axios.ts` |
|
||||
| Notifications | `notistack@^3.0.2`, `sonner@^2.0.3` | Snackbar + toast |
|
||||
| Error monitoring | `@sentry/nextjs@^10.22.0` | SDK wraps Next.js build + runtime |
|
||||
| CAPTCHA | `@marsidev/react-turnstile@^1.5.2` | Cloudflare Turnstile |
|
||||
| Dates | `dayjs@^1.11.13`, `date-fns-jalali@^4.1.0-0` | Jalali (Persian) calendar support |
|
||||
| QR code | `qrcode@^1.5.4` | Wallet payment QR generation |
|
||||
| Fonts | DM Sans, Inter, Nunito Sans, Public Sans, Barlow | Variable fonts via `@fontsource-variable` |
|
||||
|
||||
---
|
||||
|
||||
## 3. App Router Page Structure
|
||||
|
||||
All routes live under `frontend/src/app/`. The dev server and Docker container both bind port `8083`.
|
||||
|
||||
### Top-level route segments
|
||||
|
||||
| Route | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `/` | Public | Landing / marketing home |
|
||||
| `/api/health` | API Route | Container health-check endpoint |
|
||||
| `/api/llm` | API Route | LLM proxy for amanat-assist features |
|
||||
| `/auth/jwt/*` | Public | Sign-in, sign-up, OTP verify, password reset, update |
|
||||
| `/checkout/request-network/*` | Public | Request Network payment checkout shell |
|
||||
| `/dashboard/*` | Protected | Main authenticated app (see below) |
|
||||
| `/design-preview` | Internal | Theme/component sandbox |
|
||||
| `/error` | Public | Global error display |
|
||||
| `/payment/callback`, `/payment/cancel` | Public | Payment gateway redirect landing |
|
||||
| `/post/[slug]` | Public | Blog post reader |
|
||||
| `/shop/[seller]/[id]` | Public | Public seller shop / item view |
|
||||
| `/store/items`, `/store/checkout` | Public | Storefront browsing and checkout |
|
||||
| `/telegram` | Mini App | Telegram Mini App shell (see §7) |
|
||||
|
||||
### Dashboard sub-routes (AuthGuard + EmailVerificationGuard)
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `/dashboard` → `/dashboard/overview` | KPI home tiles, recent activity |
|
||||
| `/dashboard/chat` | Real-time escrow chat |
|
||||
| `/dashboard/account/*` | Profile, address, notifications, wallet, passkey |
|
||||
| `/dashboard/request/*` | Buyer purchase requests |
|
||||
| `/dashboard/request-template/*` | Seller request templates |
|
||||
| `/dashboard/payment/*` | Payment history and detail |
|
||||
| `/dashboard/points/*` | Loyalty hub — transactions, referrals, levels |
|
||||
| `/dashboard/disputes/*` | Dispute creation and management |
|
||||
| `/dashboard/seller/*` | Seller-side offer management |
|
||||
| `/dashboard/shop-settings/*` | Seller shop configuration (incl. Telegram config) |
|
||||
| `/dashboard/shops/*` | Browse / checkout from within dashboard |
|
||||
| `/dashboard/user/*` | Admin user management |
|
||||
| `/dashboard/post/*` | Admin blog editor (Tiptap) |
|
||||
| `/dashboard/admin/tenants/*` | Tenant admin (white-label shops; `feature/white-label-shops` only) |
|
||||
| `/dashboard/assist/*` | AI assistant (amanat-assist) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Sections / Features
|
||||
|
||||
### Marketplace and escrow flow
|
||||
|
||||
The primary buyer journey:
|
||||
|
||||
1. Buyer submits a **purchase request** (`/dashboard/request/new`) — product description, budget, chain preference.
|
||||
2. Sellers see the request and submit **offers** via request templates (`/dashboard/request-template`).
|
||||
3. Buyer selects an offer; both sides enter the **escrow chat** (`/dashboard/chat`).
|
||||
4. Buyer initiates payment — on-chain via Wagmi/Trezor or off-chain via Request Network.
|
||||
5. After delivery, buyer releases escrow funds; on dispute, both parties access `/dashboard/disputes`.
|
||||
|
||||
### Dashboard
|
||||
|
||||
Multi-role dashboard accessible post-login. Guards:
|
||||
- `AuthGuard` — redirects unauthenticated users to `/auth/jwt/sign-in`.
|
||||
- `EmailVerificationGuard` — blocks unverified accounts on key routes.
|
||||
|
||||
Sidebar nav adapts to role: buyer, seller, admin, or multi-tenant operator.
|
||||
|
||||
### Admin
|
||||
|
||||
`/dashboard/user` and `/dashboard/post` are admin-only sections gated by role check in the layout. Tenant admin (`/dashboard/admin/tenants`) is only visible on the `feature/white-label-shops` build.
|
||||
|
||||
### Telegram Mini App
|
||||
|
||||
Full Telegram Mini App (TMA) at `/telegram`. See §7 for integration details.
|
||||
|
||||
### White-label shops
|
||||
|
||||
`feature/white-label-shops` branch adds multi-tenancy: `TenantProvider` (Context), `/dashboard/admin/tenants` CRUD, custom domain config per tenant, and a `WEBAPP_ENABLED` middleware flag to route Mini App-first for `multi.amn.gg`.
|
||||
|
||||
### Blog / content
|
||||
|
||||
Public blog at `/post/[slug]` rendered server-side. Admin writes posts via Tiptap rich-text editor at `/dashboard/post`.
|
||||
|
||||
### AI assistant (amanat-assist)
|
||||
|
||||
`/api/llm` proxies requests to the amanat-assist backend service. The dashboard `/assist` section provides the in-app chat interface.
|
||||
|
||||
---
|
||||
|
||||
## 5. State Management
|
||||
|
||||
| Layer | Mechanism | Usage |
|
||||
|---|---|---|
|
||||
| Server cache / async state | `@tanstack/react-query` | All API data fetching, mutation, background refetch |
|
||||
| Legacy async state | `swr` | Some older sections not yet migrated to TQ |
|
||||
| Real-time events | `SocketContext` (`src/contexts/`) | Socket.io connection; exposes socket via `useSocket` hook |
|
||||
| Global UI state | React Context (multiple providers) | Auth, Settings (theme/direction/language), Web3 |
|
||||
| Form state | `react-hook-form` + Zod | All forms; validation on client |
|
||||
| Zustand | Not in use | No Zustand dependency in package.json |
|
||||
|
||||
The root layout stacks providers in order: `ThemeProvider` → `SettingsProvider` → `AuthProvider` → `QueryClientProvider` → `SocketContextProvider` → `Web3Provider`.
|
||||
|
||||
The Telegram layout uses a minimal provider stack: `TonConnectUIProvider` + `QueryClientProvider` only — no dashboard providers.
|
||||
|
||||
---
|
||||
|
||||
## 6. Internationalization
|
||||
|
||||
| Detail | Value |
|
||||
|---|---|
|
||||
| Library | `i18next@^26.3.0` + `react-i18next@^17.0.8` |
|
||||
| Language detection | `i18next-browser-languagedetector` + `accept-language` (server hint) |
|
||||
| Locales shipped | English (`en`), Persian/Farsi (`fa`), Arabic (`ar`), French (`fr`), Chinese (`cn`), Vietnamese (`vi`) |
|
||||
| RTL locales | `fa`, `ar` — `direction: rtl` applied at theme level; `stylis-plugin-rtl` transforms MUI Emotion styles |
|
||||
| Jalali calendar | `date-fns-jalali@^4.1.0-0` — date pickers switch to Jalali for `fa` locale |
|
||||
| Translation files | `src/locales/langs/{en,fa,ar,fr,cn,vi}/*.json` (lazy-loaded via `i18next-resources-to-backend`) |
|
||||
| Telegram locales | `src/sections/telegram/locales/{en,fa}.ts` — standalone namespace for TMA strings |
|
||||
| Default locale | Determined by browser; Persian is the primary product locale |
|
||||
|
||||
RTL layout direction is set in `SettingsProvider` and passed to the MUI theme. The `stylis-plugin-rtl` plugin auto-mirrors margin/padding/float/border-radius CSS properties.
|
||||
|
||||
---
|
||||
|
||||
## 7. Telegram Mini App Integration
|
||||
|
||||
### Loading mechanism
|
||||
|
||||
`/app/telegram/layout.tsx` injects the Telegram SDK via `next/script` with `strategy="beforeInteractive"`:
|
||||
|
||||
```tsx
|
||||
<Script src="https://telegram.org/js/telegram-web-app.js" strategy="beforeInteractive" />
|
||||
```
|
||||
|
||||
No `@telegram-apps/sdk` npm package is used. The native CDN script approach is chosen for compatibility with Telegram's own versioned releases.
|
||||
|
||||
### WebApp wrapper
|
||||
|
||||
`src/utils/telegram-webapp.ts` provides a typed wrapper around `window.Telegram.WebApp`, exposing:
|
||||
- `initData` / `initDataUnsafe` — raw launch parameters
|
||||
- `colorScheme`, `themeParams` — Telegram UI theme
|
||||
- `MainButton`, `BackButton` — native Telegram controls
|
||||
- `close()`, `expand()`, `ready()` helper calls
|
||||
|
||||
### Auth flow
|
||||
|
||||
1. On TMA load, the app extracts `initData` from `window.Telegram.WebApp`.
|
||||
2. The frontend calls `POST /api/auth/telegram` with the signed `initData` string.
|
||||
3. Backend verifies the HMAC signature against `TELEGRAM_BOT_TOKEN` and issues a JWT.
|
||||
4. The JWT is stored in memory / cookie, and subsequent API calls use the standard auth header.
|
||||
|
||||
Replay protection on this path is intentionally absent — Telegram may reuse `initData` across reloads. The backend relies on HMAC verification + `auth_date` freshness only.
|
||||
|
||||
### TMA route structure
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `/telegram` | Entry point — reads initData, authenticates, redirects |
|
||||
| `/telegram/shop` | Seller list and product browsing |
|
||||
| `/telegram/cart` | In-app cart and checkout handoff |
|
||||
| `/telegram/account` | Account tab (mirrors dashboard/account) |
|
||||
|
||||
### Components
|
||||
|
||||
`src/sections/telegram/components/` contains TMA-native components:
|
||||
- `telegram-header.tsx` — top navigation bar styled to Telegram theme
|
||||
- `telegram-chat-row.tsx`, `telegram-chat-bubble.tsx`, `telegram-chat-composer.tsx` — inline chat UI
|
||||
- `telegram-request-stepper.tsx` — step-through purchase request wizard
|
||||
- `telegram-cart-fab.tsx` — floating cart icon
|
||||
- `telegram-onboarding-sheet.tsx` — first-run onboarding bottom sheet
|
||||
- `telegram-filter-drawer.tsx`, `telegram-list-row.tsx`, `telegram-list-controls.tsx` — marketplace list views
|
||||
- `telegram-theme-toggle.tsx`, `telegram-language-toggle.tsx` — in-app settings
|
||||
- `telegram-unlinked-state.tsx` — shown when no shop is linked to the bot
|
||||
|
||||
### Shop-settings Telegram config
|
||||
|
||||
`/dashboard/shop-settings` includes a Telegram configuration UI where sellers can:
|
||||
- Link their Telegram bot to their shop
|
||||
- Set the Mini App URL
|
||||
- Preview the bot launch button (`TelegramAppButton` component)
|
||||
|
||||
---
|
||||
|
||||
## 8. Web3 Integration
|
||||
|
||||
### EVM (Ethereum / BSC / Base / Polygon / Arbitrum)
|
||||
|
||||
| Component | Detail |
|
||||
|---|---|
|
||||
| Wagmi | `wagmi@^2.19.5` — React hooks for wallet connection, transaction signing, contract reads |
|
||||
| Viem | `viem@^2.31.7` — low-level EVM client used by wagmi internally |
|
||||
| Ethers | `ethers@^6.15.0` — used in `src/web3/web3Service.ts` for legacy contract interaction |
|
||||
| WalletConnect | Project ID via `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` |
|
||||
| Alchemy | Per-chain API keys (`NEXT_PUBLIC_ALCHEMY_API_KEY_{MAINNET,ARBITRUM,BASE,POLYGON,SEPOLIA}`) for RPC and indexing |
|
||||
| Escrow wallet | `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS` — the platform escrow contract / EOA |
|
||||
|
||||
Chains supported: Ethereum Mainnet, BSC (BNB Chain), Base, Polygon, Arbitrum, Sepolia (testnet).
|
||||
|
||||
Provider hierarchy in `src/web3/context/`:
|
||||
- `wagmi-provider.tsx` — wraps app in `WagmiProvider` + `QueryClientProvider`
|
||||
- `web3-provider.tsx` — DePay + custom payment orchestration layer
|
||||
- `web3-context.tsx` + `use-web3-context.ts` — React Context for payment state
|
||||
|
||||
### Trezor
|
||||
|
||||
`@trezor/connect-web@^9.7.3` handles hardware wallet signing. `src/web3/components/web3-signing-card.tsx` surfaces the Trezor confirmation UI within the payment flow.
|
||||
|
||||
### TON
|
||||
|
||||
`@tonconnect/ui-react@^2.4.4` and `@ton/core@^0.63.1` enable TON wallet connections and payments. The Telegram layout wraps the Mini App in `TonConnectUIProvider` so TON payments work natively within Telegram.
|
||||
|
||||
### Payment UI components
|
||||
|
||||
| Component | Location |
|
||||
|---|---|
|
||||
| `web3-connect-card.tsx` | Wallet selection / connection modal |
|
||||
| `web3-payment.tsx` | Payment execution with status tracking |
|
||||
| `web3-signing-card.tsx` | Trezor/hardware wallet signing prompt |
|
||||
|
||||
### Request Network
|
||||
|
||||
`/app/checkout/request-network/` is the payment shell for Request Network-based payments. The checkout flow is server-side rendered and uses a dedicated layout outside the dashboard guard.
|
||||
|
||||
---
|
||||
|
||||
## 9. CI/CD
|
||||
|
||||
Two Woodpecker pipelines run on the CI host at `89.58.32.32` (`linux/arm64`).
|
||||
|
||||
### `production.yml` — `main` branch → dev.amn.gg
|
||||
|
||||
Trigger: push to `main` or `master`.
|
||||
|
||||
| Step | Action |
|
||||
|---|---|
|
||||
| `get-version` | Reads `package.json` version → writes `dev-<version>` to `.tags` |
|
||||
| `build-and-deploy` | `docker build -t git.tbs.amn.gg/escrow/frontend:dev .` then `docker compose up -d --no-deps --pull never frontend` against `/opt/escrow-dev/docker-compose.yml` |
|
||||
| `notify` | `node scripts/ci/tg-notify.cjs` → Telegram notification (success/failure) |
|
||||
|
||||
Image tag: `git.tbs.amn.gg/escrow/frontend:dev`. No registry push — image is built locally on the CI host. `pull_policy: never` in the compose override prevents watchtower from pulling a stale remote image.
|
||||
|
||||
### `multi.yml` — `feature/white-label-shops` → multi.amn.gg
|
||||
|
||||
Trigger: push to `feature/white-label-shops`.
|
||||
|
||||
| Step | Action |
|
||||
|---|---|
|
||||
| `build-local` | `docker build` with hardcoded `NEXT_PUBLIC_*` build-args for `multi.amn.gg` → tags as `escrow-multi-frontend:local` |
|
||||
| `deploy` | `docker compose up -d --force-recreate --no-deps frontend` against `/opt/arcane/data/projects/escrow-multi` |
|
||||
| `notify` | `node scripts/ci/tg-notify.cjs` → Telegram notification |
|
||||
|
||||
> [!important] Version bump required
|
||||
> Every push that triggers a build must increment the patch version in `package.json`. Container images are tagged by version — an unchanged version overwrites the previous image and loses history. See RTK.md version policy.
|
||||
|
||||
### Telegram CI notifications
|
||||
|
||||
`scripts/ci/tg-notify.cjs` is the CI notification script. It reads `TG_TOKEN` and `TG_USERS` from Woodpecker secrets. Messages must not use `parse_mode` (HTML/Markdown) to avoid Telegram API 400 errors from unescaped characters in commit messages.
|
||||
|
||||
---
|
||||
|
||||
## 10. Local Development Quick-Start
|
||||
|
||||
```bash
|
||||
# Prerequisites: Node >=20, yarn 1.22.22
|
||||
|
||||
cd frontend/
|
||||
|
||||
# Install dependencies
|
||||
yarn install
|
||||
|
||||
# Copy and populate env vars
|
||||
cp .env.local.example .env.local
|
||||
# Edit .env.local with backend URL, API keys, etc.
|
||||
|
||||
# Start dev server (Turbopack, port 8083)
|
||||
yarn dev
|
||||
|
||||
# Alternative: webpack (for debugging Turbopack-specific issues)
|
||||
yarn dev:webpack
|
||||
|
||||
# Type-check
|
||||
npx tsc --noEmit
|
||||
|
||||
# Lint
|
||||
yarn lint
|
||||
|
||||
# Unit tests
|
||||
yarn test
|
||||
|
||||
# E2E tests (requires running backend)
|
||||
yarn playwright:install # once
|
||||
yarn test:e2e
|
||||
|
||||
# Production build (outputs standalone server)
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
The standalone server output is at `.next/standalone/server.js`. The `build` script copies static assets and public folder into the standalone bundle automatically.
|
||||
|
||||
---
|
||||
|
||||
## 11. Environment Variables
|
||||
|
||||
All `NEXT_PUBLIC_*` variables are baked into the client bundle at build time. Server-side and secret variables are runtime-only.
|
||||
|
||||
### Client-side (build-time baked)
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `NEXT_PUBLIC_API_URL` | Backend API base URL (e.g. `https://dev.amn.gg/api`) |
|
||||
| `NEXT_PUBLIC_BACKEND_URL` | Backend root URL (used for non-API paths) |
|
||||
| `NEXT_PUBLIC_APP_URL` | Canonical frontend URL |
|
||||
| `NEXT_PUBLIC_SOCKET_URL` | Socket.io server URL |
|
||||
| `NEXT_PUBLIC_APP_NAME` | Display name of the application |
|
||||
| `NEXT_PUBLIC_APP_VERSION` | App version string (mirrors `package.json` version) |
|
||||
| `NEXT_PUBLIC_ASSETS_DIR` | Public assets base path |
|
||||
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | WalletConnect Cloud project ID |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_MAINNET` | Alchemy RPC key — Ethereum Mainnet |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_BASE` | Alchemy RPC key — Base |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_ARBITRUM` | Alchemy RPC key — Arbitrum |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_POLYGON` | Alchemy RPC key — Polygon |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_SEPOLIA` | Alchemy RPC key — Sepolia testnet |
|
||||
| `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS` | Platform escrow wallet / contract address |
|
||||
| `NEXT_PUBLIC_TELEGRAM_BOT_ID` | Telegram Bot ID for Mini App auth |
|
||||
| `NEXT_PUBLIC_TELEGRAM_MINI_APP_URL` | Deep link URL for TMA launches |
|
||||
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key |
|
||||
| `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||
| `NEXT_PUBLIC_MAPBOX_API_KEY` | Mapbox GL access token |
|
||||
| `NEXT_PUBLIC_ENABLE_TEST_PAYMENT` | `"true"` to show test payment UI |
|
||||
| `NEXT_PUBLIC_PASSKEY_RP_ID` | WebAuthn relying party ID |
|
||||
| `NEXT_PUBLIC_PASSKEY_ORIGIN` | WebAuthn origin |
|
||||
|
||||
### Multi-stack build-arg overrides (Woodpecker `multi.yml`)
|
||||
|
||||
The multi pipeline passes `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_BACKEND_URL`, `NEXT_PUBLIC_SERVER_URL`, `NEXT_PUBLIC_SOCKET_URL`, `NEXT_PUBLIC_PASSKEY_RP_ID`, and `NEXT_PUBLIC_PASSKEY_ORIGIN` as `--build-arg` to target `multi.amn.gg`.
|
||||
|
||||
> [!warning] Two stacks, two bot tokens
|
||||
> `NEXT_PUBLIC_TELEGRAM_BOT_ID` must differ between the `escrow-dev` and `escrow-multi` stacks. Sharing a bot token causes Telegram webhook delivery to break for one of the stacks.
|
||||
|
||||
---
|
||||
|
||||
## 12. Known Issues / Open Items
|
||||
|
||||
| ID | Area | Description |
|
||||
|---|---|---|
|
||||
| FE-01 | Performance | Measured 300–800ms API response times on `dev.amn.gg` are WAN RTT-bound (~235ms), not DB-bound. Server-side requests from loopback `:8083` show 3–12ms. Fix: CDN/edge delivery or server-side rendering that avoids client roundtrips. |
|
||||
| FE-02 | Cart isolation | Global payment socket broadcasts previously wiped every user's cart. Provider gate added in v2.8.4; backend room-scoping remains an open follow-up. |
|
||||
| FE-03 | Real-time | Socket.io rooms are not yet fully scoped by provider on the backend — global broadcast events can still leak cross-user in some edge cases. |
|
||||
| FE-04 | TMA auth | Telegram `initData` replay protection is intentionally absent. If the threat model changes, a server-side session deduplication layer would be needed at `/api/auth/telegram`. |
|
||||
| FE-05 | State migration | Some data-fetching paths still use `swr` rather than `@tanstack/react-query`. Incremental migration to TQ is ongoing. |
|
||||
| FE-06 | Multi-chain scanner | The AMN scanner payment rail watches specific chains. Cross-chain support (multi-seller + multi-chain) is not fully wired. |
|
||||
| FE-07 | Trezor | `@trezor/connect-web` requires a popup; CSP and popup-blocker edge cases exist in some Telegram In-App Browser contexts. |
|
||||
| FE-08 | Pre-push hook | The backend repo has a pre-push TSC hook that blocks on full-tree errors; a parallel agent's mid-refactor tree can block clean frontend commits. Always use explicit `git add <paths>`, never `git add -A`. |
|
||||
| FE-09 | CI version policy | Every CI-triggering push must bump the patch version. An unchanged version silently overwrites the previous image tag. |
|
||||
| FE-10 | TON payments | TON wallet integration is present but not fully tested end-to-end on mainnet. Verify TON mainnet contract addresses before enabling for buyers. |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-06-12 — reflects v2.11.89*
|
||||
588
10 - Services/scanner.md
Normal file
588
10 - Services/scanner.md
Normal file
@@ -0,0 +1,588 @@
|
||||
---
|
||||
title: AMN Pay Scanner
|
||||
tags: [service, scanner, payment, go, blockchain]
|
||||
version: 0.1.10
|
||||
created: 2026-06-08
|
||||
updated: 2026-06-12
|
||||
---
|
||||
|
||||
# AMN Pay Scanner
|
||||
|
||||
> [!info]
|
||||
> Version: **0.1.10** — Go 1.25 — Status: **production (BSC + Ethereum + BSC Testnet)**, other chains staged behind `SCANNER_ENABLED_CHAINS`
|
||||
> Repo: `scanner/` within the escrow monorepo.
|
||||
> Cross-ref: [[Scanner Architecture]] | [[Scanner API]]
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
AMN Pay Scanner is a standalone Go microservice that watches on-chain payment events across EVM chains, Tron, and TON, and notifies the backend via signed webhook when a payment is confirmed.
|
||||
|
||||
### What it replaces
|
||||
|
||||
The platform previously relied on **Request Network** as its payment infrastructure layer. That dependency introduced:
|
||||
|
||||
- An external smart-contract registry whose canonical proxy addresses differ per chain and cannot be trusted without on-chain verification (see memory note on RN proxy addresses)
|
||||
- A closed RN event/webhook pipeline that the backend had no control over
|
||||
- A hard SDK coupling between the backend and RN's versioned contracts
|
||||
- Inability to support Tron or TON (not in RN's network)
|
||||
|
||||
AMN Pay Scanner replaces this entirely by:
|
||||
|
||||
1. Deploying an in-house `ERC20FeeProxy` contract on each EVM chain under our own control
|
||||
2. Polling RPC endpoints directly — no RN nodes, no RN SDK
|
||||
3. Deriving payment references in-house using the same keccak256 formula the proxy contract expects
|
||||
4. Delivering signed webhooks using a backend-controlled HMAC secret
|
||||
5. Supporting **direct-address rails** (Tron, TON, manual EVM) where no proxy contract is needed
|
||||
|
||||
### Current status
|
||||
|
||||
| Chain | Status |
|
||||
|---|---|
|
||||
| BNB Smart Chain (56) | Production |
|
||||
| Ethereum Mainnet (1) | Production |
|
||||
| BSC Testnet (97) | Production (testnet) |
|
||||
| Arbitrum One (42161) | Staged — `verified: false` |
|
||||
| Polygon (137) | Staged — `verified: false` |
|
||||
| Base (8453) | Staged — `verified: false` |
|
||||
| Tron Mainnet (728126428) | Staged — `verified: false` |
|
||||
| TON Mainnet (1100) | Staged — `verified: false` |
|
||||
|
||||
---
|
||||
|
||||
## 2. How It Works
|
||||
|
||||
### Step-by-step flow
|
||||
|
||||
```
|
||||
Backend Scanner Chain
|
||||
│ │ │
|
||||
│ POST /intents │ │
|
||||
│ {chainId, token, amount, │ │
|
||||
│ destination, callbackUrl}│ │
|
||||
├──────────────────────────► │ │
|
||||
│ │ persist intent (SQLite) │
|
||||
│ │ derive paymentReference │
|
||||
│ │ compute topicRef (EVM) │
|
||||
│ {intentId, │ │
|
||||
│ paymentReference, │ │
|
||||
│ checkoutBlock} │ │
|
||||
◄──────────────────────────── │ │
|
||||
│ │ │
|
||||
│ (frontend builds tx using │ │
|
||||
│ proxyAddress + │ │
|
||||
│ paymentReference) │ │
|
||||
│ │ poll eth_getLogs │
|
||||
│ ├────────────────────────────►│
|
||||
│ │ logs [] │
|
||||
│ ◄────────────────────────────┤
|
||||
│ │ match Topics[1] → topicRef │
|
||||
│ │ validate token+amount+dest │
|
||||
│ │ status → confirming │
|
||||
│ │ │
|
||||
│ │ (wait confirmationThreshold│
|
||||
│ │ blocks / finality signal) │
|
||||
│ │ │
|
||||
│ POST callbackUrl │ │
|
||||
│ X-AMN-Signature: ... │ │
|
||||
│ {intentId, txHash, │ │
|
||||
│ status:"confirmed", ...} │ │
|
||||
◄──────────────────────────── │ │
|
||||
│ 200 OK │ │
|
||||
├──────────────────────────► │ │
|
||||
│ │ record webhookDeliveredAt │
|
||||
```
|
||||
|
||||
### Intent status lifecycle
|
||||
|
||||
```
|
||||
pending ──(tx seen)──► confirming ──(depth reached)──► confirmed ──(webhook ok)──► [done]
|
||||
│ │ │
|
||||
│ │ (deep reorg / TTL) │ (all retries fail)
|
||||
└────────────────────────┴──────────────► expired webhook_failed
|
||||
```
|
||||
|
||||
- **Tron / TON** skip `confirming` — their chain APIs only surface already-finalized transactions. Status jumps directly to `confirmed`.
|
||||
- **Startup reconciliation**: on startup, any `confirmed` intent with `webhook_delivered_at IS NULL` created within the last 7 days has its webhook re-delivered. This recovers from crashes between `finalizeIntent` and `deliverWebhook`.
|
||||
- **`webhook_failed`** intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and immediately on `POST /admin/webhooks/retry`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Supported Chains
|
||||
|
||||
> [!note]
|
||||
> Chains marked `verified: false` in `supported-chains.json` do **not** start a worker goroutine at runtime. Force-enable specific chain IDs without a rebuild by setting `SCANNER_ENABLED_CHAINS=56,1,42161`.
|
||||
|
||||
| Chain | Chain ID | Type | Proxy Address | Confirmation Depth | Active by Default |
|
||||
|---|---|---|---|---|---|
|
||||
| BNB Smart Chain | 56 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 200 blocks (~10 min) | **yes** |
|
||||
| Ethereum Mainnet | 1 | EVM | `0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C` | 50 blocks (~10 min) | **yes** |
|
||||
| BSC Testnet | 97 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 5 blocks | **yes** (testnet) |
|
||||
| Arbitrum One | 42161 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 2400 blocks (~54 min) | no |
|
||||
| Polygon | 137 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 300 blocks | no |
|
||||
| Base | 8453 | EVM | `0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814` | 300 blocks | no |
|
||||
| Tron Mainnet | 728126428 | Tron | TRC20 USDT contract `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | TronGrid confirmed (~200 reported) | no |
|
||||
| TON Mainnet | 1100 | TON | USDT Jetton master `EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs` | TonCenter finalized (~120 reported) | no |
|
||||
|
||||
> [!warning] Chain-specific notes
|
||||
> - **Ethereum**: uses the v0.1.0 proxy ABI at `0x370DE27…`. A v0.2.0-next proxy is also deployed at `0x0DfbEe…` on ETH but checkout uses the v0.1.0 ABI — do not swap addresses.
|
||||
> - **Arbitrum**: 2400-block threshold covers the optimistic rollup challenge window (~54 min at ~1.3 s/block).
|
||||
> - **Base**: proxy address `0x1892196…` is non-canonical — it differs from the RN CREATE2 expected address for this chain. Verify on-chain before enabling in production.
|
||||
> - **Tron**: no fee-proxy contract exists on Tron. Matching is by unique HD-derived destination address, not payment reference.
|
||||
> - **TON**: lag is reported in **seconds**, not blocks. Per-intent polling is O(pending intents) TonCenter calls per cycle.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ scanner binary │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌────────────────────────────────────────┐ │
|
||||
│ │ HTTP API │ │ Worker Pool │ │
|
||||
│ │ (api.go) │ │ │ │
|
||||
│ │ │ │ ┌────────────────┐ eth_getLogs / │ │
|
||||
│ │ POST /intents │ │ │ ChainWorker ├─► eth_blockNumber │ │
|
||||
│ │ GET /intents │ │ │ (EVM × N) │ (JSON-RPC) │ │
|
||||
│ │ DELETE /intents │ │ └────────────────┘ │ │
|
||||
│ │ POST /balances │ │ ┌────────────────┐ TronGrid REST │ │
|
||||
│ │ /check │ │ │ TronChain- ├─► /v1/contracts/ │ │
|
||||
│ │ POST /balance- │ │ │ Worker │ {addr}/events │ │
|
||||
│ │ watches │ │ └────────────────┘ │ │
|
||||
│ │ GET /balance- │ │ ┌────────────────┐ TonCenter v3 │ │
|
||||
│ │ watches/id │ │ │ TonChain- ├─► /jetton/ │ │
|
||||
│ │ DEL /balance- │ │ │ Worker │ transfers │ │
|
||||
│ │ watches/id │ │ └────────────────┘ │ │
|
||||
│ │ GET /scanner/ │ └─────────────┬──────────────────────── ┘ │
|
||||
│ │ status │ │ match / confirm │
|
||||
│ │ POST /admin/ │ ▼ │
|
||||
│ │ webhooks/retry │ ┌────────────────────────────────────────┐ │
|
||||
│ └────────┬──────────┘ │ SQLite (WAL mode) │ │
|
||||
│ │ │ intents · checkpoints · balance_watches│ │
|
||||
│ │ └───────────────┬────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────┐ ┌────────────────────────────────────────┐ │
|
||||
│ │ BalanceWatch- │ │ webhook.go │ │
|
||||
│ │ Scheduler │ │ HMAC-SHA256 sign → POST callbackUrl │ │
|
||||
│ │ (balance_ │ │ retry: 5s → 30s → 2m → 10m → 1h │ │
|
||||
│ │ watch.go) │ │ → webhook_failed│ │
|
||||
│ └─────────────────┘ └────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Background loops (main.go): │
|
||||
│ • intent TTL expiry (INTENT_TTL_HOURS) │
|
||||
│ • webhook retry loop (WEBHOOK_RETRY_HOURS) │
|
||||
│ • startup reconciliation (confirmed intents, no delivery) │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
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. API Routes
|
||||
|
||||
All endpoints except `/health` require `Authorization: Bearer <SCANNER_API_KEY>`. Request bodies are capped at 64 KB.
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/health` | none | Liveness probe — returns `{"status":"ok"}` |
|
||||
| `GET` | `/scanner/status` | Bearer | Chain lag, last scanned block, pending intent count, active balance-watch count per chain |
|
||||
| `POST` | `/intents` | Bearer | Register a payment intent; returns `intentId`, `paymentReference`, `checkoutBlock` |
|
||||
| `GET` | `/intents/{id}` | Bearer | Fetch full intent record including current status and tx details |
|
||||
| `DELETE` | `/intents/{id}` | Bearer | Cancel a pending intent (sets status to `expired`) |
|
||||
| `POST` | `/balances/check` | Bearer | Read current ERC-20 balance for an address+token on a given EVM chain |
|
||||
| `POST` | `/balance-watches` | Bearer | Start an async balance-change watch on an EVM address/token pair |
|
||||
| `GET` | `/balance-watches/{id}` | Bearer | Fetch balance-watch status, current balance, and check schedule |
|
||||
| `DELETE` | `/balance-watches/{id}` | Bearer | Stop a balance watch (also: `POST /balance-watches/{id}/stop`) |
|
||||
| `POST` | `/admin/webhooks/retry` | Bearer | Force immediate retry of all `webhook_failed` intents |
|
||||
|
||||
Full request/response schemas: [[Scanner API]]
|
||||
|
||||
---
|
||||
|
||||
## 6. Payment Reference Derivation (EVM)
|
||||
|
||||
The `ERC20FeeProxy` contract indexes payments by a `bytes8` reference. The scanner derives it deterministically so the scan loop needs only one indexed DB lookup per log.
|
||||
|
||||
```
|
||||
# Step 1 — derive the bytes8 payment reference
|
||||
input = lower(intentId) + lower(salt) + lower(destination)
|
||||
paymentReference = last8Bytes(keccak256(input)) ← bytes8, stored as 16 hex chars
|
||||
|
||||
# Step 2 — derive the EVM log topic index key
|
||||
topicRef = keccak256(paymentReferenceBytes) ← bytes32, 64 hex chars
|
||||
↑ this is Topics[1] in every TransferWithReferenceAndFee log
|
||||
```
|
||||
|
||||
- `salt` is a 32-byte random hex string generated at intent creation time to prevent reference collisions.
|
||||
- `destination` is the EVM treasury/seller wallet address, always lowercased before hashing.
|
||||
- Both `paymentReference` and `topicRef` are written to the `intents` table at creation time.
|
||||
- The scan inner loop executes `SELECT * FROM intents WHERE topic_ref = ? AND status = 'pending'` — O(1) with index regardless of how many pending intents exist.
|
||||
|
||||
**Event signature** used as `Topics[0]` filter:
|
||||
```
|
||||
TransferWithReferenceAndFee
|
||||
keccak256 = 0xbc8b749850bdc0c5e4a49d50f87ec40dca2acdb70a84e7c6823ccdc7b3b3d4b3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. EVM / Tron / TON Matching Logic
|
||||
|
||||
### EVM
|
||||
|
||||
1. Worker calls `eth_getLogs` for the proxy contract address + `TransferWithReferenceAndFee` event topic in 2 000-block chunks.
|
||||
2. For each log, extract `Topics[1]` (the `topicRef`).
|
||||
3. Query DB: `WHERE topic_ref = ? AND status = 'pending'`.
|
||||
4. On match: decode `log.Data` to extract `tokenAddress`, `amount`, `destination`, `feeAmount`. Validate all four against the intent record.
|
||||
5. Update status to `confirming`; record `txHash`, `blockNumber`, `logIndex`.
|
||||
6. On subsequent polls: if `chainHead - blockNumber + 1 >= confirmationsRequired`, finalize and deliver webhook.
|
||||
|
||||
**Reorg protection**: the EVM checkpoint is rewound by `3 × confirmationThreshold` blocks (clamped 20–500) on every tick. Any log from a reorganized block will be re-fetched and re-matched. The unique index on `(tx_hash, log_index)` prevents double-confirmation if the same log is matched on two consecutive ticks.
|
||||
|
||||
### Tron
|
||||
|
||||
- No proxy contract on Tron. Each intent receives a unique HD-derived destination address.
|
||||
- Worker polls TronGrid `/v1/contracts/{usdtTrc20}/events?event_name=Transfer` filtered to the intent's destination address.
|
||||
- Match criterion: `to == destination AND amount >= intent.Amount`.
|
||||
- TronGrid only surfaces already-confirmed transactions — status jumps directly to `confirmed` with no `confirming` intermediate state.
|
||||
- Addresses from TronGrid arrive in `41xxxx` (21-byte hex) format. The worker normalizes them to `0x`-prefixed 20-byte EVM format for storage and comparison.
|
||||
- Checkpoint is stored as a millisecond Unix timestamp in `last_scanned_block`.
|
||||
- Pagination follows `meta.links.next` until nil.
|
||||
|
||||
### TON
|
||||
|
||||
- Also uses per-intent unique destination addresses (no proxy contract).
|
||||
- Worker calls TonCenter v3 `/jetton/transfers?account={destination}&jetton_master={usdtJetton}&start_utime={checkpoint}` for each pending TON intent individually.
|
||||
- Match criterion: `destination == intent.Destination AND amount >= intent.Amount`.
|
||||
- TonCenter returns only finalized transactions — status jumps directly to `confirmed`.
|
||||
- TON addresses are base64url (`EQ…`/`UQ…`), case-sensitive. They must never be lowercased.
|
||||
- Checkpoint stored as Unix seconds. Lag reported in seconds, not blocks.
|
||||
- **Scaling note**: each pending TON intent triggers one HTTP round-trip per scan cycle. High concurrency will exhaust TonCenter rate limits.
|
||||
|
||||
---
|
||||
|
||||
## 8. Webhook Payload
|
||||
|
||||
### Payment confirmed (intent webhook)
|
||||
|
||||
Posted to `callbackUrl` when an intent reaches `confirmed` status:
|
||||
|
||||
```json
|
||||
{
|
||||
"intentId": "018f1a2b-3c4d-7e8f-9a0b-c1d2e3f4a5b6",
|
||||
"paymentReference": "0xa1b2c3d4e5f60718",
|
||||
"txHash": "0x4a3b2c1d...",
|
||||
"blockNumber": 39000010,
|
||||
"confirmations": 200,
|
||||
"amount": "10000000000000000000",
|
||||
"token": "0x55d398326f99059fF775485246999027B3197955",
|
||||
"chainId": 56,
|
||||
"status": "confirmed"
|
||||
}
|
||||
```
|
||||
|
||||
Headers:
|
||||
- `X-AMN-Signature: hex(HMAC-SHA256(rawBody, callbackSecret))`
|
||||
|
||||
The `confirmations` value is **capped** at the chain acceptance threshold once confirmed. The scanner does not keep incrementing after the payment is safe to credit.
|
||||
|
||||
**Retry schedule on delivery failure**: `5 s → 30 s → 2 min → 10 min → 1 h → webhook_failed`
|
||||
|
||||
After exhausting retries the intent status becomes `webhook_failed`. Recovery: `POST /admin/webhooks/retry` or wait for the `WEBHOOK_RETRY_HOURS` background sweep (default 6 h).
|
||||
|
||||
### Balance changed (balance-watch webhook)
|
||||
|
||||
Posted to the watch's `callbackUrl` when a balance delta is detected:
|
||||
|
||||
```json
|
||||
{
|
||||
"eventType": "balance_changed",
|
||||
"watchId": "payment-123-c56-USDT",
|
||||
"chainId": 56,
|
||||
"chainType": "evm",
|
||||
"address": "0xabc...",
|
||||
"tokenAddress": "0x55d398326f99059fF775485246999027B3197955",
|
||||
"tokenSymbol": "USDT",
|
||||
"decimals": 18,
|
||||
"previousBalance": "0",
|
||||
"currentBalance": "10000000000000000000",
|
||||
"delta": "10000000000000000000",
|
||||
"changeCount": 1,
|
||||
"checkedAt": "2026-06-08T12:00:00Z",
|
||||
"status": "balance_changed"
|
||||
}
|
||||
```
|
||||
|
||||
Additional headers:
|
||||
- `X-AMN-Delivery-ID: <watchId>`
|
||||
- `X-AMN-Event-Type: balance_changed`
|
||||
- `X-AMN-Signature: hex(HMAC-SHA256(rawBody, callbackSecret))`
|
||||
|
||||
The scanner only advances `current_balance` in the DB after a successful (2xx) delivery. A down backend will get the same notification on the next scheduled check.
|
||||
|
||||
**Watch polling cadence** (age-decayed):
|
||||
- First 24 hours: every 5 minutes
|
||||
- 24–48 hours: every 10 minutes
|
||||
- 48–96 hours: every 20 minutes
|
||||
- 96+ hours: every 40 minutes
|
||||
- Hard expiry: 7 days after creation
|
||||
|
||||
---
|
||||
|
||||
## 9. SQLite DB Schema
|
||||
|
||||
Database at `DB_PATH` (default `./scanner.db`; Docker: `/data/scanner.db`). WAL mode with 5 000 ms busy timeout. Connection pool capped at 1 to serialize writes.
|
||||
|
||||
### `intents`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `intent_id` | TEXT PK | Caller-supplied UUID |
|
||||
| `chain_id` | INTEGER | Numeric chain ID |
|
||||
| `chain_type` | TEXT | `evm` / `tron` / `ton` |
|
||||
| `token_address` | TEXT | EVM/Tron: lowercase `0x` hex; TON: base64url |
|
||||
| `destination` | TEXT | Receiving address |
|
||||
| `amount` | TEXT | Base-10 smallest unit (wei / TRC-20 units / nanoton) |
|
||||
| `payment_reference` | TEXT | 8-byte hex — EVM proxy rail only |
|
||||
| `topic_ref` | TEXT | `keccak256(paymentReferenceBytes)` — EVM scan index |
|
||||
| `status` | TEXT | `pending` / `confirming` / `confirmed` / `expired` / `webhook_failed` |
|
||||
| `callback_url` | TEXT | Backend webhook endpoint |
|
||||
| `callback_secret` | TEXT | HMAC key — excluded from all JSON responses (`json:"-"`) |
|
||||
| `confirmations_required` | INTEGER | Set to chain acceptance floor at intent creation |
|
||||
| `tx_hash` | TEXT NULL | Set once the transaction is seen on-chain |
|
||||
| `log_index` | INTEGER NULL | Log position within tx (EVM only) |
|
||||
| `block_number` | INTEGER NULL | Block number (EVM); ms timestamp (Tron); unix seconds (TON) |
|
||||
| `confirmations` | INTEGER | Depth while confirming; capped at threshold after confirmation |
|
||||
| `salt` | TEXT | 32-byte random hex used in reference derivation |
|
||||
| `webhook_delivered_at` | TEXT NULL | RFC3339 timestamp of first successful delivery |
|
||||
| `created_at` / `updated_at` | DATETIME | UTC |
|
||||
|
||||
Indexes: `(status)`, `(chain_id, status)`, `(payment_reference)`, `(topic_ref)`.
|
||||
Unique index: `(tx_hash, log_index) WHERE tx_hash IS NOT NULL` — prevents double-confirmation.
|
||||
|
||||
### `checkpoints`
|
||||
|
||||
| Column | Notes |
|
||||
|---|---|
|
||||
| `chain_id` PK | Numeric chain ID |
|
||||
| `last_scanned_block` | Block number (EVM), ms timestamp (Tron), unix seconds (TON) |
|
||||
| `updated_at` | UTC |
|
||||
|
||||
### `balance_watches`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `watch_id` | TEXT PK | Caller-supplied idempotency key |
|
||||
| `chain_id` / `chain_type` | INTEGER / TEXT | Currently EVM only |
|
||||
| `token_address` / `token_symbol` | TEXT | ERC-20 contract + optional registry symbol |
|
||||
| `decimals` | INTEGER | Token decimals for display |
|
||||
| `address` | TEXT | Watched holder address |
|
||||
| `baseline_balance` | TEXT | Base-unit balance at watch creation |
|
||||
| `current_balance` | TEXT | Last successfully delivered balance |
|
||||
| `status` | TEXT | `watching` / `stopped` / `expired` |
|
||||
| `callback_url` / `callback_secret` | TEXT | Signed webhook destination + HMAC key |
|
||||
| `last_checked_at` / `next_check_at` | DATETIME | Scheduler state |
|
||||
| `change_count` / `last_notified_at` | INTEGER / DATETIME | Notification audit |
|
||||
| `expires_at` | DATETIME | Hard stop 7 days after creation |
|
||||
| `created_at` / `updated_at` | DATETIME | UTC |
|
||||
|
||||
Indexes: `(status, next_check_at)` for due-scan queries; `(chain_id, status)` for status reporting.
|
||||
|
||||
---
|
||||
|
||||
## 10. Configuration
|
||||
|
||||
All configuration via environment variables. Copy `.env.example` before first run.
|
||||
|
||||
| Variable | Default | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `PORT` | `8080` | no | HTTP listen port |
|
||||
| `DB_PATH` | `./scanner.db` | no | SQLite file path. Docker: mount `/data`, set `/data/scanner.db` |
|
||||
| `CHAINS_JSON_PATH` | `./supported-chains.json` | no | Chain registry JSON file |
|
||||
| `TOKENS_JSON_PATH` | `./tokens.json` | no | Token registry for symbol/decimals metadata |
|
||||
| `SCANNER_API_KEY` | _(none)_ | **yes (prod)** | Shared secret for Bearer auth. Generate: `openssl rand -hex 32`. Unset = all requests allowed (dev only) |
|
||||
| `POLL_INTERVAL_SEC` | `15` | no | Chain polling interval in seconds |
|
||||
| `INTENT_TTL_HOURS` | `24` | no | Expire pending intents after N hours. `0` = disabled |
|
||||
| `WEBHOOK_RETRY_HOURS` | `6` | no | Background re-delivery interval for `webhook_failed` intents. `0` = disabled |
|
||||
| `BALANCE_WATCH_TICK_SEC` | `60` | no | How often the scheduler checks for due balance watches |
|
||||
| `BALANCE_WATCH_BATCH_SIZE` | `50` | no | Max due watches processed per tick |
|
||||
| `RPC_BSC` | chain config | no | Override BSC JSON-RPC URL |
|
||||
| `RPC_ARB` | chain config | no | Override Arbitrum JSON-RPC URL |
|
||||
| `RPC_ETH` | chain config | no | Override Ethereum JSON-RPC URL |
|
||||
| `RPC_POLYGON` | chain config | no | Override Polygon JSON-RPC URL |
|
||||
| `RPC_BASE` | chain config | no | Override Base JSON-RPC URL |
|
||||
| `TRONGRID_API_KEY` | _(none)_ | strongly recommended | Free tier is severely rate-limited; required for real Tron traffic |
|
||||
| `TONCENTER_API_KEY` | _(none)_ | recommended | Rate-limited without key |
|
||||
| `SCANNER_ENABLED_CHAINS` | JSON `verified` flags | no | Comma-separated chain IDs to activate, overriding `verified` field. E.g. `56,1` |
|
||||
| `SCANNER_CALLBACK_ALLOWED_HOSTS` | _(none)_ | prod recommended | Comma-separated hosts/IPs allowed as `callbackUrl` targets (SSRF guard) |
|
||||
|
||||
---
|
||||
|
||||
## 11. Docker Deployment
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t amn-scanner .
|
||||
|
||||
# Run (standalone)
|
||||
docker run -d \
|
||||
--name amn-scanner \
|
||||
--network shared-web \
|
||||
-p 8080:8080 \
|
||||
-v /opt/arcane/data/projects/escrow-dev/scanner-data:/data \
|
||||
--env-file .env \
|
||||
-e DB_PATH=/data/scanner.db \
|
||||
amn-scanner
|
||||
```
|
||||
|
||||
### Dev server (89.58.32.32)
|
||||
|
||||
The scanner is part of the `escrow-dev` Arcane project. The dev stack builds images locally — it does **not** pull from any registry.
|
||||
|
||||
```bash
|
||||
# 1. Copy changed scanner source files
|
||||
scp -i ~/CascadeProjects/wzp scanner/*.go root@89.58.32.32:/tmp/escrow-backend-build/scanner/
|
||||
|
||||
# 2. Rebuild image on server (~2–3 min)
|
||||
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
|
||||
"cd /tmp/escrow-backend-build/scanner && \
|
||||
docker build -t amn-scanner-local:dev . && \
|
||||
cd /opt/arcane/data/projects/escrow-dev && \
|
||||
docker compose up -d scanner"
|
||||
```
|
||||
|
||||
Health check: `curl http://amn-scanner:8080/health` (internal) or via the Caddyfile vhost.
|
||||
|
||||
### Health probe
|
||||
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Integration with the Backend
|
||||
|
||||
The backend wires the scanner through the `amn.scanner` provider. See memory note [[amn_scanner_payin_wiring]] for full service/dispatch registration and the 6 required env vars.
|
||||
|
||||
### Registering a payment intent
|
||||
|
||||
```typescript
|
||||
// src/services/amnScanner/...
|
||||
const resp = await fetch(`${SCANNER_URL}/intents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${SCANNER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
intentId: payment._id.toString(), // MongoDB ObjectId string
|
||||
chainId: 56,
|
||||
tokenAddress: '0x55d398326f99059fF775485246999027B3197955', // USDT BSC
|
||||
destination: sellerWalletAddress,
|
||||
amount: amountInWei, // base-10 string, smallest unit
|
||||
callbackUrl: `${BACKEND_URL}/api/payment/amn-scanner/webhook`,
|
||||
callbackSecret: process.env.SCANNER_CALLBACK_SECRET,
|
||||
}),
|
||||
});
|
||||
const { intentId, paymentReference, checkoutBlock } = await resp.json();
|
||||
// Store intentId in the payment record
|
||||
// Pass checkoutBlock to the frontend for transaction construction
|
||||
```
|
||||
|
||||
The `checkoutBlock` response contains everything the frontend needs to call `ERC20FeeProxy.transferWithReferenceAndFee()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"destination": "0x...",
|
||||
"tokenAddress": "0x55d398326f99059fF775485246999027B3197955",
|
||||
"tokenSymbol": "USDT",
|
||||
"decimals": 18,
|
||||
"chainId": 56,
|
||||
"proxyAddress": "0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9",
|
||||
"paymentReference": "0xa1b2c3d4e5f60718",
|
||||
"feeAmount": "0",
|
||||
"feeAddress": "0x0000000000000000000000000000000000000000",
|
||||
"amountWei": "10000000000000000000"
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] Token decimals
|
||||
> Read token decimals on-chain, not from an internal registry. The scanner's `checkoutBlock.decimals` comes from `tokens.json`, which may lag registry updates.
|
||||
|
||||
### Receiving the webhook callback
|
||||
|
||||
```typescript
|
||||
// POST /api/payment/amn-scanner/webhook
|
||||
app.post('/api/payment/amn-scanner/webhook', async (req, res) => {
|
||||
const signature = req.headers['x-amn-signature'];
|
||||
const expected = hmacSha256Hex(req.rawBody, process.env.SCANNER_CALLBACK_SECRET);
|
||||
if (!timingSafeEqual(signature, expected)) return res.status(401).end();
|
||||
|
||||
const { intentId, status, txHash, amount, chainId } = req.body;
|
||||
if (status !== 'confirmed') return res.status(200).end(); // ignore non-terminal
|
||||
|
||||
await paymentService.markConfirmed({ intentId, txHash, amount, chainId });
|
||||
res.status(200).end();
|
||||
});
|
||||
```
|
||||
|
||||
> [!warning] Always scope by provider
|
||||
> The backend must always scope payment lookups to `provider: "amn.scanner"`. Sweeping all pending payments will destroy records from other providers (RN, manual). See memory note on payment cleanup + provider filter.
|
||||
|
||||
### Using direct balance checks (non-proxy flows)
|
||||
|
||||
```typescript
|
||||
// Synchronous balance read (manual payment flow)
|
||||
const { balance } = await scannerClient.post('/balances/check', {
|
||||
chainId: 56,
|
||||
address: sellerWalletAddress,
|
||||
token: 'USDT',
|
||||
});
|
||||
// Store baseline, then re-check when buyer clicks "I paid"
|
||||
|
||||
// Async balance watch
|
||||
await scannerClient.post('/balance-watches', {
|
||||
watchId: `payment-${paymentId}-c56-USDT`,
|
||||
chainId: 56,
|
||||
address: sellerWalletAddress,
|
||||
token: 'USDT',
|
||||
callbackUrl: `${BACKEND_URL}/api/payment/amn-scanner/webhook`,
|
||||
callbackSecret: process.env.SCANNER_CALLBACK_SECRET,
|
||||
baselineBalance: '0',
|
||||
});
|
||||
|
||||
// Stop watch after payment resolved
|
||||
await scannerClient.delete(`/balance-watches/payment-${paymentId}-c56-USDT`);
|
||||
```
|
||||
|
||||
### Backend environment variables
|
||||
|
||||
```
|
||||
SCANNER_URL=http://amn-scanner:8080
|
||||
SCANNER_API_KEY=<same value as scanner SCANNER_API_KEY>
|
||||
SCANNER_CALLBACK_SECRET=<shared HMAC key, same value used in callbackSecret field>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Known Limitations / Open Items
|
||||
|
||||
| # | Area | Description |
|
||||
|---|---|---|
|
||||
| 1 | **TON scaling** | O(pending intents) TonCenter API calls per scan cycle. Becomes a bottleneck above ~50 concurrent TON intents. Future fix: batch by address or use a streaming/webhook API. |
|
||||
| 2 | **Tron/TON balance watches** | `POST /balances/check` and `POST /balance-watches` only support EVM ERC-20 (`eth_call` balanceOf). Tron TRC-20 and TON Jetton balance reads are future scope. |
|
||||
| 3 | **Non-EVM chains unverified** | Arbitrum, Polygon, Base, Tron, TON are `"verified": false` — workers do not start by default. Needs production testing and `SCANNER_ENABLED_CHAINS` opt-in before enabling for real traffic. |
|
||||
| 4 | **Base proxy address non-canonical** | `0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814` differs from the RN CREATE2 expected address for Base. Must be verified on-chain before enabling Base in production. |
|
||||
| 5 | **Ethereum proxy version** | Chain 1 uses the v0.1.0 proxy at `0x370DE27…`. A v0.2.0 proxy is also deployed on ETH. The two have different ABI signatures — do not silently swap addresses. |
|
||||
| 6 | **Single SQLite instance** | The embedded SQLite database is not horizontally scalable. Running multiple scanner replicas would require coordination or migration to a shared DB (Postgres). Acceptable for current load. |
|
||||
| 7 | **No native-token support** | Only ERC-20, TRC-20, and Jetton (TON) transfers are scanned. Native token payments (BNB, ETH, TRX, TON coin) are not supported. |
|
||||
| 8 | **Single-seller only** | AMN Scanner pay-in supports single-seller flow. Multi-seller cart payments and cross-chain routing are not implemented. |
|
||||
| 9 | **No webhook key rotation** | HMAC-SHA256 with a pre-shared `callbackSecret`. There is no key rotation mechanism — changing the secret requires re-registering intents. |
|
||||
| 10 | **No EVM testnet for ETH/Polygon/Base** | Only BSC Testnet (97) has a testnet entry in `supported-chains.json`. Testing on Ethereum Sepolia or Polygon Amoy requires manually adding chain entries. |
|
||||
| 11 | **Arbitrum threshold latency** | The 2400-block Arbitrum threshold (~54 min) is deliberately conservative for the optimistic rollup challenge window. This makes Arbitrum slow for real-time escrow use. |
|
||||
304
10 - Services/tenant.md
Normal file
304
10 - Services/tenant.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# Tenant Service
|
||||
|
||||
> **Last updated:** 2026-06-10 — current `feature/white-label-shops` scan.
|
||||
|
||||
Tenant lifecycle, custom-domain provisioning, encrypted Telegram bot management, tenant webhook handling, and request-time tenant resolution for the white-label multi-shop branch.
|
||||
|
||||
---
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
backend/src/services/tenant/
|
||||
├── tenantService.ts # Core lifecycle + bootstrap payload
|
||||
├── tenantBotService.ts # Encrypted bot token management
|
||||
├── tenantAuthService.ts # Tenant role middleware factories
|
||||
├── domainProvisioningService.ts # DNS verification + Caddy route lifecycle
|
||||
└── caddyService.ts # Caddy Admin API wrapper
|
||||
|
||||
backend/src/shared/middleware/
|
||||
└── tenantResolution.ts # Request-time Host → tenant resolver
|
||||
|
||||
backend/src/routes/
|
||||
├── tenantRoutes.ts # Authenticated tenant admin API
|
||||
├── storefrontRoutes.ts # Public storefront bootstrap/stubs
|
||||
└── tenantWebhookRoutes.ts # Telegram tenant bot webhook
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tenantService.ts
|
||||
|
||||
**Singleton export:** `tenantService` (also default export).
|
||||
|
||||
### Typed errors
|
||||
|
||||
| Class | `code` | Thrown when |
|
||||
| --- | --- | --- |
|
||||
| `TenantSlugInvalidError` | `TENANT_SLUG_INVALID` | Slug does not match `/^[a-z0-9-]{3,40}$/` |
|
||||
| `TenantSlugTakenError` | `TENANT_SLUG_TAKEN` | Slug already registered |
|
||||
| `TenantNotFoundError` | `TENANT_NOT_FOUND` | `updateTenant` / `suspendTenant` / `activateTenant` on missing id |
|
||||
|
||||
Route handlers map these to `400 / 409 / 404` via the shared `handleServiceError` helper in `tenantRoutes.ts`.
|
||||
|
||||
### Methods
|
||||
|
||||
#### `createTenant(input)`
|
||||
|
||||
Creates a tenant, auto-grants the `owner` role to the creating user, and seeds a default `amn_escrow` payment policy (all in sequence, not a transaction — acceptable for Phase 0/1).
|
||||
|
||||
```ts
|
||||
await tenantService.createTenant({
|
||||
ownerUserId: 'uuid',
|
||||
slug: 'myshop',
|
||||
displayName: 'My Shop',
|
||||
type: 'hosted_seller', // optional, default 'hosted_seller'
|
||||
brand: { primaryColor: '#1F6FEB' }, // optional
|
||||
features: {}, // optional
|
||||
localeDefaults: ['en'], // optional
|
||||
});
|
||||
```
|
||||
|
||||
Slug is lowercased before the uniqueness check.
|
||||
|
||||
#### `getTenantById(id)` / `getTenantBySlug(slug)`
|
||||
|
||||
Direct DB lookups. Return `null` if not found.
|
||||
|
||||
#### `resolveTenantByHost(host)`
|
||||
|
||||
The main resolution path for HTTP requests.
|
||||
|
||||
```
|
||||
(a) host ends with .amn.gg → strip suffix → findBySlug (must be 'active')
|
||||
(b) custom host → findByHostname (must be 'active') → findById
|
||||
```
|
||||
|
||||
Returns `{ tenant, domain? }` or `null`. Never throws — callers can treat null as a 404.
|
||||
|
||||
#### `resolveTenantBySlug(slug, { previewOnly })`
|
||||
|
||||
Used only for `/t/:slug/bootstrap` preview paths. `previewOnly: true` allows `pending` tenants. `previewOnly: false` requires `status = 'active'`.
|
||||
|
||||
#### `buildBootstrapPayload(tenant)` → `TenantBootstrapPayload`
|
||||
|
||||
Assembles the public bootstrap object from the tenant row and its payment policy. Feature flags are derived from policy rails and overridden by `tenant.features` JSONB.
|
||||
|
||||
```ts
|
||||
interface TenantBootstrapPayload {
|
||||
tenantId: string;
|
||||
shopId?: string;
|
||||
slug: string;
|
||||
brand: { name: string; logoUrl?: string; primaryColor?: string; supportEmail?: string };
|
||||
features: { escrowCheckout: boolean; directCheckout: boolean; externalPayments: boolean; telegramMiniApp: boolean };
|
||||
paymentRails: TenantPaymentRail[];
|
||||
localeDefaults: string[];
|
||||
}
|
||||
```
|
||||
|
||||
**Security:** never includes `ownerUserId`, `brand.supportEmail` is only included if set, no encrypted fields.
|
||||
|
||||
#### `updateTenant(id, patch)` / `suspendTenant(id)` / `activateTenant(id)` / `listTenants(opts?)`
|
||||
|
||||
Standard CRUD. All throw `TenantNotFoundError` on missing id.
|
||||
|
||||
---
|
||||
|
||||
## tenantBotService.ts
|
||||
|
||||
**Singleton export:** `tenantBotService`.
|
||||
|
||||
Manages Telegram bot token registration with AES-256-GCM encryption. The service encrypts on write; repositories only store ciphertext. The raw token is never logged or returned.
|
||||
|
||||
**Required env var:** `TENANT_SECRET_KEY` — a 32-byte key provided as 64 hex chars or 44 base64 chars. Missing or wrongly sized keys fail fast before bot registration can proceed.
|
||||
|
||||
### Methods
|
||||
|
||||
#### `registerBot(tenantId, { telegramBotId, username, botToken, miniAppUrl? })`
|
||||
|
||||
1. Encrypts `botToken` with AES-256-GCM using `TENANT_SECRET_KEY`.
|
||||
2. Resolves the bot username via Telegram `getMe` when `username` is omitted.
|
||||
3. Generates a random `webhookSecret` and `claimToken`.
|
||||
4. Calls `tenantBotRepo.create(...)` — stores `encryptedToken`, `encryptedTokenIv`, `encryptedTokenTag`, `webhookSecret`, and `claimToken`.
|
||||
5. If `APP_URL` or the first `FRONTEND_URL` value is configured, fire-and-forget registers a Telegram webhook at `/api/telegram/tenant-webhook/:botId`.
|
||||
6. Returns the public bot record **without** encrypted fields or webhook secret. Pending bots include a derived `claimUrl`.
|
||||
|
||||
#### `listBotsForTenant(tenantId)`
|
||||
|
||||
Returns all bots for the tenant, with encrypted fields stripped from the response.
|
||||
|
||||
#### `configureBotMenu(botId, shopUrl)`
|
||||
|
||||
Decrypts the token internally and calls Telegram `setChatMenuButton` so the bot opens `shopUrl/telegram/`. Errors are logged but do not block bot registration.
|
||||
|
||||
#### `claimAdmin(botId, claimToken, telegramUserId)`
|
||||
|
||||
Called by `tenantWebhookRoutes` on `/start <claimToken>`. Verifies the pending bot claim token, stores `adminTelegramUserId`, flips the bot to `active`, and sends a confirmation message.
|
||||
|
||||
#### `revokeBot(botId)`
|
||||
|
||||
Sets `status = 'revoked'` on the bot row.
|
||||
|
||||
> [!warning] Secret handling
|
||||
> `getDecryptedToken()` and token decryption are internal-only. Never call them from an HTTP route handler and never log plaintext BotFather tokens.
|
||||
|
||||
---
|
||||
|
||||
## domainProvisioningService.ts
|
||||
|
||||
Owns the custom-domain lifecycle for `multi.amn.gg` and tenant-owned hostnames.
|
||||
|
||||
### Methods
|
||||
|
||||
#### `verifyAndProvision(domainId)`
|
||||
|
||||
1. Touches `lastCheckedAt`.
|
||||
2. Accepts either an A record pointing to `CADDY_SERVER_IP` or a CNAME pointing to `CADDY_CNAME_TARGET`.
|
||||
3. Adds an idempotent Caddy route via `caddyService.addRoute(hostname)`.
|
||||
4. Updates the domain to `status = 'active'`, `tlsStatus = 'pending'`.
|
||||
5. Marks `status = 'degraded'`, `tlsStatus = 'failed'` when Caddy provisioning fails.
|
||||
|
||||
Returns `'active'` or `'pending'`.
|
||||
|
||||
#### `checkTlsStatus(domainId)`
|
||||
|
||||
Performs an HTTPS probe through Caddy and updates `tlsStatus` to `issued`, `pending`, or `failed`.
|
||||
|
||||
#### `deprovision(domainId)`
|
||||
|
||||
Removes the Caddy route and marks the domain `suspended` with `tlsStatus = 'expired'`.
|
||||
|
||||
#### `syncActiveDomains()`
|
||||
|
||||
On backend startup, pings Caddy Admin API and re-adds all active domain routes. The database is the source of truth because Caddy API routes can be lost on Caddy restart.
|
||||
|
||||
#### `startPoller()`
|
||||
|
||||
Polls pending domains and active domains with pending TLS status every `DOMAIN_POLL_INTERVAL_MS` (default 60000 ms).
|
||||
|
||||
---
|
||||
|
||||
## caddyService.ts
|
||||
|
||||
Thin Caddy Admin API wrapper.
|
||||
|
||||
| Method | Purpose |
|
||||
| --- | --- |
|
||||
| `ping()` | Check Admin API reachability. |
|
||||
| `addRoute(hostname)` | Add a host route. `/api/*`, `/socket.io/*`, and `/uploads/*` proxy to backend; all other paths proxy to frontend. |
|
||||
| `removeRoute(hostname)` | Delete the route by Caddy `@id`. |
|
||||
| `hasRoute(hostname)` | Check if the route exists. |
|
||||
| `checkTls(hostname)` | Probe HTTPS and classify TLS as `issued`, `pending`, or `failed`. |
|
||||
|
||||
---
|
||||
|
||||
## tenantAuthService.ts
|
||||
|
||||
Provides Express middleware factories for tenant-scoped authorization.
|
||||
|
||||
#### `requireTenantRole(...roles: TenantUserRoleName[])`
|
||||
|
||||
Returns an Express middleware that:
|
||||
1. Reads `req.params.tenantId`.
|
||||
2. Checks `tenant_user_roles` for `(tenantId, req.user.id, role ∈ roles)`.
|
||||
3. Also passes if `req.user.role === 'admin'` (platform admin bypasses tenant role checks).
|
||||
4. Returns `403` if no matching role found.
|
||||
|
||||
Usage:
|
||||
```ts
|
||||
router.get('/:tenantId/settings',
|
||||
authenticateToken,
|
||||
requireTenantRole('owner', 'manager'),
|
||||
handler
|
||||
);
|
||||
```
|
||||
|
||||
#### `requireTenantOwner`
|
||||
|
||||
Shortcut for `requireTenantRole('owner')`.
|
||||
|
||||
#### `requirePlatformAdmin`
|
||||
|
||||
Returns `403` unless `req.user.role === 'admin'`. Thin wrapper around `authorizeRoles('admin')`.
|
||||
|
||||
---
|
||||
|
||||
## tenantResolutionMiddleware
|
||||
|
||||
`backend/src/shared/middleware/tenantResolution.ts`
|
||||
|
||||
Express middleware for the public storefront surface. Attaches `req.tenant: TenantRecord | undefined` and `req.tenantDomain: TenantDomainRecord | undefined`.
|
||||
|
||||
Resolution order and security invariants are documented in [[Tenant API#Tenant resolution middleware]].
|
||||
|
||||
The Express `Request` type augmentation lives in this file:
|
||||
```ts
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
tenant?: TenantRecord;
|
||||
tenantDomain?: TenantDomainRecord;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tenantWebhookRoutes.ts
|
||||
|
||||
Mounted at `/api/telegram` before the normal authenticated route groups.
|
||||
|
||||
`POST /tenant-webhook/:botId`:
|
||||
1. Requires `X-Telegram-Bot-Api-Secret-Token`.
|
||||
2. Fetches the bot row and compares the header to `webhookSecret`.
|
||||
3. Touches `lastWebhookAt`.
|
||||
4. Handles `/start <claimToken>` for pending bots by calling `tenantBotService.claimAdmin()`.
|
||||
5. Acknowledges other updates with `200 { ok: true }`.
|
||||
|
||||
---
|
||||
|
||||
## Frontend — TenantContext
|
||||
|
||||
`frontend/src/contexts/TenantContext.tsx`
|
||||
|
||||
Fetches `/api/storefront/bootstrap` on mount. Exposes:
|
||||
|
||||
```ts
|
||||
interface TenantContextValue {
|
||||
tenant: TenantBootstrapPayload | null;
|
||||
isLoading: boolean;
|
||||
isAmanatDefault: boolean;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`useTenant()` is a guard hook — throws if called outside `TenantProvider`.
|
||||
|
||||
On `404 TENANT_NOT_FOUND` or network error the provider falls back to `AMANAT_DEFAULTS` with `isAmanatDefault: true`. This means the rest of the frontend works unchanged on `amn.gg` — no tenant resolution required there.
|
||||
|
||||
`frontend/src/hooks/use-tenant-theme.ts` derives `primaryColor`, `cssVars`, and `brandName` from `useTenant()`. `--tenant-primary` CSS variable defaults to `#1F6FEB` when no tenant color is set.
|
||||
|
||||
Admin UI lives in `frontend/src/app/dashboard/admin/tenants` and `frontend/src/sections/admin/tenants`. It includes list/detail views, domain Check DNS / Check TLS actions, bot registration with activation links, payment policy editing, and member-role controls.
|
||||
|
||||
> [!warning] Current frontend/backend mismatch
|
||||
> The Members tab posts to `/tenants/:tenantId/members` and deletes `/tenants/:tenantId/members/:memberId`, while the backend currently exposes `POST /tenants/:tenantId/roles` and `DELETE /tenants/:tenantId/roles`. Fix one side before relying on member management in production.
|
||||
|
||||
---
|
||||
|
||||
## Env vars
|
||||
|
||||
| Variable | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `TENANT_BASE_DOMAIN` | no | Base domain for subdomain tenants. Default `amn.gg`. |
|
||||
| `TENANT_SECRET_KEY` | yes (when registering bots) | 32-byte AES key, provided as 64 hex chars or 44 base64 chars. |
|
||||
| `APP_URL` / `FRONTEND_URL` | yes for webhook auto-registration | Base URL used to register Telegram `setWebhook`. `APP_URL` wins; otherwise first comma-separated `FRONTEND_URL` value is used. |
|
||||
| `CADDY_ADMIN_URL` | no | Caddy Admin API URL. Default `http://infra-caddy:2019`. |
|
||||
| `CADDY_BACKEND_UPSTREAM` | no | Backend upstream for dynamic tenant routes. Default `escrow-multi-backend:5001`. |
|
||||
| `CADDY_FRONTEND_UPSTREAM` | no | Frontend upstream for dynamic tenant routes. Default `escrow-multi-frontend:8083`. |
|
||||
| `CADDY_SERVER_IP` | no | Public IP accepted by DNS verification. |
|
||||
| `CADDY_CNAME_TARGET` | no | CNAME target accepted by DNS verification. Default `multi.amn.gg`. |
|
||||
| `DOMAIN_POLL_INTERVAL_MS` | no | Pending-domain/TLS poll interval. Default `60000`. |
|
||||
|
||||
Related: [[Tenant]], [[Tenant API]], [[PRD - Seller-Owned White-Label Shops and Bots]].
|
||||
148
11 - Testing/Concurrency Test Results 2026-06-06.md
Normal file
148
11 - Testing/Concurrency Test Results 2026-06-06.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Concurrency & Performance Test Results — 2026-06-06
|
||||
|
||||
## Environment
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Date | 2026-06-06 |
|
||||
| Backend version | v2.9.3 → v2.9.5 |
|
||||
| Target | `http://172.18.0.6:5001` (loopback, server → container direct) |
|
||||
| Payment mode | `PAYMENT_MODE=status` (no real blockchain) |
|
||||
| Flow | Full E2E: setup buyer+3 sellers → createRequest → 3 offers → selectOffer → pay → deliver → confirmDelivery |
|
||||
| Server | 89.58.32.32 (netcup ARM, 6 vCPU) |
|
||||
| Runner | `scripts/smoke/marketplace-e2e-notifications.mjs` |
|
||||
|
||||
---
|
||||
|
||||
## Run 1 — Baseline (rate limiter blocking, v2.9.3)
|
||||
|
||||
`CONCURRENCY_LEVELS=1,2,4,8,16,32`
|
||||
|
||||
| Level | Passed | Total | Rate | Failure |
|
||||
|-------|--------|-------|------|---------|
|
||||
| C1 | 1 | 1 | 100% | — |
|
||||
| C2 | 0 | 2 | 0% | 429 rate limit |
|
||||
| C4–C32 | 0 | — | 0% | 429 rate limit |
|
||||
|
||||
**Finding:** globalLimiter (100 req/15 min) exhausted by concurrent user setup. Added `RATE_LIMIT_BYPASS_IPS` env var to skip limiter for the Docker host gateway IP.
|
||||
|
||||
---
|
||||
|
||||
## Run 2 — Clean baseline (bypass active, UV_THREADPOOL_SIZE default=4)
|
||||
|
||||
`CONCURRENCY_LEVELS=1,2,4,8,16,32` — run ID `20260606090606`
|
||||
|
||||
| Level | Passed | Total | Rate | Failure |
|
||||
|-------|--------|-------|------|---------|
|
||||
| C1 | 1 | 1 | **100%** | — |
|
||||
| C2 | 2 | 2 | **100%** | — |
|
||||
| C4 | 4 | 4 | **100%** | — |
|
||||
| C8 | 8 | 8 | **100%** | — |
|
||||
| C16 | 15 | 16 | 93.75% | 1× admin.create 500 |
|
||||
| C32 | 10 | 32 | 31% | auth.login + admin.create timeouts |
|
||||
| **Total** | **40** | **63** | **63.5%** | |
|
||||
|
||||
### API Latency (all levels combined)
|
||||
|
||||
| API | p50 | p95 | p99 | Max |
|
||||
|-----|-----|-----|-----|-----|
|
||||
| auth.login | 5221ms | 15000ms | 15002ms | 15002ms |
|
||||
| users.admin.create | 4372ms | 15004ms | 15007ms | 15007ms |
|
||||
| marketplace.purchaseRequests.create | 315ms | 507ms | 579ms | 579ms |
|
||||
| marketplace.offers.create | 246ms | 399ms | 448ms | 450ms |
|
||||
| marketplace.offers.select | 193ms | 455ms | 504ms | 504ms |
|
||||
| marketplace.purchaseRequests.status.payment | 231ms | 383ms | 512ms | 512ms |
|
||||
| marketplace.delivery.update | 92ms | 245ms | 258ms | 258ms |
|
||||
| marketplace.delivery.confirm | 42ms | 96ms | 129ms | 129ms |
|
||||
| notifications.list | 23ms | 233ms | 592ms | 640ms |
|
||||
|
||||
**Root cause of C32 failures:** bcrypt is CPU-bound; with 4 libuv threads (default), 128 concurrent bcrypt ops (32 flows × 4 hashes each) queue behind 4 slots. p50 login jumps from 509ms (C1) to 5221ms (C32 aggregate).
|
||||
|
||||
**Bugs found during this run:**
|
||||
1. Selected seller never received offer-accepted notification — `acceptedOffer.id` was `undefined` because `toSellerOffer()` maps to `_id` not `.id` on a plain object. Fixed in commit `de910aa`.
|
||||
2. Telegram Mini App URL was the entire comma-separated `FRONTEND_URL` CORS list, producing `ERR_NAME_NOT_RESOLVED`. Fixed in commit `6b6319c`.
|
||||
|
||||
---
|
||||
|
||||
## Run 3 — After UV_THREADPOOL_SIZE=16
|
||||
|
||||
Added `UV_THREADPOOL_SIZE=16` to `/opt/arcane/data/projects/escrow-dev/.env`. Redeployed v2.9.5.
|
||||
|
||||
`CONCURRENCY_LEVELS=16,20` — run ID `20260606103005`
|
||||
|
||||
| Level | Passed | Total | Rate |
|
||||
|-------|--------|-------|------|
|
||||
| C16 | 16 | 16 | **100%** |
|
||||
| C20 | 20 | 20 | **100%** |
|
||||
| **Total** | **36** | **36** | **100%** |
|
||||
|
||||
### API Latency (C16+C20 combined)
|
||||
|
||||
| API | p50 | p95 | Max |
|
||||
|-----|-----|-----|-----|
|
||||
| auth.login | 8227ms | 12702ms | 13996ms |
|
||||
| users.admin.create | 6383ms | 11002ms | 14416ms |
|
||||
| marketplace.offers.create | 604ms | 1111ms | 1380ms |
|
||||
| marketplace.offers.select | 758ms | 1359ms | 1675ms |
|
||||
| marketplace.purchaseRequests.create | 499ms | 1010ms | 1160ms |
|
||||
| marketplace.delivery.update | 236ms | 379ms | 489ms |
|
||||
| marketplace.delivery.confirm | 66ms | 218ms | 221ms |
|
||||
| notifications.list | 92ms | 653ms | 3233ms |
|
||||
|
||||
Auth and admin.create are still slow (6–8s p50) but no longer timeout. All flows complete successfully.
|
||||
|
||||
---
|
||||
|
||||
## Run 4 — C24 + C32 (UV_THREADPOOL_SIZE=16)
|
||||
|
||||
`CONCURRENCY_LEVELS=24,32` — run ID `20260606103348`
|
||||
|
||||
| Level | Passed | Total | Rate | Failure |
|
||||
|-------|--------|-------|------|---------|
|
||||
| C24 | 16 | 24 | 66.7% | 8× admin.create 500 (DB unique collision) |
|
||||
| C32 | 14 | 32 | 43.75% | 6× auth.login timeout, 12× admin.create timeout |
|
||||
| **Total** | **30** | **56** | **53.6%** | |
|
||||
|
||||
**New failure mode at C24:** `users.admin.create` returns 500 (not timeout). Likely a DB unique constraint collision when 24 workers simultaneously generate user emails with similar patterns, or a Mongoose/Postgres write conflict. This is a test-harness artifact — in production, 24 users don't register simultaneously.
|
||||
|
||||
**Health alert:** Gatus fired `status=degraded` during the C24 wave. The 500 errors on admin.create triggered the health endpoint's degraded status. Recovered immediately after the test.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Stable ceiling** | **C20 (100% pass rate)** |
|
||||
| **Soft ceiling** | C24 (66% — DB write conflict on concurrent user creation) |
|
||||
| **Hard ceiling** | C32 (44% — bcrypt CPU saturation even with threadpool=16) |
|
||||
| **UV_THREADPOOL fix** | Moved stable ceiling from C8 → C20 |
|
||||
| **Real-world equivalent** | C20 ≈ 500–1,500 simultaneous active users (at 15–30s think time) |
|
||||
| **DAU estimate** | Safe up to ~5,000–8,000 DAU at current infra |
|
||||
|
||||
### Bugs fixed as a result of testing
|
||||
|
||||
| Bug | Fix |
|
||||
|-----|-----|
|
||||
| Selected seller never gets offer-accepted notification | `acceptedOffer.id` → `String(acceptedOffer._id)` in `SellerOfferService.ts` |
|
||||
| Telegram Mini App URL was unparseable CORS list | Split `FRONTEND_URL` on comma, take first entry |
|
||||
| `RATE_LIMIT_BYPASS_IPS` env var added | Skip globalLimiter for trusted internal IPs (loopback test runner) |
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **`UV_THREADPOOL_SIZE=16`** — already applied to dev env. Apply to production env file as well.
|
||||
2. **Reduce bcrypt rounds 12 → 10** — 4× faster per hash, still above OWASP minimum. Apply in `authService.ts`, `userRoutes.ts`, `userController.ts`, `init-admin.ts`.
|
||||
3. **Test harness improvement** — pre-pool users before concurrent phase to eliminate admin.create as a concurrency bottleneck. See `scripts/smoke/marketplace-realistic-load.mjs`.
|
||||
|
||||
### Feature idea noted during testing
|
||||
|
||||
**Counter-offer mechanism (eBay-style):** Allow a seller to propose a counter-price on an existing offer rather than only accepting or rejecting. Buyer can accept/reject/counter again. This would add a natural negotiation loop to the marketplace without requiring full escrow re-entry. Low implementation cost on the offer state machine; high UX value for high-value transactions.
|
||||
|
||||
---
|
||||
|
||||
## Raw report files
|
||||
|
||||
Stored on the test server at `/tmp/e2e-reports/`:
|
||||
- `marketplace-e2e-20260606090606.{json,md}` — Run 2 (baseline)
|
||||
- `marketplace-e2e-20260606103005.{json,md}` — Run 3 (C16+C20)
|
||||
- `marketplace-e2e-20260606103348.{json,md}` — Run 4 (C24+C32)
|
||||
285
11 - Testing/Concurrency and Performance Profile.md
Normal file
285
11 - Testing/Concurrency and Performance Profile.md
Normal file
@@ -0,0 +1,285 @@
|
||||
---
|
||||
title: Concurrency and Performance Profile
|
||||
tags: [testing, performance, concurrency, profiling, e2e]
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Concurrency and Performance Profile
|
||||
|
||||
This procedure defines the ramp test for simultaneous escrow E2E flows and the
|
||||
report format for performance characteristics.
|
||||
|
||||
The purpose is not only load generation. It must prove that business behavior
|
||||
remains correct under concurrency: payments confirm once, notifications are
|
||||
issued to the right users, and no request/offer/payment state leaks across
|
||||
parallel workers.
|
||||
|
||||
## Test Shape
|
||||
|
||||
One worker is one complete isolated E2E flow:
|
||||
|
||||
```text
|
||||
buyer + sellers -> request -> bids -> accept -> payment intent -> tUSDT payment
|
||||
-> scanner confirmation -> seller delivery -> buyer confirmation
|
||||
```
|
||||
|
||||
Each worker must use unique:
|
||||
|
||||
- run id suffix;
|
||||
- buyer and seller users;
|
||||
- purchase request;
|
||||
- selected offer;
|
||||
- payment id;
|
||||
- scanner destination/baseline;
|
||||
- tx hash or simulated payment fixture, depending on mode.
|
||||
|
||||
Notifications are mandatory inside every worker. See
|
||||
[[Notification Assertion Procedure]].
|
||||
|
||||
Implemented runner:
|
||||
|
||||
```bash
|
||||
cd ~/CascadeProjects/escrow/backend
|
||||
BASE_URL=https://dev.amn.gg \
|
||||
PAYMENT_MODE=status \
|
||||
CONCURRENCY_LEVELS=1,2,4,8,16,32 \
|
||||
ROUNDS=1 \
|
||||
bash scripts/smoke/marketplace-e2e-notifications.sh
|
||||
```
|
||||
|
||||
Use `PAYMENT_MODE=live` for low-concurrency BSC Testnet tUSDT confirmation.
|
||||
Use `PAYMENT_MODE=status` for high-concurrency marketplace/notification
|
||||
profiling without consuming gas.
|
||||
|
||||
## Ramp Plan
|
||||
|
||||
Start with one simultaneous worker and double until a stop condition is reached:
|
||||
|
||||
| Stage | Simultaneous workers | Purpose |
|
||||
|---|---:|---|
|
||||
| C1 | `1` | Baseline correctness and latency. |
|
||||
| C2 | `2` | Detect simple race conditions. |
|
||||
| C4 | `4` | Validate small parallel seller/payment load. |
|
||||
| C8 | `8` | First meaningful contention check. |
|
||||
| C16 | `16` | Stress DB/API/socket fanout. |
|
||||
| C32 | `32` | Upper dev-stack target before release planning. |
|
||||
| C64+ | `64+` | Only if C32 passes and infrastructure headroom is clear. |
|
||||
|
||||
Hold each stage long enough to complete at least one full E2E round per worker.
|
||||
For API-only profiling, also support a fixed-duration mode such as 5 minutes per
|
||||
stage.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Payment behavior | Use |
|
||||
|---|---|---|
|
||||
| Live-chain mode | Real BSC Testnet tUSDT transfers | Final confidence at low concurrency; expensive/slower; consumes gas. |
|
||||
| Status-only smoke mode | Moves accepted request to `payment` through the status route | Implemented high-concurrency marketplace/notification profiling without chain variables. |
|
||||
| Scanner fixture mode | Deterministic scanner/balance fixture or controlled test endpoint | High concurrency without chain bottleneck. Must not be enabled in production. |
|
||||
| API-only dry run | Runs request/offer/delivery and skips payment finalization | Marketplace/notification profiling without chain variables. |
|
||||
|
||||
Live-chain mode should usually stop at low concurrency unless there is enough
|
||||
tBNB/tUSDT and the chain/RPC is reliable. Higher stages should use scanner
|
||||
fixture mode once implemented.
|
||||
|
||||
## Metrics To Collect
|
||||
|
||||
### Business correctness
|
||||
|
||||
| Metric | Target |
|
||||
|---|---|
|
||||
| completed worker success rate | `100%` for C1-C8, `>= 99%` for C16+ after retries are classified |
|
||||
| duplicate payment credit count | `0` |
|
||||
| wrong-recipient notification count | `0` |
|
||||
| cross-worker state leak count | `0` |
|
||||
| non-buyer delivery confirmation success | `0` |
|
||||
| ledger inconsistency count | `0` |
|
||||
|
||||
### API latency
|
||||
|
||||
Initial performance goals for dev profiling:
|
||||
|
||||
| Operation | p50 goal | p95 goal | p99 watch |
|
||||
|---|---:|---:|---:|
|
||||
| login | `< 300 ms` | `< 1 s` | `< 2 s` |
|
||||
| create request | `< 400 ms` | `< 1.5 s` | `< 3 s` |
|
||||
| create offer | `< 400 ms` | `< 1.5 s` | `< 3 s` |
|
||||
| accept offer | `< 500 ms` | `< 2 s` | `< 4 s` |
|
||||
| create payment intent | `< 750 ms` | `< 3 s` | `< 6 s` |
|
||||
| scanner balance check | `< 1 s` | `< 5 s` | `< 10 s` |
|
||||
| seller delivery | `< 500 ms` | `< 2 s` | `< 4 s` |
|
||||
| buyer delivery confirmation | `< 500 ms` | `< 2 s` | `< 4 s` |
|
||||
| notification visibility | `< 1 s` | `< 5 s` | `< 10 s` |
|
||||
|
||||
These are starting goals, not final SLOs. The first complete C1-C32 run should
|
||||
produce a baseline report and then adjust targets with evidence.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
Collect per stage:
|
||||
|
||||
- backend CPU and memory;
|
||||
- frontend CPU and memory;
|
||||
- scanner CPU and memory;
|
||||
- MongoDB CPU, memory, connections, slow queries;
|
||||
- Postgres CPU, memory, connections, locks;
|
||||
- Redis CPU, memory, connected clients;
|
||||
- container restarts;
|
||||
- Docker image/version;
|
||||
- BSC Testnet RPC latency/error rate;
|
||||
- Socket.IO connected clients and emitted event count;
|
||||
- notification insert count and error count.
|
||||
|
||||
Suggested host commands:
|
||||
|
||||
```bash
|
||||
docker stats --no-stream
|
||||
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}'
|
||||
docker logs --since 5m escrow-backend
|
||||
docker logs --since 5m escrow-scanner
|
||||
```
|
||||
|
||||
Do not paste secrets from environment output into reports.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
Stop the ramp immediately if any P0 condition appears:
|
||||
|
||||
- payment marked paid without correct chain/token/destination/amount evidence;
|
||||
- duplicate ledger credit;
|
||||
- notification delivered to wrong user;
|
||||
- expected notification missing for a step without approved known-gap classification;
|
||||
- backend, scanner, Mongo, Postgres, or Redis container restarts;
|
||||
- sustained HTTP 5xx rate above `1%`;
|
||||
- p95 create payment intent exceeds `10 s` for two consecutive stages;
|
||||
- scanner confirmation/check p95 exceeds `30 s` outside known BSC Testnet RPC issues;
|
||||
- queue/backlog grows without draining after the stage ends;
|
||||
- host CPU remains above `85%` or memory above `90%` after cooldown.
|
||||
|
||||
## Stage Procedure
|
||||
|
||||
For each stage:
|
||||
|
||||
1. Verify dev stack health.
|
||||
2. Capture container stats baseline.
|
||||
3. Create isolated worker test data.
|
||||
4. Start all workers at a barrier time.
|
||||
5. For every worker, execute full E2E and notification assertions.
|
||||
6. Capture per-operation timings.
|
||||
7. Capture infrastructure metrics during run.
|
||||
8. Wait for queues/notifications to settle.
|
||||
9. Capture cooldown metrics.
|
||||
10. Classify failures:
|
||||
- product bug;
|
||||
- test data/setup bug;
|
||||
- BSC Testnet/RPC external issue;
|
||||
- infrastructure capacity issue;
|
||||
- known product gap.
|
||||
11. Decide whether to proceed to the next stage.
|
||||
|
||||
## Worker Result Schema
|
||||
|
||||
Each worker should produce a JSON result:
|
||||
|
||||
```json
|
||||
{
|
||||
"workerId": "C8-W03",
|
||||
"stage": 8,
|
||||
"runId": "20260606-perf-C8-W03",
|
||||
"status": "pass",
|
||||
"buyerUserId": "<id>",
|
||||
"sellerUserIds": ["<id>", "<id>", "<id>"],
|
||||
"purchaseRequestId": "<uuid>",
|
||||
"selectedOfferId": "<uuid>",
|
||||
"paymentId": "<uuid>",
|
||||
"txHash": "0x...",
|
||||
"timingsMs": {
|
||||
"login": 180,
|
||||
"createRequest": 420,
|
||||
"createOffers": 910,
|
||||
"acceptOffer": 330,
|
||||
"createPaymentIntent": 850,
|
||||
"scannerConfirm": 4200,
|
||||
"sellerDelivery": 380,
|
||||
"buyerConfirmDelivery": 410,
|
||||
"total": 12100
|
||||
},
|
||||
"notifications": [
|
||||
{
|
||||
"step": "seller_offer_created",
|
||||
"recipient": "buyer",
|
||||
"observed": true,
|
||||
"latencyMs": 640
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
## Report Template
|
||||
|
||||
Create one report per full ramp:
|
||||
|
||||
```markdown
|
||||
# Performance Profile Report - <date>
|
||||
|
||||
## Summary
|
||||
|
||||
- Target:
|
||||
- Backend/frontend/scanner versions:
|
||||
- Commit SHAs:
|
||||
- Payment mode: live-chain / scanner fixture / API-only
|
||||
- Ramp stages completed:
|
||||
- Overall result:
|
||||
|
||||
## Key Findings
|
||||
|
||||
| Finding | Severity | Evidence | Next action |
|
||||
|---|---|---|---|
|
||||
|
||||
## Stage Results
|
||||
|
||||
| Stage | Workers | Pass | Fail | p95 total | p95 payment intent | p95 scanner | p95 notification | 5xx rate |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
|
||||
## Notification Results
|
||||
|
||||
| Step | Expected | Observed | Missing | Wrong recipient | p95 latency |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
|
||||
## Infrastructure
|
||||
|
||||
| Stage | Backend CPU/mem | Scanner CPU/mem | Mongo | Postgres | Redis | Restarts |
|
||||
|---|---|---|---|---|---|---:|
|
||||
|
||||
## Payment Correctness
|
||||
|
||||
- Duplicate credits:
|
||||
- Under/overpayment anomalies:
|
||||
- Scanner mismatches:
|
||||
- Ledger mismatches:
|
||||
|
||||
## Bottlenecks
|
||||
|
||||
- API:
|
||||
- Database:
|
||||
- Scanner:
|
||||
- Socket/notifications:
|
||||
- RPC/chain:
|
||||
|
||||
## Decisions
|
||||
|
||||
- Current safe dev concurrency:
|
||||
- Recommended production target:
|
||||
- Required fixes before next ramp:
|
||||
```
|
||||
|
||||
## Initial Performance Characteristic Hypotheses
|
||||
|
||||
These are the expectations to validate:
|
||||
|
||||
- Request/offer APIs should scale mostly with Mongo/Postgres write throughput.
|
||||
- Notification latency will become a visible bottleneck before raw API latency if every offer/status change creates individual Mongo inserts and socket emits.
|
||||
- Scanner live-chain checks are likely bounded by BSC Testnet RPC latency and should be separated from API-only profiling.
|
||||
- Payment intent creation may become slower if destination derivation, token registry lookup, and scanner registration are serial.
|
||||
- Socket fanout should be watched at C16+ because each worker has multiple actors and multiple tabs/devices may multiply room membership.
|
||||
265
11 - Testing/Escrow Marketplace E2E Procedure.md
Normal file
265
11 - Testing/Escrow Marketplace E2E Procedure.md
Normal file
@@ -0,0 +1,265 @@
|
||||
---
|
||||
title: Escrow Marketplace E2E Procedure
|
||||
tags: [testing, e2e, escrow, marketplace, buyer, seller]
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Escrow Marketplace E2E Procedure
|
||||
|
||||
This procedure validates the marketplace flow with one buyer and at least two
|
||||
sellers. Use it for live dev validation after payment, marketplace, delivery,
|
||||
or scanner changes.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Dev API is reachable: `GET https://dev.amn.gg/api/version`.
|
||||
- Backend, frontend, and scanner containers are healthy.
|
||||
- Admin credentials are available through a secure local channel, not docs.
|
||||
- BSC Testnet wallet has enough tBNB for gas and enough canonical tUSDT.
|
||||
- Backend/scanner chain 97 registry points to `0x109F54Dab34426D5477986b0460aE5dFBA65f022`.
|
||||
- Local wallet private key or mnemonic is stored only in ignored `.env`.
|
||||
- Notification checks are enabled in the runner. See [[Notification Assertion Procedure]].
|
||||
|
||||
## Actors
|
||||
|
||||
| Actor | Count | Role |
|
||||
|---|---:|---|
|
||||
| Buyer | 1 | Creates request, accepts offer, funds payment, confirms delivery. |
|
||||
| Sellers | 2 minimum, 3 preferred | Submit competing offers and delivery evidence. |
|
||||
| Admin | 1 | Creates test users and can inspect/repair state. |
|
||||
|
||||
## High-Level Procedure
|
||||
|
||||
Use the implemented backend runner for repeatable execution:
|
||||
|
||||
```bash
|
||||
BASE_URL=https://dev.amn.gg \
|
||||
PAYMENT_MODE=status \
|
||||
CONCURRENCY_LEVELS=1 \
|
||||
ROUNDS=2 \
|
||||
bash scripts/smoke/marketplace-e2e-notifications.sh
|
||||
```
|
||||
|
||||
Use `PAYMENT_MODE=live` for the funded BSC Testnet tUSDT rail and
|
||||
`PAYMENT_MODE=record` only when explicitly testing the legacy marketplace
|
||||
payment-record route. See [[Marketplace E2E Smoke Runner]].
|
||||
|
||||
1. Generate a run id.
|
||||
2. Admin creates one buyer and at least two sellers.
|
||||
3. Assert initial notification baselines for buyer and sellers.
|
||||
4. Buyer creates purchase request with a min/max USDT budget.
|
||||
5. Assert purchase-request notifications for expected sellers.
|
||||
6. Sellers submit bids inside the budget range.
|
||||
7. After each seller bid, assert offer notifications for the buyer.
|
||||
8. Randomize or vary:
|
||||
- bid amount,
|
||||
- delivery timing,
|
||||
- seller note,
|
||||
- delivery method.
|
||||
9. Buyer selects a bid.
|
||||
10. Assert offer-accepted notification for selected seller and offer-rejected/updated notifications for non-selected sellers when implemented.
|
||||
11. Backend creates scanner payment intent.
|
||||
12. Assert payment-pending or payment-started notification where implemented; if missing, record as notification coverage gap.
|
||||
13. Buyer pays with BSC Testnet tUSDT.
|
||||
14. Scanner confirms the payment.
|
||||
15. Assert payment-confirmed notifications for buyer and selected seller.
|
||||
16. Seller marks delivery.
|
||||
17. Assert delivery notification for buyer.
|
||||
18. Buyer confirms delivery.
|
||||
19. Assert delivery-confirmed notification for selected seller and buyer status notification if implemented.
|
||||
20. Record current block: flow pauses before automated release policy.
|
||||
|
||||
## Expected State Transitions
|
||||
|
||||
| Step | Purchase request expectation | Payment expectation |
|
||||
|---|---|---|
|
||||
| Request created | request visible to sellers | no payment |
|
||||
| Offers submitted | `received_offers` or equivalent offer-ready state | no payment |
|
||||
| Offer accepted | selected offer stored | payment intent can be created |
|
||||
| Payment sent | payment/check pending or processing | scanner sees chain/token/destination/amount |
|
||||
| Scanner confirms | request can proceed to delivery path | status paid/confirmed/completed depending endpoint |
|
||||
| Seller delivers | `delivery` | escrow remains held |
|
||||
| Buyer confirms | `delivered`, `deliveryConfirmed=true` | release policy not automatic yet |
|
||||
|
||||
## Notification Assertions After Every Step
|
||||
|
||||
Notification verification is mandatory after every state-changing step. A step
|
||||
is not complete until the test runner has either:
|
||||
|
||||
1. observed the expected notification for every recipient; or
|
||||
2. recorded a known coverage gap with route/action, expected recipient, and linked issue/backlog item.
|
||||
|
||||
Use [[Notification Assertion Procedure]] for the exact API/socket checks.
|
||||
|
||||
| Step | Expected recipients | Required notification assertion |
|
||||
|---|---|---|
|
||||
| Baseline after user creation | buyer, every seller | Capture unread count and latest notification id for each actor before business mutations. |
|
||||
| Buyer creates request | eligible sellers | Sellers receive a request/new-opportunity notification, or the gap is recorded. |
|
||||
| Seller submits bid | buyer | Buyer receives a new-offer notification with `relatedId` or action URL pointing to the request/offer. |
|
||||
| Buyer selects bid | selected seller; non-selected sellers if rejection notices are implemented | Selected seller receives acceptance notification. Non-selected sellers receive rejection/update notification where supported. |
|
||||
| Payment intent created | buyer | Buyer receives payment-started/pending notification where supported. Missing `pending_payment` coverage is a known notification gap and must be recorded. |
|
||||
| Scanner confirms payment | buyer, selected seller | Buyer and seller receive payment-confirmed/funded notification. |
|
||||
| Seller marks delivery | buyer | Buyer receives delivery/proof submitted notification. |
|
||||
| Buyer confirms delivery | selected seller; buyer if status-change notices are implemented | Seller receives delivery-confirmed notification. |
|
||||
| Dispute raised, when added | buyer, seller, admin/mediator | All parties receive dispute-created/hold notification. |
|
||||
| Release/refund, when added | buyer, seller, admin | Funds movement notification is persisted and pushed. |
|
||||
|
||||
Minimum notification evidence per assertion:
|
||||
|
||||
| Evidence | Source |
|
||||
|---|---|
|
||||
| unread count before and after | `GET /api/notifications/unread-count` |
|
||||
| latest notification payload | `GET /api/notifications?limit=5` |
|
||||
| recipient id/email | authenticated actor profile or test user record |
|
||||
| notification category/type/title/actionUrl | notification payload |
|
||||
| socket event, if runner supports it | `new-notification` on `user-<userId>` |
|
||||
| elapsed time from action to notification visibility | runner timestamp |
|
||||
|
||||
## Buyer Request Template
|
||||
|
||||
Use unique values so live data can be filtered later:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Scanner BSC Testnet E2E <runId> R<round>",
|
||||
"description": "Automated scanner payment test round <round>",
|
||||
"productType": "physical_product",
|
||||
"productLink": "https://example.test/e2e/<runId>/<round>",
|
||||
"quantity": 1,
|
||||
"budget": {
|
||||
"min": 0.18,
|
||||
"max": 0.48,
|
||||
"currency": "USDT"
|
||||
},
|
||||
"urgency": "medium"
|
||||
}
|
||||
```
|
||||
|
||||
## Seller Bid Rules
|
||||
|
||||
For each round:
|
||||
|
||||
- create at least two bids;
|
||||
- prefer three bids so ranking/selection is clearer;
|
||||
- keep all bids within buyer min/max budget;
|
||||
- vary delivery timing, for example 1 day, 4 days, 7 days;
|
||||
- pick a deterministic winner rule for automation, such as lowest price.
|
||||
|
||||
Example bid set:
|
||||
|
||||
| Seller | Amount | Delivery |
|
||||
|---|---:|---|
|
||||
| seller1 | `0.31 USDT` | `7 days` |
|
||||
| seller2 | `0.28 USDT` | `5 days` |
|
||||
| seller3 | `0.26 USDT` | `4 days` |
|
||||
|
||||
## Delivery Proof
|
||||
|
||||
Seller delivery should include structured proof where the API supports it:
|
||||
|
||||
```json
|
||||
{
|
||||
"proof": {
|
||||
"type": "e2e",
|
||||
"runId": "<runId>",
|
||||
"round": 1,
|
||||
"note": "Seller delivery proof for automated dev test"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Buyer confirmation may use similar metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"proof": {
|
||||
"type": "e2e-confirmation",
|
||||
"runId": "<runId>",
|
||||
"round": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Assertions
|
||||
|
||||
Minimum assertions per round:
|
||||
|
||||
| Assertion | Required |
|
||||
|---|---|
|
||||
| Buyer login succeeds | yes |
|
||||
| Seller logins succeed | yes |
|
||||
| Request id is created | yes |
|
||||
| At least two offer ids are created | yes |
|
||||
| Selected offer id matches accepted seller | yes |
|
||||
| Payment id is created | yes |
|
||||
| Payment chain id is `97` | yes |
|
||||
| Payment token is canonical tUSDT | yes |
|
||||
| On-chain tx hash exists | yes |
|
||||
| Scanner check returns paid/confirmed | yes |
|
||||
| Seller delivery returns HTTP 200 | yes |
|
||||
| Buyer confirm delivery returns HTTP 200 | yes |
|
||||
| Final request status is `delivered` | yes |
|
||||
| Notification assertion executed after each state-changing step | yes |
|
||||
| Notification gaps are recorded with expected recipient and route/action | yes |
|
||||
|
||||
## Reference Execution - 2026-06-06
|
||||
|
||||
Run id:
|
||||
|
||||
```text
|
||||
20260606043238
|
||||
```
|
||||
|
||||
Generated users:
|
||||
|
||||
| Actor | Email |
|
||||
|---|---|
|
||||
| Buyer | `amn-e2e-20260606043238-buyer@example.test` |
|
||||
| Seller 1 | `amn-e2e-20260606043238-seller1@example.test` |
|
||||
| Seller 2 | `amn-e2e-20260606043238-seller2@example.test` |
|
||||
| Seller 3 | `amn-e2e-20260606043238-seller3@example.test` |
|
||||
|
||||
Round 1:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| purchaseRequestId | `732e9de8-9631-484e-a5ac-bb657ca55020` |
|
||||
| paymentId | `f7f02ba4-9154-4408-984b-d3481d1ec5fa` |
|
||||
| selectedOfferId | `263b30f0-7ee6-4e63-9d0a-af2d7624fdde` |
|
||||
| selected seller | `seller3` |
|
||||
| amount | `0.26 USDT` |
|
||||
| token | `0x109F54Dab34426D5477986b0460aE5dFBA65f022` |
|
||||
| txHash | `0x7a5c2785161df3367374574d8e1af00c548131c8a44c3fa06b592966920e3edc` |
|
||||
| scanner result | paid |
|
||||
| final request status | `delivered` |
|
||||
|
||||
Round 2:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| purchaseRequestId | `da34d9bc-2b2d-4dc3-98f0-aa1a07a55ebb` |
|
||||
| paymentId | `2e8582eb-3ac3-4793-b10f-ea721b6466d4` |
|
||||
| selectedOfferId | `36e3a912-2121-4f07-87e6-4c73b1adf224` |
|
||||
| selected seller | `seller2` |
|
||||
| amount | `0.32 USDT` |
|
||||
| token | `0x109F54Dab34426D5477986b0460aE5dFBA65f022` |
|
||||
| txHash | `0x861a1197d7d345609f5a46b1a3723a29877ba9929bd1e7b21f7060381a1b14d0` |
|
||||
| scanner result | paid |
|
||||
| final request status | `delivered` |
|
||||
|
||||
Important finding from this run:
|
||||
|
||||
- Payment confirmation succeeded after correcting the chain 97 token registry to the actual tUSDT contract.
|
||||
- Buyer delivery confirmation initially failed with HTTP 403 because user ids were compared across stores directly. Backend `2.8.117` fixed the id comparison; both confirmations then returned HTTP 200.
|
||||
|
||||
## Current Product Boundary
|
||||
|
||||
The procedure currently stops at `delivered`.
|
||||
|
||||
Release policy is not complete:
|
||||
|
||||
- physical-product grace period is not implemented;
|
||||
- gift-card/digital immediate release is not implemented;
|
||||
- dispute-to-release/refund automation is not complete.
|
||||
|
||||
Use [[Testing Expansion Backlog]] before extending this scenario into fund release.
|
||||
185
11 - Testing/Marketplace E2E Smoke Runner.md
Normal file
185
11 - Testing/Marketplace E2E Smoke Runner.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
title: Marketplace E2E Smoke Runner
|
||||
tags: [testing, smoke, e2e, notifications, performance]
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Marketplace E2E Smoke Runner
|
||||
|
||||
The backend repo has a committed marketplace runner:
|
||||
|
||||
```bash
|
||||
backend/scripts/smoke/marketplace-e2e-notifications.sh
|
||||
```
|
||||
|
||||
It creates isolated test actors, runs buyer/seller marketplace flows, checks
|
||||
persisted notifications after every business mutation, and writes JSON/Markdown
|
||||
reports under ignored `backend/tmp/e2e-reports/`.
|
||||
|
||||
## Required Environment
|
||||
|
||||
Do not put secrets in command history or docs. Load these from an ignored local
|
||||
env file or a secure shell session:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `BASE_URL` | Target API, for example `https://dev.amn.gg`. Defaults to local `http://127.0.0.1:5001`. |
|
||||
| `ADMIN_EMAIL` | Admin account email used only to create test users. |
|
||||
| `ADMIN_PASSWORD` | Admin account password, never committed or printed. |
|
||||
| `PAYMENT_MODE` | `status`, `record`, or `live`. Defaults to `status`. |
|
||||
| `CONCURRENCY_LEVELS` | Comma-separated worker ramp, for example `1,2,4,8`. |
|
||||
| `ROUNDS` | Rounds per worker. Use `2` for the basic two-round requirement. |
|
||||
| `SELLERS_PER_WORKER` | Sellers per flow. Minimum enforced by runner is `2`; default is `3`. |
|
||||
| `SKIP_WRONG_RECIPIENT_CHECK` | Optional escape hatch for rate-limited environments. Defaults to `false`. |
|
||||
|
||||
## Payment Modes
|
||||
|
||||
| Mode | Use | Notes |
|
||||
|---|---|---|
|
||||
| `status` | Cheap full-flow smoke without wallet funds. | Moves the request to `payment` through the status route, then exercises seller delivery and buyer confirmation. |
|
||||
| `record` | Tests legacy `POST /api/marketplace/payments`. | Current dev run shows this route returns HTTP 500 with generated buyer/seller ids. Keep this mode for regression coverage. |
|
||||
| `live` | Tests BSC Testnet tUSDT direct-balance scanner rail. | Requires `E2E_PAYMENT_PRIVATE_KEY` or `E2E_PAYMENT_MNEMONIC`, gas, and tUSDT. Sends ERC-20 transfer and calls direct-balance check. |
|
||||
|
||||
Live mode also reads:
|
||||
|
||||
| Variable | Default |
|
||||
|---|---|
|
||||
| `E2E_NETWORK` | `bsc-testnet` |
|
||||
| `E2E_CHAIN_ID` | `97` |
|
||||
| `E2E_TOKEN_SYMBOL` | `USDT` |
|
||||
| `RPC_URL_CHAIN_97` / `BSC_TESTNET_RPC_URL` | public BSC Testnet RPC fallback |
|
||||
|
||||
## Example Commands
|
||||
|
||||
Two-round full-flow smoke without wallet funds:
|
||||
|
||||
```bash
|
||||
BASE_URL=https://dev.amn.gg \
|
||||
PAYMENT_MODE=status \
|
||||
CONCURRENCY_LEVELS=1 \
|
||||
ROUNDS=2 \
|
||||
bash scripts/smoke/marketplace-e2e-notifications.sh
|
||||
```
|
||||
|
||||
Concurrency profile starting at one worker:
|
||||
|
||||
```bash
|
||||
BASE_URL=https://dev.amn.gg \
|
||||
PAYMENT_MODE=status \
|
||||
CONCURRENCY_LEVELS=1,2,4,8,16,32 \
|
||||
ROUNDS=1 \
|
||||
bash scripts/smoke/marketplace-e2e-notifications.sh
|
||||
```
|
||||
|
||||
Live BSC Testnet tUSDT smoke:
|
||||
|
||||
```bash
|
||||
BASE_URL=https://dev.amn.gg \
|
||||
PAYMENT_MODE=live \
|
||||
CONCURRENCY_LEVELS=1 \
|
||||
ROUNDS=1 \
|
||||
bash scripts/smoke/marketplace-e2e-notifications.sh
|
||||
```
|
||||
|
||||
## Notification Behavior
|
||||
|
||||
The runner treats each state-changing business step as a notification checkpoint.
|
||||
Each checkpoint records:
|
||||
|
||||
- actor and role;
|
||||
- expected related id;
|
||||
- whether a persisted notification was found;
|
||||
- whether the missing notification is a known gap;
|
||||
- unread count from the notification list response.
|
||||
|
||||
By default, known gaps do not abort the flow. Set `STRICT_NOTIFICATIONS=true`
|
||||
to make known gaps fail the run. Set `ABORT_ON_NOTIFICATION_FAILURE=true` to
|
||||
stop at the first missing notification.
|
||||
|
||||
## Output
|
||||
|
||||
Reports are ignored by Git:
|
||||
|
||||
```text
|
||||
backend/tmp/e2e-reports/marketplace-e2e-<runId>.json
|
||||
backend/tmp/e2e-reports/marketplace-e2e-<runId>.md
|
||||
```
|
||||
|
||||
The report includes:
|
||||
|
||||
- flow pass/fail counts;
|
||||
- notification found/missing/known-gap counts;
|
||||
- wrong-recipient notification count;
|
||||
- API p50/p95/p99/max timings;
|
||||
- request, offer, payment, token, and transaction ids where applicable.
|
||||
|
||||
Actor tokens and admin credentials are never written to the report.
|
||||
|
||||
## Reference Dev Run - 2026-06-06
|
||||
|
||||
Command shape:
|
||||
|
||||
```bash
|
||||
BASE_URL=https://dev.amn.gg PAYMENT_MODE=record CONCURRENCY_LEVELS=1 ROUNDS=2 ...
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| flows passed | `0/2` |
|
||||
| request-created notifications found | `8/8` |
|
||||
| buyer new-offer notifications found | `6/6` |
|
||||
| rejected-seller notifications found | `4/4` |
|
||||
| selected-seller accepted notifications by related id | `0/2` |
|
||||
| wrong-recipient notifications | `0` |
|
||||
| legacy marketplace payment records | `0/2`, HTTP 500 |
|
||||
|
||||
Observed defects:
|
||||
|
||||
1. Selected-seller accepted notification is not discoverable by the selected
|
||||
offer `relatedId`. The service path appears to create the accepted-offer
|
||||
notification with an incomplete related id.
|
||||
2. `POST /api/marketplace/payments` returned HTTP 500 after offer selection
|
||||
with generated test actors. This blocks `PAYMENT_MODE=record`; use
|
||||
`PAYMENT_MODE=status` for flow-only smoke or `PAYMENT_MODE=live` for scanner
|
||||
verification.
|
||||
3. Dev API rate limiting is `100` requests per `900s`; concurrency runs must
|
||||
use a fresh window or a non-rate-limited test environment.
|
||||
|
||||
## Reference Direct-Backend Run - 2026-06-06
|
||||
|
||||
Command shape:
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:15001 PAYMENT_MODE=status CONCURRENCY_LEVELS=1 ROUNDS=1 ...
|
||||
```
|
||||
|
||||
This used a temporary SSH tunnel to the dev backend container to avoid the
|
||||
public edge rate-limit window.
|
||||
|
||||
Result:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| flows passed | `1/1` |
|
||||
| final request status | `delivered` |
|
||||
| wrong-recipient notifications | `0` |
|
||||
| required notification missing | `1` |
|
||||
| known notification gaps | `2` |
|
||||
|
||||
Key ids:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| purchaseRequestId | `dc6ec076-9c15-46a1-ab6b-1d9d17604614` |
|
||||
| selectedOfferId | `9f56dd00-8b08-4c9f-b91c-5875e1949113` |
|
||||
|
||||
Observed notification result:
|
||||
|
||||
- request-created notifications passed for buyer and three targeted sellers;
|
||||
- buyer new-offer notifications passed for all three seller bids;
|
||||
- rejected-seller notifications passed;
|
||||
- selected-seller accepted notification still failed by selected offer `relatedId`;
|
||||
- payment-confirmed notification was found in `PAYMENT_MODE=status`;
|
||||
- seller delivery and buyer delivery confirmation notifications are known gaps.
|
||||
160
11 - Testing/Notification Assertion Procedure.md
Normal file
160
11 - Testing/Notification Assertion Procedure.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: Notification Assertion Procedure
|
||||
tags: [testing, notifications, e2e, socket-io]
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Notification Assertion Procedure
|
||||
|
||||
Every E2E business step must verify notifications. A test step is incomplete
|
||||
until notification persistence and, where practical, real-time delivery are
|
||||
validated.
|
||||
|
||||
Related docs:
|
||||
|
||||
- [[04 - Flows/Notification Flow]]
|
||||
- [[03 - API Reference/Notification API]]
|
||||
- [[Escrow Marketplace E2E Procedure]]
|
||||
|
||||
## Principle
|
||||
|
||||
For each state-changing action, assert notifications for the expected recipients.
|
||||
If the product currently does not emit a notification for that action, the E2E
|
||||
must record a notification coverage gap. Silent missing notifications should not
|
||||
be treated as a pass.
|
||||
|
||||
## Notification Channels To Check
|
||||
|
||||
| Channel | Required? | How |
|
||||
|---|---|---|
|
||||
| Persistence | Always | `GET /api/notifications`, `GET /api/notifications/unread-count` |
|
||||
| Socket push | Required when runner has sockets enabled | Listen for `new-notification` on `user-<userId>` |
|
||||
| Badge count sync | Required for read/mark-read steps | `unread-count-update` socket event or count endpoint |
|
||||
| Email/push | Not required today | Planned digest/push work is not implemented |
|
||||
|
||||
## Baseline Before a Scenario
|
||||
|
||||
For every actor:
|
||||
|
||||
1. Authenticate.
|
||||
2. Read unread count:
|
||||
```http
|
||||
GET /api/notifications/unread-count
|
||||
```
|
||||
3. Read latest notifications:
|
||||
```http
|
||||
GET /api/notifications?page=1&limit=5
|
||||
```
|
||||
4. Store:
|
||||
- `baselineUnreadCount`;
|
||||
- latest notification id;
|
||||
- timestamp.
|
||||
|
||||
Do this before creating purchase requests or offers so later assertions can
|
||||
distinguish new notifications from old ones.
|
||||
|
||||
## Per-Step Assertion Algorithm
|
||||
|
||||
After each action:
|
||||
|
||||
1. Identify expected recipients.
|
||||
2. Poll each recipient's notifications for up to the notification SLA window.
|
||||
3. Assert at least one new notification matching the action.
|
||||
4. Assert unread count increased by the expected number, unless the notification
|
||||
is intentionally auto-read.
|
||||
5. Assert `actionUrl` or `relatedId` points to the correct request/payment/offer
|
||||
where the notification type supports it.
|
||||
6. If socket instrumentation is active, assert the same notification arrived via
|
||||
`new-notification`.
|
||||
7. Record `notificationLatencyMs = notification.createdAt - actionCompletedAt`
|
||||
or runner-observed first-seen time.
|
||||
|
||||
Suggested polling:
|
||||
|
||||
| Target | Value |
|
||||
|---|---:|
|
||||
| poll interval | `500 ms` |
|
||||
| timeout | `10 s` for local/dev API notifications |
|
||||
| hard failure threshold | `30 s` |
|
||||
|
||||
## Expected Marketplace Notifications
|
||||
|
||||
| Action | Expected recipients | Expected category/type |
|
||||
|---|---|---|
|
||||
| Buyer creates request | eligible sellers | `purchase_request` / new request or opportunity |
|
||||
| Seller submits offer | buyer | `offer` / new offer |
|
||||
| Buyer accepts offer | selected seller | `offer` / accepted |
|
||||
| Buyer accepts offer | non-selected sellers | `offer` / rejected or no-longer-selected, when implemented |
|
||||
| Payment intent created | buyer | `payment` / pending or started, when implemented |
|
||||
| Scanner confirms payment | buyer, selected seller | `payment` / confirmed or funded |
|
||||
| Seller marks delivery | buyer | `delivery` / delivery submitted |
|
||||
| Buyer confirms delivery | selected seller | `delivery` / delivery confirmed |
|
||||
| Dispute raised | buyer, seller, admin/mediator | `delivery`/`payment`/`system` dispute hold, when implemented |
|
||||
| Release/refund | buyer, seller | `payment` / release or refund |
|
||||
|
||||
Known current gaps from existing docs:
|
||||
|
||||
- `pending_payment` and `seller_paid` status changes are not covered by
|
||||
`NotificationService.notifyRequestStatusChanged`.
|
||||
- Dispute service notification emits are TODO stubs in the dashboard dispute path.
|
||||
|
||||
These known gaps should still be reported by the E2E runner as gaps, not ignored.
|
||||
|
||||
## Evidence Format
|
||||
|
||||
For each step, append a notification assertion record:
|
||||
|
||||
```json
|
||||
{
|
||||
"step": "seller_offer_created",
|
||||
"recipient": "buyer",
|
||||
"recipientUserId": "<id>",
|
||||
"expected": true,
|
||||
"observed": true,
|
||||
"latencyMs": 742,
|
||||
"unreadBefore": 3,
|
||||
"unreadAfter": 4,
|
||||
"notificationId": "<id>",
|
||||
"category": "offer",
|
||||
"actionUrl": "/dashboard/request/<requestId>",
|
||||
"socketObserved": true,
|
||||
"gap": null
|
||||
}
|
||||
```
|
||||
|
||||
For a known gap:
|
||||
|
||||
```json
|
||||
{
|
||||
"step": "payment_intent_created",
|
||||
"recipient": "buyer",
|
||||
"expected": true,
|
||||
"observed": false,
|
||||
"gap": "No notification emitted for pending_payment status",
|
||||
"linkedDoc": "Notification Flow#Purchase request status coverage gap"
|
||||
}
|
||||
```
|
||||
|
||||
## Pass/Fail Rules
|
||||
|
||||
| Condition | Result |
|
||||
|---|---|
|
||||
| Expected notification appears within SLA | pass |
|
||||
| Expected notification appears after SLA but before hard timeout | warning |
|
||||
| Expected notification never appears and no approved gap exists | fail |
|
||||
| Notification appears for wrong user | fail |
|
||||
| Notification exists but has wrong request/payment/offer id | fail |
|
||||
| Socket missing but persistence exists | warning unless socket coverage is the target |
|
||||
| Unread count inconsistent with persisted notification | fail |
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
Notification metrics must be included in concurrency profiles:
|
||||
|
||||
- notification persistence latency p50/p95/p99;
|
||||
- socket delivery latency p50/p95/p99;
|
||||
- notification failures by action and recipient;
|
||||
- unread count mismatch rate;
|
||||
- duplicate notification rate;
|
||||
- Mongo notification insert error rate.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Offer Selection & Rejection — Bug Analysis & Fix (2026-06-06)
|
||||
|
||||
## Symptom (reported)
|
||||
|
||||
In the Telegram Mini App, a buyer created a request, received offers from multiple
|
||||
sellers, accepted one and paid for it. **Both** the winning and losing seller then
|
||||
saw their request stuck at step 4 — «۴. انتظار ارسال کالا» (awaiting shipment) — as
|
||||
if both had won and both needed to ship goods.
|
||||
|
||||
## Investigation — backend vs UI
|
||||
|
||||
We traced the actual request (`کیر خر`, id `54c9de14-…`) directly in Postgres:
|
||||
|
||||
| Offer | Seller | DB status |
|
||||
|-------|--------|-----------|
|
||||
| `e90a099f` | `8346800b` | **accepted** ✅ |
|
||||
| `81b1e7af` | `4ba2a6fe` | **rejected** ✅ |
|
||||
|
||||
- `purchase_requests.selected_offer_id` = `e90a099f` (the winner) ✅
|
||||
- Request status: `delivery` ✅
|
||||
|
||||
**Notifications** (also correct):
|
||||
|
||||
| Recipient | Title |
|
||||
|-----------|-------|
|
||||
| winning seller `8512c583` | `✅ پیشنهاد شما پذیرفته شد!` |
|
||||
| losing seller `a13d3f04` | `❌ پیشنهاد شما رد شد` |
|
||||
|
||||
**Conclusion: the backend was correct.** Offers were properly accepted/rejected and
|
||||
both sellers received the correct notification. The bug was **purely in the Mini App
|
||||
UI**, which derived the seller's step from the *request* status (`delivery`) without
|
||||
checking the seller's *own* offer status.
|
||||
|
||||
## Root cause (UI)
|
||||
|
||||
`telegram-request-detail-view.tsx` computed the seller flow purely from
|
||||
`request.status`:
|
||||
|
||||
```ts
|
||||
const sellerOnDeliveryStep = role === 'seller' && request?.status === 'delivery';
|
||||
const currentStep = determineCurrentStepFromStatus(request.status, role);
|
||||
```
|
||||
|
||||
A rejected seller, whose offer status is `rejected`, still saw the full seller stepper
|
||||
(including step 4 «انتظار ارسال کالا») because the request as a whole is in `delivery`.
|
||||
|
||||
### ID-namespace gotcha
|
||||
|
||||
The fix needs to know whether *this* seller won. Marketplace offers store
|
||||
`sellerId` as the **Postgres UUID** (`users.id`), but the auth user's `_id`/`id`
|
||||
is the **legacy Mongo ObjectId** (`legacy_object_id`). The auth payload exposes
|
||||
`pgId` for the UUID — so the ownership check must compare `offer.sellerId` against
|
||||
`user.pgId`, **not** `user._id`. (Verified via `/api/auth/login` response shape.)
|
||||
|
||||
## Fix (UI) — frontend v2.9.13 (built on mojtaba's v2.9.12)
|
||||
|
||||
The parallel agent (mojtaba) shipped **v2.9.12** first: it added the canonical
|
||||
`StepContext` API in `request-config.tsx` (`determineSellerStep` returns
|
||||
`SELLER_REJECTED_STEP = 0` when `hasSelectedOffer && !isSelectedSeller && hasOffer`
|
||||
and status is post-selection), fixed the **web** seller view, and fixed the telegram
|
||||
stepper's RTL connector lines. **But it did not wire the telegram detail view into
|
||||
that API** — that view still called `determineCurrentStepFromStatus(status, role)`
|
||||
without a ctx and kept an ungated `sellerOnDeliveryStep`, so the mini-app stayed broken.
|
||||
|
||||
**v2.9.13** (this fix) wires the telegram detail view into mojtaba's StepContext:
|
||||
- New `userId` prop carries the user's **pgId** (from `telegram-mini-app-view.tsx`
|
||||
as `selfPgId = user.pgId ?? selfId`).
|
||||
- For sellers in a post-selection status, fetch the offers list. The API only returns
|
||||
non-rejected offers, so a loser's offer is absent — we synthesise
|
||||
`sellerOfferStatus: 'rejected'` when a winning offer exists that isn't this seller's
|
||||
(so `determineSellerStep`'s `hasOffer` guard is satisfied and it returns step 0).
|
||||
- Build `sellerStepCtx = { sellerOfferStatus, isSelectedSeller, hasSelectedOffer }` and
|
||||
pass it to `determineCurrentStepFromStatus(status, role, ctx)` — same logic as web.
|
||||
- `sellerIsRejected = currentStep === 0`; gate `sellerOnDeliveryStep` on `!sellerIsRejected`
|
||||
and render a dedicated «پیشنهاد شما انتخاب نشد» screen.
|
||||
- New locale keys `offer_not_selected_title` / `offer_not_selected_body` (en + fa).
|
||||
|
||||
### Key gotcha — pgId vs legacy _id
|
||||
Offers store `sellerId` as the **Postgres UUID** (`users.id`); the auth user's
|
||||
`_id`/`id` is the **legacy Mongo ObjectId** (`legacy_object_id`). The auth payload
|
||||
exposes `pgId` for the UUID. Ownership checks must compare `offer.sellerId` against
|
||||
`user.pgId`, not `user._id`. Notification `userId`, however, uses the legacy id.
|
||||
|
||||
## Hardening (backend) — v2.9.11
|
||||
|
||||
Although the *select-then-pay* flow (the Mini App path, via `marketplaceController.selectOffer`
|
||||
→ `SellerOfferService.acceptOffer`) already persisted loser notifications, several
|
||||
**direct payment paths** rejected sibling offers at the repo/SQL level **without**
|
||||
sending notifications:
|
||||
|
||||
- `paymentRoutes.ts` `/payments/verify` — called `repo.acceptOffer` (no notify)
|
||||
- `paymentController.ts` payment propagation — called `repo.acceptOffer` (no notify)
|
||||
- `paymentCoordinator.ts` escrow-funded path — raw in-tx reject (no notify)
|
||||
|
||||
### Changes
|
||||
|
||||
1. **`SellerOfferService.acceptOffer` is now idempotent.** It snapshots the
|
||||
pending/active siblings *before* the accept and notifies exactly those freshly
|
||||
rejected. A repeat call rejects 0 rows → notifies nobody. The winner notification
|
||||
only fires when the offer actually transitions to `accepted` (guarded on prior
|
||||
status). This makes it safe to call from every payment path without double-notify.
|
||||
|
||||
2. **`paymentRoutes` & `paymentController`** now call `SellerOfferService.acceptOffer`
|
||||
(with a repo fallback) so winner + losers are notified.
|
||||
|
||||
3. **`paymentCoordinator`** keeps its atomic in-transaction reject (v2.9.10) for the
|
||||
money path, but now captures the freshly-rejected seller ids via `.returning()`
|
||||
and sends the winner/loser notifications **after commit** (best-effort).
|
||||
|
||||
## Regression test
|
||||
|
||||
`backend/scripts/smoke/offer-selection-rejection.mjs` — **21/21 PASS** against dev.
|
||||
|
||||
Flow: 1 buyer + 3 sellers → request → 3 offers → buyer selects offer[0]. Asserts:
|
||||
- buyer sees exactly 1 offer (2 rejected + hidden)
|
||||
- the visible offer is the winner with status `accepted`
|
||||
- each losing seller's offer is hidden (rejected)
|
||||
- **each losing seller received the `❌ پیشنهاد شما رد شد` notification**
|
||||
- **the winning seller received the `✅ پیشنهاد شما پذیرفته شد!` notification**
|
||||
- the request records the winning `selectedOfferId`
|
||||
|
||||
Run:
|
||||
```bash
|
||||
ADMIN_EMAIL=… ADMIN_PASSWORD=… API_BASE_URL=https://dev.amn.gg \
|
||||
node backend/scripts/smoke/offer-selection-rejection.mjs
|
||||
```
|
||||
|
||||
## Files touched
|
||||
|
||||
**Frontend (v2.9.13 — pushed)**
|
||||
- `src/sections/telegram/view/telegram-request-detail-view.tsx`
|
||||
- `src/sections/telegram/view/telegram-mini-app-view.tsx`
|
||||
- `src/sections/telegram/locales/{en,fa,types}.ts`
|
||||
|
||||
**Backend (v2.9.11 — pushed; bundled with the v2.9.12 Mongo retirement)**
|
||||
- `src/services/marketplace/SellerOfferService.ts`
|
||||
- `src/services/payment/paymentRoutes.ts`
|
||||
- `src/services/payment/paymentController.ts`
|
||||
- `src/services/payment/paymentCoordinator.ts`
|
||||
- `scripts/smoke/offer-selection-rejection.mjs`
|
||||
|
||||
Push was initially held while the parallel Mongo-retirement refactor (which broke
|
||||
the shared working tree's typecheck) was in flight. Once it compiled clean, the
|
||||
nuke + v2.9.11 were committed (`30a88eb` v2.9.12, `15bbae3` v2.9.11) and pushed.
|
||||
206
11 - Testing/Payment Safety Edge Cases.md
Normal file
206
11 - Testing/Payment Safety Edge Cases.md
Normal file
@@ -0,0 +1,206 @@
|
||||
---
|
||||
title: Payment Safety Edge Cases
|
||||
tags: [testing, payment, safety, aml, scanner, edge-cases]
|
||||
created: 2026-06-08
|
||||
---
|
||||
|
||||
# Payment Safety Edge Cases
|
||||
|
||||
Automated tests live in `backend/__tests__/payment-edge-cases.test.ts` (38 tests, all passing).
|
||||
This document records the design rationale, current system behaviour, and remaining gaps for each
|
||||
of the five payment edge-case families.
|
||||
|
||||
## Edge Case Families
|
||||
|
||||
### 1 · Blacklisted / OFAC-Sanctioned Sender Wallet
|
||||
|
||||
**How the system works**
|
||||
The OFAC SDN list is downloaded from US Treasury once per 24 h and cached locally. Address
|
||||
screening runs when the seller has opted in (`requireAmlCheck=true` on the seller offer). For
|
||||
on-chain (BSC Verifier) payments the buyer address comes from the ERC-20 Transfer log `from` field.
|
||||
For direct-balance (AMN Scanner) payments the buyer address must be stored in
|
||||
`amnScannerDirectBalance.buyerAddress` at intent-creation time.
|
||||
|
||||
**Implemented behaviour**
|
||||
|
||||
| Scenario | Result |
|
||||
|---|---|
|
||||
| OFAC-listed address + seller opted in | `block=true`, `reason=aml_sanctions` |
|
||||
| OFAC-listed address + seller NOT opted in | `block=false`, `reason=aml_not_required` |
|
||||
| OFAC provider unreachable + `amlBlockOnFailure=true` | `block=true` (fail-closed) |
|
||||
| OFAC provider unreachable + `amlBlockOnFailure=false` | `block=false`, `providerUnavailable=true` (fail-open) |
|
||||
| No buyer address (direct-balance, no tx hash) | `block=false`, `reason=no_buyer_address_to_screen` |
|
||||
| Direct-balance + `buyerAddress` stored + sanctioned | `block=true` via `fundDirectBalancePayment` AML gate |
|
||||
| BTC / XMR addresses in SDN XML | Ignored (only EVM `0x…` addresses parsed) |
|
||||
|
||||
**Remaining gaps**
|
||||
|
||||
- AML is opt-in per seller. A sanctioned address can pay any seller with `requireAmlCheck=false`.
|
||||
Platform-level mandatory screening could be added via a `PLATFORM_AML_REQUIRED=1` env flag.
|
||||
- SDN list can be up to 24 h stale.
|
||||
|
||||
**Relevant code**
|
||||
- `src/services/payment/safety/ofacProvider.ts` — SDN download, parse, cache
|
||||
- `src/services/payment/safety/amlScreeningService.ts` — `screenPaymentForAml()`
|
||||
- `src/services/payment/amnScanner/directBalancePaymentService.ts` — `fundDirectBalancePayment()` AML gate
|
||||
|
||||
---
|
||||
|
||||
### 2 · Overpayment and Underpayment
|
||||
|
||||
**How the system works**
|
||||
On-chain (BSC Verifier): amount ≥ expected → success; amount < expected → `insufficient_amount`.
|
||||
Direct-balance (webhook): delta ≥ expected → `funded=true`; delta < expected → `funded=false`.
|
||||
Overpay excess is accepted silently and remains locked at the derived destination address.
|
||||
|
||||
**Implemented behaviour**
|
||||
|
||||
| Scenario | Result |
|
||||
|---|---|
|
||||
| On-chain overpay (15 USDT vs 10 expected) | `success=true`, `actualAmount` returned |
|
||||
| On-chain exact match | `success=true` |
|
||||
| On-chain underpay by 1 wei | `failureReason=insufficient_amount`, both amounts returned |
|
||||
| Direct-balance overpay | `funded=true`, `reason=paid` |
|
||||
| Direct-balance exact match | `funded=true` |
|
||||
| Direct-balance underpay | `funded=false`, `reason=underpaid:delta=…,expected=…` **+ `payment-underpaid` socket event** with `shortfall` |
|
||||
| Direct-balance zero balance | `funded=false` |
|
||||
|
||||
**Remaining gaps**
|
||||
|
||||
- No dedicated `underpaid` payment status in the DB — payment stays `pending` until expiry.
|
||||
- Overpay excess locked at derived address with no automated recovery.
|
||||
|
||||
**Relevant code**
|
||||
- `src/services/payment/decentralizedPaymentService.ts` — `BSCTransactionVerifier.verifyTransfer()`
|
||||
- `src/services/payment/amnScanner/directBalancePaymentService.ts` — `processDirectBalanceWebhook()`
|
||||
|
||||
---
|
||||
|
||||
### 3 · Native Coin (ETH / BNB) Sent Instead of ERC-20
|
||||
|
||||
**How the system works**
|
||||
Native coin transfers emit no ERC-20 Transfer log. On-chain verification sees no Transfer events.
|
||||
The AMN scanner watches a specific ERC-20 token balance; native coin does not affect it.
|
||||
|
||||
**Implemented behaviour**
|
||||
|
||||
| Scenario | Result |
|
||||
|---|---|
|
||||
| On-chain tx succeeded, empty logs | `failureReason=wrong_asset` ← **distinguishable from transfer_not_found** |
|
||||
| On-chain tx with only Approval log | `failureReason=transfer_not_found` |
|
||||
| Direct-balance webhook, ERC-20 balance unchanged | `funded=false` — scanner unaware of native coin arrival |
|
||||
| Direct-balance API check, native baseline captured | `warning=native_coin_detected:delta=N` when native balance increased |
|
||||
|
||||
**Remaining gaps**
|
||||
|
||||
- The direct-balance **webhook** path cannot detect native coin because the scanner only reports
|
||||
ERC-20 balance changes. A native-coin watch could be added to the scanner service separately.
|
||||
- Native coin locked at derived address — no automated sweep path.
|
||||
|
||||
**`wrong_asset` vs `transfer_not_found`**
|
||||
|
||||
| failureReason | Meaning |
|
||||
|---|---|
|
||||
| `wrong_asset` | Tx succeeded on-chain with zero logs — almost certainly native coin was sent |
|
||||
| `transfer_not_found` | Tx succeeded but logs exist yet none match token/recipient — likely wrong token or misconfigured tx |
|
||||
|
||||
**Relevant code**
|
||||
- `decentralizedPaymentService.ts:verifyTransfer()` — `receipt.logs.length === 0` → `wrong_asset`
|
||||
- `directBalancePaymentService.ts:createDirectBalancePayIntent()` — captures `nativeBaselineBalance`
|
||||
- `directBalancePaymentService.ts:checkDirectBalancePayment()` — compares native balance to baseline
|
||||
|
||||
---
|
||||
|
||||
### 4 · Wrong ERC-20 Token Sent to Deposit Address
|
||||
|
||||
**How the system works**
|
||||
On-chain: BSC Verifier detects token/recipient mismatches explicitly. Direct-balance webhook: the
|
||||
scanner reports the token address in the payload; mismatches are caught at intake.
|
||||
|
||||
**Implemented behaviour**
|
||||
|
||||
| Scenario | Result |
|
||||
|---|---|
|
||||
| On-chain: USDC sent when USDT expected | `failureReason=wrong_token`, `actualToken` populated |
|
||||
| On-chain: right token, wrong recipient | `failureReason=wrong_recipient`, `actualRecipient` populated |
|
||||
| On-chain: completely random token + address | `failureReason=transfer_not_found` |
|
||||
| Direct-balance webhook: wrong `tokenAddress` | `funded=false`, `reason=address-token-mismatch` **+ `payment-wrong-token` socket event** |
|
||||
| Direct-balance webhook: wrong `chainId` | `funded=false`, `reason=address-token-mismatch` **+ `payment-wrong-token` socket event** |
|
||||
| Direct-balance webhook: wrong `address` | `funded=false`, `reason=address-token-mismatch` **+ `payment-wrong-token` socket event** |
|
||||
|
||||
**Remaining gaps**
|
||||
|
||||
- Wrong-token funds are locked at the derived address — no automated recovery.
|
||||
|
||||
**Relevant code**
|
||||
- `decentralizedPaymentService.ts` — `parseTransferLogs()`, `verifyTransfer()` wrong-token detection
|
||||
- `directBalancePaymentService.ts:processDirectBalanceWebhook()` — mismatch emits `payment-wrong-token`
|
||||
|
||||
---
|
||||
|
||||
### 5 · Smart-Contract Sender
|
||||
|
||||
**How the system works**
|
||||
The ERC-20 Transfer log `from` field (topics[1]) is the immediate sender. Smart contracts
|
||||
(DEX routers, multisigs, mixers) appear here. The system has no built-in EOA detection — only
|
||||
OFAC-listed contract addresses are blocked.
|
||||
|
||||
**Implemented behaviour**
|
||||
|
||||
| Scenario | Result |
|
||||
|---|---|
|
||||
| Contract address in Transfer log | Surfaced as `evidence.from`, passed to AML |
|
||||
| OFAC-listed contract + seller opted in | `block=true` |
|
||||
| Unlisted contract (e.g. mixer) + not on OFAC | `block=false` — **gap** |
|
||||
| Gnosis Safe (legitimate multisig) | `block=false` — indistinguishable from mixer by address alone |
|
||||
| `requireEoaSender=true` + contract bytecode | `failureReason=contract_sender`, `success=false` |
|
||||
| `requireEoaSender=true` + EOA (empty bytecode) | `success=true` as normal |
|
||||
| `requireEoaSender` not set (default) | Contract sender passes — backward-compatible |
|
||||
|
||||
**Enabling EOA enforcement**
|
||||
|
||||
Set env flag at any scope (per-service or globally):
|
||||
|
||||
```
|
||||
TRANSACTION_SAFETY_REQUIRE_EOA_SENDER=1
|
||||
```
|
||||
|
||||
This is wired to all 5 `verifyTransfer` call sites:
|
||||
- `transactionSafetyProvider.ts` (webhook safety evaluation)
|
||||
- `paymentController.ts` (new + re-verify web3 payment paths)
|
||||
- `paymentRoutes.ts`
|
||||
- `marketplace/routes.ts`
|
||||
|
||||
**Remaining gaps**
|
||||
|
||||
- No bytecode check in `screenPaymentForAml()` itself — the AML layer cannot gate on sender type.
|
||||
- No seller-level `requireEoaSender` flag — currently only a platform-wide env toggle.
|
||||
- Gnosis Safe and unlisted mixers remain indistinguishable at address level. A known-multisig
|
||||
factory allowlist would be required for principled permitting.
|
||||
|
||||
**Relevant code**
|
||||
- `decentralizedPaymentService.ts:verifyTransfer()` — `requireEoaSender` → `eth_getCode` → `contract_sender`
|
||||
- `transactionSafetyProvider.ts` — reads `TRANSACTION_SAFETY_REQUIRE_EOA_SENDER`
|
||||
|
||||
---
|
||||
|
||||
## Test File Reference
|
||||
|
||||
`backend/__tests__/payment-edge-cases.test.ts`
|
||||
|
||||
| Section | Tests | Status |
|
||||
|---|---|---|
|
||||
| 1 · Blacklisted / OFAC wallet | 10 | ✓ all pass |
|
||||
| 2 · Overpayment and underpayment | 8 | ✓ all pass |
|
||||
| 3 · Native coin (ETH/BNB) | 4 | ✓ all pass |
|
||||
| 4 · Wrong ERC-20 token | 7 | ✓ all pass |
|
||||
| 5 · Smart-contract sender | 9 | ✓ all pass |
|
||||
| **Total** | **38** | **✓** |
|
||||
|
||||
Tests labelled `GAP ·` document known system limitations that pass because they describe current
|
||||
(undesired) behaviour. Tests labelled `FIXED ·` confirm a gap was mitigated.
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd backend && node_modules/.bin/jest __tests__/payment-edge-cases.test.ts --no-coverage
|
||||
```
|
||||
180
11 - Testing/Scanner BSC Testnet Payment Procedure.md
Normal file
180
11 - Testing/Scanner BSC Testnet Payment Procedure.md
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
title: Scanner BSC Testnet Payment Procedure
|
||||
tags: [testing, scanner, payment, bsc-testnet, usdt]
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Scanner BSC Testnet Payment Procedure
|
||||
|
||||
This procedure validates that dev payments use the correct BSC Testnet tUSDT
|
||||
contract and that scanner confirms the payment.
|
||||
|
||||
Related docs:
|
||||
|
||||
- [[04 - Flows/Payment Flow - Scanner]]
|
||||
- [[01 - Architecture/Scanner Architecture]]
|
||||
- [[03 - API Reference/Scanner API]]
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Dev stack is healthy.
|
||||
- Scanner is deployed from the latest Forgejo `scanner` commit.
|
||||
- Backend and scanner token registries agree on chain 97 USDT.
|
||||
- Frontend `2.8.118+` is deployed if wallet UI is part of the test.
|
||||
- Test wallet has:
|
||||
- tBNB for gas,
|
||||
- tUSDT at `0x109F54Dab34426D5477986b0460aE5dFBA65f022`.
|
||||
|
||||
## Chain Registry Smoke
|
||||
|
||||
Run from backend:
|
||||
|
||||
```bash
|
||||
cd ~/CascadeProjects/escrow/backend
|
||||
BASE_URL=https://dev.amn.gg bash scripts/smoke/bsc-testnet-payment-registry.sh
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- chain 97 exists;
|
||||
- USDT address is `0x109f54dab34426d5477986b0460ae5dfba65f022`;
|
||||
- decimals are `18`;
|
||||
- RPC URL is a BSC Testnet RPC;
|
||||
- frontend/backend metadata does not point to the old token.
|
||||
|
||||
## Live Scanner Balance Check
|
||||
|
||||
From a backend or host context that can reach scanner, check the funded wallet
|
||||
by symbol.
|
||||
|
||||
Expected fields:
|
||||
|
||||
| Field | Expected |
|
||||
|---|---|
|
||||
| `chainId` | `97` |
|
||||
| `tokenSymbol` | `USDT` |
|
||||
| `tokenAddress` | `0x109f54dab34426d5477986b0460ae5dfba65f022` |
|
||||
| `decimals` | `18` |
|
||||
| `balance` | positive base-unit balance |
|
||||
|
||||
If BscScan shows token balance but scanner returns zero, the usual cause is a
|
||||
token contract mismatch.
|
||||
|
||||
## Direct-Balance Payment Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer wallet
|
||||
participant FE as Frontend/API test
|
||||
participant BE as Backend
|
||||
participant SC as Scanner
|
||||
participant BSC as BSC Testnet
|
||||
|
||||
FE->>BE: Create payment intent
|
||||
BE->>SC: Store baseline balance for destination/token
|
||||
SC->>BSC: balanceOf(destination)
|
||||
SC-->>BE: baseline recorded
|
||||
FE-->>B: Show destination, amount, token
|
||||
B->>BSC: transfer tUSDT to destination
|
||||
FE->>BE: "I paid" / direct-balance check
|
||||
BE->>SC: POST /balances/check
|
||||
SC->>BSC: balanceOf(destination)
|
||||
SC-->>BE: delta >= expected amount
|
||||
BE-->>FE: payment paid/confirmed
|
||||
```
|
||||
|
||||
## Required Assertions
|
||||
|
||||
| Assertion | Why |
|
||||
|---|---|
|
||||
| Intent uses chain `97` | Ensures testnet path, not BSC mainnet. |
|
||||
| Intent uses token `0x109F54...` | Ensures canonical test USDT. |
|
||||
| Amount base units equals decimal amount * `10^18` | Prevents 6-vs-18 decimal errors. |
|
||||
| Destination address matches scanner check | Prevents false positives from wrong wallet. |
|
||||
| Tx hash exists on BSC Testnet | Confirms buyer transfer actually happened. |
|
||||
| Scanner reports `paid` or `confirmed` | Confirms scanner sees the transfer. |
|
||||
| Backend marks payment paid/confirmed | Confirms scanner result reaches business state. |
|
||||
|
||||
## Negative Scenarios
|
||||
|
||||
These are designed and should be automated before release confidence is claimed.
|
||||
|
||||
### Wrong token
|
||||
|
||||
1. Create a chain 97 tUSDT intent.
|
||||
2. Pay the same amount using a different token contract.
|
||||
3. Run scanner balance check.
|
||||
|
||||
Expected:
|
||||
|
||||
- intended tUSDT destination delta is unchanged;
|
||||
- payment remains pending;
|
||||
- logs explain token mismatch or no matching delta.
|
||||
|
||||
### Underpayment
|
||||
|
||||
1. Create an intent for `0.30 USDT`.
|
||||
2. Transfer `0.29 USDT`.
|
||||
3. Run scanner balance check.
|
||||
|
||||
Expected:
|
||||
|
||||
- scanner returns unpaid or insufficient delta;
|
||||
- backend does not mark payment paid.
|
||||
|
||||
### Wrong destination
|
||||
|
||||
1. Create a valid intent.
|
||||
2. Transfer exact token/amount to a different address.
|
||||
3. Run scanner balance check for the intended destination.
|
||||
|
||||
Expected:
|
||||
|
||||
- intended payment remains pending.
|
||||
|
||||
### Duplicate payment
|
||||
|
||||
1. Complete a payment successfully.
|
||||
2. Transfer the same amount to the same destination again.
|
||||
3. Trigger check/webhook path again.
|
||||
|
||||
Expected:
|
||||
|
||||
- no duplicate ledger credit;
|
||||
- payment remains single-paid;
|
||||
- second transfer is either ignored or tracked as overpayment/manual recovery.
|
||||
|
||||
### Missing gas
|
||||
|
||||
1. Use a wallet with tUSDT but no tBNB.
|
||||
2. Attempt payment through checkout UI.
|
||||
|
||||
Expected:
|
||||
|
||||
- wallet cannot broadcast transaction;
|
||||
- UI shows wallet/network/gas error;
|
||||
- scanner does not mark paid.
|
||||
|
||||
## UI Assertions
|
||||
|
||||
For frontend `2.8.118+`:
|
||||
|
||||
| UI item | Expected |
|
||||
|---|---|
|
||||
| Network row | `BSC Testnet (97)` |
|
||||
| Token contract row | `USDT 0x109F...f022` with copy/link support |
|
||||
| Address link | `https://testnet.bscscan.com/address/...` |
|
||||
| Tx link | `https://testnet.bscscan.com/tx/...` |
|
||||
| Wallet switch | chain id `97` is supported by Wagmi config |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Check |
|
||||
|---|---|---|
|
||||
| Wallet funded but scanner sees zero | Wrong token contract in scanner/backend registry | `tokens.json`, `supported-chains.json`, scanner balance result |
|
||||
| Intent id looks like `undefined-c56-USDC` | Backend adapter fell back to global merchant/default context | `scannerContext` in backend AMN scanner intent path |
|
||||
| Payment waits for too many confirmations | Runtime/admin threshold for chain 97 too high | `confirmation_threshold:97` |
|
||||
| UI links to mainnet BscScan | Frontend explorer map missing chain 97 | frontend checkout version |
|
||||
| Wallet cannot switch to testnet | Wagmi config missing `bscTestnet` | frontend `src/web3/config.ts` |
|
||||
|
||||
222
11 - Testing/Smoke and Regression Procedure.md
Normal file
222
11 - Testing/Smoke and Regression Procedure.md
Normal file
@@ -0,0 +1,222 @@
|
||||
---
|
||||
title: Smoke and Regression Procedure
|
||||
tags: [testing, smoke, regression, ci, deploy]
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Smoke and Regression Procedure
|
||||
|
||||
This page defines the standard checks before and after backend/frontend/scanner
|
||||
changes.
|
||||
|
||||
## Local/Pre-Push Rules
|
||||
|
||||
For frontend or backend code changes:
|
||||
|
||||
1. Run focused tests for the touched area.
|
||||
2. Run typecheck.
|
||||
3. Run `git diff --check`.
|
||||
4. Bump backend and frontend patch versions together if a build/deploy will be pushed.
|
||||
5. For payment/scanner changes, run the relevant smoke script before pushing.
|
||||
|
||||
Docs-only changes do not require version bumps.
|
||||
|
||||
## Backend Smoke Scripts
|
||||
|
||||
Backend smoke scripts live in:
|
||||
|
||||
```text
|
||||
backend/scripts/smoke/
|
||||
```
|
||||
|
||||
Current scripts:
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `backend-health.sh` | Basic API health/version target check. |
|
||||
| `auth-basic.sh` | Basic auth behavior. |
|
||||
| `addresses-basic.sh` | Address APIs. |
|
||||
| `bsc-testnet-payment-registry.sh` | Chain 97 token/RPC registry sanity. |
|
||||
| `confirmation-thresholds.sh` | Confirmation threshold APIs/settings. |
|
||||
| `funds-ledger-repo.sh` | Funds ledger repository behavior. |
|
||||
| `marketplace-e2e-notifications.sh` | Buyer/seller E2E smoke with notification assertions, concurrency levels, and reports. |
|
||||
| `marketplace-request-budget.sh` | Purchase request budget validation. |
|
||||
| `rn-intent.sh` | Request Network / in-house intent creation smoke. |
|
||||
| `rn-webhook.sh` | Webhook shape/signature flow. |
|
||||
| `user-admin-postgres.sh` | Admin user persistence. |
|
||||
| `user-dependencies.sh` | User dependency smoke. |
|
||||
|
||||
Run against dev:
|
||||
|
||||
```bash
|
||||
cd ~/CascadeProjects/escrow/backend
|
||||
BASE_URL=https://dev.amn.gg bash scripts/smoke/bsc-testnet-payment-registry.sh
|
||||
```
|
||||
|
||||
Two-round marketplace smoke:
|
||||
|
||||
```bash
|
||||
cd ~/CascadeProjects/escrow/backend
|
||||
BASE_URL=https://dev.amn.gg \
|
||||
PAYMENT_MODE=status \
|
||||
CONCURRENCY_LEVELS=1 \
|
||||
ROUNDS=2 \
|
||||
bash scripts/smoke/marketplace-e2e-notifications.sh
|
||||
```
|
||||
|
||||
Use `PAYMENT_MODE=live` when the BSC Testnet wallet has gas and canonical
|
||||
tUSDT. The runner writes reports to ignored `backend/tmp/e2e-reports/`; see
|
||||
[[Marketplace E2E Smoke Runner]].
|
||||
|
||||
Run against local:
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:5001 bash scripts/smoke/backend-health.sh
|
||||
```
|
||||
|
||||
## Focused Automated Tests
|
||||
|
||||
Payment/scanner backend focus:
|
||||
|
||||
```bash
|
||||
cd ~/CascadeProjects/escrow/backend
|
||||
npm test -- --runTestsByPath \
|
||||
__tests__/rn-in-house-checkout.test.ts \
|
||||
__tests__/decentralized-payment-verifier.test.ts \
|
||||
__tests__/amn-pay-adapter-intent.test.ts \
|
||||
--runInBand
|
||||
```
|
||||
|
||||
Marketplace/delivery focus:
|
||||
|
||||
```bash
|
||||
npm test -- --runTestsByPath \
|
||||
__tests__/simple-marketplace.test.ts \
|
||||
__tests__/marketplace-idor.test.ts \
|
||||
__tests__/escrow-state-machine.test.ts \
|
||||
--runInBand
|
||||
```
|
||||
|
||||
Frontend checkout focus:
|
||||
|
||||
```bash
|
||||
cd ~/CascadeProjects/escrow/frontend
|
||||
npx tsc --noEmit --ignoreDeprecations 6.0
|
||||
npx eslint src/web3/config.ts src/web3/types.ts src/sections/payment/checkout/rn-in-house-checkout-view.tsx
|
||||
npm run build
|
||||
```
|
||||
|
||||
Frontend Docker install-layer smoke:
|
||||
|
||||
```bash
|
||||
cd ~/CascadeProjects/escrow/frontend
|
||||
DOCKER_BUILDKIT=1 docker build --no-cache --target builder -t escrow-frontend-builder-smoke .
|
||||
docker image rm escrow-frontend-builder-smoke
|
||||
docker builder prune -f
|
||||
```
|
||||
|
||||
Use this when CI fails before `npm run build` in the Docker dependency install
|
||||
layer. For a faster local isolation check, create a temporary Dockerfile from the
|
||||
production Dockerfile through the Yarn install `RUN` block, then append:
|
||||
|
||||
```dockerfile
|
||||
RUN test -d node_modules/country-flag-icons
|
||||
```
|
||||
|
||||
The expected install command uses a locked BuildKit Yarn cache, a Yarn mutex,
|
||||
and `--frozen-lockfile`, matching the last known green production build shape.
|
||||
|
||||
Scanner focus:
|
||||
|
||||
```bash
|
||||
cd ~/CascadeProjects/escrow/scanner
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## CI/Deploy Verification
|
||||
|
||||
Use Woodpecker with token from the local environment:
|
||||
|
||||
```bash
|
||||
export WOODPECKER_SERVER=https://ci.tbs.amn.gg
|
||||
export WOODPECKER_TOKEN=<local secret>
|
||||
```
|
||||
|
||||
Never commit or paste the token into docs.
|
||||
|
||||
When Docker build steps fail with `ENOSPC`, inspect logs and host storage with
|
||||
Woodpecker before retrying blindly:
|
||||
|
||||
```bash
|
||||
cd ~/CascadeProjects/escrow
|
||||
set -a; source .env; set +a
|
||||
woodpecker-cli pipeline ls --limit 5 5
|
||||
woodpecker-cli pipeline ps 5 <pipeline-number>
|
||||
woodpecker-cli pipeline log show 5 <pipeline-number> build-and-deploy
|
||||
```
|
||||
|
||||
The backend and frontend production pipelines run local Docker builds on the
|
||||
arm64 agent and should retain the last-green simple build shape unless a scoped
|
||||
CI change is explicitly approved. Do not add Docker prune/cleanup blocks to the
|
||||
production pipelines as a first response; inspect the host and clear space
|
||||
operationally. If a killed pipeline still shows a step as `running`, queued
|
||||
builds may stay pending until the Woodpecker agent process or the stuck Docker
|
||||
process is restarted on the host.
|
||||
|
||||
Poll latest pipelines by repo id:
|
||||
|
||||
| Repo | Woodpecker repo id | Expected pipeline |
|
||||
|---|---:|---|
|
||||
| backend | `5` | typecheck, build-and-deploy, notify |
|
||||
| frontend | `7` | build-and-deploy, notify |
|
||||
| scanner | `8` | build/test/deploy |
|
||||
|
||||
Required post-deploy checks:
|
||||
|
||||
```bash
|
||||
curl -fsS https://dev.amn.gg/api/version
|
||||
curl -fsS -I https://dev.amn.gg/
|
||||
ssh -i <ssh-key> root@<dev-host> \
|
||||
"docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}' | grep -E 'escrow-(backend|frontend|scanner)'"
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `/api/version` matches package version for backend/frontend build pair;
|
||||
- frontend root returns HTTP 200;
|
||||
- backend/frontend/scanner containers are healthy;
|
||||
- corresponding Woodpecker pipelines are `success`.
|
||||
|
||||
## Regression Evidence Format
|
||||
|
||||
For every test run, record:
|
||||
|
||||
```markdown
|
||||
### YYYY-MM-DD - <short summary>
|
||||
|
||||
- Target: `https://dev.amn.gg`
|
||||
- Versions: backend/frontend/scanner
|
||||
- CI: backend #, frontend #, scanner #
|
||||
- Commands:
|
||||
- `...`
|
||||
- Live scenarios:
|
||||
- run id
|
||||
- request ids
|
||||
- payment ids
|
||||
- tx hashes
|
||||
- Result: pass/fail/blocked
|
||||
- Residual risk:
|
||||
- ...
|
||||
```
|
||||
|
||||
## When To Stop a Release
|
||||
|
||||
Stop or roll back if any P0 condition fails:
|
||||
|
||||
- payment can be marked paid without correct chain/token/destination/amount evidence;
|
||||
- buyer cannot complete a valid payment;
|
||||
- non-buyer can confirm delivery;
|
||||
- frontend cannot connect/switch to the chain returned by backend checkout;
|
||||
- backend/frontend/scanner registry mismatch exists;
|
||||
- CI deploy reports success but live containers are not healthy;
|
||||
- secrets appear in git status, logs, docs, or CI output.
|
||||
142
11 - Testing/Test Environment and Data.md
Normal file
142
11 - Testing/Test Environment and Data.md
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: Test Environment and Data
|
||||
tags: [testing, environment, data, secrets, bsc-testnet]
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Test Environment and Data
|
||||
|
||||
This page defines the shared test environment and safe test-data practices.
|
||||
|
||||
## Environments
|
||||
|
||||
| Environment | URL / location | Purpose |
|
||||
|---|---|---|
|
||||
| Dev web/API | `https://dev.amn.gg` | Live development deployment used for end-to-end validation. |
|
||||
| Dev host | Deployment host over SSH | Docker stack, Woodpecker agent, scanner/backend/frontend containers. |
|
||||
| Forgejo | `git.tbs.amn.gg` | Source of pushed backend/frontend/scanner/docs repos. |
|
||||
| Woodpecker | `https://ci.tbs.amn.gg` | CI/CD for dev images and Docker stack updates. |
|
||||
| BSC Testnet | chain id `97` | Real testnet chain used for token payment validation. |
|
||||
|
||||
## Secrets Policy
|
||||
|
||||
Do not write secrets into docs, commits, shell history snippets, screenshots, or
|
||||
issue reports.
|
||||
|
||||
Secrets include:
|
||||
|
||||
- admin password
|
||||
- CI token
|
||||
- SSH private key
|
||||
- wallet mnemonic
|
||||
- wallet private key
|
||||
- `.env` contents
|
||||
- scanner API keys or webhook secrets
|
||||
|
||||
Safe to document:
|
||||
|
||||
- public wallet addresses
|
||||
- public tx hashes
|
||||
- token contract addresses
|
||||
- chain ids
|
||||
- request/payment/offer ids from test data
|
||||
- command shapes with placeholder env vars
|
||||
|
||||
## Wallets
|
||||
|
||||
For live payment tests, create a fresh test wallet programmatically and store
|
||||
the secret material only in an ignored local `.env`.
|
||||
|
||||
Required checks:
|
||||
|
||||
```bash
|
||||
cd ~/CascadeProjects/escrow/backend
|
||||
git check-ignore -v .env
|
||||
git status --short --ignored .env
|
||||
stat -f '%Sp %N' .env
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `.env` is ignored.
|
||||
- `.env` is not tracked.
|
||||
- file mode is restrictive, ideally `600`.
|
||||
|
||||
Current funded dev test wallet public address:
|
||||
|
||||
```text
|
||||
0x052D3D7F112A2CF1B0f65D9B8D6a91899d46e898
|
||||
```
|
||||
|
||||
Never document its seed phrase or private key.
|
||||
|
||||
## BSC Testnet Tokens
|
||||
|
||||
| Token | Chain id | Contract | Decimals | Notes |
|
||||
|---|---:|---|---:|---|
|
||||
| Test USDT | `97` | `0x109F54Dab34426D5477986b0460aE5dFBA65f022` | `18` | Canonical dev tUSDT for scanner tests. |
|
||||
| Test USDC | `97` | `0x64544969ed7EBf5f083679233325356EbE738930` | `18` | Secondary test token. |
|
||||
|
||||
The backend, scanner, and frontend must agree on the same chain/token registry.
|
||||
If the wallet has tUSDT but scanner reads zero, first check that chain 97 USDT
|
||||
points at `0x109F54...`, not an older faucet token.
|
||||
|
||||
## Test Users
|
||||
|
||||
Use generated users with a unique run id:
|
||||
|
||||
```text
|
||||
amn-e2e-<YYYYMMDDHHMMSS>-buyer@example.test
|
||||
amn-e2e-<YYYYMMDDHHMMSS>-seller1@example.test
|
||||
amn-e2e-<YYYYMMDDHHMMSS>-seller2@example.test
|
||||
amn-e2e-<YYYYMMDDHHMMSS>-seller3@example.test
|
||||
```
|
||||
|
||||
Roles:
|
||||
|
||||
| Actor | Suggested role | Why |
|
||||
|---|---|---|
|
||||
| Buyer | `tester` | Allows testnet payment rails when dev config gates them by tester role. |
|
||||
| Sellers | seller-capable test users | Submit bids and delivery evidence. |
|
||||
| Admin | existing admin account | Creates users and can inspect/repair state. |
|
||||
|
||||
Do not commit generated passwords. If an automation creates passwords, keep the
|
||||
pattern inside the local script or secret manager, not in docs.
|
||||
|
||||
## Run ID Convention
|
||||
|
||||
Every live E2E run should generate one run id and include it in:
|
||||
|
||||
- test user emails
|
||||
- purchase request titles
|
||||
- product links
|
||||
- delivery proof metadata
|
||||
- local log filename
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
runId = 20260606043238
|
||||
title = Scanner BSC Testnet E2E 20260606043238 R1
|
||||
```
|
||||
|
||||
## Evidence To Capture
|
||||
|
||||
For every live payment round, record:
|
||||
|
||||
| Field | Example |
|
||||
|---|---|
|
||||
| run id | `20260606043238` |
|
||||
| buyer email | generated `@example.test` email |
|
||||
| seller emails | generated `@example.test` emails |
|
||||
| purchase request id | UUID |
|
||||
| selected offer id | UUID |
|
||||
| payment id | UUID |
|
||||
| chain id | `97` |
|
||||
| token address | `0x109F54...` |
|
||||
| amount base units | `260000000000000000` |
|
||||
| tx hash | `0x...` |
|
||||
| scanner status | `paid` / `confirmed` |
|
||||
| delivery status | `delivery` / `delivered` |
|
||||
| CI builds | backend/frontend/scanner pipeline numbers |
|
||||
|
||||
112
11 - Testing/Test Scenario Catalog.md
Normal file
112
11 - Testing/Test Scenario Catalog.md
Normal file
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Test Scenario Catalog
|
||||
tags: [testing, scenarios, qa, e2e]
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Test Scenario Catalog
|
||||
|
||||
This catalog lists the designed test flows. Each scenario should eventually
|
||||
have an automation owner, a smoke command, and a UAT checklist.
|
||||
|
||||
## Priority Model
|
||||
|
||||
| Priority | Meaning |
|
||||
|---|---|
|
||||
| P0 | Blocks release or payment trust if failing. |
|
||||
| P1 | Core user experience or operational reliability. |
|
||||
| P2 | Important edge case with workaround. |
|
||||
| P3 | Nice-to-have or exploratory. |
|
||||
|
||||
## Scenario Matrix
|
||||
|
||||
| ID | Scenario | Priority | Current status | Procedure |
|
||||
|---|---|---:|---|---|
|
||||
| ESCROW-E2E-001 | Buyer creates request, multiple sellers bid, buyer accepts, pays, seller delivers, buyer confirms | P0 | Live tested on dev, two rounds | [[Escrow Marketplace E2E Procedure]] |
|
||||
| PAY-SCAN-001 | BSC Testnet tUSDT direct-balance scanner confirms funded wallet transfer | P0 | Live tested on dev | [[Scanner BSC Testnet Payment Procedure]] |
|
||||
| PAY-SCAN-002 | Scanner/backend/frontend token registry consistency for chain 97 | P0 | Smoke covered | [[Smoke and Regression Procedure]] |
|
||||
| PAY-SCAN-003 | Wrong token contract does not confirm payment | P0 | **Automated** (`payment-edge-cases.test.ts §4`) | [[Payment Safety Edge Cases#4 · Wrong ERC-20 Token Sent to Deposit Address]] |
|
||||
| PAY-SCAN-004 | Underpayment does not confirm payment | P0 | **Automated** (`payment-edge-cases.test.ts §2`) | [[Payment Safety Edge Cases#2 · Overpayment and Underpayment]] |
|
||||
| PAY-SCAN-005 | Wrong destination does not confirm payment | P0 | **Automated** (`payment-edge-cases.test.ts §4`) | [[Payment Safety Edge Cases#4 · Wrong ERC-20 Token Sent to Deposit Address]] |
|
||||
| PAY-SAFE-001 | OFAC-sanctioned wallet blocked when seller opts in | P0 | **Automated** (`payment-edge-cases.test.ts §1`) | [[Payment Safety Edge Cases#1 · Blacklisted / OFAC-Sanctioned Sender Wallet]] |
|
||||
| PAY-SAFE-002 | Native coin (ETH/BNB) returns `wrong_asset` instead of `transfer_not_found` | P1 | **Automated** (`payment-edge-cases.test.ts §3`) | [[Payment Safety Edge Cases#3 · Native Coin (ETH / BNB) Sent Instead of ERC-20]] |
|
||||
| PAY-SAFE-003 | Smart-contract sender blocked when `TRANSACTION_SAFETY_REQUIRE_EOA_SENDER=1` | P1 | **Automated** (`payment-edge-cases.test.ts §5`) | [[Payment Safety Edge Cases#5 · Smart-Contract Sender]] |
|
||||
| PAY-SAFE-004 | Underpaid direct-balance emits `payment-underpaid` event with shortfall | P1 | **Automated** (`payment-edge-cases.test.ts §2`) | [[Payment Safety Edge Cases#2 · Overpayment and Underpayment]] |
|
||||
| PAY-SAFE-005 | Wrong-token direct-balance emits `payment-wrong-token` event | P1 | **Automated** (`payment-edge-cases.test.ts §4`) | [[Payment Safety Edge Cases#4 · Wrong ERC-20 Token Sent to Deposit Address]] |
|
||||
| DELIVERY-001 | Seller delivery advances request to `delivery` | P0 | Live tested on dev | [[Escrow Marketplace E2E Procedure]] |
|
||||
| DELIVERY-002 | Buyer delivery confirmation advances request to `delivered` | P0 | Live tested after `2.8.117` id fix | [[Escrow Marketplace E2E Procedure]] |
|
||||
| DELIVERY-003 | Non-buyer cannot confirm delivery | P0 | Designed, should be regression test | [[Testing Expansion Backlog]] |
|
||||
| RELEASE-001 | Physical product enters grace period before release | P0 | Product policy not implemented | [[Testing Expansion Backlog]] |
|
||||
| RELEASE-002 | Gift card/digital product releases immediately after proof/confirmation | P0 | Product policy not implemented | [[Testing Expansion Backlog]] |
|
||||
| DISPUTE-001 | Buyer opens dispute before release | P0 | Backend incomplete / needs route alignment | [[Testing Expansion Backlog]] |
|
||||
| DISPUTE-002 | Admin resolves dispute for seller and release path is unblocked | P0 | Not complete | [[Testing Expansion Backlog]] |
|
||||
| DISPUTE-003 | Admin resolves dispute for buyer and refund path is unblocked | P0 | Not complete | [[Testing Expansion Backlog]] |
|
||||
| CI-001 | Code push builds and deploys backend/frontend/scanner via Woodpecker | P0 | Live used | [[Smoke and Regression Procedure]] |
|
||||
| UI-CHAIN-001 | Checkout shows BSC Testnet, tUSDT contract, and testnet explorer links | P1 | Implemented in frontend `2.8.118` | [[Scanner BSC Testnet Payment Procedure]] |
|
||||
| NOTIF-E2E-001 | Every E2E state-changing step issues expected notifications | P0 | Designed, needs automation | [[Notification Assertion Procedure]] |
|
||||
| PERF-CONC-001 | Ramp simultaneous full escrow E2E workers: 1, 2, 4, 8, 16, 32+ | P0 | Designed, needs automation and baseline report | [[Concurrency and Performance Profile]] |
|
||||
| AUTH-001 | Admin-created generated users can log in and execute role actions | P0 | Live used | [[Escrow Marketplace E2E Procedure]] |
|
||||
| CLEANUP-001 | Test users/requests can be identified and excluded from reports | P2 | Designed | [[Test Environment and Data]] |
|
||||
|
||||
## Core Escrow Scenario
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Create buyer and sellers"] --> B["Buyer creates purchase request"]
|
||||
B --> C["Sellers submit bids"]
|
||||
C --> D["Buyer accepts one bid"]
|
||||
D --> E["Buyer pays tUSDT on BSC Testnet"]
|
||||
E --> F["Scanner confirms payment"]
|
||||
F --> G["Seller delivers"]
|
||||
G --> H["Buyer confirms delivery"]
|
||||
H --> I["Assert notifications after every step"]
|
||||
I --> J["Policy boundary: grace, release, or dispute"]
|
||||
```
|
||||
|
||||
## Concurrency Scenario Family
|
||||
|
||||
The concurrency profile runs the same full escrow worker in parallel and doubles
|
||||
the worker count until a stop condition is reached:
|
||||
|
||||
```text
|
||||
1 -> 2 -> 4 -> 8 -> 16 -> 32 -> 64 ...
|
||||
```
|
||||
|
||||
Each worker executes one isolated buyer/seller/payment/delivery flow with unique
|
||||
test users, request ids, destination addresses, and payment ids. Notification
|
||||
assertions remain mandatory inside each worker. See
|
||||
[[Concurrency and Performance Profile]].
|
||||
|
||||
## Payment Negative Scenario Families
|
||||
|
||||
| Family | What to mutate | Expected result |
|
||||
|---|---|---|
|
||||
| Wrong token | Pay old BSC Testnet USDT or USDC instead of canonical tUSDT | Scanner does not mark intended payment paid. |
|
||||
| Wrong chain | Pay on chain `56` or another EVM chain | Scanner watch for chain `97` remains pending. |
|
||||
| Wrong amount | Send less than expected base-unit amount | Direct-balance delta below threshold, no paid transition. |
|
||||
| Wrong destination | Send correct token/amount to another address | Intended destination delta unchanged, no paid transition. |
|
||||
| Duplicate payment | Send twice to same destination after paid | First payment confirms; second must not double-credit escrow ledger. |
|
||||
| Late payment | Pay after expiration/cancellation | Payment should not auto-credit an inactive intent without explicit recovery. |
|
||||
|
||||
## Release Policy Scenario Families
|
||||
|
||||
These are not implemented yet, but they define the test shape we need before
|
||||
shipping fund release automation.
|
||||
|
||||
| Product type | Expected policy |
|
||||
|---|---|
|
||||
| Physical product | Delivery proof starts a grace period. Funds release only after buyer confirmation, no dispute, or grace expiry. |
|
||||
| Gift card | Delivery should require immediate proof and release funds immediately or near-immediately after buyer acceptance, depending on final policy. |
|
||||
| Digital file/license | Same as gift card unless manual review is required. |
|
||||
| Service | May need milestone acceptance and dispute window. |
|
||||
|
||||
## Scenario Completion Criteria
|
||||
|
||||
A scenario is complete when all are true:
|
||||
|
||||
1. It has a written procedure.
|
||||
2. It has deterministic test data setup.
|
||||
3. It captures expected API statuses and DB/business states.
|
||||
4. It has at least one automated test or smoke script where practical.
|
||||
5. It has a documented live-dev verification result.
|
||||
6. It names residual risk or product gaps.
|
||||
212
11 - Testing/Testing Expansion Backlog.md
Normal file
212
11 - Testing/Testing Expansion Backlog.md
Normal file
@@ -0,0 +1,212 @@
|
||||
---
|
||||
title: Testing Expansion Backlog
|
||||
tags: [testing, backlog, qa, release]
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Testing Expansion Backlog
|
||||
|
||||
This is the testing backlog to complete before expanding the escrow test suite
|
||||
or claiming release confidence.
|
||||
|
||||
## P0 - Product Policy Gaps
|
||||
|
||||
### Physical product grace period
|
||||
|
||||
Need implementation and tests for:
|
||||
|
||||
- seller marks delivery;
|
||||
- buyer receives delivery window;
|
||||
- grace period starts;
|
||||
- buyer can confirm delivery before expiry;
|
||||
- buyer can dispute before expiry;
|
||||
- funds release only after buyer confirmation, no dispute, or grace expiry.
|
||||
|
||||
Acceptance tests:
|
||||
|
||||
| ID | Expected |
|
||||
|---|---|
|
||||
| RELEASE-PHYS-001 | Physical delivery starts hold/grace timer. |
|
||||
| RELEASE-PHYS-002 | Buyer confirmation before expiry makes funds releasable. |
|
||||
| RELEASE-PHYS-003 | Dispute before expiry blocks release. |
|
||||
| RELEASE-PHYS-004 | Grace expiry without dispute makes funds releasable. |
|
||||
|
||||
### Immediate release for gift cards/digital goods
|
||||
|
||||
Need implementation and tests for:
|
||||
|
||||
- product type is gift card or digital;
|
||||
- seller proof is required immediately;
|
||||
- buyer acceptance or product policy triggers immediate/near-immediate release;
|
||||
- dispute path can still stop release if policy includes a short challenge window.
|
||||
|
||||
Acceptance tests:
|
||||
|
||||
| ID | Expected |
|
||||
|---|---|
|
||||
| RELEASE-DIGI-001 | Gift card delivery does not use physical-product grace period. |
|
||||
| RELEASE-DIGI-002 | Gift card proof advances to releasable immediately after buyer acceptance. |
|
||||
| RELEASE-DIGI-003 | Wrong/missing proof blocks release. |
|
||||
|
||||
## P0 - Dispute Coverage
|
||||
|
||||
The dispute mechanism is not complete enough for end-to-end fund movement.
|
||||
|
||||
Tests needed:
|
||||
|
||||
| ID | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| DISPUTE-001 | Buyer opens dispute after seller delivery, before release | payment/request enters dispute hold |
|
||||
| DISPUTE-002 | Seller cannot force release while dispute is open | release attempt rejected |
|
||||
| DISPUTE-003 | Admin resolves for seller | release becomes available |
|
||||
| DISPUTE-004 | Admin resolves for buyer | refund becomes available |
|
||||
| DISPUTE-005 | Duplicate dispute resolution | idempotent or rejected safely |
|
||||
| DISPUTE-006 | Dashboard dispute route vs release-hold route | no route shadowing ambiguity |
|
||||
|
||||
## P0 - Payment Negative Tests
|
||||
|
||||
**Automated as of 2026-06-08** (`backend/__tests__/payment-edge-cases.test.ts`, 38 tests):
|
||||
See [[Payment Safety Edge Cases]] for full detail.
|
||||
|
||||
- ✅ wrong token (on-chain `wrong_token` + direct-balance `address-token-mismatch` + `payment-wrong-token` event)
|
||||
- ✅ wrong chain (direct-balance `address-token-mismatch`)
|
||||
- ✅ wrong destination (direct-balance `address-token-mismatch`)
|
||||
- ✅ underpayment (`insufficient_amount` on-chain; `underpaid` direct-balance + `payment-underpaid` event)
|
||||
- ✅ native coin sent instead of ERC-20 (`wrong_asset` on-chain; stays pending in direct-balance webhook)
|
||||
- ✅ OFAC-sanctioned sender blocked (opt-in per seller; direct-balance `fundDirectBalancePayment` AML gate)
|
||||
- ✅ smart-contract sender blocked via `TRANSACTION_SAFETY_REQUIRE_EOA_SENDER=1`
|
||||
|
||||
Still needs automation:
|
||||
- duplicate payment (double-credit guard);
|
||||
- late payment after cancelled/expired intent;
|
||||
- payment with no gas;
|
||||
- scanner unavailable during payment;
|
||||
- scanner webhook signature invalid (partially covered by `amn-pay-adapter-webhook-signature.test.ts`);
|
||||
- balance check baseline missing or stale.
|
||||
|
||||
## P0 - Authorization and ID Boundaries
|
||||
|
||||
Add regression tests for cross-store id comparisons:
|
||||
|
||||
| Route/action | Required test |
|
||||
|---|---|
|
||||
| buyer delivery confirmation | buyer can confirm even when session id and request buyer id use different stores |
|
||||
| buyer delivery confirmation | non-buyer cannot confirm |
|
||||
| seller delivery update | only selected seller/admin can deliver |
|
||||
| offer acceptance | only buyer/admin can accept |
|
||||
| payment check | only buyer/admin/system can advance payment where applicable |
|
||||
|
||||
## P1 - UI/Browser E2E
|
||||
|
||||
Add Playwright tests for:
|
||||
|
||||
- checkout renders BSC Testnet chain label;
|
||||
- token contract row shows canonical tUSDT;
|
||||
- testnet explorer links are generated correctly;
|
||||
- wrong wallet chain shows switch button;
|
||||
- insufficient balance warning appears;
|
||||
- payment submitted screen waits for scanner confirmation;
|
||||
- paid socket/poll fallback transitions to success.
|
||||
|
||||
## P1 - Notification Assertions
|
||||
|
||||
Initial reusable E2E helpers are implemented in
|
||||
`backend/scripts/smoke/marketplace-e2e-notifications.mjs`. Remaining work:
|
||||
|
||||
- capture per-user notification baseline;
|
||||
- poll `GET /api/notifications` and `GET /api/notifications/unread-count`;
|
||||
- optionally attach Socket.IO clients for buyer/seller/admin actors;
|
||||
- fail on wrong-recipient notifications;
|
||||
- record known notification gaps with route/action and expected recipient;
|
||||
- include notification latency in every E2E report.
|
||||
|
||||
Current dev findings from the first two-round runner report:
|
||||
|
||||
- `NOTIF-E2E-001` passes for buyer new-offer notifications.
|
||||
- Seller new-request fanout passes for privately targeted sellers.
|
||||
- Rejected-seller notifications pass by rejected offer `relatedId`.
|
||||
- `NOTIF-E2E-002` fails by selected offer `relatedId`; accepted-offer notification is not discoverable with the selected offer id.
|
||||
|
||||
Acceptance tests:
|
||||
|
||||
| ID | Expected |
|
||||
|---|---|
|
||||
| NOTIF-E2E-001 | New seller offer creates a buyer notification and unread-count increment. |
|
||||
| NOTIF-E2E-002 | Buyer offer acceptance creates selected seller notification. |
|
||||
| NOTIF-E2E-003 | Seller delivery creates buyer notification. |
|
||||
| NOTIF-E2E-004 | Buyer delivery confirmation creates seller notification. |
|
||||
| NOTIF-E2E-005 | Missing `pending_payment` notification is reported as a known gap, not a silent pass. |
|
||||
|
||||
## P1 - Concurrency and Performance Profile
|
||||
|
||||
Initial executable runner and report generator are implemented in
|
||||
`backend/scripts/smoke/marketplace-e2e-notifications.mjs`. Remaining work:
|
||||
|
||||
- worker abstraction for one isolated buyer/sellers/payment/delivery flow;
|
||||
- barrier start for simultaneous workers;
|
||||
- ramp stages `1, 2, 4, 8, 16, 32`;
|
||||
- JSON result per worker;
|
||||
- markdown performance profile report per run;
|
||||
- infrastructure snapshots before/during/after each stage;
|
||||
- stop-condition enforcement.
|
||||
|
||||
Current dev blocker:
|
||||
|
||||
- Dev API rate limiting is `100` requests per `900s`, so C2+ ramp runs need a
|
||||
fresh window, a lower-polling profile, or a non-rate-limited test environment.
|
||||
- `PAYMENT_MODE=record` currently fails because `POST /api/marketplace/payments`
|
||||
returns HTTP 500 after offer selection with generated test actors.
|
||||
|
||||
Acceptance tests:
|
||||
|
||||
| ID | Expected |
|
||||
|---|---|
|
||||
| PERF-CONC-001 | C1 completes with all notification assertions. |
|
||||
| PERF-CONC-002 | C2/C4 detect no cross-worker state leakage. |
|
||||
| PERF-CONC-003 | Report includes p50/p95/p99 API, scanner, notification, and total timings. |
|
||||
| PERF-CONC-004 | Runner stops the ramp on P0 notification/payment/ledger correctness failure. |
|
||||
| PERF-CONC-005 | Runner separates live-chain mode from scanner-fixture/API-only modes. |
|
||||
|
||||
## P1 - CI and Deployment
|
||||
|
||||
Add a deployment smoke checklist artifact after every pipeline:
|
||||
|
||||
- backend version endpoint;
|
||||
- frontend root 200;
|
||||
- scanner `/health`;
|
||||
- chain 97 token balance check;
|
||||
- current Docker image digests;
|
||||
- last commit SHA per service.
|
||||
|
||||
## P1 - Test Data Lifecycle
|
||||
|
||||
Need cleanup strategy:
|
||||
|
||||
- flag generated users with run id;
|
||||
- archive or tag generated purchase requests;
|
||||
- prevent test data from polluting analytics;
|
||||
- define whether test tx/payment rows are permanent audit evidence or can be purged.
|
||||
|
||||
## P2 - Observability
|
||||
|
||||
Add dashboards/log queries for:
|
||||
|
||||
- scanner intent lifecycle;
|
||||
- balance-check deltas;
|
||||
- payment state transitions;
|
||||
- delivery status changes;
|
||||
- dispute holds;
|
||||
- release/refund attempts.
|
||||
|
||||
## Suggested Next Automation Order
|
||||
|
||||
1. Fix selected-seller accepted notification related id.
|
||||
2. Fix or retire the legacy `POST /api/marketplace/payments` record path.
|
||||
3. Run `PAYMENT_MODE=status` through delivery after the dev rate-limit window resets.
|
||||
4. Run `PAYMENT_MODE=live` once wallet gas/tUSDT and rate-limit headroom are available.
|
||||
5. Add non-buyer delivery confirmation regression test.
|
||||
6. Add scanner negative tests for wrong token and underpayment.
|
||||
7. Implement physical vs digital release policy.
|
||||
8. Add release-policy tests.
|
||||
9. Resolve dispute route/policy gaps.
|
||||
10. Add dispute-to-release/refund E2E tests.
|
||||
79
11 - Testing/Testing Overview.md
Normal file
79
11 - Testing/Testing Overview.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Testing Overview
|
||||
tags: [testing, qa, e2e, smoke]
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Testing Overview
|
||||
|
||||
This section is the home for Amanat test procedures and designed test scenarios.
|
||||
It complements [[07 - Development/Testing]], which documents the test runners and
|
||||
repository-level commands.
|
||||
|
||||
Use this section when planning or executing end-to-end validation across buyer,
|
||||
seller, payment scanner, delivery, payout, dispute, and deployment behavior.
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Purpose |
|
||||
|---|---|
|
||||
| [[Test Environment and Data]] | Environments, accounts, wallets, tokens, and secret-handling rules. |
|
||||
| [[Test Scenario Catalog]] | Canonical scenarios we have designed or need to extend. |
|
||||
| [[Payment Safety Edge Cases]] | Five payment edge-case families (OFAC, underpay, native coin, wrong token, contract sender) — design rationale, current behaviour, gaps, and 38-test suite reference. |
|
||||
| [[Escrow Marketplace E2E Procedure]] | Buyer/seller/request/bid/delivery procedure, including the current two-round flow. |
|
||||
| [[Scanner BSC Testnet Payment Procedure]] | BSC Testnet tUSDT scanner payment procedure and failure modes. |
|
||||
| [[Notification Assertion Procedure]] | Required notification checks after every E2E business step. |
|
||||
| [[Concurrency and Performance Profile]] | Ramp test design, profiling targets, metrics, and report template. |
|
||||
| [[Marketplace E2E Smoke Runner]] | Implemented backend smoke runner, modes, commands, reports, and current dev findings. |
|
||||
| [[Smoke and Regression Procedure]] | CLI, CI, and post-deploy smoke checks. |
|
||||
| [[Testing Expansion Backlog]] | Gaps to cover before broader release confidence. |
|
||||
|
||||
## Test Layers
|
||||
|
||||
| Layer | Goal | Primary owner |
|
||||
|---|---|---|
|
||||
| Unit tests | Validate isolated services, state machines, utilities, components. | Backend/frontend/scanner repos |
|
||||
| Integration tests | Validate repository adapters, payment orchestration, webhooks, auth, marketplace APIs. | Backend/frontend repos |
|
||||
| Smoke tests | Fast checks for one deployment target, usually via `BASE_URL`. | Backend scripts |
|
||||
| Browser E2E | Validate user-visible web flows. | Frontend Playwright |
|
||||
| Live dev E2E | Validate real dev deployment, CI image, scanner service, BSC Testnet, and test tokens. | QA/operator |
|
||||
| Concurrency profile | Ramp simultaneous full-flow workers and measure API, DB, scanner, notification, and chain behavior. | QA/operator + backend |
|
||||
| UAT | Validate product scenarios and acceptance criteria. | Product + QA |
|
||||
|
||||
## Golden Path Coverage
|
||||
|
||||
The minimum live-dev confidence path is:
|
||||
|
||||
1. Admin creates one buyer and at least two sellers.
|
||||
2. Buyer creates a request with a bounded USDT budget.
|
||||
3. Each seller submits a bid inside the budget with different amount and delivery timing.
|
||||
4. Buyer accepts one bid.
|
||||
5. Buyer funds escrow with BSC Testnet tUSDT.
|
||||
6. Scanner confirms the token transfer.
|
||||
7. Seller delivers.
|
||||
8. Buyer confirms delivery.
|
||||
9. After every state-changing step, assert notifications for every expected recipient.
|
||||
10. Flow pauses at the current product-policy boundary: release/grace/dispute automation is not complete.
|
||||
|
||||
The current live tested version of this path is documented in
|
||||
[[Escrow Marketplace E2E Procedure#Reference execution - 2026-06-06]].
|
||||
|
||||
## Definitions
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| Dev deployment | `https://dev.amn.gg`, backed by the dev Docker stack on the deployment host. |
|
||||
| Scanner | Separate in-house AMN scanner service that verifies token payments. |
|
||||
| Direct-balance rail | Scanner payment mode where balance deltas on a destination address confirm payment. |
|
||||
| BSC Testnet tUSDT | Test ERC-20 used for dev payments: `0x109F54Dab34426D5477986b0460aE5dFBA65f022`. |
|
||||
| Grace period | Product-policy delay after delivery before automatic fund release. Not implemented yet. |
|
||||
| Immediate release | Product-policy mode for digital goods such as gift cards. Not implemented yet. |
|
||||
|
||||
## Rules
|
||||
|
||||
- Never commit seed phrases, private keys, admin passwords, CI tokens, or `.env` files.
|
||||
- Use generated test users with unique run IDs. Do not reuse personal accounts for destructive scenarios.
|
||||
- Record enough evidence to reproduce a failure: request id, offer id, payment id, tx hash, chain id, token address, HTTP status, and CI build number.
|
||||
- Treat a passing local test as necessary but not sufficient for scanner/payment work. Payment changes must also be verified against dev after deploy.
|
||||
- Every live-dev test should state what remains untested or blocked.
|
||||
- Use `backend/scripts/smoke/marketplace-e2e-notifications.sh` for implemented buyer/seller smoke runs before extending scenarios manually.
|
||||
0
2026-06-07.md
Normal file
0
2026-06-07.md
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user