Compare commits
167 Commits
c461d9bd5f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a56571af | ||
|
|
55634b3b57 | ||
|
|
d44565fae9 | ||
|
|
846004e6d8 | ||
|
|
52fb52888f | ||
|
|
d4959bd4da | ||
|
|
dda0a3d2df | ||
|
|
b4772d10c7 | ||
|
|
1c87a53f02 | ||
|
|
771dce9e29 | ||
|
|
a35953d90f | ||
|
|
885f4d1299 | ||
|
|
a3808842c1 | ||
|
|
cf17b0b6c6 | ||
|
|
f9e09b1757 | ||
|
|
e8fc8480fe | ||
|
|
6e47d01c32 | ||
|
|
04272ae673 | ||
|
|
bc5f7c7a64 | ||
|
|
d27c10dd17 | ||
|
|
fd3ca85929 | ||
| 98c39f6844 | |||
| 14d46ceaa6 | |||
| 2e41e216c0 | |||
| 92f1c884c9 | |||
| bc74fa77d9 | |||
| 9cd56586fb | |||
| 3c53a6a33e | |||
| e5949c3967 | |||
| 8e58ee85f0 | |||
| 66a0ccfdb8 | |||
| 6d25172a13 | |||
| 20e03d4340 | |||
| 4288e5cd44 | |||
| 03c750cae7 | |||
| e8c48043ed | |||
| 358996cbf2 | |||
| 2602d0c838 | |||
| 640360d63c | |||
| 3f550de6b2 | |||
| f4ea4c7af8 | |||
|
|
217ab37496 | ||
|
|
bbf12cad33 | ||
|
|
c06ae56d88 | ||
|
|
2149e6435a | ||
|
|
8cdafbe62a | ||
|
|
de6bef8e9a | ||
|
|
9344f2702c | ||
|
|
e5ed1c96e5 | ||
|
|
4247a7f83f | ||
|
|
90bd05aa98 | ||
|
|
0f042fd384 | ||
|
|
f8063b320e | ||
|
|
c17d351cd1 | ||
|
|
ec949b5a19 | ||
|
|
ec4b01e1b4 | ||
|
|
9134a7ff63 | ||
|
|
c83d783ff7 | ||
|
|
a116c4f592 | ||
|
|
62d3045f0c | ||
|
|
7e7a015ff6 | ||
|
|
0df8d3d592 | ||
|
|
ffd57f2d18 | ||
|
|
7fe5ac7cd4 | ||
|
|
fc53a3b7f5 | ||
|
|
1bdca917b3 | ||
|
|
14467cc6fb | ||
|
|
8a68be797a | ||
|
|
21443d34a0 | ||
|
|
1a198252b3 | ||
|
|
c104f313ce | ||
|
|
cee5048d74 | ||
|
|
92e2ee5f49 | ||
|
|
00c7a62e51 | ||
|
|
a2204c641b | ||
|
|
c721120e85 | ||
|
|
3318b34f1e | ||
|
|
28861953c8 | ||
|
|
0089285373 | ||
|
|
71da5a8d80 | ||
|
|
7224bc56c2 | ||
|
|
551a22a245 | ||
|
|
f079ef6f52 | ||
|
|
f6045a07b2 | ||
|
|
a8f7ca31f9 | ||
|
|
2e4bb4ae00 | ||
|
|
14c72d1a6a | ||
|
|
23060261c7 | ||
|
|
be167d110e | ||
|
|
34f79b0303 | ||
|
|
5d585ff8ff | ||
|
|
0e16eecda6 | ||
|
|
a4f44dbb58 | ||
|
|
e3a70f5e65 | ||
|
|
475d75781f | ||
|
|
6ac52b1c50 | ||
|
|
5f23c6e5aa | ||
|
|
580d228484 | ||
|
|
b19fd77a60 | ||
|
|
2e09369345 | ||
|
|
ec6760ac65 | ||
|
|
707db6d43c | ||
|
|
c91b75c036 | ||
|
|
8d9eb97e9e | ||
|
|
821fecf5d3 | ||
|
|
54cd089e80 | ||
|
|
634e3faf3d | ||
|
|
6ca672987e | ||
|
|
bf367fc5fe | ||
|
|
d24ba38479 | ||
|
|
288c7ac33f | ||
|
|
ffaa6d2a1c | ||
|
|
3c72c37e31 | ||
|
|
1ebfd206ce | ||
|
|
687891cbaf | ||
|
|
65ce2ef23c | ||
|
|
e818dc6fc0 | ||
|
|
65663ad6ec | ||
|
|
aedc05110c | ||
|
|
3480efedd1 | ||
|
|
0d1d468307 | ||
|
|
3056be53db | ||
|
|
7f3a22abb8 | ||
|
|
fbc51c4866 | ||
|
|
10b2ad9023 | ||
|
|
7f9bb6aae6 | ||
|
|
1ab689e056 | ||
|
|
f1ee199d92 | ||
|
|
1032891d26 | ||
|
|
9ccd807a55 | ||
|
|
35b1c7ed27 | ||
|
|
28f39a31f6 | ||
|
|
5ed4936898 | ||
|
|
55e4938a57 | ||
|
|
cfee91355b | ||
|
|
0d0f4e5f9c | ||
|
|
0221c905f0 | ||
|
|
0cadc1a642 | ||
|
|
8b01685b48 | ||
|
|
4510eb769a | ||
|
|
414e86bfb5 | ||
|
|
d8c078ad5a | ||
|
|
07367d3183 | ||
|
|
357d346787 | ||
|
|
23d9f2f66f | ||
|
|
00e5ce6b20 | ||
|
|
a339a1c64e | ||
|
|
c9a80da7c3 | ||
|
|
bad3002006 | ||
|
|
178b5f0dc7 | ||
|
|
6cc5d43a10 | ||
|
|
b04e3a67f5 | ||
|
|
0b08802996 | ||
|
|
1af337f005 | ||
|
|
3feb806caa | ||
|
|
ec8ed8f6a8 | ||
|
|
8937aea57c | ||
|
|
c0bce7feac | ||
|
|
9f784406d7 | ||
|
|
461cd8421c | ||
|
|
6231128288 | ||
|
|
fd8e7e1b28 | ||
|
|
000bb78112 | ||
|
|
d3d6632375 | ||
|
|
1e84d67e24 | ||
|
|
570e3f3c36 | ||
|
|
5d47101714 |
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
* text=auto
|
||||
|
||||
# Anything executed by a Linux shell must keep LF endings. A CRLF checkout
|
||||
# makes bash fail with "\r: command not found" on the very first line.
|
||||
*.sh text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# systemd chokes on trailing CR in unit values.
|
||||
*.service text eol=lf
|
||||
*.timer text eol=lf
|
||||
31
.github/workflows/architecture-governance.yml
vendored
31
.github/workflows/architecture-governance.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install Dependencies
|
||||
@@ -26,5 +26,34 @@ jobs:
|
||||
- name: Enforce Boundaries
|
||||
run: npm run arch:check
|
||||
|
||||
# Was entirely missing before 2026-08-18: this workflow built and
|
||||
# checked boundaries but never ran a single test. karma.conf.js's
|
||||
# CHROME_BIN fallback is a Windows path, which the ubuntu-latest
|
||||
# runner doesn't have - browser-actions/setup-chrome supplies one
|
||||
# and CHROME_BIN below points at it explicitly.
|
||||
- name: Setup Chrome
|
||||
id: setup-chrome
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
|
||||
- name: Unit tests with coverage gate
|
||||
env:
|
||||
CHROME_BIN: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
run: npm run test:coverage
|
||||
|
||||
# The production build is what enforces the bundle budget. The initial
|
||||
# bundle sits at ~1.55 MB raw against a 700 kB target, so the error
|
||||
# threshold is a ratchet, not the goal: it is set just above today's
|
||||
# size so the bundle cannot grow while we work it back down. Lower the
|
||||
# ratchet in angular.json every time it comes down.
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# Stops payment credentials returning to the browser bundle. See
|
||||
# scripts/ci/scan-bundle.sh for what it looks for and why.
|
||||
- name: Scan bundle for credentials
|
||||
run: npm run scan:bundle
|
||||
|
||||
- name: E2E
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
npm run e2e
|
||||
|
||||
196
.github/workflows/deploy.yml
vendored
Normal file
196
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
name: Deploy Frontend
|
||||
|
||||
# Multi-tenant: one bundle serves every customer domain, so a single deploy
|
||||
# updates all of them at once. There is no per-tenant build or per-tenant deploy.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Branch or SHA to deploy
|
||||
required: false
|
||||
default: main
|
||||
reconcile_api_domains:
|
||||
description: >-
|
||||
Also provision api.<base-domain> nginx vhosts and TLS. Off by default:
|
||||
existing API domains are configured by hand, and re-running the helper
|
||||
writes a second server block for a server_name that already has one.
|
||||
Turn this on only when adding a NEW base domain.
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: deploy-frontend
|
||||
cancel-in-progress: false # never abandon a half-finished release swap
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Enforce boundaries
|
||||
run: npm run arch:check
|
||||
|
||||
- name: Build
|
||||
run: npm run build -- --configuration production
|
||||
|
||||
- name: Resolve build output
|
||||
id: dist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# @angular/build:application emits into dist/<name>/browser.
|
||||
# Fall back to the flat layout so this survives a builder change.
|
||||
if [ -d dist/dexarmarket/browser ]; then
|
||||
DIR=dist/dexarmarket/browser
|
||||
elif [ -f dist/dexarmarket/index.html ]; then
|
||||
DIR=dist/dexarmarket
|
||||
else
|
||||
echo "no build output found under dist/dexarmarket" >&2
|
||||
ls -R dist || true
|
||||
exit 1
|
||||
fi
|
||||
test -f "$DIR/index.html" || { echo "$DIR has no index.html" >&2; exit 1; }
|
||||
echo "dir=$DIR" >> "$GITHUB_OUTPUT"
|
||||
echo "Deploying from $DIR ($(find "$DIR" -type f | wc -l) files)"
|
||||
|
||||
- name: Configure SSH
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$DEPLOY_SSH_KEY" || { echo "secret DEPLOY_SSH_KEY is empty" >&2; exit 1; }
|
||||
test -n "$DEPLOY_KNOWN_HOSTS" || { echo "secret DEPLOY_KNOWN_HOSTS is empty" >&2; exit 1; }
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
# Pinned host key, so a MITM or a rebuilt server fails the deploy
|
||||
# instead of being trusted silently.
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
# Opt-in only. api.<base-domain> vhosts already exist and are hand-managed;
|
||||
# the helper writes its own file per domain, so running it unconditionally
|
||||
# would give nginx two server blocks for one server_name and re-run certbot
|
||||
# against a live API on every single deploy. Frontend releases do not need
|
||||
# this step - it is for standing up a NEW base domain.
|
||||
- name: Reconcile tenant API domains
|
||||
if: ${{ inputs.reconcile_api_domains }}
|
||||
env:
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
STOREFRONT_DOMAINS: ${{ secrets.STOREFRONT_DOMAINS }}
|
||||
CERTBOT_EMAIL: ${{ secrets.CERTBOT_EMAIL }}
|
||||
BACKEND_UPSTREAM: ${{ secrets.BACKEND_UPSTREAM }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$HOST" || { echo "secret DEPLOY_HOST is empty" >&2; exit 1; }
|
||||
test -n "$USER" || { echo "secret DEPLOY_USER is empty" >&2; exit 1; }
|
||||
test -n "$STOREFRONT_DOMAINS" || { echo "secret STOREFRONT_DOMAINS is empty" >&2; exit 1; }
|
||||
test -n "$CERTBOT_EMAIL" || { echo "secret CERTBOT_EMAIL is empty" >&2; exit 1; }
|
||||
BACKEND_UPSTREAM="${BACKEND_UPSTREAM:-https://127.0.0.1:445}"
|
||||
[[ "$CERTBOT_EMAIL" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || {
|
||||
echo "CERTBOT_EMAIL is invalid" >&2; exit 1;
|
||||
}
|
||||
[[ "$BACKEND_UPSTREAM" =~ ^https?://[A-Za-z0-9.:-]+$ ]] || {
|
||||
echo "BACKEND_UPSTREAM is invalid" >&2; exit 1;
|
||||
}
|
||||
|
||||
declare -A API_BASE_DOMAINS=()
|
||||
for storefront in $STOREFRONT_DOMAINS; do
|
||||
[[ "$storefront" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || {
|
||||
echo "invalid storefront domain: $storefront" >&2; exit 1;
|
||||
}
|
||||
IFS=. read -ra labels <<< "$storefront"
|
||||
label_count=${#labels[@]}
|
||||
take=2
|
||||
tld=${labels[label_count-1]}
|
||||
second_level=${labels[label_count-2]}
|
||||
if (( label_count >= 3 && ${#tld} == 2 && ${#second_level} <= 3 )); then
|
||||
take=3
|
||||
fi
|
||||
start=$((label_count - take))
|
||||
base_domain=$(IFS=.; echo "${labels[*]:start}")
|
||||
API_BASE_DOMAINS["$base_domain"]=1
|
||||
done
|
||||
|
||||
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
|
||||
for domain in "${!API_BASE_DOMAINS[@]}"; do
|
||||
$SSH "$USER@$HOST" sudo /usr/local/sbin/marketplaces-configure-api-domain \
|
||||
--domain "$domain" --email "$CERTBOT_EMAIL" --upstream "$BACKEND_UPSTREAM"
|
||||
done
|
||||
|
||||
- name: Upload release
|
||||
env:
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
SRC: ${{ steps.dist.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE="${GITHUB_SHA::12}"
|
||||
echo "RELEASE=$RELEASE" >> "$GITHUB_ENV"
|
||||
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
|
||||
$SSH "$USER@$HOST" "mkdir -p /srv/marketplaces/releases/$RELEASE/frontend"
|
||||
tar -C "$SRC" -czf - . | $SSH "$USER@$HOST" \
|
||||
"tar -xzf - -C /srv/marketplaces/releases/$RELEASE/frontend"
|
||||
|
||||
- name: Activate release
|
||||
env:
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -i ~/.ssh/deploy_key -o BatchMode=yes "$USER@$HOST" bash -euo pipefail <<EOSSH
|
||||
BASE=/srv/marketplaces
|
||||
REL="\$BASE/releases/$RELEASE"
|
||||
test -f "\$REL/frontend/index.html" || { echo "upload incomplete, refusing to swap" >&2; exit 1; }
|
||||
# ln -T onto a temp name then mv: the swap is atomic, so no request
|
||||
# is ever served from a half-updated root.
|
||||
ln -sfnT "\$REL" "\$BASE/current.new"
|
||||
mv -Tf "\$BASE/current.new" "\$BASE/current"
|
||||
sudo /bin/systemctl reload nginx
|
||||
# Keep the last 5 releases so a rollback is a symlink change.
|
||||
ls -1dt "\$BASE"/releases/*/ | tail -n +6 | xargs -r rm -rf
|
||||
echo "active: \$(readlink -f \$BASE/current)"
|
||||
EOSSH
|
||||
|
||||
- name: Verify
|
||||
env:
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -i ~/.ssh/deploy_key -o BatchMode=yes "$USER@$HOST" \
|
||||
'curl -fsS -o /dev/null -w "health=%{http_code}\n" http://127.0.0.1/health &&
|
||||
curl -fsS -o /dev/null -w "index=%{http_code}\n" http://127.0.0.1/'
|
||||
|
||||
- name: Report
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
echo "Deployed ${GITHUB_SHA::12} to ${{ secrets.DEPLOY_HOST }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Deploy of ${GITHUB_SHA::12} FAILED. The previous release is still active — the symlink only moves after a successful upload." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
packages/*/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
@@ -74,3 +75,7 @@ docs/context/schema/route.schema.json
|
||||
docs/context/schema/strategy.schema.json
|
||||
docs/context/schema/work-state.schema.json
|
||||
docs/context/schema/workspace.schema.json
|
||||
|
||||
# Playwright artifacts
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
One document, everyone reads it: product, backend, frontend, QA. It answers three questions for every domain — **what does the frontend already call**, **what shape does it send/expect**, and **is it real or mocked today**. Generated from the actual Angular frontend source (this repo has no backend code — it is a pure client consuming an external API), cross-checked against the frontend's own tolerant adapters, not aspirational.
|
||||
|
||||
**For what doesn't exist yet:** this doc describes the live surface only. The full set of forward-looking wire contracts for Product Plan v3.1 (money/FX, orders, catalog/offer split, connectors, seller portal, identity, tenant registry, RBAC, analytics — 10 phases + 2 tracks) lives in [docs/backend/](docs/backend/README.md).
|
||||
|
||||
Maturity tags used throughout:
|
||||
|
||||
| Tag | Meaning |
|
||||
@@ -115,6 +117,45 @@ Storage: `localStorage['ed25519AdminToken']` (access), `localStorage['ed25519Adm
|
||||
|
||||
**Open decision (business, not technical — ask a human):** whether Mechanism A is retired outright in favor of Mechanism B at cutover, or both run in parallel gated by role/tenant config.
|
||||
|
||||
### 2c. Email/phone OTP login — customer (NOT IMPLEMENTED, proposed)
|
||||
|
||||
**Gap:** customer storefront login/checkout requires Telegram (Mechanism A) — shoppers without Telegram have no way to identify themselves. Raised as a real usability problem, not a hypothetical.
|
||||
|
||||
**Ask:** a third, independent auth mechanism (coexists with 2a/2b, replaces neither):
|
||||
|
||||
```
|
||||
POST /auth/otp/request
|
||||
Body: { "identifier": "user@example.com" } // or E.164 phone, e.g. "+79991234567"
|
||||
Response: { "requestId": "...", "expiresAt": "2026-08-15T10:15:00Z" }
|
||||
```
|
||||
|
||||
```
|
||||
POST /auth/otp/verify
|
||||
Body: { "requestId": "...", "code": "482913" }
|
||||
Response (on success): {
|
||||
"sessionId": "...", "userId": 8823771, "username": null,
|
||||
"displayName": "user@example.com", "active": true, "expires": "2026-08-15T11:15:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
The success response must be shaped identically to the existing `AuthSession` (`sessionId, userId, username, displayName, active, expires`, §2a's client model) — this lets every existing downstream consumer (guards, session signals, cart/checkout) work unchanged regardless of which mechanism produced the session.
|
||||
|
||||
Rate limiting/expiry, explicit so nothing is left to guesswork: 60s resend cooldown per identifier between `/request` calls; code expires 10 minutes after issuance; `requestId` allows up to 5 verify attempts before it's invalidated (consumed on success, on the 5th wrong attempt, or on expiry) — not single-use-per-attempt, so one mistyped digit doesn't force a full 60s wait for a new code.
|
||||
|
||||
**Error responses must use the existing envelope** (§5), with these codes on `/verify` (the client maps each to distinct UX — see the design doc):
|
||||
|
||||
| `error.code` | HTTP status | Meaning |
|
||||
|---|---|---|
|
||||
| `VALIDATION_FAILED` | 422 | Malformed identifier (`error.details[0]` names the field). |
|
||||
| `RATE_LIMITED` | 429 | Resend cooldown not yet elapsed. |
|
||||
| `CODE_EXPIRED` | 410 | 10-minute window passed. |
|
||||
| `CODE_INVALID` | 401 | Wrong code, attempts remain on this `requestId`. |
|
||||
| `REQUEST_NOT_FOUND` | 404 | `requestId` unknown, exhausted (5 wrong attempts), or expired. |
|
||||
|
||||
Admin can toggle which login methods (Telegram/Email/Phone) are shown to shoppers — this is a client-only UI gate (Admin Settings, `LocalStorageService`-persisted), not a backend flag; all endpoints stay available regardless of the toggle state.
|
||||
|
||||
See `docs/superpowers/specs/2026-08-15-email-phone-login-design.md` for the full design. No client code exists yet — nothing to build against a 404.
|
||||
|
||||
---
|
||||
|
||||
## 3. Bootstrap — the runtime config document
|
||||
@@ -204,7 +245,7 @@ No cursor/keyset pagination exists anywhere. No server-side page-size cap is enf
|
||||
|
||||
## 5. Error model
|
||||
|
||||
**The frontend does not currently parse any backend error envelope for any real endpoint** — no interceptor inspects error responses; every caller reacts at the raw `HttpErrorResponse.status`/`.message` level. The one partial exception (Ed25519 admin auth) derives its error code from **HTTP status only**, ignoring any body field, which is itself a known bug (see below). Everything in this section is therefore a **recommended envelope to adopt going forward**, not something already wired end-to-end — apply it to new endpoints and treat the frontend gaps below as follow-up work, not something this doc can silently paper over.
|
||||
**The frontend does not currently parse any backend error envelope for any real endpoint** — no interceptor inspects error responses; every caller reacts at the raw `HttpErrorResponse.status`/`.message` level. The one partial exception (Ed25519 admin auth) now reads `error.error.code` from the body when present (`authErrorCodeFromBackendCode()`), falling back to HTTP status only when no body code is sent. Everything in this section is therefore a **recommended envelope to adopt going forward**, not something already wired end-to-end — apply it to new endpoints and treat the frontend gaps below as follow-up work, not something this doc can silently paper over.
|
||||
|
||||
### The envelope
|
||||
|
||||
@@ -242,8 +283,8 @@ No cursor/keyset pagination exists anywhere. No server-side page-size cap is enf
|
||||
| 503 (infra down) | `SERVICE_UNAVAILABLE` | Same "backend unavailable, retry" screen as 500, on the Ed25519 flow only. |
|
||||
| 503 (maintenance) | `MAINTENANCE_MODE` (+`maintenanceUntil`) | **No maintenance-mode concept exists in the frontend at all today.** Same HTTP status as infra-down 503 — `error.code` is the only way to distinguish them. |
|
||||
| 403 (tenant disabled) | `TENANT_DISABLED` | **No handling exists.** No code path today distinguishes "tenant exists but is disabled" from any other 403. |
|
||||
| 401 (token expired) | `TOKEN_EXPIRED` | **Known bug, not just a gap:** the client has a dedicated "Session expired" screen wired and ready, but `toAuthErrorShape()` only reaches it via a no-refresh-token-present client-side branch — a *real* backend 401 on `/refresh` always renders the generic "Unauthorized" screen instead, because the mapping function ignores any body code and derives purely from HTTP status. Fix requires the backend to send `error.code: "TOKEN_EXPIRED"` **and** a small frontend change to prefer it. |
|
||||
| 401 (bad signature) | `INVALID_SIGNATURE` | Same bug class as above — dedicated screen exists, unreachable from a real HTTP response for the identical reason. |
|
||||
| 401 (token expired) | `TOKEN_EXPIRED` | **Fixed** — `toAuthErrorShape()` (`core/auth/services/auth.service.ts`) now reads `error.error.code` via `authErrorCodeFromBackendCode()` before falling back to HTTP status. A backend 401 on `/refresh` sending `error.code: "TOKEN_EXPIRED"` reaches the dedicated "Session expired" screen. |
|
||||
| 401 (bad signature) | `INVALID_SIGNATURE` | **Fixed**, same mechanism — reaches the dedicated screen when the backend sends `error.code: "INVALID_SIGNATURE"`. |
|
||||
|
||||
**Every admin backoffice list page** (Users/Orders/Monitoring/Moderation/Transactions/Products/Categories/Analytics/Customers/Dashboard) shares one generic pattern: a boolean `error` signal → "Something went wrong" + retry button. None of them branch on status or `code` today — every status above collapses into the same generic UI until facades are individually updated.
|
||||
|
||||
@@ -266,6 +307,18 @@ Base: `ApiConfigService.getBaseUrl()`. Headers on every call (`apiHeadersInterce
|
||||
| `/items/{id}/questiion` | POST | `{ question, sessionID, timestamp }` | `{ message }` — **literal typo `questiion`, preserve it, matches the client** |
|
||||
| `/purchase-email` | POST | `{ email, phone?, telegramUserId, items[] }` | `{ message }` |
|
||||
| `/regions` | GET | — | `Region[]` — client falls back **silently** to 6 hardcoded regions on any error |
|
||||
| `/geo/resolve` | GET | — | `GeoIpResponse` — **not built yet**, see below |
|
||||
|
||||
**`/geo/resolve` — new, required.** Resolves the *caller's* IP to a coarse location so the storefront can pre-select a region. The server reads the client IP (behind the proxy, so honour `X-Forwarded-For` with `trustProxy`); the browser sends nothing and receives no third-party payload.
|
||||
|
||||
Response is the existing `GeoIpResponse` shape (`src/app/models/location.model.ts`): `{ city, country, countryCode, region?, timezone?, lat?, lon? }`.
|
||||
|
||||
Rules:
|
||||
- City-level precision only. Do not return coordinates finer than the city centroid, and do not persist the lookup against a customer record — this runs for anonymous visitors.
|
||||
- Any failure returns a non-2xx. The client already treats every error as "stay on the manual picker", so a degraded geo provider must never block the storefront.
|
||||
- Rate-limit per IP; it is an unauthenticated endpoint.
|
||||
|
||||
This replaces a direct browser call to `http://ip-api.com`, which leaked every visitor's IP to a third party and — being plaintext on an HTTPS origin — was blocked as mixed content, so region auto-detect never actually worked in production. Until this endpoint ships the client silently falls back to the manual region picker, which is the same behaviour production has had all along.
|
||||
|
||||
### 6.1 Products — the tolerance contract
|
||||
|
||||
@@ -332,7 +385,7 @@ WebSessionID: 3f1c2a0e-…
|
||||
{ "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" }
|
||||
```
|
||||
|
||||
**Payments are frozen** — this call chain is explicitly out of scope for changes; document only, don't modify.
|
||||
**Payments were frozen; unfrozen 2026-08-17** (Sprint 0.1 decision, see `docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md`). This call chain is now in scope for the Phase 1 rework specified in `docs/backend/BACKEND-INTEGRATION.md` — the server-authoritative-amount contract there replaces the client-trusted `amount`/`price` fields described below.
|
||||
|
||||
---
|
||||
|
||||
@@ -422,6 +475,7 @@ Worth knowing explicitly, so nobody assumes a gateway swap will "just work" for
|
||||
- **User experience (wishlist/compare/recently-viewed/saved-searches)** — fully denormalized objects in `localStorage`, guest-first. A DI token exists for a future authenticated repository, but nothing is bound to it — comment in code notes it "can be switched to authenticated repository later."
|
||||
- **Diagnostics** — inspects runtime/bootstrap/widget state locally; the one live-ish probe is a `/ping` health check.
|
||||
- **Cart contents** — see §7, real payment/order calls exist, cart *state* never round-trips to a backend.
|
||||
- **Currency conversion / display rates** — `CurrencyRatesService` holds RUB-based conversion rates in-memory, admin-editable via Admin Settings, persisted to `localStorage` only. `CurrencyConvertPipe` applies them client-side wherever a storefront price is rendered. The `Currency` request header (§6) is still sent on every call, but nothing round-trips a rate from the backend — see §12.7.
|
||||
|
||||
---
|
||||
|
||||
@@ -512,3 +566,46 @@ Separately, `createCartPayment()` (payment-gateway charge creation) still sends
|
||||
}
|
||||
```
|
||||
`actor` must be derived server-side from the authenticated caller, never trusted from the request body.
|
||||
|
||||
### 12.5 Back-in-stock ("Notify Me") subscription
|
||||
|
||||
**Gap:** the "Notify Me" button on out-of-stock products had no real subscription mechanism at all - it just toggled wishlist. Client fix already shipped: `notifyMe()` now calls `POST /items/{id}/notify-me` and, if that fails (today it always will - the endpoint doesn't exist), falls back to a local-only record in `localStorage['restockSubscriptions']` so the request isn't silently dropped while waiting on the backend. The shopper sees the same confirmation either way.
|
||||
|
||||
**Ask:** implement `POST /items/{id}/notify-me`, plus whatever mechanism actually sends the notification once the item restocks (Telegram message, most likely, given the rest of the auth stack). Request body sent today:
|
||||
```json
|
||||
{ "telegramUserId": "8823771" }
|
||||
```
|
||||
`telegramUserId` may be `null` for a non-Telegram web session - decide whether to also accept an email address as an alternative identifier (the frontend has no email capture on this flow today, so that would need a small frontend addition too). Once this ships, the frontend's localStorage fallback becomes purely a resilience path rather than the common case, and could optionally sync any locally-queued subscriptions on next successful call.
|
||||
|
||||
### 12.7 Currency conversion / FX rates
|
||||
|
||||
**Gap:** the backend has no per-currency pricing — it sends prices in one base currency (`RUB`) regardless of the `Currency` header (§6), and there's no exchange-rate endpoint. Client fix already shipped: admin manually enters a RUB-based rate per supported currency (Admin Settings → Currency rates), and every storefront price display converts client-side via that static, admin-typed number. Rates never update themselves and can drift from the real market rate.
|
||||
|
||||
**Ask:** this was raised as a real accounting concern (bank settlement totals not reconciling against order counts) — two options, not mutually exclusive:
|
||||
1. Backend returns prices already converted per the `Currency` header (removes client-side conversion entirely, most correct).
|
||||
2. Backend exposes a live/periodically-updated FX-rate endpoint (e.g. pegged to Rapira or another exchange) that the frontend polls instead of relying on an admin-typed static number — smaller change, keeps pricing display client-side but removes the manual-entry drift.
|
||||
Either way, the *authoritative* amount charged (`createCartPayment`'s `amount`, §12.3) must be computed/validated server-side against whichever rate source is authoritative — a client-side conversion (current or future) must never be trusted for the actual charge amount.
|
||||
|
||||
### 12.8 Admin purchase notifications depend on Orders CRUD being real
|
||||
|
||||
**Gap:** `AdminOrderWatcherService` (new — polls for new orders to toast/badge the admin) polls `AdminOrdersGateway.loadOrders()` (§8), which is bound to the mock `AdminOrdersLocalGateway` — a static, 24-row in-memory seed with no create path (see §8's gateway table, "Orders … MOCK-ONLY, no seam"). No genuinely new order can ever appear today, so the feature is functionally inert until Orders CRUD gets a real backend (§10 step 6).
|
||||
|
||||
**Ask:** nothing new beyond what §10/§11 already ask for — once a real `AdminOrdersApiGateway` is bound, this feature starts working with no additional frontend change. Flagging here only so nobody spends time debugging "why doesn't the notification ever fire" against the mock.
|
||||
|
||||
### 12.9 Admin product view counts
|
||||
|
||||
**Gap:** Admin Products (§8) runs on a fully separate mock domain from the storefront's live catalog — `AdminProduct.visits` is a new field added to support a "Views" column in Admin Products, but the mock gateway always defaults it to `0` because there is no real tracking source available to the admin domain today. This is unrelated to the storefront's `Item.visits` field (§6, `/items/{id}`), which is live-wired but never displayed anywhere in the UI.
|
||||
|
||||
**Ask:** two options, not mutually exclusive:
|
||||
1. Once admin Products gets a real backend (§10 step 4), include a per-product view/visit count in the response.
|
||||
2. Bridge `AdminProduct.visits` to the storefront's already-live `Item.visits` by product id, if a unified product identity exists between the storefront and admin domains — smaller change than building new tracking infrastructure.
|
||||
|
||||
### 12.10 Trending search terms
|
||||
|
||||
**Gap:** `SearchTrendingService.loadTrending()` is a stub returning `of(null)` - no trending-searches endpoint exists. It already degrades gracefully (UI hides the trending section rather than showing an error), so this is purely a missing-feature gap, not a bug.
|
||||
|
||||
**Ask:** an endpoint returning the top N search queries over some recent window, e.g.:
|
||||
```json
|
||||
{ "trending": [{ "query": "wireless earbuds", "count": 214 }, { "query": "winter jacket", "count": 187 }] }
|
||||
```
|
||||
Once it exists, wire `loadTrending()` to it and map `query` -> `SearchSuggestion.title/text`.
|
||||
|
||||
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
Recent work, newest first. Scoped to what changed and why — see `BACKEND-API-REFERENCE.md` for the backend-dependency detail on anything marked "depends on backend."
|
||||
|
||||
## 2026-08-15 — Admin purchase notifications
|
||||
|
||||
Admin gets notified when a new order lands: a toast (click-to-navigate) plus an unread badge + order list on the topbar bell icon (previously an unused "no notifications" placeholder). Poll-based — the backend has no WebSocket/SSE, so this follows the same polling pattern already used for payment status.
|
||||
|
||||
- `AdminOrderWatcherService` (new) polls `AdminOrdersGateway.loadOrders()` on an admin-editable interval (default 15s, editable in Admin Settings), diffs against persisted "last notified"/"last acknowledged" order pointers, and fires toasts only for genuinely new orders — never spams on first load.
|
||||
- `UserNotificationService` gained an optional click-to-navigate `route` so a toast can jump straight to the order detail page.
|
||||
- Reactively stops/starts polling off `AdminAuthService.isAuthenticated()` — a first attempt at this (tying it to component destruction) turned out not to work because logout never navigates or destroys the admin shell; caught and corrected before merge.
|
||||
- **Depends on backend:** the mock `AdminOrdersLocalGateway` has no create path, so this feature is functionally inert until Orders CRUD gets a real backend gateway. See `BACKEND-API-REFERENCE.md` §12.8.
|
||||
- Design/plan: `docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md`, `docs/superpowers/plans/2026-08-15-admin-purchase-notifications.md`.
|
||||
|
||||
## 2026-08-14 — Currency conversion (client-side)
|
||||
|
||||
Switching currency (RUB/USD/EUR/AMD) now actually converts displayed prices, instead of just swapping the currency label next to an unchanged number.
|
||||
|
||||
- `CurrencyRatesService` (new) holds RUB-based conversion rates, admin-editable in Admin Settings → Currency rates, persisted to `localStorage`.
|
||||
- `CurrencyConvertPipe` (new) applies the selected currency's rate wherever a storefront price renders: product cards, product detail page, quick-view dialog, delivery pricing, compare table, cart line items and totals, delivery selector.
|
||||
- Admin backoffice screens intentionally keep showing raw stored-currency values — that's the existing convention for every other admin price display.
|
||||
- Fixed a follow-on bug: the cart's delivery-selector component was using the item's *source* currency as both the display label and the conversion target, so the amount never actually converted (only the label matched) — same number shown in every currency.
|
||||
- **Depends on backend:** rates are a manually-typed admin number today, not a live exchange rate, because the backend doesn't return per-currency pricing. See `BACKEND-API-REFERENCE.md` §12.7 for the two proposed backend directions (server-side conversion, or a live FX-rate endpoint) — raised because bank settlement totals weren't reconciling against order counts, which points at a real pricing-accuracy gap, not just a display one.
|
||||
|
||||
---
|
||||
|
||||
Earlier history: `git log`.
|
||||
@@ -6,16 +6,16 @@ Findings only — nothing in this document has been fixed as part of writing it.
|
||||
|
||||
## As a Customer / End User
|
||||
|
||||
1. **Ed25519 admin-auth "session expired" and "invalid signature" recovery screens are dead UI.** Both are fully built and wired, but `toAuthErrorShape()` (`core/auth/services/auth.service.ts:110-118`) derives the error code from HTTP status only, never a body-level code — so a real backend 401 always shows the generic "Unauthorized" screen instead. Also an [Engineering](#as-backend--api-engineer) and [Backend](#as-backend--api-engineer) item.
|
||||
2. **Dark mode selector does nothing.** The light/dark/system dropdown saves correctly, but no CSS anywhere reads the `data-theme-mode` attribute it sets — picking anything but Light changes nothing visually.
|
||||
3. **"Site Layout" selector (Theme section) has no effect.** `layout.type` is edited but page rendering only ever reads each page's own `layout`, never the top-level selector.
|
||||
4. **Footer "Contacts" link has nothing behind it.** No static-page content exists for it at all in the bootstrap data (unlike other footer legal pages, which are populated).
|
||||
5. **Product pages get no per-product SEO.** `SeoService.setItemMeta(item)` — the method that would set per-product Open Graph/canonical tags — exists but is **never called anywhere in the codebase**. Every product page ships only the site-wide default meta tags.
|
||||
6. **`og:locale` is hardcoded to `'ru_RU'`** in both SEO meta-tag code paths, regardless of the active locale — a real gap for EN/HY visitors' social-share previews.
|
||||
7. **No structured data (JSON-LD) and no sitemap generation exist anywhere** — confirmed absent, not partially built. Sitemap is backend-only work; JSON-LD would need net-new frontend code.
|
||||
8. **Checkout's payment-description fallback is a hardcoded Russian string** (`'Покупка на Маркетплейсе'`) used as a last resort when no brand name or hostname is available — single-tenant-framed wording in a multi-tenant product.
|
||||
9. **Brand color contrast fails WCAG AA.** `--border-color` measures 1.24–1.42:1 against a 3:1 UI-component requirement in every theme; `--success`/`--warning`/`--error`/`--info-color` fail 4.5:1 when used as plain text. Real palette colors, not a token bug — see [Accessibility](#as-accessibility-reviewer).
|
||||
10. **`stars.component.scss:10` uses a literal hex color** (`#cdd6d5`) with no design token behind it — any future palette change will silently miss this one glyph.
|
||||
1. **FIXED (verified 2026-08-17).** ~~Ed25519 admin-auth "session expired"/"invalid signature" screens were dead UI~~ — `toAuthErrorShape()` now reads `error.error.code` via `authErrorCodeFromBackendCode()` before falling back to HTTP status. See `BACKEND-API-REFERENCE.md` §5.
|
||||
2. **FIXED (2026-08-17).** ~~Dark mode selector did nothing~~ — structural dark overrides (bg/text/border/shadow) now wired for all three tenant themes under `[data-theme-mode="dark"]`. Brand colors intentionally unchanged pending a theme-owner-approved dark palette.
|
||||
3. **FIXED (verified 2026-08-17).** ~~"Site Layout" selector had no effect~~ — `SectionEngineService.resolveLayoutType()` now falls back to `bootstrap.layout.type` when a page has no layout of its own.
|
||||
4. **Not a code gap (re-verified 2026-08-17), a content gap.** The mechanism is already fully generic: `features/project-editor/sections/footer-section.component.ts`'s Footer Builder lets an admin create any static page via the CMS and link it into a footer column by `pageKey`, resolved by `FooterResolverService`. "Contacts" just has no authored static page yet on whichever tenant's bootstrap this was checked against — that's a per-tenant content task, not a frontend fix.
|
||||
5. **FIXED (verified 2026-08-17).** ~~Product pages got no per-product SEO~~ — `SeoService.setItemMeta(item)` is called from `product-details-container.component.ts`.
|
||||
6. **FIXED (verified 2026-08-17).** ~~`og:locale` was hardcoded to `ru_RU`~~ — reads `languageService.currentLanguage()` via `OG_LOCALE_MAP` at both call sites.
|
||||
7. **FIXED (verified 2026-08-17) on JSON-LD; sitemap remains backend-only work.** ~~No structured data (JSON-LD) exists anywhere~~ — `SeoService.setJsonLd()` injects a real `<script type="application/ld+json">` for `Product` (per-item, via `setItemMeta()`) and `Organization` (site default, via `resetToDefaults()`). No JSON-LD exists yet for `BreadcrumbList` or `ItemList`/category pages — smaller net-new addition if wanted. Sitemap generation is still backend-only, unchanged.
|
||||
8. **FIXED (verified 2026-08-17).** ~~Checkout's payment-description fallback was a hardcoded Russian string~~ — `getPaymentDescription()` (`pages/cart/cart.component.ts:613`) already tries `branding.brandName`, then hostname, and only falls to `i18n.t('cart.paymentDescriptionFallback')` last, translated in all 3 languages (`i18n/{en,ru,hy}.ts`).
|
||||
9. **FIXED (2026-08-17), user-authorized.** ~~Brand color contrast failed WCAG AA~~ — `--border-color` and `--success`/`--warning`/`--error`/`--info-color` darkened, hue-preserving, in all three theme files to clear 3:1 (border, non-text) and 4.5:1 (status colors, plain text). See [Accessibility](#as-accessibility-reviewer).
|
||||
10. **FIXED (verified 2026-08-17).** ~~`stars.component.scss:10` used a literal hex color~~ — now uses `var(--border-color)`.
|
||||
11. **No multi-vendor cart handling exists.** Checkout is one inline flow producing exactly one order from one payment popup; a cart with items from multiple sellers has no defined behavior (relevant the moment Seller Management ships beyond its current disabled-by-default placeholder).
|
||||
|
||||
---
|
||||
@@ -50,21 +50,21 @@ Findings only — nothing in this document has been fixed as part of writing it.
|
||||
See [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md) for the full contract. Structural gaps worth flagging here specifically:
|
||||
|
||||
1. **Only 2 of 11 admin gateway domains (Categories, Dashboard-metrics) have a DI-token seam.** The other 9 — Orders, Products, Users, Transactions, Monitoring, Moderation, plus derived Customers/Analytics — inject their mock gateway class directly. A token has to be added to each before any real backend can be bound, independent of how easy that domain's actual endpoint is to build.
|
||||
2. **`AdminRole` is defined twice with unrelated shapes** (auth string-union vs. a Users-page display interface) — needs a naming reconciliation before the real role table is built.
|
||||
2. **FIXED (verified 2026-08-17).** ~~`AdminRole` was defined twice with unrelated shapes~~ — only one `AdminRole` export exists (`core/auth/models/permission.model.ts`); the Users-page shape is `AdminUserRoleRecord` with a disambiguating comment.
|
||||
3. **Two unrelated `Category` types exist**, both fed by the same `/category` response, both still in active use.
|
||||
4. **Duplicate search models** exist under two different module paths.
|
||||
4. **Worse than previously stated (re-verified 2026-08-17): three overlapping `SearchState`-shaped types, not two.** `core/search/models/search.model.ts` is already a clean re-export shim (fixed), but `core/search/models/search-state.model.ts` is a genuine second copy consumed by `catalog-container.component.ts`, and `features/search/facade/search.facade.ts` additionally defines its own private `LegacySearchState` interface with the same fields again. Reconciling all three touches the highest-traffic storefront surface (catalog rendering) — needs its own careful pass with full consumer tracing, not a quick rename.
|
||||
5. **The error envelope is entirely a proposal** — no interceptor in the app inspects error response bodies today; every error reaction happens at the raw HTTP-status level. Adopting an envelope is a net-new build for both sides, not a preservation of existing behavior.
|
||||
6. **429 (rate limiting) has zero client-side handling anywhere** — no interceptor, facade, or component references it. If the backend rate-limits, today's frontend has no graceful path for that response.
|
||||
7. **No API versioning scheme has been decided** — no version segment, no version header, anywhere in the client.
|
||||
8. **Centralized error-handling scaffolding exists but was never built.** `src/app/core/error-handling/`, `src/app/core/guards/`, and `src/app/core/interceptors/` each contain only a `.gitkeep` file — someone planned a shared error-handling layer, and every caller still handles failures ad hoc at the call site instead. Worth building once real backends start returning the error envelope in [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md), rather than adding another one-off handler per facade.
|
||||
9. **Admin Reports and Seller Management pages have zero data wiring of any kind** — not even a mock gateway call. Reports reuses `AdminAnalyticsFacade` (itself mock-derived) for its numbers; Seller Management is a static placeholder page with no `HttpClient` reference anywhere. Neither is currently a "swap the gateway" job — Reports inherits whatever Analytics becomes, Seller Management has no data layer to swap yet.
|
||||
10. **Two per-domain provider tokens have a dead mock branch, silently.** `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY` always resolve to the real API implementation regardless of `useMockData` — there is no mock class bound to either token. Anyone toggling mock mode expecting storefront products/categories to mock out will be surprised; only Bootstrap, Backoffice-widget-data, and Admin-Categories actually respect the mock/api switch.
|
||||
10. **FIXED (verified 2026-08-17).** ~~`PRODUCT_DATA_PROVIDER`/`CATEGORY_REPOSITORY` had a dead mock branch~~ — both tokens' factories now resolve directly to the real API implementation with the dead switch removed, documented inline as intentional.
|
||||
|
||||
---
|
||||
|
||||
## As Accessibility Reviewer
|
||||
|
||||
1. **Brand color contrast genuinely fails WCAG AA** — see [Product Owner item 9 above](#as-product-owner--business) for the numbers. This requires a theme-owner sign-off before any fix ships, since it changes brand appearance, not just token values.
|
||||
1. **FIXED (2026-08-17), user-authorized.** ~~Brand color contrast failed WCAG AA~~ — see [Product Owner item 9 above](#as-product-owner--business). Applied a hue-preserving darkening of the failing tokens rather than a redesign; a distinct dark-mode-specific status palette (introduced alongside dark mode this session) has not been separately contrast-checked and remains open.
|
||||
2. **No screen-reader software testing has ever been performed on this codebase** — every existing accessibility verification (including in this review) is automated accessibility-tree inspection, never a real NVDA/VoiceOver session. Recommend at least one manual pass on the highest-traffic flows (checkout, product page, admin login) before treating any part of the app as accessibility-verified end to end.
|
||||
3. **Known past pattern worth re-checking elsewhere:** a raw `<textarea>` (no dedicated shared textarea component exists in the codebase) previously shipped without its `aria-label`/label association wired correctly in one place (Seller Management's Message field, since fixed). Any other raw `<textarea>` usage in the app should be checked for the same gap, since the shared `app-input` component handles this automatically but plain textareas do not.
|
||||
|
||||
@@ -76,7 +76,7 @@ See [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md) for the full contract.
|
||||
2. **`catalog.navigationMode`** renders an intentional placeholder — confirmed not a bug, but the alternate nav UIs it implies (mega-menu, top-carousel, left-nav) don't exist yet if ever wanted.
|
||||
3. **Angular 22 upgrade is researched but not started** (~2–3.5 days estimated, needs a dependency fix and Node version bump first). Explicitly recommended as its own dedicated session, never bundled with feature work.
|
||||
4. **`MarketplaceRef` and `TenantConfig` both represent "a marketplace" from two different vantage points** — a deliberate, documented distinction today, but worth consolidating if a third marketplace-shaped type is ever proposed.
|
||||
5. **`sellerId` fields are typed as bare `string` instead of the `UUID` alias** used everywhere else in the newer sellers domain — zero functional impact, pure convention drift, cheap to fix opportunistically.
|
||||
5. **FIXED (verified 2026-08-17).** ~~`sellerId` fields were typed as bare `string`~~ — `core/sellers/models/seller-scope.model.ts` and all other sellers-domain usages type it `UUID`.
|
||||
6. **No shared breadcrumb component exists anywhere** — the only breadcrumb logic in the entire storefront is one local signal inside the catalog container, duplicated conceptually wherever a future breadcrumb might be needed.
|
||||
7. **Bootstrap `apiEndpoints.{website,builder,backoffice}` are empty objects in the mock today** — meaning no builder or backoffice CRUD path exists as a literal anywhere in the client. Any concrete path documented for those domains is a proposal until this is populated.
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
{
|
||||
"replace": "src/app/interceptors/mock-data.interceptor.ts",
|
||||
"with": "src/app/interceptors/mock-data.interceptor.production.ts"
|
||||
},
|
||||
{
|
||||
"replace": "src/app/mock-gateway.providers.ts",
|
||||
"with": "src/app/mock-gateway.providers.production.ts"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
@@ -59,7 +63,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "700kB",
|
||||
"maximumError": "1.5MB"
|
||||
"maximumError": "1.1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
373
docs/BRAND-BOOTSTRAP.md
Normal file
373
docs/BRAND-BOOTSTRAP.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# Brand bootstrap — full JSON reference
|
||||
|
||||
What one JSON document must contain to turn this codebase into a live, branded marketplace. Frontend is Angular 22, multi-tenant, one bundle for every domain — a brand is 100% config, zero code or rebuild. Source of truth for wire shape: [`bootstrap-config.model.ts`](../src/app/shared/models/config/bootstrap-config.model.ts) and its per-section models in the same folder. Backend contract: [`BACKEND-INTEGRATION.md`](backend/BACKEND-INTEGRATION.md). Deploy/domain/TLS mechanics: [`DEPLOYMENT.md`](DEPLOYMENT.md).
|
||||
|
||||
## How it works
|
||||
|
||||
1. Request arrives at `https://<any-domain>`.
|
||||
2. nginx forwards the verified `Host` to the API as `X-Storefront-Host`. **Tenant identity comes only from this header — never from a client-supplied field.**
|
||||
3. SPA calls `GET /bootstrap` (also proxied through `api.<base-domain>`).
|
||||
4. Backend resolves tenant from the host, returns this JSON. Frontend renders entirely from it — theme, nav, pages, feature flags, locales.
|
||||
5. One backend, many brands: each `Marketplace` row + its `MarketplaceDomain` rows is a brand. No per-brand deploy.
|
||||
|
||||
Acceptance check used in CI: `curl -fsS https://api.<domain>/bootstrap | jq -e 'type=="object"'`.
|
||||
|
||||
## Minimal path to a new brand
|
||||
|
||||
1. Backend: create a `Marketplace` row (`docs/backend/BACKEND-INTEGRATION.md` §11) and at least one `MarketplaceDomain` (`type: 'production'`).
|
||||
2. Point the domain's DNS A record at the server.
|
||||
3. TLS: either it's a `*.yourapex.com` subdomain (wildcard, zero extra work — [`DEPLOYMENT.md`](DEPLOYMENT.md) §4.1) or a customer's own domain (`add-domain.sh`, §4.4, or the `sync-domains.sh` reconciler, §4.2).
|
||||
4. `configure-api-domain.sh` for the base domain — creates `api.<domain>` (backend proxy, CORS, cert). One API hostname per base domain; subdomains reuse it.
|
||||
5. Backend returns a populated bootstrap JSON for that `Host`. Nothing to redeploy on the frontend side.
|
||||
6. Verify: `curl -I https://<domain>/health` (nginx, expect 200) and `curl -fsS https://api.<domain>/bootstrap | jq .` (backend, expect the object below).
|
||||
|
||||
---
|
||||
|
||||
## Full annotated example
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"generatedAt": "2026-08-22T00:00:00Z",
|
||||
|
||||
"tenant": {
|
||||
"id": "tenant-acme-001",
|
||||
"slug": "acme",
|
||||
"code": "ACME",
|
||||
"host": "shop.acme.com",
|
||||
"name": "Acme Marketplace",
|
||||
"websiteBaseUrl": "https://shop.acme.com",
|
||||
"builderBaseUrl": "https://builder.shop.acme.com",
|
||||
"backofficeBaseUrl": "https://backoffice.shop.acme.com",
|
||||
"defaultLocale": "en",
|
||||
"supportedLocales": ["en", "ru"],
|
||||
"defaultCurrency": "USD",
|
||||
"supportedCurrencies": ["USD", "EUR"],
|
||||
"timezone": "America/New_York",
|
||||
"documentationUrl": "https://docs.shop.acme.com"
|
||||
},
|
||||
|
||||
"branding": {
|
||||
"brandName": "Acme",
|
||||
"legalName": "Acme Commerce LLC",
|
||||
"slogan": "Everything, delivered",
|
||||
"logoUrl": "https://cdn.acme.com/logo.svg",
|
||||
"logoCompactUrl": "https://cdn.acme.com/logo-compact.svg",
|
||||
"faviconUrl": "https://cdn.acme.com/favicon.ico",
|
||||
"appIconUrl": "https://cdn.acme.com/icon-192.png",
|
||||
"supportEmail": "support@acme.com",
|
||||
"supportPhone": "+1-555-000-0000"
|
||||
},
|
||||
|
||||
"theme": {
|
||||
"themeId": "acme-light",
|
||||
"mode": "light",
|
||||
"palette": {
|
||||
"primary": "#1a56db",
|
||||
"secondary": "#7e8a97",
|
||||
"accent": "#60a5fa",
|
||||
"success": "#10b981",
|
||||
"warning": "#f59e0b",
|
||||
"danger": "#ef4444",
|
||||
"info": "#3b82f6",
|
||||
"textPrimary": "#111827",
|
||||
"textSecondary": "#6b7280",
|
||||
"backgroundPrimary": "#ffffff",
|
||||
"backgroundSecondary": "#f9fafb",
|
||||
"border": "#e5e7eb"
|
||||
},
|
||||
"typography": {
|
||||
"primaryFontFamily": "Inter, sans-serif",
|
||||
"headingFontFamily": "Inter, sans-serif",
|
||||
"baseFontSize": 16
|
||||
},
|
||||
"spacing": { "unit": 4, "scale": [0, 4, 8, 12, 16, 24, 32, 48] },
|
||||
"borderRadiusScale": { "sm": "6px", "md": "10px", "lg": "14px", "xl": "20px" },
|
||||
"shadows": {
|
||||
"sm": "0 2px 8px rgba(0,0,0,0.1)",
|
||||
"md": "0 4px 12px rgba(0,0,0,0.15)",
|
||||
"lg": "0 12px 32px rgba(26,86,219,0.2)"
|
||||
},
|
||||
"iconSet": "default"
|
||||
},
|
||||
|
||||
"company": {
|
||||
"companyName": "Acme Commerce LLC",
|
||||
"registrationNumber": "0000000000",
|
||||
"taxId": "00-0000000",
|
||||
"address": {
|
||||
"country": "USA",
|
||||
"region": "NY",
|
||||
"city": "New York",
|
||||
"street": "5th Ave 1",
|
||||
"postalCode": "10001"
|
||||
},
|
||||
"contacts": {
|
||||
"email": "support@acme.com",
|
||||
"phone": "+1-555-000-0000",
|
||||
"telegram": "@acme_support",
|
||||
"website": "https://acme.com"
|
||||
}
|
||||
},
|
||||
|
||||
"featureFlags": {
|
||||
"wishlist": true, "compare": true, "reviews": true, "blog": false,
|
||||
"chat": false, "analytics": true, "notifications": true,
|
||||
"coupons": true, "loyalty": false, "giftCards": false, "invoices": true
|
||||
},
|
||||
|
||||
"apiEndpoints": {
|
||||
"bootstrap": { "path": "/bootstrap", "method": "GET", "timeoutMs": 10000 },
|
||||
"website": {}, "builder": {}, "backoffice": {}
|
||||
},
|
||||
|
||||
"localization": {
|
||||
"defaultLocale": "en",
|
||||
"supportedLocales": ["en", "ru"],
|
||||
"currencyByLocale": { "en": "USD", "ru": "RUB" },
|
||||
"dictionaries": [
|
||||
{ "locale": "en", "dictionaryUrl": "/assets/i18n/en.json", "version": "1.0.0" },
|
||||
{ "locale": "ru", "dictionaryUrl": "/assets/i18n/ru.json", "version": "1.0.0" }
|
||||
]
|
||||
},
|
||||
|
||||
"seo": {
|
||||
"default": { "title": "Acme", "description": "Everything, delivered", "robots": "index,follow" },
|
||||
"byPageKey": {
|
||||
"home": { "title": "Acme - Home", "description": "Everything, delivered", "canonicalUrl": "https://shop.acme.com/", "robots": "index,follow" }
|
||||
}
|
||||
},
|
||||
|
||||
"permissions": {
|
||||
"definitions": [
|
||||
{ "key": "builder.pages.edit", "description": "Edit pages in builder" },
|
||||
{ "key": "backoffice.products.read", "description": "Read products in backoffice" }
|
||||
],
|
||||
"roles": [
|
||||
{ "role": "builder_admin", "permissions": ["builder.pages.edit"] },
|
||||
{ "role": "backoffice_manager", "permissions": ["backoffice.products.read"] }
|
||||
]
|
||||
},
|
||||
|
||||
"header": { "showLogo": true, "showSearch": true, "showCategories": true, "showCart": true, "sticky": true, "layout": "default" },
|
||||
"layout": { "type": "default" },
|
||||
|
||||
"navigation": {
|
||||
"header": [
|
||||
{ "id": "nav-home", "labelKey": "nav.home", "route": "/", "icon": "home", "order": 1 },
|
||||
{ "id": "nav-search", "labelKey": "nav.search", "route": "/search", "icon": "search", "order": 2 },
|
||||
{ "id": "nav-cart", "labelKey": "nav.cart", "route": "/cart", "icon": "cart", "order": 3 }
|
||||
],
|
||||
"footer": [
|
||||
{ "id": "footer-about", "labelKey": "nav.about", "route": "/about-us", "order": 1 },
|
||||
{ "id": "footer-privacy", "labelKey": "nav.privacy", "route": "/privacy-policy", "order": 2 }
|
||||
]
|
||||
},
|
||||
|
||||
"footer": {
|
||||
"paymentIcons": [{ "src": "/assets/images/visa-logo.svg", "alt": "Visa", "width": 40, "height": 28 }],
|
||||
"copyrightText": { "en": "© 2026 Acme. All rights reserved.", "ru": "© 2026 Acme. Все права защищены." },
|
||||
"legalPageKeys": ["about-us", "privacy-policy", "terms-of-service"]
|
||||
},
|
||||
|
||||
"catalog": {
|
||||
"layout": "grid",
|
||||
"navigationMode": "default",
|
||||
"defaultSort": "relevance",
|
||||
"availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"],
|
||||
"enabledFilters": ["price", "availability", "rating", "brand", "category"],
|
||||
"showBreadcrumbs": true, "showCategoryBanner": true, "showRatings": true,
|
||||
"showDiscounts": true, "showAvailability": true, "suggestionsEnabled": true, "searchHistoryEnabled": true
|
||||
},
|
||||
|
||||
"productPage": {
|
||||
"rating": { "enabled": true },
|
||||
"reviews": { "enabled": true, "pageSize": 5, "showSummary": true },
|
||||
"questions": { "enabled": true, "pageSize": 5 },
|
||||
"tabs": { "enabled": true, "items": ["description", "specifications", "reviews", "questions", "delivery", "warranty"] },
|
||||
"relatedProducts": { "enabled": true }
|
||||
},
|
||||
|
||||
"userExperience": {
|
||||
"wishlist": { "enabled": true, "headerBadgeEnabled": true },
|
||||
"compare": { "enabled": true, "maxItems": 4, "hideIdenticalDefault": false, "highlightDifferencesDefault": true },
|
||||
"recentlyViewed": { "enabled": true, "maxItems": 12, "widgetEnabled": true },
|
||||
"share": { "enabled": true },
|
||||
"continueBrowsing": { "enabled": true },
|
||||
"savedSearches": { "enabled": true, "maxItems": 10 }
|
||||
},
|
||||
|
||||
"features": {
|
||||
"wishlist": true, "compare": true, "reviews": true, "comments": true,
|
||||
"questions": true, "recommendations": true, "recentlyViewed": true,
|
||||
"searchHistory": true, "recentlySearched": true, "ratings": true,
|
||||
"share": true, "brands": true, "manufacturers": true,
|
||||
"availability": true, "discounts": true, "badges": true
|
||||
},
|
||||
|
||||
"widgetRegistry": { "manifestUrl": "https://api.acme.com/widget-manifest.json" },
|
||||
|
||||
"staticPages": {
|
||||
"about-us": {
|
||||
"route": "/about-us",
|
||||
"title": { "en": "About Us", "ru": "О компании" },
|
||||
"html": { "en": "<h2>About Us</h2><p>...</p>", "ru": "<h2>О компании</h2><p>...</p>" }
|
||||
}
|
||||
},
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page-home", "key": "home", "title": "Home",
|
||||
"route": { "path": "/", "exact": true },
|
||||
"layout": { "type": "default" },
|
||||
"seoKey": "home", "visible": true,
|
||||
"sections": [
|
||||
{
|
||||
"id": "section-hero", "type": "hero", "order": 1,
|
||||
"layout": { "strategy": "hero", "columns": 1, "gap": "1.5rem", "align": "stretch" },
|
||||
"visibility": { "desktop": true, "tablet": true, "mobile": true },
|
||||
"visible": true,
|
||||
"widgets": [
|
||||
{
|
||||
"id": "widget-hero-main", "type": "hero", "version": "1.0.0", "order": 1,
|
||||
"padding": "0.5rem 0",
|
||||
"visibility": { "desktop": true, "tablet": true, "mobile": true },
|
||||
"visible": true,
|
||||
"props": {
|
||||
"title": { "en": "Welcome to Acme", "ru": "Добро пожаловать в Acme" },
|
||||
"subtitle": { "en": "Everything, delivered", "ru": "Всё, с доставкой" },
|
||||
"ctaLabel": { "en": "Start Shopping", "ru": "Начать покупки" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"modules": { "sellerManagement": { "enabled": false } }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field reference
|
||||
|
||||
### `tenant` (required) — [`tenant.model.ts`](../src/app/shared/models/config/tenant.model.ts)
|
||||
|
||||
Identity and locale/currency defaults. `host` must exactly match the domain nginx forwards — mismatches are how tenant leakage bugs happen. `websiteBaseUrl` / `builderBaseUrl` / `backofficeBaseUrl` are the three surfaces this same brand can present (storefront, page builder, admin backoffice) — each gets its own subdomain or host.
|
||||
|
||||
### `branding` (required) — [`branding.model.ts`](../src/app/shared/models/config/branding.model.ts)
|
||||
|
||||
Everything a human sees as "this is the brand": name, logo variants, favicon, support contact. `logoCompactUrl` is used where header space is tight (mobile, collapsed nav).
|
||||
|
||||
### `theme` (required) — [`theme.model.ts`](../src/app/shared/models/config/theme.model.ts)
|
||||
|
||||
Full design-token set: color palette, typography, spacing scale, border radii, shadows. Consumed by [`theme-css-vars.mapper.ts`](../src/app/theme/mappers/theme-css-vars.mapper.ts) → CSS custom properties at runtime. `mode` is `light` or `dark`; ship a matching palette for whichever `themeId` you pick.
|
||||
|
||||
### `company` (required) — [`company.model.ts`](../src/app/shared/models/config/company.model.ts)
|
||||
|
||||
Legal/registration data for invoices, footer legal text, compliance pages. Not user-facing branding — this is the registered entity behind the brand.
|
||||
|
||||
### `featureFlags` (required) — [`feature-flags.model.ts`](../src/app/shared/models/config/feature-flags.model.ts)
|
||||
|
||||
Coarse on/off switches for major product areas (wishlist, blog, chat, loyalty, gift cards, invoices...). Distinct from `features` below — this set gates bigger surfaces.
|
||||
|
||||
### `features` (optional) — [`features-config.model.ts`](../src/app/shared/models/config/features-config.model.ts)
|
||||
|
||||
Finer-grained per-marketplace toggles (comments, recommendations, badges, etc). Omit any key to fall back to `DEFAULT_MARKETPLACE_FEATURES_CONFIG` (all `true`).
|
||||
|
||||
### `apiEndpoints` (required) — [`api-endpoints.model.ts`](../src/app/shared/models/config/api-endpoints.model.ts)
|
||||
|
||||
Per-surface endpoint overrides. `bootstrap` itself is always required; `website`/`builder`/`backoffice` may stay empty objects to use defaults.
|
||||
|
||||
### `localization` (required) — [`localization.model.ts`](../src/app/shared/models/config/localization.model.ts)
|
||||
|
||||
Locale list, default, per-locale currency, and dictionary URLs (`/assets/i18n/<locale>.json` or a CDN URL). Every locale in `tenant.supportedLocales` needs an entry here.
|
||||
|
||||
### `seo` (required) — [`seo.model.ts`](../src/app/shared/models/config/seo.model.ts)
|
||||
|
||||
Default meta tags plus per-`pageKey` overrides, consumed by [`seo.service.ts`](../src/app/services/seo.service.ts).
|
||||
|
||||
### `permissions` (required) — [`permissions.model.ts`](../src/app/shared/models/config/permissions.model.ts)
|
||||
|
||||
Role → permission-key map used by frontend guards. The frontend never hardcodes role logic beyond hiding affordances — see `BACKEND-INTEGRATION.md` §4.6; the authoritative check still happens server-side per request.
|
||||
|
||||
### `header` (optional) — [`header-config.model.ts`](../src/app/shared/models/config/header-config.model.ts)
|
||||
|
||||
Which header elements show (`showSearch`, `showCart`, `showRegion`, ...) and `layout` (`default` | `centered`). Omit to use `DEFAULT_HEADER_CONFIG`.
|
||||
|
||||
### `catalog` (optional) — [`catalog-config.model.ts`](../src/app/shared/models/config/catalog-config.model.ts)
|
||||
|
||||
Product-listing behavior: layout, sort options, enabled filters, which badges/breadcrumbs show.
|
||||
|
||||
### `layout` (optional) — [`layout.model.ts`](../src/app/shared/models/config/layout.model.ts)
|
||||
|
||||
Top-level page shell type.
|
||||
|
||||
### `navigation` (required) — [`navigation.model.ts`](../src/app/shared/models/config/navigation.model.ts)
|
||||
|
||||
Header and footer link lists, each entry `{ id, labelKey, route, icon?, order }`. `labelKey` resolves against the locale dictionaries in `localization`.
|
||||
|
||||
### `footer` (optional) — [`footer-config.model.ts`](../src/app/shared/models/config/footer-config.model.ts)
|
||||
|
||||
Payment-method icons, per-locale copyright text, legal page keys to link.
|
||||
|
||||
### `productPage` (optional) — [`product-page-config.model.ts`](../src/app/shared/models/config/product-page-config.model.ts)
|
||||
|
||||
Reviews, questions, tabs, related-products behavior on the PDP.
|
||||
|
||||
### `userExperience` (optional) — [`user-experience-config.model.ts`](../src/app/shared/models/config/user-experience-config.model.ts)
|
||||
|
||||
Wishlist, compare, recently-viewed, share, saved-searches — limits and toggles.
|
||||
|
||||
### `pages` (required) — [`page.model.ts`](../src/app/shared/models/config/page.model.ts)
|
||||
|
||||
The actual page tree. Each page has a route, layout, and a `sections[]` list; each section has `layout` (`hero` | `grid` | `carousel` | ...), responsive `visibility`, and `widgets[]`. Each widget references a `type` + `version` resolved against the widget manifest (see `widgetRegistry`) and carries its own `props` (usually per-locale strings). This is what the page builder edits and what [`section-engine.service.ts`](../src/app/dynamic-renderer/section-engine/section-engine.service.ts) renders.
|
||||
|
||||
### `staticPages` (optional) — [`static-page.model.ts`](../src/app/shared/models/config/static-page.model.ts)
|
||||
|
||||
Simple route → per-locale `{ title, html }` pages (about, privacy, terms, contacts) that don't need the full section/widget builder.
|
||||
|
||||
### `widgetRegistry` (optional) — [`widget-registry.model.ts`](../src/app/shared/models/config/widget-registry.model.ts)
|
||||
|
||||
URL to the widget manifest — the catalog of widget types/versions this brand's `pages[].sections[].widgets[]` are allowed to reference. See [`widget-manifest.service.ts`](../src/app/widgets/registry/widget-manifest.service.ts).
|
||||
|
||||
### `modules` (optional) — [`platform-modules.model.ts`](../src/app/shared/models/config/platform-modules.model.ts)
|
||||
|
||||
Platform-level capability gates that introduce a whole new scope (currently just `sellerManagement`), not a simple toggle. Absent or `undefined` = every module disabled, and existing marketplaces that never send this field behave exactly as before (ADR-011). A disabled module must add zero new routes/menus/API calls.
|
||||
|
||||
### `seller` (optional, backend-resolved only) — [`seller.model.ts`](../src/app/shared/models/config/seller.model.ts)
|
||||
|
||||
Present only when `modules.sellerManagement.enabled` is `true` **and** the request resolves beneath a specific seller. The frontend never decides this itself — same rule as tenant resolution (ADR-001): the backend resolves scope from the verified host/session, never from a client-supplied field.
|
||||
|
||||
---
|
||||
|
||||
## Required vs optional at a glance
|
||||
|
||||
| Required | Optional (sensible defaults exist) |
|
||||
|---|---|
|
||||
| `schemaVersion`, `generatedAt` | `features` |
|
||||
| `tenant` | `header` |
|
||||
| `branding` | `catalog` |
|
||||
| `theme` | `layout` |
|
||||
| `company` | `footer` |
|
||||
| `featureFlags` | `productPage` |
|
||||
| `apiEndpoints` | `userExperience` |
|
||||
| `localization` | `staticPages` |
|
||||
| `seo` | `widgetRegistry` |
|
||||
| `permissions` | `modules` |
|
||||
| `navigation` | `seller` (backend-resolved, never client-set) |
|
||||
| `pages` | |
|
||||
|
||||
## Going live — checklist
|
||||
|
||||
- [ ] `Marketplace` row created (§11 of `BACKEND-INTEGRATION.md`), `lifecycleState` progressed to `production_ready`
|
||||
- [ ] `MarketplaceDomain` row(s) added, `type: 'production'`
|
||||
- [ ] DNS A record → server IP
|
||||
- [ ] TLS: wildcard subdomain (no action) or `add-domain.sh` / reconciler for a custom domain
|
||||
- [ ] `api.<base-domain>` configured (`configure-api-domain.sh`) — CORS echoes the exact storefront origin, never `*` with credentials
|
||||
- [ ] Backend returns full bootstrap JSON for that `Host` — validate with `curl -fsS https://api.<domain>/bootstrap | jq .`
|
||||
- [ ] `curl -I https://<domain>/health` → `200`
|
||||
- [ ] Every locale in `tenant.supportedLocales` has a `localization.dictionaries[]` entry and a `localization.currencyByLocale` entry
|
||||
- [ ] `navigation.header`/`footer` routes match real routes; `staticPages`/`pages[].route` keys line up with `legalPageKeys`
|
||||
286
docs/DEPLOYMENT.md
Normal file
286
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# Deployment — server provisioning, CD, TLS
|
||||
|
||||
Frontend deployment plus API-domain edge configuration. The backend service is a
|
||||
separate developer's responsibility. API hostnames are separate reverse proxies
|
||||
and return `502` until their configured upstream exists (production currently
|
||||
defaults to `https://127.0.0.1:445`).
|
||||
|
||||
**Multi-tenant, one bundle.** Every customer domain is served by the same build. The SPA resolves its tenant from the `Host` header ([BACKEND-HANDOFF §1a](backend/BACKEND-INTEGRATION.md)). One deploy updates every domain simultaneously — there is no per-tenant build and no per-tenant deploy.
|
||||
|
||||
The SPA derives one API origin from the storefront's base domain:
|
||||
`example.com`, `store1.example.com`, and `www.example.com` all use
|
||||
`api.example.com`. Tenant identity still comes from the complete storefront
|
||||
host; tenant subdomains do not create additional API DNS names.
|
||||
|
||||
---
|
||||
|
||||
## 1. Files
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `scripts/deploy/server-setup.sh` | One-time server provisioning. Idempotent. Run as root. |
|
||||
| `scripts/deploy/add-domain.sh` | Attach one domain + issue TLS. Run per domain, as root, after DNS resolves. |
|
||||
| `scripts/deploy/configure-api-domain.sh` | Configure shared `api.<base domain>` TLS, storefront-origin CORS, backend proxy, and JSON bootstrap verification. |
|
||||
| `.github/workflows/deploy.yml` | CD: build → upload → atomic swap → verify. Triggers on push to `main`. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Layout on the server
|
||||
|
||||
```
|
||||
/srv/marketplaces/
|
||||
├── releases/
|
||||
│ ├── a1b2c3d4e5f6/frontend/ <- one directory per deployed commit
|
||||
│ └── ... (last 5 kept)
|
||||
└── current -> releases/a1b2c3d4e5f6
|
||||
```
|
||||
|
||||
nginx root is `/srv/marketplaces/current/frontend`. Activation is a symlink swap, so no request is ever served from a half-written directory, and a rollback is a symlink change rather than a rebuild.
|
||||
|
||||
**On the current production host there is one extra hop.** That server predates
|
||||
`server-setup.sh` and was provisioned by hand, so instead of the catch-all vhost
|
||||
it has per-domain configs (`gorbushka.conf`, `dexarmarket.conf`,
|
||||
`gorbushka-admin.conf`, `gorbushka-landing.conf`) whose `root` is
|
||||
`/var/www/dexarmarket/browser`. That path is itself a symlink:
|
||||
|
||||
```
|
||||
/var/www/dexarmarket/browser -> /srv/marketplaces/current/frontend
|
||||
```
|
||||
|
||||
so the release/`current` model above still holds and the workflow needs no
|
||||
per-host special-casing. Until 2026-08-22 `browser` pointed straight at one
|
||||
pinned release directory with no `current` in between, which is why two
|
||||
successfully-uploaded releases sat unserved.
|
||||
|
||||
---
|
||||
|
||||
## 3. First-time setup
|
||||
|
||||
### 3.1 Generate a CI deploy key
|
||||
|
||||
On your machine, **not** on the server:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "ci@marketplaces" -f ./marketplaces_deploy -N ""
|
||||
```
|
||||
|
||||
Two files result. `marketplaces_deploy.pub` goes to the server; `marketplaces_deploy` (private) goes into CI secrets and nowhere else.
|
||||
|
||||
### 3.2 Provision the server
|
||||
|
||||
Copy `scripts/deploy/` to the server and run:
|
||||
|
||||
```bash
|
||||
sudo bash server-setup.sh --pubkey "$(cat marketplaces_deploy.pub)"
|
||||
```
|
||||
|
||||
This installs nginx + certbot, creates a **key-only** `deploy` user with no
|
||||
password, writes the catch-all nginx config, opens 80/443/OpenSSH in ufw, and
|
||||
installs a root-owned, argument-validating API-domain helper. The deploy user may
|
||||
run that helper and reload nginx, but cannot replace the helper.
|
||||
|
||||
It also applies host hardening (added 2026-08-21, FH-D.3) — three drop-in files,
|
||||
so a re-run replaces its own config and never edits a distro file in place:
|
||||
|
||||
| File | Effect |
|
||||
|---|---|
|
||||
| `/etc/ssh/sshd_config.d/10-marketplaces-hardening.conf` | Password and keyboard-interactive auth off, root key-only, no agent/X11 forwarding, `MaxAuthTries 3`, 30 s login grace |
|
||||
| `/etc/fail2ban/jail.d/marketplaces.local` | `sshd`, `nginx-http-auth`, `nginx-bad-request` jails — 5 failures in 10 min, 1 h ban |
|
||||
| `/etc/sysctl.d/99-marketplaces-hardening.conf` | No redirects or source routing, reverse-path filtering, SYN cookies, forwarding off, restricted kernel pointers and dmesg |
|
||||
|
||||
Both accounts on this host are key-only by construction, so disabling password
|
||||
auth cannot lock anyone out — it only closes unlimited guessing against a
|
||||
credential nobody intended to exist. The script runs `sshd -t` before reloading
|
||||
and removes its own drop-in if the test fails, because a bad sshd config taking
|
||||
effect on a remote box is how people lock themselves out permanently.
|
||||
|
||||
Confirm after provisioning:
|
||||
|
||||
```bash
|
||||
sudo fail2ban-client status sshd
|
||||
sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin|maxauthtries'
|
||||
```
|
||||
|
||||
Verify before continuing:
|
||||
|
||||
```bash
|
||||
curl -I http://<server-ip>/health
|
||||
```
|
||||
|
||||
Expect `200`. A placeholder page is served until the first real deploy.
|
||||
|
||||
### 3.3 Capture the host key
|
||||
|
||||
```bash
|
||||
ssh-keyscan -H <server-ip>
|
||||
```
|
||||
|
||||
The output is the `DEPLOY_KNOWN_HOSTS` secret. Pinning it means a rebuilt or impersonated server fails the deploy instead of being trusted silently.
|
||||
|
||||
### 3.4 Add CI secrets
|
||||
|
||||
Required for every deploy:
|
||||
|
||||
| Secret | Value |
|
||||
|---|---|
|
||||
| `DEPLOY_HOST` | server IP or hostname |
|
||||
| `DEPLOY_USER` | `deploy` |
|
||||
| `DEPLOY_SSH_KEY` | contents of the **private** key file |
|
||||
| `DEPLOY_KNOWN_HOSTS` | output of `ssh-keyscan -H <server-ip>` |
|
||||
|
||||
Required **only** when running the workflow with `reconcile_api_domains` on
|
||||
(§4.6) — a normal release deploy never reads these:
|
||||
|
||||
| Secret | Value |
|
||||
|---|---|
|
||||
| `STOREFRONT_DOMAINS` | space-separated full hosts, e.g. `gorbushka.market store1.example.com` |
|
||||
| `CERTBOT_EMAIL` | operations email used for Let's Encrypt |
|
||||
| `BACKEND_UPSTREAM` | optional; defaults to `https://127.0.0.1:445` |
|
||||
|
||||
When that step does run, point each base domain's shared API hostname at the
|
||||
server first. For `gorbushka.market` and `store1.gorbushka.market`, only
|
||||
`api.gorbushka.market` is required. The workflow deduplicates
|
||||
`STOREFRONT_DOMAINS` by base domain and deliberately stops before release
|
||||
activation if DNS, certificate issuance, nginx validation, or the JSON
|
||||
`/bootstrap` check fails.
|
||||
|
||||
### 3.5 Deploy
|
||||
|
||||
Push to `main`, or run the workflow manually with a ref. The workflow refuses to swap the symlink unless the uploaded release contains an `index.html`, so a failed upload leaves the previous release serving.
|
||||
|
||||
---
|
||||
|
||||
## 4. Domains and TLS — dynamic by default
|
||||
|
||||
Domains arrive continuously: one today, five tomorrow. Nothing here requires a person per domain.
|
||||
|
||||
**HTTP already needs zero configuration.** The nginx catch-all serves *any* `Host`, and the SPA resolves its tenant from that header. Point a domain's A record at the server and it works over port 80 immediately. Only TLS needs a certificate per name — that is the whole problem this section solves.
|
||||
|
||||
Two mechanisms, used together:
|
||||
|
||||
### 4.1 Wildcard — tenants on our own apex
|
||||
|
||||
One certificate covers every `<slug>.<apex>`. A new tenant subdomain is then live over HTTPS the moment DNS resolves, with **no certificate work at all**.
|
||||
|
||||
```bash
|
||||
sudo bash setup-wildcard-tls.sh \
|
||||
--apex marketplaces.example.com \
|
||||
--email ops@example.com \
|
||||
--dns cloudflare --creds /root/cloudflare.ini
|
||||
```
|
||||
|
||||
Wildcards require DNS-01 validation, so certbot must write a `_acme-challenge` TXT record. With a provider plugin (`cloudflare`, `route53`) renewal is unattended. `--dns manual` works but prompts for a TXT record at **every** renewal — fine to prove the setup out, not acceptable as a steady state.
|
||||
|
||||
**Hostinger has no certbot plugin.** If DNS lives there: either move DNS to a provider that has one (Cloudflare is free, minutes of work), or drive issuance from the [Phase 9](backend/BACKEND-INTEGRATION.md) domain-automation API once it exists.
|
||||
|
||||
### 4.2 Reconciler — tenants on their own domains
|
||||
|
||||
A wildcard cannot cover a customer's own domain. `sync-domains.sh` runs on a 10-minute timer and reconciles the live set against a desired list:
|
||||
|
||||
- issues certificates for domains that lack one
|
||||
- skips domains whose certificate has more than 30 days left
|
||||
- skips subdomains already covered by `WILDCARD_APEX`
|
||||
- leaves domains alone while their DNS has not propagated yet, and retries next tick
|
||||
- disables server blocks for domains removed from the source — **without deleting the certificate**, so re-adding one later is instant
|
||||
- caps issuance per run, so a misconfigured source cannot burn the weekly ACME budget in a single pass
|
||||
|
||||
Configure `/etc/marketplaces/domains.env`:
|
||||
|
||||
```bash
|
||||
DOMAINS_SOURCE=file:/etc/marketplaces/domains.txt
|
||||
CERTBOT_EMAIL=ops@example.com
|
||||
MAX_ISSUE_PER_RUN=10
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now marketplaces-domains.timer
|
||||
sudo /srv/marketplaces/bin/sync-domains.sh --dry-run # see the plan, change nothing
|
||||
```
|
||||
|
||||
Adding a domain becomes: append a line to `/etc/marketplaces/domains.txt` (or add the row in the backend registry), point DNS, wait one tick.
|
||||
|
||||
### 4.3 Backend-driven, once Phase 9 ships
|
||||
|
||||
Point the reconciler at the registry instead of a file and the loop closes — `MarketplaceDomain` already carries exactly the statuses this needs (`planned → dns_pending → ssl_pending → active → failed`):
|
||||
|
||||
```bash
|
||||
DOMAINS_SOURCE=https://api.example.com/api/admin/v2/domains
|
||||
DOMAINS_API_TOKEN=...
|
||||
```
|
||||
|
||||
The script accepts a bare JSON array of hostnames, or objects with `domain` + `status`, in which case it acts only on `active` rows. **A fetch failure aborts the run rather than reading as "remove every domain."**
|
||||
|
||||
### 4.4 One-off
|
||||
|
||||
For a single domain, outside the reconciler:
|
||||
|
||||
```bash
|
||||
sudo bash add-domain.sh shop.example.com --email ops@example.com --with-www
|
||||
```
|
||||
|
||||
### 4.5 API domains in CD are opt-in
|
||||
|
||||
The deploy workflow's **Reconcile tenant API domains** step is gated behind the
|
||||
`reconcile_api_domains` input and is **off for push-triggered deploys**.
|
||||
|
||||
`configure-api-domain.sh` writes `/etc/nginx/sites-available/api.<domain>` and
|
||||
enables it. The API vhosts on the current production host were created by hand
|
||||
under different filenames (`gorbushka-api.conf`), so running the helper there
|
||||
produces a *second* server block for a `server_name` that already has one, and
|
||||
re-runs certbot against a live API — on every deploy. Shipping frontend files
|
||||
needs none of that.
|
||||
|
||||
Turn it on from the workflow-dispatch form only when standing up a **new** base
|
||||
domain. Before the first such run, reconcile the naming: either delete the
|
||||
hand-made vhost and let the helper own the name, or leave the step off and keep
|
||||
managing API domains manually.
|
||||
|
||||
### 4.6 Verify
|
||||
|
||||
```bash
|
||||
curl -I https://shop.example.com/health
|
||||
sudo certbot certificates
|
||||
journalctl -u marketplaces-domains.service --since "1 hour ago"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Rollback
|
||||
|
||||
```bash
|
||||
ssh deploy@<server-ip>
|
||||
ls -1dt /srv/marketplaces/releases/*/ # newest first
|
||||
ln -sfnT /srv/marketplaces/releases/<sha> /srv/marketplaces/current.new
|
||||
mv -Tf /srv/marketplaces/current.new /srv/marketplaces/current
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Only the last 5 releases are retained. Older ones need a rebuild from the tag.
|
||||
|
||||
The production host reaches releases through `/var/www/dexarmarket/browser ->
|
||||
/srv/marketplaces/current/frontend` (§2), so moving `current` is all a rollback
|
||||
needs there too — do not repoint `browser` at a release directly, or the next
|
||||
deploy's swap will silently stop taking effect.
|
||||
|
||||
---
|
||||
|
||||
## 6. Operational checks
|
||||
|
||||
```bash
|
||||
curl -I http://<host>/health # 200 from nginx
|
||||
readlink -f /srv/marketplaces/current # which commit is live
|
||||
sudo nginx -t # config valid
|
||||
systemctl status nginx certbot.timer # both active
|
||||
sudo tail -f /var/log/nginx/marketplaces.error.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Known limits
|
||||
|
||||
- **`/api/` 502s until the backend runs.** Expected. nginx proxies to `127.0.0.1:8080`; nothing listens there yet.
|
||||
- **No staging environment.** `main` goes straight to production. Adding one means a second server plus a `staging` branch trigger.
|
||||
- **No smoke test beyond HTTP 200.** The verify step confirms nginx serves the shell, not that the app boots. A real check needs the E2E harness from Track Q.
|
||||
- **Caching.** `index.html` is `no-store`; hashed assets are `immutable` for a year. A deploy therefore takes effect on the next page load, with no cache purge.
|
||||
392
docs/FORK-ANALYSIS-2026-08-21.md
Normal file
392
docs/FORK-ANALYSIS-2026-08-21.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Fork Analysis — `marketplaces-main.zip` (hub.numus.cc/numus/marketplaces)
|
||||
|
||||
**Date:** 2026-08-21
|
||||
**Artifact analysed:** `C:\Users\darbi\Downloads\marketplaces-main.zip` (4.48 MB, 13 MB extracted)
|
||||
**Analysed against:** this repo, branch `B2B`, HEAD `92f1c88`
|
||||
**Canonical repo of the archive:** `ssh://git@hub.numus.cc:2222/numus/marketplaces.git`, tag `handoff-baseline-2026-08-11`
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict in five lines
|
||||
|
||||
1. This is **not a fork of our repo**. It is a **separate monorepo** — NestJS backend + PostgreSQL + two Angular apps + real infrastructure — that shares an *older* common ancestor with us (`dexarmarket`, Angular 21.2.18).
|
||||
2. They **received our code on 11 Aug 2026**, audited it, and parked it verbatim under `reference/parallel-frontend/` with a SHA-256 fingerprint. They explicitly ruled it **not production**, and wrote a document listing what they will and will not take from us.
|
||||
3. They are ahead of us in exactly one dimension, and it is the decisive one: **they have a backend, a database, RBAC, payments, tenancy by Host, publish/rollback revisions, and a deploy runbook that exists.** We have contracts describing all of that and 22 mock gateways.
|
||||
4. We are ahead of them in exactly one dimension, and they admit it in writing: **frontend depth and editor UX** (530 `.ts` vs 189, 158 components, 30 spec files, boundary checker, Angular 22). Their backoffice is 45 files and still ships `mock-data.service.ts`.
|
||||
5. **VK and Yandex login do not exist in their code.** Zero references, backend and frontend. Section 8 covers what they actually have and gives the design to add VK ID + Yandex ID on our side.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the archive actually contains
|
||||
|
||||
```
|
||||
marketplaces/
|
||||
├── platform-api/ NestJS 11 + Fastify + Prisma + PostgreSQL 17 (44 .ts, ~4 800 LOC)
|
||||
├── backoffice/ Angular 21.2.18 admin + order-manager portal (45 .ts)
|
||||
├── marketplaces/ Angular 21.2.18 runtime storefront (189 .ts)
|
||||
├── infra/ Docker Compose, Nginx, backup, domain automation (21 files)
|
||||
├── docs/ 11 canonical documents, ~1 090 lines, Russian
|
||||
└── reference/
|
||||
└── parallel-frontend/ ← OUR REPO, verbatim, 791 files
|
||||
```
|
||||
|
||||
### 1.1 Lineage — read this carefully
|
||||
|
||||
- `marketplaces/package.json` is named **`dexarmarket`**, Angular **21.2.18**, with brand configs `dexar` / `novo` / `lavero`.
|
||||
- Our `package.json` is named **`dexarmarket`**, Angular **22.0.8**.
|
||||
- `reference/parallel-frontend/package.json` is **our current code**, Angular 22.0.8, with our `arch:check` scripts.
|
||||
|
||||
Common ancestor. They branched earlier and went backend-first; we stayed frontend and went deep. `reference/parallel-frontend/SOURCE_MANIFEST.md` records:
|
||||
|
||||
> Source: `marketplaces-main.zip`, received 11 August 2026. SHA-256 `4d2d990416f573791b09df9ca8c02266432b5ddf213b9ed38c0bced1f19d854f`. 745 files in `src/`, 26 in `public/`, 1 in `tools/`.
|
||||
|
||||
They stripped our sprint reports and internal task docs and replaced them with their own summary. Our source they copied unchanged.
|
||||
|
||||
### 1.2 They are not "overtaking us" — they were handed the platform mandate
|
||||
|
||||
`docs/DEVELOPER_HANDOFF.md` is a **handover-to-a-new-developer document**, with a priority ladder that puts our work last:
|
||||
|
||||
> 1. Security, tenant isolation and financial correctness.
|
||||
> 2. `docs/PRODUCT_SPECIFICATION.md`.
|
||||
> 3. Real models and invariants of `platform-api`.
|
||||
> 4. Existing confirmed production scenarios.
|
||||
> 5. **UI/UX patterns of the parallel implementation.**
|
||||
|
||||
That is the political read: our repo has been reclassified from "the product" to "the design reference". Everything below assumes we want that reversed or renegotiated.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inventory comparison
|
||||
|
||||
| | **Them (archive)** | **Us (`B2B` @ 92f1c88)** |
|
||||
|---|---|---|
|
||||
| Backend | NestJS 11 / Fastify, 44 files, running | None. 17 contract docs in `docs/backend/` |
|
||||
| Database | PostgreSQL 17, Prisma, 36 models, 3 migrations | None |
|
||||
| Storefront | Angular 21.2.18, 189 `.ts` | Angular 22.0.8, 530 `.ts`, 158 components |
|
||||
| Backoffice | Angular 21.2.18, 45 `.ts`, still mock-backed | 14 admin modules, 22 local + 22 API gateway pairs |
|
||||
| Auth (admin) | Email + Argon2id + mandatory TOTP, HttpOnly cookie, server sessions | `admin-auth.guard.ts` + dev bypass |
|
||||
| Auth (customer) | Telegram QR via external `USERAUTH_API_URL`, server session, HttpOnly cookie | Telegram, client-side |
|
||||
| RBAC | 5 roles enforced server-side + per-marketplace membership | Client-side permission model |
|
||||
| Payments | Vitanova + NUMUS adapters, encrypted per-tenant credentials, HMAC webhooks, idempotency, poll fallback | FX/pricing gateways, payment contracts, no server |
|
||||
| Multi-tenancy | Host → verified `MarketplaceDomain` → tenant, 30 s cache, 404 on unknown | Bootstrap-driven runtime config |
|
||||
| Publish | Immutable `MarketplaceRevision` snapshots, atomic publish, rollback-as-new-revision | Draft/publish UI, local persistence |
|
||||
| Infra | Compose (internal + egress networks), Nginx templates, certbot, WAL archiving, backup timers, restore check, fail2ban, sysctl/ssh hardening | `scripts/deploy/*.sh`, GH Actions deploy, wildcard TLS |
|
||||
| Tests | 9 spec files, 25 tests total, **no e2e at all** | 30 spec files, Playwright e2e, coverage floor in CI |
|
||||
| Arch governance | None | `check-boundaries.mjs`, madge cycles, `architecture-governance.yml` |
|
||||
| Bundle | storefront 648 kB (48 kB over) | 1.15 MB (452 kB over a 700 kB budget) — *their measurement of us* |
|
||||
|
||||
---
|
||||
|
||||
## 3. Their backend, in detail — the part worth studying
|
||||
|
||||
### 3.1 Tenant resolution (`common/tenant.service.ts`)
|
||||
|
||||
- `normalizeHost()` lowercases, strips trailing dot, strips port.
|
||||
- Looks up `MarketplaceDomain` by `hostname` **unique index**, requires `verifiedAt != null`, requires `status = ACTIVE`.
|
||||
- 30-second in-process cache keyed by hostname, with `invalidate(hostname?)`.
|
||||
- Unknown host → `404`, never a fallback tenant.
|
||||
- **Preview:** HMAC-signed token `base64url(payload).base64url(hmac)` carrying `{marketplaceId, expiresAt, nonce}`, 15 min TTL, delivered as a `storefront_preview` cookie. A global Fastify `onRequest` hook returns `404 Preview mode is read-only` for any non-GET on `/api/v1/*` while that cookie is present. Cheap, clean, and something we do not have.
|
||||
|
||||
### 3.2 Admin auth (`auth/admin-auth.service.ts`)
|
||||
|
||||
- Argon2id (`memoryCost 65536, timeCost 3, parallelism 1`), TOTP **mandatory** — first login without TOTP returns a signed 10-minute `setupToken` + `otpauth://` URI and refuses to issue a session until TOTP is confirmed.
|
||||
- Sessions are random 32 bytes, stored as **SHA-256 hash only**, 12 h TTL, with `ipAddress` + `userAgent`.
|
||||
- `authenticate()` rejects if `revokedAt`, expired, user inactive, **or TOTP not enabled**.
|
||||
- Password change requires ≥16 chars and revokes every live session in the same transaction.
|
||||
- Role weights: `ORDER_MANAGER 0 < VIEWER 1 < CONTENT_MANAGER 2 < ADMIN 3 < OWNER 4`; `hasAccess()` checks weight **and** marketplace scope.
|
||||
- Manager portal uses a **separate cookie** (`manager_session`) and a separate guard, pinned to one marketplace slug.
|
||||
|
||||
### 3.3 CSRF / origin control (`main.ts`, `auth/admin-origins.ts`)
|
||||
|
||||
A global hook rejects any non-GET on `/api/admin/*` or `/api/manager/*` whose `Origin` header is not in `ADMIN_ORIGIN` + `ADMIN_ORIGINS`. CORS `origin` is the same allowlist with `credentials: true`. Rate limit 120/min per IP; multipart capped at 1 file / 10 MB / 4 fields; body limit 12 MB; `trustProxy: true`; global `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`.
|
||||
|
||||
### 3.4 Checkout (`checkout/checkout.service.ts`) — the atomicity pattern
|
||||
|
||||
```sql
|
||||
UPDATE "MarketplaceInventory"
|
||||
SET "reserved" = "reserved" + $qty, "updatedAt" = NOW()
|
||||
WHERE "marketplaceId" = $mp AND "variantId" = $variant
|
||||
AND ("onHand" - "reserved") >= $qty
|
||||
RETURNING "id"
|
||||
```
|
||||
|
||||
Empty result → `409 Insufficient stock`. This single conditional UPDATE inside a Prisma transaction is the whole oversell defence: no read-then-write race, no advisory locks. Then a `StockReservation` (15 min), one `InventoryMovement` per line with `reason: 'checkout_reservation'`, and an `Order` carrying a full `productSnapshot` per item. Price comes only from the server-side snapshot; the browser's price is never read. `publicToken` is `randomBytes(24).base64url` — no sequential IDs leak. Digital codes are decrypted into the response **only** when order status is `PAID`/`PROCESSING`/`FULFILLED`.
|
||||
|
||||
### 3.5 Payments (`payments/payment.service.ts`)
|
||||
|
||||
- `Payment.idempotencyKey` is a **unique column**; a repeat POST with the same key returns the existing payment, and a key reused across a different order/marketplace → `409`.
|
||||
- Provider credentials live in `PaymentCredential.encryptedConfig`, AES-256-GCM (`v1.iv.tag.ciphertext`, base64url) with a 32-byte `FIELD_ENCRYPTION_KEY`. Decrypted only inside the service, never serialized into a response.
|
||||
- NUMUS webhook: requires `eventId`/`eventType`/`timestamp`/`signature` headers, validates the envelope (`schemaVersion === 1`), HMAC-verifies the **raw body**, then inserts into `PaymentWebhookEvent` with `@@unique([provider, eventKey])`. A Prisma `P2002` collision returns `{accepted: true, duplicate: true}` — replay is a no-op by construction, not by an `if`.
|
||||
- Vitanova webhook: same shape, per-marketplace `webhookSecret` overriding the global one, `eventKey` falling back to `sha256(rawBody)`.
|
||||
- `pollPending(50)` is the reconciliation fallback for the last 24 h; failures are swallowed so the next tick retries.
|
||||
- `checkoutUrl` passes through `safeHttpsUrl()` before it is ever returned to a browser.
|
||||
|
||||
### 3.6 Publish / revisions (`admin/admin.service.ts`)
|
||||
|
||||
`publish()` materializes the draft into a full snapshot, computes `version = max(version) + 1`, writes an immutable `MarketplaceRevision`, and flips `publishedRevision` in the same transaction. Clone copies theme/categories/offers/variants/category links and **forces inventory to zero**, drops domains/customers/orders/secrets, and creates an OWNER membership for the actor. Category cloning is a topological walk that throws `Category tree contains a cycle`.
|
||||
|
||||
### 3.7 Config validation (`common/storefront-config.ts`)
|
||||
|
||||
The section schema is validated **server-side**, per section type, with hard clamps: max 40 sections; id regex; per-type height ranges (`categoryRail 98–360`, `productRail 390–760`, hero fallback 312); colour must match `#rrggbb` or fall back; URLs accepted only if local `/path` or `https://`; product/category ID lists deduped and capped at 24 UUIDs. This is exactly the "validation engine" they praised in our editor — except theirs runs where it actually binds.
|
||||
|
||||
### 3.8 Infrastructure (`infra/`)
|
||||
|
||||
- Compose with **two networks**: `platform` (`internal: true`, no egress — Postgres lives here) and `egress` (API + worker + migrate only).
|
||||
- API bound to `127.0.0.1:3000` only. `no-new-privileges` on every service.
|
||||
- Postgres 17.7 with `wal_level=replica`, `archive_mode=on`, `archive_timeout=300`, archive command copying WAL into a backup volume.
|
||||
- `migrate` is a separate one-shot service; `api` and `worker` both `depends_on: migrate: service_completed_successfully`.
|
||||
- Healthchecks: the API container hits its own `/health/ready`.
|
||||
- systemd timers: `marketplaces-backup`, `marketplaces-domain-sync`, `marketplaces-thumbnails` (path-triggered), plus `*-healthcheck` timers per app.
|
||||
- `provision-domain.sh` refuses to run unless the domain's A record already resolves to the server IP, then certbot webroot, then a **manual** review step before Nginx reload.
|
||||
- Hardening set we do not have: `fail2ban/jail.local`, `sshd` hardening drop-in, `sysctl` hardening, `docker/daemon.json`, scoped sudoers per deploy role, `restore-check.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 4. What they wrote about us (`docs/PARALLEL_IMPLEMENTATION_AUDIT.md`)
|
||||
|
||||
Their measurements of our code, 11 Aug 2026:
|
||||
|
||||
- Production build passes on Node 24.18.1.
|
||||
- **Initial bundle ~1.15 MB against a 700 kB budget — 452 kB over.**
|
||||
- Boundary + cycle checks pass.
|
||||
- 57 unit tests pass; **5 spec files total**; "checkout, RBAC, publishing, orders and admin CRUD flows are not meaningfully covered".
|
||||
- `npm audit --omit=dev`: **0 production vulnerabilities** — better than all three of their own packages.
|
||||
|
||||
Their blocking objections:
|
||||
|
||||
| Their objection | Is it fair? |
|
||||
|---|---|
|
||||
| Mock/localStorage repositories as production implementation | **Fair.** 22 local gateways, 19 files touching `localStorage`. |
|
||||
| Publishing config through localStorage | **Fair** for the modules that still do it. |
|
||||
| Admin JWT / refresh token in localStorage | Fair as of the snapshot. |
|
||||
| Admin session cookie set by JS and readable via `document.cookie` | **Fair and serious.** |
|
||||
| Shared Telegram session for customer *and* admin | **Fair and serious.** |
|
||||
| Client-side `authorization-key`, `userid-value`, partner ID | **Fair and serious** — payment credentials in the browser. |
|
||||
| Direct call to `http://ip-api.com` from an HTTPS storefront | Fair — mixed content plus a third-party geo leak. |
|
||||
| Unconditional `bypassSecurityTrustResourceUrl` on a bank URL | **Fair.** Redirect targets must be backend-allowlisted. |
|
||||
| Storefront + editor + backoffice in one deployable bundle | Fair, and it is also why our bundle is 452 kB over. |
|
||||
| Hardcoded fallback regions / provider URLs / brands in components | Fair. |
|
||||
|
||||
What they said they **want** from us (their P1 list, their order): editor information architecture; the section-editor schema; the validation engine with blockers/warnings/notices; undo/redo + dirty state + change summary; device preview and per-device media; searchable product/category pickers with SKU/price/stock; the media-library interaction model; semantic design tokens; the admin IA; and **our boundary checker and ADR tooling**.
|
||||
|
||||
That list is our leverage. It is also a precise statement of which of our modules are worth hardening first.
|
||||
|
||||
---
|
||||
|
||||
## 5. Differences that matter, ranked by consequence
|
||||
|
||||
1. **Truth ownership.** Their price, stock, tenant and payment truth is server-side and provably so. Ours is a contract document. Every argument about "who is ahead" reduces to this one.
|
||||
2. **Session model.** They: server-stored, hashed, HttpOnly, revocable, TOTP-gated, separate cookies per contour. Us: client-held.
|
||||
3. **Idempotency.** They: unique constraints doing the work (`Payment.idempotencyKey`, `PaymentWebhookEvent(provider,eventKey)`). Us: zero `idempot*` anywhere in the codebase.
|
||||
4. **Deployability.** They: Compose + migrations + healthchecks + WAL + restore check + domain automation. Us: shell scripts and GH Actions, with no database to migrate.
|
||||
5. **Frontend depth.** Us: 2.8× their storefront file count, 158 components, dynamic renderer, widget system, theme system, i18n, 30 spec files, Playwright e2e, boundary governance. Them: a 45-file backoffice with `mock-data.service.ts` still in it.
|
||||
6. **Test posture.** They have **no e2e whatsoever** and 25 unit tests across the whole platform. Their own `VERIFICATION.md` says the count "is insufficient to conclude production readiness". We have e2e plus a CI coverage floor. This is a real gap on their side and worth naming out loud.
|
||||
7. **Framework currency.** We are on Angular 22 / TS 6.0.3; both of their apps are on 21.2.18 with 7–8 fixable high findings in production dependencies.
|
||||
|
||||
---
|
||||
|
||||
## 6. What we should take — concrete, ordered
|
||||
|
||||
### P0 — take these regardless of how the org question resolves
|
||||
|
||||
1. **Conditional-UPDATE stock reservation.** Adopt the pattern verbatim in `backend/BACKEND-INTEGRATION.md`: reserve via `WHERE (onHand - reserved) >= qty RETURNING id`, empty result = 409. It removes a whole class of race conditions and it is one line of SQL.
|
||||
2. **Idempotency as a unique constraint, not application logic.** `Payment.idempotencyKey UNIQUE`, `PaymentWebhookEvent @@unique([provider, eventKey])`, P2002 → `{duplicate: true}`. Push this into `backend/BACKEND-INTEGRATION.md` as a schema requirement, not a behavioural note.
|
||||
3. **Move every credential out of the browser.** Their audit is right about `authorization-key` / `userid-value` / partner ID. Mirror `PaymentCredential.encryptedConfig` (AES-256-GCM, versioned `v1.iv.tag.ct`) in our contract and delete the client-side header path.
|
||||
4. **HttpOnly server sessions, separate cookie per contour** (`bo_session`, `manager_session`, `marketplace_session`). Kill the JS-set cookie and the shared Telegram session for admin + customer. This is our single worst finding in their audit.
|
||||
5. **Origin allowlist hook for all admin mutations.** Twelve lines in `main.ts`; kills CSRF for cookie-authenticated mutations. Mirror in `backend/BACKEND-INTEGRATION.md`.
|
||||
6. **Backend-side config validation.** Our validation engine is better than theirs, but it runs in the browser. The server must re-run it. Their clamp-and-fallback ergonomics are right: never reject a colour, clamp it; never accept a non-https URL, blank it.
|
||||
|
||||
### P1 — take into our own architecture
|
||||
|
||||
7. **Signed preview token + read-only preview enforcement.** HMAC token, 15 min, `storefront_preview` cookie, global hook rejecting non-GET. We have preview UI and no preview safety.
|
||||
8. **Immutable revisions with `version = max+1`, rollback-as-new-revision.** Never rewrite history; `publishedRevision` is an integer pointer flipped in-transaction.
|
||||
9. **Clone semantics.** Copy design + catalog assignments, force inventory to 0, never copy domains/customers/orders/secrets. Their topological category walk with cycle detection is worth copying line for line.
|
||||
10. **`MarketplaceAuthCredential` table.** They have it and do not use it. It is exactly the right home for per-tenant VK/Yandex OAuth app credentials — see §8.
|
||||
11. **Two-network Compose split** (`internal: true` for the data network) and API bound to loopback. Makes "the database is not reachable from the internet" structural rather than a firewall promise.
|
||||
12. **WAL archiving + `restore-check.sh` + a scheduled restore drill.** We have deploy automation and no proven restore.
|
||||
|
||||
### P2 — process, not code
|
||||
|
||||
13. Their **`DEVELOPER_HANDOFF.md` §7 "inviolable invariants"** list is a better acceptance gate than anything currently in our delivery plan. Nine lines, each falsifiable. Adopt it as the header of our own handoff doc.
|
||||
14. Their **PR policy**: one functional area per PR; mandatory purpose, screenshots, API changes, migrations, test evidence, security impact, rollback plan; never change payment/inventory/order state machines inside a redesign PR.
|
||||
15. Their **status discipline**: "a local build or the existence of a UI does not mean production readiness". Every release records version, migration, healthcheck, smoke, audit, rollback.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ideas worth stealing (product-level)
|
||||
|
||||
- **`ORDER_MANAGER` as a fully separate contour** — separate URL, separate shell, separate cookie, separate login, pinned to one marketplace, cannot see catalog/design/domains/payment settings. Genuinely good product thinking: the people who touch orders all day are not admins, and giving them their own small app removes an entire permissions surface.
|
||||
- **`FulfillmentMode: MANUAL | CODE_POOL` + a `DigitalCode` pool** with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted values, `valueHash` unique per `(marketplace, variant)`, and codes revealed only after payment. We have no digital-goods story at all; this is a complete one in one table.
|
||||
- **Marketplace status machine** `DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`, with `DOMAIN_PENDING` as a real state rather than an error condition.
|
||||
- **Per-tenant delivery options priced in minor units on the offer**, validated at checkout ("select a delivery option for each physical product").
|
||||
- **`InventoryMovement` as an append-only journal** with `reason`, `referenceType`, `referenceId`, `actorId` — every stock change explainable after the fact. This directly answers the "we do not trust your numbers" complaint in the v3.1 plan.
|
||||
- **CSV marketplace import** (`POST /marketplaces/import`, `dryRun` default true) — bulk tenant creation as a first-class operation.
|
||||
- **Their §22 acceptance scenarios** (15 of them) are a ready-made e2e suite. Scenario 3 (two concurrent purchases of the last unit) and scenario 10 (replayed webhook) are the two tests that would catch the most expensive possible bugs. Write those two this sprint regardless of anything else in this document.
|
||||
|
||||
---
|
||||
|
||||
## 8. VK ID and Yandex login — what is actually there, and how we add it
|
||||
|
||||
### 8.1 Finding: they do not have it
|
||||
|
||||
Exhaustive search of the archive (`*.ts`, `*.html`, `*.md`, `*.json`, `*.prisma`, `*.sql`, `*.yml`, `*.conf`, env examples), excluding `node_modules` and excluding our own code under `reference/`:
|
||||
|
||||
- `vk` / `vkontakte` / `vkid` — **0 hits** in source. The only matches anywhere are inside `package-lock.json` integrity hashes and two Armenian/English FAQ content pages.
|
||||
- `yandex` — **0 hits** in source; the same two content pages only.
|
||||
- `oauth` — **0 hits** in their code. The single `oauth`-adjacent file in the whole archive is **ours**: `reference/parallel-frontend/src/app/core/auth/services/auth.service.ts`.
|
||||
- The Prisma schema has **no** `ExternalIdentity`, no `provider` column on `Customer`, and no social tables. Customer identity is `@@unique([marketplaceId, telegramUserId])` — Telegram only.
|
||||
|
||||
**Their only customer login is Telegram**, and it is not even self-hosted: `CustomerAuthService` proxies to an external service at `USERAUTH_API_URL` (`https://users.vitanova.network:456`), creates a web session, polls `/users/sessions/{id}` until `status` is confirmed, then upserts a `Customer` and issues its own 30-day session cookie.
|
||||
|
||||
So there is nothing to copy from them here. But their **session-issuing half is the right shape**, and it is what VK/Yandex should terminate into.
|
||||
|
||||
### 8.2 What we already have
|
||||
|
||||
| File | State |
|
||||
|---|---|
|
||||
| [vk-id-gateway.interface.ts](src/app/core/identity/services/vk-id-gateway.interface.ts) | `getAuthorizeUrl()`, `completeCallback(code, codeVerifier)` |
|
||||
| [vk-id-api.gateway.ts](src/app/core/identity/services/vk-id-api.gateway.ts) | Real HTTP client → `/api/identity/v1/vk/authorize`, `/vk/callback` |
|
||||
| [vk-id-local.gateway.ts](src/app/core/identity/services/vk-id-local.gateway.ts) | Mock |
|
||||
| [vk-id-gateway.token.ts](src/app/core/identity/services/vk-id-gateway.token.ts) | DI seam via `environment.useMockData` |
|
||||
| [vk-id-login.component.ts](src/app/components/vk-id-login/vk-id-login.component.ts) | Button component |
|
||||
| [customer-identity.model.ts](src/app/core/identity/models/customer-identity.model.ts) | `ExternalIdentityProvider = 'vk_id' \| 'telegram' \| 'max'` |
|
||||
| [backend/BACKEND-INTEGRATION.md](backend/BACKEND-INTEGRATION.md) | §2 defines the VK ID contract |
|
||||
|
||||
We have the scaffolding and the contract. Yandex is absent everywhere except one mention of *Yandex Market* as a possible marketplace connector in the gap analysis — a different thing entirely.
|
||||
|
||||
### 8.3 Design — one provider-agnostic social login, VK ID and Yandex ID as instances
|
||||
|
||||
**Principle (already in our contract — keep it):** the OAuth code exchange happens entirely backend-side. No client secret, no access token, and no `code_verifier` ever reaches the browser.
|
||||
|
||||
**Change to make:** our current interface passes `codeVerifier` from the client, which forces the browser to generate and store the PKCE verifier. We are a confidential client — the backend should own the verifier. Recommended surface:
|
||||
|
||||
```
|
||||
GET /api/identity/v1/{provider}/authorize
|
||||
-> 302 to the provider, OR { url } for the client to navigate to.
|
||||
Backend generates state + code_verifier and stores both in a
|
||||
short-lived HttpOnly cookie (or server-side, keyed by state),
|
||||
10 min TTL, single use.
|
||||
|
||||
GET /api/identity/v1/{provider}/callback?code=…&state=…[&device_id=…]
|
||||
-> backend validates state, exchanges code + stored verifier,
|
||||
fetches the profile, resolves/links Customer, issues the
|
||||
marketplace session cookie, 302 back into the storefront.
|
||||
|
||||
POST /api/identity/v1/{provider}/unlink (authenticated)
|
||||
GET /api/identity/v1/me/identities (authenticated) -> linked providers
|
||||
```
|
||||
|
||||
`{provider}` ∈ `vk` | `yandex` (later `telegram`, `max`). One controller, one service, a per-provider strategy object. The frontend keeps exactly one gateway interface:
|
||||
|
||||
```ts
|
||||
export type SocialProvider = 'vk' | 'yandex';
|
||||
|
||||
export interface SocialIdentityGateway {
|
||||
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
|
||||
listIdentities(): Observable<ExternalIdentity[]>;
|
||||
unlink(provider: SocialProvider): Observable<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`VkIdGateway` collapses into it, `completeCallback()` disappears from the frontend entirely (the backend handles the callback and redirects), and `vk-id-login.component` becomes `social-login-button` with a provider input. Add `'yandex_id'` to `ExternalIdentityProvider` in `customer-identity.model.ts`.
|
||||
|
||||
**Provider specifics** — confirm exact parameter and scope names against the live provider docs before implementing; both providers have revised their flows recently.
|
||||
|
||||
*VK ID* — OAuth 2.1, **PKCE mandatory**, S256.
|
||||
- Authorize: `https://id.vk.com/authorize` — `client_id`, `redirect_uri`, `response_type=code`, `code_challenge`, `code_challenge_method=S256`, `state`, `scope` (typically `vkid.personal_info email phone`).
|
||||
- The callback returns a **`device_id` alongside `code`**, and it is required for the token exchange. Missing it makes every exchange fail; this is the single most common VK ID integration bug.
|
||||
- Token: `POST https://id.vk.com/oauth2/auth` — `grant_type=authorization_code`, `code`, `code_verifier`, `device_id`, `client_id`, `redirect_uri`.
|
||||
- Profile: `POST https://id.vk.com/oauth2/user_info` with the access token → stable `user_id`, name, optional email/phone.
|
||||
- Logout: `https://id.vk.com/oauth2/logout` — call it on unlink so the provider session is not left dangling.
|
||||
|
||||
*Yandex ID* — OAuth 2.0, PKCE supported; use it.
|
||||
- Authorize: `https://oauth.yandex.ru/authorize` — `response_type=code`, `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method=S256`.
|
||||
- Token: `POST https://oauth.yandex.ru/token` — `grant_type=authorization_code`, `code`, `code_verifier`, HTTP Basic auth with `client_id:client_secret`.
|
||||
- Profile: `GET https://login.yandex.ru/info?format=json` with header `Authorization: OAuth <access_token>` → `id` (stable), `login`, `default_email`, `default_phone`, `psuid`, avatar id.
|
||||
- Yandex returns an email in most cases; VK often will not. Do not make email a required field on `Customer`.
|
||||
|
||||
**Data model** — add to whatever schema we land on. Their `MarketplaceAuthCredential` is the right precedent for the credentials half.
|
||||
|
||||
```prisma
|
||||
model ExternalIdentity {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
customerId String @db.Uuid
|
||||
provider String // 'vk_id' | 'yandex_id' | 'telegram' | 'max'
|
||||
providerUserId String
|
||||
email String?
|
||||
phone String?
|
||||
displayName String?
|
||||
verifiedAt DateTime @default(now())
|
||||
lastUsedAt DateTime @default(now())
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerUserId]) // one provider account -> one customer
|
||||
@@index([customerId])
|
||||
}
|
||||
```
|
||||
|
||||
Plus, per tenant, an encrypted OAuth app config using the same envelope as their `FieldEncryptionService`:
|
||||
|
||||
```
|
||||
MarketplaceAuthCredential { marketplaceId, provider, encryptedConfig, active }
|
||||
encryptedConfig = { clientId, clientSecret, scopes[], redirectUri }
|
||||
```
|
||||
|
||||
**Five rules that decide whether this ships correctly:**
|
||||
|
||||
1. **Redirect URI vs. multi-tenant domains.** VK and Yandex both validate `redirect_uri` against an exact registered list. With N tenant domains you cannot register N URIs per app, and you cannot let tenants supply their own. Use **one central identity host** (e.g. `id.<platform-domain>`) as the only registered callback, carry the origin tenant inside the signed `state`, and 302 back to the tenant domain with a short-lived signed one-time handoff token that the tenant's API exchanges for the session cookie. Decide this before writing any code — retrofitting it is expensive.
|
||||
2. **`@@unique([provider, providerUserId])`, plus a decision on per-tenant customer separation.** Their platform isolates `Customer` per marketplace even for the same Telegram ID. Decide explicitly whether one VK account across two of our storefronts is one customer or two. Their answer is *two*; that is the safer default for data protection and the one our `Customer.marketplaceId` already implies.
|
||||
3. **Identity conflict is not an upsert.** Our PHASE-8 §2 already says this: if `providerUserId` is already bound to a different `Customer`, route to controlled resolution — never silently rebind. Enforce it with the unique index so the database refuses, rather than trusting the service layer.
|
||||
4. **`state` is single-use and bound to the browser.** Store `{ state, codeVerifier, marketplaceId, returnTo, expiresAt }` server-side or in a signed HttpOnly cookie; delete on first use. Reject unknown/expired/replayed `state` with a generic error.
|
||||
5. **The session that comes out is our normal session.** VK/Yandex end where Telegram ends: a random 32-byte token, stored as SHA-256, HttpOnly + Secure + SameSite=Lax, per-marketplace, revocable. Social login is an *entry path*, not a session format.
|
||||
|
||||
**Build order:** provider-agnostic backend endpoints + `ExternalIdentity` table → VK ID (v3.1 names it the primary social login) → Yandex ID (a second instance of the same strategy, roughly a day once VK works) → migrate Telegram onto `ExternalIdentity` so it becomes one provider among several rather than the schema's only key → account-linking UI (`/me/identities`, link/unlink) → email/phone OTP as recovery.
|
||||
|
||||
---
|
||||
|
||||
## 9. What we must not copy from them
|
||||
|
||||
- **Angular 21.2.18** with 7–8 open high findings in production dependencies, in both apps. We are on 22.0.8 with a clean production audit. Do not regress.
|
||||
- **`mock-data.service.ts` in the backoffice** — they still ship one while telling us mocks are disqualifying.
|
||||
- **25 unit tests and zero e2e.** Their own verification doc concedes this is not sufficient.
|
||||
- **`ORDER_MANAGER_MARKETPLACE_SLUG` pinned by environment variable** (default `'dexar'`, `'novo'` in the example env). Manager scope should come from membership rows, not an env string.
|
||||
- **Server IP hardcoded in `provision-domain.sh`** (`109.120.134.244`), and the `sslip.io` staging hosts baked into the committed env example.
|
||||
- Their **section schema** is narrower than ours (5 section types vs our widget system). Take their *server-side validation discipline*, not their schema.
|
||||
|
||||
---
|
||||
|
||||
## 10. Recommended next actions
|
||||
|
||||
| # | Action | Why now |
|
||||
|---|---|---|
|
||||
| 1 | Write the two e2e tests from their §22: concurrent purchase of the last unit, and a replayed webhook | Highest bug-cost coverage per hour, and they have neither |
|
||||
| 2 | Remove client-held payment credentials and JS-set admin cookies | Their audit's most serious finding, and it is correct |
|
||||
| 3 | Fold their invariant list (§7 of their handoff) into our own handoff doc as a signed acceptance gate | Turns their strongest document into our shared standard |
|
||||
| 4 | Decide the central-identity-host question in §8.3 rule 1 | Blocks VK ID, and it is a one-way door |
|
||||
| 5 | Implement provider-agnostic social identity, then VK ID, then Yandex ID | v3.1 §14 names VK ID the primary social login; nobody has it yet, including them |
|
||||
| 6 | Cut the storefront bundle below budget by splitting storefront / editor / backoffice deployables | 452 kB over, and it is the one performance criticism that is objectively measured |
|
||||
| 7 | Take the position explicitly that the two codebases merge as *their backend + our frontend* | Their handoff doc already ranks our work fifth; unchallenged, that becomes the plan of record |
|
||||
|
||||
---
|
||||
|
||||
## Appendix — where things live in the archive
|
||||
|
||||
| Concern | Path |
|
||||
|---|---|
|
||||
| Tenant by Host, preview tokens | `platform-api/src/common/tenant.service.ts` |
|
||||
| Admin auth, TOTP, RBAC weights | `platform-api/src/auth/admin-auth.service.ts` |
|
||||
| Origin allowlist / CSRF hook | `platform-api/src/main.ts`, `src/auth/admin-origins.ts` |
|
||||
| Stock reservation SQL | `platform-api/src/checkout/checkout.service.ts` |
|
||||
| Idempotency, webhooks, polling | `platform-api/src/payments/payment.service.ts` |
|
||||
| Field encryption (AES-256-GCM) | `platform-api/src/common/field-encryption.service.ts` |
|
||||
| Server-side section validation | `platform-api/src/common/storefront-config.ts` |
|
||||
| Publish / rollback / clone | `platform-api/src/admin/admin.service.ts` |
|
||||
| Customer (Telegram) sessions | `platform-api/src/storefront/customer-auth.service.ts` |
|
||||
| Data model, 36 entities | `platform-api/prisma/schema.prisma` |
|
||||
| Compose, networks, WAL | `infra/compose.yml` |
|
||||
| Domain provisioning, backup, restore check | `infra/scripts/` |
|
||||
| Host hardening (fail2ban, sshd, sysctl) | `marketplaces/infra/server/` |
|
||||
| Their audit of our code | `docs/PARALLEL_IMPLEMENTATION_AUDIT.md` |
|
||||
| Their target spec (479 lines) | `docs/PRODUCT_SPECIFICATION.md` |
|
||||
| Their handoff + invariants | `docs/DEVELOPER_HANDOFF.md` |
|
||||
| Our code, verbatim | `reference/parallel-frontend/` |
|
||||
304
docs/FORK-HARVEST-TODO.md
Normal file
304
docs/FORK-HARVEST-TODO.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# Fork Harvest — TODO
|
||||
|
||||
**Branch:** `improvements/fork-harvest` (from `B2B` @ `92f1c88`)
|
||||
**Design:** [2026-08-21-fork-harvest-design.md](superpowers/specs/2026-08-21-fork-harvest-design.md)
|
||||
**Source analysis:** [FORK-ANALYSIS-2026-08-21.md](FORK-ANALYSIS-2026-08-21.md)
|
||||
|
||||
Improvements only. Nothing here regresses our Angular version, test count, or architecture governance.
|
||||
|
||||
**Effort:** S ≤ half a day · M ≤ 2 days · L > 2 days
|
||||
**Lane:** A frontend · B backend contract · C `@marketplaces/auth` package · D infra/ops · E process
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 — Decide first (blocks Wave 4)
|
||||
|
||||
- [ ] **FH-0.1 — Decide the central identity host** · L · Lane C · *blocker*
|
||||
VK ID and Yandex ID both validate `redirect_uri` against an exact registered list. We cannot register one per tenant domain, and we cannot let tenants supply their own.
|
||||
**Decision needed:** single central callback host (e.g. `id.<platform-domain>`) as the only registered URI, tenant carried inside signed `state`, 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for a session cookie.
|
||||
**Also decide:** is one VK account across two of our storefronts one `Customer` or two? Their platform says two; our `Customer.marketplaceId` already implies two.
|
||||
**Done when:** an ADR exists in `docs/context/adrs/` and both questions have a recorded answer.
|
||||
|
||||
- [ ] **FH-0.2 — Confirm the server-priced checkout path covers every live flow** · S · Lane A · *blocks FH-1.3*
|
||||
`api.service.ts` already has a server-priced checkout session method. Confirm no production flow still depends on `createPayment(payload, headers)` before deleting the header path.
|
||||
**Done when:** every caller of the legacy header path is enumerated and has a replacement.
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — Live defects with a security benefit (Lane A, this sprint)
|
||||
|
||||
- [x] **FH-1.1 — Kill the plaintext third-party geo call** · S · Lane A · **done 2026-08-21**
|
||||
Now `GET {tenantApiBase}/geo/resolve`, same base as `/regions`. Server reads the client IP; nothing leaves our infrastructure. Endpoint specified in [BACKEND-API-REFERENCE.md](../BACKEND-API-REFERENCE.md) §6 — **not built yet**, and until it is the client falls back to the manual picker, which is what production has effectively had all along. Covered by `src/app/services/location.service.spec.ts` (4 tests, one of which fails the build on any off-origin or plaintext request from this service).
|
||||
*Was:* `location.service.ts:75` called `http://ip-api.com/json/?fields=…` from an HTTPS origin. Mixed active content is blocked, so `detectLocation()` only ever took its error branch — auto-detect was dead in production, not merely insecure — and the attempt still leaked every visitor's IP to a third party.
|
||||
|
||||
- [ ] **FH-1.2 — Stop blindly trusting the bank redirect URL** · M · Lane A
|
||||
`src/app/pages/cart/cart.component.ts:485` — `bypassSecurityTrustResourceUrl(bankUrl)` with no validation, rendered into a popup iframe. Most acquirer 3-D Secure pages send `X-Frame-Options: DENY`, so the popup is blank for those banks. Their spec: card checkout navigates the current tab, no intermediate popup.
|
||||
**Do:** accept only an `https:` URL whose origin the backend returned in the payment response (backend allowlist, per their `safeHttpsUrl()`); navigate the current tab instead of framing.
|
||||
**Done when:** a non-https or non-allowlisted URL is refused with a visible payment error; a test covers both the accepted and the refused case.
|
||||
|
||||
- [x] **FH-1.3 — Remove provider credentials from the browser** · M · Lane A · **landed via the `@marketplaces/payment` migration**
|
||||
The legacy payment surface on `ApiService` was deleted wholesale in that work. `grep -ri "authorization-key\|userid-value\|web-97ec" src/` now returns nothing. Keep FH-3.5 (bundle secret scan) to stop it coming back.
|
||||
*Was:* `api.service.ts:675` set `authorization-key` and `userid-value` headers client-side, and `api.service.ts:143` shipped a partner ID literal in the bundle. Their audit's most serious finding, and it was correct.
|
||||
|
||||
- [ ] **FH-1.4 — Send `Idempotency-Key` on payment creation** · S · Lane A
|
||||
Zero `idempot*` anywhere in our codebase. Their API requires the header and rejects a key reused across a different order.
|
||||
**Do:** generate one key per checkout attempt, stable across retries and across a double-click, sent on payment creation.
|
||||
**Done when:** the existing `checkout-idempotent-click.spec.ts` asserts both requests carry the *same* key.
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — Contract hardening (Lane B, parallel with Wave 1)
|
||||
|
||||
Each item is normative text plus an acceptance scenario in `backend/BACKEND-INTEGRATION.md`, so it becomes a delivery gate rather than a wish.
|
||||
|
||||
- [x] **FH-2.1 — Conditional-UPDATE stock reservation** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-3 §3.1 — the conditional `UPDATE … WHERE (available - reserved) >= qty RETURNING id`, 409 on zero rows, whole-cart rollback, 15 min TTL.
|
||||
`UPDATE … SET reserved = reserved + $qty WHERE (onHand - reserved) >= $qty RETURNING id`; empty result → `409`. Reservation TTL 15 min. Price read only from the server-side snapshot, never from the request.
|
||||
**Acceptance:** two concurrent purchases of the last unit produce exactly one payable order.
|
||||
|
||||
- [x] **FH-2.2 — Idempotency as unique constraints** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-7 §5 — unique constraints on `payment.idempotency_key` and `(provider, event_key)`, insert-first webhook handling, `sha256(rawBody)` fallback key, signature over the raw body, 24 h poll as reconciliation.
|
||||
`Payment.idempotencyKey UNIQUE`; a key reused against a different order/marketplace → `409`. `PaymentWebhookEvent @@unique([provider, eventKey])`; duplicate insert → `{accepted: true, duplicate: true}`. `eventKey` falls back to `sha256(rawBody)`. Signature verified against the **raw** body. Status poll as a 24-hour reconciliation fallback.
|
||||
**Acceptance:** a replayed webhook neither completes the order twice nor moves stock twice.
|
||||
|
||||
- [x] **FH-2.3 — Session and credential model** · M · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** TRACK-S §2.1 — 32 random bytes stored as SHA-256 only, HttpOnly/Secure/SameSite, one cookie per contour, Argon2id params, mandatory TOTP with a single-use enrolment token, password change revokes all sessions in-transaction.
|
||||
Server-stored sessions; random 32 bytes; **stored as SHA-256 hash only**; HttpOnly + Secure + SameSite; revocable; a distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`). Argon2id `memoryCost 65536, timeCost 3, parallelism 1`. TOTP mandatory, gated by a signed 10-minute setup token. Password change ≥16 chars and revokes every live session in the same transaction. Role weights `ORDER_MANAGER 0 < VIEWER 1 < CONTENT_MANAGER 2 < ADMIN 3 < OWNER 4`, checked together with marketplace scope.
|
||||
**Acceptance:** a CONTENT_MANAGER cannot read an unassigned marketplace through a direct API call.
|
||||
|
||||
- [x] **FH-2.4 — Origin allowlist for admin mutations** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** TRACK-S §2.2 — origin allowlist ahead of routing on every admin/platform/manager mutation, same list for CORS.
|
||||
Global hook: any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist → `403`. CORS uses the same allowlist with `credentials: true`.
|
||||
**Acceptance:** a cross-origin POST with a valid session cookie is refused.
|
||||
|
||||
- [x] **FH-2.5 — Tenant by verified Host only** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-9 §6 — normalization specified, `verifiedAt` required, cache with explicit invalidation, proxy header trust, no public endpoint accepts `marketplaceId`.
|
||||
Normalize host (lowercase, strip trailing dot, strip port) → unique `hostname` row → require `verifiedAt` and `ACTIVE`. Short cache with explicit invalidation. Unknown host → `404`, never a fallback tenant. The public API never accepts a `marketplaceId` from the browser.
|
||||
**Acceptance:** an unknown Host returns 404 and leaks no other tenant's data.
|
||||
|
||||
- [x] **FH-2.6 — Signed preview token, read-only preview** · S · `PHASE-9-…`
|
||||
**Written 2026-08-21:** PHASE-9 §5.3 — HMAC preview token, 15 min, HttpOnly cookie, every non-GET 404s while preview is active, `noindex`.
|
||||
HMAC-signed token carrying `{marketplaceId, expiresAt, nonce}`, 15-minute TTL, `storefront_preview` cookie. Global hook returns `404 Preview mode is read-only` for any non-GET while that cookie is present. Preview is not indexable.
|
||||
**Acceptance:** a mutation attempted in preview mode is refused.
|
||||
|
||||
- [x] **FH-2.7 — Immutable revisions, rollback, clone** · M · new section, `PHASE-9-…`
|
||||
**Written 2026-08-21:** PHASE-9 §5.1–5.2 — `version = max+1` unique per marketplace, materialized snapshot, pointer flipped in-transaction, rollback as a new revision, clone carry/no-carry list, inventory to zero, topological category walk.
|
||||
`version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction. Rollback creates a new revision; history is never rewritten. Clone copies design + catalog assignments, **forces inventory to 0**, never copies domains/customers/orders/secrets, and walks the category tree topologically with explicit cycle detection.
|
||||
**Acceptance:** rollback restores the chosen revision and leaves live inventory untouched.
|
||||
|
||||
- [x] **FH-2.8 — Append-only inventory journal** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-3 §3.2 — `InventoryMovement` append-only with reason, reference, actor, and `resultingAvailable` written at the time.
|
||||
Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. Direct answer to the v3.1 "we cannot explain your numbers" complaint.
|
||||
**Acceptance:** any current quantity is reconstructible from the journal alone.
|
||||
|
||||
- [x] **FH-2.9 — Per-tenant encrypted credentials** · S · `PHASE-1` / `PHASE-7`
|
||||
**Written 2026-08-21:** TRACK-S §4.2 — `v1.iv.tag.ciphertext` AES-256-GCM envelope, per-value IV, decrypt only in-service, HMAC fingerprints for display, backend-built allowlisted redirect URLs.
|
||||
AES-256-GCM, versioned envelope `v1.iv.tag.ciphertext` (base64url), 32-byte key from the environment. Decrypted only inside the service; never serialized into any response. Redirect/callback URLs built backend-side and allowlisted.
|
||||
**Acceptance:** no credential appears in any API response, JS bundle, or browser storage.
|
||||
|
||||
- [x] **FH-2.10 — Server-side storefront config validation** · M · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-10 §3a — server re-runs the editor rules, clamp-and-fallback ergonomics, structural violations 400, limits published as one schema, referential checks as publish blockers.
|
||||
The server re-runs our editor's validation. Clamp-and-fallback ergonomics: clamp out-of-range numbers rather than rejecting; blank a URL that is not local `/path` or `https://` rather than erroring; fall back an invalid colour. Cap sections per page and IDs per list.
|
||||
**Acceptance:** a hand-crafted API call cannot store a config the editor would have refused.
|
||||
|
||||
- [x] **FH-2.11 — Digital goods** · M · `PHASE-3-…`
|
||||
**Written 2026-08-21:** PHASE-3 §6a — `FulfillmentMode`, `DigitalCode` states, `valueHash` unique per (marketplace, offer), codes revealed only when paid.
|
||||
`FulfillmentMode: MANUAL | CODE_POOL`. `DigitalCode` pool with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted value, `valueHash` unique per `(marketplace, variant)`. Codes revealed only when the order is `PAID`/`PROCESSING`/`FULFILLED`.
|
||||
**Acceptance:** an unpaid order never returns a code.
|
||||
|
||||
- [~] **FH-2.12 — Marketplace status machine** · **rejected 2026-08-21 — ours is better**
|
||||
Theirs is `DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`. PHASE-9 §2 already carries `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, plus a lifecycle endpoint that must name the specific blocker preventing the next transition. Adopting theirs would be a downgrade. Recorded so it does not get raised again.
|
||||
|
||||
- [x] **FH-2.13 — Order public token, not sequential IDs** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-2 §3.1 — `publicToken` ≥24 random bytes for every customer-facing route, tenant-scoped lookup, snapshot completeness, snapshots never updated in place.
|
||||
Orders are addressed publicly by a random `base64url` token. Order line items carry an immutable snapshot of name, SKU, price, currency, delivery, and contact data at purchase time.
|
||||
|
||||
- [x] **FH-2.14 — Order-manager as a separate contour** · M · `TRACK-S-…`
|
||||
**Written 2026-08-21:** TRACK-S §8a — separate URL, shell, login and cookie; scope from membership rows not configuration; endpoints refuse rather than hide; PII masking and audited reveal.
|
||||
Separate URL, shell, cookie, and login; scoped to assigned marketplaces via **membership rows, not an environment variable** (their env-pinned slug is the one part not to copy). No visibility into catalog, design, domains, payment settings, or platform users. PII masked in lists, revealed in detail only with permission, and both export and reveal are logged.
|
||||
|
||||
- [x] **FH-2.15 — Bulk import: idempotency and rollback** · S · **done 2026-08-21**
|
||||
The validate-then-apply half already existed — PHASE-3 §6 has the preview of validation errors and a separate apply step, which is equivalent to their `dryRun`. What was missing and is now written: the import is **idempotent by SKU/external key** so re-running a file updates rather than duplicates, a row-level error never publishes a partial result, and an applied import is rollback-able only while none of its products have appeared on a paid order.
|
||||
|
||||
---
|
||||
|
||||
## Wave 3 — Proof (Lane A)
|
||||
|
||||
- [ ] **FH-3.1 — E2E: concurrent purchase of the last unit** · M · `e2e/`
|
||||
Their §22 scenario 3. Two sessions race for the final unit; exactly one payable order results, the other gets a clean out-of-stock state.
|
||||
|
||||
- [ ] **FH-3.2 — E2E: replayed webhook** · M · `e2e/`
|
||||
Their §22 scenario 10. The same provider event delivered twice does not complete the order twice or move stock twice.
|
||||
|
||||
- [x] **FH-3.3 — Bundle budget as a blocking CI check** · S · **done 2026-08-21**
|
||||
`maximumError` on the initial bundle lowered `1.8MB → 1.6MB` in `angular.json`, and again to `1.1MB` once FH-3.4 landed. Measured today: **1.55 MB raw / 324.58 kB transfer** — worse than the 1.15 MB they measured on 11 Aug, so this had been growing unwatched. The threshold is a **ratchet**, not the target: set just above today's size so the bundle cannot grow, with the 700 kB warning left in place as the goal. Lower it every time the number comes down. CI now runs the production build (`npm run build` already defaults to production).
|
||||
|
||||
- [~] **FH-3.4 — Get the initial bundle down** · L · **1.55 MB → 1.04 MB on 2026-08-21; not yet at target**
|
||||
**Premise corrected after measuring.** Admin, editor, catalog, cart and the `en`/`hy` locales are *already* lazy chunks — nothing admin-shaped ships to an anonymous visitor. The whole initial bundle is `main` alone, so this was never a "split the deployables" job.
|
||||
|
||||
Built with `--stats-json` and read the esbuild metafile rather than guessing. Composition of the 1.5 MB `main`:
|
||||
|
||||
| bytes | what |
|
||||
|---:|---|
|
||||
| 495,831 | `@angular/compiler` |
|
||||
| 290,589 | `src/app/i18n/ru.ts` |
|
||||
| 162,915 | `@angular/core` |
|
||||
| 82,209 | `@angular/router` |
|
||||
| 53,354 | `@angular/common` |
|
||||
|
||||
**`@angular/compiler` — 33% of the bundle — was the JIT compiler, in an AOT production build.** `src/main.ts` imported it explicitly, with a comment explaining that `@marketplaces/auth` shipped plain `tsc` output carrying no Ivy metadata, so Angular JIT-compiled its classes at runtime and bootstrap threw without it.
|
||||
|
||||
That comment was **stale**. The package is `0.2.0`, built with ng-packagr, `module: dist/fesm2022/marketplaces-auth.mjs` — proper Angular Package Format, partial-compiled (`ɵɵngDeclareInjectable`), linked at consumer build time. No JIT needed.
|
||||
|
||||
Removed the import. Verified against the **production** bundle served statically, not just a successful build — the failure mode it guarded was a runtime throw, so a green build proves nothing. Angular 22.0.8 bootstrapped, the router resolved `/ru`, and the app rendered its own "server unavailable" screen, which means DI, HttpClient and the whole interceptor chain ran. That chain injects `AuthService` from `@marketplaces/auth` — the exact class named in the old comment. Zero JIT/compiler errors; the only console output was the expected 404s from having no backend.
|
||||
|
||||
**Result: 1.55 MB → 1.04 MB raw, 323.58 kB → 215.45 kB transfer.** Ratchet lowered `1.6MB → 1.1MB`, which is now also the guard against the import being re-added.
|
||||
|
||||
**Remaining path to the 700 kB target.** The next lever is `i18n/ru.ts` at 290 kB — the default locale, eager, while `en`/`hy` are lazy. `TranslateService` already has the loader plumbing and `languageGuard` already awaits a preload before route activation, so making `ru` lazy is mechanically small. It is deliberately **not** done here: it adds a round-trip before first paint for the majority language, which is a product tradeoff rather than a cleanup. Needs a decision, then it is roughly a 750 kB bundle.
|
||||
Also outstanding: `qrcode` (23.7 kB), pulled in by `@marketplaces/auth`, is not ESM and causes an optimizer bailout — a fix for the package repo.
|
||||
|
||||
- [x] **FH-3.5 — Bundle secret scan in CI** · S · **done 2026-08-21**
|
||||
`scripts/ci/scan-bundle.sh`, wired as `npm run scan:bundle` and a CI step after Build. Seven patterns: both provider auth headers, the partner ID shape, `client_secret`, private key blocks, AWS keys, Telegram bot tokens. Verified in both directions — clean against the real `dist/`, and fails with exit 1 against a planted credential.
|
||||
|
||||
---
|
||||
|
||||
## Wave 4 — Identity: VK ID + Yandex ID (Lane C, `@marketplaces/auth`)
|
||||
|
||||
Nothing to copy from the archive — it has zero VK/Yandex/OAuth code. We take the session-issuing shape of their Telegram flow and terminate both providers into it.
|
||||
|
||||
The **client half and the contract are done** (2026-08-21). What remains is backend implementation, and registering the OAuth applications — which is what FH-0.1 gates.
|
||||
|
||||
- [x] **FH-4.1 — Provider-agnostic social identity surface** · M · **done 2026-08-21**
|
||||
Landed as `social-identity-gateway.interface.ts` / `-api.gateway.ts` / `-local.gateway.ts` / `-gateway.token.ts` under `src/app/core/identity/services/`, with the four `vk-id-*` files deleted and `vk-id-login` replaced by `social-login-button` taking a `provider` input. `'yandex_id'` added to `ExternalIdentityProvider`. Covered by `social-identity-gateway.spec.ts` (5 tests), which asserts the request carries no `code_verifier` or `client_secret` — so re-adding a browser-held verifier fails the build rather than passing review.
|
||||
Collapse `VkIdGateway` into `SocialIdentityGateway`:
|
||||
```ts
|
||||
export type SocialProvider = 'vk' | 'yandex';
|
||||
export interface SocialIdentityGateway {
|
||||
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
|
||||
listIdentities(): Observable<ExternalIdentity[]>;
|
||||
unlink(provider: SocialProvider): Observable<void>;
|
||||
}
|
||||
```
|
||||
Touches: `src/app/core/identity/services/vk-id-gateway.interface.ts`, `vk-id-api.gateway.ts`, `vk-id-local.gateway.ts`, `vk-id-gateway.token.ts`, `src/app/components/vk-id-login/` → `social-login-button`. Add `'yandex_id'` to `ExternalIdentityProvider` in `core/identity/models/customer-identity.model.ts`.
|
||||
|
||||
- [x] **FH-4.2 — Move PKCE ownership to the backend** · S · **done 2026-08-21**
|
||||
`completeCallback()` is gone from the frontend entirely. PHASE-8 §2.1–2.2 rewritten: `/authorize` mints and stores `{state, codeVerifier, marketplaceId, returnTo, expiresAt}` single-use for 10 minutes, `/callback` is a backend GET that exchanges, links, issues the session cookie and redirects. `returnTo` validated against the tenant's own origin.
|
||||
Today `completeCallback(code, codeVerifier)` forces the browser to generate and hold the verifier. We are a confidential client. Backend generates `state` + `code_verifier`, stores them single-use for 10 minutes, handles the callback, and redirects. `completeCallback()` leaves the frontend entirely.
|
||||
Contract endpoints: `GET /api/identity/v1/{provider}/authorize`, `GET /api/identity/v1/{provider}/callback`, `POST /{provider}/unlink`, `GET /me/identities`. Update `backend/BACKEND-INTEGRATION.md` §2.
|
||||
|
||||
- [~] **FH-4.3 — `ExternalIdentity` model** · S · Lane B · **contract written 2026-08-21, awaiting backend**
|
||||
PHASE-8 §1 and §2.3: `UNIQUE (provider, providerUserId)`, conflict routes to controlled resolution rather than rebinding, optional email/phone/displayName, per-tenant OAuth app config under the Track S §4.2 envelope.
|
||||
```prisma
|
||||
@@unique([provider, providerUserId]) // one provider account -> one customer
|
||||
```
|
||||
Conflict is **not** an upsert: a `providerUserId` already bound to a different `Customer` routes to controlled resolution. The unique index makes the database refuse a silent rebind. Per-tenant OAuth app config stored encrypted (same envelope as FH-2.9): `{ clientId, clientSecret, scopes[], redirectUri }`.
|
||||
|
||||
- [~] **FH-4.4 — VK ID** · M · **contract written 2026-08-21, awaiting backend**
|
||||
PHASE-8 §2.5 carries the full endpoint set and the `device_id` trap.
|
||||
OAuth 2.1, PKCE mandatory (S256). Authorize `https://id.vk.com/authorize`; token `POST https://id.vk.com/oauth2/auth`; profile `POST https://id.vk.com/oauth2/user_info`; logout `https://id.vk.com/oauth2/logout` on unlink.
|
||||
**Trap to write into the contract:** the callback returns `device_id` alongside `code`, and the token exchange fails without it. This is the most common VK ID integration bug.
|
||||
VK often does not return an email — email must stay optional on `Customer`.
|
||||
|
||||
- [~] **FH-4.5 — Yandex ID** · S · **contract written 2026-08-21, awaiting backend**
|
||||
PHASE-8 §2.5. On the client it is a `provider` input, not new code.
|
||||
OAuth 2.0 with PKCE. Authorize `https://oauth.yandex.ru/authorize`; token `POST https://oauth.yandex.ru/token` with HTTP Basic `client_id:client_secret`; profile `GET https://login.yandex.ru/info?format=json` with header `Authorization: OAuth <token>` → `id`, `login`, `default_email`, `default_phone`, `psuid`.
|
||||
A second strategy object against the same surface — roughly a day once VK works.
|
||||
*Confirm exact parameter and scope names against live provider docs; both providers revised their flows recently.*
|
||||
|
||||
- [~] **FH-4.6 — Migrate Telegram onto `ExternalIdentity`** · M · **client + contract done 2026-08-21; backend write path pending**
|
||||
Client: the gateway now separates the two provider sets — `SocialProvider` (`vk`/`yandex`, has an OAuth authorize) vs `ExternalIdentityProvider` (adds `telegram`/`max`, listable and unlinkable). `unlink()` takes the wider type, so the linking UI detaches Telegram through the same path as VK. The dev local gateway seeds a Telegram identity so the surface is exercisable now.
|
||||
Contract: PHASE-8 §2.6 — Telegram login writes an `ExternalIdentity` row under the same uniqueness/conflict rule as VK, appears in `/me/identities`, is removable subject to the last-identity `409`, and — the audit finding — keeps customer (`marketplace_session`) and admin (`bo_session`) sessions as separate cookies so a Telegram customer never satisfies an admin guard. Identity row vs messaging `BotConversationBinding` kept distinct.
|
||||
Backend still owns: the actual write-on-login and the session split enforcement. Telegram login itself lives in `@marketplaces/auth`.
|
||||
|
||||
- [x] **FH-4.7 — Account linking UI** · M · **done 2026-08-21**
|
||||
`AccountIdentitiesComponent` (`src/app/features/website/account/identities/`): lists linked identities from `GET /me/identities`, offers attach buttons only for OAuth providers not yet linked (reusing `SocialLoginButtonComponent`), detaches through `unlink()`, disables the detach control on the last remaining identity with an explanatory title, and has a slot for the §2.3 conflict message. Loading / error / ready states, error surfaced rather than shown as an empty account. 6 unit tests. Not yet wired into a route — the storefront has no customer account area and no live OAuth app (FH-0.1) — but fully built and tested behind that.
|
||||
|
||||
- [x] **FH-4.8 — Email/phone OTP repositioned as recovery** · S · **done 2026-08-21**
|
||||
PHASE-8 §3 now states it explicitly: OTP is a way back in when a linked messenger is unreachable and a second factor a customer may add, never the front-and-centre first login option, and one more `ExternalIdentity`/`ContactMethod` on the same customer rather than a parallel account. The [email/phone spec](superpowers/specs/2026-08-15-email-phone-login-design.md) stays valid; only its priority relative to VK ID moves.
|
||||
|
||||
---
|
||||
|
||||
## Continuous — Ops (Lane D)
|
||||
|
||||
- [ ] **FH-D.1 — Proven restore drill** · M
|
||||
We have deploy automation and no proven restore. Add a restore-check script and schedule it. Their `restore-check.sh` + WAL archiving (`wal_level=replica`, `archive_mode=on`, `archive_timeout=300`) is the model.
|
||||
**Done when:** a restore into a clean environment has been executed and its result recorded.
|
||||
|
||||
- [ ] **FH-D.2 — Database unreachable from the internet, structurally** · S · Lane B/D
|
||||
Data network `internal: true`; API bound to loopback only; `no-new-privileges` on every service. Makes it a property of the topology rather than a firewall promise.
|
||||
|
||||
- [x] **FH-D.3 — Host hardening we lack** · M · **done 2026-08-21**
|
||||
Three drop-in files in `scripts/deploy/server-setup.sh`, documented in [DEPLOYMENT.md](DEPLOYMENT.md) §3.2: sshd hardening (password and keyboard-interactive auth off, root key-only, `MaxAuthTries 3`, 30 s grace, no forwarding), fail2ban (`sshd`, `nginx-http-auth`, `nginx-bad-request`; 5 failures in 10 min, 1 h ban), and sysctl (no redirects or source routing, rp_filter, SYN cookies, forwarding off, restricted kernel pointers and dmesg). The script runs `sshd -t` before reloading and removes its own drop-in if the test fails — a bad sshd config taking effect remotely is how people lock themselves out permanently.
|
||||
Scoped sudoers was already in place. *Kept ours where ours is better:* `add-domain.sh` pre-checks the DNS A record and runs `nginx -t` before and after; ufw was already configured. Their hardcoded server IP deliberately not copied.
|
||||
|
||||
---
|
||||
|
||||
## Continuous — Process (Lane E)
|
||||
|
||||
- [x] **FH-E.1 — Adopt the nine invariants as an acceptance gate** · S · **done 2026-08-21**
|
||||
Now `backend/BACKEND-INTEGRATION.md` §0, ahead of everything else, each one cross-referenced to the contract section that specifies it. Framed as a release gate: violate one and it does not ship, regardless of what else is finished.
|
||||
|
||||
- [x] **FH-E.2 — PR policy** · S · **done 2026-08-21**
|
||||
`backend/BACKEND-INTEGRATION.md` §0a, with the expand/contract migration rule alongside it.
|
||||
|
||||
- [x] **FH-E.3 — Release discipline** · S · **done 2026-08-21**
|
||||
`backend/BACKEND-INTEGRATION.md` §0a. A release records version, migrations, healthcheck, smoke, dependency audit, and the rollback path actually available.
|
||||
|
||||
- [x] **FH-E.4 — ADR for the harvest** · S · **done 2026-08-21**
|
||||
[ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md). Records what we take, what we reject, what we keep because ours is better, and the one organizational question it deliberately does not settle.
|
||||
|
||||
- [x] **FH-E.6 — Keep mock gateways out of production builds** · M · **done 2026-08-21**
|
||||
21 token factories now `inject(XApiGateway)` unconditionally; mock overrides moved to `src/app/mock-gateway.providers.ts`, swapped for a production copy that imports nothing via `fileReplacements`. Zero `*LocalGateway` classes and zero fixtures in the production bundle, down from 21 classes and 75 kB of source. `scan-bundle.sh` gained two patterns so it cannot return, verified in both directions. Dev behaviour unchanged — flip `useMockData` in `environment.ts` as before.
|
||||
Worth noting for whoever picks up FH-E.5: `useMockData` is `false` in **both** environment files, so none of these mocks were ever the selected implementation. They were pure weight.
|
||||
**Not fixed here:** `MediaRepository` is still bound to `MockMediaRepository` unconditionally in `app.config.ts`. That one cannot simply be deleted — no real implementation exists — so it is a missing API gateway, not dead weight.
|
||||
Measured 2026-08-21: mock seed data reaches the production bundle. `ptr_local`, a fixture literal from `partner-hierarchy-local.gateway.ts`, is present in a built lazy chunk. Cause: 21 DI tokens use `factory: () => (environment.useMockData ? inject(XLocalGateway) : inject(XApiGateway))`, and referencing both branches keeps both classes reachable, so the optimizer cannot drop the mock. 75 kB of local-gateway source, plus its fixtures, ships to users.
|
||||
This is the concrete form of their strongest objection — "mock repositories as production implementation" — and it is mechanical to fix. The pattern to copy is already in this repo: `mock-data.interceptor.production.ts` swapped in via `fileReplacements`.
|
||||
**Done when:** `scan-bundle.sh` can gate on mock fixture markers and pass.
|
||||
|
||||
- [x] **FH-E.5 — Reduce `localStorage` to cache, never truth** · M · **audited + gap marked 2026-08-21**
|
||||
Audited all 21 `localStorage` users. The premise — "localStorage is your source of truth" — turned out **already false** across the app:
|
||||
- Every admin facade (products, categories, orders, moderation, dashboard) uses `localStorage` for **view preferences only** — `viewMode`, `density`, `visibleColumns`, `expandedIds`, `sort`. Entity CRUD goes through the API gateways. That is cache, not truth.
|
||||
- `currency-rates.service.ts` already removed its localStorage-typed rates (its own comment records it).
|
||||
- `language`, `location` region, `search-history`, `api-headers` anonymous session id, `admin-preferences` — all legitimate preference/cache.
|
||||
- The editor already surfaces an **"unsaved local draft restored"** banner (`draftRestored` → save bar), which is the recovery-cache indicator this item asked for.
|
||||
|
||||
**One real gap, and it is backend-blocked:** the project editor's `publish()` applies config to the in-memory runtime and saves the draft to localStorage, then declares itself published — no server round-trip, because the PHASE-9 §5 revision API does not exist yet. Marked precisely in `publish()` with the required behaviour (await the server, only then mark published) and cross-referenced to the contract. Cannot be finished on the frontend alone; the contract for the fix is already written.
|
||||
|
||||
Net: nothing to rip out — the codebase was already at the target state everywhere the backend exists to support it.
|
||||
---
|
||||
|
||||
## Scoreboard
|
||||
|
||||
| Wave | Done | Contract written, awaiting backend | Open | Blocked by |
|
||||
|---|---:|---:|---:|---|
|
||||
| 0 — Decide | 0 | — | 2 | needs a person, not a session |
|
||||
| 1 — Live defects | 2 | — | 2 | FH-1.2 / FH-1.4 sit in files another session owns |
|
||||
| 2 — Contracts | 14 | — | 0 | 1 rejected (FH-2.12) |
|
||||
| 3 — Proof | 3 | 1 | 1 | see note below |
|
||||
| 4 — Identity | 4 | 3 | 1 | OAuth apps, which FH-0.1 gates |
|
||||
| Ops | 1 | — | 2 | — |
|
||||
| Process | 6 | — | 0 | — |
|
||||
| **Total** | **30** | **4** | **8** | 1 rejected |
|
||||
|
||||
**Landed 2026-08-21**
|
||||
|
||||
- **Wave 1** — FH-1.1 (geo off `ip-api.com`, 4 new tests), FH-1.3 (credentials out of the browser, via the `@marketplaces/payment` migration).
|
||||
- **Wave 2** — all 14 remaining contract items written into `docs/backend/`, tagged `FH-*` and dated so each traces back to the analysis. FH-2.12 rejected on the merits: our lifecycle state machine is richer than theirs.
|
||||
- **Wave 3** — FH-3.3 (bundle budget ratcheted to a blocking error at 1.6 MB, measured 1.55 MB), FH-3.5 (`scripts/ci/scan-bundle.sh`, in CI, verified in both directions).
|
||||
- **Wave 4** — FH-4.1 and FH-4.2 complete on the client and in the contract; FH-4.3–4.5 specified and waiting on backend plus registered OAuth applications.
|
||||
- **Process** — FH-E.1–E.4, including [ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md).
|
||||
|
||||
Test count over the session: 247 -> 256 (+4 geo, +5 social identity). Initial bundle 1.55 MB -> 1.04 MB. Build green, boundary and cycle checks green.
|
||||
|
||||
**On FH-3.1 / FH-3.2 — reclassified, not skipped**
|
||||
|
||||
Both are backend races: two transactions competing for the last unit, and the same provider event arriving twice. Playwright against mocked routes cannot prove either — a test that mocks both sides of a race proves only that the mock behaved. `checkout-idempotent-click.spec.ts` already says this in its own header and covers the genuinely frontend-testable half.
|
||||
|
||||
So the acceptance criteria now live where they bind, as normative text in PHASE-3 §3.1 and PHASE-7 §5, and the e2e work they imply is **backend integration testing**, not frontend e2e. What *is* worth doing on our side first: the existing checkout e2e specs have a known-failing session setup (documented in-file, dated 2026-08-21) — a green suite is the prerequisite for anything built on top of it.
|
||||
|
||||
**Next**
|
||||
|
||||
1. **FH-0.1** — the central identity host, and the one-customer-or-two question. One-way door, gates the remaining Wave 4 work, needs a decision from a person.
|
||||
2. **FH-1.2 / FH-1.4** — bank URL validation and `Idempotency-Key`. Both live in `cart.component.ts` / the payment package; pick up once that work settles.
|
||||
3. **FH-E.6** — mock fixtures reach production chunks. The fix is mechanical but touches 21 DI token files plus `app.config.ts`, which another session currently owns — deliberately deferred rather than merged into a busy tree. Plan: move mock selection out of the token factories into one dev-only provider array swapped by `fileReplacements`, the same mechanism `mock-data.interceptor.production.ts` already uses.
|
||||
4. **Fix the e2e session setup**, then revisit what proof is worth adding.
|
||||
58
docs/PACKAGE-EXTRACTION.md
Normal file
58
docs/PACKAGE-EXTRACTION.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# @marketplaces/auth & @marketplaces/payment — build, version, release, infrastructure
|
||||
|
||||
See [ADR-0001](context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md) for why. This doc is the how. For *consuming* the packages (install, DI providers, exported API), see [PACKAGES-USAGE.md](PACKAGES-USAGE.md).
|
||||
|
||||
## Current state
|
||||
|
||||
Working end to end with no credentials. `marketplaces` has no local copy of either package and no `.npmrc` — it installs `@marketplaces/auth` directly over git. A fresh clone plus `npm install` builds and tests green on any machine or CI runner.
|
||||
|
||||
## 1. Source repo
|
||||
|
||||
[sources.vitanova.network/sdarbinyan/vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git) — npm workspaces monorepo, `packages/auth` + `packages/payment`, source on `main`.
|
||||
|
||||
## 2. How releases work
|
||||
|
||||
npm cannot install a subdirectory of a git repo, so each package is published to its own **release branch** where the repo root *is* the package: `release/auth`, `release/payment`. Each contains only `package.json`, the built `dist/`, and a generated README.
|
||||
|
||||
```
|
||||
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth"
|
||||
```
|
||||
|
||||
This was chosen over a registry because it needs **nothing**: no npm registry, no token, no tunnel, no CI secret. Anonymous git read is the only requirement, which is what makes CI and fresh clones work unattended.
|
||||
|
||||
`release/*` branches are generated and force-pushed. Never commit to them by hand.
|
||||
|
||||
## 3. Versioning
|
||||
|
||||
[Changesets](https://github.com/changesets/changesets). A PR that changes a package adds a changeset file (`npx changeset` at the repo root — pick package, bump type, one-line description). `ci.yml` rejects PRs without one.
|
||||
|
||||
## 4. CI/CD (vitanovaPackages)
|
||||
|
||||
- **`ci.yml`** — on PRs and non-main pushes: install, build, test, require a changeset.
|
||||
- **`release.yml`** — on push to `main`, two jobs:
|
||||
- `release-branches` (matrix over `auth`/`payment`): builds each package and force-pushes its output to `release/<pkg>`. Skips cleanly when nothing changed.
|
||||
- `version-pr`: opens/updates a "Version Packages" PR when unreleased changesets exist. Merging it bumps versions on `main`, which re-triggers the release.
|
||||
|
||||
Only the checkout token is needed — no secrets to configure.
|
||||
|
||||
Workflows use GitHub Actions syntax; Gitea/Forgejo Actions are compatible. Other CI needs translating (steps are: install, build, test, force-push a branch).
|
||||
|
||||
## 5. The Verdaccio registry (superseded, still running)
|
||||
|
||||
A private Verdaccio instance runs on the dev server: Docker container `verdaccio`, port 4873, config and storage at `/srv/marketplaces/verdaccio/`, registry user `marketplaces-ci`. It holds `@marketplaces/auth@0.1.0` and `@marketplaces/payment@0.1.0`.
|
||||
|
||||
**Nothing uses it.** It was the original plan, but it listens on `127.0.0.1:4873` and the server firewall allows only 80/443/SSH — so no CI runner and no developer could reach it without an SSH tunnel, which defeats the point. The git-release-branch approach (§2) replaced it.
|
||||
|
||||
Keep it or remove it; no code or workflow depends on it. To reach it manually:
|
||||
|
||||
```bash
|
||||
ssh -L 4873:127.0.0.1:4873 seto@213.21.246.138
|
||||
```
|
||||
|
||||
Making it the primary path again would need a reverse proxy through nginx plus TLS (no certificate exists on that box), or an open port carrying credentials over plain HTTP — neither is done, and neither is necessary now.
|
||||
|
||||
## 6. Migration status
|
||||
|
||||
**Auth: done.** `@marketplaces/auth` holds the real implementation — `telegram/` (live QR/session auth, customer + admin) and `ed25519/` (challenge/response admin auth, backend not shipped). Environment coupling was replaced with `AUTH_API_URL`/`TELEGRAM_BOT_USERNAME` injection tokens; `environment.production` became Angular's `isDevMode()`. `AdminPermissionsService` and `requireAdminPermission` stayed in `marketplaces` (`core/admin-auth/`) — they read this app's mock Users domain, not a portable auth concern. All ~30 call sites import from the package; the old in-app auth files are deleted. Build, boundary checks, and 103/103 tests pass.
|
||||
|
||||
**Payment: not started.** `core/finance`/`core/pricing` still live in `marketplaces`. `@marketplaces/payment` is published as an empty scaffold and is not a dependency of anything.
|
||||
126
docs/PACKAGES-USAGE.md
Normal file
126
docs/PACKAGES-USAGE.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Using `@marketplaces/auth` and `@marketplaces/payment`
|
||||
|
||||
How to install and consume the shared packages in `marketplaces` or any other project. For *why* they exist see [ADR-0001](context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md); for how they are built and released see [PACKAGE-EXTRACTION.md](PACKAGE-EXTRACTION.md).
|
||||
|
||||
## 1. Install
|
||||
|
||||
Nothing to set up. The packages are installed straight over git from release branches in [vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git), where the repo root *is* the package:
|
||||
|
||||
```json
|
||||
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth"
|
||||
```
|
||||
|
||||
That is already in `marketplaces`' `package.json`, so a fresh clone plus `npm install` just works — **no npm registry, no auth token, no SSH tunnel, no CI secret.** Anonymous git read is the only requirement.
|
||||
|
||||
To add it to another project:
|
||||
|
||||
```bash
|
||||
npm install "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth"
|
||||
```
|
||||
|
||||
**On pinning:** a branch ref tracks the tip, so `npm install` can pick up a new build. That is deliberate while the package churns. For reproducible installs, replace `#release/auth` with a commit SHA. See [ADR-0001](context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md) on blast radius.
|
||||
|
||||
## 2. Required providers
|
||||
|
||||
`@marketplaces/auth` has no knowledge of any specific app's environment config. It reads two injection tokens, both provided by the consuming app in `app.config.ts`:
|
||||
|
||||
```ts
|
||||
import { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
{ provide: AUTH_API_URL, useValue: environment.authApiUrl },
|
||||
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
|
||||
// ...
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
| Token | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `AUTH_API_URL` | yes | Base URL of the auth backend, e.g. `https://api.example.com`. Both auth mechanisms build their endpoints from this. |
|
||||
| `TELEGRAM_BOT_USERNAME` | no | Bot username for QR/deep-link login URLs. Falls back to a default if absent. |
|
||||
|
||||
Missing `AUTH_API_URL` produces `NG0201: No provider found for InjectionToken @marketplaces/auth AUTH_API_URL` at the first injection — including in unit tests, where any `TestBed` that constructs a component touching auth must provide it:
|
||||
|
||||
```ts
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: AUTH_API_URL, useValue: 'https://test.local' }],
|
||||
});
|
||||
```
|
||||
|
||||
## 3. What is in the package
|
||||
|
||||
Two independent auth mechanisms. They deliberately share no state — a customer QR scan never authenticates an admin session or vice versa (distinct cookies, signals, guards, interceptors).
|
||||
|
||||
### `telegram/` — live today
|
||||
|
||||
Telegram QR/session auth against `{AUTH_API_URL}/users/sessions`. One backend endpoint set, used by both customer and admin login; only *storage* differs.
|
||||
|
||||
| Export | What it is |
|
||||
|---|---|
|
||||
| `AuthService` | Customer session. Signals: `session`, `status`, `isAuthenticated`, `showLoginDialog`, `displayName`. Methods: `checkSession()`, `createWebSession()`, `requestLogin()`, `hideLogin()`, `logout()`, `onTelegramLoginComplete()`, `getTelegramAppLoginUrl()`. Cookie `webSessionID`, `SameSite=Lax`. |
|
||||
| `AdminAuthService` | Admin session. Same signal/method shape plus `getAdminToken()`/`setAdminTokens()`/`clearAdminTokens()` (reserved for when the backend issues admin JWTs) and `devBypassLogin()` (no-ops outside dev mode). Cookie `adminSessionID`, `SameSite=Strict`. |
|
||||
| `TelegramSessionApiService` | Thin HTTP client + response normalization. Holds no state, writes no cookies. |
|
||||
| `adminAuthGuard` | `CanActivateFn` — allows if the admin session is authenticated, otherwise opens the login dialog. |
|
||||
| `adminAuthHeadersInterceptor` | Attaches `AdminWebSessionID` (and `Authorization: Bearer` when a token exists) to admin-gated paths only (`/admin/`, `/backoffice/`, `/builder/`, `/media/`). Never touches customer requests. |
|
||||
| `AuthSession`, `WebSessionStart`, `AuthStatus`, `AdminAuthStatus` | Wire/state types. |
|
||||
|
||||
Typical usage:
|
||||
|
||||
```ts
|
||||
import { AuthService, AdminAuthService, adminAuthGuard, adminAuthHeadersInterceptor } from '@marketplaces/auth';
|
||||
|
||||
// routes
|
||||
{ path: 'backoffice', canActivate: [adminAuthGuard], loadComponent: ... }
|
||||
|
||||
// http
|
||||
provideHttpClient(withInterceptors([adminAuthHeadersInterceptor, ...]))
|
||||
|
||||
// component
|
||||
private readonly auth = inject(AuthService);
|
||||
readonly isLoggedIn = this.auth.isAuthenticated; // signal
|
||||
```
|
||||
|
||||
**Security note:** the Telegram session API has no concept of "admin." The frontend cannot distinguish an admin Telegram session from a regular one — it only decides *where to store* the result. Real admin authorization must be enforced server-side on every admin request. See [TRACK-S](backend/BACKEND-INTEGRATION.md).
|
||||
|
||||
### `ed25519/` — prepared, backend not shipped
|
||||
|
||||
Challenge/response admin auth: `GET /api/admin/auth/challenge` → sign nonce with a device-local non-extractable Ed25519 key → `POST /api/admin/auth/verify` → JWT pair. Calling these today 404s/connection-errors, which surfaces as the `backend-unavailable` error screen. Nothing is mocked.
|
||||
|
||||
| Export | What it is |
|
||||
|---|---|
|
||||
| `AuthFacade` | The surface components should use. `isAuthenticated`, `status`, `role`, `loginPhase`, `lastError`; `login(redirectTo?)`, `logout(redirectTo?)`, `restoreSession()`, `can(permission)`. |
|
||||
| `Ed25519AuthService` | Low-level flow orchestrator (exported under this name so it doesn't collide with the telegram `AuthService`). |
|
||||
| `SessionService` | JWT/refresh pair + derived claims, auto-refresh before expiry. |
|
||||
| `Ed25519KeypairService` | WebCrypto Ed25519 keypair in IndexedDB. Private key is non-extractable and never leaves the device. |
|
||||
| `PermissionService` | Derives permissions from the JWT `role` claim. UI-only gate. |
|
||||
| `JwtService` | Decode only, never verification — the frontend has no trusted key; signature checking is the backend's job on every request. |
|
||||
| `Ed25519VerificationService` / `NoopEd25519VerificationService` | Abstract seam + fail-closed default binding. |
|
||||
| `AdminRole`, `Permission`, `ROLE_PERMISSIONS`, `AuthChallenge`, `AuthTokenPair`, `JwtClaims`, `AuthError`, `AuthErrorCode`, … | Types and wire contracts. |
|
||||
|
||||
Bind the verification seam in `app.config.ts`:
|
||||
|
||||
```ts
|
||||
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService },
|
||||
```
|
||||
|
||||
## 4. What deliberately stayed in the app
|
||||
|
||||
`AdminPermissionsService` and `requireAdminPermission` live in `marketplaces` (`src/app/core/admin-auth/`), not in the package. They read this app's mock Users domain to derive a permission set — app-specific, not a portable auth concern. If another project needs permission gating it should use the package's `PermissionService` (JWT-claim-driven) instead.
|
||||
|
||||
## 5. `@marketplaces/payment`
|
||||
|
||||
Published at `0.1.0` but **scaffold only** — no implementation yet, nothing exported, and `marketplaces` does not depend on it. `core/finance` and `core/pricing` still live in the app. Payment business logic is server-side by design (see [Phase 1](backend/BACKEND-INTEGRATION.md) and [Phase 7](backend/BACKEND-INTEGRATION.md)); the eventual package is a thin client for FX/pricing/checkout gateways.
|
||||
|
||||
## 6. Making a change to a package
|
||||
|
||||
1. Clone [vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git).
|
||||
2. Edit under `packages/auth/src` (or `packages/payment/src`), export from `index.ts`.
|
||||
3. `npx changeset` at the repo root — pick the package and bump type, write one line about the change.
|
||||
4. Commit, push, open a PR to `main`. CI builds, tests, and rejects the PR if the changeset is missing.
|
||||
5. On merge, CI rebuilds and force-pushes `release/auth` / `release/payment`, and opens a "Version Packages" PR if there are unreleased changesets.
|
||||
6. In `marketplaces`, run `npm update @marketplaces/auth`, then the build + test suite before merging.
|
||||
|
||||
Never commit to a `release/*` branch — they are generated and force-pushed. Never edit `node_modules/@marketplaces/*` — overwritten on every install.
|
||||
479
docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md
Normal file
479
docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md
Normal file
@@ -0,0 +1,479 @@
|
||||
# Product Plan v3.1 — Delivery Plan (Phases → Sprints → Todos)
|
||||
|
||||
Companion to [PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md](PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md). Every gap identified there is assigned here exactly once. Wire contracts for every `[BE]`/`[BOTH]` phase and track below are written up in [docs/backend/](backend/README.md) — hand that directory to whoever builds the backend.
|
||||
|
||||
**No calendar dates.** The plan itself (§12) refuses invented dates and fixes *sequence + exit criteria* instead. This document does the same. Sprints are ordered units of work, not two-week promises. Sizes are relative: **S** / **M** / **L** / **XL**.
|
||||
|
||||
**Ownership tags:** `[FE]` this repo · `[BE]` backend/platform service · `[BOTH]` coordinated contract change · `[DEC]` decision, no code.
|
||||
|
||||
**Deviation from the plan's own order, and why:** the plan sequences P0-C (external ingestion) before P0-D (catalog integrity). We swap them. External order ingestion maps `externalSKU → internal offer` (§5.1), and `Offer` does not exist yet — ingestion has nothing to map onto until the Product/Offer split ships. Everything else follows the plan's ordering.
|
||||
|
||||
---
|
||||
|
||||
## Phase map
|
||||
|
||||
| Phase | Name | Plan ref | Gate |
|
||||
|---|---|---|---|
|
||||
| **0** | Unblock & seams | — | Decisions answered; every admin domain swappable |
|
||||
| **1** | Money & payment truth | P0-A, §2.3 §3.3 §3.8 §7 | An order total is explainable from data |
|
||||
| **2** | Orders canonical + notifications | P0-B, §2.8 §2.10 §3.5 | Paid order appears and notifies without refresh |
|
||||
| **3** | Catalog integrity + fulfillment | P0-D, §2.1 §2.4 §3.6 | Any published offer is genuinely buyable and fulfillable |
|
||||
| **4** | External order ingestion | P0-C, §5 §3.7 | External purchase lands in Orders, no duplicates |
|
||||
| **🚦** | **PRODUCTION LAUNCH GATE** | §3 LAUNCH BLOCKERS, §13.2 | All P0 closed and evidenced |
|
||||
| **5** | Seller Portal | P1-A, §2.2 | Seller runs own offers and orders in scoped UI |
|
||||
| **6** | Server cart + checkout session | P1-B, §2.5 §2.6 | Client price never trusted; repeat-safe |
|
||||
| **7** | Payments hardening + reconciliation | P1-C, §2.7 §7.3 | Internal vs provider matched, mismatches visible |
|
||||
| **8** | Identity & messaging | §2.9 §3.4 §14 | VK/MAX/Telegram linked; bot collects delivery |
|
||||
| **9** | Tenant registry, domains, releases | P2-A, §4.3 §8 | New marketplace launched with no hardcode |
|
||||
| **10** | Tenant content modules (Gorbushka) | P2-B, §11 | Content tenant on same runtime/backoffice |
|
||||
|
||||
**Parallel tracks** (start early, run across phases): **A** Analytics pipeline · **S** Security/RBAC/audit · **P** Partner provisioning API (P1–P3 gate Phase 1) · **Q** QA & E2E · **N** API namespace migration · **Z** Pre-existing repo debt.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Unblock & seams
|
||||
|
||||
Nothing downstream can be honestly estimated until this closes. Two sprints: one is other people answering questions, one is work we can do today with no answers.
|
||||
|
||||
### Sprint 0.1 — Decisions `[DEC]`
|
||||
|
||||
**Answered 2026-08-17.** Kept as a record — the reasoning behind each answer still governs how later phases get built.
|
||||
|
||||
- [x] **Backend ownership — answered 2026-08-18.** A separate backend developer implements against `docs/backend/`. This repository's team owns the frontend and the contract set itself, which is why the contracts are the primary handoff artifact rather than a side deliverable.
|
||||
- [x] **Unfreeze the payment chain — YES.** `BACKEND-API-REFERENCE.md §7`'s do-not-modify note no longer applies. Phases 1, 6, 7 are unblocked to proceed.
|
||||
- [x] **External marketplaces — no fixed list.** User: connectors must onboard "our new ones, partners, new, etc." as they arrive — i.e. the platform's own future partner integrations, not a fixed enumeration of named third-party marketplaces to build against up front. **Consequence for Phase 4:** build the Sprint 4.1 connector framework generic/config-driven (auth, mapping, retry, dead-letter as pluggable per-connector config) so a new partner is an onboarding, not a code change. Sprint 4.2 ("one sprint per named marketplace") is retired as written — replaced by a generic "add connector" runbook, sized once the framework exists, not per-name up front.
|
||||
- [x] **FX rate source — build our own, as a safety gate.** User: "not yet, lets handle from our side, if they dont" — no external provider is committed yet. Backend owns FX computation in-house as the authoritative source; the `source` field in the Phase 1 contract stays provider-agnostic and can point at an internal computed rate as legitimately as an external adapter. This *is* the "configured fallback" the contract doc's §3.2 already describes — now the default, not the fallback.
|
||||
- [x] **§14 vs. email/phone OTP — VK ID first, then everything else.** User: "do all after vk." Delivery-plan Phase 8 sprint order changes: 8.3 (VK ID) now precedes 8.2 (OTP) — see Phase 8 below.
|
||||
- [x] **Multi-seller orders — unified**, judgment call as instructed. One `Order` per checkout regardless of seller count, split into per-seller `Fulfillment` groups internally (matches §2.8's "canonical Order regardless of source" and §2.5's cart-level seller-grouping requirement without introducing parallel parent orders). Applies to Phase 3's `Offer` model, Phase 5's Seller Portal order view (scoped to that seller's fulfillment groups within the shared order), and closes the three-document disagreement flagged in Z16.
|
||||
- [x] **"Fixed 5-second payment" claim — resolved as a non-issue.** User: "make polling 5 secs." Checked `config/constants.ts`: `PAYMENT_POLL_INTERVAL_MS` is already `5000`. This is a poll *cadence* against real provider status each tick, not an artificial fixed-delay-then-success — stays compliant with the plan's §3.2 prohibition. No code change needed; confirmed and left as-is.
|
||||
- [x] **API namespace migration — adopt for new endpoints only, no forced migration.** User: unclear on the question, deferred to "what's recommended," noted "APIs are our domains" (i.e. we control the surface, lower urgency to force a big-bang rename). Recommendation taken: `backend/BACKEND-INTEGRATION.md` already specifies all-new endpoints under the `/api/v2/...` namespace family. Legacy endpoints (`/cart`, `/orders`, `/items`, etc.) stay as-is until a dedicated migration sprint is scheduled — not blocking Phase 1.
|
||||
- [x] **Document version — v3.1 is canonical.** The source file's internal "3.0" version block is stale/wrong; all our docs treat v3.1 as authoritative going forward.
|
||||
|
||||
**Exit:** all nine answered in writing.
|
||||
|
||||
### Sprint 0.2 — Seams and type reconciliation `[FE]` — runs regardless of answers
|
||||
|
||||
- [ ] Add DI tokens to the 9 admin domains that have none: Orders, Products, Users, Transactions, Monitoring, Moderation (+ derived Customers, Analytics). **M** — hard prerequisite for every `[BE]` swap in Phases 1–7.
|
||||
- [ ] Reconcile `AdminRole` — defined twice with unrelated shapes (auth string-union vs. Users-page display interface). **S**
|
||||
- [ ] Reconcile the two `Category` types, both fed by the same `/category` response, both in use. **S**
|
||||
- [ ] Resolve `SellerConfig` (bootstrap) vs. `Seller`/`SellerBranding` (domain) — pick one or document the mapping. Blocks Phase 5. **S**
|
||||
- [ ] Build the feature-flag / capability-guard service an existing ADR already promises; migrate the hand-rolled `sellerManagement.enabled` check onto it. **S**
|
||||
- [ ] Build the centralized error-handling layer (`core/error-handling/`, `core/interceptors/` are `.gitkeep`-only today): error-envelope interceptor + 429 handling. **M** `[BOTH]` — envelope shape needs backend agreement.
|
||||
- [ ] Fix `toAuthErrorShape()` to read a body-level code, not HTTP status alone — the built "session expired" / "invalid signature" screens are currently dead UI. **S**
|
||||
- [ ] Bind mock implementations to `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY`, or delete the dead mock branch. Today both silently ignore `useMockData`. **S**
|
||||
|
||||
**Exit:** any admin domain can be pointed at a real backend by swapping one provider.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Money & payment truth (P0-A)
|
||||
|
||||
Closes §3.3 and §3.8, and half of the §13.1 acceptance table. The single highest-value phase: it is what makes totals explainable to a bank.
|
||||
|
||||
### Sprint 1.1 — Money model `[BOTH]`
|
||||
|
||||
- [ ] `Money = { amountMinor: int, currency }` end to end. Kill float arithmetic in `CurrencyRatesService.convert()`. **L**
|
||||
- [ ] Currency minor-units + rounding rules table (RUB/USD/EUR/AMD at minimum). **M**
|
||||
- [ ] Delete browser-owned rates: remove `currencyRates.v1` from `localStorage` and the hardcoded `DEFAULT_RATES` fallbacks (`USD: 0.011`, `AMD: 4.3`). **S**
|
||||
- [ ] Remove the admin-typed rate editor from Admin Settings once a real source exists. **S**
|
||||
|
||||
### Sprint 1.2 — FX quote + rate source `[BE]` + `[FE]`
|
||||
|
||||
- [ ] `FxQuote { base, quote, rate, source, observedAt, expiresAt, quoteId }` entity + endpoint. **M**
|
||||
- [ ] Rate-source adapter behind an interface; concrete provider pluggable (§7.1). **M**
|
||||
- [ ] Stale/outlier quote rules; checkout **blocks** or uses an explicitly configured fallback. **M**
|
||||
- [ ] `PriceBook`: offer base currency + allowed display/checkout currencies per tenant. **M**
|
||||
|
||||
### Sprint 1.3 — Price snapshot + server-authoritative amount `[BOTH]` — needs the freeze lifted
|
||||
|
||||
- [ ] `PriceSnapshot { offerId, amount, currency, fxQuoteId, capturedAt }`, immutable. **L**
|
||||
- [ ] Server computes and validates the charged amount. Stop trusting `CartPaymentRequest.amount` and the per-item `price[]` array from the browser. **L** — the plan's §2.5 headline requirement.
|
||||
- [ ] Old orders never recalculated when a rate updates. **S**
|
||||
- [ ] Backoffice "total formula" panel: lines × qty − discounts + delivery + fees, plus the FX quote used (§7.2). **M**
|
||||
- [ ] `PriceHistory` on offer price and stock, with author/source (§2.1). **M**
|
||||
|
||||
### Sprint 1.4 — Payment timeline `[BE]` + `[FE]`
|
||||
|
||||
- [ ] Explicit state machines: `PaymentIntent` (created→pending→authorized/paid→failed/cancelled), `Payment` (received→confirmed→captured/settled→refunded), `Order` (pending_payment→paid→processing→fulfilled). **L**
|
||||
- [ ] Persist `provider event id`, `provider timestamp`, `receivedAt`, `processedAt` per transition. **M**
|
||||
- [ ] Webhook entrypoint with signature verification + idempotency (§2.7). **L**
|
||||
- [ ] Idempotency keys on checkout, payment and order creation. Zero `idempot*` exists today. **M**
|
||||
- [ ] Replace client-polled status signals with server truth; keep polling only as a UI fallback. **M**
|
||||
- [ ] Keep the current honest behaviour: no artificial delay. Already compliant — protect it with a test. **S**
|
||||
|
||||
**Exit criteria (plan's own):** currency converts correctly; payment timeline reconstructable from provider events; every total explainable from `SKU/qty/delivery/discount/FX`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Orders canonical + notifications (P0-B)
|
||||
|
||||
### Sprint 2.1 — Canonical order model `[BOTH]`
|
||||
|
||||
- [ ] `Order` header: `marketplaceId, source, customer, currency, subtotal, discounts, delivery, total, paymentStatus, orderStatus`. **L**
|
||||
- [ ] `OrderLine` with `offerId, sellerId, skuSnapshot, titleSnapshot, qty, unitPriceMinor, lineTotalMinor, priceSnapshotId`. **M**
|
||||
- [ ] `OrderEvent` timeline: created, paid, seller notified, accepted, fulfilled, cancelled, refunded (§2.8). Closes our own "Real order audit trail" TODO. **M**
|
||||
- [ ] Real `AdminOrdersApiGateway` replacing the 24-row static seed with no create path. **L** `[BE]`
|
||||
- [ ] Admin order actions: assign, resend notification, replay sync, cancel/refund by permission, comment, export. **M**
|
||||
- [ ] `OrderContactSnapshot` — name/contacts frozen at order time, immune to later profile edits (§2.9). **S**
|
||||
|
||||
### Sprint 2.2 — Event bus + Notification Center `[BE]` + `[FE]`
|
||||
|
||||
- [ ] Platform event bus emitting `order.created`, `order.paid`, `payment.failed`, `webhook.error`, `stock.low`, `oversell`, `refund.requested/completed`, `external_order.imported`. **L**
|
||||
- [ ] `Notification` entity: `unread/read`, `severity`, `marketplaceId`, entity type/id, **deep link**. **M**
|
||||
- [ ] `DeliveryAttempt` log per external channel — a Telegram/email failure must never lose the internal notification (§2.10). **M**
|
||||
- [ ] Backoffice Notifications section: unread queue, incidents, filter by marketplace and event type. Missing entirely from our nav today. **M**
|
||||
- [ ] Repoint `AdminOrderWatcherService` from polling to the event stream. Feature is already built and inert — this is what switches it on. **S**
|
||||
|
||||
**Exit:** a paid order appears in backoffice without manual refresh, with deep link and seller/source.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Catalog integrity + fulfillment (P0-D)
|
||||
|
||||
Biggest structural change in the whole programme. Everything about multi-seller commerce hangs off it.
|
||||
|
||||
### Sprint 3.1 — Product / Offer split `[BOTH]`
|
||||
|
||||
- [ ] Introduce `Offer/Listing { id, marketplaceId, sellerId, variantId, sellerSku, priceMinor, currency, stockPolicy, status, publishedAt }`. **XL** — does not exist in any form today.
|
||||
- [ ] Move price, stock, currency and status off `Product` onto `Offer`. **L**
|
||||
- [ ] Formalise `Product` / `Variant` / `SKU` / `Category` (with `attributesSchema`, SEO) as content-only. **L**
|
||||
- [ ] Unify the admin mock product domain with the live storefront `Item` domain — two unrelated shapes today. **L**
|
||||
- [ ] Offer lookup in backoffice by internal SKU, seller SKU, product ID or external mapping (§2.1 "готово, когда"). **M**
|
||||
|
||||
### Sprint 3.2 — Lifecycle, import, inventory `[BOTH]`
|
||||
|
||||
- [ ] `draft → moderation → published → paused/archived` for both product and offer; wire the existing mock Moderation module to it. **M**
|
||||
- [ ] Bulk import CSV/API: required-field validation, **error preview before apply**. Nothing exists (current "bulk" is Admin Categories edit actions only). **L**
|
||||
- [ ] `InventoryRecord`: `available` / `reserved` / `sold` counted separately. **L**
|
||||
- [ ] Reservations at checkout or pre-payment per strategy, with TTL. **M**
|
||||
- [ ] Idempotent upsert for seller feed stock updates; repeat webhook must not double-decrement. **M**
|
||||
- [ ] Oversell → dedicated incident queue, never silently hidden (§2.4). **M**
|
||||
|
||||
### Sprint 3.3 — Fulfillment + executability `[BOTH]`
|
||||
|
||||
- [ ] `Fulfillment` entity: manual / warehouse / pickup / digital; `status, assignedTo, issuedAt/shippedAt`, evidence where applicable. One `fulfil*` reference exists in the entire codebase today. **L**
|
||||
- [ ] Publish-time executability validation — an offer that cannot actually be fulfilled cannot be published (§3.6). **M**
|
||||
- [ ] Explicit test proving there is **no** inspector-detection branch anywhere: same production flow for every buyer (§3.6, §10.2, §13.2 last item). **S**
|
||||
- [ ] Multi-seller cart grouping by seller and fulfillment rules — currently undefined behaviour (§2.5). **M** — **Sprint 0.1 decision (2026-08-17): unified.** One `Order` per checkout regardless of seller count; group lines into per-seller `Fulfillment` entries internally, no parallel parent orders.
|
||||
|
||||
**Exit:** any published, available offer really passes order → fulfillment.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — External order ingestion (P0-C)
|
||||
|
||||
Zero percent built today. **Sprint 0.1 decision (2026-08-17): no fixed marketplace list** — connectors onboard "our new ones, partners, new, etc." as they arrive, not a pre-named enumeration. Sprint 4.2 is retired as originally written ("one sprint per named marketplace") and replaced with a generic onboarding runbook — Sprint 4.1's framework is now the deliverable that matters, sized to be genuinely config-driven rather than one-off per provider.
|
||||
|
||||
### Sprint 4.1 — Connector framework `[BE]`
|
||||
|
||||
- [ ] `Connector` + `ConnectorCredentialRef` in secret storage, scoped per marketplace/seller. **M**
|
||||
- [ ] Inbound: webhook where the provider supports it, polling fallback with cursor/since. **L**
|
||||
- [ ] `RawExternalEvent` — persist the raw payload before parsing, for traceability. **S**
|
||||
- [ ] Normalizer: external payload → canonical `ExternalOrderEvent` → internal `Order`. **L**
|
||||
- [ ] `ExternalOrderMapping`: `externalSellerId / externalProductId / externalSKU → internal seller/offer`. **L**
|
||||
- [ ] Idempotency on `source + externalOrderId/eventId`; a repeat must not create a duplicate order. **M**
|
||||
- [ ] Exponential retry, `DeadLetter`, manual replay from backoffice. **M**
|
||||
- [ ] **Unmatched queue** for events with no SKU mapping. **M**
|
||||
- [ ] Status/fulfillment push back to the external marketplace where its API allows (§5.2 step 8). **M**
|
||||
- [ ] **Config-driven adapter contract** — a new partner connector is authored as configuration (auth type, field mapping, rate limits) against the Sprint 4.1 framework, not a bespoke integration each time. **L** — this is what "no fixed list" requires structurally.
|
||||
|
||||
### Sprint 4.2 — Connector onboarding runbook `[BE]` — repeats per new partner, no longer named up front
|
||||
|
||||
- [ ] Generic onboarding checklist against the Sprint 4.1 framework: auth, endpoint mapping, rate limits, sandbox verification. **M each**, sized down from **L** now that the framework absorbs the bespoke work.
|
||||
|
||||
### Sprint 4.3 — Connector observability `[FE]` + `[BE]`
|
||||
|
||||
- [ ] Backoffice **Integrations** section (missing from our nav): connectors, payment providers, FX sources, messaging. **M**
|
||||
- [ ] Per-connector health: last success, lag, errors, rate limit, backlog, unmatched mapping. **M**
|
||||
- [ ] Trace id on every connector error, visible in backoffice (§5.2 SLA). **S**
|
||||
- [ ] SLA instrumentation: webhook 99% under 60s; polling ≤ interval + 60s; **0** duplicate orders. **M**
|
||||
|
||||
**Exit:** an external purchase creates/updates an order automatically, never duplicates, and notifies the responsible manager.
|
||||
|
||||
---
|
||||
|
||||
## 🚦 PRODUCTION LAUNCH GATE
|
||||
|
||||
Per §3 "LAUNCH BLOCKERS" and the §13.2 checklist. Do not schedule a launch before every line is green **and evidenced by a test, not an assertion**.
|
||||
|
||||
- [ ] All P0 closed and confirmed by tests
|
||||
- [ ] Production analytics collecting real events (Track A)
|
||||
- [ ] Catalog contains only genuinely available/publishable offers
|
||||
- [ ] Seller permissions verified (Phase 5 or enforced-empty)
|
||||
- [ ] cart → checkout → payment → order end-to-end smoke passed
|
||||
- [ ] Webhook signatures, idempotency, retry verified
|
||||
- [ ] External connector reconciliation passed
|
||||
- [ ] FX source live, stale-quote policy verified
|
||||
- [ ] Notification delivery + fallback verified
|
||||
- [ ] Refund flow + reconciliation smoke passed
|
||||
- [ ] Domains/SSL/health checks green (Phase 9)
|
||||
- [ ] Backup/rollback exists
|
||||
- [ ] Audit enabled (Track S)
|
||||
- [ ] **No branch anywhere alters commerce flow based on who the buyer appears to be**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Seller Portal (P1-A)
|
||||
|
||||
A placeholder page with a `false` flag and zero backend bytes today. Note: the enabled code path has **never been exercised even once** — every prior verification ran with the flag at its real value.
|
||||
|
||||
### Sprint 5.1 — Seller foundation `[BOTH]`
|
||||
- [ ] `SellerOrganization`, `SellerUser`, `SellerMarketplaceMembership`, `SellerIntegration`. **L**
|
||||
- [ ] Onboarding: organisation, credentials/profile, contacts, marketplace applications, moderation status. **L**
|
||||
- [ ] Backoffice **Sellers** section (missing from nav): organisations, applications, roles, status, listings, integration health. **L**
|
||||
|
||||
### Sprint 5.2 — Seller working surfaces `[FE]` + `[BE]`
|
||||
- [ ] Catalog: create/edit products & offers, media, attributes, submit for moderation, bulk import. **L**
|
||||
- [ ] Prices & Stock: mass edit, API/feed sync, change history, sync errors. **L**
|
||||
- [ ] Orders: new, confirm, pick/issue/ship, cancel, return, SLA, comments. Per the unified-orders decision (Sprint 0.1), this view is scoped to *this seller's* `Fulfillment` group within each shared `Order`, not a separate seller-owned order. **L**
|
||||
- [ ] Finance: accruals, commissions, refunds, settlement/payout register, report export. **L**
|
||||
- [ ] Team: `SELLER_OWNER`, `SELLER_CATALOG_MANAGER`, `SELLER_ORDER_MANAGER`, `SELLER_FINANCE_VIEWER`, `SELLER_VIEWER`. **M**
|
||||
- [ ] Integrations: API credentials, webhook/feed status, external SKU mapping, sync logs. **M**
|
||||
|
||||
### Sprint 5.3 — Seller isolation `[BE]` + `[Q]`
|
||||
- [ ] A seller cannot see another seller's products, orders, customers, finance or API keys — enforced backend-side, tested. **M**
|
||||
- [ ] Bank/payment detail changes: step-up auth + audit event + approval when maker/checker is on. **M**
|
||||
- [ ] Seller staff permissions verified backend-side regardless of UI visibility. **M**
|
||||
- [ ] First-ever fixture test of the seller-management enabled state. **S**
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Server cart + checkout session (P1-B)
|
||||
|
||||
Partly pulled forward into Sprint 1.3 (server-authoritative amount). This phase completes the move.
|
||||
|
||||
### Sprint 6.1 — Server cart `[BOTH]`
|
||||
- [ ] `Cart` / `CartLine` server-side, keyed on `offerId`. Replaces `localStorage` + Telegram CloudStorage. **L**
|
||||
- [ ] Idempotent add/update/remove; quantity validated against stock and seller rules. **M**
|
||||
- [ ] Price-refresh: cart surfaces price changes before checkout and requires explicit confirmation when the total moved. **M**
|
||||
- [ ] Guest cart via session token; authenticated cart bound to customer account. **M**
|
||||
- [ ] Expiration: inactive carts cleared, reservations released on TTL. **S**
|
||||
|
||||
### Sprint 6.2 — Checkout session `[BOTH]`
|
||||
- [ ] `CheckoutSession` entity. `features/website/checkout/` is an empty directory today; checkout lives in a 751-line cart popup. **XL**
|
||||
- [ ] Server re-validates offers and stock at checkout start. **M**
|
||||
- [ ] Contact requirements enforced by tenant policy: email and/or phone verifiable (§2.6 step 4). **M**
|
||||
- [ ] Clear total breakdown shown to the customer. **M**
|
||||
- [ ] `PaymentIntent` via provider adapter; repeat click must not create a second intent. **M**
|
||||
- [ ] Guest-checkout on/off per tenant policy (§6.2). **S**
|
||||
- [ ] `DeliveryOption` entity. **M**
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Payments hardening + reconciliation (P1-C)
|
||||
|
||||
### Sprint 7.1 — Refunds `[BOTH]`
|
||||
- [ ] `Refund` as a first-class operation with reason, actor and order-line linkage. `requestRefund(id)` is a mock method today. **L**
|
||||
- [ ] Partial refunds; `refunded / partially_refunded` states. **M**
|
||||
|
||||
### Sprint 7.2 — Reconciliation `[BE]` + `[FE]`
|
||||
- [ ] `ReconciliationRecord`; match on `providerPaymentId` / merchant reference / amount+currency fallback (§7.3). **L** — zero `reconcil*` in the codebase today.
|
||||
- [ ] Classify: unmatched, duplicate, amount mismatch, status mismatch. **M**
|
||||
- [ ] Backoffice **Payments & Finance** section (missing from nav): payments, refunds, reconciliation queue, unmatched events, settlements. **L**
|
||||
- [ ] Controlled resolution with full audit trail. **M**
|
||||
- [ ] Settlements / payout register. **L** — zero `settlement*` today.
|
||||
|
||||
### Sprint 7.3 — Provider breadth `[DEC]` + `[BOTH]`
|
||||
- [ ] Decide additional providers beyond the current QR/card flow (wallets, BNPL) — open business question. **DEC**
|
||||
- [ ] Provider adapter interface so a new provider is a plug-in, not a rewrite. **M**
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Identity & messaging (§2.9, §3.4, §14)
|
||||
|
||||
**Sprint 0.1 decision (2026-08-17): VK ID first, then everything else** ("do all after vk"). Order below is resequenced accordingly — VK ID moved ahead of OTP.
|
||||
|
||||
### Sprint 8.1 — Customer identity core `[BOTH]`
|
||||
- [ ] `Customer`, `ExternalIdentity`, `ContactMethod`, `Verification`, `Consent`. **L**
|
||||
- [ ] Telegram demoted from sole identity to one provider among several. **M**
|
||||
- [ ] `emailVerifiedAt` / `phoneVerifiedAt` / `telegramLinkedAt`. **S**
|
||||
- [ ] Backoffice **Customers** on real data: profiles, verified contacts, orders, consent. **M**
|
||||
- [ ] Sensitive profile changes logged. **S**
|
||||
|
||||
### Sprint 8.2 — VK ID `[BOTH]` — new in v3.1, now first per Sprint 0.1
|
||||
- [ ] OAuth 2.1/PKCE completed **backend-side**; link external identity to `Customer`. **L**
|
||||
- [ ] VK ID as the primary storefront social login. **M**
|
||||
- [ ] Repeat login must never create a duplicate customer. **M**
|
||||
- [ ] Identity-conflict handling → controlled resolution, never overwrite an existing binding (§14.3). **M**
|
||||
|
||||
### Sprint 8.3 — Email/phone OTP `[BOTH]` — after VK ID
|
||||
- [ ] Implement the approved [email/phone login spec](superpowers/specs/2026-08-15-email-phone-login-design.md). **L**
|
||||
- [ ] Position it as recovery/fallback per v3.1 §14, not as the primary path. **S**
|
||||
|
||||
### Sprint 8.4 — MAX + Telegram bot channels `[BOTH]` — new in v3.1
|
||||
- [ ] `ContactChannel`, `BotConversationBinding`, `MessagingConsent`. **L**
|
||||
- [ ] MAX bot-assisted linking: one-time code, TTL, single-use, bound to marketplace + browser session. **L**
|
||||
- [ ] Provider secrets never reach the frontend; all bot updates handled idempotently. **M**
|
||||
- [ ] Bot adapters (VK / MAX / Telegram) normalised into one `MessagingEvent` keyed to `orderId`. **L**
|
||||
|
||||
### Sprint 8.5 — Notification Orchestrator + delivery conversation `[BE]` — new in v3.1
|
||||
- [ ] Orchestrator routes `order.paid` to the customer's chosen channel; the backoffice notification always fires regardless. **L**
|
||||
- [ ] Channel choice in checkout ("where should we send confirmation?"), recorded in `OrderContactSnapshot`; linking flow must not lose the cart or checkout session. **M**
|
||||
- [ ] Delivery Conversation State Machine: `not_started → awaiting_customer → details_received → manager_assigned/auto_confirmed → shipment_planned → completed`. **L**
|
||||
- [ ] Bot collects city/address/recipient/phone/time window/comment; backend validates and snapshots into the order. **L**
|
||||
- [ ] **The bot must never change financial statuses** — delivery fields only, via Delivery Service. **M**
|
||||
- [ ] Follow-up rules per tenant; after N attempts hand off to a manager, no infinite spam. **M**
|
||||
- [ ] Manager handoff view: message history, current conversation state, accept handoff. **M**
|
||||
- [ ] Messenger unavailability creates a `DeliveryAttempt` error and triggers fallback — never blocks the order. **M**
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Tenant registry, domains, releases (P2-A)
|
||||
|
||||
### Sprint 9.1 — Marketplace Registry `[BOTH]`
|
||||
- [ ] `Marketplace`, `MarketplaceDomain`, `MarketplaceFeatureSet`, `MarketplaceRevision`. **L**
|
||||
- [ ] Backoffice **Marketplaces** section (missing from nav): registry, type, status, domains, currencies, feature set, responsible manager. **L**
|
||||
- [ ] Onboarding wizard, all 8 steps of §4.3 (card → feature set → domains → design → roles → integrations → staging + smoke → production launch). **XL**
|
||||
- [ ] Lifecycle state machine `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, **showing which blocker prevents the next transition**. **L**
|
||||
- [ ] Marketplace dashboard (§4.2): GMV, paid orders, conversion, payment failure rate, orders needing action, seller moderation queue, low stock, unmatched events, integration health, domain/SSL/release status. **L**
|
||||
- [ ] Re-scope the [super-admin Phase 1 design](superpowers/specs/superuser.md) against this — it overlaps registry and audit. **M**
|
||||
- [ ] Consolidate `MarketplaceRef` vs. `TenantConfig` if a third marketplace-shaped type appears. **S**
|
||||
|
||||
### Sprint 9.2 — Domain automation `[BE]`
|
||||
- [ ] Hostinger DNS integration, all 7 endpoints from §8.2. Zero references exist today. **L**
|
||||
- [ ] Read current zone → snapshot/rollback payload → build and validate plan → apply only after production approval. **L**
|
||||
- [ ] **Never touch MX/SPF/DKIM/DMARC/CAA** without a separate task. **S**
|
||||
- [ ] Propagation, SSL and health verification; mark domain active only after checks pass. **M**
|
||||
- [ ] Backoffice **Domains & Releases** section (missing from nav). **M**
|
||||
|
||||
### Sprint 9.3 — Publish model `[BOTH]`
|
||||
- [ ] `draft → validation → preview → publish` with immutable published revisions; rollback creates a new revision (§8.3). **L**
|
||||
- [ ] Real builder persistence — today `apiEndpoints.builder` is an empty placeholder and "publish" only promotes a `localStorage` signal. **L**
|
||||
- [ ] CMS/static pages get a real backend write path (currently in-memory bootstrap only). **L**
|
||||
- [ ] Enforce that orders/payments/inventory ledger are **not** part of a content revision and never roll back with the storefront. **S**
|
||||
- [ ] Tenant resolution hardening: verified Host server-side, unknown Host → 404 with **no fallback tenant** (§6.1). **M**
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Tenant content modules (P2-B, Gorbushka)
|
||||
|
||||
Only after Commerce Core is real. The plan is explicit that Gorbushka does not define the architecture.
|
||||
|
||||
### Sprint 10.1 — Directory content entities `[BOTH]`
|
||||
- [ ] `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `News/Promo`, `StaticPage`, `Lead`, `MallSettings`. Only static pages exist today. **XL**
|
||||
- [ ] Every entity carries `marketplaceId`, audit, and publish/preview flow. **M**
|
||||
- [ ] Mall scheme / floors / pins UI. **L**
|
||||
- [ ] Rent listings + lead capture. **M**
|
||||
|
||||
### Sprint 10.2 — Gorbushka tenant config `[FE]`
|
||||
- [ ] Feature set per §11.1: CMS, shops, services, scheme, rent, news, SEO/media/domains **on**; catalog / seller portal / commerce **platform-ready but off**. **M**
|
||||
- [ ] Prove commerce can be switched on later without touching backend or storefront code. **M**
|
||||
|
||||
---
|
||||
|
||||
## Parallel tracks
|
||||
|
||||
### Track A — Analytics pipeline (P1-D, §3.1 §6.3)
|
||||
|
||||
**Start at Phase 1, not last.** Longest lead time in the programme, and it is a P0 in the plan's own §3. There is no tracking infrastructure at all today — this is not a missing endpoint.
|
||||
|
||||
- [ ] **A1** Server-side event logging spine. **XL** `[BE]`
|
||||
- [ ] **A2** Traffic events: `session_started`, `page_view`, source/utm/referrer, unique users/sessions. **M**
|
||||
- [ ] **A3** Catalog events: `search`, `category_view`, `product_view`, `seller_view`. **M**
|
||||
- [ ] **A4** Commerce events: `add_to_cart`, `cart_view`, `checkout_started`, `payment_started`, `payment_success/failed`, `order_created`. **M**
|
||||
- [ ] **A5** Operations metrics: `order_paid_to_notification` latency, fulfillment time, connector lag, payment webhook lag. **M**
|
||||
- [ ] **A6** Quality metrics: frontend/backend errors, checkout validation failures, FX stale-rate blocks. **M**
|
||||
- [ ] **A7** Real funnel dashboard in backoffice, replacing the mock-composed Analytics facade. **L**
|
||||
- [ ] **A8** **Synthetic traffic technically separated** from production analytics — staging/test only, never presented as real visits (§3.1, §6.3). **M**
|
||||
- [ ] **A9** Real product view counts — the shipped "Views" column always renders `0`. Either bridge to the live storefront `Item.visits` or serve it from the real Products backend. **S**
|
||||
- [ ] **A10** Post-launch monitoring set (§13.3): checkout conversion, payment success/failure, webhook lag, order-notification lag, connector lag, FX quote age, unmatched reconciliation, stuck fulfillment. **L**
|
||||
- [ ] **A11** Trending search terms endpoint — `loadTrending()` is a stub returning `of(null)`. **S**
|
||||
|
||||
### Track S — Security, RBAC, audit (§4.4, §10)
|
||||
|
||||
**Gate on Phase 5 and on the launch gate.** Today the role model is decorative: types exist, nothing gates any button, page or action. Anyone who authenticates has full access.
|
||||
|
||||
- [ ] **S1** Enforce RBAC backend-side with tenant scope on every request. **L**
|
||||
- [ ] **S2** Implement the 17 roles across 3 scopes (5 platform / 7 marketplace / 5 seller). **L**
|
||||
- [ ] **S3** Frontend permission guards on routes and actions — currently zero. **M**
|
||||
- [ ] **S4** Audit log covering permissions, seller changes, catalog moderation, price, payment/refund, manual order actions, integrations, production launch. `audit` appears only as mock display fields today. **L**
|
||||
- [ ] **S5** Backoffice **Audit & Security** section (missing from nav): role changes, sensitive actions, login/security events, exports. **M**
|
||||
- [ ] **S6** Step-up authentication for sensitive financial actions. **M**
|
||||
- [ ] **S7** Rate limits and abuse controls on storefront/auth/provider endpoints; client-side 429 handling (zero today). **M**
|
||||
- [ ] **S8** Secret storage for provider/connector credentials, scoped per marketplace/seller. **M**
|
||||
- [ ] **S9** PII minimisation: store only necessary customer data, restrict access and export. **M**
|
||||
- [ ] **S10** Ed25519 admin auth backend — wired client-side, 404s today. Decide: build it, or drop it for the plan's conventional RBAC. **DEC** + **L**
|
||||
- [ ] **S11** HttpOnly session cookie (existing frontend-blocked TODO). **M**
|
||||
|
||||
### Track P — Partner provisioning API (added 2026-08-18)
|
||||
|
||||
Inbound partner API for programmatic merchant-hierarchy management. Contract: [backend/BACKEND-INTEGRATION.md](backend/BACKEND-INTEGRATION.md). Decision: [ADR-0003](context/adrs/ADR-0003-generic-partner-provisioning-api.md).
|
||||
|
||||
**P1–P3 gate Phase 1.** They change the payments and tenant schemas, so they must land before Phase 1 is implemented — retrofitting a routing dimension onto a populated payments table costs far more than carrying it from the first row. P4 onward can run any time after.
|
||||
|
||||
- [ ] **P1** Add `RoutingContext` to `CheckoutSession`/`PaymentIntent`/`Payment` ([Phase 1 §6.5](backend/BACKEND-INTEGRATION.md)) and to `Refund`/`ReconciliationRecord` ([Phase 7](backend/BACKEND-INTEGRATION.md)). Frozen at checkout-session creation, immutable after. **M**
|
||||
- [ ] **P2** Add `Company` and `Project` above `Marketplace`; `Marketplace` gains `companyId`/`projectId`/`externalReference` ([Phase 9 §1](backend/BACKEND-INTEGRATION.md)). **M**
|
||||
- [ ] **P3** Add `PaymentPoint` (one payment method per marketplace; `qr` and `card` both ship today) and backfill existing marketplaces per [Phase 9 §1.2](backend/BACKEND-INTEGRATION.md). **M**
|
||||
- [ ] **P4** Provisioning endpoints: create/read/status/disable for project, store, payment point, with cascading disable. **L**
|
||||
- [ ] **P5** Idempotency-Key handling: replay on identical body, `409` on same key + different body, in-flight collision, 24h retention. **M**
|
||||
- [ ] **P6** Partner credentials: public-key registration, node-scoped authority, signed-request verification, rotation with overlap, immediate revoke ([Track S §4.1](backend/BACKEND-INTEGRATION.md)). **L**
|
||||
- [ ] **P7** Read surfaces: full-hierarchy fetch, `externalReference` lookup, partner-scoped audit query. **M**
|
||||
- [ ] **P8** `PartnerProfile` config: required levels, level aliases, routing field names, rate tier, rotation window. Onboarding a partner must be a config row, not a deployment. **M**
|
||||
- [ ] **P9** TEST/LIVE partition: disjoint credentials, disjoint ids, `403` on cross-environment access. **M**
|
||||
- [ ] **P10** Partner OpenAPI spec generated from the implementation, plus documented error codes and published rate limits. **M**
|
||||
|
||||
### Track Q — QA & E2E (§13)
|
||||
|
||||
The plan's entire Definition of Done is end-to-end. We have **zero** E2E tests and ~32% statement / ~19% branch coverage across 11 spec files.
|
||||
|
||||
- [ ] **Q1** Stand up an E2E harness (Playwright or equivalent) — none exists. **L**
|
||||
- [ ] **Q2** Solve automated admin login; several past "verified live" claims were code-inspection only because `/edit` and `/backoffice` need Telegram login. **M**
|
||||
- [ ] **Q3** E2E: full §13.1 acceptance path — seller → catalog → storefront → cart → checkout → payment → order → notification → fulfillment. **XL**
|
||||
- [ ] **Q4** E2E: currency switch recalculates by FX quote — explicitly, `160 RUB` must not become `160 USD/AMD`. **M**
|
||||
- [ ] **Q5** E2E: repeat webhook and double-click create exactly one order. **M**
|
||||
- [ ] **Q6** E2E: external marketplace purchase imports and notifies. **M**
|
||||
- [ ] **Q7** Facade tests for cart/checkout, moderation, Orders, Products, Users, Transactions, Monitoring — the domains about to get real backends carry the most regression risk with the least coverage. **L**
|
||||
- [ ] **Q8** Regression pattern for reactive flag/config reads that must track `bootstrapRevision()` — this bug class already bit us once and was invisible until specifically hunted. **S**
|
||||
- [ ] **Q9** Set a justified coverage floor and a CI gate. Deliberately unset today. **M**
|
||||
- [ ] **Q10** One real screen-reader pass (NVDA/VoiceOver). Never performed on this codebase — every accessibility claim to date is automated tree inspection only. **M**
|
||||
|
||||
### Track N — API namespace migration (§9.3)
|
||||
|
||||
Cheapest now, more expensive every phase. Decision in Sprint 0.1.
|
||||
|
||||
- [ ] **N1** Adopt `/api/v2/storefront/*`, `/api/admin/v2/*`, `/api/seller/v1/*`, `/api/identity/v1/*`, `/api/providers/v1/*`, `/api/integrations/v1/*`. **L** `[BOTH]`
|
||||
- [ ] **N2** Migrate today's flat unversioned endpoints (`/cart`, `/orders`, `/items`, `/category`, `/searchitems`) plus the separate `qrApiUrl` host. **L**
|
||||
- [ ] **N3** Agree the structured error envelope; today no interceptor reads error bodies at all. **M** (implementation lands in Sprint 0.2)
|
||||
|
||||
### Track Z — Pre-existing repo debt
|
||||
|
||||
Not in the plan, but real. Fold into whichever phase touches the same surface.
|
||||
|
||||
- [ ] **Z1** Dark-mode selector does nothing — nothing reads `data-theme-mode`. **S**
|
||||
- [ ] **Z2** "Site Layout" selector has no effect — `layout.type` is edited but never read. **S**
|
||||
- [ ] **Z3** Footer "Contacts" link has no content behind it. **S**
|
||||
- [ ] **Z4** `SeoService.setItemMeta()` exists but is **never called** — product pages ship only site-wide meta. **S**
|
||||
- [ ] **Z5** `og:locale` hardcoded to `ru_RU` regardless of active locale. **S**
|
||||
- [ ] **Z6** No JSON-LD structured data, no sitemap generation. **M**
|
||||
- [ ] **Z7** Hardcoded Russian payment-description fallback (`'Покупка на Маркетплейсе'`) in a multi-tenant product. **S**
|
||||
- [ ] **Z8** Brand colours fail WCAG AA — `--border-color` at 1.24–1.42:1 against a 3:1 requirement; status colours fail 4.5:1 as text. **Needs theme-owner sign-off, not just a code fix.** **M**
|
||||
- [ ] **Z9** Literal hex `#cdd6d5` in `stars.component.scss:10` with no token behind it. **S**
|
||||
- [ ] **Z10** Two large lazy chunks unaddressed: `project-editor` (~1.0 MB), `catalog-container` (~330–375 kB). Profile under real backend latency, not instant mock responses. **M**
|
||||
- [ ] **Z11** `navigation.header` is editable in the builder with zero runtime consumer — needs a product decision, not a wiring fix. **DEC**
|
||||
- [ ] **Z12** `catalog.navigationMode` renders a deliberate placeholder; the mega-menu / carousel / left-nav variants it implies do not exist. **DEC**
|
||||
- [ ] **Z13** `sellerId` typed as bare `string` instead of the `UUID` alias used elsewhere. **S**
|
||||
- [ ] **Z14** No shared breadcrumb component; the only breadcrumb logic is a local signal in the catalog container. **S**
|
||||
- [ ] **Z15** Duplicate search models under two module paths. **S**
|
||||
- [ ] **Z16** Consolidate the eight cross-linked Seller Management documents onto the now-resolved decision (unified orders, Sprint 0.1, 2026-08-17) — at least three independently restated the question before it was answered. Do this **before** Phase 5 starts. **M**
|
||||
- [ ] **Z17** Angular 22 upgrade — researched, not started; needs a dependency fix and a Node bump. **Its own dedicated session, never bundled with feature work.** **M**
|
||||
|
||||
---
|
||||
|
||||
## Critical path
|
||||
|
||||
```
|
||||
Sprint 0.1 (decisions)
|
||||
└─> Sprint 0.2 (seams)
|
||||
└─> Phase 1 (money truth) ──────────────┐
|
||||
└─> Phase 2 (orders + notif) │
|
||||
└─> Phase 3 (offer split) │
|
||||
└─> Phase 4 (external ingestion)
|
||||
└─> 🚦 LAUNCH GATE
|
||||
Track A (analytics) ── starts at Phase 1, gates the launch ──┘
|
||||
Track S (RBAC/audit) ── starts at Phase 2, gates the launch ──┘
|
||||
Track Q (E2E) ── starts at Phase 1, evidences the gate ┘
|
||||
```
|
||||
|
||||
Phases 5–10 all sit behind the launch gate and can be resequenced by business priority. Phases 1–4 cannot.
|
||||
|
||||
**Single hardest dependency:** Phase 1 Sprint 1.3 needs the payment chain unfrozen. If that answer is "no", the programme stops at Sprint 0.2 and the plan's P0s cannot be delivered — that outcome should go back to them in writing, not be worked around.
|
||||
279
docs/PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md
Normal file
279
docs/PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Product Plan v3.1 — What They Want vs. What We Have
|
||||
|
||||
**Source:** `Marketplaces-Platform-Product-Plan-v3.1.pdf` (27 pages, RU). Version block inside still reads `3.0 / 17 августа 2026` — the filename says v3.1. Section 14 is the v3.1 addition (appended after the document's own conclusion).
|
||||
|
||||
**Our side, as verified in this repo:** Angular frontend only (426 `.ts` files). Sources for "what we have": [BACKEND-API-REFERENCE.md](../BACKEND-API-REFERENCE.md), [GAPS-AND-IMPROVEMENTS.md](../GAPS-AND-IMPROVEMENTS.md), and direct source inspection.
|
||||
|
||||
---
|
||||
|
||||
## 1. What they are actually asking for
|
||||
|
||||
One sentence: **stop building storefronts, build a platform** — a single multi-tenant commerce core where launching a new marketplace is a configuration act, not an engineering project.
|
||||
|
||||
Their own acceptance bar (§"ГЛАВНЫЙ КРИТЕРИЙ" and §13):
|
||||
|
||||
> A real product walks the whole path: seller → catalog → storefront → cart → checkout → payment → order → notification → fulfillment → reconciliation.
|
||||
|
||||
Three things the document is really about, under the product language:
|
||||
|
||||
1. **They do not trust our numbers.** Traffic counters, payment timings, order totals and currency amounts are all called out as unexplainable. §10.2 says it outright: don't fix appearance, fix the data.
|
||||
2. **They suspect demo behaviour in production.** "No fixed 5-second payment", "no synthetic traffic in production analytics", "no special branch for banks/inspectors" (§3.2, §3.1, §3.6, §10.2, and again in the launch checklist). This is an audit/compliance posture, not a feature request — a bank or NSPK is checking this platform.
|
||||
3. **Commerce Core is no longer optional.** In v3.0 language, Catalog/Seller Portal/Cart/Checkout/Payments/Orders stopped being "a possible extension" and became mandatory platform modules. Gorbushka is demoted to "one tenant scenario" (§11) — it does not define the architecture.
|
||||
|
||||
**Launch blockers they define (§3, "LAUNCH BLOCKERS"):** all P0s — money/FX, payment timeline, notifications, external order ingestion, price traceability, guaranteed fulfillability of published offers.
|
||||
|
||||
---
|
||||
|
||||
## 2. What is new in v3.1 vs v3.0
|
||||
|
||||
Everything in **§14 "Customer Identity и коммуникация после покупки"** (pages 26–27). Nothing else in the document is marked as changed.
|
||||
|
||||
| New in v3.1 | Detail | Our state |
|
||||
|---|---|---|
|
||||
| **VK ID as primary social login** | Backend completes OAuth 2.1/PKCE, links external identity to `Customer` | Zero. No `vk` reference anywhere in source; one `oauth` reference total. |
|
||||
| **MAX messenger bot** | Bot-assisted account linking via one-time code; official MAX Bot API | Zero. |
|
||||
| **Telegram demoted** | Kept, but as *one* identity provider among several | Today Telegram is the **only** login for both customers and admins. |
|
||||
| **Notification Orchestrator** | Routes `order.paid` to the customer's chosen channel; backoffice notification always fires even if the messenger is down | Zero. |
|
||||
| **Delivery Conversation State Machine** | `not_started → awaiting_customer → details_received → manager_assigned/auto_confirmed → shipment_planned → completed`, bot collects delivery details, manager handoff | Zero. |
|
||||
| **Channel choice in checkout** | "Where should we send confirmation?" — VK / MAX / Telegram / email-SMS fallback, recorded in `OrderContactSnapshot` | Zero. |
|
||||
| **`ExternalIdentity` / `ContactChannel` / `BotConversationBinding` / `MessagingConsent`** | Four new entities | Zero. |
|
||||
|
||||
**Manager note:** §14 partially collides with our approved [email/phone OTP login spec](superpowers/specs/2026-08-15-email-phone-login-design.md). v3.1 keeps email/phone but reduces them to *recovery/fallback* when a messenger is unavailable. Our in-flight work is still valid, but its priority drops below VK ID. Needs a call before that spec is implemented.
|
||||
|
||||
---
|
||||
|
||||
## 3. The differences — detailed
|
||||
|
||||
Legend: ✅ have · 🟡 partial / mock only · ❌ missing · ⚠️ conflicts with something we already decided.
|
||||
|
||||
### 3.1 Platform components (§1.1) — 8 named components, we have 2
|
||||
|
||||
| Plan component | Our state |
|
||||
|---|---|
|
||||
| Storefront Runtime | ✅ Bootstrap-driven, tenant-configured, no per-project fork. This is our strongest match to the plan. |
|
||||
| Platform Backoffice | 🟡 14 admin modules exist, but only **Categories** has a real HTTP backend. 9 of 11 admin domains inject their mock gateway directly — no DI seam to swap at all. |
|
||||
| Platform API | 🟡 Storefront catalog/search/cart-payment are live; everything admin-side is mock. |
|
||||
| Seller Portal | ❌ A static placeholder page, feature flag `false` by default, zero backend bytes, zero `HttpClient` reference. |
|
||||
| Workers / Event Processing | ❌ Nothing. No event bus, no retry, no dead-letter. |
|
||||
| Integration Hub | ❌ Nothing. Zero `reconcil*`, zero `idempot*` in the whole codebase. |
|
||||
| Domain Automation | ❌ Nothing. Zero `hostinger` references — the plan's §8.2 lists seven Hostinger DNS endpoints we have never touched. |
|
||||
| Marketplace Registry / Launch Center | ❌ Nothing shipped. Closest thing is our unshipped [super-admin Phase 1 design](superpowers/specs/superuser.md), which covers cross-tenant *viewing* but not registry/feature-set/launch. |
|
||||
|
||||
### 3.2 Catalog model (§2.1) — the biggest structural gap
|
||||
|
||||
The plan's core catalog idea is a **two-layer split**: `Product` (content card) vs. `Offer/Listing` (the seller's commercial proposition, which owns price, stock, currency, status). Order lines then snapshot the offer.
|
||||
|
||||
| Plan entity | Our state |
|
||||
|---|---|
|
||||
| `Product` / `Variant` / `SKU` | 🟡 Exists as admin mock + a separate live storefront `Item` domain. Two unrelated `Category` types, both fed by the same response, both in use. |
|
||||
| `Offer / Listing` | ❌ Does not exist. Price and stock hang off the product. Multi-seller pricing on one product card is not expressible. |
|
||||
| `PriceSnapshot` | ❌ Does not exist. |
|
||||
| `InventoryRecord` (available/reserved/sold) | ❌ Does not exist. No reservations, no TTL, no oversell queue. |
|
||||
| `PriceHistory` | ❌ Does not exist. |
|
||||
| Draft → moderation → published → paused/archived | 🟡 An admin Moderation module exists, on mock data. |
|
||||
| Bulk import CSV/API with pre-apply error preview | ❌ Only bulk *edit* actions inside Admin Categories. No import pipeline. |
|
||||
| "Storefront search/filters run on published data, not local mock arrays" | ⚠️ Directly aimed at us. `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY` silently always resolve to the real API — but Search, wishlist/compare, cart contents and CMS are entirely `localStorage`. |
|
||||
|
||||
### 3.3 Money, FX and price traceability (§2.3, §3.3, §3.8, §7)
|
||||
|
||||
This is where the plan is most explicit, and where we most clearly do the forbidden thing.
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| `Money = amountMinor + currency`, **no float for money math** | ⚠️ We use plain `number` prices and float division/multiplication in `CurrencyRatesService.convert()`. |
|
||||
| Rates come from a configurable **external source** with `source`, `rate`, `timestamp`, `TTL` | ⚠️ Rates are **hand-typed by an admin** into Admin Settings and stored in **browser `localStorage`** (`currencyRates.v1`), with hardcoded fallbacks (`USD: 0.011`, `AMD: 4.3`). They never update and drift from market. |
|
||||
| `FxQuote { base, quote, rate, source, observedAt, expiresAt, quoteId }` | ❌ Does not exist. |
|
||||
| Stale-quote control blocks checkout | ❌ Does not exist. |
|
||||
| Checkout writes an immutable price snapshot; old orders never recalculated | ❌ Does not exist. |
|
||||
| `PriceBook` (base currency + allowed display/checkout currencies) | ❌ Does not exist. |
|
||||
| Backoffice shows the total formula: lines × qty − discounts + delivery + fees, plus the FX quote used | ❌ Does not exist. |
|
||||
| Reconciliation of internal orders vs. provider transactions | ❌ Does not exist (`reconcil*` = 0 hits repo-wide). |
|
||||
|
||||
**Nuance worth telling them:** their §3.3 complaint is *"switching RUB/USD/AMD keeps the same number"*. Our storefront **does** convert the displayed number. Their real, unstated problem is the one our own [§12.7](../BACKEND-API-REFERENCE.md) already flagged: the **charged** amount is computed client-side in RUB and posted to `/cart` as `amount`, so bank settlement totals don't reconcile against order counts. We agree with the plan here — we raised it first.
|
||||
|
||||
### 3.4 Cart and Checkout (§2.5, §2.6) — ⚠️ head-on conflict with a frozen system
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Cart is **server-side**, keyed on `offerId` | ⚠️ Cart is `localStorage` + Telegram CloudStorage. There is no backend cart at all. |
|
||||
| "Client never sends a trusted price to the server" | ⚠️ `CartPaymentRequest` sends `amount`, `currency`, and a per-item `price` array from the browser. This is exactly the pattern the plan forbids. |
|
||||
| Checkout is a **server session** producing a price snapshot + contact snapshot | ❌ Checkout is an inline popup in `pages/cart/cart.component.ts` (751 lines). `features/website/checkout/` is an empty directory. |
|
||||
| Idempotent order creation keyed on the payment | ❌ `/orders` is called fire-and-forget after payment success. Zero `idempot*` in the codebase. |
|
||||
| Backend re-validates offers/stock at checkout | ❌ No stock concept exists to validate. |
|
||||
| Multi-seller cart grouped by seller and fulfillment rules | ❌ Undefined behaviour — already flagged in our own gaps doc. |
|
||||
| No duplicate payment intents on double-click | 🟡 Popup state guards the UI; nothing server-side. |
|
||||
|
||||
**Blocker:** [BACKEND-API-REFERENCE.md §7](../BACKEND-API-REFERENCE.md) states *"Payments are frozen — this call chain is explicitly out of scope for changes."* The plan's P0-A and P0-C cannot be delivered without unfreezing it. **This needs an explicit decision from whoever froze it.**
|
||||
|
||||
### 3.5 Payments (§2.7, §3.2)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Explicit state machines: `PaymentIntent` / `Payment` / `Order` | ❌ None. Payment status is a client-side signal with values `creating/waiting/success/timeout/error`. |
|
||||
| Webhook signature verification + idempotency | ❌ None. `webhook` appears only as a display field in the admin **monitoring mock**. |
|
||||
| Store `provider event id`, `provider timestamp`, `receivedAt`, `processedAt` | ❌ None. |
|
||||
| "No artificial fixed delays" | ✅ **We already comply.** We poll real provider status (`/qr/dynamic/{partnerId}/{qrId}`, `/card/{partnerId}/{orderId}`) on an interval bounded by the QR TTL. There is no 5-second timer in this codebase. |
|
||||
| Refunds as a first-class operation with reason/actor/order-line link | ❌ `requestRefund(id)` exists only as a mock gateway method. |
|
||||
| Reconciliation queue | ❌ None. |
|
||||
|
||||
**Ask them:** §3.2 describes a fixed 5-second payment. We cannot reproduce it here. Either they observed a different build/environment, or they inferred it from the *admin* mock data. Worth pinning down before we spend P0 budget on a problem that may not be ours.
|
||||
|
||||
### 3.6 Orders and Fulfillment (§2.8, §3.6)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Canonical `Order` regardless of source (storefront / external marketplace / backoffice / API partner) | ❌ Admin Orders is a **static 24-row in-memory seed with no create path**, and no DI token to swap it. |
|
||||
| `OrderLine` with SKU/title/price snapshots | ❌ |
|
||||
| `Source mapping` (`externalMarketplace`, `externalOrderId`, `connectorId`) | ❌ |
|
||||
| `Fulfillment` (manual / warehouse / pickup / digital) with evidence | ❌ One `fulfil*` hit in the entire codebase. |
|
||||
| `Timeline` of all order events | ❌ Already logged as our own frontend-blocked TODO ("Real order audit trail"). |
|
||||
| Admin actions: assign, resend notification, replay sync, cancel/refund by permission | ❌ |
|
||||
| **No special branch for inspectors — any published, available product must be genuinely buyable and fulfillable** | ❌ We have no publish-time executability validation and no fulfillment flow, so we cannot currently *prove* compliance either way. |
|
||||
|
||||
### 3.7 Customer identity (§2.9, §3.4, §14)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| `Customer` + multiple `ExternalIdentity` + verified `ContactMethod` | ❌ Telegram user is effectively the customer identity. |
|
||||
| `emailVerifiedAt` / `phoneVerifiedAt` / `telegramLinkedAt` | ❌ |
|
||||
| Order contact snapshot, immutable after order creation | ❌ |
|
||||
| Email/phone OTP | 🟡 **Designed, not built** — spec approved 2026-08-15. |
|
||||
| VK ID / MAX | ❌ New in v3.1, nothing exists. |
|
||||
| Guest checkout toggled by tenant policy | ❌ |
|
||||
|
||||
### 3.8 Notifications (§2.10, §3.5)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Platform event bus emitting `order.created` / `order.paid` / `payment.failed` / `webhook.error` / `stock.low` / `oversell` / `refund.*` / `external_order.imported` | ❌ |
|
||||
| Notification with `unread/read`, `severity`, `marketplaceId`, entity type/id, **deep link** | 🟡 `AdminOrderWatcherService` polls for new orders and toasts/badges the admin — the right shape, wrong data source. |
|
||||
| Unread counter + filter by marketplace / event type in backoffice | 🟡 Partial (counter yes, marketplace filter no). |
|
||||
| External channel delivery status logged; a Telegram/email failure must not lose the internal notification | ❌ |
|
||||
|
||||
**Status:** the notification feature is built and **functionally inert** — it polls the mock Orders gateway, which has no create path, so no new order can ever appear. It starts working the day Orders gets a real backend, with no further frontend change.
|
||||
|
||||
### 3.9 Analytics (§3.1, §6.3)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Server-side event logging: `session_started`, `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `payment_started/success/failed`, `order_created` | ❌ **No tracking pipeline exists at all.** Not a missing endpoint — missing infrastructure. Our own docs rate it the single largest remaining backend effort. |
|
||||
| Operational metrics: notification latency, fulfillment time, connector lag, webhook lag | ❌ |
|
||||
| Quality metrics: frontend/backend errors, checkout validation failures, FX stale blocks | ❌ |
|
||||
| Real funnel in backoffice | ❌ Admin Analytics composes five mock gateways and has no data source. |
|
||||
| Synthetic traffic technically separated from production analytics | ⚠️ Cannot comply — there is no production analytics to separate it from. |
|
||||
| Product view counts | 🟡 A "Views" column was shipped in Admin Products; it always renders `0` because no tracking source exists. Storefront `Item.visits` is live-wired but displayed nowhere. |
|
||||
|
||||
### 3.10 Backoffice navigation (§4.1) — 12 required sections, 5 missing outright
|
||||
|
||||
Have (mock unless noted): Overview/Dashboard, Catalog (Categories real, Products mock), Orders, Payments partial (Transactions), Customers, Notifications partial, Content & Design (builder/CMS, `localStorage` only), Monitoring, Reports, Users, Settings.
|
||||
|
||||
Missing entirely:
|
||||
|
||||
- **Marketplaces** — registry, type, status, domains, currencies, feature set, responsible manager. Nothing.
|
||||
- **Sellers** — organizations, applications, roles, listings, integration health. Placeholder page only.
|
||||
- **Payments & Finance** — refunds, reconciliation, unmatched events, settlements. `settlement*` = 0 hits.
|
||||
- **Integrations** — external connectors, payment providers, FX sources, messaging. Nothing.
|
||||
- **Domains & Releases** — DNS/SSL, staging, production, health checks, rollback. Nothing.
|
||||
- **Audit & Security** — role changes, sensitive actions, login/security events, exports. `audit` appears only as display fields on mock models.
|
||||
|
||||
### 3.11 Roles and RBAC (§4.4, §10.1) — ⚠️ our most serious security gap
|
||||
|
||||
The plan specifies three scopes and 17 named roles (5 platform, 7 marketplace, 5 seller).
|
||||
|
||||
Our state: **the admin role model is decorative.** `AdminRole` and permissions exist as types, but nothing gates any button, page or action anywhere in the app. Anyone who passes admin authentication has full access. `AdminRole` is additionally defined twice with unrelated shapes.
|
||||
|
||||
Also missing from §10.1: idempotency keys, rate-limit handling (429 has zero client-side handling), step-up authentication for financial actions, audit log, PII minimisation policy.
|
||||
|
||||
### 3.12 External marketplace integrations (§5) — 0% built
|
||||
|
||||
Nothing in this section exists in any form: connector contract, webhook-preferred/polling-fallback ingestion, raw event storage, normalizer, SKU mapping, unmatched queue, exponential retry, dead-letter, manual replay, reconciliation, connector observability, and the proposed SLA (99% of webhook events processed under 60s, zero duplicate orders).
|
||||
|
||||
**Blocking unknown:** the plan never names which external marketplaces. Ozon? Wildberries? Yandex Market? Avito? Each is a separate connector with its own auth and rate limits. We cannot size this without the list.
|
||||
|
||||
### 3.13 Domains, publishing and tenant launch (§8)
|
||||
|
||||
| Plan requirement | Our state |
|
||||
|---|---|
|
||||
| Marketplace lifecycle `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, with the blocking item shown per transition | ❌ |
|
||||
| DNS automation via Hostinger API (7 endpoints listed), snapshot + rollback, never touching MX/SPF/DKIM/DMARC/CAA, approval gate in production, propagation + SSL + health checks | ❌ Zero references. |
|
||||
| Publish model: `draft → validation → preview → publish`, immutable published revision, rollback creates a new revision | 🟡 The builder edits an in-memory config and persists drafts to `localStorage`. "Publish" only promotes a local signal. No revisions, no server-side publish endpoint (`apiEndpoints.builder` is an empty placeholder). |
|
||||
| Commerce data explicitly **not** part of content revisions | ✅ Structurally true today — orders/payments simply aren't in the revision at all. |
|
||||
|
||||
### 3.14 API boundaries (§9.3) — ⚠️ a naming migration we have not planned
|
||||
|
||||
Plan namespaces: `/api/v2/storefront/*`, `/api/admin/v2/*`, `/api/seller/v1/*`, `/api/identity/v1/*`, `/api/providers/v1/*`, `/api/integrations/v1/*`.
|
||||
|
||||
Ours: unversioned, flat — `/cart`, `/orders`, `/items`, `/category`, `/searchitems`, plus a separate `qrApiUrl` host. Our own reference says **"No API versioning scheme has been decided"**.
|
||||
|
||||
Adopting the plan's namespaces is a coordinated frontend+backend rename, not a config change. It should be sequenced *before* the new commerce endpoints are built, not after.
|
||||
|
||||
Also in §9: the plan's error model assumes a structured envelope. Ours is a proposal only — no interceptor inspects error bodies today; every error reaction happens at raw HTTP-status level.
|
||||
|
||||
### 3.15 Gorbushka as a tenant (§11)
|
||||
|
||||
The plan lists mall-directory content entities: `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `News/Promo`, `StaticPage`, `Lead`, `MallSettings` — each with `marketplaceId`, audit, and publish/preview.
|
||||
|
||||
We have: static pages inside the bootstrap document. None of the other nine entity types exist, and CMS content has no backend write path at all.
|
||||
|
||||
Positive read: the plan explicitly says Gorbushka must **not** dictate platform architecture, and that the existing frontend is UX reference only. That matches our ADR-0001 constraint ("frontend must not contain marketplace-specific code"). No conflict here — just unbuilt scope.
|
||||
|
||||
### 3.16 Definition of Done (§13) — where we stand today
|
||||
|
||||
Of the 13 launch-checklist items, we can currently claim **zero** as green. Additionally, our own QA position makes their DoD hard to evidence:
|
||||
|
||||
- ~32% statement coverage, ~19% branch coverage, 11 spec files repo-wide.
|
||||
- **Zero E2E tests** — no Playwright/Cypress config anywhere. The plan's acceptance criteria are all end-to-end by construction.
|
||||
- Several past "verified live" claims were code-inspection only, because `/edit` and `/backoffice` require Telegram admin login that automated environments cannot complete.
|
||||
|
||||
---
|
||||
|
||||
## 4. What we have that the plan does not account for
|
||||
|
||||
Not gaps — assets and risks they should know about before sequencing:
|
||||
|
||||
1. **Project editor / builder** (~1.0 MB lazy chunk) — a full visual site builder. The plan's §8.3 publish model would replace its persistence layer entirely.
|
||||
2. **Ed25519 challenge/response admin auth** — fully wired client-side, backend returns 404 today. The plan never mentions it; it assumes conventional RBAC.
|
||||
3. **Widget manifest / dynamic renderer** — the mechanism that makes one storefront runtime serve many tenants. This is the part of the plan we have *already* solved and should defend.
|
||||
4. **Super-admin Phase 1 design** (`docs/superpowers/specs/superuser.md`) — cross-tenant read-only view. Overlaps §4.3 Marketplace Registry and §10 audit. Worth re-scoping against the plan rather than building as specified.
|
||||
5. **Three in-flight items already answer v3.0 P0s:** admin purchase notifications (§3.5), admin product views column (§3.1), email/phone OTP login (§3.4). Two of the three are inert until a real backend exists.
|
||||
|
||||
---
|
||||
|
||||
## 5. Manager's read — the honest framing
|
||||
|
||||
**Split of ownership.** Roughly 80% of this document is backend and platform-service work: Platform API, Workers/Event Processing, Integration Hub, Domain Automation, payment state machines, reconciliation, analytics pipeline. This repository is a frontend. Of the plan's ~14 sections, only Storefront Runtime (§6.1) is substantially delivered, and it is delivered *well*.
|
||||
|
||||
**The real message is trust, not features.** Every P0 in §3 is a variant of "we cannot explain your numbers." Sequencing should follow that: traceability first (money model, price snapshot, payment timeline, audit), feature breadth second. That happens to also be the plan's own P0-A ordering.
|
||||
|
||||
**The largest single risk is not scope — it is the frozen payment chain.** Cart is client-owned, price is client-supplied, orders are fire-and-forget, and the whole chain is marked "do not modify." Three P0s sit behind that freeze. Nothing else in this list can be honestly estimated until that decision is reversed or explained.
|
||||
|
||||
**Second risk: RBAC.** The plan assumes 17 enforced roles across three scopes. We enforce none. Any real admin backend going live before this is fixed hands full platform access to every authenticated operator.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions — answered 2026-08-17
|
||||
|
||||
See [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Sprint 0.1 for the full record and downstream consequences. Summary:
|
||||
|
||||
1. **Backend ownership — answered 2026-08-18.** A separate backend developer implements against `docs/backend/`. This repository owns the frontend and the contract set.
|
||||
2. **Payment chain — unfrozen. Yes.** Phases 1, 6, 7 proceed.
|
||||
3. **External marketplaces — no fixed list.** Connectors onboard partners as they arrive; build the Phase 4 framework config-driven/generic, not per-named-provider.
|
||||
4. **FX rate source — ours, in-house, as a safety gate.** No external provider committed; backend computes FX authoritatively until/unless one is chosen later.
|
||||
5. **§14 vs. OTP — VK ID first, then everything else** ("do all after vk"). Phase 8 resequenced.
|
||||
6. **Multi-seller orders — unified.** One `Order` per checkout, seller-scoped `Fulfillment` groups internally. Resolves the three-document disagreement.
|
||||
7. **"Fixed 5-second payment" — resolved as a non-issue.** `PAYMENT_POLL_INTERVAL_MS` is already `5000` — that's poll cadence against real provider status, not an artificial delay. Confirmed compliant, no change needed.
|
||||
8. **API namespace — new endpoints only, no forced migration.** `/api/v2/...` used for all new Phase 1+ contracts; legacy endpoints stay as-is pending a dedicated migration sprint.
|
||||
9. **Document version — v3.1 is canonical.** The source PDF's internal "3.0" version block is stale.
|
||||
|
||||
---
|
||||
|
||||
## 7. Suggested first slice (if they want a proposal back)
|
||||
|
||||
Following their own dependency order, restricted to what is buildable and provable:
|
||||
|
||||
1. **Money model + FX quote + price snapshot** (P0-A) — needs the payment freeze lifted. Removes client-supplied `amount`, kills the float math, gives every total an explainable formula. This one item closes §3.3, §3.8 and half of §13.1.
|
||||
2. **Order canonical model + timeline + notification wiring** (P0-B) — the notification feature already exists and switches on for free.
|
||||
3. **RBAC enforcement** — not on their P0 list, but it is the gate on everything else in the backoffice going live safely.
|
||||
4. **Analytics event pipeline** (P0/§3.1) — long lead time, so start it in parallel rather than last.
|
||||
|
||||
Explicitly *not* in a first slice: Seller Portal, external connectors, domain automation, VK/MAX bots. All of them depend on the commerce core being real first, which is what the plan itself says in §12.1.
|
||||
499
docs/backend/BACKEND-INTEGRATION.md
Normal file
499
docs/backend/BACKEND-INTEGRATION.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# Backend — the whole thing, one file
|
||||
|
||||
**Date:** 2026-08-22 · **Branch of record:** `improvements/fork-harvest`
|
||||
|
||||
This is the single source of truth for the marketplaces backend. It replaces the former `docs/backend/` set (Phase 1–10, Track A/S, the handoffs, the partner and harvest docs) — all of it is folded in here. The frontend is Angular 22, built and waiting; **there is no backend yet.** Everything below is the wire contract and the invariants the frontend needs, never DB schema or service boundaries, which stay the backend's own call.
|
||||
|
||||
> **The rule (keep this file alive).** When a backend need is added, a contract changes, or something ships, update THIS file in the same change — the relevant section and the change log at the bottom (§14). One file, always current. Do not create a new backend `.md`; add a section here.
|
||||
|
||||
---
|
||||
|
||||
## 0. Map
|
||||
|
||||
| § | Area | Was |
|
||||
|---|---|---|
|
||||
| 1 | System shape — multi-tenancy, auth, infra state | Handoff §1–4 |
|
||||
| 2 | Release invariants (the gate) | Handoff §0 |
|
||||
| 3 | How work lands — PR & release discipline | Handoff §0a |
|
||||
| 4 | Cross-cutting mechanisms | Phase 1/Track S/Partner |
|
||||
| 5 | Money, FX, payment state machine | Phase 1 |
|
||||
| 6 | Cart & checkout | Phase 6 |
|
||||
| 7 | Payments, reconciliation, refunds, settlements | Phase 7 |
|
||||
| 8 | Catalog, offers, inventory, fulfillment | Phase 3 |
|
||||
| 9 | Orders, events, notifications | Phase 2 |
|
||||
| 10 | Identity & messaging (VK/Yandex/Telegram/MAX) | Phase 8 |
|
||||
| 11 | Tenant registry, domains, publish | Phase 9 |
|
||||
| 12 | Sellers · connectors · content · analytics · partner API | Phase 5/4/10, Track A, Partner |
|
||||
| 13 | Infra, tenant routing, deploy | Tenant-API handoff + hardening |
|
||||
| — | Acceptance tests · build order · dev setup · open decisions · change log | §2 end, §15–18, §14 |
|
||||
|
||||
New endpoints use `/api/v2/...`; legacy endpoints (documented in `../../BACKEND-API-REFERENCE.md`) are not being migrated.
|
||||
|
||||
---
|
||||
|
||||
## 1. System shape
|
||||
|
||||
### 1.1 Multi-tenancy — shapes every endpoint
|
||||
|
||||
One deployed bundle serves **every** customer domain; there is no per-tenant build. The chain: `TenantResolverService` reads the browser hostname → `ApiConfigService` uses one API host per base domain (`example.com` and `store1.example.com` both use `api.example.com`) → nginx validates the browser origin and forwards the full storefront hostname as `X-Storefront-Host` → the backend resolves the tenant from that trusted header, **never** from the shared API `Host`, and treats the frontend-supplied hostname as an untrusted hint, deriving real scope from the authenticated session. A tenant must never read another tenant's data — return `403`, not an empty result. Bootstrap carries only what's needed before app start (branding, languages, homepage layout, navigation, enabled widgets, footer pages); never products, orders, cart, or users.
|
||||
|
||||
**`published: boolean`** — required top-level field on the bootstrap response (2026-08-22). `true` once the marketplace has a `publishedRevision` (§11); `false` while it has none — every other field may then be anything, including a partial/placeholder row, since the frontend ignores the rest of the body and renders its own built-in all-features-on placeholder instead (`ConfigService`, see [Brand-bootstrap design](../superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md)). Field absent (old backend) is read as `true` for backward compatibility — do not omit it once built.
|
||||
|
||||
### 1.2 Auth — read before writing any endpoint
|
||||
|
||||
Auth lives in `@marketplaces/auth` (published from vitanovaPackages; see `../PACKAGES-USAGE.md`). Two mechanisms exist client-side:
|
||||
|
||||
- **Telegram QR/session (live).** `{authApiUrl}/users/sessions` — `POST` create, `GET /{id}` poll, `DELETE /{id}` logout. A clean implementation returns `{ webSessionID, user: { userId, username, firstName, lastName }, status, expiresAt }`.
|
||||
- **Ed25519 challenge/response (not built).** `GET /api/admin/auth/challenge`, `POST /api/admin/auth/verify|refresh|logout`.
|
||||
|
||||
**The critical gap:** the session API has no concept of "admin." The frontend only chooses where to *store* the result. **Every admin endpoint must independently verify authorization server-side** — client-side guards are UI convenience, never security. Admin requests carry `AdminWebSessionID: <sessionId>` (and `Authorization: Bearer <token>` once admin JWTs exist) on paths containing `/admin/`, `/backoffice/`, `/builder/`, `/media/`.
|
||||
|
||||
**Admin credential (login/password) auth — required, not yet built.** `admin.gorbushka.market` can authenticate via Telegram today; login/password is not implemented, so the frontend must not validate or embed admin credentials, and the Ed25519 `/admin-login` page is not production-ready (its challenge/verify endpoints don't exist). Tenant identity comes only from nginx's trusted `X-Storefront-Host` — never from the login body.
|
||||
|
||||
```http
|
||||
POST /api/identity/v1/session { login, password }
|
||||
-> { accessToken (short JWT), refreshToken (rotating opaque), expiresAt,
|
||||
mustChangePassword, user: { id, login, displayName, roles[], tenantId } }
|
||||
POST /api/identity/v1/session/refresh
|
||||
DELETE /api/identity/v1/session
|
||||
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
|
||||
GET /api/identity/v1/session/permissions
|
||||
```
|
||||
|
||||
Errors: `400` malformed; `401 INVALID_CREDENTIALS` (one generic message for unknown login and wrong password); `403 TENANT_DISABLED` / `TENANT_MISMATCH`; `429 RATE_LIMITED` with `Retry-After`. While `mustChangePassword` is true, every non-auth admin endpoint returns `403 PASSWORD_CHANGE_REQUIRED`. Provisioning: random one-time bootstrap password (never `{slug}2026$`), store only an Argon2id hash with a unique salt, never log passwords/refresh tokens/authorization headers/session ids, rate-limit by tenant+login+source IP with backoff, rotate refresh tokens and revoke the full family on reuse, audit login success/failure + password change + refresh reuse + logout + lockout. nginx must preserve `proxy_set_header X-Storefront-Host $storefront_host; proxy_set_header Origin "";` and, for `Origin: https://admin.gorbushka.market`, resolve `$storefront_host` to `gorbushka.market`; API upstream stays `https://127.0.0.1:445`.
|
||||
|
||||
### 1.3 Infrastructure state (dev server `213.21.246.138`, user `seto`)
|
||||
|
||||
| Thing | State |
|
||||
|---|---|
|
||||
| nginx 1.24 | Running. Proxies `/api/` → `127.0.0.1:8080`; `/health` = `ok`. |
|
||||
| Backend on :8080 | **Not running.** `/api/` currently 502s. |
|
||||
| PostgreSQL | Installed, inactive. Needs db, user, schema. |
|
||||
| `@marketplaces/auth` | Installs over plain git, no credentials. |
|
||||
| TLS / certbot | **Not installed.** Plain HTTP today. Multi-tenant needs per-domain or wildcard certs. |
|
||||
| DNS / subdomains | **Not set up.** No domain points at the server. Phase-equivalent target in §11. |
|
||||
| Frontend CD | Push to `main` deploys nothing today; `deploy.yml` exists (§13). |
|
||||
|
||||
Host hardening (sshd, fail2ban, sysctl) **is** applied on the frontend deploy — see `../DEPLOYMENT.md` §3.2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Release invariants (the gate)
|
||||
|
||||
A release that violates any one of these does not ship. Each is falsifiable; the acceptance tests are in §15.
|
||||
|
||||
1. Public tenant is determined by verified `Host` alone. No public endpoint accepts a `marketplaceId` from the browser.
|
||||
2. The price of an order is computed by the backend. A price in a request is ignored, never validated-and-used.
|
||||
3. Stock and reservation change atomically — two buyers racing for the last unit produce exactly one payable order.
|
||||
4. Payment creation and webhook receipt are idempotent, enforced by unique constraints, not handler logic.
|
||||
5. Provider credentials never leave the backend — not in a response, not in a bundle, not in a log.
|
||||
6. A published revision is immutable. Rollback creates a new revision; history is never rewritten.
|
||||
7. Rolling back design does not roll back live inventory, orders, or payments.
|
||||
8. No user reads a marketplace they are not assigned to — through the UI or a direct API call.
|
||||
9. Every administrative mutation leaves an audit record: actor, action, before, after.
|
||||
|
||||
---
|
||||
|
||||
## 3. How work lands
|
||||
|
||||
**One functional area per PR.** Each carries: purpose, screenshots (where UI), API changes, migrations, test evidence, security impact, rollback plan. Never change a payment/inventory/order state machine in the same PR as a redesign.
|
||||
|
||||
**Migrations are expand/contract.** The expand step must be deployable on its own.
|
||||
|
||||
**A release is not "the build passed."** Each records version, migrations applied, healthcheck, post-deploy smoke, dependency audit, and the rollback path. Audit coverage (invariant 9) is a property every mutating endpoint carries from its first line, not a step.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-cutting mechanisms
|
||||
|
||||
### 4.1 Money
|
||||
|
||||
All money is minor units, never float. `Money { amountMinor: number; currency: string }` (ISO 4217). RUB/USD/EUR/AMD are 2-decimal. Conversion rounds half-up to the currency's minor-unit precision, once, at the point of conversion — never re-rounded on redisplay.
|
||||
|
||||
### 4.2 Sessions (FH-2.3)
|
||||
|
||||
- Token = 32 random bytes, stored as **SHA-256 hash only** — a DB read yields no usable credential.
|
||||
- `HttpOnly; Secure; SameSite`; revocable; rows carry `expiresAt`/`revokedAt`/`ip`/`userAgent`. Admin sessions 12 h, customer sessions 30 days.
|
||||
- **One cookie name per contour** — `bo_session` / `manager_session` / `marketplace_session`. A customer session must never satisfy an admin guard; the guarantee is different cookies checked by different guards.
|
||||
- Validation rejects on: unknown hash, `revokedAt` set, past `expiresAt`, user deactivated, or second factor not enrolled.
|
||||
- Password change revokes every live session for the user **in the same transaction** as the password write.
|
||||
- Credentials: Argon2id `memoryCost 65536, timeCost 3, parallelism 1`, ≥16 chars. TOTP **mandatory** for every platform/marketplace role: first login without an enrolled factor returns a signed, single-use, 10-minute enrolment token + `otpauth://` URI and issues no session until confirmed. The enrolment token grants nothing else.
|
||||
|
||||
### 4.3 Origin allowlist (FH-2.4)
|
||||
|
||||
One hook ahead of routing: any non-`GET`/`HEAD`/`OPTIONS` on `/api/admin/*`, `/api/platform/*`, `/api/manager/*` whose `Origin` is not allowlisted → `403`, before the handler. CORS uses the same allowlist with `credentials:true` — never `*`, never reflected. The allowlist is per-environment configuration.
|
||||
|
||||
### 4.4 Encrypted secret envelope (FH-2.9)
|
||||
|
||||
Stored credentials use `v1.<iv>.<authTag>.<ciphertext>` base64url, AES-256-GCM, 12-byte random IV per value, 32-byte key from env/secret-manager. The version tag lets the algorithm rotate. Decrypt only inside the using service — never on a DTO, in a log, or in any response (including to a `PLATFORM_OWNER`; backoffice shows presence, last-rotated, and an HMAC fingerprint, not the value). Fingerprints are `HMAC-SHA256(key, value)`. Redirect/callback URLs are built backend-side from the verified domain and allowlisted; the browser receives a URL to navigate to, never the material to build one. Covers payment credentials, connector credentials, bot tokens, FX keys, per-tenant OAuth secrets.
|
||||
|
||||
### 4.5 RoutingContext (Partner §7, on every payment)
|
||||
|
||||
```ts
|
||||
interface RoutingContext {
|
||||
companyId: string;
|
||||
routingPath: string[]; // ordered node ids, root -> leaf
|
||||
leafNodeId: string; // the payment point money is accepted at
|
||||
environment: 'TEST' | 'LIVE';
|
||||
merchantReference: string; // partner-supplied, opaque, echoed on every related event
|
||||
providerPaymentId: string; // our payment id, stable, unique
|
||||
}
|
||||
```
|
||||
|
||||
Required on `CheckoutSession`, `PaymentIntent`, `Payment`, and every refund/reconciliation/settlement row. Resolved and **frozen at checkout-session creation**, immutable for the payment's life. `routingPath` must resolve to exactly one leaf or the payment is rejected at creation (never accepted and resolved during reconciliation). A payment whose leaf is `suspended`/`disabled` is rejected. `environment` must match the credential's or `403`. **Carry it from the first payment row — retrofitting it onto a populated table is far more expensive.**
|
||||
|
||||
### 4.6 RBAC (Track S)
|
||||
|
||||
17 roles, 3 scopes:
|
||||
|
||||
```ts
|
||||
type PlatformRole = 'PLATFORM_OWNER' | 'TECH_ADMIN' | 'SECURITY_ADMIN' | 'DOMAIN_MANAGER' | 'VIEWER';
|
||||
type MarketplaceRole = 'MARKETPLACE_ADMIN' | 'CONTENT_MANAGER' | 'CATALOG_MANAGER'
|
||||
| 'ORDER_MANAGER' | 'FINANCE_MANAGER' | 'SUPPORT_MANAGER' | 'VIEWER';
|
||||
type SellerRole = 'SELLER_OWNER' | 'SELLER_CATALOG_MANAGER' | 'SELLER_ORDER_MANAGER'
|
||||
| 'SELLER_FINANCE_VIEWER' | 'SELLER_VIEWER';
|
||||
```
|
||||
|
||||
Every `/api/admin/v2/*` and `/api/platform/v1/*` endpoint checks `(role, tenantScope)` against the session **before** touching data. A `MARKETPLACE_ADMIN` for A querying B's data gets `403`, not an empty result. `GET /api/identity/v1/session/permissions -> { role, scopes[], marketplaceIds[] }` is what frontend guards derive from — never hardcode role logic client-side beyond hiding affordances.
|
||||
|
||||
**Step-up auth** required before: bank/payment detail changes, production launch, role grants at `PLATFORM_OWNER`/`MARKETPLACE_ADMIN` level, any manual financial override. **PII minimization:** exposed only to roles that need it for scope; export endpoints are themselves audited.
|
||||
|
||||
**Bootstrap admin & self-service** (§8 of old Track S): each marketplace ships one bootstrap `MARKETPLACE_ADMIN` — `login` = marketplace slug, `password` = a cryptographically random one-time secret delivered out of band (never derived from the slug), `mustChangePassword: true`; login succeeds but every non-auth request `403`s with `PASSWORD_CHANGE_REQUIRED` until changed. `POST /api/identity/v1/session/change-password`. A `MARKETPLACE_ADMIN` provisions sub-admins scoped to its own tenant via `POST /api/admin/v2/team/invite { email, role: MarketplaceRole, marketplaceId }` (+ `GET/PATCH/DELETE /team`); `role` must be a `MarketplaceRole` (platform-scope → `403 SCOPE_ESCALATION_DENIED`), `marketplaceId` is forced server-side to the caller's scope, every change audited, `MARKETPLACE_ADMIN` grants require step-up.
|
||||
|
||||
### 4.7 Audit log
|
||||
|
||||
```ts
|
||||
interface AuditEvent {
|
||||
id: string; actor: string; action: string; // 'role.changed', 'offer.price_updated', 'refund.approved'
|
||||
entityType: string; entityId: string;
|
||||
before?: unknown; after?: unknown; reason?: string; occurredAt: string; ip?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Mandatory coverage: permission changes, seller status changes, catalog moderation, price changes, payment/refund actions, manual order overrides, credential changes, launch actions. `GET /api/admin/v2/audit?marketplaceId=&entityType=&actor=&from=&to=`.
|
||||
|
||||
### 4.8 Rate limiting
|
||||
|
||||
`429 { error: { code: 'RATE_LIMITED', retryAfterSeconds } }` on storefront/auth/provider endpoints. Partner limits are per `partnerId` by tier, published in the OpenAPI so a partner reads its limit rather than discovering it via `429`.
|
||||
|
||||
### 4.9 Order-manager contour (FH-2.14)
|
||||
|
||||
`ORDER_MANAGER` is a **separate surface**, not a narrower menu: own URL, shell, login, and session cookie; a manager hitting a backoffice URL gets `403` from the guard. Scope from **membership rows, never configuration**. Catalog, design, domains, payment settings, platform users refuse — not merely hidden. PII masked in lists, revealed in detail only with permission, reveal and export audited.
|
||||
|
||||
---
|
||||
|
||||
## 5. Money, FX, payment state machine (Phase 1)
|
||||
|
||||
**Why:** rates are typed into `localStorage` and drift; the charged `amount` is computed client-side and trusted; nothing records which FX rate produced a price. Bank/NSPK totals can't reconcile.
|
||||
|
||||
**FX quote.** `GET /api/v2/pricing/fx-quote?base=RUB"e=USD` → `{ quoteId, base, quote, rate, source, observedAt, expiresAt }`. `rate` may be float (market rate, not money). The frontend must re-fetch past `expiresAt`. If the source is down, the backend either blocks (`503 FX_SOURCE_UNAVAILABLE`) or serves a `"source":"fallback"` quote — a tenant setting. FX source is **ours, in-house, as the default** (`source: "internal"`); no external provider committed.
|
||||
|
||||
**PriceSnapshot.** Created once at checkout, immutable. `{ id, offerId, amount, displayAmount, fxQuoteId, capturedAt }`. Never recalculated — an old order shows the price it was actually charged.
|
||||
|
||||
**Server-authoritative amount (highest priority).** Replace client-trusted `POST /cart {amount, items[{price}]}` with:
|
||||
|
||||
```
|
||||
POST /api/v2/storefront/checkout { offers: [{offerId, qty}], currency, deliveryOptionId }
|
||||
```
|
||||
|
||||
The frontend sends offer ids + quantities only; the backend computes every price from the live offer price and current FX quote. **No `amount`/`price` is ever accepted from the client for anything affecting the charge.** `POST /api/v2/storefront/payments/intents` references `checkoutSessionId` only. Total = `sum(unitPrice*qty) − discounts + delivery + taxes/fees`, reconstructable per line for backoffice.
|
||||
|
||||
**Payment state machine.**
|
||||
```
|
||||
PaymentIntent: created -> pending -> authorized/paid -> failed/cancelled
|
||||
Payment: received -> confirmed -> captured/settled -> refunded/partially_refunded
|
||||
Order: pending_payment -> paid -> processing -> fulfilled/completed
|
||||
```
|
||||
`PaymentEvent { id, paymentIntentId, fromState, toState, providerEventId, providerTimestamp, receivedAt, processedAt }`. No fixed delays anywhere. Webhook: `POST /api/providers/v1/payments/{provider}/webhook` — signature mandatory (`401` on fail), idempotency key `provider + providerEventId`, on success emit `payment.confirmed`/`payment.failed` onto the bus so order creation is event-driven. Idempotent order creation: `POST /api/admin/v2/orders` (internal) with `Idempotency-Key: <checkoutSessionId>` returns the existing order on retry.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cart & checkout (Phase 6)
|
||||
|
||||
Server-owned cart from add-to-cart onward (today it's `localStorage` + Telegram CloudStorage; `features/website/checkout/` is empty).
|
||||
|
||||
```ts
|
||||
interface Cart { id; marketplaceId; customerId?; sessionToken?; createdAt; expiresAt }
|
||||
interface CartLine { id; cartId; offerId; qty; addedAt } // never a client price
|
||||
interface CheckoutSession { id; cartId; customerContact:{email?,phone?,verified}; deliveryOptionId; status:'open'|'confirmed'|'expired'; createdAt; expiresAt }
|
||||
interface DeliveryOption { id; marketplaceId; label; price: Money; type:'pickup'|'courier'|'digital' }
|
||||
```
|
||||
|
||||
```
|
||||
POST /api/v2/storefront/cart/lines { offerId, qty }
|
||||
PATCH /api/v2/storefront/cart/lines/{id} { qty }
|
||||
DELETE /api/v2/storefront/cart/lines/{id}
|
||||
GET /api/v2/storefront/cart
|
||||
```
|
||||
|
||||
Idempotent mutations; qty validated against Offer/Inventory on **every** mutation. Guest cart by `sessionToken`, merges into the customer cart on login (never drops items). Inactive carts and their reservations clear on `expiresAt`. **Price-refresh:** `GET /cart` returns captured price + current price + `priceChanged` when an offer's price moved; the frontend must confirm before checkout, the backend must expose the comparison, never silently pick one. Checkout reads the server cart directly; contact requirement and guest-checkout allowance are per-tenant policy.
|
||||
|
||||
---
|
||||
|
||||
## 7. Payments, reconciliation, refunds, settlements (Phase 7)
|
||||
|
||||
**Idempotency as constraints (FH-2.2).** `UNIQUE(payment.idempotency_key)` and `UNIQUE(payment_webhook_event.provider, event_key)`.
|
||||
- Payment create requires `Idempotency-Key`; same key + same order → return existing, + different order/marketplace → `409`.
|
||||
- Webhook: insert the event row **first**; a unique-violation is the duplicate signal → `{accepted:true, duplicate:true}`, stop. Only a successful insert applies the status change; set `processedAt` after applying (a crash between insert and apply shows as unprocessed, not lost). `event_key` = provider event id, else `sha256(rawBody)`. **Signature verified against the raw body** before any parse.
|
||||
- Poll as reconciliation, not primary: a scheduled job re-checks provider status for payments still `pending` in the last 24 h and applies through the same state-machine path; transient failures swallowed, next tick retries. No fixed delay, no UI-driven poll standing in for a missed webhook.
|
||||
|
||||
**Refunds.** `Refund { id, orderId, orderLineIds[], amount, reason, actor, status:'requested'|'approved'|'processing'|'completed'|'failed', requestedAt, completedAt?, routing }`. Routing is **copied verbatim** from the original payment, never re-resolved — a store suspended after payment is still refundable. `POST /api/admin/v2/orders/{orderId}/refunds { orderLineIds, amount, reason }`, `GET` same. Updates `Payment.status` to `refunded`/`partially_refunded`, emits `refund.requested`/`refund.completed`.
|
||||
|
||||
**Reconciliation.** `ReconciliationRecord { id, orderId, providerPaymentId?, internalAmount, providerAmount?, matchStrategy:'provider_payment_id'|'merchant_reference'|'amount_currency_fallback', result:'matched'|'unmatched'|'duplicate'|'amount_mismatch'|'status_mismatch', resolvedBy?, resolvedAt?, resolutionNote?, routing }`. Match by providerPaymentId → merchant reference → amount+currency; surface non-matched in backoffice with audited resolution. `GET /api/admin/v2/reconciliation/queue?marketplaceId=&companyId=&projectId=&leafNodeId=&result=`, `POST /{id}/resolve {note}`.
|
||||
|
||||
**Settlements.** `Settlement { id, sellerId, periodStart, periodEnd, grossAmount, commission, refunds, netPayout, status:'pending'|'paid' }`. Seller split happens **after** routing: payment → routed to one payment point (frozen at checkout) → reconciled there → split across the sellers whose lines the order contains. A settlement belongs to one seller within one store; a seller in two stores gets two settlements. Splitting never rewrites RoutingContext. `grossAmount` across a store's settlements must reconcile against that store's matched rows for the period. `GET /api/seller/v1/finance/settlements`, `GET /api/admin/v2/finance/settlements?...`.
|
||||
|
||||
**Provider breadth:** QR + card today via one integration; the `PaymentIntent`/`Payment` shapes are provider-agnostic, so wallets/BNPL are a new adapter behind the same state machine — an open business decision, no action until made.
|
||||
|
||||
---
|
||||
|
||||
## 8. Catalog, offers, inventory, fulfillment (Phase 3)
|
||||
|
||||
**Two-layer split.** `Product` (content) vs `Offer` (one seller's proposition). One product, many offers.
|
||||
|
||||
```ts
|
||||
interface Product { id; marketplaceId; categoryId; brand?; title; description; attributes; media[]; status:'draft'|'moderation'|'published'|'paused'|'archived' }
|
||||
interface Variant { id; productId; sku; barcode?; optionValues; dimensions? }
|
||||
interface Category { id; marketplaceId; parentId|null; slug; attributesSchema; order; seo }
|
||||
interface Offer { id; marketplaceId; sellerId; variantId; sellerSku; price: Money; stockPolicy:'track'|'no_track'|'preorder'; status:...; publishedAt?; executabilityChecked }
|
||||
interface PriceHistory { offerId; price: Money; changedBy; changedAt }
|
||||
```
|
||||
|
||||
**Inventory** `{ offerId, available, reserved, sold, warehouse?, source }` + `StockReservation { id, offerId, qty, reason:'checkout'|'pre_payment', expiresAt, released }`. available/reserved/sold counted separately, never derived. Feed updates are idempotent upserts. Oversell → dedicated incident queue, never silently hidden.
|
||||
|
||||
**Atomic reservation (FH-2.1).** Reserve with one conditional write:
|
||||
```sql
|
||||
UPDATE inventory SET reserved = reserved + :qty
|
||||
WHERE offer_id = :id AND (available - reserved) >= :qty RETURNING id
|
||||
```
|
||||
Zero rows → `409`, no retry, no partial reserve; a multi-line cart reserves every line in one transaction and rolls all back if any line returns zero. No `SELECT` before the `UPDATE`, no advisory lock — the `WHERE` clause is the concurrency control. TTL 15 min. Release and consume follow the same one-statement rule.
|
||||
|
||||
**Inventory journal (FH-2.8).** Every change writes one immutable `InventoryMovement { id, offerId, deltaAvailable, deltaReserved, deltaSold, reason, referenceType?, referenceId?, actor?, resultingAvailable, occurredAt }`. Never updated/deleted; a correction is a new compensating row. `resultingAvailable` recorded at the time; replaying the journal reproduces the record exactly. A manual adjustment without `actor` is rejected.
|
||||
|
||||
**Publish-time executability.** An offer that can't be fulfilled must not publish: valid `Fulfillment` type, stock policy `track` with `available>0` or `no_track`/`preorder`, required category attributes present. This is what makes "no branch distinguishes a buyer from an inspector" true.
|
||||
|
||||
**Digital code pools (FH-2.11).** `FulfillmentMode: manual | code_pool`. `DigitalCode { id, marketplaceId, offerId, encryptedValue, valueHash, status:'available'|'reserved'|'assigned'|'revoked', orderLineId?, createdAt, assignedAt? }`. `valueHash` unique per `(marketplace, offer)` — importing a code twice is refused by the DB. `available` for a code_pool offer derives from the count of available codes. Moves `available→reserved` under the FH-2.1 write, `reserved→assigned` only on confirmed payment. **A code is returned to the browser only when the order is `paid`/`processing`/`fulfilled`** — earlier states return an empty code list. Revocation is terminal and audited.
|
||||
|
||||
**Bulk import.** `POST /api/admin/v2/products/bulk-import` (CSV multipart or JSON array) returns a validation-error **preview**; a separate `POST .../bulk-import/{importId}/apply` commits. Idempotent by SKU/external key (FH-2.15) — re-run updates, never duplicates; a row-level error never publishes a partial result; rollback-able only while none of its products have appeared on a paid order, then archive.
|
||||
|
||||
**Endpoints.** `GET/POST/PATCH /api/admin/v2/products[/{id}]`, `GET/POST/PATCH /api/admin/v2/offers[/{id}]`, `POST /api/admin/v2/offers/{id}/publish` (runs executability, `422 details[]` on fail), `GET /api/admin/v2/offers/lookup?sku=&sellerSku=&externalId=`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Orders, events, notifications (Phase 2)
|
||||
|
||||
**One `Order` per checkout**, regardless of seller count; lines group into per-seller `Fulfillment`. No parent/child splitting. A seller sees only their `Fulfillment` group and their `OrderLine`s.
|
||||
|
||||
```ts
|
||||
interface Order { id; marketplaceId; source:'storefront'|'external'|'backoffice'|'api_partner'; externalOrderRef?; customerId?; currency; subtotal; discount; delivery; total: Money; paymentStatus; orderStatus; createdAt; paidAt? }
|
||||
interface OrderLine { id; orderId; offerId; sellerId; skuSnapshot; titleSnapshot; qty; unitPrice; lineTotal: Money; priceSnapshotId }
|
||||
interface Fulfillment { id; orderId; sellerId; type:'manual'|'warehouse'|'pickup'|'digital'; status:'pending'|'assigned'|'in_progress'|'issued'|'shipped'|'cancelled'; assignedTo?; issuedAt?; shippedAt?; evidence? }
|
||||
interface OrderEvent { id; orderId; type:'created'|'paid'|'seller_notified'|'accepted'|'fulfilled'|'cancelled'|'refunded'; actor?; occurredAt; metadata? }
|
||||
interface OrderContactSnapshot { orderId; name; email?; phone?; preferredChannel?; capturedAt } // immutable
|
||||
```
|
||||
|
||||
**Public token + snapshot completeness (FH-2.13).** `Order.publicToken` ≥24 random bytes, base64url, unique; **every customer-facing route addresses an order by it, never by `id`** (a sequential id turns "check my order" into enumeration). `GET /api/v2/storefront/orders/{publicToken}` is tenant-scoped; a valid token from another marketplace → `404`. `OrderLine` snapshots everything that must survive a later edit — currency, per-line discount, delivery option and price, tax/fee components — written once at creation, never updated in place; a correction is a new event/refund/amendment.
|
||||
|
||||
**Endpoints.** `GET /api/admin/v2/orders?marketplaceId=&status=&source=&page=&pageSize=`, `GET /{id}`, `PATCH /{id}/status`, `POST /{id}/refund-request`, `POST /{id}/notes`, `POST /{id}/archive|restore`, `DELETE /{id}`. `GET /api/seller/v1/orders` returns only the authenticated seller's fulfillment groups and lines.
|
||||
|
||||
**Event bus.** `order.created|paid`, `payment.failed`, `webhook.error`, `stock.low`, `oversell`, `refund.requested|completed`, `external_order.imported`. Backend owns the implementation. Contract: `order.paid` **always** produces a backoffice notification even if every external channel is down.
|
||||
|
||||
**Notification Center.** `Notification { id, marketplaceId, entityType, entityId, severity:'info'|'warning'|'critical', eventType, read, deepLink, createdAt }` + `DeliveryAttempt { notificationId, channel, status:'sent'|'failed', error?, attemptedAt }`. `GET /api/admin/v2/notifications?...`, `PATCH /{id}/read`. A `DeliveryAttempt` failure never prevents the `Notification` row from being created and visible.
|
||||
|
||||
---
|
||||
|
||||
## 10. Identity & messaging (Phase 8)
|
||||
|
||||
Customer identity providers: VK ID and Yandex ID (OAuth), Telegram and MAX (bot/QR). **Frontend is built and tested** — provider-agnostic gateway, VK/Yandex login buttons, and the account-linking screen all exist; what's left is backend + the FH-0.1 decision.
|
||||
|
||||
**Provider-agnostic surface (FH-4.1/4.2).**
|
||||
```
|
||||
GET /api/identity/v1/{provider}/authorize?returnTo= -> { url } (or 302)
|
||||
GET /api/identity/v1/{provider}/callback?code=&state=[&device_id=]
|
||||
POST /api/identity/v1/{provider}/unlink (authenticated)
|
||||
GET /api/identity/v1/me/identities (authenticated) -> ExternalIdentity[]
|
||||
```
|
||||
`/authorize` mints and stores `{state, codeVerifier, marketplaceId, returnTo, expiresAt}` **single-use for 10 min**, returns/302s to the provider with `code_challenge` (S256). `/callback` validates `state`, exchanges the code with the stored verifier, links the identity, issues the session cookie, redirects to a `returnTo` validated against the tenant origin. **The client never sees a secret, token, or verifier** — we are a confidential client, the backend owns PKCE. Unknown/expired/replayed `state` → generic error.
|
||||
|
||||
**`ExternalIdentity` (FH-4.3).** `{ customerId, provider:'vk_id'|'yandex_id'|'telegram'|'max', providerUserId, email?, phone?, displayName?, verifiedAt, lastUsedAt }`. `UNIQUE(provider, providerUserId)`; a provider account already bound to a *different* customer is an identity conflict routed to controlled resolution — never a silent rebind, enforced by the index. Email optional (VK often returns none). Per-tenant OAuth app config `{ clientId, clientSecret, scopes[], redirectUri }` stored under the §4.4 envelope.
|
||||
|
||||
**VK ID (FH-4.4).** OAuth 2.1, PKCE mandatory. Authorize `id.vk.com/authorize`, token `POST id.vk.com/oauth2/auth`, profile `POST id.vk.com/oauth2/user_info`, logout on unlink. **The callback returns `device_id` alongside `code` and the token exchange fails without it** — the most common integration bug.
|
||||
|
||||
**Yandex ID (FH-4.5).** OAuth 2.0 + PKCE. Authorize `oauth.yandex.ru/authorize`, token `POST oauth.yandex.ru/token` (HTTP Basic `client_id:client_secret`), profile `GET login.yandex.ru/info?format=json` (`Authorization: OAuth <token>`). A second strategy on the same surface; build after VK.
|
||||
|
||||
**Telegram → identity (FH-4.6).** A Telegram login writes an `ExternalIdentity` (`provider:'telegram'`) under the same uniqueness/conflict rule; appears in `/me/identities`, unlinkable subject to the **last-identity `409`** (never remove a customer's only login). Keep customer (`marketplace_session`) and admin (`bo_session`) sessions as distinct cookies — closes the shared customer/admin session finding. The identity row and the messaging `BotConversationBinding` stay separate records.
|
||||
|
||||
**Email/phone OTP (FH-4.8).** Recovery when a linked messenger is unreachable and an addable second factor — never the primary login; one more identity/contact on the same customer, not a parallel account. Implements the existing `../superpowers/specs/2026-08-15-email-phone-login-design.md`.
|
||||
|
||||
**MAX + Telegram bot channels.** `BotConversationBinding { customerId, marketplaceId, provider:'telegram'|'max', chatId, state, orderId?, lastMessageAt }`. MAX linking: `POST /api/identity/v1/max/link-code -> { code, expiresAt }` (single-use, bound to marketplace + browser session); user sends the code to the bot; `POST /api/providers/v1/max/bot-webhook` (idempotent) links the session. All providers' bot updates normalize to `MessagingEvent { provider, chatId, orderId?, text?, receivedAt }`. Bot tokens never reach the frontend.
|
||||
|
||||
**Notification Orchestrator + delivery conversation.** `order.paid` routes to the customer's chosen channel; the backoffice notification always fires even if the messenger is down. The bot never changes financial statuses — it writes delivery-detail fields via a dedicated service only. Follow-ups rate-limited, then hand off to a human. `POST /api/providers/v1/{provider}/bot-webhook`, `GET /api/admin/v2/orders/{orderId}/conversation`, `POST /{orderId}/conversation/handoff`.
|
||||
|
||||
**Blocking decision — FH-0.1.** VK and Yandex validate `redirect_uri` against an exact registered list; a multi-tenant platform can't register one per tenant domain. Resolution to confirm: one **central identity host** as the sole registered callback, tenant carried in the signed `state`, a 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for the session cookie. Also decide: one VK account across two storefronts — one `Customer` or two? (`Customer.marketplaceId` implies two, the safer default.) Record both in an ADR before any identity code.
|
||||
|
||||
---
|
||||
|
||||
## 11. Tenant registry, domains, publish (Phase 9)
|
||||
|
||||
**Hierarchy** (Company → Project → Marketplace → PaymentPoint; see §12 partner API). `Company`/`Project` are thin ownership/scope nodes; all config stays on `Marketplace`.
|
||||
|
||||
```ts
|
||||
interface Marketplace { id; companyId; projectId; externalReference?; name; code; type:'commerce'|'mall_directory'|'hybrid'|'single_brand'; ownerId; countries[]; locales[]; currencies[]; timezone; lifecycleState }
|
||||
type MarketplaceLifecycleState = 'draft'|'configured'|'content_ready'|'domains_planned'|'staging_live'|'qa_passed'|'production_ready'|'live'|'paused'|'archived';
|
||||
interface MarketplaceDomain { marketplaceId; domain; type:'production'|'www'|'staging'|'preview'|'api'|'seller'; status:'planned'|'dns_pending'|'ssl_pending'|'active'|'failed' }
|
||||
interface MarketplaceFeatureSet { marketplaceId; features: Record<string,boolean> }
|
||||
interface MarketplaceRevision { id; marketplaceId; status:'draft'|'validated'|'preview'|'published'; publishedAt?; supersedesRevisionId? }
|
||||
interface PaymentPoint { id; marketplaceId; method:'qr'|'card'; currencies[]; externalReference?; status; providerAccountRef?; createdAt; updatedAt }
|
||||
```
|
||||
|
||||
Creating a payment point registers the channel but does **not** enable real money (needs `providerAccountRef` via a separate flow). Backfill existing marketplaces: create a Company, a Project ("marketplaces"), set `companyId`/`projectId` on every marketplace, create PaymentPoints for existing methods, then make the fks non-nullable.
|
||||
|
||||
**Lifecycle.** `GET /api/admin/v2/marketplaces/{id}/lifecycle -> { currentState, nextState, blockers[] }` (return the *specific* blocker), `POST .../lifecycle/advance`. **Onboarding wizard** — 8 steps: `POST /marketplaces` (name/code/type/owner/locales/currencies/timezone), `PATCH /{id}/feature-set`, `POST /{id}/domains`, `PATCH /{id}/design`, `POST /{id}/roles`, `PATCH /{id}/integrations`, `POST /{id}/staging-launch` (smoke tests), `POST /{id}/production-launch` (all P0 blockers closed + approval).
|
||||
|
||||
**Domain automation (Hostinger).** `GET/POST(validate)/PUT/DELETE /api/dns/v1/zones/{domain}`, `GET /snapshots/{domain}[/{id}]`, `POST /snapshots/{domain}/{id}/restore`. Order: read zone → **snapshot before any change** → build+validate plan → never touch MX/SPF/DKIM/DMARC/CAA without a scoped task → apply after approval → verify propagation/SSL/health → mark `active` only then.
|
||||
|
||||
**Publish model.** `draft → validation → preview → publish`. `POST /api/admin/v2/marketplaces/{id}/revisions`, `.../{revId}/validate|publish|rollback`.
|
||||
- **Immutability (FH-2.7):** `version = max(version)+1`, `UNIQUE(marketplaceId, version)`, materialized snapshot (a product renamed tomorrow doesn't change what was published today), `publishedRevision` pointer flipped in the publishing transaction, rollback writes revision *n* as *max+1* (history only grows). **Operational state — inventory, reservations, orders, payments — never travels with a revision.**
|
||||
- **Clone (FH-2.7):** carries theme/sections/pages/navigation/category tree/collections/offer assignments; **never** carries domains/admin users/customers/sessions/orders/payments/credentials/webhook secrets/audit. Inventory starts at zero unless a platform role opts otherwise. Category walk is topological with cycle detection (`400` naming the cycle).
|
||||
- **Preview (FH-2.6):** `POST .../{id}/preview-token -> { url, expiresAt }`. HMAC over `{marketplaceId, expiresAt, nonce}`, 15-min TTL, `storefront_preview` HttpOnly cookie, constant-time compare, invalid/expired → `404` (an unpublished storefront doesn't confirm its existence). **While the preview cookie is present, every non-`GET` on the public API → `404`** (hook ahead of routing). Responses carry `X-Robots-Tag: noindex, nofollow`.
|
||||
|
||||
**Tenant resolution (FH-2.5).** `GET /api/v2/storefront/bootstrap` resolves server-side from verified `Host`. Normalize: lowercase, strip trailing dot, strip port, then match a unique `hostname` row — resolve only once `verifiedAt` is set and the marketplace serves. Brief cache (~30 s) with **explicit invalidation** on domain add/verify/remove and state change. `Host` read from the trusted proxy chain (proxy overwrites the client value). **No public endpoint accepts `marketplaceId`.** Unknown/unverified host → `404`, no fallback tenant.
|
||||
|
||||
**Hard invariant:** `Order`, `Payment`, `InventoryRecord`, and every ledger row are not part of a revision.
|
||||
|
||||
---
|
||||
|
||||
## 12. Sellers · connectors · content · analytics · partner API
|
||||
|
||||
### 12.1 Seller portal (Phase 5)
|
||||
|
||||
A seller never owns a separate `Order` — they see their `Fulfillment` groups and `OrderLine`s within shared orders, pre-filtered server-side (never trust a frontend `sellerId`).
|
||||
|
||||
```ts
|
||||
interface SellerOrganization { id; marketplaceId; legalName; status:'pending'|'approved'|'suspended'|'rejected'; bankDetailsRef; createdAt }
|
||||
interface SellerUser { id; sellerOrganizationId; role: SellerRole; email; status:'active'|'invited'|'suspended' }
|
||||
interface SellerMarketplaceMembership { sellerOrganizationId; marketplaceId; status }
|
||||
interface SellerIntegration { sellerOrganizationId; apiCredentialRef; webhookUrl?; lastSyncAt?; lastSyncError? }
|
||||
```
|
||||
|
||||
`POST /api/seller/v1/onboarding`, `GET /profile`, `GET/POST/PATCH /offers`, `POST /offers/bulk-price-update`, `GET /orders`, `PATCH /orders/{orderId}/fulfillment/{fulfillmentId}`, `GET /finance/accruals|settlements`, `POST /finance/bank-details` (step-up + audit, optional maker/checker), `GET /team`, `POST /team/invite`, `GET /integrations`. Every endpoint enforces `SellerUser.role` server-side; the query layer carries an implicit `WHERE sellerOrganizationId = :authenticatedSeller` — a seller can never reach another seller's data by parameter manipulation.
|
||||
|
||||
### 12.2 Connectors — external order ingest (Phase 4)
|
||||
|
||||
A new partner connector is an onboarding action, not a code change. Fixed shared pipeline: ingest → verify/auth → persist `RawExternalEvent` **before parsing** → normalize to a canonical shape → map `externalSku → Offer` (no mapping → Unmatched queue, never silent) → create/update order (`source:'external'`) → emit events → push status back if supported.
|
||||
|
||||
```ts
|
||||
interface Connector { id; marketplaceId; provider; authType:'webhook_signed'|'api_key'|'oauth2'; credentialRef; pollingIntervalSeconds?; cursorState?; status:'active'|'paused'|'error' }
|
||||
interface RawExternalEvent { id; connectorId; payload; receivedAt; processedAt? }
|
||||
interface ExternalOrderMapping { connectorId; externalSellerId; externalProductId; externalSku; internalSellerId; internalOfferId }
|
||||
interface DeadLetter { id; connectorId; rawEventId; reason; retryCount; lastAttemptAt; resolvedAt? }
|
||||
interface ExternalOrderEvent { connectorId; externalOrderId; externalCreatedAt; customer; lines[{externalSku,qty,unitPriceMinor,currency}]; totalMinor; currency; rawEventId }
|
||||
```
|
||||
|
||||
Idempotency key = `connectorId + externalOrderId/eventId`; **zero duplicate orders on repeated delivery**. `POST /api/providers/v1/{connector}/webhook`, `GET/POST/PATCH /api/admin/v2/integrations[/{id}]`, `GET /{id}/unmatched`, `POST /{id}/unmatched/{eventId}/resolve`, `POST /{id}/dead-letter/{id}/replay`. SLA: webhook 99% under 60 s; polling delay ≤ `interval + 60`; every error carries a trace id.
|
||||
|
||||
### 12.3 Content modules — mall-class tenants (Phase 10)
|
||||
|
||||
Lowest priority, only after commerce core is real. Entities (all carry `marketplaceId`, audit, and the §11 draft/publish flow): `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `Lead`, `NewsPromo`, `MallSettings`. `GET/POST/PATCH/DELETE /api/admin/v2/content/{shops|shop-categories|services|floors|scheme-pins|rent-listings|news}`, `POST /content/rent-listings/{id}/leads`, `PATCH /content/mall-settings`. Commerce modules are platform-ready but off via `MarketplaceFeatureSet` — the point is proving a tenant flips `catalog`/`cart`/`checkout` to `true` later with zero code change.
|
||||
|
||||
**Server-side content validation (FH-2.10).** The server re-runs the editor's rules on write. Clamp-and-fallback: clamp out-of-range numbers, fall back an invalid colour, blank a URL that isn't a same-origin path or `https://`, trim/truncate text. Structural violations (unknown block type, malformed id, too many blocks/ids) → `400`. Limits published as one schema both sides read. Referential checks (block → deleted category / unpublished offer) are publish blockers unless a fallback is declared.
|
||||
|
||||
### 12.4 Analytics (Track A) — start early, longest lead time
|
||||
|
||||
`AnalyticsEvent { eventType, marketplaceId, sessionId, customerId?, timestamp, properties, isSynthetic }`. `POST /api/v2/storefront/analytics/events`. Backend is source of truth for `sessionId` and `isSynthetic` — **never trust a client synthetic flag.** Vocabulary: traffic (`session_started`, `page_view`, `product_view`), catalog (`search`, `category_view`, `seller_view`), commerce (`add_to_cart`, `checkout_started`, `payment_started|success|failed`, `order_created`) — emitted from the same code paths that produce `PaymentEvent`/`OrderEvent`, not a drifting parallel layer. `OperationalMetric` for latencies/lag. **Synthetic traffic** is staging/demo only, `isSynthetic:true` set server-side by environment/token — reports filter it by construction. `GET /api/admin/v2/analytics/funnel|operational|quality`, `GET /api/v2/storefront/search/trending`.
|
||||
|
||||
### 12.5 Partner provisioning — inbound (`/api/partner/v1/`)
|
||||
|
||||
Partners provision their own merchant hierarchy, then payments route back to the correct leaf. **Deliberately generic** — no partner name in any entity/field/endpoint; partner-specific behaviour lives in a `PartnerProfile` config row.
|
||||
|
||||
Four fixed levels `Company → Project → Store(=Marketplace) → PaymentPoint`; middle levels optional per profile. `ProvisioningNode { id, level, parentId, companyId, path[], environment:'TEST'|'LIVE', status:'active'|'suspended'|'disabled', externalReference, displayName, ... }`. `path` is server-computed; nodes never re-parent (move = disable + create); `disable` cascades terminally, `suspend` cascades reversibly by cascade id; creating a node never enables money. `TEST`/`LIVE` are a hard partition (cross-env → `403`).
|
||||
|
||||
Write: `POST /companies/{id}/projects`, `/projects/{id}/stores`, `/stores/{id}/payment-points`, `PATCH /nodes/{id}/status`, `POST /nodes/{id}/disable`. Read: `GET /nodes/{id}`, `/companies/{id}/hierarchy`, `/nodes/lookup?externalReference=`, `/companies/{id}/audit`. Every `POST` needs `Idempotency-Key` (scope `(partnerId, endpoint, key)`, 24 h, same key+body → replay, +different body → `409`, no partial hierarchy). **Signed requests** (ed25519/rsa-pss), private key never transmitted, ±5 min skew, nonce replay rejected; authority is the credential's `scopeNodeId` subtree, a credential can never widen its own scope. `POST/GET/rotate/DELETE /credentials`. Stable error codes (`validation_failed 422`, `scope_forbidden 403`, `environment_mismatch 403`, `node_disabled 409`, `signature_invalid 401`, …). Partner-facing serialization uses the partner's own field names via `PartnerProfile.routingFieldNames`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Infra, tenant routing, deploy
|
||||
|
||||
**Deterministic hostname rule.** One API hostname per base domain: `example.com`, `store1.example.com`, `www.example.com` all use `https://api.example.com`. Localhost is the only exception (local `/api` proxy).
|
||||
|
||||
**Backend must,** for every request on the shared `api.<base-domain>`: use `X-Storefront-Host` (nginx derives it from a validated browser `Origin`, sends it as upstream `Host`, keeps the shared API host in `X-Forwarded-Host`); not infer a subdomain tenant from the API `Host`; resolve the normalized storefront hostname through the domain registry; reject unknown/disabled/unverified domains with `403` before reading tenant data (never fall back to a default tenant); bind the session to the resolved tenant and reject a mismatch; trust `X-Storefront-Host`/`X-Forwarded-*` only from the known proxy; return JSON for `/bootstrap` with a tenant identity matching the domain (HTML or a default-tenant response is a fault).
|
||||
|
||||
**CORS.** Echo the exact validated storefront origin, `Access-Control-Allow-Credentials: true`, `Vary: Origin`, methods `GET,POST,PUT,PATCH,DELETE,OPTIONS`, headers `Authorization, Content-Type, AdminWebSessionID, X-Requested-With`, preflight `204`. Never `*` with credentials.
|
||||
|
||||
**nginx/TLS.** `scripts/deploy/configure-api-domain.sh --domain … --email … --upstream https://127.0.0.1:445` (idempotent, root) creates the shared `api.<domain>`, issues/renews its cert, configures CORS, proxies all paths. Subdomains need no extra API DNS/cert.
|
||||
|
||||
**CI/CD.** `deploy.yml` runs the same configurator before activating a frontend release. Secrets: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_KNOWN_HOSTS`, `STOREFRONT_DOMAINS`, `CERTBOT_EMAIL`, `BACKEND_UPSTREAM`. One-time `server-setup.sh` installs the root-owned configurator and host hardening (`../DEPLOYMENT.md` §3.2).
|
||||
|
||||
**Structural DB isolation (FH-D.2).** Data network `internal: true`, API bound to loopback, `no-new-privileges` on every service. **Restore drill (FH-D.1):** WAL archiving (`wal_level=replica`, `archive_mode=on`, `archive_timeout=300`) plus a scheduled restore-check that restores into a clean environment and records the result.
|
||||
|
||||
**Acceptance:** `curl -fsS https://api.example.com/bootstrap | jq -e 'type=="object"'` and an OPTIONS preflight both pass; the bundle contains no fixed marketplace API hostname; unknown domains `403`; API never returns the Angular `index.html` fallback.
|
||||
|
||||
---
|
||||
|
||||
## 14. Change log
|
||||
|
||||
Append here whenever a section changes. Newest first.
|
||||
|
||||
- **2026-08-22** — Added `published: boolean` to the bootstrap response contract (§1.1): frontend now renders a built-in generic placeholder (all feature flags on) for any marketplace with no published revision, decided from this one field rather than HTTP status. See [Brand-bootstrap design](../superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md).
|
||||
- **2026-08-22** — Consolidated the entire `docs/backend/` set into this one file per the single-doc rule; folded in the admin credential (login/password) auth handoff (§1.2). No contract content changed; the former per-phase files are removed.
|
||||
- **2026-08-21** — Harvest additions (`FH-*`) folded in across §4–§13, from the parallel-platform review ([ADR-0006](../context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md)): atomic reservation, inventory journal, idempotency constraints, session model, origin allowlist, secret envelope, order public token, revision immutability/clone/preview, tenant resolution hardening, server-side content validation, digital code pools, order-manager contour, provider-agnostic identity + VK/Yandex + Telegram migration, host hardening.
|
||||
- **2026-08-18** — RoutingContext + Company/Project/PaymentPoint hierarchy added (partner provisioning); backend ownership answered (separate developer).
|
||||
- **2026-08-17** — Payment chain freeze lifted (Sprint 0.1); FX source decided in-house.
|
||||
|
||||
---
|
||||
|
||||
## 15. Acceptance tests
|
||||
|
||||
Backend integration tests — the frontend can't prove a race or a replay against a mock.
|
||||
|
||||
| # | Scenario | Passes when | Guards |
|
||||
|---|---|---|---|
|
||||
| A1 | Two concurrent checkouts for the last unit | One payable order, one clean `409` | Inv. 3 |
|
||||
| A2 | Same provider webhook delivered twice | Order completes once, stock moves once, one notification | Inv. 4 |
|
||||
| A3 | A price sent in a checkout request | Ignored; charged amount is the server's | Inv. 2 |
|
||||
| A4 | Unknown/unverified `Host` | `404`, no other tenant's data | Inv. 1 |
|
||||
| A5 | `MARKETPLACE_ADMIN` for A queries B directly | `403`, not empty | Inv. 8 |
|
||||
| A6 | Cross-origin POST with a valid session cookie | Refused | §4.3 |
|
||||
| A7 | Any credential value searched for in responses/logs/bundle | Absent | Inv. 5 |
|
||||
| A8 | Rollback a design revision | Revision restored, live inventory untouched | Inv. 6–7 |
|
||||
| A9 | Mutation while a preview cookie is present | `404` | §11 |
|
||||
| A10 | Hand-crafted config the editor would reject | Refused | §12.3 |
|
||||
| A11 | Unpaid order requests its digital code | Empty code list | §8 |
|
||||
| A12 | Re-run the same import file | Updates, no duplicate | §8 |
|
||||
| A13 | Second VK login, same `providerUserId` | Same `Customer`, no duplicate | §10 |
|
||||
| A14 | VK account already bound to another customer | Conflict resolution, no silent rebind | §10 |
|
||||
| A15 | Unlink a customer's only identity | `409` | §10 |
|
||||
|
||||
---
|
||||
|
||||
## 16. Build order
|
||||
|
||||
1. **Launch gate (P0):** money model (§5) → orders/events (§9) → catalog/offers/inventory (§8) → connectors (§12.2). Track S (§4.6, §4.9) gates the launch — enforce it, nothing does today. Track A (§12.4) starts in parallel with §5 (longest lead time). Read the partner API (§12.5) before implementing §5 — it adds RoutingContext to the payment tables.
|
||||
2. **Publish & content:** §11 (preview/revision/clone/tenant hardening), §12.3 content validation.
|
||||
3. **Identity:** unblock FH-0.1, then §10 in order VK → Yandex → Telegram migration → OTP. Frontend already built.
|
||||
4. **Digital goods & manager contour:** §8 code pools, §4.9.
|
||||
5. **Continuous:** §13 ops.
|
||||
|
||||
---
|
||||
|
||||
## 17. Dev setup (day one)
|
||||
|
||||
1. Start/configure PostgreSQL; create db + user.
|
||||
2. Design the schema from these contracts (schema is the backend's own call; tenant scoping from day one).
|
||||
3. Build the API service on `127.0.0.1:8080` — nginx already proxies `/api/`.
|
||||
4. Implement the **bootstrap config endpoint** (§1.1) — without it the frontend can't render.
|
||||
5. Implement the Telegram session endpoints — login is fully built client-side, blocked only on these.
|
||||
6. Implement `GET /api/identity/v1/session/permissions` (§4.6) — frontend guards derive from it.
|
||||
7. Seed per-marketplace bootstrap admins (§4.6).
|
||||
|
||||
Steps 4–6 unblock the entire frontend.
|
||||
|
||||
---
|
||||
|
||||
## 18. Open decisions
|
||||
|
||||
- **FH-0.1** — central identity host + one-VK-account-across-storefronts (§10). Blocks identity.
|
||||
- Additional payment providers (wallets/BNPL) — new adapter, business decision (§7).
|
||||
- Per-connector adapters — written per partner at onboarding (§12.2).
|
||||
- Backfill of Company/Project/PaymentPoint for existing marketplaces — sequence in §11, not scheduled.
|
||||
- CI registry reachability (reverse proxy + TLS, or a different registry).
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: ADR-0001
|
||||
title: Extract auth and payment into shared @marketplaces packages
|
||||
status: active
|
||||
date: 2026-08-17
|
||||
supersedes: []
|
||||
tags: [architecture, auth, payment, monorepo]
|
||||
---
|
||||
|
||||
# ADR-0001: Extract auth and payment into shared @marketplaces packages
|
||||
|
||||
## Context
|
||||
|
||||
`marketplaces` currently owns auth end-to-end: customer auth (`core/auth` — VK ID, OTP, session, facade), admin auth (`core/admin-auth` — ed25519-verified admin sessions, permission guards, interceptor), and a legacy `services/auth.service.ts`. Payment/finance logic (`core/finance`, `core/pricing`) is server-owned per [Phase 1](../../backend/BACKEND-INTEGRATION.md) and [Phase 7](../../backend/BACKEND-INTEGRATION.md) contracts — the frontend piece is thin (gateways/tokens, no business logic).
|
||||
|
||||
Multiple marketplace projects beyond this repo need the same auth and payment client logic. Duplicating it per-project drifts fast (auth bugs get fixed in one place, not others) and blocks a consistent security posture across projects — directly relevant to [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md), which already treats auth/RBAC as the single most serious cross-cutting concern.
|
||||
|
||||
## Decision
|
||||
|
||||
Extract auth and payment client logic into two standalone, independently versioned npm packages:
|
||||
|
||||
- `@marketplaces/auth` — customer auth (VK ID/OTP/session), admin auth (ed25519 verification, permission guards, interceptors), token/session management.
|
||||
- `@marketplaces/payment` — payment/finance client gateways, FX/pricing models, checkout client contracts (thin — business logic stays backend per Phase 1/7).
|
||||
|
||||
Each package:
|
||||
1. Lives in its own git repo (handed over separately; this repo does not host it long-term).
|
||||
2. Is consumed by `marketplaces` (and other projects) as an installed node_modules dependency — imported, never copy-pasted.
|
||||
3. Is versioned with semver; CI on the package repo auto-bumps and publishes on push to `main`, driven by conventional commit prefixes already used in this repo (`feat:`/`fix:`/etc — semantic-release reads these directly).
|
||||
4. Ships with its own test suite; `marketplaces` treats it as a black-box dependency, not source to edit in place.
|
||||
|
||||
Rollout order: scaffold packages and CI in this repo first (reversible, local-only) → hand over target git repo → publish → migrate `marketplaces` call sites to import from the package → delete the in-repo originals only after the app builds and passes tests against the package.
|
||||
|
||||
## Amendment 2026-08-18 — distribution mechanism
|
||||
|
||||
The original decision left distribution open ("private registry ... or installed straight from git"). A private Verdaccio registry was stood up on the dev server and both packages published to it. **That approach was then abandoned**: the registry listens on `127.0.0.1:4873` behind a firewall allowing only 80/443/SSH, so neither CI runners nor developers could install without an SSH tunnel. That broke `marketplaces`' existing `architecture-governance` workflow, whose `npm ci` step could no longer resolve `@marketplaces/auth`.
|
||||
|
||||
Distribution is now **git release branches**: `release/auth` and `release/payment` in vitanovaPackages, each an orphan branch whose root *is* the package (`package.json` + built `dist/`), force-pushed by CI on every release. Consumers install with `git+<repo>#release/auth` — no registry, no token, no tunnel, no CI secret; anonymous git read suffices.
|
||||
|
||||
The Verdaccio instance still runs but nothing depends on it. Making a registry the primary path again would require a reverse proxy plus TLS on the dev server, which buys nothing over the current approach at this scale.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `marketplaces` loses direct edit access to auth/payment source — changes go through the package's own repo/PR/release cycle. Slower iteration, but consistent behavior across all consuming projects.
|
||||
- ~30 call sites in `marketplaces` (see `core/auth`, `core/admin-auth`, `services/auth.service.ts`, interceptors) need import rewiring during migration — tracked as follow-up work, not done in this ADR.
|
||||
- New failure mode: `marketplaces` builds now depend on `sources.vitanova.network` being reachable. A branch ref also tracks its tip, so an install can pick up a new build — acceptable while the package churns, but pin to a commit SHA once it stabilises.
|
||||
- [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md) §8 (admin provisioning) becomes package-owned behavior once migrated — that doc's endpoint contracts stay backend-side and unaffected, only the frontend client implementation moves.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
id: ADR-0003
|
||||
title: Build partner merchant-provisioning as a generic API, not a per-partner integration
|
||||
status: active
|
||||
date: 2026-08-18
|
||||
supersedes: []
|
||||
tags: [architecture, api, payments, multi-tenant, security, decision]
|
||||
---
|
||||
|
||||
# ADR-0003: Build partner merchant-provisioning as a generic API, not a per-partner integration
|
||||
|
||||
## Context
|
||||
|
||||
A partner asked (2026-08-18) for an API to programmatically manage a merchant hierarchy — Company → Project → Store → PaymentPoint — with idempotent provisioning, `externalReference` lookup, TEST/LIVE separation, public-key credentials with scoped authority and rotation, and payment/callback fields that route a payment unambiguously to one store.
|
||||
|
||||
Their request arrived written in their own vocabulary. Building against that vocabulary directly would produce a partner-shaped API, and the next partner asking for the same capability with different level names would either get a second parallel surface or force a rename through our schema.
|
||||
|
||||
Three facts about our current model made the ask non-trivial:
|
||||
|
||||
1. Nothing exists above `Marketplace` ([Phase 9](../../backend/BACKEND-INTEGRATION.md)). No company, no project.
|
||||
2. Payments carry no store dimension ([Phase 1](../../backend/BACKEND-INTEGRATION.md) §6). Reconciliation can reconstruct *why* an amount was charged but not *who for*.
|
||||
3. We have no partner-facing write API at all. [Phase 4](../../backend/BACKEND-INTEGRATION.md) is outbound/ingest — the opposite direction.
|
||||
|
||||
## Decision
|
||||
|
||||
Build one generic partner provisioning API. Contract: [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md).
|
||||
|
||||
### 1. Partner-specific behaviour is config, never schema
|
||||
|
||||
No partner name appears in any entity, field, endpoint, or status value. Everything partner-varying lives in a `PartnerProfile` row: which levels are required, level name aliases, routing field names, rate-limit tier, key rotation window, webhook field map. **Onboarding a partner is a config row, not a deployment.**
|
||||
|
||||
Deliberately *not* configurable, because configurability there breaks reconciliation or safety: status values and transitions, idempotency semantics, environment partitioning, signature scheme, the four-level ceiling.
|
||||
|
||||
### 2. Fixed four levels with optional middles, not a free-form tree
|
||||
|
||||
`company → project → store → payment_point`. Middle levels are omittable per partner profile; depth is never partner-defined. An arbitrary-depth tree would push every downstream consumer — routing, reconciliation, settlement, audit — into handling shapes no partner actually has.
|
||||
|
||||
### 3. Credentials are node-scoped
|
||||
|
||||
The partner asked us to choose between per-company, per-project, and per-store credentials. We answer all three with one mechanism: a credential binds to **any single node**, and its authority is that node's subtree. Partner keypairs are partner-generated; we hold only the public key. Rotation runs with a bounded overlap; revocation is immediate and irreversible.
|
||||
|
||||
### 4. Level mapping onto our model
|
||||
|
||||
| Partner level | Our entity |
|
||||
|---|---|
|
||||
| `company` | new, thin |
|
||||
| `project` | new, thin — a product line (e.g. `marketplaces`) |
|
||||
| `store` | `Marketplace` (Phase 9), gains `companyId`/`projectId`/`externalReference` |
|
||||
| `payment_point` | new — one payment method accepted at one marketplace (`qr`, `card`; both ship today) |
|
||||
|
||||
`PaymentPoint` is an acceptance channel, not a physical till and not a settlement account. Registering one never enables real money — financial enablement is a separate approved flow that sets `providerAccountRef`.
|
||||
|
||||
### 5. Seller is excluded from the hierarchy
|
||||
|
||||
`Seller` ([Phase 5](../../backend/BACKEND-INTEGRATION.md)) is orthogonal. A payment routes to one payment point, is reconciled there, and only then splits across the sellers whose lines the order contains ([Phase 7](../../backend/BACKEND-INTEGRATION.md) §3.1). Putting `Seller` in the partner hierarchy would force every partner to model our multi-seller concept, which most do not have.
|
||||
|
||||
### 6. RoutingContext lands in Phase 1 before implementation, not after
|
||||
|
||||
`RoutingContext` (companyId, routingPath, leafNodeId, environment, merchantReference, providerPaymentId) is required on `CheckoutSession`, `PaymentIntent`, `Payment`, `Refund`, `ReconciliationRecord`. Frozen at checkout-session creation, immutable thereafter.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Cost now:** two new entities (`Company`, `Project`), one new leaf (`PaymentPoint`), three amended contracts (Phases 1, 7, 9) plus Track S §4.1, and a backfill for existing marketplaces (Phase 9 §1.2).
|
||||
|
||||
**Cost avoided:** retrofitting a routing dimension onto a populated payments table after launch; a second parallel provisioning surface for partner number two.
|
||||
|
||||
**Accepted limits:**
|
||||
- A partner needing more than four levels cannot be served without a contract change. Judged unlikely enough to be worth the simplicity.
|
||||
- Backfilled rows carry a synthetic company and project. `externalReference` stays null for them.
|
||||
- Partners cannot create companies through the API — company creation stays a commercial, out-of-band action.
|
||||
|
||||
**Unaffected:** the `@marketplaces/auth` / `@marketplaces/payment` package split ([ADR-0001](ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md)). The provisioning API is backend-side; nothing about it belongs in a frontend package.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: ADR-0004
|
||||
title: Derive each API host from the complete storefront host
|
||||
status: superseded
|
||||
date: 2026-08-20
|
||||
supersedes: []
|
||||
tags: [architecture, multi-tenant, api, routing, dns]
|
||||
superseded_by: [ADR-0005]
|
||||
---
|
||||
|
||||
# ADR-0004: Derive each API host from the complete storefront host
|
||||
|
||||
> Superseded by [ADR-0005](ADR-0005-share-api-host-across-storefront-subdomains.md).
|
||||
|
||||
## Context
|
||||
|
||||
One production bundle serves root domains and arbitrary storefront subdomains.
|
||||
The old bundle embedded `api.dexarmarket.ru`, while an earlier correction used
|
||||
a same-origin `/backend` gateway. Neither expresses the required domain rule:
|
||||
each storefront has a corresponding API hostname derived from its full host.
|
||||
|
||||
Examples:
|
||||
|
||||
- `example.com` uses `api.example.com`.
|
||||
- `store1.example.com` uses `api.store1.example.com`.
|
||||
|
||||
Bootstrap, auth, legacy endpoints, and versioned endpoints must not use
|
||||
different base-host selection rules.
|
||||
|
||||
## Decision
|
||||
|
||||
At runtime the frontend prefixes the complete browser hostname with `api.` and
|
||||
keeps the browser protocol: `{protocol}//api.{hostname}`.
|
||||
|
||||
- Bootstrap loads from `https://api.{hostname}/bootstrap`.
|
||||
- Auth receives the same derived base through `AUTH_API_URL`.
|
||||
- Legacy endpoints append their existing paths to that base.
|
||||
- Versioned `/api/...` endpoints retain the `/api` prefix.
|
||||
- Localhost and loopback continue to use the local `/api` development proxy.
|
||||
- An explicit `tenantApiBaseUrls` entry may override the convention for an
|
||||
exceptional host, without changing the shared bundle.
|
||||
|
||||
The complete hostname is preserved. In particular, `www.example.com` maps to
|
||||
`api.www.example.com`; no label is stripped or interpreted by the frontend.
|
||||
|
||||
## Consequences
|
||||
|
||||
One artifact works on root domains and nested storefront subdomains without a
|
||||
tenant allowlist or per-domain build. Every API hostname must have DNS, TLS, a
|
||||
working reverse proxy, and CORS configured for its corresponding storefront.
|
||||
|
||||
A wildcard such as `*.example.com` does not cover the multi-label hostname
|
||||
`api.store1.example.com`; nested API names need explicit certificates/DNS or a
|
||||
certificate and routing strategy that covers that depth. Backend tenant lookup
|
||||
must recognize `api.<storefront-host>` as the API alias of `<storefront-host>`.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: ADR-0005
|
||||
title: Share one API host across storefront subdomains
|
||||
status: active
|
||||
date: 2026-08-20
|
||||
supersedes: [ADR-0004]
|
||||
tags: [architecture, multi-tenant, api, routing, dns]
|
||||
---
|
||||
|
||||
# ADR-0005: Share one API host across storefront subdomains
|
||||
|
||||
## Context
|
||||
|
||||
One frontend bundle serves a base storefront domain and tenant subdomains. The
|
||||
API is shared at the base-domain level; a tenant subdomain must not create a
|
||||
nested API hostname.
|
||||
|
||||
## Decision
|
||||
|
||||
- `example.com`, `store1.example.com`, and `www.example.com` all use
|
||||
`https://api.example.com`.
|
||||
- The complete storefront hostname remains the tenant hint. nginx validates the
|
||||
browser Origin and forwards that hostname as `X-Storefront-Host`.
|
||||
- Backend tenant lookup trusts that header only from the known proxy, verifies
|
||||
it against the domain registry, and binds authenticated sessions to the same
|
||||
tenant.
|
||||
- Localhost continues through `/api`. `tenantApiBaseUrls` remains available for
|
||||
public-suffix or custom-domain exceptions.
|
||||
|
||||
## Consequences
|
||||
|
||||
Tenant subdomains need no extra API DNS records or certificates. CORS must echo
|
||||
the exact allowed storefront origin, while unknown or disabled domains still
|
||||
receive `403` from the backend. The shared API `Host` alone cannot identify a
|
||||
subdomain tenant.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
id: ADR-0006
|
||||
title: Harvest mechanisms from the parallel platform, keep our architecture
|
||||
status: active
|
||||
date: 2026-08-21
|
||||
tags: [architecture, security, contracts, platform, governance]
|
||||
---
|
||||
|
||||
# ADR-0006: Harvest mechanisms from the parallel platform, keep our architecture
|
||||
|
||||
## Context
|
||||
|
||||
A second team built a competing platform monorepo — NestJS/Fastify API, PostgreSQL/Prisma, two Angular apps, Docker/Nginx infrastructure — sharing an older `dexarmarket` ancestor with this repo. On 2026-08-11 they received a snapshot of our code, audited it, and vendored it into their tree as `reference/parallel-frontend/`, classified as a UI/UX reference rather than production. Their handoff document ranks our work fifth of five priority sources.
|
||||
|
||||
Full comparison: [FORK-ANALYSIS-2026-08-21.md](../../FORK-ANALYSIS-2026-08-21.md).
|
||||
|
||||
The asymmetry is real and runs both ways. They have working server-side truth: tenancy resolved from a verified `Host`, RBAC enforced per endpoint, hashed server sessions with mandatory TOTP, encrypted per-tenant payment credentials, idempotent webhooks, immutable publish revisions, WAL archiving and a restore check. We have the deeper frontend — 530 `.ts` files against 189, 158 components, 30 spec files plus Playwright e2e against their 25 unit tests and no e2e at all, Angular 22 with a clean production audit against their 21.2.18 with open high findings, and architecture governance in CI that they have no equivalent of.
|
||||
|
||||
Three of their audit findings against us were still live when re-checked on 2026-08-21, and two of them were defects rather than posture: a plaintext `ip-api.com` call that mixed-content blocking had silently killed in production, and an unvalidated bank URL rendered into an iframe that most acquirers refuse to be framed in.
|
||||
|
||||
## Decision
|
||||
|
||||
Take the mechanisms. Do not take the architecture, and do not merge the codebases.
|
||||
|
||||
- **Harvest** specific, proven mechanisms into our backend contracts under a traceable `FH-*` tag: conditional-write stock reservation, idempotency as a unique constraint, hashed server sessions with per-contour cookies, an origin allowlist on cookie-authenticated mutations, an AES-256-GCM envelope for stored secrets, signed read-only preview, revision immutability and clone semantics, an append-only inventory journal, server-side content validation, and digital code pools.
|
||||
- **Reject** anything that would regress us: their Angular version, their mock service still shipping in a backoffice, their test posture, their environment-pinned manager scope, their hardcoded server IP, and their narrower section schema.
|
||||
- **Keep ours where ours is better** and say so explicitly, so it does not get relitigated: our marketplace lifecycle state machine is richer than theirs, our bulk-import preview/apply flow is equivalent, our editor validation engine is stronger — it simply needs a server-side counterpart to bind.
|
||||
- **Record the nine invariants** from their handoff as the acceptance gate at the head of our own backend handoff, since they are more falsifiable than anything our delivery plan had.
|
||||
|
||||
## Consequences
|
||||
|
||||
The backend contracts gain normative mechanism text where they previously stated intent, which raises the bar a backend built against them must clear — at the cost of more prescription than these documents originally carried. That trade is deliberate: "the webhook must be idempotent" survives one refactor, `UNIQUE (provider, event_key)` survives every refactor.
|
||||
|
||||
Our repo stays frontend-only. Nothing harvested requires standing up Prisma or NestJS here; anything that would have becomes a contract line instead. Work splits across five lanes — frontend, contracts, the `@marketplaces/auth` package, infrastructure, and process — tracked in [FORK-HARVEST-TODO.md](../../FORK-HARVEST-TODO.md).
|
||||
|
||||
Adopting their invariants and their PR and release discipline as our own means our releases get slower and more evidenced. That is the intended direction.
|
||||
|
||||
The organizational question this ADR does not settle: whether the two implementations converge as their backend plus our frontend. Left unchallenged, their handoff document's ranking becomes the plan of record by default.
|
||||
@@ -4,3 +4,11 @@
|
||||
{"id":"PV-20260713T000000Z-0004","subject":"translatable-fields","predicate":"must-be-modeled-as","object":"generic translations.{lang} map so adding/removing a language automatically exposes/removes translation fields across all translatable objects","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["i18n","constraint"]}
|
||||
{"id":"PV-20260713T000000Z-0005","subject":"admin-app","predicate":"is-isolated-from","object":"marketplace storefront bundle: admin code never ships to storefront and vice versa, though they may share a domain","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["admin","security"]}
|
||||
{"id":"PV-20260713T000000Z-0006","subject":"widgets","predicate":"must-not-own","object":"page spacing or page width; the renderer owns sections, spacing, and page width, widgets own only their internal layout","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["widgets","layout"]}
|
||||
{"id":"PV-20260818T001500Z-a1f3","subject":"auth-and-payment-client-logic","predicate":"is-decided-to-extract-into","object":"standalone versioned npm packages @marketplaces/auth and @marketplaces/payment, installed as dependencies rather than edited in-repo","src":["docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T00:15:00Z","confidence":"high","tags":["architecture","auth","payment","decision"]}
|
||||
{"id":"PV-20260818T104000Z-c7d1","subject":"partner-merchant-provisioning","predicate":"is-decided-to-build-as","object":"one generic inbound API where all partner-specific behaviour is a PartnerProfile config row (required levels, level aliases, routing field names, rate tier); no partner name appears in any entity, field, endpoint or status value","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:40:00Z","confidence":"high","tags":["architecture","api","partner","decision"]}
|
||||
{"id":"PV-20260818T104100Z-e2b8","subject":"partner-hierarchy-levels","predicate":"map-onto","object":"company and project are new thin entities above Marketplace; store IS Marketplace (Phase 9); payment_point is new and equals one payment method accepted at one marketplace (qr, card)","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:41:00Z","confidence":"high","tags":["architecture","multi-tenant","payments","decision"]}
|
||||
{"id":"PV-20260818T104200Z-f5a9","subject":"Seller","predicate":"is-excluded-from","object":"the partner provisioning hierarchy; a payment routes to exactly one payment point, is reconciled there, and only then splits across sellers in Phase 7 settlement","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md"],"status":"active","kind":"constraint","updated_at":"2026-08-18T10:42:00Z","confidence":"high","tags":["payments","reconciliation","sellers"]}
|
||||
{"id":"PV-20260818T104300Z-b3c4","subject":"RoutingContext","predicate":"is-required-on","object":"CheckoutSession, PaymentIntent, Payment, Refund and ReconciliationRecord; frozen at checkout-session creation and immutable thereafter, so a payment is always attributable to exactly one payment point","src":["docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md","docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md"],"status":"active","kind":"constraint","updated_at":"2026-08-18T10:43:00Z","confidence":"high","tags":["payments","reconciliation","contract"]}
|
||||
{"id":"PV-20260818T104400Z-d9e2","subject":"partner-api-credentials","predicate":"are-scoped-by","object":"a single node whose subtree defines authority; we hold only the partner-generated public key, rotation runs on a bounded overlap window and revocation is immediate and irreversible","src":["docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md","docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:44:00Z","confidence":"high","tags":["security","credentials","partner"]}
|
||||
{"id":"PV-20260818T104500Z-a6f7","subject":"checkout-payment-methods","predicate":"already-support","object":"both qr and card end to end in src/app/pages/cart/cart.component.ts (separate create paths and separate status pollers); card is not an outstanding gap","src":["src/app/pages/cart/cart.component.ts","src/app/services/api.service.ts"],"status":"active","kind":"implemented","updated_at":"2026-08-18T10:45:00Z","confidence":"high","tags":["payments","frontend"]}
|
||||
{"id":"PV-20260820T095500Z-b17e","subject":"tenant-api-routing","predicate":"is-decided-to-use","object":"one runtime-derived API origin per base domain; example.com and store1.example.com both map to api.example.com for bootstrap, auth, legacy, and versioned endpoints","src":["docs/context/adrs/ADR-0005-share-api-host-across-storefront-subdomains.md","src/app/core/config/api-config.service.ts"],"status":"active","kind":"decision","updated_at":"2026-08-20T16:00:00Z","confidence":"high","tags":["architecture","multi-tenant","api","routing","dns"]}
|
||||
|
||||
225
docs/superpowers/plans/2026-08-15-admin-product-views-column.md
Normal file
225
docs/superpowers/plans/2026-08-15-admin-product-views-column.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Admin Product Views Column Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Show a real (currently-zero) per-product view count as a toggleable column in Admin Products list, and document the backend gap that keeps it at zero today.
|
||||
|
||||
**Architecture:** Add `visits: number` to the `AdminProduct` model, default it to `0` everywhere the mock gateway constructs an `AdminProduct`, add `'visits'` to the existing toggleable-column system (`ALL_PRODUCT_COLUMNS`), render it in the table view using the established `isColumnVisible()` pattern.
|
||||
|
||||
**Tech Stack:** Angular signals, existing `LocalStorageService`-backed column-visibility persistence (already built, not touched).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Never fabricate view numbers — the mock gateway has no real tracking source, so `visits` must default to `0`, not a random/seeded number.
|
||||
- Table view only — no grid-view or product-detail-page display (out of scope per design doc).
|
||||
- Follow the existing `isColumnVisible('stock')`-style pattern exactly — no new column-visibility mechanism.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `visits` field, column, and backend doc ask
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/admin/products/models/admin-product.model.ts:81-121` (add field)
|
||||
- Modify: `src/app/features/admin/products/services/admin-products-local.gateway.ts:82-94,133-166` (default the field)
|
||||
- Modify: `src/app/features/admin/products/facade/admin-products.facade.ts:39` (add to column list)
|
||||
- Modify: `src/app/features/admin/products/components/admin-products-list.component.html:96-112` (render column + header)
|
||||
- Modify: `src/app/i18n/en.ts:1647,1670`, `src/app/i18n/ru.ts:1642`, `src/app/i18n/hy.ts:1642`, `src/app/i18n/translations.ts:1655` (i18n keys)
|
||||
- Modify: `BACKEND-API-REFERENCE.md` (new §12.10 ask)
|
||||
- Test: `src/app/features/admin/products/services/admin-products-local.gateway.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `AdminProduct.visits: number`
|
||||
- Produces: `ALL_PRODUCT_COLUMNS` includes `'visits'` (so `AdminProductColumn` union includes `'visits'`)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/products/services/admin-products-local.gateway.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { AdminProductsLocalGateway } from './admin-products-local.gateway';
|
||||
|
||||
describe('AdminProductsLocalGateway visits field', () => {
|
||||
let gateway: AdminProductsLocalGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
gateway = TestBed.inject(AdminProductsLocalGateway);
|
||||
});
|
||||
|
||||
it('defaults visits to 0 on every loaded product', (done) => {
|
||||
gateway.loadProducts({ search: '', categoryId: 'all', visibility: 'all', stockStatus: 'all', page: 1, pageSize: 50 }).subscribe(result => {
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(result.items.every(product => product.visits === 0)).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets visits to 0 on a duplicated product, even if the source had a nonzero count', (done) => {
|
||||
gateway.loadProducts({ search: '', categoryId: 'all', visibility: 'all', stockStatus: 'all', page: 1, pageSize: 50 }).subscribe(result => {
|
||||
const source = result.items[0];
|
||||
gateway.duplicateProduct(source.id).subscribe(duplicated => {
|
||||
expect(duplicated).not.toBeNull();
|
||||
expect(duplicated!.visits).toBe(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note: read `src/app/features/admin/products/models/admin-product.model.ts` for the exact `AdminProductListFilters` shape before writing the test's filter object — if the field names above (`categoryId`, `visibility`, `stockStatus`, `page`, `pageSize`) don't match exactly, use the real ones; don't guess.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-products-local.gateway.spec.ts'`
|
||||
Expected: FAIL — `Property 'visits' does not exist on type 'AdminProduct'` (TS compile error surfaces as a Karma failure).
|
||||
|
||||
- [ ] **Step 3: Add the field to the model**
|
||||
|
||||
In `src/app/features/admin/products/models/admin-product.model.ts`, add to the `AdminProduct` interface (next to `quantity: number;`):
|
||||
|
||||
```typescript
|
||||
quantity: number;
|
||||
visits: number;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Default it in the mock gateway**
|
||||
|
||||
In `src/app/features/admin/products/services/admin-products-local.gateway.ts`, in `toAdminProduct()` (around line 155, next to the `quantity` line):
|
||||
|
||||
```typescript
|
||||
quantity: product.stockStatus === 'out_of_stock' ? 0 : product.stockStatus === 'low_stock' ? 3 : 25,
|
||||
visits: 0,
|
||||
```
|
||||
|
||||
In `duplicateProduct()` (around line 82-91), add `visits: 0` to the override object so a duplicate never inherits the source's count via the `...source` spread:
|
||||
|
||||
```typescript
|
||||
const duplicated: AdminProduct = {
|
||||
...source,
|
||||
archived: false,
|
||||
id: `${source.id}-copy-${Date.now()}`,
|
||||
sku: `${source.sku}-COPY`,
|
||||
slug: `${source.slug}-copy-${Date.now()}`,
|
||||
name: `${source.name} Copy`,
|
||||
visits: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-products-local.gateway.spec.ts'`
|
||||
Expected: PASS (2/2)
|
||||
|
||||
- [ ] **Step 6: Add the column**
|
||||
|
||||
In `src/app/features/admin/products/facade/admin-products.facade.ts:39`, change:
|
||||
|
||||
```typescript
|
||||
export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated'] as const;
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated', 'visits'] as const;
|
||||
```
|
||||
|
||||
In `src/app/features/admin/products/components/admin-products-list.component.html`, add a header cell after the `visibility` header (around line 100):
|
||||
|
||||
```html
|
||||
@if (isColumnVisible('visibility')) { <th scope="col">{{ 'adminProducts.visibility' | translate }}</th> }
|
||||
@if (isColumnVisible('visits')) { <th scope="col">{{ 'adminProducts.views' | translate }}</th> }
|
||||
```
|
||||
|
||||
And a matching body cell after the `visibility` cell (around line 126, right after its closing `}`):
|
||||
|
||||
```html
|
||||
@if (isColumnVisible('visits')) { <td>{{ product.visits }}</td> }
|
||||
```
|
||||
|
||||
The column-picker panel (`admin-products-list.component.html:50-59`) needs no template change — it already iterates `allColumns` generically and looks up `adminProducts.column_<name>`, so it auto-picks up `'visits'` once the i18n key exists (Step 7).
|
||||
|
||||
- [ ] **Step 7: Add i18n keys**
|
||||
|
||||
In `src/app/i18n/translations.ts`, in the `adminProducts` interface block, add two lines (next to `stockStatus: string;` and near the other `column_*` entries):
|
||||
|
||||
```typescript
|
||||
stockStatus: string;
|
||||
views: string;
|
||||
```
|
||||
```typescript
|
||||
column_updated: string;
|
||||
column_visits: string;
|
||||
```
|
||||
|
||||
In `src/app/i18n/en.ts`, `adminProducts` block:
|
||||
```typescript
|
||||
stockStatus: 'Stock status',
|
||||
views: 'Views',
|
||||
```
|
||||
```typescript
|
||||
column_updated: 'Last updated',
|
||||
column_visits: 'Views',
|
||||
```
|
||||
|
||||
In `src/app/i18n/ru.ts`, `adminProducts` block (next to its `stockStatus:` line and its `column_updated:` line — read the file first to find them, they're at different line numbers than en.ts):
|
||||
```typescript
|
||||
views: 'Просмотры',
|
||||
```
|
||||
```typescript
|
||||
column_visits: 'Просмотры',
|
||||
```
|
||||
|
||||
In `src/app/i18n/hy.ts`, `adminProducts` block:
|
||||
```typescript
|
||||
views: 'Դիտումներ',
|
||||
```
|
||||
```typescript
|
||||
column_visits: 'Դիտումներ',
|
||||
```
|
||||
|
||||
(For ru.ts/hy.ts: read the file first, find the exact existing `stockStatus:`/`column_updated:` lines in the `adminProducts` block — there may be more than one `column_updated:` in the file for a different admin domain, only edit the one inside `adminProducts`, at the location already found: `ru.ts:1642` area, `hy.ts:1642` area.)
|
||||
|
||||
- [ ] **Step 8: Run full verification**
|
||||
|
||||
Run: `npx tsc --noEmit -p tsconfig.json`
|
||||
Expected: no errors.
|
||||
|
||||
Run: `npx ng build --configuration development`
|
||||
Expected: build succeeds.
|
||||
|
||||
Run: `npm run test -- --include='**/admin-products-local.gateway.spec.ts'`
|
||||
Expected: PASS (2/2).
|
||||
|
||||
- [ ] **Step 9: Document the backend gap**
|
||||
|
||||
In `BACKEND-API-REFERENCE.md`, after the existing §12.8 section (search for `### 12.8 Admin purchase notifications depend on Orders CRUD being real` — it currently ends right before `### 12.9 Trending search terms`), insert a new section, and renumber `12.9` to `12.10`:
|
||||
|
||||
```markdown
|
||||
### 12.9 Admin product view counts
|
||||
|
||||
**Gap:** Admin Products (§8) runs on a fully separate mock domain from the storefront's live catalog — `AdminProduct.visits` is a new field added to support a "Views" column in Admin Products, but the mock gateway always defaults it to `0` because there is no real tracking source available to the admin domain today. This is unrelated to the storefront's `Item.visits` field (§6, `/items/{id}`), which is live-wired but never displayed anywhere in the UI.
|
||||
|
||||
**Ask:** two options, not mutually exclusive:
|
||||
1. Once admin Products gets a real backend (§10 step 4), include a per-product view/visit count in the response.
|
||||
2. Bridge `AdminProduct.visits` to the storefront's already-live `Item.visits` by product id, if a unified product identity exists between the storefront and admin domains — smaller change than building new tracking infrastructure.
|
||||
|
||||
### 12.10 Trending search terms
|
||||
```
|
||||
|
||||
(The existing body text of the old `### 12.9 Trending search terms` section stays exactly as-is below the renumbered heading — only the heading number changes, from `12.9` to `12.10`.)
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/products/models/admin-product.model.ts src/app/features/admin/products/services/admin-products-local.gateway.ts src/app/features/admin/products/services/admin-products-local.gateway.spec.ts src/app/features/admin/products/facade/admin-products.facade.ts src/app/features/admin/products/components/admin-products-list.component.html src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts src/app/i18n/translations.ts BACKEND-API-REFERENCE.md
|
||||
git commit -m "feat: admin product views column (always 0 until backend tracks it)"
|
||||
```
|
||||
@@ -0,0 +1,944 @@
|
||||
# Admin Purchase Notifications Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Notify admin (toast + topbar bell badge/panel) when a new order lands on the marketplace, poll-based since the backend has no WebSocket/SSE.
|
||||
|
||||
**Architecture:** A single `AdminOrderWatcherService` polls `AdminOrdersLocalGateway.loadOrders()` on an editable interval (default 15s), diffs against a persisted "last notified" order id to fire toasts for genuinely new orders, and exposes a `recentOrders`/`unreadCount` signal pair that the existing (currently-empty) topbar bell panel renders. Poll interval is editable in the admin settings page, same pattern as the currency-rates section added previously.
|
||||
|
||||
**Tech Stack:** Angular 17+ signals, RxJS, Jasmine/Karma (`ng test`), existing `LocalStorageService`/`UserNotificationService`/`TranslateService` patterns.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No WebSocket/SSE available — polling only (confirmed `BACKEND-API-REFERENCE.md:20`).
|
||||
- Persist state via `LocalStorageService` (`getItem`/`setItem`), never raw `localStorage`.
|
||||
- Reuse the existing topbar bell (`admin-layout.component.html:141-157`) instead of adding a new nav badge.
|
||||
- Admin backoffice price/amount displays stay in the order's raw stored currency (no `currencyConvert` pipe) — consistent with every other admin screen.
|
||||
- Route arrays for admin navigation use the pattern `[languageService.currentLanguage(), 'backoffice', 'orders', id]` (no leading `/`), matching `admin-orders-list-page.component.ts:52`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `UserNotificationService` gains an optional click-to-navigate route
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/website/user-experience/services/user-notification.service.ts`
|
||||
- Modify: `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts`
|
||||
- Modify: `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html`
|
||||
- Test: `src/app/features/website/user-experience/services/user-notification.service.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `UserNotificationService.show(message: string, type?: UserNotificationType, durationMs?: number, route?: string[]): void`
|
||||
- Produces: `UserNotification.route?: string[]`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/features/website/user-experience/services/user-notification.service.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { UserNotificationService } from './user-notification.service';
|
||||
|
||||
describe('UserNotificationService', () => {
|
||||
let service: UserNotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(UserNotificationService);
|
||||
});
|
||||
|
||||
it('stores the route on the notification when provided', () => {
|
||||
service.show('New order #1042', 'info', 4000, ['en', 'backoffice', 'orders', 'ord_1']);
|
||||
|
||||
const [note] = service.notifications();
|
||||
expect(note.message).toBe('New order #1042');
|
||||
expect(note.route).toEqual(['en', 'backoffice', 'orders', 'ord_1']);
|
||||
});
|
||||
|
||||
it('leaves route undefined when not provided', () => {
|
||||
service.show('Saved');
|
||||
|
||||
const [note] = service.notifications();
|
||||
expect(note.route).toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/user-notification.service.spec.ts'`
|
||||
Expected: FAIL — `show` has no fourth parameter, `route` does not exist on `UserNotification`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Replace the full contents of `src/app/features/website/user-experience/services/user-notification.service.ts`:
|
||||
|
||||
```typescript
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type UserNotificationType = 'success' | 'info' | 'warning';
|
||||
|
||||
export interface UserNotification {
|
||||
id: string;
|
||||
message: string;
|
||||
type: UserNotificationType;
|
||||
/** Route to navigate to when the notification is clicked. Absent means not clickable. */
|
||||
route?: string[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserNotificationService {
|
||||
private readonly state = signal<UserNotification[]>([]);
|
||||
|
||||
readonly notifications = this.state.asReadonly();
|
||||
|
||||
show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500, route?: string[]): void {
|
||||
const next: UserNotification = {
|
||||
id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
message,
|
||||
type,
|
||||
...(route ? { route } : {}),
|
||||
};
|
||||
|
||||
this.state.update(items => [next, ...items].slice(0, 4));
|
||||
|
||||
setTimeout(() => this.dismiss(next.id), durationMs);
|
||||
}
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.state.update(items => items.filter(item => item.id !== id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Modify `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts` — replace full contents:
|
||||
|
||||
```typescript
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { UserNotification, UserNotificationService } from '../../services/user-notification.service';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-notifications',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './floating-notifications.component.html',
|
||||
styleUrls: ['./floating-notifications.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FloatingNotificationsComponent {
|
||||
private readonly notificationsService = inject(UserNotificationService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly notifications = this.notificationsService.notifications;
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.notificationsService.dismiss(id);
|
||||
}
|
||||
|
||||
navigate(note: UserNotification): void {
|
||||
if (note.route) {
|
||||
void this.router.navigate(note.route);
|
||||
}
|
||||
this.dismiss(note.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace full contents of `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html`:
|
||||
|
||||
```html
|
||||
@if (notifications().length > 0) {
|
||||
<aside class="floating-notifications" aria-live="polite" aria-atomic="true">
|
||||
@for (note of notifications(); track note.id) {
|
||||
<article
|
||||
class="floating-note"
|
||||
[class]="'floating-note floating-note-' + note.type"
|
||||
[class.floating-note-clickable]="!!note.route"
|
||||
(click)="note.route && navigate(note)"
|
||||
>
|
||||
<p>{{ note.message }}</p>
|
||||
<button type="button" (click)="$event.stopPropagation(); dismiss(note.id)" [attr.aria-label]="'common.dismiss' | translate">×</button>
|
||||
</article>
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/user-notification.service.spec.ts'`
|
||||
Expected: PASS (2 specs)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/website/user-experience/services/user-notification.service.ts src/app/features/website/user-experience/services/user-notification.service.spec.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html
|
||||
git commit -m "feat: UserNotificationService supports click-to-navigate toasts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `AdminOrderWatcherService` — polling, diffing, toast firing
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/features/admin/shell/services/admin-order-watcher.service.ts`
|
||||
- Test: `src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts`
|
||||
- Modify: `src/app/i18n/translations.ts`, `src/app/i18n/en.ts`, `src/app/i18n/ru.ts`, `src/app/i18n/hy.ts` (one new key)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AdminOrdersLocalGateway.loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult>` (existing)
|
||||
- Consumes: `UserNotificationService.show(message, type?, durationMs?, route?)` (Task 1)
|
||||
- Consumes: `LocalStorageService.getItem(key): string | null`, `.setItem(key, value): void` (existing)
|
||||
- Produces: `AdminOrderWatcherService.recentOrders: Signal<AdminOrder[]>`
|
||||
- Produces: `AdminOrderWatcherService.unreadCount: Signal<number>`
|
||||
- Produces: `AdminOrderWatcherService.intervalMs: Signal<number>`
|
||||
- Produces: `AdminOrderWatcherService.start(): void`
|
||||
- Produces: `AdminOrderWatcherService.markAllSeen(): void`
|
||||
- Produces: `AdminOrderWatcherService.setIntervalSeconds(seconds: number): void`
|
||||
|
||||
- [ ] **Step 1: Add the i18n key first (needed by the test's translated toast message)**
|
||||
|
||||
In `src/app/i18n/translations.ts`, inside the `topbar:` block under `adminShell` (next to `notificationsEmpty: string;`):
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: string;
|
||||
notificationNewOrder: string;
|
||||
```
|
||||
|
||||
In `src/app/i18n/en.ts`, inside `adminShell.topbar` (next to `notificationsEmpty:`):
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: 'No new notifications',
|
||||
notificationNewOrder: 'New order #{{orderNumber}}',
|
||||
```
|
||||
|
||||
In `src/app/i18n/ru.ts`, inside `adminShell.topbar`:
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: 'Нет новых уведомлений',
|
||||
notificationNewOrder: 'Новый заказ №{{orderNumber}}',
|
||||
```
|
||||
|
||||
In `src/app/i18n/hy.ts`, inside `adminShell.topbar`:
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: 'Նոր ծանուցումներ չկան',
|
||||
notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}',
|
||||
```
|
||||
|
||||
(Match each file's existing `notificationsEmpty` value/indentation exactly — only add the new line after it.)
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { AdminOrderWatcherService } from './admin-order-watcher.service';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model';
|
||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||
|
||||
function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder {
|
||||
return {
|
||||
id,
|
||||
orderNumber,
|
||||
status: 'pending',
|
||||
customer: { name: 'Test Customer', email: 't@example.com', phone: '+70000000000' },
|
||||
payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' },
|
||||
shipping: { address: '', method: '', trackingNumber: '' },
|
||||
items: [],
|
||||
total: 1000,
|
||||
currency: 'RUB',
|
||||
notes: '',
|
||||
internalNotes: '',
|
||||
timeline: [],
|
||||
archived: false,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminOrderWatcherService', () => {
|
||||
let ordersByPoll: AdminOrder[][];
|
||||
let pollIndex: number;
|
||||
let notifications: UserNotificationService;
|
||||
let service: AdminOrderWatcherService;
|
||||
|
||||
function fakeGateway() {
|
||||
return {
|
||||
loadOrders: () => {
|
||||
const items = ordersByPoll[pollIndex] ?? ordersByPoll[ordersByPoll.length - 1];
|
||||
const result: AdminOrdersListResult = { items, total: items.length, page: 1, pageSize: 20 };
|
||||
return of(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pollIndex = 0;
|
||||
ordersByPoll = [
|
||||
[makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')],
|
||||
];
|
||||
|
||||
localStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway },
|
||||
],
|
||||
});
|
||||
|
||||
notifications = TestBed.inject(UserNotificationService);
|
||||
service = TestBed.inject(AdminOrderWatcherService);
|
||||
});
|
||||
|
||||
it('does not toast on the very first poll and marks everything as acknowledged', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
expect(notifications.notifications().length).toBe(0);
|
||||
expect(service.unreadCount()).toBe(0);
|
||||
expect(service.recentOrders().map(o => o.id)).toEqual(['o2', 'o1']);
|
||||
}));
|
||||
|
||||
it('toasts and increments unreadCount for orders newer than the last-notified one', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
pollIndex = 1;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
|
||||
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
|
||||
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
|
||||
]);
|
||||
|
||||
tick(service.intervalMs());
|
||||
|
||||
expect(notifications.notifications().length).toBe(1);
|
||||
expect(notifications.notifications()[0].message).toContain('1003');
|
||||
expect(notifications.notifications()[0].route).toEqual(['ru', 'backoffice', 'orders', 'o3']);
|
||||
expect(service.unreadCount()).toBe(1);
|
||||
}));
|
||||
|
||||
it('markAllSeen resets unreadCount without clearing recentOrders', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
pollIndex = 1;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
|
||||
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
|
||||
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
|
||||
]);
|
||||
tick(service.intervalMs());
|
||||
|
||||
expect(service.unreadCount()).toBe(1);
|
||||
|
||||
service.markAllSeen();
|
||||
|
||||
expect(service.unreadCount()).toBe(0);
|
||||
expect(service.recentOrders().map(o => o.id)).toEqual(['o3', 'o2', 'o1']);
|
||||
}));
|
||||
|
||||
it('setIntervalSeconds updates intervalMs and rejects invalid values', () => {
|
||||
service.setIntervalSeconds(30);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
|
||||
service.setIntervalSeconds(0);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
|
||||
service.setIntervalSeconds(-5);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note: `LanguageService` defaults to `'ru'` (see `language.service.ts:23`), which is why the expected route in the second test starts with `'ru'`.
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts'`
|
||||
Expected: FAIL — `admin-order-watcher.service.ts` does not exist yet.
|
||||
|
||||
- [ ] **Step 4: Implement**
|
||||
|
||||
Create `src/app/features/admin/shell/services/admin-order-watcher.service.ts`:
|
||||
|
||||
```typescript
|
||||
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
|
||||
import { AdminOrder } from '../../orders/models/admin-order.model';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
|
||||
const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1';
|
||||
const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1';
|
||||
const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1';
|
||||
export const DEFAULT_POLL_INTERVAL_MS = 15000;
|
||||
const MIN_POLL_INTERVAL_MS = 1000;
|
||||
const RECENT_ORDERS_LIMIT = 20;
|
||||
const TOAST_DURATION_MS = 4000;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminOrderWatcherService {
|
||||
private readonly gateway = inject(AdminOrdersLocalGateway);
|
||||
private readonly storage = inject(LocalStorageService);
|
||||
private readonly notifications = inject(UserNotificationService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly i18n = inject(TranslateService);
|
||||
|
||||
private readonly recentOrdersSignal = signal<AdminOrder[]>([]);
|
||||
readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();
|
||||
|
||||
private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_KEY));
|
||||
|
||||
readonly unreadCount = computed(() => {
|
||||
const orders = this.recentOrdersSignal();
|
||||
if (orders.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const ackId = this.lastAcknowledgedOrderIdSignal();
|
||||
if (ackId === null) {
|
||||
return orders.length;
|
||||
}
|
||||
const idx = orders.findIndex(order => order.id === ackId);
|
||||
return idx === -1 ? orders.length : idx;
|
||||
});
|
||||
|
||||
private readonly intervalMsSignal = signal<number>(this.readStoredIntervalMs());
|
||||
readonly intervalMs: Signal<number> = this.intervalMsSignal.asReadonly();
|
||||
|
||||
private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY);
|
||||
private timerId: ReturnType<typeof setInterval> | null = null;
|
||||
private started = false;
|
||||
|
||||
start(): void {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
this.poll();
|
||||
this.scheduleNext();
|
||||
}
|
||||
|
||||
setIntervalSeconds(seconds: number): void {
|
||||
if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) {
|
||||
return;
|
||||
}
|
||||
const ms = Math.round(seconds * 1000);
|
||||
this.intervalMsSignal.set(ms);
|
||||
this.storage.setItem(POLL_INTERVAL_KEY, String(ms));
|
||||
if (this.started) {
|
||||
this.scheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
markAllSeen(): void {
|
||||
const newestId = this.recentOrdersSignal()[0]?.id ?? null;
|
||||
this.lastAcknowledgedOrderIdSignal.set(newestId);
|
||||
if (newestId) {
|
||||
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNext(): void {
|
||||
if (this.timerId !== null) {
|
||||
clearInterval(this.timerId);
|
||||
}
|
||||
this.timerId = setInterval(() => this.poll(), this.intervalMsSignal());
|
||||
}
|
||||
|
||||
private poll(): void {
|
||||
this.gateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: RECENT_ORDERS_LIMIT }).subscribe({
|
||||
next: result => this.handleOrders(result.items),
|
||||
error: err => console.error('Error polling for new orders:', err),
|
||||
});
|
||||
}
|
||||
|
||||
private handleOrders(items: AdminOrder[]): void {
|
||||
this.recentOrdersSignal.set(items);
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstPoll = this.lastNotifiedOrderId === null;
|
||||
const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId);
|
||||
const newOrders = isFirstPoll ? [] : (notifyIndex === -1 ? items : items.slice(0, notifyIndex));
|
||||
|
||||
this.lastNotifiedOrderId = items[0].id;
|
||||
this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId);
|
||||
|
||||
if (isFirstPoll) {
|
||||
// Nothing existed to compare against yet - treat current orders as already
|
||||
// acknowledged so a fresh admin session doesn't see the whole history as unread.
|
||||
if (this.lastAcknowledgedOrderIdSignal() === null) {
|
||||
this.lastAcknowledgedOrderIdSignal.set(items[0].id);
|
||||
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = newOrders.length - 1; i >= 0; i--) {
|
||||
const order = newOrders[i];
|
||||
this.notifications.show(
|
||||
this.i18n.t('adminShell.topbar.notificationNewOrder', { orderNumber: order.orderNumber }),
|
||||
'info',
|
||||
TOAST_DURATION_MS,
|
||||
[this.languageService.currentLanguage(), 'backoffice', 'orders', order.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private readStoredIntervalMs(): number {
|
||||
const stored = Number(this.storage.getItem(POLL_INTERVAL_KEY));
|
||||
return Number.isFinite(stored) && stored >= MIN_POLL_INTERVAL_MS ? stored : DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts'`
|
||||
Expected: PASS (4 specs)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/shell/services/admin-order-watcher.service.ts src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
|
||||
git commit -m "feat: AdminOrderWatcherService polls for new orders and toasts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire the watcher into the admin topbar bell
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/admin/shell/admin-layout.component.ts`
|
||||
- Modify: `src/app/features/admin/shell/admin-layout.component.html`
|
||||
- Modify: `src/app/features/admin/shell/admin-layout.component.scss`
|
||||
- Test: `src/app/features/admin/shell/admin-layout.component.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AdminOrderWatcherService.{recentOrders, unreadCount, start, markAllSeen}` (Task 2)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/shell/admin-layout.component.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { signal } from '@angular/core';
|
||||
import { AdminLayoutComponent } from './admin-layout.component';
|
||||
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||
import { AdminOrder } from '../orders/models/admin-order.model';
|
||||
|
||||
function makeOrder(id: string, orderNumber: string): AdminOrder {
|
||||
return {
|
||||
id,
|
||||
orderNumber,
|
||||
status: 'pending',
|
||||
customer: { name: 'Test Customer', email: 't@example.com', phone: '' },
|
||||
payment: { method: 'card', status: 'paid', amount: 500, currency: 'RUB' },
|
||||
shipping: { address: '', method: '', trackingNumber: '' },
|
||||
items: [],
|
||||
total: 500,
|
||||
currency: 'RUB',
|
||||
notes: '',
|
||||
internalNotes: '',
|
||||
timeline: [],
|
||||
archived: false,
|
||||
createdAt: '2026-08-15T10:00:00.000Z',
|
||||
updatedAt: '2026-08-15T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminLayoutComponent notifications bell', () => {
|
||||
let watcherStub: {
|
||||
recentOrders: ReturnType<typeof signal<AdminOrder[]>>;
|
||||
unreadCount: ReturnType<typeof signal<number>>;
|
||||
start: jasmine.Spy;
|
||||
markAllSeen: jasmine.Spy;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
watcherStub = {
|
||||
recentOrders: signal<AdminOrder[]>([makeOrder('o1', '1001')]),
|
||||
unreadCount: signal(1),
|
||||
start: jasmine.createSpy('start'),
|
||||
markAllSeen: jasmine.createSpy('markAllSeen'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AdminLayoutComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrderWatcherService, useValue: watcherStub },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('starts the watcher once on construction', () => {
|
||||
TestBed.createComponent(AdminLayoutComponent);
|
||||
expect(watcherStub.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exposes unreadCount and recentOrders from the watcher', () => {
|
||||
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.unreadCount()).toBe(1);
|
||||
expect(component.recentOrders().map(o => o.id)).toEqual(['o1']);
|
||||
});
|
||||
|
||||
it('marks orders seen when the notifications panel opens', () => {
|
||||
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.toggleNotifications();
|
||||
expect(component.notificationsOpen()).toBe(true);
|
||||
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
||||
|
||||
component.toggleNotifications();
|
||||
expect(component.notificationsOpen()).toBe(false);
|
||||
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-layout.component.spec.ts'`
|
||||
Expected: FAIL — `AdminOrderWatcherService` not referenced by the component yet, `unreadCount`/`recentOrders` don't exist on `AdminLayoutComponent`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/app/features/admin/shell/admin-layout.component.ts`, add the import and field (place near the other service injections):
|
||||
|
||||
```typescript
|
||||
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||
```
|
||||
|
||||
```typescript
|
||||
private readonly orderWatcher = inject(AdminOrderWatcherService);
|
||||
|
||||
readonly unreadCount = this.orderWatcher.unreadCount;
|
||||
readonly recentOrders = this.orderWatcher.recentOrders;
|
||||
```
|
||||
|
||||
In the constructor, after `this.readRouteData();`, add:
|
||||
|
||||
```typescript
|
||||
this.orderWatcher.start();
|
||||
```
|
||||
|
||||
Replace the `toggleNotifications` method:
|
||||
|
||||
```typescript
|
||||
toggleNotifications(): void {
|
||||
this.notificationsOpen.update(open => !open);
|
||||
if (this.notificationsOpen()) {
|
||||
this.orderWatcher.markAllSeen();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a navigation helper next to `adminLinkFor`:
|
||||
|
||||
```typescript
|
||||
goToOrder(orderId: string): void {
|
||||
void this.router.navigate([this.currentLang(), 'backoffice', 'orders', orderId]);
|
||||
this.notificationsOpen.set(false);
|
||||
}
|
||||
```
|
||||
|
||||
In `src/app/features/admin/shell/admin-layout.component.html`, replace the notifications block (lines 141-157):
|
||||
|
||||
```html
|
||||
<div class="admin-layout__notifications">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-layout__icon-button"
|
||||
aria-haspopup="true"
|
||||
[attr.aria-expanded]="notificationsOpen()"
|
||||
[attr.aria-label]="'adminShell.topbar.notifications' | translate"
|
||||
(click)="toggleNotifications()"
|
||||
>
|
||||
<app-icon name="bell" [size]="18" />
|
||||
@if (unreadCount() > 0) {
|
||||
<span class="admin-layout__notifications-badge">{{ unreadCount() }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (notificationsOpen()) {
|
||||
<div class="admin-layout__notifications-panel" role="menu">
|
||||
@if (recentOrders().length === 0) {
|
||||
<p>{{ 'adminShell.topbar.notificationsEmpty' | translate }}</p>
|
||||
} @else {
|
||||
@for (order of recentOrders(); track order.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="admin-layout__notification-item"
|
||||
role="menuitem"
|
||||
(click)="goToOrder(order.id)"
|
||||
>
|
||||
<span class="admin-layout__notification-order">#{{ order.orderNumber }}</span>
|
||||
<span class="admin-layout__notification-customer">{{ order.customer.name }}</span>
|
||||
<span class="admin-layout__notification-amount">{{ order.total }} {{ order.currency }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
In `src/app/features/admin/shell/admin-layout.component.scss`, add (near other `.admin-layout__notifications*` rules if any exist, otherwise at the end):
|
||||
|
||||
```scss
|
||||
.admin-layout__notifications {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-layout__notifications-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-layout__notification-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.admin-layout__notification-item:hover {
|
||||
background: var(--bg-secondary, #f4f6f5);
|
||||
}
|
||||
|
||||
.admin-layout__notification-order { font-weight: var(--font-weight-medium, 500); }
|
||||
.admin-layout__notification-customer { color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); }
|
||||
.admin-layout__notification-amount { font-size: var(--font-size-sm, 0.8125rem); }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-layout.component.spec.ts'`
|
||||
Expected: PASS (3 specs)
|
||||
|
||||
- [ ] **Step 5: Run full build to catch template errors**
|
||||
|
||||
Run: `npx ng build --configuration development`
|
||||
Expected: build succeeds, no template compile errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/shell/admin-layout.component.ts src/app/features/admin/shell/admin-layout.component.html src/app/features/admin/shell/admin-layout.component.scss src/app/features/admin/shell/admin-layout.component.spec.ts
|
||||
git commit -m "feat: wire order watcher into admin topbar bell (badge + panel)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Editable poll interval in admin settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/admin/settings/pages/admin-settings-page.component.ts`
|
||||
- Modify: `src/app/features/admin/settings/pages/admin-settings-page.component.html`
|
||||
- Modify: `src/app/i18n/translations.ts`, `src/app/i18n/en.ts`, `src/app/i18n/ru.ts`, `src/app/i18n/hy.ts`
|
||||
- Test: `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AdminOrderWatcherService.{intervalMs, setIntervalSeconds}` (Task 2)
|
||||
|
||||
- [ ] **Step 1: Add i18n keys**
|
||||
|
||||
In `src/app/i18n/translations.ts`, inside `adminSettings:` (after `currencyRatesSaved: string;`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: string;
|
||||
notificationIntervalExplain: string;
|
||||
notificationIntervalSave: string;
|
||||
notificationIntervalSaved: string;
|
||||
```
|
||||
|
||||
In `src/app/i18n/en.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Rates saved',`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: 'New-order check interval (seconds)',
|
||||
notificationIntervalExplain: 'How often the admin panel polls for new orders to show a notification.',
|
||||
notificationIntervalSave: 'Save interval',
|
||||
notificationIntervalSaved: 'Interval saved',
|
||||
```
|
||||
|
||||
In `src/app/i18n/ru.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Курсы сохранены',`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: 'Интервал проверки новых заказов (сек)',
|
||||
notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.',
|
||||
notificationIntervalSave: 'Сохранить интервал',
|
||||
notificationIntervalSaved: 'Интервал сохранён',
|
||||
```
|
||||
|
||||
In `src/app/i18n/hy.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Փոխարժեքները պահպանվեցին',`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)',
|
||||
notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։',
|
||||
notificationIntervalSave: 'Պահպանել ինտերվալը',
|
||||
notificationIntervalSaved: 'Ինտերվալը պահպանվեց',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { signal } from '@angular/core';
|
||||
import { AdminSettingsPageComponent } from './admin-settings-page.component';
|
||||
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
|
||||
|
||||
describe('AdminSettingsPageComponent notification interval', () => {
|
||||
let watcherStub: {
|
||||
intervalMs: ReturnType<typeof signal<number>>;
|
||||
setIntervalSeconds: jasmine.Spy;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
watcherStub = {
|
||||
intervalMs: signal(15000),
|
||||
setIntervalSeconds: jasmine.createSpy('setIntervalSeconds'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AdminSettingsPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrderWatcherService, useValue: watcherStub },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes the draft from the current interval in seconds', () => {
|
||||
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||
expect(fixture.componentInstance.notificationIntervalSecondsDraft()).toBe(15);
|
||||
});
|
||||
|
||||
it('saveNotificationInterval calls setIntervalSeconds with the draft value', () => {
|
||||
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.notificationIntervalSecondsDraft.set(30);
|
||||
component.saveNotificationInterval();
|
||||
|
||||
expect(watcherStub.setIntervalSeconds).toHaveBeenCalledWith(30);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-settings-page.component.spec.ts'`
|
||||
Expected: FAIL — `notificationIntervalSecondsDraft`/`saveNotificationInterval` don't exist yet.
|
||||
|
||||
- [ ] **Step 4: Implement**
|
||||
|
||||
In `src/app/features/admin/settings/pages/admin-settings-page.component.ts`, add the import:
|
||||
|
||||
```typescript
|
||||
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
|
||||
```
|
||||
|
||||
Add the field and methods to the class (alongside the currency-rates fields):
|
||||
|
||||
```typescript
|
||||
readonly orderWatcher = inject(AdminOrderWatcherService);
|
||||
readonly notificationIntervalSecondsDraft = signal(Math.round(this.orderWatcher.intervalMs() / 1000));
|
||||
readonly showNotificationIntervalSaved = signal(false);
|
||||
|
||||
saveNotificationInterval(): void {
|
||||
this.orderWatcher.setIntervalSeconds(this.notificationIntervalSecondsDraft());
|
||||
this.showNotificationIntervalSaved.set(true);
|
||||
setTimeout(() => this.showNotificationIntervalSaved.set(false), SAVED_MESSAGE_DURATION_MS);
|
||||
}
|
||||
```
|
||||
|
||||
In `src/app/features/admin/settings/pages/admin-settings-page.component.html`, add a new `.settings-card` block after the currency-rates one (before the closing `</section>`):
|
||||
|
||||
```html
|
||||
<div class="settings-card">
|
||||
<h2>{{ 'adminSettings.notificationInterval' | translate }}</h2>
|
||||
<p class="settings-explain">{{ 'adminSettings.notificationIntervalExplain' | translate }}</p>
|
||||
<div class="rate-row">
|
||||
<input
|
||||
class="rate-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
[ngModel]="notificationIntervalSecondsDraft()"
|
||||
(ngModelChange)="notificationIntervalSecondsDraft.set($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="rate-actions">
|
||||
<button type="button" class="save-button" (click)="saveNotificationInterval()">{{ 'adminSettings.notificationIntervalSave' | translate }}</button>
|
||||
<span class="saved-message" *ngIf="showNotificationIntervalSaved()">{{ 'adminSettings.notificationIntervalSaved' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-settings-page.component.spec.ts'`
|
||||
Expected: PASS (2 specs)
|
||||
|
||||
- [ ] **Step 6: Full verification**
|
||||
|
||||
Run: `npx tsc --noEmit -p tsconfig.json`
|
||||
Expected: no errors.
|
||||
|
||||
Run: `npx ng build --configuration development`
|
||||
Expected: build succeeds.
|
||||
|
||||
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts' --include='**/admin-layout.component.spec.ts' --include='**/admin-settings-page.component.spec.ts' --include='**/user-notification.service.spec.ts'`
|
||||
Expected: all specs PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/settings/pages/admin-settings-page.component.ts src/app/features/admin/settings/pages/admin-settings-page.component.html src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
|
||||
git commit -m "feat: editable new-order poll interval in admin settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Verification (after all tasks)
|
||||
|
||||
1. `npm run barry -- kb search --source cq --query "admin order notifications"` if KB sharing is enabled (skip if local-only — see project `CLAUDE.md`).
|
||||
2. Start the dev server, log into `/backoffice`, leave the tab open.
|
||||
3. In another tab (or via `admin-orders-local.gateway.ts`'s seed data timing), wait for the poll interval — confirm no toast fires on first load.
|
||||
4. Trigger a new order (checkout flow in `cart.component.ts`, or temporarily lower `SEED_COUNT`/seed timing in `admin-orders-local.gateway.ts` to simulate one — revert after testing) and confirm: toast appears with the order number, bell badge shows `1`, clicking either navigates to `/backoffice/orders/:id`.
|
||||
5. Open the bell panel without clicking a row — confirm badge clears but the order still lists in the panel.
|
||||
6. Change the interval in Admin Settings, save, confirm the toast "Interval saved" message. no crash on next poll cycle.
|
||||
596
docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md
Normal file
596
docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md
Normal file
@@ -0,0 +1,596 @@
|
||||
# Frontend Default Bootstrap Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** When a marketplace has no published revision, the frontend renders a built-in, all-features-on generic placeholder instead of a broken/empty page — decided from one explicit `published: boolean` field on the `/bootstrap` response, not from HTTP status.
|
||||
|
||||
**Architecture:** Add `published` to `BootstrapConfig`. Add one new frontend-only constant `DEFAULT_BOOTSTRAP: BootstrapConfig`, composed from existing `DEFAULT_HEADER_CONFIG` / `DEFAULT_MARKETPLACE_FEATURES_CONFIG` / `DEFAULT_PLATFORM_MODULES_CONFIG` plus a hardcoded generic shell for the sections with no existing default (`tenant`, `branding`, `theme`, `company`, `featureFlags`, `apiEndpoints`, `localization`, `seo`, `permissions`, `navigation`, `footer`, `pages`, `staticPages`). `ConfigService.loadBootstrap()` swaps its cached snapshot to `DEFAULT_BOOTSTRAP` whenever the fetched response has `published === false`. No provider changes.
|
||||
|
||||
**Tech Stack:** Angular 22, RxJS, Jasmine/Karma (existing `.spec.ts` pattern in this repo).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `published` missing/undefined on a response must be treated as `true` (backward compatible — matches the existing pattern for `modules`/ADR-011).
|
||||
- Fallback is a whole-object swap — no field-level merging with the real response.
|
||||
- Fallback triggers only on the explicit `published: false` signal, never on HTTP failure (existing `catchError` behavior in `ConfigService` is untouched).
|
||||
- `DEFAULT_BOOTSTRAP.featureFlags` and `.features` must have every flag `true`.
|
||||
- Reuse `DEFAULT_HEADER_CONFIG`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG`, `DEFAULT_PLATFORM_MODULES_CONFIG` as-is — do not redefine their values inline.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `published` to the `BootstrapConfig` contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/shared/models/config/bootstrap-config.model.ts`
|
||||
- Modify: `src/assets/mock/bootstrap/bootstrap.json` (add `"published": true` so the existing mock keeps behaving as "already live")
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `BootstrapConfig.published: boolean` — consumed by Task 3 (`ConfigService`).
|
||||
|
||||
- [ ] **Step 1: Add the field to the interface**
|
||||
|
||||
In `src/app/shared/models/config/bootstrap-config.model.ts`, add `published` right after `generatedAt`:
|
||||
|
||||
```ts
|
||||
export interface BootstrapConfig {
|
||||
schemaVersion: string;
|
||||
generatedAt: string;
|
||||
published: boolean;
|
||||
tenant: TenantConfig;
|
||||
branding: BrandingConfig;
|
||||
theme: ThemeConfig;
|
||||
company: CompanyConfig;
|
||||
featureFlags: FeatureFlagsConfig;
|
||||
features?: MarketplaceFeaturesConfig;
|
||||
apiEndpoints: ApiEndpointsConfig;
|
||||
localization: LocalizationConfig;
|
||||
seo: SeoConfig;
|
||||
permissions: PermissionsConfig;
|
||||
header?: HeaderConfig;
|
||||
catalog?: CatalogConfig;
|
||||
layout?: PlatformLayoutConfig;
|
||||
navigation: NavigationConfig;
|
||||
footer?: FooterConfig;
|
||||
productPage?: ProductPageConfig;
|
||||
userExperience?: UserExperienceConfig;
|
||||
pages: PageConfig[];
|
||||
staticPages?: StaticPagesConfig;
|
||||
widgetRegistry?: WidgetRegistryConfig;
|
||||
modules?: PlatformModulesConfig;
|
||||
seller?: SellerConfig;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the mock fixture**
|
||||
|
||||
In `src/assets/mock/bootstrap/bootstrap.json`, add `"published": true,` as the line right after `"generatedAt": "2026-07-03T00:00:00Z",` (line 3).
|
||||
|
||||
- [ ] **Step 3: Compile check**
|
||||
|
||||
Run: `npx tsc --noEmit -p tsconfig.json`
|
||||
Expected: no new errors referencing `bootstrap-config.model.ts` or `bootstrap.json` (the mock file isn't type-checked, but any TS consumer that builds a `BootstrapConfig` object literal without `published` will now fail — confirms the field is wired through).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/shared/models/config/bootstrap-config.model.ts src/assets/mock/bootstrap/bootstrap.json
|
||||
git commit -m "feat: add published field to BootstrapConfig contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add the `DEFAULT_BOOTSTRAP` constant
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/shared/models/config/default-bootstrap.const.ts`
|
||||
- Modify: `src/app/shared/models/config/index.ts` (export the new file)
|
||||
- Test: `src/app/shared/models/config/default-bootstrap.const.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `BootstrapConfig` (Task 1), `DEFAULT_HEADER_CONFIG` from `./header-config.model`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG` from `./features-config.model`, `DEFAULT_PLATFORM_MODULES_CONFIG` from `./platform-modules.model`.
|
||||
- Produces: `DEFAULT_BOOTSTRAP: BootstrapConfig` — consumed by Task 3 (`ConfigService`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/shared/models/config/default-bootstrap.const.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { DEFAULT_BOOTSTRAP } from './default-bootstrap.const';
|
||||
|
||||
describe('DEFAULT_BOOTSTRAP', () => {
|
||||
it('is marked unpublished', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.published).toBe(false);
|
||||
});
|
||||
|
||||
it('has every feature flag turned on', () => {
|
||||
Object.values(DEFAULT_BOOTSTRAP.featureFlags).forEach(value => {
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('has every optional MarketplaceFeaturesConfig flag turned on', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.features).toBeDefined();
|
||||
Object.values(DEFAULT_BOOTSTRAP.features!).forEach(value => {
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('has at least one page with a hero section', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.pages.length).toBeGreaterThan(0);
|
||||
const heroSection = DEFAULT_BOOTSTRAP.pages[0].sections.find(s => s.type === 'hero');
|
||||
expect(heroSection).toBeDefined();
|
||||
});
|
||||
|
||||
it('has a generic brand name, not a real tenant name', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.branding.brandName).toBe('Marketplace');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false`
|
||||
Expected: FAIL — `Cannot find module './default-bootstrap.const'`
|
||||
|
||||
- [ ] **Step 3: Write the constant**
|
||||
|
||||
Create `src/app/shared/models/config/default-bootstrap.const.ts`:
|
||||
|
||||
```ts
|
||||
import { BootstrapConfig } from './bootstrap-config.model';
|
||||
import { DEFAULT_HEADER_CONFIG } from './header-config.model';
|
||||
import { DEFAULT_MARKETPLACE_FEATURES_CONFIG } from './features-config.model';
|
||||
import { DEFAULT_PLATFORM_MODULES_CONFIG } from './platform-modules.model';
|
||||
|
||||
/**
|
||||
* Whole-object fallback rendered whenever the backend reports
|
||||
* `published: false` for the resolved marketplace (no published revision
|
||||
* yet). Every feature flag is on so it doubles as a full-surface product
|
||||
* demo. See docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md.
|
||||
*/
|
||||
export const DEFAULT_BOOTSTRAP: BootstrapConfig = {
|
||||
schemaVersion: '1.0.0',
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
published: false,
|
||||
|
||||
tenant: {
|
||||
id: 'tenant-default-unpublished',
|
||||
slug: 'default',
|
||||
code: 'DEFAULT',
|
||||
host: 'default.local',
|
||||
name: 'Marketplace',
|
||||
websiteBaseUrl: 'https://marketplace.local',
|
||||
builderBaseUrl: 'https://builder.marketplace.local',
|
||||
backofficeBaseUrl: 'https://backoffice.marketplace.local',
|
||||
defaultLocale: 'en',
|
||||
supportedLocales: ['en'],
|
||||
defaultCurrency: 'USD',
|
||||
supportedCurrencies: ['USD'],
|
||||
timezone: 'UTC',
|
||||
},
|
||||
|
||||
branding: {
|
||||
brandName: 'Marketplace',
|
||||
legalName: 'Marketplace',
|
||||
slogan: 'Your store, coming soon',
|
||||
logoUrl: '/icons/icon-192x192.png',
|
||||
logoCompactUrl: '/icons/icon-192x192.png',
|
||||
faviconUrl: '/favicon.ico',
|
||||
appIconUrl: '/icons/icon-192x192.png',
|
||||
supportEmail: 'support@marketplace.local',
|
||||
},
|
||||
|
||||
theme: {
|
||||
themeId: 'default-light',
|
||||
mode: 'light',
|
||||
palette: {
|
||||
primary: '#497671',
|
||||
secondary: '#a1b4b5',
|
||||
accent: '#a7ceca',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
textPrimary: '#1e3c38',
|
||||
textSecondary: '#667a77',
|
||||
backgroundPrimary: '#ffffff',
|
||||
backgroundSecondary: '#f5f5f5',
|
||||
border: '#d3dad9',
|
||||
},
|
||||
typography: {
|
||||
primaryFontFamily: 'DM Sans, sans-serif',
|
||||
headingFontFamily: 'DM Sans, sans-serif',
|
||||
baseFontSize: 16,
|
||||
},
|
||||
spacing: { unit: 4, scale: [0, 4, 8, 12, 16, 24, 32, 48] },
|
||||
borderRadiusScale: { sm: '8px', md: '12px', lg: '16px', xl: '22px' },
|
||||
shadows: {
|
||||
sm: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
md: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
lg: '0 12px 32px rgba(73,118,113,0.2)',
|
||||
},
|
||||
iconSet: 'default',
|
||||
},
|
||||
|
||||
company: {
|
||||
companyName: 'Marketplace',
|
||||
address: { country: '', city: '' },
|
||||
contacts: { email: 'support@marketplace.local' },
|
||||
},
|
||||
|
||||
featureFlags: {
|
||||
wishlist: true,
|
||||
compare: true,
|
||||
reviews: true,
|
||||
questions: true,
|
||||
comments: true,
|
||||
recommendations: true,
|
||||
blog: true,
|
||||
chat: true,
|
||||
analytics: true,
|
||||
notifications: true,
|
||||
coupons: true,
|
||||
loyalty: true,
|
||||
giftCards: true,
|
||||
invoices: true,
|
||||
},
|
||||
features: DEFAULT_MARKETPLACE_FEATURES_CONFIG,
|
||||
|
||||
apiEndpoints: {
|
||||
bootstrap: { path: '/bootstrap', method: 'GET', timeoutMs: 10000 },
|
||||
website: {},
|
||||
builder: {},
|
||||
backoffice: {},
|
||||
},
|
||||
|
||||
localization: {
|
||||
defaultLocale: 'en',
|
||||
supportedLocales: ['en'],
|
||||
currencyByLocale: { en: 'USD' },
|
||||
dictionaries: [{ locale: 'en', dictionaryUrl: '/assets/i18n/en.json', version: '1.0.0' }],
|
||||
},
|
||||
|
||||
seo: {
|
||||
default: { title: 'Marketplace', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
|
||||
byPageKey: {
|
||||
home: { title: 'Marketplace - Home', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
|
||||
},
|
||||
},
|
||||
|
||||
permissions: { definitions: [], roles: [] },
|
||||
|
||||
header: DEFAULT_HEADER_CONFIG,
|
||||
|
||||
navigation: {
|
||||
header: [
|
||||
{ id: 'nav-home', labelKey: 'nav.home', route: '/', icon: 'home', order: 1 },
|
||||
{ id: 'nav-search', labelKey: 'nav.search', route: '/search', icon: 'search', order: 2 },
|
||||
{ id: 'nav-cart', labelKey: 'nav.cart', route: '/cart', icon: 'cart', order: 3 },
|
||||
],
|
||||
footer: [
|
||||
{ id: 'footer-about', labelKey: 'nav.about', route: '/about-us', order: 1 },
|
||||
{ id: 'footer-contacts', labelKey: 'nav.contacts', route: '/contacts', order: 2 },
|
||||
],
|
||||
},
|
||||
|
||||
footer: {
|
||||
paymentIcons: [],
|
||||
copyrightText: { en: '© 2026 Marketplace. All rights reserved.' },
|
||||
legalPageKeys: ['about-us', 'privacy-policy', 'terms-of-service'],
|
||||
},
|
||||
|
||||
staticPages: {
|
||||
'about-us': {
|
||||
route: '/about-us',
|
||||
title: { en: 'About Us' },
|
||||
html: { en: '<h2>About Us</h2><p>This marketplace has not published its storefront yet.</p>' },
|
||||
},
|
||||
'privacy-policy': {
|
||||
route: '/privacy-policy',
|
||||
title: { en: 'Privacy Policy' },
|
||||
html: { en: '<h2>Privacy Policy</h2><p>Placeholder content until publish.</p>' },
|
||||
},
|
||||
'terms-of-service': {
|
||||
route: '/terms-of-service',
|
||||
title: { en: 'Terms of Service' },
|
||||
html: { en: '<h2>Terms of Service</h2><p>Placeholder content until publish.</p>' },
|
||||
},
|
||||
},
|
||||
|
||||
pages: [
|
||||
{
|
||||
id: 'page-home',
|
||||
key: 'home',
|
||||
title: 'Home',
|
||||
route: { path: '/', exact: true },
|
||||
layout: { type: 'default' },
|
||||
seoKey: 'home',
|
||||
visible: true,
|
||||
sections: [
|
||||
{
|
||||
id: 'section-hero',
|
||||
type: 'hero',
|
||||
order: 1,
|
||||
layout: { strategy: 'hero', columns: 1, gap: '1.5rem', align: 'stretch' },
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget-hero-main',
|
||||
type: 'hero',
|
||||
version: '1.0.0',
|
||||
order: 1,
|
||||
padding: '0.5rem 0',
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
props: {
|
||||
title: { en: 'Welcome to Marketplace' },
|
||||
subtitle: { en: 'This storefront has not been published yet' },
|
||||
ctaLabel: { en: 'Learn more' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'section-categories',
|
||||
type: 'categories',
|
||||
order: 2,
|
||||
layout: { strategy: 'grid', columns: 1, gap: '1.5rem', align: 'stretch' },
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget-categories-root',
|
||||
type: 'categories',
|
||||
version: '1.0.0',
|
||||
order: 1,
|
||||
padding: '0.25rem 0',
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
props: { title: 'Categories', source: 'root', emptyMessage: 'No categories available' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
modules: DEFAULT_PLATFORM_MODULES_CONFIG,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Export it from the barrel file**
|
||||
|
||||
In `src/app/shared/models/config/index.ts`, add one line (alphabetical position, after `catalog-config.model`):
|
||||
|
||||
```ts
|
||||
export * from './default-bootstrap.const';
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false`
|
||||
Expected: PASS (5 specs)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/shared/models/config/default-bootstrap.const.ts src/app/shared/models/config/default-bootstrap.const.spec.ts src/app/shared/models/config/index.ts
|
||||
git commit -m "feat: add DEFAULT_BOOTSTRAP placeholder config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Swap to `DEFAULT_BOOTSTRAP` in `ConfigService` when unpublished
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/core/config/config.service.ts`
|
||||
- Test: `src/app/core/config/config.service.spec.ts` (new file — none exists today)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DEFAULT_BOOTSTRAP` (Task 2), `BootstrapConfig.published` (Task 1), existing `CONFIG_PROVIDER` token / `ConfigProvider.loadBootstrap()`.
|
||||
- Produces: no new public method — `loadBootstrap()` and `getBootstrapSnapshot()` keep their existing signatures; behavior changes only in which object ends up cached.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `src/app/core/config/config.service.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { ConfigService } from './config.service';
|
||||
import { CONFIG_PROVIDER } from './config-provider.token';
|
||||
import { ConfigProvider } from './config-provider.interface';
|
||||
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
|
||||
|
||||
function makeRealBootstrap(overrides: Partial<BootstrapConfig> = {}): BootstrapConfig {
|
||||
return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides };
|
||||
}
|
||||
|
||||
describe('ConfigService', () => {
|
||||
let provider: jasmine.SpyObj<ConfigProvider>;
|
||||
|
||||
function setup(response: BootstrapConfig): ConfigService {
|
||||
provider = jasmine.createSpyObj<ConfigProvider>('ConfigProvider', ['loadBootstrap']);
|
||||
provider.loadBootstrap.and.returnValue(of(response));
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ConfigService, { provide: CONFIG_PROVIDER, useValue: provider }],
|
||||
});
|
||||
return TestBed.inject(ConfigService);
|
||||
}
|
||||
|
||||
it('caches the real response when published is true', done => {
|
||||
const real = makeRealBootstrap();
|
||||
const service = setup(real);
|
||||
|
||||
service.loadBootstrap().subscribe(result => {
|
||||
expect(result.tenant.name).toBe('Acme');
|
||||
expect(service.getBootstrapSnapshot()).toEqual(real);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('swaps to DEFAULT_BOOTSTRAP when published is false', done => {
|
||||
const draft = makeRealBootstrap({ published: false });
|
||||
const service = setup(draft);
|
||||
|
||||
service.loadBootstrap().subscribe(result => {
|
||||
expect(result).toEqual(DEFAULT_BOOTSTRAP);
|
||||
expect(service.getBootstrapSnapshot()).toEqual(DEFAULT_BOOTSTRAP);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a missing published field as published (backward compatible)', done => {
|
||||
const legacy = makeRealBootstrap();
|
||||
delete (legacy as Partial<BootstrapConfig>).published;
|
||||
const service = setup(legacy);
|
||||
|
||||
service.loadBootstrap().subscribe(result => {
|
||||
expect(result.tenant.name).toBe('Acme');
|
||||
expect(result).not.toEqual(DEFAULT_BOOTSTRAP);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `ng test --include='**/config.service.spec.ts' --watch=false`
|
||||
Expected: FAIL on the "swaps to DEFAULT_BOOTSTRAP" spec — `result` currently equals `draft` (unpublished, unswapped), not `DEFAULT_BOOTSTRAP`.
|
||||
|
||||
- [ ] **Step 3: Implement the swap**
|
||||
|
||||
Replace the body of `src/app/core/config/config.service.ts` with:
|
||||
|
||||
```ts
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { catchError, map, shareReplay, tap } from 'rxjs/operators';
|
||||
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
|
||||
import { CONFIG_PROVIDER } from './config-provider.token';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ConfigService {
|
||||
private readonly provider = inject(CONFIG_PROVIDER);
|
||||
|
||||
private bootstrapSnapshot: BootstrapConfig | null = null;
|
||||
private bootstrap$?: Observable<BootstrapConfig>;
|
||||
private readonly revisionState = signal(0);
|
||||
|
||||
readonly bootstrapRevision = this.revisionState.asReadonly();
|
||||
|
||||
loadBootstrap(forceRefresh: boolean = false): Observable<BootstrapConfig> {
|
||||
if (this.bootstrapSnapshot && !forceRefresh && this.bootstrap$) {
|
||||
return this.bootstrap$;
|
||||
}
|
||||
|
||||
if (!this.bootstrap$ || forceRefresh) {
|
||||
this.bootstrap$ = this.provider.loadBootstrap().pipe(
|
||||
map(config => (config.published === false ? DEFAULT_BOOTSTRAP : config)),
|
||||
tap(config => {
|
||||
this.bootstrapSnapshot = config;
|
||||
this.revisionState.update(value => value + 1);
|
||||
}),
|
||||
shareReplay(1),
|
||||
catchError(error => {
|
||||
this.bootstrap$ = undefined;
|
||||
this.bootstrapSnapshot = null;
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.bootstrap$;
|
||||
}
|
||||
|
||||
getBootstrapSnapshot(): BootstrapConfig | null {
|
||||
return this.bootstrapSnapshot;
|
||||
}
|
||||
|
||||
applyBootstrapOverride(next: BootstrapConfig): void {
|
||||
const cloned = JSON.parse(JSON.stringify(next)) as BootstrapConfig;
|
||||
this.bootstrapSnapshot = cloned;
|
||||
this.bootstrap$ = of(cloned);
|
||||
this.revisionState.update(value => value + 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The only change from the current file: the `map` operator inserted before `tap`, and the `DEFAULT_BOOTSTRAP` import. `config.published === false` (strict) rather than `!config.published` is deliberate — it makes `undefined`/missing explicitly fall through to "treat as published," matching the backward-compatibility constraint.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `ng test --include='**/config.service.spec.ts' --watch=false`
|
||||
Expected: PASS (3 specs)
|
||||
|
||||
- [ ] **Step 5: Run the full unit suite to check for regressions**
|
||||
|
||||
Run: `ng test --watch=false`
|
||||
Expected: PASS, no new failures (existing consumers of `ConfigService` only rely on `loadBootstrap()`/`getBootstrapSnapshot()`, unchanged signatures).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/core/config/config.service.ts src/app/core/config/config.service.spec.ts
|
||||
git commit -m "feat: fall back to DEFAULT_BOOTSTRAP when marketplace is unpublished"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: E2E smoke test for the unpublished placeholder
|
||||
|
||||
**Files:**
|
||||
- Modify: `e2e/smoke.spec.ts` (existing Playwright smoke suite)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Playwright route interception (`page.route`), `DEFAULT_BOOTSTRAP.branding.brandName` (Task 2) as the assertion target.
|
||||
|
||||
- [ ] **Step 1: Read the existing smoke spec to match its conventions**
|
||||
|
||||
Run: `cat e2e/smoke.spec.ts` (or open the file) — confirm the existing pattern for intercepting `/bootstrap` if one exists, and the base URL fixture used by other specs in this file.
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Add to `e2e/smoke.spec.ts`:
|
||||
|
||||
```ts
|
||||
test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => {
|
||||
await page.route('**/bootstrap', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Marketplace')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it to verify it fails**
|
||||
|
||||
Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"`
|
||||
Expected: FAIL — either the route interception payload is rejected client-side (schema mismatch) or the text isn't found, since Task 1–3 aren't wired in yet if this task runs standalone. If Tasks 1–3 are already merged, this should already pass; if it fails for a reason other than "text not found" (e.g. a network error), fix the intercepted payload shape first, not the app code.
|
||||
|
||||
- [ ] **Step 4: Confirm it passes against the real implementation**
|
||||
|
||||
Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"`
|
||||
Expected: PASS, once Tasks 1–3 are committed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/smoke.spec.ts
|
||||
git commit -m "test: add e2e smoke test for unpublished-marketplace placeholder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** §1 (backend `published` field) → Task 1. §2 (whole-object swap, `DEFAULT_BOOTSTRAP` composed from existing `DEFAULT_*` constants) → Task 2. §2 (`ConfigService` trigger point) → Task 3. §3 (error handling: missing field = published, HTTP failure unchanged) → covered by Task 3 Step 1 test 3 and by leaving `catchError` untouched. §4 (testing) → Tasks 2–4 cover unit + E2E; schema-shape check is TypeScript compilation itself (Task 2 Step 3 must compile against `BootstrapConfig`).
|
||||
- **Backend-side resolution logic** (how the backend decides `published` from `MarketplaceRevision.status`) is explicitly out of scope per the spec — not a frontend-repo task.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Admin product view count column — design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Related backlog item:** #1 (site traffic counter)
|
||||
|
||||
## Problem
|
||||
|
||||
User reported "site traffic isn't visible, counter shows low." Investigation found two separate things already exist and are working as intended, neither of which is the actual gap:
|
||||
|
||||
- Admin Analytics → Traffic tab already shows an honest `"Unknown - available after backend"` badge (`admin-analytics-page.component.html:231`) — no fake data, correctly reflects that no traffic-tracking pipeline exists at all (`BACKEND-API-REFERENCE.md` §10 step 10).
|
||||
- The storefront `Item.visits` field is wired end-to-end from the live backend (`api.service.ts:438`) but is never rendered anywhere in the UI, and the backend mock always seeds it `0`.
|
||||
|
||||
User confirmed (via clarifying question) the actual complaint is: **no per-product view count visible in Admin Products.**
|
||||
|
||||
Further investigation found Admin Products runs on a fully separate mock domain (`AdminProduct` model, `admin-products-local.gateway.ts`, seeded from `list.json`) that has no relationship to the storefront's live `Item.visits` pipeline at all. So a "Views" column here cannot show real per-product traffic today — there is no data source for it in the admin domain. This mirrors the currency/FX and order-notification gaps already documented this session: build the honest client-side piece, document the backend gap explicitly, never fabricate numbers.
|
||||
|
||||
## Design
|
||||
|
||||
**Model:** add `visits: number` to `AdminProduct` (`src/app/features/admin/products/models/admin-product.model.ts`), alongside the other stat-like fields (`priority`, `quantity`).
|
||||
|
||||
**Mock gateway:** `admin-products-local.gateway.ts` defaults `visits: 0` when building the in-memory seed from `list.json` — no fabricated numbers, matches the field's actual state (nothing increments it yet).
|
||||
|
||||
**List column:** `ALL_PRODUCT_COLUMNS` (`admin-products.facade.ts:39`) gains `'visits'`. Rendered in `admin-products-list.component.html` table view only (grid view is out of scope per user's placement choice), following the exact existing `isColumnVisible('stock')`/`isColumnVisible('price')` pattern — toggleable via the same column-picker UI, persisted the same way (`LocalStorageService`, `COLUMNS_KEY`).
|
||||
|
||||
**i18n:** one new key, `adminProducts.views` (label for the column header), added to `en.ts`/`ru.ts`/`hy.ts`/`translations.ts`.
|
||||
|
||||
## Backend doc update
|
||||
|
||||
New `BACKEND-API-REFERENCE.md` §12.x ask (numbered after the existing 12.8, following the established "Gap / Ask" format): the admin Products domain has no view-count source. Two options to raise:
|
||||
1. Once admin Products gets a real backend (§10 step 4), include a view/visit count per product in the response.
|
||||
2. Alternatively, bridge to the storefront's already-live `Item.visits` (§6, `/items/{id}`) by product id — smaller change if a unified product identity exists between the storefront and admin domains.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Storefront customer-facing "N people viewed this" display — not requested, deferred (was offered as a placement option, not chosen).
|
||||
- Product edit/detail page display — not requested (list column only, per user's placement choice).
|
||||
- Any client-side view tracking/incrementing — explicitly rejected in favor of the honest display-only approach; a client-only counter would only reflect the admin's own browser, not real shoppers, same trap already avoided for currency rates.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Admin purchase notifications — design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Related backlog item:** #7 (marked ВАЖНО — important)
|
||||
|
||||
## Problem
|
||||
|
||||
Admin has no signal when a purchase happens on the marketplace. Orders only surface if
|
||||
someone manually opens the Orders list and refreshes. Backend exposes no WebSocket/SSE
|
||||
(confirmed in `BACKEND-API-REFERENCE.md:20` — every "live" feature today, e.g. payment
|
||||
status, is plain polling), so this has to be poll-based like the rest of the app.
|
||||
|
||||
## Architecture
|
||||
|
||||
**`AdminOrderWatcherService`** (new, `providedIn: root`, admin-scoped)
|
||||
|
||||
- Polls `AdminOrdersLocalGateway.loadOrders()` (sorted `createdAt` desc, already the
|
||||
default sort) on an interval.
|
||||
- Diffs the newest order's `id`/`createdAt` against the last-seen value, kept in memory
|
||||
and persisted via `LocalStorageService` (survives page reload, same pattern as
|
||||
`AdminPreferencesService`).
|
||||
- On finding order(s) newer than last-seen: fires one toast per new order and
|
||||
increments an `unreadCount` signal.
|
||||
- Started once at the admin shell root, so it keeps polling regardless of which admin
|
||||
page is open.
|
||||
|
||||
**Poll interval**
|
||||
|
||||
- Editable by admin, default 15s.
|
||||
- Setting lives in the same admin-settings page as currency rates
|
||||
(`admin-settings-page.component.ts`), persisted via `LocalStorageService`.
|
||||
|
||||
**Toast delivery**
|
||||
|
||||
- Reuses the existing `UserNotificationService` / `FloatingNotificationsComponent`
|
||||
(already global — `providedIn: root`, mounted once in `app.html`). No new toast UI.
|
||||
- `UserNotification` gains an optional `route: string[]` field.
|
||||
- `FloatingNotificationsComponent` gets a click handler: navigate to `route` (if set)
|
||||
then dismiss.
|
||||
|
||||
**Badge — reuses existing topbar bell**
|
||||
|
||||
`admin-layout.component.html:141-157` already has an unused bell icon +
|
||||
dropdown panel (currently hardcoded to always show "no notifications").
|
||||
Wire the watcher's data into it instead of adding a new indicator:
|
||||
|
||||
- `unreadCount` signal (from `AdminOrderWatcherService`) rendered as a badge
|
||||
on the bell icon (`admin-layout__icon-button`).
|
||||
- Opening the panel (`notificationsOpen()`, already wired to the bell click)
|
||||
lists the unread new orders instead of the static "notificationsEmpty"
|
||||
text.
|
||||
- Opening the panel marks all currently-known orders as seen → badge resets
|
||||
to 0 (same trigger `AdminLayoutComponent.toggleNotifications()` already
|
||||
has).
|
||||
|
||||
**Click behavior**
|
||||
|
||||
- Toast click → `/admin/orders/:id` (the new order's detail page).
|
||||
- Clicking an order row inside the bell panel → same, then closes the panel.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
AdminOrderWatcherService (interval timer)
|
||||
-> AdminOrdersLocalGateway.loadOrders()
|
||||
-> diff against last-seen order id/createdAt (LocalStorageService)
|
||||
-> new order(s) found?
|
||||
-> UserNotificationService.show(message, 'info', { route: ['/admin/orders', id] })
|
||||
-> unreadCount.update(n => n + 1)
|
||||
-> admin clicks toast/badge -> router navigate -> orders-list visit resets unreadCount
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
Poll failures are silent/logged only (`console.error`), consistent with existing
|
||||
polling code (payment status polling in `cart.component.ts`). No toast spam on
|
||||
transient network errors — watcher just retries on the next interval.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Native OS push notifications (tab not focused) — user explicitly chose in-app
|
||||
toast/badge only, not browser Notification API.
|
||||
- Sound alerts — not selected.
|
||||
- Telegram/email alerts to staff — not selected, would need backend bot/mail
|
||||
integration.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Email/phone customer login (OTP) — design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Related backlog item:** #4 (Telegram-only identification)
|
||||
|
||||
## Problem
|
||||
|
||||
Customer storefront login/checkout requires Telegram today (`src/app/services/auth.service.ts`, `TelegramSessionApiService`) — shoppers without Telegram have no way to identify themselves. User asked for email/phone as an alternative.
|
||||
|
||||
Backend has zero email/phone/OTP/password infrastructure — only Telegram session polling exists (`BACKEND-API-REFERENCE.md` §2a). Building real authentication client-side is not possible; this is fundamentally a backend feature. Per user's explicit choice, this round produces the design + backend spec only — no client UI/code, since there is no real backend to build a working feature against yet (mirrors the currency-FX and admin-notifications backend-dependency pattern already documented this session).
|
||||
|
||||
## Design
|
||||
|
||||
**Mechanism: OTP code (email or SMS)**, chosen over magic link (email-only, extra click) and password (heaviest backend lift — storage, hashing, reset flow). Passwordless matches the feel of the existing Telegram QR flow.
|
||||
|
||||
**A third, independent auth mechanism** — coexists with Telegram QR (§2a) and admin Ed25519 (§2b, still unimplemented) exactly the way those two already coexist. Does not replace or modify either.
|
||||
|
||||
### Proposed backend endpoints
|
||||
|
||||
```
|
||||
POST /auth/otp/request
|
||||
Body: { "identifier": "user@example.com" } // or E.164 phone: "+79991234567"
|
||||
Response: { "requestId": "...", "expiresAt": "2026-08-15T10:15:00Z" }
|
||||
```
|
||||
|
||||
```
|
||||
POST /auth/otp/verify
|
||||
Body: { "requestId": "...", "code": "482913" }
|
||||
Response (on success): {
|
||||
"sessionId": "...",
|
||||
"userId": 8823771,
|
||||
"username": null,
|
||||
"displayName": "user@example.com",
|
||||
"active": true,
|
||||
"expires": "2026-08-15T11:15:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
The success response is shaped identically to the existing `AuthSession` model (`src/app/models/auth.model.ts`) — `sessionId`, `userId`, `username`, `displayName`, `active`, `expires`. This is deliberate: every downstream consumer (auth guards, session signals, cart/checkout) already works against `AuthSession` regardless of which mechanism produced it, so wiring this in later requires no changes to guards or session state — only a new "request/verify" UI flow that ends by populating the same session shape Telegram QR already produces.
|
||||
|
||||
**Rate limiting / expiry (backend-enforced, not left implicit):**
|
||||
- Resend cooldown: 60s between `POST /auth/otp/request` calls for the same identifier.
|
||||
- Code expiry: 10 minutes from issuance.
|
||||
- `requestId` allows up to 5 verify attempts before it's invalidated — consumed on success, on the 5th wrong attempt, or on expiry, whichever comes first. (Revised from an earlier single-use-per-attempt draft: burning the whole request on one typo is bad UX — a shopper should be able to correct a mistyped digit without waiting out a fresh 60s cooldown.)
|
||||
|
||||
### Admin-configurable login methods
|
||||
|
||||
Admin can enable/disable each login method independently — Telegram QR, Email OTP, Phone OTP — via three checkboxes in Admin Settings, same section/pattern as the existing currency-rates and notification-interval settings (`admin-settings-page.component.ts`, `LocalStorageService`-persisted signal).
|
||||
|
||||
- Default: all three enabled — a settings change must never silently lock shoppers out.
|
||||
- A new `AuthMethodsService` (or an extension of the existing settings service) exposes `enabledMethods: Signal<('telegram' | 'email' | 'phone')[]>`. The storefront login screen reads it and only renders buttons for enabled methods; if exactly one is enabled, skip the method-picker screen entirely and go straight to it.
|
||||
- Purely a client-side UI gate — the backend OTP endpoints stay unconditionally available; disabling "Email OTP" in admin just hides the button, it doesn't need a corresponding backend flag. (Same category of client-only gate as the existing `adminAuthGuard`/permission checks — real enforcement, if ever needed, would be a separate backend concern.)
|
||||
|
||||
### Error handling
|
||||
|
||||
The codebase has an established error envelope (`BACKEND-API-REFERENCE.md` §5: `error.code`, `error.message`, `error.status`, `error.details`) explicitly flagged as "recommended for new endpoints, not wired anywhere yet." Since the OTP endpoints are new, this is the natural first real adopter — every response maps to a specific code, not just an HTTP status:
|
||||
|
||||
| `error.code` | HTTP status | UX |
|
||||
|---|---|---|
|
||||
| `VALIDATION_FAILED` | 422 | Inline field error under the identifier input, sourced from `error.details[0].message` — same pattern the client already uses for local validation errors (`cart.component.ts`'s email/phone inline errors), so a 422 slots into the existing inline-error UI without inventing a second display mechanism. |
|
||||
| `RATE_LIMITED` | 429 | "Too many attempts — try again in Ns," countdown derived from `error.details`/`Retry-After` if present, otherwise a flat 60s. Resend button stays disabled until the countdown ends. |
|
||||
| `CODE_EXPIRED` | 410 | "This code expired — request a new one." Auto-focuses/enables the resend action; does not silently re-send. |
|
||||
| `CODE_INVALID` | 401 | "Wrong code, try again" — stays on the code-entry screen (does not consume the whole flow; see the 5-attempt allowance above). Shows the remaining-attempts count once ≤2 remain. |
|
||||
| `REQUEST_NOT_FOUND` | 404 | `requestId` unknown/already invalidated (5 wrong attempts, expiry, or a stale reload) — "This login attempt is no longer valid, start again," returns to the identifier-entry step. |
|
||||
| Anything else / network error / 5xx | — | Generic fallback: "Something went wrong. Try again, or use a different login method" — the second half of that sentence is a real, populated action, not filler text: it surfaces whichever other methods are currently enabled per the admin toggle above (e.g. falls back to the Telegram QR button), not just a dead-end retry link. |
|
||||
|
||||
**Identifier validation:** email vs. phone format is auto-detected client-side. Extract the validation logic already written inline in `cart.component.ts` (`validateEmail`/`validatePhone`, currently only used for post-purchase contact capture) into a shared utility rather than duplicating it when the client UI is eventually built — the same email/phone shape-checking applies to both use cases.
|
||||
|
||||
### Future client UI (not built this round)
|
||||
|
||||
A "Login with email or phone" option next to the existing Telegram QR button: identifier entry → code entry → session established. Deferred until the backend endpoints above exist — no client code to write against a 404.
|
||||
|
||||
## Backend doc update
|
||||
|
||||
New `BACKEND-API-REFERENCE.md` §2c ("Email/phone OTP login — customer (NOT IMPLEMENTED)"), following the same Gap/Ask format as the existing §12.x entries, documenting the two endpoints, the response-shape compatibility requirement, and the rate-limit/expiry asks above.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Admin backoffice login — user confirmed this round is customer-storefront only (item 4 was split into two potential specs during brainstorming; admin auth is a separate future spec if wanted).
|
||||
- Magic link and password mechanisms — considered, OTP chosen.
|
||||
- Any client-side UI or session-handling code — explicitly deferred; nothing to build against a non-existent backend.
|
||||
- Account merging (e.g. a shopper who later links Telegram + email to the same identity) — not raised, not designed.
|
||||
137
docs/superpowers/specs/2026-08-15-platform-super-admin-design.md
Normal file
137
docs/superpowers/specs/2026-08-15-platform-super-admin-design.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Platform Super-Admin — Phase 1 Design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Audience:** Internal admin & risk team ("super puper user")
|
||||
|
||||
## Purpose
|
||||
|
||||
A cross-tenant view for internal admin/risk staff: see every project (store/tenant) on the
|
||||
platform, drill into one, and review its access list, audit log, admin edit history, and
|
||||
purchase history. Read-only in this phase.
|
||||
|
||||
Editing project data / impersonating a store's admin ("edit all", with a per-change "notify
|
||||
this store's admin" toggle) is explicitly **out of scope** for this phase — see
|
||||
[Phase 2](#phase-2-out-of-scope-here) below. Phase 1 exists first because Phase 2's edit and
|
||||
notify plumbing depends on the tenant-context switch this phase builds.
|
||||
|
||||
## Non-goals (Phase 1)
|
||||
|
||||
- No editing of any tenant's data.
|
||||
- No impersonation of a store's admin.
|
||||
- No "notify store admin" mechanism (that's a Phase 2 concern, tied to edit actions that
|
||||
don't exist yet).
|
||||
- No real backend — this repo is frontend-only; the backend contract is specified here for
|
||||
whoever owns that service, not implemented here.
|
||||
|
||||
## Architecture
|
||||
|
||||
- New top-level feature module: `src/app/features/platform-admin/`.
|
||||
- New route tree `/platform-admin/**`, own shell/layout. **Not** nested under any tenant's
|
||||
`/admin/**` — a project is not "logged into" the way a store admin is.
|
||||
- New `platformAdminAuthGuard` (parallel to, but sharing no state with, `adminAuthGuard` in
|
||||
`core/admin-auth/admin-auth.guard.ts`).
|
||||
- `PlatformAuthService` — session/login state for the super-admin, backed by a
|
||||
`PlatformAuthGateway` interface: `login(credentials)`, `logout()`, `session()`.
|
||||
- `PlatformAuthLocalGateway` — dev-only implementation. Reads the expected credential from
|
||||
a **git-ignored** local file (`platform-auth.local-secret.ts`, added to `.gitignore`),
|
||||
never committed, never present in a production build path.
|
||||
- `PlatformAuthApiGateway` — later swap-in once the backend endpoint exists; same
|
||||
interface, no caller changes needed.
|
||||
|
||||
## Data model
|
||||
|
||||
```ts
|
||||
interface PlatformProjectSummary {
|
||||
id: UUID;
|
||||
name: string;
|
||||
slug: string;
|
||||
host: string;
|
||||
status: 'active' | 'suspended';
|
||||
createdAt: number;
|
||||
adminCount: number;
|
||||
lastActivityAt: number | null;
|
||||
}
|
||||
|
||||
interface PlatformProjectAccessEntry {
|
||||
userId: UUID;
|
||||
displayName: string;
|
||||
telegramUsername: string;
|
||||
roleId: string; // maps to existing AdminRole / ROLE_PERMISSIONS
|
||||
}
|
||||
|
||||
type PlatformProjectHistoryEntry =
|
||||
| { kind: 'access'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
|
||||
| { kind: 'edit'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
|
||||
| { kind: 'purchase'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string };
|
||||
```
|
||||
|
||||
- `PlatformProjectSummary[]` is produced by `PlatformProjectsGateway.list()`, which aggregates
|
||||
the existing `TenantConfig` fixture list plus derived stats. Mock gateway now; real
|
||||
aggregation is a backend concern later.
|
||||
- `PlatformProjectAccessEntry` reuses the existing `AdminRole` / `ROLE_PERMISSIONS` shape from
|
||||
`core/auth/models/permission.model.ts` — no new role system.
|
||||
- `PlatformProjectHistoryEntry` is a discriminated union covering all three history types the
|
||||
user asked for (access/audit, admin edit history, purchase history). Mock gateway simulates
|
||||
aggregation from existing per-tenant sources (e.g. the pattern in
|
||||
`AdminDashboardHistoryService`, `admin-transactions`); real aggregation is a backend concern.
|
||||
- Every super-admin **view** into a project also writes its own `kind: 'access'` entry
|
||||
(`platform.viewedProject`) — the risk team needs to know who looked at what, not just what
|
||||
changed.
|
||||
|
||||
## Components / pages
|
||||
|
||||
- `PlatformProjectsListPageComponent` — table of all projects: name, status, admin count,
|
||||
last activity. Search/filter by status.
|
||||
- `PlatformProjectDetailPageComponent` — project overview stats, then tabs:
|
||||
- **Access** — `PlatformProjectAccessEntry[]` for that tenant.
|
||||
- **Audit Log** — `history` filtered to `kind: 'access'`.
|
||||
- **Edit History** — `history` filtered to `kind: 'edit'`.
|
||||
- **Purchase History** — `history` filtered to `kind: 'purchase'`.
|
||||
- All read-only in this phase.
|
||||
|
||||
## Security
|
||||
|
||||
- `platformAdminAuthGuard` denies unless the session carries `platform.superadmin`. Like the
|
||||
existing `AdminPermissionsService`, the frontend check is defense-in-depth only — real
|
||||
enforcement must happen server-side once the backend endpoint exists. This is called out
|
||||
explicitly so it's never mistaken for the source of truth.
|
||||
- No credential is ever hardcoded in committed source. Dev-only credential lives in a
|
||||
git-ignored local file; production auth goes through the real backend endpoint below.
|
||||
- Session timeout for platform-admin: 15 minutes idle (shorter than regular tenant-admin
|
||||
sessions — higher-privilege session, smaller blast radius if a session is left open).
|
||||
- Every super-admin action (including read-only views) is itself audit-logged.
|
||||
- After implementation, run `/security-audit` on this feature specifically before it ships.
|
||||
|
||||
### Backend contract (for whoever owns that service — not implemented in this repo)
|
||||
|
||||
Add to `BACKEND-API-REFERENCE.md`:
|
||||
|
||||
- `POST /platform-admin/auth` — verifies a hashed credential server-side, returns a session
|
||||
token scoped to `platform.superadmin`. Never a plaintext credential check in a client-shipped
|
||||
artifact.
|
||||
- `GET /platform-admin/projects` — returns `PlatformProjectSummary[]`.
|
||||
- `GET /platform-admin/projects/:id/history` — returns `PlatformProjectHistoryEntry[]` for
|
||||
that tenant, paginated.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `platformAdminAuthGuard`, `PlatformProjectsGateway` (mock), history-aggregation
|
||||
mapping logic.
|
||||
- No E2E in this phase — no real backend to exercise end-to-end yet.
|
||||
|
||||
## Phase 2 (out of scope here)
|
||||
|
||||
A separate spec/plan cycle, once Phase 1 ships:
|
||||
|
||||
- Full edit / impersonation: super-admin acts as a tenant's admin across every existing admin
|
||||
module (products, orders, categories, settings, etc.), reusing those modules under a
|
||||
tenant-context switch.
|
||||
- Per-edit-action **"notify this store's admin about this change"** checkbox, **default
|
||||
unchecked**. Uses the existing in-app notification pattern (the one behind
|
||||
`admin-order-watcher.service.ts`'s unread-badge flow) so the affected tenant's admin sees it
|
||||
in their notification feed. Unchecked-by-default matters: some super-admin edits are
|
||||
discreet technical fixes where alerting the store admin would be noise or a reputational
|
||||
concern, not every edit should ping them.
|
||||
- This phase needs the tenant-context switch and audit-logging plumbing this Phase 1 spec
|
||||
establishes, which is why it's sequenced after.
|
||||
201
docs/superpowers/specs/2026-08-21-fork-harvest-design.md
Normal file
201
docs/superpowers/specs/2026-08-21-fork-harvest-design.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Fork Harvest — Design / Working Description
|
||||
|
||||
**Branch:** `improvements/fork-harvest` (cut from `B2B` @ `92f1c88`)
|
||||
**Date:** 2026-08-21
|
||||
**Input:** [FORK-ANALYSIS-2026-08-21.md](../../FORK-ANALYSIS-2026-08-21.md)
|
||||
**Companion:** [FORK-HARVEST-TODO.md](../../FORK-HARVEST-TODO.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Take **only the improvements** from the `hub.numus.cc/numus/marketplaces` archive. Nothing else. No architecture adoption, no code copying, no rewrite, no framework regression.
|
||||
|
||||
The archive is a competing platform monorepo, not a fork of us. It contains our repo verbatim as `reference/parallel-frontend/`. It is ahead of us on backend truth and operations, behind us on frontend depth, testing, and framework currency.
|
||||
|
||||
This document is the working brief for whoever executes the harvest — including a future session of me. It states what is true today in this repo, what changes, and how each item is proven done.
|
||||
|
||||
---
|
||||
|
||||
## 2. Constraint that shapes everything
|
||||
|
||||
**This repo has no backend.** 530 `.ts` files, Angular 22, zero server code. Our backend exists only as 17 contract documents in `docs/backend/`, implemented by another team.
|
||||
|
||||
That splits every harvested improvement into one of five lanes:
|
||||
|
||||
| Lane | Meaning | Where it lands |
|
||||
|---|---|---|
|
||||
| **A — Frontend** | We write the code, this sprint | `src/`, `e2e/`, `angular.json`, CI |
|
||||
| **B — Contracts** | We write the requirement; backend implements | `docs/backend/*.md` |
|
||||
| **C — Packages** | Ships in `@marketplaces/auth` (external repo `vitanovaPackages`) | package repo + DI wiring here |
|
||||
| **D — Infra/Ops** | Deploy scripts and runbook | `scripts/deploy/`, `docs/DEPLOYMENT.md` |
|
||||
| **E — Process** | Governance, gates, policy | `../../backend/BACKEND-INTEGRATION.md`, ADRs |
|
||||
|
||||
Anything that would require us to stand up Prisma, Postgres, or NestJS in *this* repo is out of scope. It becomes a Lane B contract line instead.
|
||||
|
||||
---
|
||||
|
||||
## 3. What we verified in our own code (2026-08-21, current HEAD)
|
||||
|
||||
Their audit was written against our 11 Aug snapshot. Re-checked against today's code:
|
||||
|
||||
| Their finding | Status now | Evidence |
|
||||
|---|---|---|
|
||||
| Admin session cookie set by JS, readable via `document.cookie` | **Already fixed** | zero `document.cookie` hits in `src/` |
|
||||
| Admin JWT / refresh token in `localStorage` | **Already fixed** | no `setItem(*token*)` anywhere |
|
||||
| `http://ip-api.com` from an HTTPS storefront | **STILL LIVE** | `src/app/services/location.service.ts:75` |
|
||||
| Unconditional `bypassSecurityTrustResourceUrl` on a bank URL | **STILL LIVE** | `src/app/pages/cart/cart.component.ts:485` |
|
||||
| Client-side `authorization-key` / `userid-value` headers | **STILL LIVE** | `src/app/services/api.service.ts:675` |
|
||||
| Hardcoded partner ID in the bundle | **STILL LIVE** | `src/app/services/api.service.ts:143` — `'web-97ec-9c57-4dde-9037-3a68f7f83750'` |
|
||||
| `localStorage` as persistence | **19 files**, mostly admin facades + editor draft storage | see TODO FH-A7 |
|
||||
| Bundle 452 kB over a 700 kB budget | **Still true** | their measured build |
|
||||
| Zero `idempot*` in the codebase | **Still true** | no Idempotency-Key sent on payment creation |
|
||||
|
||||
Two of those are live bugs, not just security posture:
|
||||
|
||||
- **`http://ip-api.com`** — browsers block mixed active content on an HTTPS origin. `detectLocation()` therefore always takes its error branch in production. Region auto-detect has been silently dead.
|
||||
- **Bank URL in an iframe** — most acquirer 3-D Secure pages send `X-Frame-Options: DENY` / frame-ancestors CSP. The popup renders blank for those banks. Their spec calls this out explicitly: card checkout should navigate the current tab, not open an intermediate popup.
|
||||
|
||||
That reframes three of the "security" items as **defect fixes with a security benefit**, which is a much easier sell and a much better use of the sprint.
|
||||
|
||||
---
|
||||
|
||||
## 4. Selection rule — what counts as "an improvement"
|
||||
|
||||
An item is harvested only if it passes all four:
|
||||
|
||||
1. **It is better than what we have**, not merely different.
|
||||
2. **It survives without their backend.** Either we can build it, or it is a contract line the backend team can implement against.
|
||||
3. **It does not regress us.** Nothing that drops us to Angular 21, reintroduces mocks, or lowers our test bar.
|
||||
4. **It is falsifiable.** There is a test, a check, or an observable state that proves it done.
|
||||
|
||||
Explicitly rejected by this rule (from the analysis §9): their Angular version, their `mock-data.service.ts`, their 25-test/zero-e2e posture, their env-pinned `ORDER_MANAGER_MARKETPLACE_SLUG`, their hardcoded server IP, their narrower 5-type section schema.
|
||||
|
||||
---
|
||||
|
||||
## 5. The harvest, by theme
|
||||
|
||||
### 5.1 Correctness primitives (the highest-value cluster)
|
||||
|
||||
Three patterns from their backend that are worth more than everything else combined, because each replaces application logic with a database guarantee:
|
||||
|
||||
**Conditional-UPDATE reservation.** One statement is their entire oversell defence:
|
||||
|
||||
```sql
|
||||
UPDATE "MarketplaceInventory"
|
||||
SET "reserved" = "reserved" + $qty
|
||||
WHERE "marketplaceId" = $mp AND "variantId" = $variant
|
||||
AND ("onHand" - "reserved") >= $qty
|
||||
RETURNING "id"
|
||||
```
|
||||
|
||||
Empty result set → `409`. No read-then-write window, no advisory lock, no retry loop. Goes into `../../backend/BACKEND-INTEGRATION.md` as a normative requirement, not a suggestion.
|
||||
|
||||
**Idempotency as a unique constraint.** `Payment.idempotencyKey UNIQUE` and `PaymentWebhookEvent @@unique([provider, eventKey])`. A duplicate insert throws, and the catch returns `{accepted: true, duplicate: true}`. Replay protection becomes structurally impossible to forget, versus an `if` somebody eventually deletes. Goes into `PHASE-7`.
|
||||
|
||||
**Append-only inventory journal.** Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. This is the direct answer to the v3.1 plan's "we cannot explain your numbers" complaint — it makes every quantity reconstructible after the fact.
|
||||
|
||||
None of these are hard. All three are cheap to specify and expensive to retrofit.
|
||||
|
||||
### 5.2 Session and credential hygiene
|
||||
|
||||
Their model, which we adopt as the contract target: server-stored sessions, random 32 bytes, **stored as SHA-256 hash only**, HttpOnly + Secure + SameSite, revocable, one distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`), Argon2id `memoryCost 65536 / timeCost 3 / parallelism 1`, mandatory TOTP with a signed 10-minute setup token, and password change revoking every live session in the same transaction.
|
||||
|
||||
Plus the twelve-line CSRF defence we do not have: a global `onRequest` hook rejecting any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist.
|
||||
|
||||
Our side of this is subtractive: stop sending provider credentials from the browser, stop shipping a partner ID literal in the bundle.
|
||||
|
||||
### 5.3 Tenant and preview safety
|
||||
|
||||
Host → verified domain row → tenant, 30-second cache with explicit invalidation, `404` on unknown host with no fallback tenant. We have bootstrap-driven runtime config and no equivalent guarantee written down.
|
||||
|
||||
Their preview mechanism is the piece worth copying outright: an HMAC-signed token carrying `{marketplaceId, expiresAt, nonce}`, 15-minute TTL, delivered as a `storefront_preview` cookie, plus a global hook that returns `404 Preview mode is read-only` for any non-GET while that cookie is present. We have preview UI and no preview safety at all.
|
||||
|
||||
### 5.4 Publish, revisions, clone
|
||||
|
||||
`version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction, rollback creates a *new* revision rather than rewriting history. Clone copies design and catalog assignments, **forces inventory to zero**, and never copies domains, customers, orders, or secrets; its category walk is topological with explicit cycle detection.
|
||||
|
||||
### 5.5 Product ideas worth taking
|
||||
|
||||
- **Order-manager as a fully separate contour** — separate URL, shell, cookie, login, scoped to one marketplace, with no visibility into catalog, design, domains, or payment settings. Removes an entire permissions surface rather than guarding it.
|
||||
- **Digital goods in one table** — `FulfillmentMode: MANUAL | CODE_POOL`, a `DigitalCode` pool with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted values, `valueHash` unique per `(marketplace, variant)`, codes revealed only once the order is `PAID`. We have no digital-goods story; this is a complete one.
|
||||
- **`DOMAIN_PENDING` as a real marketplace state**, not an error condition.
|
||||
- **CSV marketplace import with `dryRun` default true** — bulk tenant creation as a first-class operation.
|
||||
- **Their §22 acceptance list** as a ready-made e2e suite. Two of the fifteen are worth writing immediately: concurrent purchase of the last unit, and a replayed webhook.
|
||||
|
||||
### 5.6 Social identity — VK ID and Yandex ID
|
||||
|
||||
The archive has **zero** VK/Yandex/OAuth code; there is nothing to copy. What we take is the *session-issuing shape* of their Telegram flow and terminate VK/Yandex into it.
|
||||
|
||||
Design decisions, all of which belong in `@marketplaces/auth`:
|
||||
|
||||
1. **Provider-agnostic surface.** `SocialIdentityGateway` with a `SocialProvider` union, replacing today's VK-specific `VkIdGateway`. One controller pattern backend-side, one strategy object per provider.
|
||||
2. **Backend-owned PKCE.** Our current interface passes `codeVerifier` from the client, which forces the browser to generate and hold the verifier. We are a confidential client. The backend generates `state` + `code_verifier`, stores them single-use for 10 minutes, and the browser only ever gets redirected. `completeCallback()` disappears from the frontend entirely.
|
||||
3. **VK ID gotcha:** the callback returns `device_id` next to `code`, and the token exchange fails without it. This is the single most common VK ID integration bug and it must be in the contract text.
|
||||
4. **Multi-tenant `redirect_uri` is a one-way door.** Both providers validate `redirect_uri` against an exact registered list; we cannot register one per tenant domain. Resolution: a single central identity host as the only registered callback, tenant carried inside the signed `state`, then a 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for its session cookie. **This must be decided before any code is written.**
|
||||
5. **Identity conflict is not an upsert.** `@@unique([provider, providerUserId])` so the database refuses a silent rebind; conflicts route to controlled resolution.
|
||||
|
||||
Build order: provider-agnostic surface → VK ID → Yandex ID (a second strategy, roughly a day) → migrate Telegram onto `ExternalIdentity` → linking UI → email/phone OTP demoted to recovery.
|
||||
|
||||
### 5.7 Operations
|
||||
|
||||
Adopt: WAL archiving plus a *scheduled, proven* restore drill; a data network that is `internal: true` so "the database is not reachable from the internet" is structural rather than a firewall promise; host hardening we lack (fail2ban, sshd drop-in, sysctl).
|
||||
|
||||
Already better on our side, keep as-is: our `add-domain.sh` already pre-checks the DNS A record and runs `nginx -t` before and after; `server-setup.sh` already configures ufw. Their `provision-domain.sh` hardcodes the server IP — do not copy that shape.
|
||||
|
||||
### 5.8 Process
|
||||
|
||||
Their `DEVELOPER_HANDOFF.md` §7 is nine falsifiable invariants and is a better acceptance gate than anything currently in our delivery plan. Their PR policy (one functional area per PR; mandatory security impact and rollback plan; never touch payment/inventory/order state machines inside a redesign PR) and their release discipline ("a local build or the existence of a UI does not mean production readiness") are both worth adopting verbatim.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sequencing
|
||||
|
||||
Four waves. Each wave is independently shippable; nothing in a later wave blocks an earlier one.
|
||||
|
||||
**Wave 1 — Defect fixes with a security benefit (this sprint, Lane A).**
|
||||
The three live bugs: geo over HTTP, bank URL in an iframe, provider credentials and partner ID in the bundle. Plus `Idempotency-Key` on payment creation. All frontend, all provable, all things their audit will otherwise keep pointing at.
|
||||
|
||||
**Wave 2 — Contract hardening (Lane B, parallel with Wave 1).**
|
||||
Write the correctness primitives, session model, tenant/preview rules, revision semantics, and inventory journal into `docs/backend/`. Costs no engineering capacity from the frontend team and immediately raises the bar the backend is built to.
|
||||
|
||||
**Wave 3 — Proof (Lane A).**
|
||||
The two acceptance e2e tests, bundle budget as a blocking CI check, and the deployable split that gets us under budget.
|
||||
|
||||
**Wave 4 — Identity (Lane C).**
|
||||
Blocked on the central-identity-host decision. Provider-agnostic surface, VK ID, Yandex ID, Telegram migration, linking UI.
|
||||
|
||||
Ops (Lane D) and process (Lane E) run continuously alongside.
|
||||
|
||||
---
|
||||
|
||||
## 7. Explicitly out of scope
|
||||
|
||||
- Merging the two codebases, in either direction.
|
||||
- Reimplementing their backend here.
|
||||
- Adopting their section schema, their template list, or their backoffice.
|
||||
- Any dependency downgrade.
|
||||
- Removing our boundary checker, cycle check, or coverage floor to match their looser governance.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Contract lines in `docs/backend/` are written and never implemented | Pair each with an acceptance scenario in the handoff doc so it is a delivery gate, not a wish |
|
||||
| The central-identity-host decision slips and blocks all of Wave 4 | It is the first item in the TODO; escalate on day one |
|
||||
| Removing the client-side payment credential path breaks checkout before the server side exists | Confirm the server-priced checkout session path (already in `api.service.ts`) covers every live flow before deleting the legacy header path |
|
||||
| The deployable split is larger than estimated | Wave 3 item, not a blocker for Waves 1–2; can ship the bundle-budget CI check first and let it fail loudly |
|
||||
| Harvest is read as "they were right about everything" | The analysis records where they are behind us — tests, e2e, framework currency, frontend depth — and the TODO carries no item that regresses those |
|
||||
|
||||
---
|
||||
|
||||
## 9. Done means
|
||||
|
||||
- Every Wave 1 item has a test or an observable check proving it.
|
||||
- Every Lane B item exists as normative text in `docs/backend/` with an acceptance scenario attached.
|
||||
- The two §22 acceptance tests run in CI.
|
||||
- Bundle budget is a blocking check and the storefront is under it.
|
||||
- VK ID and Yandex ID both log a customer in through `@marketplaces/auth`, with the client never holding a secret, a token, or a code verifier.
|
||||
- No item in this harvest lowered our Angular version, our test count, or our architecture governance.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Frontend default bootstrap (unpublished-marketplace placeholder)
|
||||
|
||||
**Date:** 2026-08-22
|
||||
**Status:** approved (decided by project owner in-session, no further review requested)
|
||||
|
||||
## Problem
|
||||
|
||||
Production `/bootstrap` has no fallback today. A marketplace with no published revision either 404s or returns whatever partial row the backend has — frontend has nothing sane to render. Need a placeholder that shows immediately for any brand before its first publish, with every feature switched on so it doubles as a full product demo.
|
||||
|
||||
## Decision
|
||||
|
||||
Whole-object fallback, decided client-side from one explicit backend signal.
|
||||
|
||||
### 1. Backend contract change
|
||||
|
||||
Add one required top-level field to the `/bootstrap` response:
|
||||
|
||||
```ts
|
||||
interface BootstrapConfig {
|
||||
schemaVersion: string;
|
||||
generatedAt: string;
|
||||
published: boolean; // NEW — false until MarketplaceRevision.status = 'published'
|
||||
tenant: TenantConfig;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`published` mirrors whether the marketplace has a `publishedRevision` (see `MarketplaceRevision.status` in `BACKEND-INTEGRATION.md` §11) — not `lifecycleState` directly, since a marketplace can be `live` while a *new* draft revision sits unpublished. Backend still returns full real `tenant`/`branding`/etc when `published: true`; when `false` it may return anything or the last-known real data — frontend ignores every other field in that case (see §2).
|
||||
|
||||
### 2. Frontend: whole-object swap
|
||||
|
||||
New constant, colocated with the other `DEFAULT_*` config constants:
|
||||
|
||||
```ts
|
||||
// src/app/shared/models/config/default-bootstrap.const.ts
|
||||
export const DEFAULT_BOOTSTRAP: BootstrapConfig = {
|
||||
schemaVersion: '1.0.0',
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
published: false,
|
||||
tenant: { /* generic placeholder — brandName 'Marketplace', no real domain */ },
|
||||
branding: { brandName: 'Marketplace', ... },
|
||||
theme: { /* the existing default-light palette from bootstrap.json */ },
|
||||
featureFlags: { wishlist: true, compare: true, reviews: true, blog: true, chat: true,
|
||||
analytics: true, notifications: true, coupons: true, loyalty: true,
|
||||
giftCards: true, invoices: true }, // everything ON
|
||||
features: DEFAULT_MARKETPLACE_FEATURES_CONFIG, // reused, already all-true
|
||||
header: DEFAULT_HEADER_CONFIG, // reused
|
||||
modules: DEFAULT_PLATFORM_MODULES_CONFIG, // reused (sellerManagement off — real module gate, not a feature flag)
|
||||
navigation: { /* hardcoded generic nav */ },
|
||||
pages: [ /* hardcoded generic home page, hero+categories+featured, same shape as bootstrap.json */ ],
|
||||
staticPages: { /* generic about/privacy/terms/contacts */ },
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
`ConfigService.loadBootstrap()` gains one check after the provider emits:
|
||||
|
||||
```ts
|
||||
tap(config => {
|
||||
const resolved = config.published ? config : DEFAULT_BOOTSTRAP;
|
||||
this.bootstrapSnapshot = resolved;
|
||||
this.revisionState.update(v => v + 1);
|
||||
}),
|
||||
```
|
||||
|
||||
No change to `ApiBootstrapProvider`, `MockBootstrapProvider`, or the `ConfigProvider` interface — the swap is a `ConfigService`-only concern, so it applies uniformly regardless of provider mode.
|
||||
|
||||
### 3. Error handling
|
||||
|
||||
- `published` missing/undefined from an old backend response → treat as `true` (backwards compatible: existing marketplaces that never send the field keep behaving exactly as today, same pattern already used for `modules`/ADR-011).
|
||||
- Actual HTTP failure (network error, 5xx) stays a hard error — `catchError` behavior unchanged, no fallback. Fallback is only for the *known* "not published yet" case, not for "backend unreachable." (Matches your earlier answer: explicit signal, not HTTP-status-driven.)
|
||||
|
||||
### 4. Testing
|
||||
|
||||
- `ConfigService` unit test: `published: false` response → snapshot equals `DEFAULT_BOOTSTRAP`.
|
||||
- `ConfigService` unit test: `published: true` → snapshot equals the real response, untouched.
|
||||
- `ConfigService` unit test: `published` absent → snapshot equals the real response (back-compat).
|
||||
- `DEFAULT_BOOTSTRAP` itself: a schema-shape test (it must satisfy `BootstrapConfig` — TypeScript already enforces this at compile time, so this is really just "does it compile").
|
||||
- One E2E smoke: a marketplace with no revision renders the placeholder home page without erroring.
|
||||
|
||||
## Out of scope (explicitly deferred)
|
||||
|
||||
- Field-level merge (real brand name + placeholder theme) — rejected in favor of simpler whole-object swap.
|
||||
- Any admin-panel UI for previewing/editing the default — not asked for.
|
||||
- Backend implementation of `published` resolution logic — backend team's own call once they build the service; this spec only fixes the wire contract.
|
||||
137
docs/superpowers/specs/superuser.md
Normal file
137
docs/superpowers/specs/superuser.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Platform Super-Admin — Phase 1 Design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Audience:** Internal admin & risk team ("super puper user")
|
||||
|
||||
## Purpose
|
||||
|
||||
A cross-tenant view for internal admin/risk staff: see every project (store/tenant) on the
|
||||
platform, drill into one, and review its access list, audit log, admin edit history, and
|
||||
purchase history. Read-only in this phase.
|
||||
|
||||
Editing project data / impersonating a store's admin ("edit all", with a per-change "notify
|
||||
this store's admin" toggle) is explicitly **out of scope** for this phase — see
|
||||
[Phase 2](#phase-2-out-of-scope-here) below. Phase 1 exists first because Phase 2's edit and
|
||||
notify plumbing depends on the tenant-context switch this phase builds.
|
||||
|
||||
## Non-goals (Phase 1)
|
||||
|
||||
- No editing of any tenant's data.
|
||||
- No impersonation of a store's admin.
|
||||
- No "notify store admin" mechanism (that's a Phase 2 concern, tied to edit actions that
|
||||
don't exist yet).
|
||||
- No real backend — this repo is frontend-only; the backend contract is specified here for
|
||||
whoever owns that service, not implemented here.
|
||||
|
||||
## Architecture
|
||||
|
||||
- New top-level feature module: `src/app/features/platform-admin/`.
|
||||
- New route tree `/platform-admin/**`, own shell/layout. **Not** nested under any tenant's
|
||||
`/admin/**` — a project is not "logged into" the way a store admin is.
|
||||
- New `platformAdminAuthGuard` (parallel to, but sharing no state with, `adminAuthGuard` in
|
||||
`core/admin-auth/admin-auth.guard.ts`).
|
||||
- `PlatformAuthService` — session/login state for the super-admin, backed by a
|
||||
`PlatformAuthGateway` interface: `login(credentials)`, `logout()`, `session()`.
|
||||
- `PlatformAuthLocalGateway` — dev-only implementation. Reads the expected credential from
|
||||
a **git-ignored** local file (`platform-auth.local-secret.ts`, added to `.gitignore`),
|
||||
never committed, never present in a production build path.
|
||||
- `PlatformAuthApiGateway` — later swap-in once the backend endpoint exists; same
|
||||
interface, no caller changes needed.
|
||||
|
||||
## Data model
|
||||
|
||||
```ts
|
||||
interface PlatformProjectSummary {
|
||||
id: UUID;
|
||||
name: string;
|
||||
slug: string;
|
||||
host: string;
|
||||
status: 'active' | 'suspended';
|
||||
createdAt: number;
|
||||
adminCount: number;
|
||||
lastActivityAt: number | null;
|
||||
}
|
||||
|
||||
interface PlatformProjectAccessEntry {
|
||||
userId: UUID;
|
||||
displayName: string;
|
||||
telegramUsername: string;
|
||||
roleId: string; // maps to existing AdminRole / ROLE_PERMISSIONS
|
||||
}
|
||||
|
||||
type PlatformProjectHistoryEntry =
|
||||
| { kind: 'access'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
|
||||
| { kind: 'edit'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
|
||||
| { kind: 'purchase'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string };
|
||||
```
|
||||
|
||||
- `PlatformProjectSummary[]` is produced by `PlatformProjectsGateway.list()`, which aggregates
|
||||
the existing `TenantConfig` fixture list plus derived stats. Mock gateway now; real
|
||||
aggregation is a backend concern later.
|
||||
- `PlatformProjectAccessEntry` reuses the existing `AdminRole` / `ROLE_PERMISSIONS` shape from
|
||||
`core/auth/models/permission.model.ts` — no new role system.
|
||||
- `PlatformProjectHistoryEntry` is a discriminated union covering all three history types the
|
||||
user asked for (access/audit, admin edit history, purchase history). Mock gateway simulates
|
||||
aggregation from existing per-tenant sources (e.g. the pattern in
|
||||
`AdminDashboardHistoryService`, `admin-transactions`); real aggregation is a backend concern.
|
||||
- Every super-admin **view** into a project also writes its own `kind: 'access'` entry
|
||||
(`platform.viewedProject`) — the risk team needs to know who looked at what, not just what
|
||||
changed.
|
||||
|
||||
## Components / pages
|
||||
|
||||
- `PlatformProjectsListPageComponent` — table of all projects: name, status, admin count,
|
||||
last activity. Search/filter by status.
|
||||
- `PlatformProjectDetailPageComponent` — project overview stats, then tabs:
|
||||
- **Access** — `PlatformProjectAccessEntry[]` for that tenant.
|
||||
- **Audit Log** — `history` filtered to `kind: 'access'`.
|
||||
- **Edit History** — `history` filtered to `kind: 'edit'`.
|
||||
- **Purchase History** — `history` filtered to `kind: 'purchase'`.
|
||||
- All read-only in this phase.
|
||||
|
||||
## Security
|
||||
|
||||
- `platformAdminAuthGuard` denies unless the session carries `platform.superadmin`. Like the
|
||||
existing `AdminPermissionsService`, the frontend check is defense-in-depth only — real
|
||||
enforcement must happen server-side once the backend endpoint exists. This is called out
|
||||
explicitly so it's never mistaken for the source of truth.
|
||||
- No credential is ever hardcoded in committed source. Dev-only credential lives in a
|
||||
git-ignored local file; production auth goes through the real backend endpoint below.
|
||||
- Session timeout for platform-admin: 15 minutes idle (shorter than regular tenant-admin
|
||||
sessions — higher-privilege session, smaller blast radius if a session is left open).
|
||||
- Every super-admin action (including read-only views) is itself audit-logged.
|
||||
- After implementation, run `/security-audit` on this feature specifically before it ships.
|
||||
|
||||
### Backend contract (for whoever owns that service — not implemented in this repo)
|
||||
|
||||
Add to `BACKEND-API-REFERENCE.md`:
|
||||
|
||||
- `POST /platform-admin/auth` — verifies a hashed credential server-side, returns a session
|
||||
token scoped to `platform.superadmin`. Never a plaintext credential check in a client-shipped
|
||||
artifact.
|
||||
- `GET /platform-admin/projects` — returns `PlatformProjectSummary[]`.
|
||||
- `GET /platform-admin/projects/:id/history` — returns `PlatformProjectHistoryEntry[]` for
|
||||
that tenant, paginated.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `platformAdminAuthGuard`, `PlatformProjectsGateway` (mock), history-aggregation
|
||||
mapping logic.
|
||||
- No E2E in this phase — no real backend to exercise end-to-end yet.
|
||||
|
||||
## Phase 2 (out of scope here)
|
||||
|
||||
A separate spec/plan cycle, once Phase 1 ships:
|
||||
|
||||
- Full edit / impersonation: super-admin acts as a tenant's admin across every existing admin
|
||||
module (products, orders, categories, settings, etc.), reusing those modules under a
|
||||
tenant-context switch.
|
||||
- Per-edit-action **"notify this store's admin about this change"** checkbox, **default
|
||||
unchecked**. Uses the existing in-app notification pattern (the one behind
|
||||
`admin-order-watcher.service.ts`'s unread-badge flow) so the affected tenant's admin sees it
|
||||
in their notification feed. Unchecked-by-default matters: some super-admin edits are
|
||||
discreet technical fixes where alerting the store admin would be noise or a reputational
|
||||
concern, not every edit should ping them.
|
||||
- This phase needs the tenant-context switch and audit-logging plumbing this Phase 1 spec
|
||||
establishes, which is why it's sequenced after.
|
||||
39
e2e/README.md
Normal file
39
e2e/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# E2E — Playwright
|
||||
|
||||
Track Q (`docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md`, Q1). None of this existed before 2026-08-18.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm run e2e # headless, boots the dev server automatically
|
||||
npm run e2e:ui # interactive runner
|
||||
npm run e2e:report # last HTML report
|
||||
```
|
||||
|
||||
Against a different server (staging, a locally-started backend):
|
||||
|
||||
```bash
|
||||
BASE_URL=https://staging.example.com npm run e2e
|
||||
```
|
||||
|
||||
## What this suite currently covers, and what it doesn't
|
||||
|
||||
`environment.ts` ships `useMockData: false` — the dev server this suite boots hits real `/api/` endpoints, which 404 (`../docs/backend/BACKEND-INTEGRATION.md` — no backend is running anywhere this session can reach). The product catalog itself renders from a separate mocked bootstrap/catalog path (`useMockBootstrapOnLocal: true`), so real prices and real currency conversion ARE exercised — `smoke.spec.ts` explicitly ignores the expected 404 console noise rather than pretending it isn't there.
|
||||
|
||||
**This is not the same guarantee as running against a live backend.** Checkout, payment, and anything behind a real endpoint are not covered until `BASE_URL` points at a live environment. Confirmed once, concretely: on the first run, this suite caught a real bug (`@marketplaces/auth` shipping without Angular Ivy metadata, breaking app bootstrap) and a real test defect (a duplicate hidden dropdown made the first currency-switch attempt click a no-op element) — both fixed as part of standing this suite up. See the commit history in `src/main.ts` and this directory for what each was.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `currency-switch.spec.ts` | `160 RUB` must not silently become `160 USD` on a currency switch — Track Q Q4, and the regression guard `../docs/backend/BACKEND-INTEGRATION.md` §5 exists to close. Written **before** the checkout money-truth rewrite (F10–F16 in the frontend backlog), specifically so that rewrite has a net under it. |
|
||||
| `smoke.spec.ts` | App boots, storefront renders, no console errors on first paint. |
|
||||
| `admin-dev-bypass.spec.ts` | `?devBypassAdmin=true` actually reaches the admin shell without a Telegram login (Track Q F59). |
|
||||
| `checkout-request-shape.spec.ts` | The amount actually charged must be computed server-side, never sent by the client (`PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` §5.2). Was red for a real bug, not a harness issue — root-caused 2026-08-21, see the `fakeCustomerSession` comment and `api-headers.interceptor.ts`. |
|
||||
| `checkout-idempotent-click.spec.ts` | Double-clicking checkout sends exactly one checkout-session request (Track Q F62). Same root cause and fix as above. |
|
||||
|
||||
## Adding a test
|
||||
|
||||
- Prefer existing CSS classes / ARIA roles already in the templates (`.currency-button`, `role="option"`, etc.) over inventing new selectors — there are no `data-testid` attributes in this codebase yet, and adding them project-wide is out of scope for this suite.
|
||||
- One behavior per test. Name the file after the behavior, not the page.
|
||||
- If a test needs backend state that mock data can't produce, mark it `test.skip(!process.env.BASE_URL, 'needs a live backend')` rather than deleting it — it documents the gap.
|
||||
27
e2e/admin-dev-bypass.spec.ts
Normal file
27
e2e/admin-dev-bypass.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Track Q Q2 / frontend backlog F59: past "verified live" admin claims were
|
||||
* code-inspection only, because /backoffice needs a real Telegram login this
|
||||
* suite cannot perform. ?devBypassAdmin=true (src/app/app.ts, gated by
|
||||
* Angular's isDevMode() at runtime in @marketplaces/auth's
|
||||
* AdminAuthService.devBypassLogin - not just build-time, and a no-op in any
|
||||
* production build) is the existing, already-shipped answer - this test just
|
||||
* proves it actually gets an E2E run into the admin shell.
|
||||
*/
|
||||
test.describe('admin dev bypass', () => {
|
||||
test('?devBypassAdmin=true reaches the admin shell without a Telegram login', async ({ page }) => {
|
||||
await page.goto('/?devBypassAdmin=true');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// The bypass alone doesn't navigate anywhere - it only activates the
|
||||
// session, so the admin surface has to be reached directly afterwards.
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// A real Telegram-gated admin route would redirect to a login dialog;
|
||||
// reaching dashboard content is the actual proof the bypass worked.
|
||||
await expect(page).not.toHaveURL(/login/i);
|
||||
await expect(page.locator('body')).not.toContainText(/scan.*qr|log in with telegram/i);
|
||||
});
|
||||
});
|
||||
78
e2e/checkout-idempotent-click.spec.ts
Normal file
78
e2e/checkout-idempotent-click.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Page, Route, expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Track Q Q5 / frontend backlog F62: "repeat webhook and double-click create
|
||||
* exactly one order." The webhook-idempotency half is a backend contract
|
||||
* (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §6.3, provider + providerEventId as
|
||||
* the dedup key) this suite cannot exercise without a live backend. This
|
||||
* test covers the half that IS frontend-testable: a double-click on the
|
||||
* checkout button must not fire two checkout-session requests.
|
||||
*/
|
||||
|
||||
const FAKE_ITEM = {
|
||||
categoryID: 1, itemID: 5151, name: 'Idempotency Test Item', photos: null,
|
||||
description: '', currency: 'RUB', price: 500, discount: 0, rating: 0,
|
||||
callbacks: null, questions: null, quantity: 1,
|
||||
};
|
||||
|
||||
test('double-clicking checkout sends exactly one checkout-session request', async ({ page, context }) => {
|
||||
await page.addInitScript(item => {
|
||||
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
|
||||
}, FAKE_ITEM);
|
||||
|
||||
// Root-caused and fixed 2026-08-21 - see checkout-request-shape.spec.ts's
|
||||
// fakeCustomerSession comment and api-headers.interceptor.ts.
|
||||
await context.addCookies([{ name: 'webSessionID', value: 'e2e-fake-session', url: 'http://localhost:4200' }]);
|
||||
await page.route('**/users/sessions/**', route =>
|
||||
route.fulfill({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ sessionId: 'e2e-fake-session', status: 'active', username: 'e2e_user', userId: 1 }),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v2/pricing/fx-quote**', route =>
|
||||
route.fulfill({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ quoteId: 'fxq_e2e', base: 'RUB', quote: 'RUB', rate: 1, source: 'e2e', observedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 300000).toISOString() }),
|
||||
}),
|
||||
);
|
||||
|
||||
let checkoutRequestCount = 0;
|
||||
await page.route('**/api/v2/storefront/checkout', async (route: Route) => {
|
||||
checkoutRequestCount += 1;
|
||||
// Deliberately slow, so a real double-click's second event has to land
|
||||
// while the first request is still in flight - the exact race this test
|
||||
// exists to catch.
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
route.fulfill({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
checkoutSessionId: 'chk_e2e_idempotent',
|
||||
lines: [{ offerId: String(FAKE_ITEM.itemID), qty: 1, unitPrice: { amountMinor: 50000, currency: 'RUB' }, lineTotal: { amountMinor: 50000, currency: 'RUB' }, priceSnapshotId: 'snap_e2e' }],
|
||||
subtotal: { amountMinor: 50000, currency: 'RUB' }, discount: { amountMinor: 0, currency: 'RUB' },
|
||||
delivery: { amountMinor: 0, currency: 'RUB' }, total: { amountMinor: 50000, currency: 'RUB' },
|
||||
fxQuoteId: 'fxq_e2e', expiresAt: new Date(Date.now() + 300000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v2/storefront/payments/intents', route =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ qrId: 'qr_e2e', nspkurl: 'https://example.com/pay', qrTTL: 5 }) }),
|
||||
);
|
||||
|
||||
await page.goto('/cart');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// No <label for="terms-checkbox"> exists in the markup - the checkbox and
|
||||
// its text share a plain clickable wrapper - so toggle the input directly.
|
||||
await page.locator('#terms-checkbox').dispatchEvent('click');
|
||||
await expect(page.locator('#terms-checkbox')).toBeChecked();
|
||||
|
||||
const qrButton = page.getByRole('button', { name: /qr/i }).first();
|
||||
await expect(qrButton).toBeEnabled({ timeout: 10_000 });
|
||||
await qrButton.dblclick();
|
||||
|
||||
// Give the deliberately slow mock time to resolve and for any second,
|
||||
// erroneously-fired request to have landed.
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
expect(checkoutRequestCount, 'a double-click must not create two checkout sessions').toBe(1);
|
||||
});
|
||||
201
e2e/checkout-request-shape.spec.ts
Normal file
201
e2e/checkout-request-shape.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Page, Route, expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Guards the specific contract this rewrite exists to enforce
|
||||
* (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2): the amount actually charged
|
||||
* must be computed server-side, never sent by the client. Before this
|
||||
* rewrite, POST /cart carried a client-computed `amount` the backend was
|
||||
* asked to trust.
|
||||
*
|
||||
* cart.component.ts has no unit spec (no src/app/pages/cart/*.spec.ts
|
||||
* exists), so this E2E test is the only coverage the checkout request shape
|
||||
* has. Scoped narrowly on purpose: cart state is seeded directly into
|
||||
* localStorage and the customer session is faked via cookie + intercepted
|
||||
* session-check, rather than driving a full add-to-cart-then-login UI
|
||||
* journey - that journey is real product surface worth its own test, but
|
||||
* would make this test about navigation, not about what it exists to prove.
|
||||
*/
|
||||
|
||||
const FAKE_SESSION_ID = 'e2e-fake-session';
|
||||
const FAKE_ITEM = {
|
||||
categoryID: 1,
|
||||
itemID: 4242,
|
||||
name: 'E2E Test Item',
|
||||
photos: null,
|
||||
description: '',
|
||||
currency: 'RUB',
|
||||
price: 1000,
|
||||
discount: 0,
|
||||
rating: 0,
|
||||
callbacks: null,
|
||||
questions: null,
|
||||
quantity: 2,
|
||||
};
|
||||
|
||||
test.describe('checkout request shape', () => {
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await seedCart(page);
|
||||
await fakeCustomerSession(page, context);
|
||||
await mockFxQuoteEndpoint(page);
|
||||
});
|
||||
|
||||
test('checkout session request carries offers and qty, never amount or price', async ({ page }) => {
|
||||
const checkoutRequest = interceptCheckoutSession(page);
|
||||
|
||||
await page.goto('/cart');
|
||||
await acceptTermsAndCheckout(page);
|
||||
|
||||
const body = await checkoutRequest;
|
||||
|
||||
expect(body, 'must never send a client-computed amount').not.toHaveProperty('amount');
|
||||
expect(body, 'must never send a client-computed price').not.toHaveProperty('price');
|
||||
expect(Array.isArray(body.offers), 'must send an offers array').toBe(true);
|
||||
expect(body.offers[0]).toMatchObject({ offerId: String(FAKE_ITEM.itemID), qty: FAKE_ITEM.quantity });
|
||||
});
|
||||
|
||||
test('payment intent request references the checkout session id, not a raw amount', async ({ page }) => {
|
||||
interceptCheckoutSession(page); // must resolve for the intent call to fire at all
|
||||
const intentRequest = interceptPaymentIntent(page);
|
||||
|
||||
await page.goto('/cart');
|
||||
await acceptTermsAndCheckout(page);
|
||||
|
||||
const body = await intentRequest;
|
||||
|
||||
// Payment creation now goes through @marketplaces/payment
|
||||
// (MARKETPLACES_PAYMENT_GATEWAY -> POST {qrApiUrl}/api/v1/payments),
|
||||
// not api.service.ts's superseded createPaymentIntent - see
|
||||
// cart.component.ts's createPaymentIntent() comment.
|
||||
expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture');
|
||||
expect(body).not.toHaveProperty('amount');
|
||||
const metadata = body.metadata as Record<string, string> | undefined;
|
||||
expect(typeof metadata?.merchantReference).toBe('string');
|
||||
expect((metadata?.merchantReference ?? '').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function seedCart(page: Page): Promise<void> {
|
||||
await page.addInitScript(item => {
|
||||
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
|
||||
}, FAKE_ITEM);
|
||||
}
|
||||
|
||||
async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise<void> {
|
||||
// Root-caused and fixed 2026-08-21 (see api-headers.interceptor.ts):
|
||||
// apiHeadersInterceptor injected AuthService to attach a WebSessionID
|
||||
// header, but AuthService's own constructor makes the exact
|
||||
// GET /users/sessions/:id call this interceptor runs on, which threw
|
||||
// NG0200 (circular dependency) mid-construction on every page load -
|
||||
// swallowed silently, read as "session invalid," cookie cleared
|
||||
// immediately. The { url } cookie form below is unrelated to that bug but
|
||||
// is still the more correct form, so it stays.
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'webSessionID',
|
||||
value: FAKE_SESSION_ID,
|
||||
url: 'http://localhost:4200',
|
||||
},
|
||||
]);
|
||||
|
||||
// Matches TelegramSessionApiService.normalizeWebSession's expected shape.
|
||||
await page.route('**/users/sessions/**', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
sessionId: FAKE_SESSION_ID,
|
||||
status: 'active',
|
||||
username: 'e2e_user',
|
||||
userId: 1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockFxQuoteEndpoint(page: Page): Promise<void> {
|
||||
await page.route('**/api/v2/pricing/fx-quote**', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
quoteId: 'fxq_e2e',
|
||||
base: 'RUB',
|
||||
quote: 'RUB',
|
||||
rate: 1,
|
||||
source: 'e2e-fixture',
|
||||
observedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function interceptCheckoutSession(page: Page): Promise<Record<string, unknown>> {
|
||||
return new Promise(resolve => {
|
||||
page.route('**/api/v2/storefront/checkout', (route: Route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
resolve(body);
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
checkoutSessionId: 'chk_e2e_fixture',
|
||||
lines: [{
|
||||
offerId: String(FAKE_ITEM.itemID),
|
||||
qty: FAKE_ITEM.quantity,
|
||||
unitPrice: { amountMinor: FAKE_ITEM.price * 100, currency: 'RUB' },
|
||||
lineTotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||
priceSnapshotId: 'snap_e2e',
|
||||
}],
|
||||
subtotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||
discount: { amountMinor: 0, currency: 'RUB' },
|
||||
delivery: { amountMinor: 0, currency: 'RUB' },
|
||||
total: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||
fxQuoteId: 'fxq_e2e',
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function interceptPaymentIntent(page: Page): Promise<Record<string, unknown>> {
|
||||
return new Promise(resolve => {
|
||||
// @marketplaces/payment: apiUrl (qrApiUrl with its trailing /api
|
||||
// stripped, see app.config.ts) + default paymentsPath '/api/v1/payments'.
|
||||
page.route('**/api/v1/payments', (route: Route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
resolve(body);
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
paymentId: 'qr_e2e_fixture',
|
||||
method: 'qr',
|
||||
status: 'pending',
|
||||
action: { type: 'qr', url: 'https://example.com/pay/e2e' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function acceptTermsAndCheckout(page: Page): Promise<void> {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// #terms-checkbox is a custom-styled input (zero-size native element, a
|
||||
// <label> renders the visible box) - .check() refuses on geometry even
|
||||
// with force:true, so toggle it via its label the way a real user would.
|
||||
const termsCheckbox = page.locator('#terms-checkbox');
|
||||
if (await termsCheckbox.count() > 0) {
|
||||
const label = page.locator('label[for="terms-checkbox"]');
|
||||
if (await label.count() > 0) {
|
||||
await label.click();
|
||||
} else {
|
||||
await termsCheckbox.dispatchEvent('click');
|
||||
}
|
||||
}
|
||||
|
||||
const qrButton = page.getByRole('button', { name: /qr/i }).first();
|
||||
await qrButton.click();
|
||||
}
|
||||
124
e2e/currency-switch.spec.ts
Normal file
124
e2e/currency-switch.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Page, Route, expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Track Q Q4: currency switch must recalculate by FX quote. Explicitly,
|
||||
* "160 RUB" must not become "160 USD" - the number has to change, not just
|
||||
* the label next to it.
|
||||
*
|
||||
* Written before the checkout money-truth rewrite (frontend backlog F10-F16,
|
||||
* which deletes CurrencyRatesService's client-side float math and switches
|
||||
* checkout to a server-computed total per
|
||||
* docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5). This test exists so
|
||||
* that rewrite has something to break loudly if it silently stops converting.
|
||||
*
|
||||
* This session has no live backend to run against, so GET
|
||||
* /api/v2/pricing/fx-quote is intercepted with a response shaped exactly per
|
||||
* contract §3.1. That exercises the REAL code path - FxQuoteApiGateway,
|
||||
* CurrencyRatesService, the currencyConvert pipe - rather than switching the
|
||||
* whole app into mock mode, which would test a different (mock) gateway
|
||||
* instead of the one actually shipped.
|
||||
*/
|
||||
|
||||
/** Rate relative to RUB, only what this test needs. */
|
||||
const MOCK_RATE: Record<string, number> = { USD: 0.0108, EUR: 0.0092, AMD: 4.31 };
|
||||
|
||||
test.describe('currency switch', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockFxQuoteEndpoint(page);
|
||||
});
|
||||
|
||||
test('switching currency changes the displayed price value, not just its label', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const priceLocator = page.locator('.current-price, .original-price').first();
|
||||
await expect(priceLocator).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const before = await readPrice(priceLocator);
|
||||
expect(before.value, 'a price must be a real positive number before switching').toBeGreaterThan(0);
|
||||
|
||||
await switchCurrency(page, before.currency === 'USD' ? 'RUB' : 'USD');
|
||||
|
||||
// The currency LABEL flips synchronously (a signal write), but the rate
|
||||
// itself arrives from the mocked network call asynchronously - polling
|
||||
// only the label races ahead of the actual conversion and passes before
|
||||
// the number has caught up. Poll the parsed numeric value instead, since
|
||||
// that is what this test exists to guard.
|
||||
await expect
|
||||
.poll(async () => (await readPrice(priceLocator)).value, {
|
||||
message: 'price value never diverged from the pre-switch amount',
|
||||
})
|
||||
.not.toBeCloseTo(before.value, 2);
|
||||
|
||||
const after = await readPrice(priceLocator);
|
||||
|
||||
expect(after.currency, 'the currency label must actually change').not.toBe(before.currency);
|
||||
// The literal regression this test exists to catch: a rate of 1 disguised
|
||||
// as a real conversion. RUB->USD or USD->RUB is never a 1:1 rate.
|
||||
expect(after.value, `${before.value} ${before.currency} must not equal ${after.value} ${after.currency}`).not.toBeCloseTo(before.value, 2);
|
||||
});
|
||||
|
||||
test('an out-of-range rate must not silently pass as valid', async ({ page }) => {
|
||||
// Guards the specific bad-data class this suite exists to catch: a
|
||||
// conversion that returns something implausible (zero, negative, or
|
||||
// absurdly large) instead of erroring visibly.
|
||||
await page.goto('/');
|
||||
|
||||
const priceLocator = page.locator('.current-price, .original-price').first();
|
||||
await expect(priceLocator).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const { value } = await readPrice(priceLocator);
|
||||
|
||||
expect(value).toBeGreaterThan(0);
|
||||
expect(value).toBeLessThan(100_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
async function mockFxQuoteEndpoint(page: Page): Promise<void> {
|
||||
await page.route('**/api/v2/pricing/fx-quote**', (route: Route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const base = url.searchParams.get('base') ?? 'RUB';
|
||||
const quote = url.searchParams.get('quote') ?? 'USD';
|
||||
const rate = MOCK_RATE[quote] ?? 1;
|
||||
const now = new Date();
|
||||
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
quoteId: `fxq_e2e_${base}_${quote}_${now.getTime()}`,
|
||||
base,
|
||||
quote,
|
||||
rate,
|
||||
source: 'e2e-fixture',
|
||||
observedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 5 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function switchCurrency(page: Page, targetCode: string): Promise<void> {
|
||||
// The page renders more than one language-selector instance (desktop/mobile
|
||||
// variants share the same markup) - scoping to the dropdown that actually
|
||||
// carries the "open" class avoids clicking an option in a hidden duplicate,
|
||||
// which is silently a no-op rather than a failure.
|
||||
const trigger = page.locator('.currency-button:visible').first();
|
||||
await trigger.click();
|
||||
|
||||
const openDropdown = page.locator('.currency-dropdown.open').first();
|
||||
await expect(openDropdown).toBeVisible();
|
||||
|
||||
await openDropdown.locator('.currency-option', { hasText: targetCode }).first().click();
|
||||
}
|
||||
|
||||
async function readPrice(locator: import('@playwright/test').Locator): Promise<{ value: number; currency: string }> {
|
||||
const text = (await locator.textContent()) ?? '';
|
||||
// Matches "1 234.56 USD" / "1234.56 ₽" shapes the price templates render.
|
||||
const match = text.replace(/\s/g, '').match(/([\d.,]+)([A-Z]{3}|\D+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`could not parse price text: "${text}"`);
|
||||
}
|
||||
const value = Number(match[1].replace(/,/g, ''));
|
||||
return { value, currency: match[2] };
|
||||
}
|
||||
53
e2e/smoke.spec.ts
Normal file
53
e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/** First E2E test in this repo. If this fails, nothing else in the suite matters. */
|
||||
test.describe('smoke', () => {
|
||||
test('storefront boots with no console errors', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
// pageerror catches uncaught exceptions - always a real bug, always kept.
|
||||
page.on('pageerror', err => errors.push(err.message));
|
||||
page.on('console', msg => {
|
||||
if (msg.type() !== 'error') {
|
||||
return;
|
||||
}
|
||||
// "Failed to load resource" is Chrome's own message for a failed
|
||||
// network request (404/502/etc), not application code. With
|
||||
// environment.useMockData: false and no live backend behind this dev
|
||||
// server (docs/backend/BACKEND-HANDOFF.md), every /api/ call 404s by
|
||||
// design - that is a backend-availability fact, not something this
|
||||
// smoke test exists to catch. A real app-level console.error still
|
||||
// fails this test.
|
||||
if (/^Failed to load resource/.test(msg.text())) {
|
||||
return;
|
||||
}
|
||||
errors.push(msg.text());
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
// Give the bootstrap fetch + first render cycle time to settle before
|
||||
// asserting on the error list, or this is a race against app.config.ts.
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(errors, `console errors on first paint: ${errors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => {
|
||||
// This suite runs against the mock-data build (see comment at the top of
|
||||
// playwright.config.ts), so MockBootstrapProvider fetches this static
|
||||
// asset rather than a live /bootstrap endpoint - that's the URL to
|
||||
// intercept here, not the real API path.
|
||||
await page.route('**/assets/mock/bootstrap/bootstrap.json', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Marketplace')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,20 @@ module.exports = function (config) {
|
||||
dir: require('path').join(__dirname, 'coverage'),
|
||||
subdir: '.',
|
||||
reporters: [{ type: 'text-summary' }, { type: 'html' }, { type: 'lcovonly' }],
|
||||
// Floor set 2026-08-18, ~5 points below the measured level right
|
||||
// after this session's facade-test pass (43.3%/29.0%/34.1%/43.7%
|
||||
// statements/branches/functions/lines) - a deliberate floor per the
|
||||
// delivery plan's Q9 ("deliberately unset today"), not an aspiration.
|
||||
// Ratchet this UP as coverage grows; a PR that drops below it should
|
||||
// fail CI, not get merged with a lower number quietly re-baselined in.
|
||||
check: {
|
||||
global: {
|
||||
statements: 40,
|
||||
branches: 25,
|
||||
functions: 30,
|
||||
lines: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
restartOnFileChange: true,
|
||||
});
|
||||
|
||||
@@ -30,9 +30,7 @@
|
||||
{
|
||||
"name": "api-cache",
|
||||
"urls": [
|
||||
"/api/**",
|
||||
"https://api.dexarmarket.ru:445/**",
|
||||
"https://api.novo.market:444/**"
|
||||
"/api/**"
|
||||
],
|
||||
"cacheConfig": {
|
||||
"maxSize": 100,
|
||||
|
||||
1463
package-lock.json
generated
1463
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,15 @@
|
||||
"arch:check:boundaries": "node tools/architecture/check-boundaries.mjs",
|
||||
"arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json",
|
||||
"arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles",
|
||||
"scan:bundle": "bash scripts/ci/scan-bundle.sh",
|
||||
"barry": "barry-cache",
|
||||
"barry:validate": "barry-cache validate",
|
||||
"barry:resume": "barry-cache resume",
|
||||
"barry:finalize": "barry-cache finalize",
|
||||
"barry:failure": "barry-cache failure"
|
||||
"barry:failure": "barry-cache failure",
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui",
|
||||
"e2e:report": "playwright show-report"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
@@ -31,6 +35,8 @@
|
||||
"@angular/platform-browser": "22.0.8",
|
||||
"@angular/router": "22.0.8",
|
||||
"@angular/service-worker": "22.0.8",
|
||||
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth",
|
||||
"@marketplaces/payment": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/payment",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.8.0",
|
||||
"zone.js": "~0.16.0"
|
||||
@@ -39,6 +45,7 @@
|
||||
"@angular/build": "22.0.8",
|
||||
"@angular/cli": "22.0.8",
|
||||
"@angular/compiler-cli": "22.0.8",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"barry-cache": "^0.9.3",
|
||||
"istanbul-lib-instrument": "^6.0.3",
|
||||
|
||||
41
playwright.config.ts
Normal file
41
playwright.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E harness. Track Q (docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Q1) — none
|
||||
* existed before this. Runs against the mock-data build (environment.dev's
|
||||
* useMockData: true, per src/environments/), because the dev server this
|
||||
* session can reach has no live backend behind it.
|
||||
*
|
||||
* Once a real backend is reachable, point BASE_URL at it and set
|
||||
* PW_USE_MOCK_DATA=false to get end-to-end coverage instead of
|
||||
* frontend-only coverage. See e2e/README.md.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
|
||||
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL ?? 'http://localhost:4200',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
|
||||
// Boots the mock-data dev server unless BASE_URL points somewhere already
|
||||
// running (a staging box, a locally-started server).
|
||||
webServer: process.env.BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: 'npm run dexar',
|
||||
url: 'http://localhost:4200',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
12
renovate.json
Normal file
12
renovate.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["@marketplaces/auth", "@marketplaces/payment"],
|
||||
"groupName": "marketplaces shared packages",
|
||||
"automerge": false,
|
||||
"labels": ["shared-package-update"]
|
||||
}
|
||||
]
|
||||
}
|
||||
67
scripts/ci/scan-bundle.sh
Normal file
67
scripts/ci/scan-bundle.sh
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fails the build if a production bundle contains anything that should only
|
||||
# ever exist server-side.
|
||||
#
|
||||
# It also fails on mock gateway code, for the same reason in a different
|
||||
# register: a production build that can reach a *LocalGateway is a production
|
||||
# build that can serve seeded fixtures as if they were real data. Those used to
|
||||
# ship - a fixture string from partner-hierarchy-local.gateway.ts was present in
|
||||
# a production bundle on 2026-08-21 - because naming both classes in a token
|
||||
# factory kept both reachable no matter what the flag said.
|
||||
#
|
||||
# Why this exists: the storefront used to send provider payment credentials
|
||||
# from the browser - an `authorization-key` header, a `userid-value` header,
|
||||
# and a hardcoded partner ID literal compiled into the bundle. That code is
|
||||
# gone (FH-1.3), and this check is what stops it coming back. A credential in
|
||||
# a JS bundle is not a leak you can revoke quietly; it is published.
|
||||
#
|
||||
# Usage:
|
||||
# npm run build && scripts/ci/scan-bundle.sh [dist-dir]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DIST="${1:-dist}"
|
||||
|
||||
if [[ ! -d "$DIST" ]]; then
|
||||
echo "scan-bundle: '$DIST' does not exist - build first" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Each entry is "label|extended-regex". Keep patterns specific: a pattern that
|
||||
# fires on ordinary code trains people to ignore this check.
|
||||
PATTERNS=(
|
||||
"provider auth header|authorization-key"
|
||||
"provider user header|userid-value"
|
||||
"hardcoded partner id|web-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
||||
"oauth client secret|client_secret[\"']?[[:space:]]*[:=]"
|
||||
"private key block|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY"
|
||||
"aws access key|AKIA[0-9A-Z]{16}"
|
||||
"telegram bot token|[0-9]{8,10}:AA[0-9A-Za-z_-]{33}"
|
||||
"mock gateway class|[A-Za-z]+LocalGateway"
|
||||
"mock gateway fixture|ptr_local|customer_vk_mock"
|
||||
)
|
||||
|
||||
failed=0
|
||||
|
||||
for entry in "${PATTERNS[@]}"; do
|
||||
label="${entry%%|*}"
|
||||
pattern="${entry#*|}"
|
||||
|
||||
if matches="$(grep -rIlE "$pattern" "$DIST" 2>/dev/null)"; then
|
||||
if [[ -n "$matches" ]]; then
|
||||
echo "FAIL: $label found in the built bundle" >&2
|
||||
echo "$matches" | sed 's/^/ /' >&2
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $failed -ne 0 ]]; then
|
||||
echo >&2
|
||||
echo "Something reached the browser bundle that should not have." >&2
|
||||
echo "Credentials belong behind the API; mock gateways belong in dev-only" >&2
|
||||
echo "providers swapped out by angular.json fileReplacements." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "scan-bundle: clean ($DIST)"
|
||||
143
scripts/deploy/add-domain.sh
Executable file
143
scripts/deploy/add-domain.sh
Executable file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Attach one customer domain to this server and issue a TLS certificate.
|
||||
# Idempotent: re-running for an existing domain renews/repairs rather than duplicates.
|
||||
# Run as root, AFTER the domain's A/AAAA record already resolves to this server.
|
||||
#
|
||||
# bash add-domain.sh shop.example.com --email ops@example.com
|
||||
# bash add-domain.sh shop.example.com --email ops@example.com --with-www
|
||||
#
|
||||
# Why per-domain blocks exist at all: the application is multi-tenant off the
|
||||
# Host header and needs no per-domain root. Certificates are the exception —
|
||||
# certbot must match a concrete server_name, which `default_server _` is not.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="${1:-}"; shift || true
|
||||
EMAIL=""
|
||||
WITH_WWW=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--email) EMAIL="$2"; shift 2 ;;
|
||||
--with-www) WITH_WWW=1; shift ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||
[[ -n "$DOMAIN" ]] || { echo "usage: add-domain.sh <domain> --email <address> [--with-www]" >&2; exit 2; }
|
||||
[[ -n "$EMAIL" ]] || { echo "--email is required (certbot expiry notices)" >&2; exit 2; }
|
||||
|
||||
# Fail loudly rather than let certbot fail obscurely on an unpointed domain.
|
||||
echo "==> checking DNS for $DOMAIN"
|
||||
RESOLVED="$(getent hosts "$DOMAIN" | awk '{print $1}' | head -1 || true)"
|
||||
if [[ -z "$RESOLVED" ]]; then
|
||||
echo "ERROR: $DOMAIN does not resolve. Point its A record at this server first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " resolves to $RESOLVED"
|
||||
|
||||
NAMES="$DOMAIN"
|
||||
CERT_ARGS=(-d "$DOMAIN")
|
||||
if [[ $WITH_WWW -eq 1 ]]; then
|
||||
NAMES="$DOMAIN www.$DOMAIN"
|
||||
CERT_ARGS+=(-d "www.$DOMAIN")
|
||||
fi
|
||||
|
||||
CONF="/etc/nginx/sites-available/tenant-$DOMAIN.conf"
|
||||
echo "==> nginx server block: $CONF"
|
||||
cat > "$CONF" <<NGINX
|
||||
# Tenant domain: $DOMAIN
|
||||
# Same root as the catch-all — the SPA resolves the tenant from the Host header.
|
||||
# This block exists so certbot has a concrete server_name to attach TLS to.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name $NAMES;
|
||||
|
||||
root /srv/marketplaces/current/frontend;
|
||||
index index.html;
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, must-revalidate" always;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)\$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
NGINX
|
||||
|
||||
ln -sfn "$CONF" "/etc/nginx/sites-enabled/tenant-$DOMAIN.conf"
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "==> certificate"
|
||||
# --nginx rewrites the block above in place to add listen 443 + ssl directives
|
||||
# and an HTTP->HTTPS redirect. Re-running is a no-op when the cert is current.
|
||||
certbot --nginx "${CERT_ARGS[@]}" \
|
||||
--non-interactive --agree-tos --email "$EMAIL" \
|
||||
--redirect --keep-until-expiring
|
||||
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "==> companion API domain(s)"
|
||||
CONFIGURE_API="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/configure-api-domain.sh"
|
||||
[[ -x "$CONFIGURE_API" ]] || {
|
||||
echo "ERROR: configure-api-domain.sh must be executable and next to add-domain.sh" >&2
|
||||
exit 1
|
||||
}
|
||||
IFS=. read -ra DOMAIN_LABELS <<< "$DOMAIN"
|
||||
LABEL_COUNT=${#DOMAIN_LABELS[@]}
|
||||
TAKE=2
|
||||
TLD=${DOMAIN_LABELS[LABEL_COUNT-1]}
|
||||
SECOND_LEVEL=${DOMAIN_LABELS[LABEL_COUNT-2]}
|
||||
if (( LABEL_COUNT >= 3 && ${#TLD} == 2 && ${#SECOND_LEVEL} <= 3 )); then
|
||||
TAKE=3
|
||||
fi
|
||||
START=$((LABEL_COUNT - TAKE))
|
||||
API_BASE_DOMAIN=$(IFS=.; echo "${DOMAIN_LABELS[*]:START}")
|
||||
"$CONFIGURE_API" --domain "$API_BASE_DOMAIN" --email "$EMAIL"
|
||||
|
||||
echo "==> renewal timer"
|
||||
systemctl enable --now certbot.timer
|
||||
systemctl status certbot.timer --no-pager | head -3 || true
|
||||
|
||||
echo
|
||||
echo "done. verify:"
|
||||
echo " curl -I https://$DOMAIN/health"
|
||||
echo " certbot certificates | grep -A3 $DOMAIN"
|
||||
114
scripts/deploy/configure-api-domain.sh
Executable file
114
scripts/deploy/configure-api-domain.sh
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Configure one shared api.<base-domain> for the base storefront and all tenant
|
||||
# subdomains. Idempotent. Run as root after the API DNS record resolves here.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN=""
|
||||
EMAIL=""
|
||||
UPSTREAM="https://127.0.0.1:445"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--domain) DOMAIN="$2"; shift 2 ;;
|
||||
--email) EMAIL="$2"; shift 2 ;;
|
||||
--upstream) UPSTREAM="$2"; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||
[[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || {
|
||||
echo "--domain must be a valid lowercase hostname" >&2; exit 2;
|
||||
}
|
||||
[[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || {
|
||||
echo "--email must be valid" >&2; exit 2;
|
||||
}
|
||||
[[ "$UPSTREAM" =~ ^https?://[a-zA-Z0-9.:-]+$ ]] || {
|
||||
echo "--upstream must be an http(s) origin without a path" >&2; exit 2;
|
||||
}
|
||||
|
||||
API_DOMAIN="api.$DOMAIN"
|
||||
CONF="/etc/nginx/sites-available/$API_DOMAIN"
|
||||
DOMAIN_REGEX="${DOMAIN//./\\.}"
|
||||
|
||||
echo "==> checking DNS for $API_DOMAIN"
|
||||
getent hosts "$API_DOMAIN" >/dev/null || {
|
||||
echo "ERROR: $API_DOMAIN does not resolve; create DNS before provisioning TLS" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cat > "$CONF" <<NGINX
|
||||
# Managed by marketplaces configure-api-domain.sh. Manual edits are overwritten.
|
||||
# Storefront $DOMAIN and its tenant subdomains share https://$API_DOMAIN.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name $API_DOMAIN;
|
||||
|
||||
access_log /var/log/nginx/$API_DOMAIN.access.log;
|
||||
error_log /var/log/nginx/$API_DOMAIN.error.log;
|
||||
|
||||
set \$cors_origin "";
|
||||
set \$storefront_host "$DOMAIN";
|
||||
if (\$http_origin ~* "^https://(?<allowed_storefront>([a-z0-9-]+\\.)*$DOMAIN_REGEX)$") {
|
||||
set \$cors_origin \$http_origin;
|
||||
set \$storefront_host \$allowed_storefront;
|
||||
}
|
||||
|
||||
add_header Access-Control-Allow-Origin \$cors_origin always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, AdminWebSessionID, WebSessionID, Currency, X-Language, X-Region, X-Requested-With" always;
|
||||
add_header Vary "Origin" always;
|
||||
|
||||
if (\$request_method = OPTIONS) { return 204; }
|
||||
|
||||
location / {
|
||||
proxy_pass $UPSTREAM;
|
||||
proxy_http_version 1.1;
|
||||
# Browser Origin selects the storefront tenant while every tenant under
|
||||
# this base domain shares one public API hostname.
|
||||
proxy_set_header Host \$storefront_host;
|
||||
proxy_set_header X-Forwarded-Host $API_DOMAIN;
|
||||
proxy_set_header X-Storefront-Host \$storefront_host;
|
||||
# nginx has already validated and answered CORS. The existing backend
|
||||
# rejects browser Origin on :445, so do not forward it a second time.
|
||||
proxy_set_header Origin "";
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name $DOMAIN;
|
||||
}
|
||||
}
|
||||
NGINX
|
||||
|
||||
ln -sfn "$CONF" "/etc/nginx/sites-enabled/$API_DOMAIN"
|
||||
nginx -t
|
||||
|
||||
certbot --nginx -d "$API_DOMAIN" \
|
||||
--non-interactive --agree-tos --email "$EMAIL" \
|
||||
--redirect --keep-until-expiring
|
||||
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "==> verifying https://$API_DOMAIN/bootstrap"
|
||||
bootstrap_tmp="$(mktemp)"
|
||||
trap 'rm -f "$bootstrap_tmp"' EXIT
|
||||
content_type="$(curl --resolve "$API_DOMAIN:443:127.0.0.1" -fsS \
|
||||
-o "$bootstrap_tmp" -w '%{content_type}' \
|
||||
"https://$API_DOMAIN/bootstrap")"
|
||||
[[ "$content_type" == application/json* ]] || {
|
||||
echo "ERROR: $API_DOMAIN/bootstrap returned $content_type, expected application/json" >&2
|
||||
exit 1
|
||||
}
|
||||
jq -e 'type == "object"' "$bootstrap_tmp" >/dev/null
|
||||
rm -f "$bootstrap_tmp"
|
||||
trap - EXIT
|
||||
|
||||
echo "configured: $DOMAIN -> https://$API_DOMAIN -> $UPSTREAM"
|
||||
286
scripts/deploy/server-setup.sh
Executable file
286
scripts/deploy/server-setup.sh
Executable file
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# One-time server provisioning for the marketplaces frontend.
|
||||
# Idempotent: safe to re-run. Run as root on the target server.
|
||||
#
|
||||
# bash server-setup.sh --pubkey "ssh-ed25519 AAAA... ci@marketplaces"
|
||||
#
|
||||
# What it does NOT do: issue TLS certificates (no domain points here yet).
|
||||
# Run add-domain.sh per domain once DNS resolves. See docs/DEPLOYMENT.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEPLOY_USER="deploy"
|
||||
BASE="/srv/marketplaces"
|
||||
PUBKEY=""
|
||||
KEEP_RELEASES=5
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pubkey) PUBKEY="$2"; shift 2 ;;
|
||||
--user) DEPLOY_USER="$2"; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||
[[ -n "$PUBKEY" ]] || { echo "--pubkey is required (the CI deploy key's PUBLIC half)" >&2; exit 1; }
|
||||
|
||||
echo "==> packages"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq nginx certbot python3-certbot-nginx rsync ufw jq curl openssl
|
||||
|
||||
echo "==> deploy user: $DEPLOY_USER"
|
||||
if ! id -u "$DEPLOY_USER" >/dev/null 2>&1; then
|
||||
# No password is ever set: this account is key-only by construction.
|
||||
adduser --system --group --shell /bin/bash --home "/home/$DEPLOY_USER" "$DEPLOY_USER"
|
||||
fi
|
||||
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh"
|
||||
AUTH="/home/$DEPLOY_USER/.ssh/authorized_keys"
|
||||
touch "$AUTH"
|
||||
grep -qxF "$PUBKEY" "$AUTH" || echo "$PUBKEY" >> "$AUTH"
|
||||
chown "$DEPLOY_USER:$DEPLOY_USER" "$AUTH"
|
||||
chmod 600 "$AUTH"
|
||||
|
||||
echo "==> directories"
|
||||
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$BASE" "$BASE/releases"
|
||||
# First deploy creates $BASE/current as a symlink into releases/.
|
||||
# Seed a placeholder so nginx starts cleanly before anything is deployed.
|
||||
if [[ ! -e "$BASE/current" ]]; then
|
||||
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$BASE/releases/bootstrap/frontend"
|
||||
echo "<!doctype html><title>marketplaces</title><p>Not deployed yet." \
|
||||
> "$BASE/releases/bootstrap/frontend/index.html"
|
||||
chown -R "$DEPLOY_USER:$DEPLOY_USER" "$BASE/releases/bootstrap"
|
||||
ln -sfn "$BASE/releases/bootstrap" "$BASE/current"
|
||||
chown -h "$DEPLOY_USER:$DEPLOY_USER" "$BASE/current"
|
||||
fi
|
||||
|
||||
echo "==> nginx catch-all (multi-tenant: one bundle serves every domain)"
|
||||
cat > /etc/nginx/sites-available/marketplaces.conf <<'NGINX'
|
||||
# Multi-tenant by design: the SPA derives its tenant from the Host header,
|
||||
# so ONE server block serves every customer domain. Do not add a per-tenant
|
||||
# root here. Per-domain server blocks exist only to hold TLS certificates
|
||||
# (see add-domain.sh) and proxy to this same root.
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /srv/marketplaces/current/frontend;
|
||||
index index.html;
|
||||
|
||||
access_log /var/log/nginx/marketplaces.access.log;
|
||||
error_log /var/log/nginx/marketplaces.error.log;
|
||||
|
||||
# Do not let the browser cache the app shell: a deploy must take effect
|
||||
# on the next reload, not whenever a stale index.html expires.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, must-revalidate" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Hashed build artifacts are immutable by construction.
|
||||
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# SPA fallback. Must stay last: every unmatched path is a client route.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
NGINX
|
||||
|
||||
ln -sfn /etc/nginx/sites-available/marketplaces.conf /etc/nginx/sites-enabled/marketplaces.conf
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
echo "==> firewall"
|
||||
ufw allow OpenSSH >/dev/null
|
||||
ufw allow 80/tcp >/dev/null
|
||||
ufw allow 443/tcp >/dev/null
|
||||
ufw --force enable >/dev/null
|
||||
|
||||
# FH-D.3. ufw alone leaves SSH open to unlimited password guessing and leaves
|
||||
# the kernel on defaults that are wrong for an internet-facing host. All three
|
||||
# blocks below are drop-in files, so a re-run overwrites its own config and
|
||||
# never edits a distro file in place.
|
||||
|
||||
echo "==> sshd hardening"
|
||||
cat > /etc/ssh/sshd_config.d/10-marketplaces-hardening.conf <<'SSHD'
|
||||
# Both accounts on this host are key-only by construction (the deploy user is
|
||||
# created with no password at all), so password auth can only ever succeed for
|
||||
# a credential nobody intended to exist.
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PermitEmptyPasswords no
|
||||
PermitRootLogin prohibit-password
|
||||
X11Forwarding no
|
||||
AllowAgentForwarding no
|
||||
MaxAuthTries 3
|
||||
LoginGraceTime 30
|
||||
ClientAliveInterval 300
|
||||
ClientAliveCountMax 2
|
||||
SSHD
|
||||
# Validate before reloading: a bad sshd config that takes effect on a remote
|
||||
# box is how people lock themselves out permanently.
|
||||
if sshd -t; then
|
||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
||||
else
|
||||
echo "sshd config test FAILED - removing the drop-in and leaving sshd as it was" >&2
|
||||
rm -f /etc/ssh/sshd_config.d/10-marketplaces-hardening.conf
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> fail2ban"
|
||||
apt-get install -y -qq fail2ban
|
||||
cat > /etc/fail2ban/jail.d/marketplaces.local <<'F2B'
|
||||
[DEFAULT]
|
||||
backend = systemd
|
||||
findtime = 10m
|
||||
bantime = 1h
|
||||
maxretry = 5
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
|
||||
[nginx-http-auth]
|
||||
enabled = true
|
||||
|
||||
[nginx-bad-request]
|
||||
enabled = true
|
||||
F2B
|
||||
systemctl enable --now fail2ban
|
||||
systemctl restart fail2ban
|
||||
|
||||
echo "==> kernel hardening"
|
||||
cat > /etc/sysctl.d/99-marketplaces-hardening.conf <<'SYSCTL'
|
||||
# Ignore ICMP redirects and source routing: this host has one gateway and
|
||||
# nothing upstream should be rewriting its routing table.
|
||||
net.ipv4.conf.all.accept_redirects = 0
|
||||
net.ipv4.conf.default.accept_redirects = 0
|
||||
net.ipv6.conf.all.accept_redirects = 0
|
||||
net.ipv6.conf.default.accept_redirects = 0
|
||||
net.ipv4.conf.all.send_redirects = 0
|
||||
net.ipv4.conf.default.send_redirects = 0
|
||||
net.ipv4.conf.all.accept_source_route = 0
|
||||
net.ipv6.conf.all.accept_source_route = 0
|
||||
|
||||
# Reverse-path filtering and martian logging.
|
||||
net.ipv4.conf.all.rp_filter = 1
|
||||
net.ipv4.conf.default.rp_filter = 1
|
||||
net.ipv4.conf.all.log_martians = 1
|
||||
|
||||
# SYN flood resistance.
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
net.ipv4.tcp_max_syn_backlog = 2048
|
||||
net.ipv4.tcp_synack_retries = 2
|
||||
|
||||
# No IP forwarding: this is a web server, not a router.
|
||||
net.ipv4.ip_forward = 0
|
||||
|
||||
# Restrict kernel pointer and dmesg exposure to unprivileged users.
|
||||
kernel.kptr_restrict = 2
|
||||
kernel.dmesg_restrict = 1
|
||||
SYSCTL
|
||||
sysctl --quiet --system
|
||||
|
||||
echo "==> nginx config test"
|
||||
nginx -t
|
||||
systemctl enable --now nginx
|
||||
systemctl reload nginx
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
echo "==> tenant API-domain configurator"
|
||||
if [[ -f "$SRC_DIR/configure-api-domain.sh" ]]; then
|
||||
install -m 755 -o root -g root "$SRC_DIR/configure-api-domain.sh" \
|
||||
/usr/local/sbin/marketplaces-configure-api-domain
|
||||
else
|
||||
echo "configure-api-domain.sh not found next to server-setup.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> dynamic domain reconciler"
|
||||
install -d -m 755 "$BASE/bin" /etc/marketplaces "/var/lib/marketplaces"
|
||||
if [[ -f "$SRC_DIR/sync-domains.sh" ]]; then
|
||||
install -m 755 "$SRC_DIR/sync-domains.sh" "$BASE/bin/sync-domains.sh"
|
||||
|
||||
if [[ ! -f /etc/marketplaces/domains.env ]]; then
|
||||
cat > /etc/marketplaces/domains.env <<'ENVFILE'
|
||||
# Where the desired domain list comes from.
|
||||
# file:/etc/marketplaces/domains.txt one hostname per line
|
||||
# https://api.example.com/api/admin/v2/domains JSON, once the backend exists
|
||||
DOMAINS_SOURCE=file:/etc/marketplaces/domains.txt
|
||||
|
||||
# Required: certbot expiry notices.
|
||||
CERTBOT_EMAIL=
|
||||
|
||||
# Cap per run so a bad source cannot burn the weekly ACME budget in one pass.
|
||||
MAX_ISSUE_PER_RUN=10
|
||||
|
||||
# Set by setup-wildcard-tls.sh. Subdomains of this apex skip per-domain issuance.
|
||||
#WILDCARD_APEX=
|
||||
ENVFILE
|
||||
chmod 600 /etc/marketplaces/domains.env
|
||||
fi
|
||||
touch /etc/marketplaces/domains.txt
|
||||
|
||||
if [[ -d "$SRC_DIR/systemd" ]]; then
|
||||
install -m 644 "$SRC_DIR/systemd/marketplaces-domains.service" /etc/systemd/system/
|
||||
install -m 644 "$SRC_DIR/systemd/marketplaces-domains.timer" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
# Not started yet: CERTBOT_EMAIL is still blank. Enable it after filling in
|
||||
# /etc/marketplaces/domains.env, or the first run just fails on every tick.
|
||||
echo " timer installed but NOT started - set CERTBOT_EMAIL first, then:"
|
||||
echo " systemctl enable --now marketplaces-domains.timer"
|
||||
fi
|
||||
else
|
||||
echo " sync-domains.sh not found next to this script - skipping"
|
||||
fi
|
||||
|
||||
echo "==> sudoers: deployment reload plus validated tenant API provisioning"
|
||||
cat > /etc/sudoers.d/marketplaces-deploy <<SUDO
|
||||
Cmnd_Alias MARKETPLACES_DEPLOY = /bin/systemctl reload nginx, /usr/local/sbin/marketplaces-configure-api-domain *
|
||||
$DEPLOY_USER ALL=(root) NOPASSWD: MARKETPLACES_DEPLOY
|
||||
SUDO
|
||||
chmod 440 /etc/sudoers.d/marketplaces-deploy
|
||||
visudo -c -f /etc/sudoers.d/marketplaces-deploy
|
||||
|
||||
echo
|
||||
echo "done."
|
||||
echo " deploy user : $DEPLOY_USER (key-only, no password)"
|
||||
echo " web root : $BASE/current/frontend"
|
||||
echo " keep : last $KEEP_RELEASES releases"
|
||||
echo
|
||||
echo "next:"
|
||||
echo " 1. curl -I http://<this-server>/health -> expect 200"
|
||||
echo " 2. point a domain's A record here"
|
||||
echo " 3. bash add-domain.sh <domain> -> issues TLS"
|
||||
echo " 4. add CI secrets, push to main -> first real deploy"
|
||||
148
scripts/deploy/setup-wildcard-tls.sh
Normal file
148
scripts/deploy/setup-wildcard-tls.sh
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Issue ONE wildcard certificate covering every tenant subdomain under an apex.
|
||||
# After this, a new tenant at <slug>.<apex> needs no certificate work at all —
|
||||
# DNS record, and it is live over HTTPS immediately.
|
||||
#
|
||||
# setup-wildcard-tls.sh --apex marketplaces.example.com --email ops@example.com --dns cloudflare
|
||||
# setup-wildcard-tls.sh --apex marketplaces.example.com --email ops@example.com --dns manual
|
||||
#
|
||||
# Wildcards require DNS-01 validation — HTTP-01 cannot issue them. That means
|
||||
# certbot must create a _acme-challenge TXT record, which needs either a DNS
|
||||
# provider plugin (automatic, renews unattended) or manual intervention every
|
||||
# 60-90 days. Prefer a plugin. Use manual only to prove the idea out.
|
||||
#
|
||||
# Tenants on their OWN domains are not covered by a wildcard; those are handled
|
||||
# per-domain by sync-domains.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APEX=""; EMAIL=""; DNS_PLUGIN="manual"; CREDS=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--apex) APEX="$2"; shift 2 ;;
|
||||
--email) EMAIL="$2"; shift 2 ;;
|
||||
--dns) DNS_PLUGIN="$2"; shift 2 ;;
|
||||
--creds) CREDS="$2"; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||
[[ -n "$APEX" ]] || { echo "--apex is required" >&2; exit 2; }
|
||||
[[ -n "$EMAIL" ]] || { echo "--email is required" >&2; exit 2; }
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
CERT_ARGS=(-d "$APEX" -d "*.$APEX")
|
||||
|
||||
case "$DNS_PLUGIN" in
|
||||
cloudflare)
|
||||
apt-get install -y -qq python3-certbot-dns-cloudflare
|
||||
[[ -n "$CREDS" ]] || { echo "--creds <file> required for cloudflare (contains the API token)" >&2; exit 2; }
|
||||
chmod 600 "$CREDS"
|
||||
CERT_ARGS+=(--dns-cloudflare --dns-cloudflare-credentials "$CREDS" --dns-cloudflare-propagation-seconds 30)
|
||||
;;
|
||||
route53)
|
||||
apt-get install -y -qq python3-certbot-dns-route53
|
||||
CERT_ARGS+=(--dns-route53) # credentials come from the instance role or ~/.aws
|
||||
;;
|
||||
manual)
|
||||
cat >&2 <<'WARN'
|
||||
WARNING: manual DNS-01.
|
||||
|
||||
certbot will print a TXT record for you to create by hand, and will do so again
|
||||
at every renewal (every 60-90 days). Unattended renewal will NOT work. This is
|
||||
acceptable to prove the setup out; it is not acceptable as the steady state.
|
||||
|
||||
Hostinger has no certbot plugin. If DNS lives there, the options are: move DNS
|
||||
to a provider with a plugin (Cloudflare is free and takes minutes), or drive
|
||||
issuance from the Phase 9 domain-automation API instead.
|
||||
|
||||
WARN
|
||||
CERT_ARGS+=(--manual --preferred-challenges dns)
|
||||
;;
|
||||
*)
|
||||
echo "unsupported --dns: $DNS_PLUGIN (cloudflare|route53|manual)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
echo "==> issuing wildcard for $APEX and *.$APEX via $DNS_PLUGIN"
|
||||
certbot certonly "${CERT_ARGS[@]}" \
|
||||
--agree-tos --email "$EMAIL" --keep-until-expiring \
|
||||
$([[ "$DNS_PLUGIN" != "manual" ]] && echo --non-interactive)
|
||||
|
||||
LIVE="/etc/letsencrypt/live/$APEX"
|
||||
[[ -f "$LIVE/fullchain.pem" ]] || { echo "certificate not found at $LIVE" >&2; exit 1; }
|
||||
|
||||
echo "==> nginx: TLS on the catch-all, so every subdomain is served immediately"
|
||||
cat > /etc/nginx/snippets/marketplaces-wildcard-tls.conf <<SNIPPET
|
||||
# Managed by setup-wildcard-tls.sh
|
||||
ssl_certificate $LIVE/fullchain.pem;
|
||||
ssl_certificate_key $LIVE/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
SNIPPET
|
||||
|
||||
cat > /etc/nginx/sites-available/marketplaces-tls.conf <<NGINX
|
||||
# Wildcard TLS catch-all for *.$APEX
|
||||
# Any tenant subdomain is served here with no per-tenant configuration.
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server;
|
||||
http2 on;
|
||||
server_name $APEX *.$APEX;
|
||||
|
||||
include /etc/nginx/snippets/marketplaces-wildcard-tls.conf;
|
||||
|
||||
root /srv/marketplaces/current/frontend;
|
||||
index index.html;
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, must-revalidate" always;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)\$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
location /health { access_log off; return 200 "ok\n"; add_header Content-Type text/plain; }
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
location / { try_files \$uri \$uri/ /index.html; }
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
NGINX
|
||||
|
||||
ln -sfn /etc/nginx/sites-available/marketplaces-tls.conf /etc/nginx/sites-enabled/marketplaces-tls.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
systemctl enable --now certbot.timer
|
||||
|
||||
# Tell sync-domains.sh which names it can skip.
|
||||
mkdir -p /etc/marketplaces
|
||||
if [[ -f /etc/marketplaces/domains.env ]]; then
|
||||
sed -i '/^WILDCARD_APEX=/d' /etc/marketplaces/domains.env
|
||||
fi
|
||||
echo "WILDCARD_APEX=$APEX" >> /etc/marketplaces/domains.env
|
||||
|
||||
echo
|
||||
echo "done. every <slug>.$APEX is now served over HTTPS with no further action."
|
||||
echo "verify: curl -I https://anything.$APEX/health"
|
||||
234
scripts/deploy/sync-domains.sh
Normal file
234
scripts/deploy/sync-domains.sh
Normal file
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Reconcile the set of TLS-enabled domains on this server against a desired
|
||||
# list. Idempotent and safe to run on a timer: it issues what is missing,
|
||||
# leaves what is current alone, and disables what has been removed.
|
||||
#
|
||||
# The nginx catch-all already serves ANY Host over HTTP with no config, so a
|
||||
# new domain works on port 80 the moment DNS resolves. This script exists only
|
||||
# because TLS needs a certificate per name.
|
||||
#
|
||||
# sync-domains.sh # reconcile from $DOMAINS_SOURCE
|
||||
# sync-domains.sh --dry-run # print the plan, change nothing
|
||||
#
|
||||
# Config: /etc/marketplaces/domains.env
|
||||
# DOMAINS_SOURCE=file:/etc/marketplaces/domains.txt
|
||||
# DOMAINS_SOURCE=https://api.example.com/api/admin/v2/domains (JSON array)
|
||||
# CERTBOT_EMAIL=ops@example.com
|
||||
# MAX_ISSUE_PER_RUN=10
|
||||
#
|
||||
# Let's Encrypt caps new certificates per registered domain per week. The
|
||||
# per-run issuance cap keeps a misconfigured source from burning that budget
|
||||
# in one pass; the remainder is picked up on the next run.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG="/etc/marketplaces/domains.env"
|
||||
STATE_DIR="/var/lib/marketplaces"
|
||||
DRY_RUN=0
|
||||
RENEW_WINDOW_DAYS=30
|
||||
|
||||
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
[[ -f "$CONFIG" ]] && source "$CONFIG"
|
||||
|
||||
DOMAINS_SOURCE="${DOMAINS_SOURCE:-file:/etc/marketplaces/domains.txt}"
|
||||
CERTBOT_EMAIL="${CERTBOT_EMAIL:-}"
|
||||
MAX_ISSUE_PER_RUN="${MAX_ISSUE_PER_RUN:-10}"
|
||||
WILDCARD_APEX="${WILDCARD_APEX:-}"
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||
[[ -n "$CERTBOT_EMAIL" ]] || { echo "CERTBOT_EMAIL not set in $CONFIG" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
log() { printf '%s %s\n' "$(date -Is)" "$*"; }
|
||||
|
||||
# ---------------------------------------------------------------- desired set
|
||||
|
||||
fetch_desired() {
|
||||
case "$DOMAINS_SOURCE" in
|
||||
file:*)
|
||||
local path="${DOMAINS_SOURCE#file:}"
|
||||
[[ -f "$path" ]] || { log "source file $path missing"; return 1; }
|
||||
# one domain per line; # comments and blanks ignored
|
||||
sed -e 's/#.*//' -e 's/[[:space:]]//g' "$path" | grep -v '^$' || true
|
||||
;;
|
||||
http://*|https://*)
|
||||
# Expected shape: ["a.example.com","b.example.com"] or
|
||||
# [{"domain":"a.example.com","status":"active"}, ...]
|
||||
local body
|
||||
body="$(curl -fsS --max-time 20 ${DOMAINS_API_TOKEN:+-H "Authorization: Bearer $DOMAINS_API_TOKEN"} "$DOMAINS_SOURCE")" || {
|
||||
log "ERROR: could not fetch $DOMAINS_SOURCE — leaving current config untouched"
|
||||
return 1
|
||||
}
|
||||
echo "$body" | jq -r '
|
||||
if type=="array" and (.[0]|type)=="object"
|
||||
then .[] | select((.status // "active") == "active") | .domain
|
||||
else .[] end' 2>/dev/null || {
|
||||
log "ERROR: unparseable response from $DOMAINS_SOURCE"
|
||||
return 1
|
||||
}
|
||||
;;
|
||||
*)
|
||||
log "ERROR: unsupported DOMAINS_SOURCE: $DOMAINS_SOURCE"; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# A source failure must never be read as "all domains removed". Bail instead.
|
||||
if ! DESIRED_RAW="$(fetch_desired)"; then
|
||||
log "reconcile aborted: desired set unavailable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Reject anything that is not a plausible hostname before it reaches certbot
|
||||
# or an nginx server_name.
|
||||
DESIRED="$(echo "$DESIRED_RAW" | grep -Ei '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' | sort -u || true)"
|
||||
REJECTED="$(comm -23 <(echo "$DESIRED_RAW" | sort -u) <(echo "$DESIRED") || true)"
|
||||
[[ -n "$REJECTED" ]] && log "WARNING: ignoring malformed entries: $(echo "$REJECTED" | tr '\n' ' ')"
|
||||
|
||||
if [[ -z "$DESIRED" ]]; then
|
||||
log "desired set is empty — nothing to do (not treating this as 'remove everything')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "desired: $(echo "$DESIRED" | wc -l) domain(s)"
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
covered_by_wildcard() {
|
||||
# A domain one label under the wildcard apex needs no certificate of its own.
|
||||
local d="$1"
|
||||
[[ -n "$WILDCARD_APEX" ]] || return 1
|
||||
[[ "$d" == *".$WILDCARD_APEX" ]] || return 1
|
||||
[[ "${d%.$WILDCARD_APEX}" != *.* ]]
|
||||
}
|
||||
|
||||
cert_is_current() {
|
||||
local d="$1" live="/etc/letsencrypt/live/$1/cert.pem"
|
||||
[[ -f "$live" ]] || return 1
|
||||
openssl x509 -in "$live" -noout -checkend $((RENEW_WINDOW_DAYS * 86400)) >/dev/null 2>&1
|
||||
}
|
||||
|
||||
resolves_here() {
|
||||
local d="$1"
|
||||
local got want
|
||||
got="$(getent hosts "$d" | awk '{print $1}' | sort -u)"
|
||||
[[ -n "$got" ]] || return 1
|
||||
# Compare against every address this host actually answers on.
|
||||
want="$(hostname -I | tr ' ' '\n' | grep -v '^$')"
|
||||
grep -qxF -f <(echo "$want") <(echo "$got")
|
||||
}
|
||||
|
||||
write_block() {
|
||||
local d="$1" conf="/etc/nginx/sites-available/tenant-$1.conf"
|
||||
cat > "$conf" <<NGINX
|
||||
# Managed by sync-domains.sh. Manual edits are overwritten.
|
||||
# Same root as the catch-all: the SPA resolves its tenant from the Host header.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name $d;
|
||||
root /srv/marketplaces/current/frontend;
|
||||
index index.html;
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, must-revalidate" always;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)\$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
location /health { access_log off; return 200 "ok\n"; add_header Content-Type text/plain; }
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
location / { try_files \$uri \$uri/ /index.html; }
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
NGINX
|
||||
ln -sfn "$conf" "/etc/nginx/sites-enabled/tenant-$d.conf"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ reconcile
|
||||
|
||||
issued=0 skipped=0 waiting=0 wildcarded=0 failed=0
|
||||
|
||||
while read -r d; do
|
||||
[[ -n "$d" ]] || continue
|
||||
|
||||
if covered_by_wildcard "$d"; then
|
||||
wildcarded=$((wildcarded + 1)); continue
|
||||
fi
|
||||
|
||||
if cert_is_current "$d"; then
|
||||
skipped=$((skipped + 1)); continue
|
||||
fi
|
||||
|
||||
if ! resolves_here "$d"; then
|
||||
log "waiting on DNS: $d (does not resolve to this server yet)"
|
||||
waiting=$((waiting + 1)); continue
|
||||
fi
|
||||
|
||||
if [[ $issued -ge $MAX_ISSUE_PER_RUN ]]; then
|
||||
log "issuance cap ($MAX_ISSUE_PER_RUN) reached — remaining domains roll to the next run"
|
||||
break
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
log "DRY RUN would issue: $d"; issued=$((issued + 1)); continue
|
||||
fi
|
||||
|
||||
log "issuing: $d"
|
||||
write_block "$d"
|
||||
if ! nginx -t >/dev/null 2>&1; then
|
||||
log "ERROR: nginx config invalid after adding $d — reverting that block"
|
||||
rm -f "/etc/nginx/sites-enabled/tenant-$d.conf"
|
||||
failed=$((failed + 1)); continue
|
||||
fi
|
||||
systemctl reload nginx
|
||||
|
||||
if certbot --nginx -d "$d" --non-interactive --agree-tos \
|
||||
--email "$CERTBOT_EMAIL" --redirect --keep-until-expiring >>"$STATE_DIR/certbot.log" 2>&1; then
|
||||
issued=$((issued + 1))
|
||||
log "issued: $d"
|
||||
else
|
||||
log "ERROR: certbot failed for $d (see $STATE_DIR/certbot.log) — HTTP still served, HTTPS not yet"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done <<< "$DESIRED"
|
||||
|
||||
# Domains dropped from the source: stop serving them, but never delete the
|
||||
# certificate — a domain re-added next week should not need a fresh issuance.
|
||||
for link in /etc/nginx/sites-enabled/tenant-*.conf; do
|
||||
[[ -e "$link" ]] || continue
|
||||
name="$(basename "$link")"; name="${name#tenant-}"; name="${name%.conf}"
|
||||
if ! grep -qxF "$name" <<< "$DESIRED"; then
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
log "DRY RUN would disable: $name"
|
||||
else
|
||||
log "disabling (removed from source): $name"
|
||||
rm -f "$link"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $DRY_RUN -eq 0 ]]; then
|
||||
nginx -t && systemctl reload nginx
|
||||
fi
|
||||
|
||||
log "done. issued=$issued current=$skipped wildcard=$wildcarded awaiting-dns=$waiting failed=$failed"
|
||||
[[ $failed -eq 0 ]]
|
||||
12
scripts/deploy/systemd/marketplaces-domains.service
Normal file
12
scripts/deploy/systemd/marketplaces-domains.service
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Reconcile tenant TLS domains
|
||||
After=network-online.target nginx.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/srv/marketplaces/bin/sync-domains.sh
|
||||
# A failed run must not tear down what is already serving; the next run retries.
|
||||
SuccessExitStatus=0
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
12
scripts/deploy/systemd/marketplaces-domains.timer
Normal file
12
scripts/deploy/systemd/marketplaces-domains.timer
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Reconcile tenant TLS domains every 10 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=10min
|
||||
# Spread load so many servers do not all hit the ACME API at once.
|
||||
RandomizedDelaySec=90s
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -4,15 +4,19 @@ import { provideHttpClient, withInterceptors, withXhr } from '@angular/common/ht
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { cacheInterceptor } from './interceptors/cache.interceptor';
|
||||
import { apiErrorInterceptor } from './core/interceptors/api-error.interceptor';
|
||||
import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor';
|
||||
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
|
||||
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
|
||||
import { adminAuthHeadersInterceptor } from './core/admin-auth/admin-auth-headers.interceptor';
|
||||
import { Ed25519VerificationService } from './core/admin-auth/ed25519-verification.model';
|
||||
import { NoopEd25519VerificationService } from './core/admin-auth/noop-ed25519-verification.service';
|
||||
import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519VerificationService, AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth';
|
||||
import { provideMarketplacesPayment } from '@marketplaces/payment';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { MediaRepository } from './core/media/media-repository';
|
||||
import { MockMediaRepository } from './core/media/mock-media-repository.service';
|
||||
import { ApiConfigService } from './core/config/api-config.service';
|
||||
import { TenantResolverService } from './core/config/tenant-resolver.service';
|
||||
import { environment } from '../environments/environment';
|
||||
import { MOCK_GATEWAY_PROVIDERS } from './mock-gateway.providers';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -23,13 +27,55 @@ export const appConfig: ApplicationConfig = {
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'top' })
|
||||
),
|
||||
provideHttpClient(withXhr(),
|
||||
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor])
|
||||
// apiErrorInterceptor sits last so it observes the response after every
|
||||
// other interceptor has run, and normalizes whatever actually came back.
|
||||
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor, apiErrorInterceptor])
|
||||
),
|
||||
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService },
|
||||
{
|
||||
provide: AUTH_API_URL,
|
||||
useFactory: (apiConfig: ApiConfigService) => apiConfig.getBaseUrl(),
|
||||
deps: [ApiConfigService]
|
||||
},
|
||||
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
|
||||
// useFactory, not useClass: @marketplaces/auth ships plain tsc output, not
|
||||
// Angular Package Format, so it carries no baked-in Ivy DI metadata for
|
||||
// this class. useClass forces Angular to JIT-compile it at runtime, which
|
||||
// throws when @angular/compiler isn't loaded (true for this build). A
|
||||
// factory sidesteps that - NoopEd25519VerificationService has zero
|
||||
// constructor deps, so this is a correct fix, not a workaround.
|
||||
// Real fix belongs in vitanovaPackages: publish with ng-packagr.
|
||||
{ provide: Ed25519VerificationService, useFactory: () => new NoopEd25519VerificationService() },
|
||||
{ provide: MediaRepository, useClass: MockMediaRepository },
|
||||
// apiUrl: environment.qrApiUrl ('https://qr.vitanova.network/api') is the
|
||||
// same "central payment service" the legacy /qr and
|
||||
// /card/{partnerId}/{orderId} endpoints already used (api.service.ts) -
|
||||
// one service shared across every tenant, unlike the per-tenant
|
||||
// AUTH_API_URL above. Stripped the trailing /api here: the package's own
|
||||
// default paymentsPath is '/api/v1/payments', so passing qrApiUrl
|
||||
// unchanged would double it to .../api/api/v1/payments. Confirmed by
|
||||
// reading the package's baseUrl() directly (apiUrl + paymentsPath,
|
||||
// simple concatenation, no de-dup) - not yet confirmed against a live
|
||||
// backend, since qrApiUrl's own /api suffix was never meant for this
|
||||
// package. Revisit once a real payment request has actually been made.
|
||||
//
|
||||
// marketplaceDomain: a plain closure, not TenantResolverService.
|
||||
// provideMarketplacesPayment runs outside the injector (it returns
|
||||
// EnvironmentProviders, called before DI exists), so inject(DOCUMENT)
|
||||
// isn't available here. The package evaluates this function lazily
|
||||
// inside PaymentMarketplaceContext, which IS a real injection context -
|
||||
// this closure just can't be one itself. Mirrors
|
||||
// TenantResolverService.getHostname() intentionally; if that method's
|
||||
// logic changes, this needs to change with it.
|
||||
provideMarketplacesPayment({
|
||||
apiUrl: environment.qrApiUrl.replace(/\/api\/?$/, ''),
|
||||
marketplaceDomain: () => window.location.hostname.toLowerCase(),
|
||||
}),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000'
|
||||
})
|
||||
}),
|
||||
// Empty in production - the file is swapped at build time so no mock
|
||||
// gateway is even importable there. See mock-gateway.providers.ts.
|
||||
...MOCK_GATEWAY_PROVIDERS
|
||||
]
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<p>{{ 'app.serverError' | translate }}</p>
|
||||
<button class="retry-btn" (click)="retryConnection()">{{ 'app.retryConnection' | translate }}</button>
|
||||
</div>
|
||||
} @else if (isAdminHost && !isAdminRoute()) {
|
||||
<app-telegram-login mode="admin" />
|
||||
} @else if (isAdminRoute()) {
|
||||
<router-outlet></router-outlet>
|
||||
<app-telegram-login mode="admin" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { languageGuard } from './guards/language.guard';
|
||||
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
|
||||
import { adminAuthGuard, requireAdminPermission } from './core/admin-auth/admin-auth.guard';
|
||||
import { adminAuthGuard } from '@marketplaces/auth';
|
||||
import { requireAdminPermission } from './core/admin-auth/admin-auth.guard';
|
||||
import { authRoutes } from './core/auth/auth.routes';
|
||||
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
||||
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
|
||||
@@ -191,6 +192,60 @@ const coreRoutes: Routes = [
|
||||
breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
loadComponent: () => import('./features/admin/notifications/pages/admin-notifications-page.component').then(m => m.AdminNotificationsPageComponent),
|
||||
data: {
|
||||
titleKey: 'adminShell.nav.notifications',
|
||||
descriptionKey: 'adminShell.nav.notifications',
|
||||
breadcrumb: [{ labelKey: 'adminShell.nav.notifications' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'integrations',
|
||||
loadComponent: () => import('./features/admin/integrations/pages/admin-integrations-page.component').then(m => m.AdminIntegrationsPageComponent),
|
||||
data: {
|
||||
titleKey: 'adminShell.nav.integrations',
|
||||
descriptionKey: 'adminShell.nav.integrations',
|
||||
breadcrumb: [{ labelKey: 'adminShell.nav.integrations' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
loadComponent: () => import('./features/admin/finance/pages/admin-finance-page.component').then(m => m.AdminFinancePageComponent),
|
||||
data: {
|
||||
titleKey: 'adminShell.nav.finance',
|
||||
descriptionKey: 'adminShell.nav.finance',
|
||||
breadcrumb: [{ labelKey: 'adminShell.nav.finance' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'marketplaces',
|
||||
loadComponent: () => import('./features/admin/marketplaces/pages/admin-marketplaces-page.component').then(m => m.AdminMarketplacesPageComponent),
|
||||
data: {
|
||||
titleKey: 'adminShell.nav.marketplaces',
|
||||
descriptionKey: 'adminShell.nav.marketplaces',
|
||||
breadcrumb: [{ labelKey: 'adminShell.nav.marketplaces' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'partner-hierarchy',
|
||||
loadComponent: () => import('./features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component').then(m => m.AdminPartnerHierarchyPageComponent),
|
||||
data: {
|
||||
titleKey: 'adminShell.nav.partnerHierarchy',
|
||||
descriptionKey: 'adminShell.nav.partnerHierarchy',
|
||||
breadcrumb: [{ labelKey: 'adminShell.nav.partnerHierarchy' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
loadComponent: () => import('./features/admin/audit/pages/admin-audit-page.component').then(m => m.AdminAuditPageComponent),
|
||||
data: {
|
||||
titleKey: 'adminShell.nav.audit',
|
||||
descriptionKey: 'adminShell.nav.audit',
|
||||
breadcrumb: [{ labelKey: 'adminShell.nav.audit' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'moderation',
|
||||
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),
|
||||
|
||||
@@ -16,8 +16,7 @@ import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
|
||||
import { ApiHealthService } from './services/api-health.service';
|
||||
import { SeoService } from './services/seo.service';
|
||||
import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component';
|
||||
import { AdminAuthService } from './core/admin-auth/admin-auth.service';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { AdminAuthService, AuthService } from '@marketplaces/auth';
|
||||
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
|
||||
|
||||
@Component({
|
||||
@@ -29,6 +28,8 @@ import { TelegramLoginComponent } from './components/telegram-login/telegram-log
|
||||
})
|
||||
export class App implements OnInit {
|
||||
protected title = '';
|
||||
readonly isAdminHost = typeof window !== 'undefined'
|
||||
&& window.location.hostname.toLowerCase().startsWith('admin.');
|
||||
isHomePage = signal(true);
|
||||
isAdminRoute = signal(false);
|
||||
checkingServer = signal(true);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CartItem, DeliveryOption } from '../../models';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { CurrencyRatesService } from '../../services/currency-rates.service';
|
||||
|
||||
let nextDeliverySelectorId = 0;
|
||||
|
||||
@@ -15,6 +17,9 @@ let nextDeliverySelectorId = 0;
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DeliverySelectorComponent {
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly currencyRates = inject(CurrencyRatesService);
|
||||
|
||||
@Input({ required: true }) item: CartItem | null = null;
|
||||
|
||||
@Output() selectedDeliveryChange = new EventEmitter<DeliveryOption | null>();
|
||||
@@ -29,10 +34,20 @@ export class DeliverySelectorComponent {
|
||||
return this.item?.selectedDelivery ?? null;
|
||||
}
|
||||
|
||||
get currency(): string {
|
||||
/** Source currency the item's prices are stored in. */
|
||||
get sourceCurrency(): string {
|
||||
return this.item?.currency || 'RUB';
|
||||
}
|
||||
|
||||
/** Currency the shopper has selected for display. */
|
||||
get currency(): string {
|
||||
return this.languageService.currentCurrency();
|
||||
}
|
||||
|
||||
private convert(amount: number): number {
|
||||
return this.currencyRates.convert(amount, this.sourceCurrency, this.currency);
|
||||
}
|
||||
|
||||
get required(): boolean {
|
||||
return this.item?.deliveryMode !== 'digital'
|
||||
&& this.options.length > 0
|
||||
@@ -48,7 +63,8 @@ export class DeliverySelectorComponent {
|
||||
}
|
||||
|
||||
get selectedDeliveryTotal(): number {
|
||||
return (this.selectedDelivery?.deliveryPrice ?? 0) * (this.item?.quantity ?? 1);
|
||||
const base = (this.selectedDelivery?.deliveryPrice ?? 0) * (this.item?.quantity ?? 1);
|
||||
return this.convert(base);
|
||||
}
|
||||
|
||||
optionKey(option: DeliveryOption): string {
|
||||
@@ -57,7 +73,7 @@ export class DeliverySelectorComponent {
|
||||
|
||||
optionLabel(option: DeliveryOption): string {
|
||||
const details = [option.deliveryPlace, option.deliveryTime].filter(Boolean);
|
||||
details.push(`${option.deliveryPrice.toFixed(2)} ${this.currency}`);
|
||||
details.push(`${this.convert(option.deliveryPrice).toFixed(2)} ${this.currency}`);
|
||||
return details.join(' • ');
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { of } from 'rxjs';
|
||||
import { BootstrapConfig } from '../../shared/models/config';
|
||||
import { CONFIG_PROVIDER } from '../../core/config/config-provider.token';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { AuthService, AUTH_API_URL } from '@marketplaces/auth';
|
||||
import { HeaderComponent } from './header.component';
|
||||
|
||||
function makeBootstrap(): BootstrapConfig {
|
||||
@@ -52,6 +52,7 @@ describe('HeaderComponent profile control (login/logout gating regression)', ()
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
|
||||
{ provide: AUTH_API_URL, useValue: 'https://test.local' },
|
||||
{ provide: AuthService, useValue: fakeAuth },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { FeatureConfigService } from '../../core/config/feature-config.service';
|
||||
import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
|
||||
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
|
||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { AuthService } from '@marketplaces/auth';
|
||||
import { TelegramLoginComponent } from '../telegram-login/telegram-login.component';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -51,10 +51,10 @@
|
||||
|
||||
<div class="product-price">
|
||||
@if (item.discount > 0) {
|
||||
<span class="original-price">{{ item.price | number:'1.2-2' }} {{ item.currency }}</span>
|
||||
<span class="discounted-price">{{ getDiscountedPrice(item) | number:'1.2-2' }} {{ item.currency }}</span>
|
||||
<span class="original-price">{{ item.price | currencyConvert:item.currency | number:'1.2-2' }} {{ displayCurrency() }}</span>
|
||||
<span class="discounted-price">{{ getDiscountedPrice(item) | currencyConvert:item.currency | number:'1.2-2' }} {{ displayCurrency() }}</span>
|
||||
} @else {
|
||||
<span class="current-price">{{ item.price | number:'1.2-2' }} {{ item.currency }}</span>
|
||||
<span class="current-price">{{ item.price | currencyConvert:item.currency | number:'1.2-2' }} {{ displayCurrency() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Product } from '../../core/products/models/product-domain.model';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { CurrencyConvertPipe } from '../../pipes/currency-convert.pipe';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { cleanDescription, getBadgeClass, getDiscountedPrice, getMainImage, onImageError } from '../../utils/item.utils';
|
||||
|
||||
const STOCK_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -18,12 +20,15 @@ export type ProductCardAppearance = 'standard' | 'compact';
|
||||
@Component({
|
||||
selector: 'app-product-card',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe],
|
||||
imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe, CurrencyConvertPipe],
|
||||
templateUrl: './product-card.component.html',
|
||||
styleUrls: ['./product-card.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProductCardComponent {
|
||||
private readonly languageService = inject(LanguageService);
|
||||
readonly displayCurrency = this.languageService.currentCurrency;
|
||||
|
||||
@Input({ required: true }) item!: Product;
|
||||
@Input() title = '';
|
||||
@Input() description = '';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<button
|
||||
type="button"
|
||||
class="social-login-button"
|
||||
[class]="'social-login-button--' + provider()"
|
||||
[disabled]="loading()"
|
||||
(click)="startLogin()"
|
||||
>
|
||||
<app-icon name="user" [size]="18" />
|
||||
<span>{{ label() }}</span>
|
||||
</button>
|
||||
@@ -0,0 +1,19 @@
|
||||
.social-login-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-weight-bold, 700);
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { SOCIAL_IDENTITY_GATEWAY } from '../../core/identity/services/social-identity-gateway.token';
|
||||
import { SocialProvider } from '../../core/identity/services/social-identity-gateway.interface';
|
||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||
|
||||
const PROVIDER_LABEL: Record<SocialProvider, string> = {
|
||||
vk: 'Continue with VK ID',
|
||||
yandex: 'Continue with Yandex ID',
|
||||
};
|
||||
|
||||
/**
|
||||
* One button per social provider, per v3.1 §14 (VK ID is the primary
|
||||
* storefront social login; Yandex ID is the second instance of the same
|
||||
* flow, not a separate integration).
|
||||
*
|
||||
* Deliberately not spliced into TelegramLoginComponent's dialog yet. That
|
||||
* component is the live customer login surface, and adding providers to it
|
||||
* belongs in the pass that also demotes Telegram to one ExternalIdentity
|
||||
* among several (FH-4.6) - not before a real OAuth application exists to
|
||||
* test against.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-social-login-button',
|
||||
standalone: true,
|
||||
imports: [IconComponent],
|
||||
templateUrl: './social-login-button.component.html',
|
||||
styleUrls: ['./social-login-button.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SocialLoginButtonComponent {
|
||||
private readonly gateway = inject(SOCIAL_IDENTITY_GATEWAY);
|
||||
|
||||
readonly provider = input.required<SocialProvider>();
|
||||
/** Where to land after the callback. Validated backend-side. */
|
||||
readonly returnTo = input<string | undefined>(undefined);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly label = computed(() => PROVIDER_LABEL[this.provider()]);
|
||||
|
||||
startLogin(): void {
|
||||
this.loading.set(true);
|
||||
this.gateway
|
||||
.getAuthorizeUrl(this.provider(), this.returnTo())
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: url => {
|
||||
this.loading.set(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = url;
|
||||
}
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
<app-icon name="lock" [size]="40" />
|
||||
</div>
|
||||
|
||||
<h2>{{ 'auth.loginRequired' | translate }}</h2>
|
||||
<p class="login-desc">{{ 'auth.loginDescription' | translate }}</p>
|
||||
<h2>{{ (mode === 'admin' ? 'auth.adminLoginRequired' : 'auth.loginRequired') | translate }}</h2>
|
||||
<p class="login-desc">{{ (mode === 'admin' ? 'auth.adminLoginDescription' : 'auth.loginDescription') | translate }}</p>
|
||||
|
||||
@if (status() === 'checking') {
|
||||
<div class="login-status checking">
|
||||
@@ -22,7 +22,7 @@
|
||||
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
{{ 'auth.loginWithTelegram' | translate }}
|
||||
{{ (mode === 'admin' ? 'auth.adminLoginWithTelegram' : 'auth.loginWithTelegram') | translate }}
|
||||
</button>
|
||||
|
||||
<!-- @if (loginUrl()) {
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
|
||||
<p class="login-note">{{ (mode === 'admin' ? 'auth.adminLoginNote' : 'auth.loginNote') | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, Injector, Signal, inject, effect, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { AdminAuthService } from '../../core/admin-auth/admin-auth.service';
|
||||
import { AuthService, AdminAuthService, AuthSession } from '@marketplaces/auth';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
|
||||
import { QrLoginAdapter, QrLoginStatus } from '../../shared/qr-login/qr-login.model';
|
||||
import { AuthSession } from '../../models/auth.model';
|
||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
|
||||
/** Backend paths that require an active AdminWebSessionID per API-REFERENCE.md §0. */
|
||||
const ADMIN_GATED_PATH_SEGMENTS = ['/admin/', '/backoffice/', '/builder/', '/media/'];
|
||||
|
||||
/**
|
||||
* Attaches admin session/token headers only to admin API requests. Mirrors
|
||||
* apiHeadersInterceptor's self-guarding pattern but scoped to admin-gated
|
||||
* paths so it never touches customer requests and never reads AuthService's
|
||||
* session.
|
||||
*/
|
||||
export const adminAuthHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const isAdminRequest = ADMIN_GATED_PATH_SEGMENTS.some(segment => req.url.includes(segment));
|
||||
if (!isAdminRequest) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const adminAuth = inject(AdminAuthService);
|
||||
const session = adminAuth.session();
|
||||
const token = adminAuth.getAdminToken();
|
||||
|
||||
let headers = req.headers;
|
||||
if (session?.sessionId) {
|
||||
headers = headers.set('AdminWebSessionID', session.sessionId);
|
||||
}
|
||||
if (token) {
|
||||
headers = headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return next(req.clone({ headers }));
|
||||
};
|
||||
@@ -1,24 +1,14 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn } from '@angular/router';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
import { AdminPermissionsService } from './admin-permissions.service';
|
||||
|
||||
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
|
||||
export const adminAuthGuard: CanActivateFn = () => {
|
||||
const adminAuth = inject(AdminAuthService);
|
||||
|
||||
if (adminAuth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
adminAuth.requestLogin();
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* UI-only gate for a specific permission, on top of adminAuthGuard's
|
||||
* authentication check. See AdminPermissionsService for why this is
|
||||
* cosmetic until the backend ships real admin-role enforcement.
|
||||
* UI-only gate for a specific permission, on top of the package's
|
||||
* adminAuthGuard authentication check. See AdminPermissionsService for why
|
||||
* this is cosmetic until the backend ships real admin-role enforcement.
|
||||
* Kept app-local because it depends on AdminPermissionsService, which reads
|
||||
* this app's mock Users domain - not a portable auth concern.
|
||||
*/
|
||||
export function requireAdminPermission(permission: string): CanActivateFn {
|
||||
return () => {
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { AdminAuthStatus } from '../../models/admin-auth.model';
|
||||
import { AuthSession, WebSessionStart } from '../../models/auth.model';
|
||||
import { TelegramSessionApiService } from '../../services/telegram-session-api.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/**
|
||||
* Admin login uses the exact same Telegram QR/session API as the customer
|
||||
* login (TelegramSessionApiService, `{authApiUrl}/users/sessions`) - there is
|
||||
* no separate admin backend endpoint, and none should be invented client-side.
|
||||
* Only the *storage* is kept separate from AuthService, so an admin QR scan
|
||||
* never authenticates the customer session or vice versa: distinct cookie
|
||||
* name, distinct signals, distinct guard/interceptor.
|
||||
*
|
||||
* Backend gap this creates (see docs/backend/BACKEND-INTEGRATION.md §2.5): since the session
|
||||
* API itself has no concept of "admin", the frontend cannot tell an admin
|
||||
* Telegram session from a regular one. Actual admin authorization must be
|
||||
* enforced server-side when admin API calls are made with the resulting
|
||||
* session id - the frontend only decides where to *store* the result.
|
||||
*/
|
||||
const ADMIN_SESSION_COOKIE = 'adminSessionID';
|
||||
const ADMIN_TOKEN_STORAGE_KEY = 'adminToken';
|
||||
const ADMIN_REFRESH_STORAGE_KEY = 'adminRefreshToken';
|
||||
const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminAuthService {
|
||||
private readonly api = inject(TelegramSessionApiService);
|
||||
|
||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
|
||||
private readonly showLoginSignal = signal(false);
|
||||
|
||||
readonly session = this.sessionSignal.asReadonly();
|
||||
readonly status = this.statusSignal.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
||||
readonly showLoginDialog = this.showLoginSignal.asReadonly();
|
||||
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
||||
|
||||
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor() {
|
||||
this.checkSession();
|
||||
}
|
||||
|
||||
checkSession(): void {
|
||||
const webSessionID = this.getStoredAdminSessionID();
|
||||
if (!webSessionID) {
|
||||
this.clearAuthState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusSignal.set('checking');
|
||||
this.checkSessionOnce(webSessionID).subscribe(session => {
|
||||
if (!session?.active) {
|
||||
this.clearAuthState('unauthenticated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Check session without mutating internal state beyond activating on success (used for polling). */
|
||||
checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable<AuthSession | null> {
|
||||
return this.api.checkSessionOnce(webSessionID).pipe(
|
||||
tap(session => {
|
||||
if (session?.active) {
|
||||
this.activateSession(session);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */
|
||||
createWebSession(): Observable<WebSessionStart> {
|
||||
return this.api.createSession();
|
||||
}
|
||||
|
||||
getAdminAppLoginUrl(webSessionID: string): string {
|
||||
return this.api.getBotAppLoginUrl(webSessionID);
|
||||
}
|
||||
|
||||
onLoginComplete(): void {
|
||||
this.hideLogin();
|
||||
if (!this.isAuthenticated()) {
|
||||
this.checkSession();
|
||||
}
|
||||
}
|
||||
|
||||
requestLogin(): void {
|
||||
this.showLoginSignal.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only shortcut for local testing without a reachable Telegram/session
|
||||
* backend: fabricates a local session and activates it directly, skipping
|
||||
* the QR flow entirely. No-ops in production builds (checked at runtime,
|
||||
* not just build-time, so it is safe even if this code ships). Never call
|
||||
* this from anywhere reachable in a production build.
|
||||
*/
|
||||
devBypassLogin(): void {
|
||||
if (environment.production) {
|
||||
return;
|
||||
}
|
||||
this.hideLogin();
|
||||
this.activateSession({
|
||||
sessionId: `dev-bypass-${Date.now()}`,
|
||||
userId: 0,
|
||||
username: 'dev-admin',
|
||||
displayName: 'Dev Admin (local bypass)',
|
||||
active: true,
|
||||
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
hideLogin(): void {
|
||||
this.showLoginSignal.set(false);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
const webSessionID = this.sessionSignal()?.sessionId || this.getStoredAdminSessionID();
|
||||
if (!webSessionID) {
|
||||
this.clearAuthState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated'));
|
||||
}
|
||||
|
||||
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
|
||||
getAdminToken(): string | null {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
setAdminTokens(token: string, refreshToken: string): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token);
|
||||
localStorage.setItem(ADMIN_REFRESH_STORAGE_KEY, refreshToken);
|
||||
}
|
||||
|
||||
clearAdminTokens(): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
|
||||
localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY);
|
||||
}
|
||||
|
||||
private activateSession(session: AuthSession): void {
|
||||
this.sessionSignal.set(session);
|
||||
this.statusSignal.set('authenticated');
|
||||
this.setStoredAdminSessionID(session.sessionId);
|
||||
this.scheduleSessionRefresh(session.expires);
|
||||
}
|
||||
|
||||
private clearAuthState(status: AdminAuthStatus): void {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set(status);
|
||||
this.clearStoredAdminSessionID();
|
||||
this.clearAdminTokens();
|
||||
this.clearSessionRefresh();
|
||||
}
|
||||
|
||||
private scheduleSessionRefresh(expiresAt: string): void {
|
||||
this.clearSessionRefresh();
|
||||
const expiresMs = new Date(expiresAt).getTime();
|
||||
const nowMs = Date.now();
|
||||
const refreshIn = Number.isFinite(expiresMs)
|
||||
? Math.max(expiresMs - nowMs - 60_000, 30_000)
|
||||
: ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS * 1000;
|
||||
|
||||
this.sessionCheckTimer = setTimeout(() => this.checkSession(), refreshIn);
|
||||
}
|
||||
|
||||
private clearSessionRefresh(): void {
|
||||
if (this.sessionCheckTimer) {
|
||||
clearTimeout(this.sessionCheckTimer);
|
||||
this.sessionCheckTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getStoredAdminSessionID(): string | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const cookie = document.cookie.split('; ').find(row => row.startsWith(`${ADMIN_SESSION_COOKIE}=`));
|
||||
if (!cookie) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(cookie.substring(ADMIN_SESSION_COOKIE.length + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private setStoredAdminSessionID(webSessionID: string): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Strict${secure}`;
|
||||
}
|
||||
|
||||
private clearStoredAdminSessionID(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
import { AdminUsersLocalGateway } from '../../features/admin/users/services/admin-users-local.gateway';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Prep interfaces for a future Ed25519 challenge/response admin auth flow.
|
||||
* No crypto is implemented here - verification is delegated to an injectable
|
||||
* service so the real implementation (native WebCrypto Ed25519 support, or a
|
||||
* backend verification call) can be swapped in once the backend API exists,
|
||||
* without touching AdminAuthService or components.
|
||||
*/
|
||||
export interface Ed25519Challenge {
|
||||
nonce: string;
|
||||
timestamp: string;
|
||||
/** Opaque challenge payload the client must sign with its private key. */
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface Ed25519SignedResponse {
|
||||
challenge: Ed25519Challenge;
|
||||
publicKey: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface Ed25519VerificationResult {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export abstract class Ed25519VerificationService {
|
||||
abstract requestChallenge(): Observable<Ed25519Challenge>;
|
||||
abstract verify(response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { Ed25519Challenge, Ed25519SignedResponse, Ed25519VerificationResult, Ed25519VerificationService } from './ed25519-verification.model';
|
||||
|
||||
/**
|
||||
* Default DI binding for Ed25519VerificationService until the backend ships
|
||||
* the real challenge/verify endpoints. Intentionally fails closed (throws)
|
||||
* rather than pretending to verify anything, so accidental use in a login
|
||||
* path is loud instead of silently accepting unsigned sessions.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NoopEd25519VerificationService implements Ed25519VerificationService {
|
||||
requestChallenge(): Observable<Ed25519Challenge> {
|
||||
return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.'));
|
||||
}
|
||||
|
||||
verify(_response: Ed25519SignedResponse): Observable<Ed25519VerificationResult> {
|
||||
return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.'));
|
||||
}
|
||||
}
|
||||
11
src/app/core/analytics/models/analytics-event.model.ts
Normal file
11
src/app/core/analytics/models/analytics-event.model.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** Per docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1-4. */
|
||||
export type AnalyticsEventType =
|
||||
| 'session_started' | 'page_view' | 'search' | 'category_view' | 'product_view' | 'seller_view'
|
||||
| 'add_to_cart' | 'cart_view' | 'checkout_started'
|
||||
| 'payment_started' | 'payment_success' | 'payment_failed' | 'order_created';
|
||||
|
||||
export interface AnalyticsEvent {
|
||||
eventType: AnalyticsEventType;
|
||||
properties: Record<string, unknown>;
|
||||
isSynthetic: boolean;
|
||||
}
|
||||
18
src/app/core/analytics/services/analytics-api.gateway.ts
Normal file
18
src/app/core/analytics/services/analytics-api.gateway.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { AnalyticsEvent } from '../models/analytics-event.model';
|
||||
import { AnalyticsGateway } from './analytics-gateway.interface';
|
||||
|
||||
/** Contract: docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1 - server-side batched ingest. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AnalyticsApiGateway implements AnalyticsGateway {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
track(event: AnalyticsEvent): Observable<void> {
|
||||
return this.http
|
||||
.post('/api/v2/storefront/analytics/events', event)
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { AnalyticsEvent } from '../models/analytics-event.model';
|
||||
|
||||
/** Per docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
|
||||
export interface AnalyticsGateway {
|
||||
track(event: AnalyticsEvent): Observable<void>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { AnalyticsGateway } from './analytics-gateway.interface';
|
||||
import { AnalyticsApiGateway } from './analytics-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
|
||||
export const ANALYTICS_GATEWAY = new InjectionToken<AnalyticsGateway>('ANALYTICS_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => inject(AnalyticsApiGateway),
|
||||
});
|
||||
17
src/app/core/analytics/services/analytics-local.gateway.ts
Normal file
17
src/app/core/analytics/services/analytics-local.gateway.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { AnalyticsEvent } from '../models/analytics-event.model';
|
||||
import { AnalyticsGateway } from './analytics-gateway.interface';
|
||||
|
||||
/**
|
||||
* No tracking pipeline exists at all today (confirmed - this is missing
|
||||
* infrastructure, not a missing endpoint, per GAPS-AND-IMPROVEMENTS.md and
|
||||
* docs/backend/TRACK-A-ANALYTICS-CONTRACT.md). This mock only proves the
|
||||
* call-site wiring is correct; it does not persist anything.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AnalyticsLocalGateway implements AnalyticsGateway {
|
||||
track(_event: AnalyticsEvent): Observable<void> {
|
||||
return of(void 0);
|
||||
}
|
||||
}
|
||||
25
src/app/core/analytics/services/analytics.service.ts
Normal file
25
src/app/core/analytics/services/analytics.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AnalyticsEventType } from '../models/analytics-event.model';
|
||||
import { ANALYTICS_GATEWAY } from './analytics-gateway.token';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
/**
|
||||
* Thin call-site wrapper so storefront components fire events without
|
||||
* knowing about the gateway/token plumbing. isSynthetic is derived from the
|
||||
* build environment, never client-settable at the call site (per
|
||||
* docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §6 - synthetic traffic must be
|
||||
* inseparable-by-accident from production data).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AnalyticsService {
|
||||
private readonly gateway = inject(ANALYTICS_GATEWAY);
|
||||
|
||||
track(eventType: AnalyticsEventType, properties: Record<string, unknown> = {}): void {
|
||||
this.gateway.track({
|
||||
eventType,
|
||||
properties,
|
||||
isSynthetic: !environment.production,
|
||||
}).pipe(take(1)).subscribe();
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { AdminRole } from './permission.model';
|
||||
|
||||
/**
|
||||
* Wire contracts for the Ed25519 challenge/response admin auth flow. These
|
||||
* are documented in docs/AUTH.md and match the endpoints listed there
|
||||
* exactly - none of this is invented beyond what's documented as FUTURE
|
||||
* there and in docs/backend/BACKEND-INTEGRATION.md §2.5.
|
||||
*/
|
||||
export interface AuthChallenge {
|
||||
nonce: string;
|
||||
/** ISO 8601 issue time of the challenge. */
|
||||
issuedAt: string;
|
||||
/** ISO 8601 - challenge must be used before this or the backend rejects it. */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface VerifySignatureRequest {
|
||||
publicKey: string;
|
||||
signature: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
export interface AuthTokenPair {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims expected in the JWT `token`. Decoded client-side for display/UX
|
||||
* only (role-gating UI, expiry countdown) - the frontend never treats this
|
||||
* as proof of authorization; every admin request is still re-checked
|
||||
* server-side per docs/AUTH.md security considerations.
|
||||
*/
|
||||
export interface JwtClaims {
|
||||
sub: string;
|
||||
role: AdminRole;
|
||||
/** Issued-at, seconds since epoch (standard `iat` claim). */
|
||||
iat: number;
|
||||
/** Expiry, seconds since epoch (standard `exp` claim). */
|
||||
exp: number;
|
||||
publicKey: string;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Error codes the Ed25519 admin auth flow can surface to the UI. Each maps to
|
||||
* a dedicated screen (see `core/auth/pages`) rather than a generic toast,
|
||||
* because the recovery action differs per code (re-login vs. retry vs. wait).
|
||||
*/
|
||||
export type AuthErrorCode =
|
||||
| 'session-expired'
|
||||
| 'invalid-signature'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'backend-unavailable';
|
||||
|
||||
export interface AuthError {
|
||||
code: AuthErrorCode;
|
||||
message: string;
|
||||
/** HTTP status that produced this error, when known (absent for client-side errors, e.g. no Ed25519 support). */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
|
||||
export function authErrorCodeFromStatus(status: number): AuthErrorCode {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return 'unauthorized';
|
||||
case 403:
|
||||
return 'forbidden';
|
||||
case 0:
|
||||
return 'backend-unavailable';
|
||||
default:
|
||||
return status >= 500 ? 'backend-unavailable' : 'unauthorized';
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Roles the Ed25519 JWT `role` claim is expected to carry (see docs/AUTH.md
|
||||
* §JWT Claims). Ordered highest-to-lowest privilege; PermissionService does
|
||||
* not rely on the order, it is documentation only.
|
||||
*/
|
||||
export type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly';
|
||||
|
||||
/**
|
||||
* Coarse-grained permission keys. Intentionally small and domain-agnostic
|
||||
* (mirrors the existing bootstrap-level `PermissionsConfig` shape in
|
||||
* `shared/models/config/permissions.model.ts`) - fine-grained, per-domain
|
||||
* permissions stay server-side until the backend ships a real permission
|
||||
* model; the frontend only needs enough to hide/disable UI, never to be the
|
||||
* source of truth for authorization.
|
||||
*/
|
||||
export type Permission =
|
||||
| 'backoffice.read'
|
||||
| 'backoffice.write'
|
||||
| 'builder.read'
|
||||
| 'builder.write'
|
||||
| 'users.manage'
|
||||
| 'settings.manage';
|
||||
|
||||
export const ROLE_PERMISSIONS: Readonly<Record<AdminRole, readonly Permission[]>> = {
|
||||
Owner: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write', 'users.manage', 'settings.manage'],
|
||||
Administrator: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write', 'users.manage'],
|
||||
Editor: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write'],
|
||||
Support: ['backoffice.read'],
|
||||
ReadOnly: ['backoffice.read', 'builder.read']
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { ButtonComponent } from '../../../shared/ui/button/button.component';
|
||||
import { AuthFacade } from '../services/auth-facade.service';
|
||||
import { Ed25519KeypairService } from '../services/ed25519-keypair.service';
|
||||
import { AuthFacade, Ed25519KeypairService } from '@marketplaces/auth';
|
||||
|
||||
/**
|
||||
* Ed25519 admin login page. Prepared UI for the flow described in
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { map } from 'rxjs';
|
||||
import { ButtonComponent } from '../../../shared/ui/button/button.component';
|
||||
import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
|
||||
import { AuthErrorCode } from '../models/auth-error.model';
|
||||
import { AuthErrorCode } from '@marketplaces/auth';
|
||||
|
||||
interface AuthErrorCopy {
|
||||
title: string;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { AuthChallenge, AuthTokenPair, RefreshTokenRequest, VerifySignatureRequest } from '../models/auth-api.model';
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the Ed25519 admin auth endpoints documented in
|
||||
* docs/AUTH.md. These endpoints do not exist on the backend yet (FUTURE -
|
||||
* see docs/backend/BACKEND-INTEGRATION.md §2.5) - calling them today 404s
|
||||
* or connection-errors, which AuthService maps to the
|
||||
* `backend-unavailable` error screen. No mock/fake responses are fabricated
|
||||
* here; this is real HttpClient wiring against the real contract, ready for
|
||||
* the moment the backend ships.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.authApiUrl}/api/admin/auth`;
|
||||
|
||||
requestChallenge(): Observable<AuthChallenge> {
|
||||
return this.http.get<AuthChallenge>(`${this.baseUrl}/challenge`);
|
||||
}
|
||||
|
||||
verifySignature(request: VerifySignatureRequest): Observable<AuthTokenPair> {
|
||||
return this.http.post<AuthTokenPair>(`${this.baseUrl}/verify`, request);
|
||||
}
|
||||
|
||||
refresh(request: RefreshTokenRequest): Observable<AuthTokenPair> {
|
||||
return this.http.post<AuthTokenPair>(`${this.baseUrl}/refresh`, request);
|
||||
}
|
||||
|
||||
logout(refreshToken: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/logout`, { refreshToken } satisfies RefreshTokenRequest);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { finalize } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { Permission } from '../models/permission.model';
|
||||
|
||||
/**
|
||||
* Public surface for components/pages. Components should depend on this,
|
||||
* not on AuthService/SessionService/PermissionService directly, so the
|
||||
* orchestration details (which service owns what) can change without
|
||||
* touching UI code.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthFacade {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly session = inject(SessionService);
|
||||
private readonly permissions = inject(PermissionService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly isAuthenticated = this.session.isAuthenticated;
|
||||
readonly status = this.session.status;
|
||||
readonly role = this.session.role;
|
||||
readonly loginPhase = this.auth.loginPhase;
|
||||
readonly lastError = this.auth.lastError;
|
||||
|
||||
restoreSession(): void {
|
||||
this.auth.restoreSession();
|
||||
}
|
||||
|
||||
login(onSuccessRedirectTo?: string): void {
|
||||
this.auth.login().subscribe({
|
||||
next: () => {
|
||||
if (onSuccessRedirectTo) {
|
||||
this.router.navigateByUrl(onSuccessRedirectTo);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
const code = this.auth.lastError()?.code ?? 'unauthorized';
|
||||
this.router.navigate(['/admin-login/error', code]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logout(redirectTo = '/admin-login'): void {
|
||||
this.auth
|
||||
.logout()
|
||||
.pipe(finalize(() => this.router.navigateByUrl(redirectTo)))
|
||||
.subscribe({ error: () => undefined });
|
||||
}
|
||||
|
||||
can(permission: Permission): boolean {
|
||||
return this.permissions.has(permission);
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { catchError, switchMap, tap, throwError } from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthTokenPair } from '../models/auth-api.model';
|
||||
import { AuthError, authErrorCodeFromStatus } from '../models/auth-error.model';
|
||||
import { AuthApiService } from './auth-api.service';
|
||||
import { Ed25519KeypairService } from './ed25519-keypair.service';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
export type LoginPhase = 'idle' | 'requesting-challenge' | 'signing' | 'verifying' | 'done';
|
||||
|
||||
/**
|
||||
* Orchestrates the Ed25519 challenge/response admin auth flow end to end:
|
||||
*
|
||||
* GET /api/admin/auth/challenge -> { nonce }
|
||||
* sign(nonce) with local Ed25519 key -> signature
|
||||
* POST /api/admin/auth/verify -> { token, refreshToken }
|
||||
*
|
||||
* This is the lowest-level orchestrator; components should go through
|
||||
* AuthFacade rather than calling this directly.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly api = inject(AuthApiService);
|
||||
private readonly keypair = inject(Ed25519KeypairService);
|
||||
private readonly session = inject(SessionService);
|
||||
|
||||
private readonly loginPhaseSignal = signal<LoginPhase>('idle');
|
||||
private readonly lastErrorSignal = signal<AuthError | null>(null);
|
||||
|
||||
readonly loginPhase = this.loginPhaseSignal.asReadonly();
|
||||
readonly lastError = this.lastErrorSignal.asReadonly();
|
||||
|
||||
constructor() {
|
||||
this.session.onRefreshDue(() => this.refresh().subscribe());
|
||||
}
|
||||
|
||||
/** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */
|
||||
restoreSession(): void {
|
||||
this.session.restore();
|
||||
}
|
||||
|
||||
login(): Observable<AuthTokenPair> {
|
||||
this.lastErrorSignal.set(null);
|
||||
this.loginPhaseSignal.set('requesting-challenge');
|
||||
|
||||
return this.api.requestChallenge().pipe(
|
||||
switchMap(challenge =>
|
||||
this.signChallenge(challenge.nonce).pipe(
|
||||
switchMap(({ publicKeyBase64, signature }) => {
|
||||
this.loginPhaseSignal.set('verifying');
|
||||
return this.api.verifySignature({ publicKey: publicKeyBase64, signature, nonce: challenge.nonce });
|
||||
})
|
||||
)
|
||||
),
|
||||
tap(tokens => {
|
||||
this.session.activate(tokens);
|
||||
this.loginPhaseSignal.set('done');
|
||||
}),
|
||||
catchError(error => this.handleAuthError<AuthTokenPair>(error, 'invalid-signature'))
|
||||
);
|
||||
}
|
||||
|
||||
refresh(): Observable<AuthTokenPair> {
|
||||
const refreshToken = this.session.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
this.session.markExpired();
|
||||
return throwError(() => this.toAuthError({ code: 'session-expired', message: 'No refresh token available.' }));
|
||||
}
|
||||
|
||||
return this.api.refresh({ refreshToken }).pipe(
|
||||
tap(tokens => this.session.activate(tokens)),
|
||||
catchError(error => this.handleAuthError<AuthTokenPair>(error, 'session-expired', () => this.session.markExpired()))
|
||||
);
|
||||
}
|
||||
|
||||
logout(): Observable<void> {
|
||||
const refreshToken = this.session.getRefreshToken();
|
||||
this.session.clear();
|
||||
if (!refreshToken) {
|
||||
return new Observable<void>(subscriber => {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
});
|
||||
}
|
||||
return this.api.logout(refreshToken).pipe(catchError(() => throwError(() => null)));
|
||||
}
|
||||
|
||||
private signChallenge(nonce: string): Observable<{ publicKeyBase64: string; signature: string }> {
|
||||
this.loginPhaseSignal.set('signing');
|
||||
return new Observable<{ publicKeyBase64: string; signature: string }>(subscriber => {
|
||||
this.keypair
|
||||
.getOrCreateKeyPair()
|
||||
.then(({ publicKeyBase64 }) =>
|
||||
this.keypair.sign(nonce).then(signature => {
|
||||
subscriber.next({ publicKeyBase64, signature });
|
||||
subscriber.complete();
|
||||
})
|
||||
)
|
||||
.catch(error => subscriber.error(error));
|
||||
});
|
||||
}
|
||||
|
||||
private handleAuthError<T>(error: unknown, fallbackCode: AuthError['code'], onError?: () => void): Observable<T> {
|
||||
onError?.();
|
||||
return throwError(() => this.toAuthError(this.toAuthErrorShape(error, fallbackCode)));
|
||||
}
|
||||
|
||||
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
return { code: authErrorCodeFromStatus(error.status), message: error.message, status: error.status };
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return { code: fallbackCode, message: error.message };
|
||||
}
|
||||
return { code: fallbackCode, message: 'Unknown authentication error.' };
|
||||
}
|
||||
|
||||
private toAuthError(error: AuthError): AuthError {
|
||||
this.lastErrorSignal.set(error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Manages the browser-local Ed25519 keypair used to sign admin auth
|
||||
* challenges. Real WebCrypto Ed25519 (RFC 8032 support landed in evergreen
|
||||
* browsers) - not a placeholder. The private key is generated
|
||||
* non-extractable and kept only in IndexedDB as a CryptoKey handle; it is
|
||||
* never serialized, never sent anywhere, and cannot be exported by design.
|
||||
*
|
||||
* Registering `publicKey` with an admin's account (associating it with a
|
||||
* role) is a backend-side, out-of-band operation (e.g. an Owner approving a
|
||||
* new admin's public key) - entirely outside this frontend's scope.
|
||||
*/
|
||||
const DB_NAME = 'admin-auth-ed25519';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'keypair';
|
||||
const KEY_RECORD_ID = 'device-keypair';
|
||||
|
||||
interface StoredKeyPair {
|
||||
id: string;
|
||||
publicKey: CryptoKey;
|
||||
privateKey: CryptoKey;
|
||||
publicKeyBase64: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Ed25519KeypairService {
|
||||
private cached: StoredKeyPair | null = null;
|
||||
|
||||
isSupported(): boolean {
|
||||
return typeof crypto !== 'undefined' && !!crypto.subtle && typeof indexedDB !== 'undefined';
|
||||
}
|
||||
|
||||
/** Returns the device's Ed25519 keypair, generating and persisting one on first use. */
|
||||
async getOrCreateKeyPair(): Promise<{ publicKeyBase64: string }> {
|
||||
if (!this.isSupported()) {
|
||||
throw new Error('Ed25519 is not supported in this browser (requires WebCrypto + IndexedDB).');
|
||||
}
|
||||
const existing = await this.loadFromStore();
|
||||
if (existing) {
|
||||
this.cached = existing;
|
||||
return { publicKeyBase64: existing.publicKeyBase64 };
|
||||
}
|
||||
|
||||
const generated = await this.generateAndPersist();
|
||||
this.cached = generated;
|
||||
return { publicKeyBase64: generated.publicKeyBase64 };
|
||||
}
|
||||
|
||||
async sign(message: string): Promise<string> {
|
||||
const keyPair = this.cached ?? (await this.loadFromStore());
|
||||
if (!keyPair) {
|
||||
throw new Error('No Ed25519 keypair available - call getOrCreateKeyPair() first.');
|
||||
}
|
||||
|
||||
const signatureBuffer = await crypto.subtle.sign('Ed25519', keyPair.privateKey, new TextEncoder().encode(message));
|
||||
return this.toBase64(new Uint8Array(signatureBuffer));
|
||||
}
|
||||
|
||||
/** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */
|
||||
async clear(): Promise<void> {
|
||||
this.cached = null;
|
||||
const db = await this.openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).delete(KEY_RECORD_ID);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
private async generateAndPersist(): Promise<StoredKeyPair> {
|
||||
const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])) as CryptoKeyPair;
|
||||
const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
const publicKeyBase64 = this.toBase64(new Uint8Array(publicKeyRaw));
|
||||
|
||||
const record: StoredKeyPair = {
|
||||
id: KEY_RECORD_ID,
|
||||
publicKey: keyPair.publicKey,
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKeyBase64
|
||||
};
|
||||
|
||||
const db = await this.openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).put(record);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private async loadFromStore(): Promise<StoredKeyPair | null> {
|
||||
const db = await this.openDatabase();
|
||||
return new Promise<StoredKeyPair | null>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const request = tx.objectStore(STORE_NAME).get(KEY_RECORD_ID);
|
||||
request.onsuccess = () => resolve((request.result as StoredKeyPair | undefined) ?? null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
private openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
||||
request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
private toBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { JwtClaims } from '../models/auth-api.model';
|
||||
|
||||
/**
|
||||
* Client-side JWT *decoding* only - never verification. The signature is
|
||||
* meaningless to check here because the frontend has no trusted key to check
|
||||
* it against; verifying a JWT's signature is the backend's job on every
|
||||
* request. This service exists purely so the UI can read `role`/`exp` for
|
||||
* display and route-gating UX (e.g. "session expires in 4m").
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class JwtService {
|
||||
decode(token: string): JwtClaims | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.base64UrlDecode(parts[1]);
|
||||
const claims = JSON.parse(payload) as JwtClaims;
|
||||
return this.isJwtClaims(claims) ? claims : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
isExpired(claims: JwtClaims, skewSeconds = 0): boolean {
|
||||
return claims.exp * 1000 <= Date.now() + skewSeconds * 1000;
|
||||
}
|
||||
|
||||
private isJwtClaims(value: unknown): value is JwtClaims {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const claims = value as Partial<JwtClaims>;
|
||||
return typeof claims.sub === 'string' && typeof claims.role === 'string' && typeof claims.exp === 'number';
|
||||
}
|
||||
|
||||
private base64UrlDecode(input: string): string {
|
||||
const base64 = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(input.length + ((4 - (input.length % 4)) % 4), '=');
|
||||
return decodeURIComponent(escape(atob(base64)));
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import { Permission, ROLE_PERMISSIONS } from '../models/permission.model';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
/**
|
||||
* Derives the current admin's permission set from their JWT `role` claim.
|
||||
* UI-only gate (hide/disable) - the backend must independently enforce
|
||||
* every mutation server-side; see docs/AUTH.md security considerations.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PermissionService {
|
||||
private readonly session = inject(SessionService);
|
||||
|
||||
readonly permissions = computed<readonly Permission[]>(() => {
|
||||
const role = this.session.role();
|
||||
return role ? ROLE_PERMISSIONS[role] : [];
|
||||
});
|
||||
|
||||
has(permission: Permission): boolean {
|
||||
return this.permissions().includes(permission);
|
||||
}
|
||||
|
||||
hasAny(permissions: readonly Permission[]): boolean {
|
||||
return permissions.some(permission => this.has(permission));
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import { AuthTokenPair, JwtClaims } from '../models/auth-api.model';
|
||||
import { JwtService } from './jwt.service';
|
||||
|
||||
export type SessionStatus = 'unknown' | 'restoring' | 'authenticated' | 'unauthenticated' | 'expired';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'ed25519AdminToken';
|
||||
const REFRESH_STORAGE_KEY = 'ed25519AdminRefreshToken';
|
||||
/** Refresh this long before actual expiry, so a request never races an expiring token. */
|
||||
const REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Holds the Ed25519-flow JWT/refresh-token pair and derived claims. Separate
|
||||
* from AdminAuthService (Telegram-session state) by design - the two auth
|
||||
* mechanisms are not merged until the backend actually ships the Ed25519
|
||||
* endpoints and a migration decision is made (see docs/AUTH.md).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionService {
|
||||
private readonly jwt = new JwtService();
|
||||
|
||||
private readonly tokenSignal = signal<string | null>(null);
|
||||
private readonly refreshTokenSignal = signal<string | null>(null);
|
||||
private readonly claimsSignal = signal<JwtClaims | null>(null);
|
||||
private readonly statusSignal = signal<SessionStatus>('unknown');
|
||||
|
||||
readonly token = this.tokenSignal.asReadonly();
|
||||
readonly claims = this.claimsSignal.asReadonly();
|
||||
readonly status = this.statusSignal.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
||||
readonly role = computed(() => this.claimsSignal()?.role ?? null);
|
||||
|
||||
private refreshTimer?: ReturnType<typeof setTimeout>;
|
||||
private refreshCallback?: () => void;
|
||||
|
||||
/** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */
|
||||
onRefreshDue(callback: () => void): void {
|
||||
this.refreshCallback = callback;
|
||||
}
|
||||
|
||||
/** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */
|
||||
restore(): boolean {
|
||||
this.statusSignal.set('restoring');
|
||||
const token = this.readStorage(TOKEN_STORAGE_KEY);
|
||||
const refreshToken = this.readStorage(REFRESH_STORAGE_KEY);
|
||||
if (!token || !refreshToken) {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
return false;
|
||||
}
|
||||
|
||||
const claims = this.jwt.decode(token);
|
||||
if (!claims) {
|
||||
this.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.tokenSignal.set(token);
|
||||
this.refreshTokenSignal.set(refreshToken);
|
||||
this.claimsSignal.set(claims);
|
||||
|
||||
if (this.jwt.isExpired(claims)) {
|
||||
this.statusSignal.set('expired');
|
||||
} else {
|
||||
this.statusSignal.set('authenticated');
|
||||
this.scheduleRefresh(claims);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
activate(tokens: AuthTokenPair): void {
|
||||
const claims = this.jwt.decode(tokens.token);
|
||||
if (!claims) {
|
||||
throw new Error('Received a malformed JWT from the auth backend.');
|
||||
}
|
||||
|
||||
this.tokenSignal.set(tokens.token);
|
||||
this.refreshTokenSignal.set(tokens.refreshToken);
|
||||
this.claimsSignal.set(claims);
|
||||
this.statusSignal.set('authenticated');
|
||||
this.writeStorage(TOKEN_STORAGE_KEY, tokens.token);
|
||||
this.writeStorage(REFRESH_STORAGE_KEY, tokens.refreshToken);
|
||||
this.scheduleRefresh(claims);
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return this.refreshTokenSignal();
|
||||
}
|
||||
|
||||
markExpired(): void {
|
||||
this.statusSignal.set('expired');
|
||||
this.clearRefreshTimer();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tokenSignal.set(null);
|
||||
this.refreshTokenSignal.set(null);
|
||||
this.claimsSignal.set(null);
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.removeStorage(TOKEN_STORAGE_KEY);
|
||||
this.removeStorage(REFRESH_STORAGE_KEY);
|
||||
this.clearRefreshTimer();
|
||||
}
|
||||
|
||||
private scheduleRefresh(claims: JwtClaims): void {
|
||||
this.clearRefreshTimer();
|
||||
const expiresInMs = claims.exp * 1000 - Date.now();
|
||||
const refreshInMs = Math.max(expiresInMs - REFRESH_SKEW_MS, 5_000);
|
||||
this.refreshTimer = setTimeout(() => this.refreshCallback?.(), refreshInMs);
|
||||
}
|
||||
|
||||
private clearRefreshTimer(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private readStorage(key: string): string | null {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
|
||||
}
|
||||
|
||||
private writeStorage(key: string, value: string): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private removeStorage(key: string): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ApiConfigService } from '../../config/api-config.service';
|
||||
import { ApiBootstrapProvider } from './api-bootstrap.provider';
|
||||
|
||||
describe('ApiBootstrapProvider', () => {
|
||||
let provider: ApiBootstrapProvider;
|
||||
let httpTesting: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ApiBootstrapProvider,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: ApiConfigService,
|
||||
useValue: { getBaseUrl: () => 'https://api.gorbushka.market' }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
provider = TestBed.inject(ApiBootstrapProvider);
|
||||
httpTesting = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpTesting.verify());
|
||||
|
||||
it('loads bootstrap from the same tenant API base as every other request', () => {
|
||||
provider.loadBootstrap().subscribe();
|
||||
|
||||
const request = httpTesting.expectOne('https://api.gorbushka.market/bootstrap');
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush({});
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,16 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
import { ConfigProvider } from '../../config/config-provider.interface';
|
||||
import { ApiConfigService } from '../../config/api-config.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiBootstrapProvider implements ConfigProvider {
|
||||
private readonly bootstrapUrl = '/bootstrap';
|
||||
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly apiConfig: ApiConfigService
|
||||
) {}
|
||||
|
||||
loadBootstrap(): Observable<BootstrapConfig> {
|
||||
return this.http.get<BootstrapConfig>(this.bootstrapUrl);
|
||||
return this.http.get<BootstrapConfig>(`${this.apiConfig.getBaseUrl()}/bootstrap`);
|
||||
}
|
||||
}
|
||||
|
||||
35
src/app/core/cart/models/server-cart.model.ts
Normal file
35
src/app/core/cart/models/server-cart.model.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/** Per docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §2. */
|
||||
export interface ServerCart {
|
||||
id: string;
|
||||
marketplaceId: string;
|
||||
customerId?: string;
|
||||
sessionToken?: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ServerCartLine {
|
||||
id: string;
|
||||
cartId: string;
|
||||
offerId: string;
|
||||
qty: number;
|
||||
addedAt: string;
|
||||
priceChanged?: boolean;
|
||||
}
|
||||
|
||||
export interface DeliveryOption {
|
||||
id: string;
|
||||
marketplaceId: string;
|
||||
label: string;
|
||||
type: 'pickup' | 'courier' | 'digital';
|
||||
}
|
||||
|
||||
export interface CheckoutSession {
|
||||
id: string;
|
||||
cartId: string;
|
||||
customerContact: { email?: string; phone?: string; verified: boolean };
|
||||
deliveryOptionId: string;
|
||||
status: 'open' | 'confirmed' | 'expired';
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
36
src/app/core/cart/services/server-cart-api.gateway.ts
Normal file
36
src/app/core/cart/services/server-cart-api.gateway.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
|
||||
import { ServerCartGateway } from './server-cart-gateway.interface';
|
||||
|
||||
/** Contract: docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerCartApiGateway implements ServerCartGateway {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }> {
|
||||
return this.http.get<{ cart: ServerCart; lines: ServerCartLine[] }>('/api/v2/storefront/cart');
|
||||
}
|
||||
|
||||
addLine(offerId: string, qty: number): Observable<ServerCartLine> {
|
||||
return this.http.post<ServerCartLine>('/api/v2/storefront/cart/lines', { offerId, qty });
|
||||
}
|
||||
|
||||
updateLine(lineId: string, qty: number): Observable<ServerCartLine> {
|
||||
return this.http.patch<ServerCartLine>(`/api/v2/storefront/cart/lines/${encodeURIComponent(lineId)}`, { qty });
|
||||
}
|
||||
|
||||
removeLine(lineId: string): Observable<void> {
|
||||
return this.http
|
||||
.delete(`/api/v2/storefront/cart/lines/${encodeURIComponent(lineId)}`)
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
startCheckout(deliveryOptionId: string, currency: string): Observable<CheckoutSession> {
|
||||
// §5 example body is { cartId, currency, deliveryOptionId } - cartId is
|
||||
// implicit server-side (the session's own cart), so it is not sent here.
|
||||
return this.http.post<CheckoutSession>('/api/v2/storefront/checkout', { currency, deliveryOptionId });
|
||||
}
|
||||
}
|
||||
11
src/app/core/cart/services/server-cart-gateway.interface.ts
Normal file
11
src/app/core/cart/services/server-cart-gateway.interface.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
|
||||
|
||||
/** Per docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
|
||||
export interface ServerCartGateway {
|
||||
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }>;
|
||||
addLine(offerId: string, qty: number): Observable<ServerCartLine>;
|
||||
updateLine(lineId: string, qty: number): Observable<ServerCartLine>;
|
||||
removeLine(lineId: string): Observable<void>;
|
||||
startCheckout(deliveryOptionId: string, currency: string): Observable<CheckoutSession>;
|
||||
}
|
||||
9
src/app/core/cart/services/server-cart-gateway.token.ts
Normal file
9
src/app/core/cart/services/server-cart-gateway.token.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { ServerCartGateway } from './server-cart-gateway.interface';
|
||||
import { ServerCartApiGateway } from './server-cart-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
|
||||
export const SERVER_CART_GATEWAY = new InjectionToken<ServerCartGateway>('SERVER_CART_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => inject(ServerCartApiGateway),
|
||||
});
|
||||
66
src/app/core/cart/services/server-cart-local.gateway.ts
Normal file
66
src/app/core/cart/services/server-cart-local.gateway.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
|
||||
import { ServerCartGateway } from './server-cart-gateway.interface';
|
||||
|
||||
const CART_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* In-memory stand-in for the server cart. The LIVE cart today is
|
||||
* localStorage/Telegram-CloudStorage backed (pages/cart/cart.component.ts,
|
||||
* services/cart.service.ts) and deliberately untouched by this module - see
|
||||
* docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md for why swapping that live
|
||||
* payment-adjacent flow needs its own dedicated, verified pass rather than
|
||||
* a bundled mock-data rewire.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerCartLocalGateway implements ServerCartGateway {
|
||||
private cart: ServerCart = {
|
||||
id: 'cart_local',
|
||||
marketplaceId: 'default',
|
||||
sessionToken: 'local-session',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + CART_TTL_MS).toISOString(),
|
||||
};
|
||||
private lines: ServerCartLine[] = [];
|
||||
|
||||
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }> {
|
||||
return of({ cart: this.cart, lines: this.lines });
|
||||
}
|
||||
|
||||
addLine(offerId: string, qty: number): Observable<ServerCartLine> {
|
||||
const existing = this.lines.find(l => l.offerId === offerId);
|
||||
if (existing) {
|
||||
existing.qty += qty;
|
||||
return of(existing);
|
||||
}
|
||||
const line: ServerCartLine = { id: `line_${Date.now()}`, cartId: this.cart.id, offerId, qty, addedAt: new Date().toISOString() };
|
||||
this.lines.push(line);
|
||||
return of(line);
|
||||
}
|
||||
|
||||
updateLine(lineId: string, qty: number): Observable<ServerCartLine> {
|
||||
const line = this.lines.find(l => l.id === lineId);
|
||||
if (line) {
|
||||
line.qty = qty;
|
||||
}
|
||||
return of(line as ServerCartLine);
|
||||
}
|
||||
|
||||
removeLine(lineId: string): Observable<void> {
|
||||
this.lines = this.lines.filter(l => l.id !== lineId);
|
||||
return of(void 0);
|
||||
}
|
||||
|
||||
startCheckout(deliveryOptionId: string, _currency: string): Observable<CheckoutSession> {
|
||||
return of({
|
||||
id: `chk_${Date.now()}`,
|
||||
cartId: this.cart.id,
|
||||
customerContact: { verified: false },
|
||||
deliveryOptionId,
|
||||
status: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
|
||||
import { ApiCategoryRepository } from './repositories/api-category.repository';
|
||||
import { CategoryRepository } from './repositories/category.repository';
|
||||
|
||||
/**
|
||||
* No mock CategoryRepository implementation exists - same dead branch as
|
||||
* PRODUCT_DATA_PROVIDER. Always resolved to the real API repository
|
||||
* regardless of getCategoryProviderMode(); removed the dead switch.
|
||||
*/
|
||||
export const CATEGORY_REPOSITORY = new InjectionToken<CategoryRepository>('CATEGORY_REPOSITORY', {
|
||||
providedIn: 'root',
|
||||
factory: () => {
|
||||
const strategy = inject(RuntimeProviderStrategyService);
|
||||
const apiRepository = inject(ApiCategoryRepository);
|
||||
|
||||
switch (strategy.getCategoryProviderMode()) {
|
||||
case 'mock':
|
||||
case 'remote-config':
|
||||
case 'api':
|
||||
default:
|
||||
return apiRepository;
|
||||
}
|
||||
}
|
||||
factory: () => inject(ApiCategoryRepository)
|
||||
});
|
||||
70
src/app/core/config/api-config.service.spec.ts
Normal file
70
src/app/core/config/api-config.service.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ApiConfigService } from './api-config.service';
|
||||
import { TenantResolverService } from './tenant-resolver.service';
|
||||
|
||||
describe('ApiConfigService', () => {
|
||||
let service: ApiConfigService;
|
||||
let tenantResolver: jasmine.SpyObj<TenantResolverService>;
|
||||
|
||||
beforeEach(() => {
|
||||
tenantResolver = jasmine.createSpyObj<TenantResolverService>(
|
||||
'TenantResolverService',
|
||||
['getHostname', 'getBaseDomain', 'getProtocol', 'getTenantKey', 'isLocalhost']
|
||||
);
|
||||
tenantResolver.getBaseDomain.and.returnValue('gorbushka.market');
|
||||
tenantResolver.getTenantKey.and.returnValue('gorbushka');
|
||||
tenantResolver.getProtocol.and.returnValue('https:');
|
||||
tenantResolver.isLocalhost.and.returnValue(false);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ApiConfigService,
|
||||
{ provide: TenantResolverService, useValue: tenantResolver }
|
||||
]
|
||||
});
|
||||
|
||||
service = TestBed.inject(ApiConfigService);
|
||||
});
|
||||
|
||||
it('uses the current customer hostname for the production API base URL', () => {
|
||||
tenantResolver.getHostname.and.returnValue('gorbushka.market');
|
||||
|
||||
expect(service.getBaseUrl()).toBe('https://api.gorbushka.market');
|
||||
});
|
||||
|
||||
it('uses the shared base-domain API for a tenant subdomain', () => {
|
||||
tenantResolver.getHostname.and.returnValue('store1.example.com');
|
||||
tenantResolver.getBaseDomain.and.returnValue('example.com');
|
||||
|
||||
expect(service.getBaseUrl()).toBe('https://api.example.com');
|
||||
});
|
||||
|
||||
it('uses the shared base-domain API for www', () => {
|
||||
tenantResolver.getHostname.and.returnValue('www.gorbushka.market');
|
||||
|
||||
expect(service.getBaseUrl()).toBe('https://api.gorbushka.market');
|
||||
});
|
||||
|
||||
it('preserves the API namespace when targeting a tenant backend', () => {
|
||||
tenantResolver.getHostname.and.returnValue('gorbushka.market');
|
||||
|
||||
expect(service.toApiUrl('/api/v2/storefront/cart'))
|
||||
.toBe('https://api.gorbushka.market/api/v2/storefront/cart');
|
||||
});
|
||||
|
||||
it('does not duplicate the API prefix for localhost proxy requests', () => {
|
||||
tenantResolver.getHostname.and.returnValue('localhost');
|
||||
tenantResolver.getTenantKey.and.returnValue('default');
|
||||
tenantResolver.isLocalhost.and.returnValue(true);
|
||||
|
||||
expect(service.toApiUrl('/api/v2/storefront/cart')).toBe('/api/v2/storefront/cart');
|
||||
});
|
||||
|
||||
it('leaves absolute and non-API URLs unchanged', () => {
|
||||
tenantResolver.getHostname.and.returnValue('gorbushka.market');
|
||||
|
||||
expect(service.toApiUrl('https://cdn.example.com/image.png'))
|
||||
.toBe('https://cdn.example.com/image.png');
|
||||
expect(service.toApiUrl('/assets/config.json')).toBe('/assets/config.json');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user