Compare commits
83 Commits
a2204c641b
...
B2B
| 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 | ||
|
|
3318b34f1e | ||
|
|
0089285373 | ||
|
|
7224bc56c2 | ||
|
|
f079ef6f52 | ||
|
|
a8f7ca31f9 | ||
|
|
1ebfd206ce | ||
|
|
65ce2ef23c | ||
|
|
65663ad6ec | ||
|
|
3480efedd1 |
31
.github/workflows/architecture-governance.yml
vendored
31
.github/workflows/architecture-governance.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
@@ -26,5 +26,34 @@ jobs:
|
|||||||
- name: Enforce Boundaries
|
- name: Enforce Boundaries
|
||||||
run: npm run arch:check
|
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
|
- name: Build
|
||||||
run: npm run 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
|
||||||
|
|||||||
70
.github/workflows/deploy.yml
vendored
70
.github/workflows/deploy.yml
vendored
@@ -13,6 +13,15 @@ on:
|
|||||||
description: Branch or SHA to deploy
|
description: Branch or SHA to deploy
|
||||||
required: false
|
required: false
|
||||||
default: main
|
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:
|
concurrency:
|
||||||
group: deploy-frontend
|
group: deploy-frontend
|
||||||
@@ -22,6 +31,9 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -32,7 +44,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -79,6 +91,57 @@ jobs:
|
|||||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||||
chmod 644 ~/.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
|
- name: Upload release
|
||||||
env:
|
env:
|
||||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
@@ -90,9 +153,8 @@ jobs:
|
|||||||
echo "RELEASE=$RELEASE" >> "$GITHUB_ENV"
|
echo "RELEASE=$RELEASE" >> "$GITHUB_ENV"
|
||||||
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
|
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
|
||||||
$SSH "$USER@$HOST" "mkdir -p /srv/marketplaces/releases/$RELEASE/frontend"
|
$SSH "$USER@$HOST" "mkdir -p /srv/marketplaces/releases/$RELEASE/frontend"
|
||||||
rsync -az --delete \
|
tar -C "$SRC" -czf - . | $SSH "$USER@$HOST" \
|
||||||
-e "$SSH" \
|
"tar -xzf - -C /srv/marketplaces/releases/$RELEASE/frontend"
|
||||||
"$SRC/" "$USER@$HOST:/srv/marketplaces/releases/$RELEASE/frontend/"
|
|
||||||
|
|
||||||
- name: Activate release
|
- name: Activate release
|
||||||
env:
|
env:
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -75,3 +75,7 @@ docs/context/schema/route.schema.json
|
|||||||
docs/context/schema/strategy.schema.json
|
docs/context/schema/strategy.schema.json
|
||||||
docs/context/schema/work-state.schema.json
|
docs/context/schema/work-state.schema.json
|
||||||
docs/context/schema/workspace.schema.json
|
docs/context/schema/workspace.schema.json
|
||||||
|
|
||||||
|
# Playwright artifacts
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
|||||||
@@ -307,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** |
|
| `/items/{id}/questiion` | POST | `{ question, sessionID, timestamp }` | `{ message }` — **literal typo `questiion`, preserve it, matches the client** |
|
||||||
| `/purchase-email` | POST | `{ email, phone?, telegramUserId, items[] }` | `{ message }` |
|
| `/purchase-email` | POST | `{ email, phone?, telegramUserId, items[] }` | `{ message }` |
|
||||||
| `/regions` | GET | — | `Region[]` — client falls back **silently** to 6 hardcoded regions on any error |
|
| `/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
|
### 6.1 Products — the tolerance contract
|
||||||
|
|
||||||
@@ -373,7 +385,7 @@ WebSessionID: 3f1c2a0e-…
|
|||||||
{ "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" }
|
{ "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" }
|
||||||
```
|
```
|
||||||
|
|
||||||
**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/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` — the server-authoritative-amount contract there replaces the client-trusted `amount`/`price` fields described below.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
{
|
{
|
||||||
"replace": "src/app/interceptors/mock-data.interceptor.ts",
|
"replace": "src/app/interceptors/mock-data.interceptor.ts",
|
||||||
"with": "src/app/interceptors/mock-data.interceptor.production.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": [
|
"styles": [
|
||||||
@@ -59,7 +63,7 @@
|
|||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "700kB",
|
"maximumWarning": "700kB",
|
||||||
"maximumError": "1.5MB"
|
"maximumError": "1.1MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"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`
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
# Deployment — server provisioning, CD, TLS
|
# Deployment — server provisioning, CD, TLS
|
||||||
|
|
||||||
Frontend only. The backend service (`:8080`) is a separate developer's responsibility; nginx already proxies `/api/` to it and will `502` until it exists.
|
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-HANDOFF.md)). One deploy updates every domain simultaneously — there is no per-tenant build and no per-tenant deploy.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,6 +20,7 @@ Frontend only. The backend service (`:8080`) is a separate developer's responsib
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `scripts/deploy/server-setup.sh` | One-time server provisioning. Idempotent. Run as root. |
|
| `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/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`. |
|
| `.github/workflows/deploy.yml` | CD: build → upload → atomic swap → verify. Triggers on push to `main`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -28,6 +37,21 @@ Frontend only. The backend service (`:8080`) is a separate developer's responsib
|
|||||||
|
|
||||||
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.
|
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. First-time setup
|
||||||
@@ -50,7 +74,32 @@ Copy `scripts/deploy/` to the server and run:
|
|||||||
sudo bash server-setup.sh --pubkey "$(cat marketplaces_deploy.pub)"
|
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 grants `deploy` exactly one sudo right: `systemctl reload nginx`.
|
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:
|
Verify before continuing:
|
||||||
|
|
||||||
@@ -70,6 +119,8 @@ The output is the `DEPLOY_KNOWN_HOSTS` secret. Pinning it means a rebuilt or imp
|
|||||||
|
|
||||||
### 3.4 Add CI secrets
|
### 3.4 Add CI secrets
|
||||||
|
|
||||||
|
Required for every deploy:
|
||||||
|
|
||||||
| Secret | Value |
|
| Secret | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `DEPLOY_HOST` | server IP or hostname |
|
| `DEPLOY_HOST` | server IP or hostname |
|
||||||
@@ -77,6 +128,22 @@ The output is the `DEPLOY_KNOWN_HOSTS` secret. Pinning it means a rebuilt or imp
|
|||||||
| `DEPLOY_SSH_KEY` | contents of the **private** key file |
|
| `DEPLOY_SSH_KEY` | contents of the **private** key file |
|
||||||
| `DEPLOY_KNOWN_HOSTS` | output of `ssh-keyscan -H <server-ip>` |
|
| `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
|
### 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.
|
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.
|
||||||
@@ -104,7 +171,7 @@ sudo bash setup-wildcard-tls.sh \
|
|||||||
|
|
||||||
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.
|
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/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) domain-automation API once it exists.
|
**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
|
### 4.2 Reconciler — tenants on their own domains
|
||||||
|
|
||||||
@@ -153,7 +220,24 @@ For a single domain, outside the reconciler:
|
|||||||
sudo bash add-domain.sh shop.example.com --email ops@example.com --with-www
|
sudo bash add-domain.sh shop.example.com --email ops@example.com --with-www
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.5 Verify
|
### 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
|
```bash
|
||||||
curl -I https://shop.example.com/health
|
curl -I https://shop.example.com/health
|
||||||
@@ -175,6 +259,11 @@ sudo systemctl reload nginx
|
|||||||
|
|
||||||
Only the last 5 releases are retained. Older ones need a rebuild from the tag.
|
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
|
## 6. Operational checks
|
||||||
|
|||||||
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.
|
||||||
@@ -83,7 +83,7 @@ private readonly auth = inject(AuthService);
|
|||||||
readonly isLoggedIn = this.auth.isAuthenticated; // signal
|
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/TRACK-S-SECURITY-RBAC-CONTRACT.md).
|
**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
|
### `ed25519/` — prepared, backend not shipped
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ Bind the verification seam in `app.config.ts`:
|
|||||||
|
|
||||||
## 5. `@marketplaces/payment`
|
## 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/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) and [Phase 7](backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md)); the eventual package is a thin client for FX/pricing/checkout gateways.
|
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
|
## 6. Making a change to a package
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Nothing downstream can be honestly estimated until this closes. Two sprints: one
|
|||||||
- [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] **§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] **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] **"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: `docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.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] **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.
|
- [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.
|
**Exit:** all nine answered in writing.
|
||||||
@@ -397,16 +397,16 @@ Only after Commerce Core is real. The plan is explicit that Gorbushka does not d
|
|||||||
|
|
||||||
### Track P — Partner provisioning API (added 2026-08-18)
|
### Track P — Partner provisioning API (added 2026-08-18)
|
||||||
|
|
||||||
Inbound partner API for programmatic merchant-hierarchy management. Contract: [PARTNER-PROVISIONING-API-CONTRACT.md](backend/PARTNER-PROVISIONING-API-CONTRACT.md). Decision: [ADR-0003](context/adrs/ADR-0003-generic-partner-provisioning-api.md).
|
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–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/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md)) and to `Refund`/`ReconciliationRecord` ([Phase 7](backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md)). Frozen at checkout-session creation, immutable after. **M**
|
- [ ] **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/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)). **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/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.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**
|
- [ ] **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**
|
- [ ] **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/TRACK-S-SECURITY-RBAC-CONTRACT.md)). **L**
|
- [ ] **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**
|
- [ ] **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**
|
- [ ] **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**
|
- [ ] **P9** TEST/LIVE partition: disjoint credentials, disjoint ids, `403` on cross-environment access. **M**
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
# Backend handoff — start here
|
|
||||||
|
|
||||||
Single entry point for a backend developer picking this up cold. Written 2026-08-18.
|
|
||||||
|
|
||||||
## 1. What this is
|
|
||||||
|
|
||||||
`marketplaces` is a multi-tenant marketplace platform frontend (Angular 22). The frontend is built and waiting; **there is no backend yet**. Every wire contract the backend needs to implement is already written and sitting in this directory — see [README.md](README.md) for the full index and build order.
|
|
||||||
|
|
||||||
## 1a. Multi-tenancy — the thing that shapes every endpoint
|
|
||||||
|
|
||||||
One deployed bundle serves **every customer domain**. There is no per-tenant build. The chain is:
|
|
||||||
|
|
||||||
1. [`TenantResolverService`](../../src/app/core/config/tenant-resolver.service.ts) derives a `tenantKey` from `window.location.hostname` (first label; `www.` skipped; localhost falls back to a configured key).
|
|
||||||
2. [`ApiConfigService`](../../src/app/core/config/api-config.service.ts) turns that key into the API base URL — via an explicit per-tenant map or a `{tenant}` URL template.
|
|
||||||
3. `ApiBootstrapProvider` fetches that tenant's **bootstrap config**, which drives branding, theme, locales, currencies, navigation, footer, and which pages exist.
|
|
||||||
4. nginx is `default_server` / `server_name _`, so any domain pointed at the server IP gets the same bundle and self-resolves.
|
|
||||||
|
|
||||||
**What this means for you:** the bootstrap endpoint is the single most important thing to build after auth. Every request must be tenant-scoped server-side, and a tenant must never be able to read another tenant's data — return `403`, not an empty result (see [TRACK-S §2](TRACK-S-SECURITY-RBAC-CONTRACT.md)). The frontend supplies the tenant identity from the hostname; the backend must treat that as an untrusted hint and derive real scope from the authenticated session.
|
|
||||||
|
|
||||||
Constraints already fixed by the frontend design (see the platform-vision facts in `docs/context/`): no marketplace-specific code or hardcoded marketplace data in the frontend; bootstrap carries only what is needed before app start (branding, languages, homepage layout, navigation, enabled widgets, footer pages) and **never** products, orders, cart, or users.
|
|
||||||
|
|
||||||
[PHASE-9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) covers the marketplace registry, domain attachment, and publish/revision model.
|
|
||||||
|
|
||||||
## 2. Read in this order
|
|
||||||
|
|
||||||
1. [README.md](README.md) — index of all contracts, build order, and what's deliberately excluded.
|
|
||||||
2. [PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) — start here. Everything after depends on the money model.
|
|
||||||
3. Phases 2→4 — the rest of the launch gate (orders, catalog, connectors).
|
|
||||||
4. [TRACK-S-SECURITY-RBAC-CONTRACT.md](TRACK-S-SECURITY-RBAC-CONTRACT.md) — **gates the launch.** Today the admin role model is decorative: nothing server-side enforces any permission. §8 covers per-marketplace bootstrap admin accounts and self-service sub-admin management.
|
|
||||||
5. [TRACK-A-ANALYTICS-CONTRACT.md](TRACK-A-ANALYTICS-CONTRACT.md) — longest lead time, start it in parallel with Phase 1.
|
|
||||||
6. Phases 5→10 — post-launch-gate.
|
|
||||||
7. [PARTNER-PROVISIONING-API-CONTRACT.md](PARTNER-PROVISIONING-API-CONTRACT.md) — the inbound partner API. Read it **before implementing Phase 1**, not after: it adds `RoutingContext` to `CheckoutSession`/`PaymentIntent`/`Payment` ([Phase 1 §6.5](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md)) and two levels above `Marketplace` ([Phase 9 §1](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)). Building the partner API itself can wait; carrying its routing dimension in the payments tables cannot.
|
|
||||||
|
|
||||||
[../../BACKEND-API-REFERENCE.md](../../BACKEND-API-REFERENCE.md) documents the *current* live API surface (legacy endpoints, error envelope, mock-only areas). New endpoints use `/api/v2/...` namespaces; legacy endpoints are not being migrated.
|
|
||||||
|
|
||||||
## 3. Auth — read before writing any endpoint
|
|
||||||
|
|
||||||
Auth is no longer part of this repo. It lives in `@marketplaces/auth`, published from [vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git). See [../PACKAGES-USAGE.md](../PACKAGES-USAGE.md) for the full client surface. What matters on the backend side:
|
|
||||||
|
|
||||||
**Two mechanisms exist client-side.**
|
|
||||||
|
|
||||||
- **Telegram QR/session (live).** Endpoints under `{authApiUrl}/users/sessions` — `POST` to create, `GET /{id}` to poll, `DELETE /{id}` to log out. Both customer and admin login call the *same* endpoints; only client-side storage differs. The response shape is normalized permissively client-side (many key spellings accepted), but a clean implementation should return `{ webSessionID, user: { userId, username, firstName, lastName }, status, expiresAt }`.
|
|
||||||
- **Ed25519 challenge/response (not built).** `GET /api/admin/auth/challenge`, `POST /api/admin/auth/verify`, `POST /api/admin/auth/refresh`, `POST /api/admin/auth/logout`. Contracts in [TRACK-S](TRACK-S-SECURITY-RBAC-CONTRACT.md) and the package's `ed25519/models/auth-api.model.ts`. Until these ship, the client shows a `backend-unavailable` screen — nothing is mocked.
|
|
||||||
|
|
||||||
**The critical gap:** the session API has no concept of "admin." The frontend cannot distinguish an admin session from a customer one — it only chooses where to *store* the result. **Every admin endpoint must independently verify authorization server-side.** Client-side guards are UI convenience, never security. This is the single most serious open issue in the system.
|
|
||||||
|
|
||||||
Admin requests carry `AdminWebSessionID: <sessionId>` (and `Authorization: Bearer <token>` once admin JWTs exist) on paths containing `/admin/`, `/backoffice/`, `/builder/`, `/media/`.
|
|
||||||
|
|
||||||
## 4. Environment / infrastructure state
|
|
||||||
|
|
||||||
Dev server `213.21.246.138` (user `seto`, sudo, SSH key provided separately).
|
|
||||||
|
|
||||||
| Thing | State |
|
|
||||||
|---|---|
|
|
||||||
| nginx 1.24 | **Installed, running.** Config at `/etc/nginx/sites-enabled/marketplaces-dev.conf`. Serves frontend from `/srv/marketplaces/current/frontend`, backoffice from `/srv/marketplaces/current/backoffice`, proxies `/api/` → `127.0.0.1:8080`. `/health` returns `ok`. |
|
|
||||||
| Go toolchain | Installed (`/usr/local/bin/go`). |
|
|
||||||
| Backend service on :8080 | **Not running.** Nothing is listening. `/srv/marketplaces/current/api` is an empty shell. nginx's `/api/` proxy currently 502s. |
|
|
||||||
| PostgreSQL | **Installed but inactive.** Needs starting, a database, a user, and schema before anything works. |
|
|
||||||
| Shared packages | `@marketplaces/auth` installs over plain git from a release branch — no registry, token, or tunnel needed. `npm install` works out of the box. |
|
|
||||||
| Verdaccio (npm registry) | Running in Docker on port 4873, but **superseded and unused** — nothing depends on it. See [../PACKAGE-EXTRACTION.md](../PACKAGE-EXTRACTION.md) §5. |
|
|
||||||
| Firewall (ufw) | Active. 80/tcp, 443/tcp, OpenSSH. |
|
|
||||||
| TLS / certbot | **Not installed.** No certificates. Everything is plain HTTP today. For multi-tenant this is real work: every customer domain needs a certificate (per-domain issuance, or a wildcard if all tenants sit under one apex). |
|
|
||||||
| DNS / dynamic subdomains | **Not set up.** No domain currently points at the server (reverse DNS is the provider default `silky-bronze.ptr.network`). No wildcard record, no per-tenant subdomain automation, no Hostinger DNS integration. The *application* is fully multi-tenant (§1a) — this is the missing infrastructure underneath it. [PHASE-9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) specifies the target. |
|
|
||||||
| Frontend deploy (CD) | **None.** Pushing to `main` deploys nothing. `architecture-governance.yml` builds and checks boundaries but has no deploy step, and nothing writes to `/srv/marketplaces/current/frontend`. Deploys are manual today. |
|
|
||||||
| CI runner | None on this server; `sources.vitanova.network` CI runs elsewhere. |
|
|
||||||
|
|
||||||
## 5. To get a working dev environment
|
|
||||||
|
|
||||||
Nothing here is done yet — this is the setup a backend dev does on day one.
|
|
||||||
|
|
||||||
1. Start and configure PostgreSQL; create the database and application user.
|
|
||||||
2. Design the schema from the Phase 1–4 contracts (schema design is explicitly the backend's own call — the contracts specify entities, endpoints, and invariants, never tables). Tenant scoping belongs in the schema from day one; retrofitting it is painful.
|
|
||||||
3. Build the API service, listen on `127.0.0.1:8080`. nginx already proxies `/api/` to it.
|
|
||||||
4. Implement the **bootstrap config endpoint** (§1a) — without it the frontend cannot render for any tenant.
|
|
||||||
5. Implement the Telegram session endpoints — the login flow is fully built client-side and blocked only on these.
|
|
||||||
6. Implement `GET /api/identity/v1/session/permissions` ([TRACK-S §2](TRACK-S-SECURITY-RBAC-CONTRACT.md)) — frontend route guards derive from it.
|
|
||||||
7. Seed per-marketplace bootstrap admins ([TRACK-S §8](TRACK-S-SECURITY-RBAC-CONTRACT.md)): login = marketplace slug, password = `{slug}2026$`, `mustChangePassword: true`.
|
|
||||||
|
|
||||||
Steps 4–6 unblock the entire frontend. Everything after is feature work.
|
|
||||||
|
|
||||||
## 6. Frontend deploy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <marketplaces repo>
|
|
||||||
npm install # pulls @marketplaces/auth over git, no credentials needed
|
|
||||||
npm run build # -> dist/dexarmarket
|
|
||||||
```
|
|
||||||
|
|
||||||
Angular 22, Node 20+. nginx serves `/srv/marketplaces/current/frontend`, so deploying means copying `dist/dexarmarket` there — **manually, today.** There is no CD pipeline. Because of the multi-tenant design (§1a), one such deploy updates every domain at once.
|
|
||||||
|
|
||||||
## 7. Known open decisions
|
|
||||||
|
|
||||||
- Registry reachability for CI (reverse proxy + TLS, or a different registry entirely).
|
|
||||||
- ~~Backend ownership.~~ Answered 2026-08-18: implemented by a separate backend developer against this contract set.
|
|
||||||
- Additional payment providers (wallets, BNPL) — [Phase 7 §4](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md).
|
|
||||||
- Per-connector marketplace adapters — written per partner at onboarding, [Phase 4 §8](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md).
|
|
||||||
- Backfill of `Company`/`Project`/`PaymentPoint` for existing marketplaces — sequence specified in [Phase 9 §1.2](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md), not yet scheduled.
|
|
||||||
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).
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
# Partner Provisioning API Contract — Merchant Hierarchy, Credentials, Routing
|
|
||||||
|
|
||||||
Cross-cutting contract. Partner-facing, **inbound**: external partners call us. Distinct from [Phase 4](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md), which is outbound/ingest.
|
|
||||||
|
|
||||||
Depends on [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) (payment state machine), [Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md) (seller org), [Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) (marketplace registry), [Track S](TRACK-S-SECURITY-RBAC-CONTRACT.md) (keys, audit, rate limiting).
|
|
||||||
|
|
||||||
**Status: draft — level mapping decided (§10), two new entities required.**
|
|
||||||
|
|
||||||
Origin: a partner integration request (2026-08-18). **This contract is deliberately generic.** No partner name appears in any entity, field, endpoint, or status value. Partner-specific behaviour lives entirely in a `PartnerProfile` config row (§8). A second partner asking for the same thing must require zero schema and zero endpoint change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Why this exists
|
|
||||||
|
|
||||||
Partners need to provision and manage their own merchant hierarchy programmatically, then have payments route unambiguously back to the correct leaf. Today we have no partner-facing write API at all, no entity above `Marketplace`, and payments carry no store dimension — reconciliation cannot attribute a payment to a store.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Hierarchy model
|
|
||||||
|
|
||||||
Four levels, fixed. Middle levels are **optional per partner**, never free-form depth.
|
|
||||||
|
|
||||||
```
|
|
||||||
Company -> Project -> Store -> PaymentPoint
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type NodeLevel = 'company' | 'project' | 'store' | 'payment_point';
|
|
||||||
|
|
||||||
interface ProvisioningNode {
|
|
||||||
id: string; // stable, opaque, never reused
|
|
||||||
level: NodeLevel;
|
|
||||||
parentId: string | null; // null only for level 'company'
|
|
||||||
companyId: string; // denormalized root, present on every node
|
|
||||||
path: string[]; // ordered ancestor ids, root first, inclusive of self
|
|
||||||
environment: Environment;
|
|
||||||
status: NodeStatus;
|
|
||||||
externalReference: string; // partner's own id for this node
|
|
||||||
displayName: string;
|
|
||||||
createdAt: string; // ISO 8601
|
|
||||||
updatedAt: string; // ISO 8601
|
|
||||||
}
|
|
||||||
|
|
||||||
type Environment = 'TEST' | 'LIVE';
|
|
||||||
type NodeStatus = 'active' | 'suspended' | 'disabled';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Invariants
|
|
||||||
|
|
||||||
1. `parentId` must be the immediately preceding **enabled** level in the partner's profile. Skipping a required level is `422`.
|
|
||||||
2. `companyId` and `environment` are inherited from the parent and are immutable.
|
|
||||||
3. `externalReference` is unique per `(companyId, environment, level)`. Collision is `409`.
|
|
||||||
4. `path` is server-computed. Never accepted from the client.
|
|
||||||
5. A node cannot be re-parented. Ever. Move = disable + create new.
|
|
||||||
6. Creating any node **never** enables a financial capability, never creates a payment, never opens a settlement account. Financial enablement is a separate, explicitly approved flow outside this contract.
|
|
||||||
|
|
||||||
### Status semantics
|
|
||||||
|
|
||||||
| Status | Meaning | Accepts payments | Reversible |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `active` | normal | yes | — |
|
|
||||||
| `suspended` | temporarily halted | no | yes, back to `active` |
|
|
||||||
| `disabled` | terminal | no | no |
|
|
||||||
|
|
||||||
- Disabling a node cascades `disabled` to every descendant, atomically.
|
|
||||||
- Suspending a node cascades `suspended` to descendants; un-suspending restores **only** descendants that were suspended by that same cascade (tracked by cascade id), never descendants suspended independently.
|
|
||||||
- `disabled` never returns to any other status. Re-provisioning creates a new node with a new id.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Environments
|
|
||||||
|
|
||||||
`TEST` and `LIVE` are a hard partition:
|
|
||||||
|
|
||||||
- Separate credentials. A `TEST` key can never address a `LIVE` node, and vice versa — cross-environment access is `403`, not `404`.
|
|
||||||
- Node ids never collide across environments and are never transferable.
|
|
||||||
- `externalReference` uniqueness is scoped per environment — the same partner reference may exist once in each.
|
|
||||||
- No data, config, or hierarchy copy between environments in this API.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Endpoints
|
|
||||||
|
|
||||||
Namespace `/api/partner/v1/`. All timestamps ISO 8601 UTC.
|
|
||||||
|
|
||||||
### 4.1 Write
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/partner/v1/companies/{companyId}/projects
|
|
||||||
POST /api/partner/v1/projects/{projectId}/stores
|
|
||||||
POST /api/partner/v1/stores/{storeId}/payment-points
|
|
||||||
|
|
||||||
PATCH /api/partner/v1/nodes/{nodeId}/status -- { status: 'active' | 'suspended', reason?: string }
|
|
||||||
POST /api/partner/v1/nodes/{nodeId}/disable -- terminal, cascading
|
|
||||||
```
|
|
||||||
|
|
||||||
Creation body:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface CreateNodeRequest {
|
|
||||||
externalReference: string;
|
|
||||||
displayName: string;
|
|
||||||
metadata?: Record<string, string>; // opaque to us, echoed back, never interpreted
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Read
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/partner/v1/nodes/{nodeId}
|
|
||||||
GET /api/partner/v1/companies/{companyId}/hierarchy?environment=TEST|LIVE&status=...&depth=...
|
|
||||||
GET /api/partner/v1/nodes/lookup?externalReference=...&level=...&environment=...
|
|
||||||
GET /api/partner/v1/companies/{companyId}/audit?from=...&to=...&cursor=...
|
|
||||||
```
|
|
||||||
|
|
||||||
- `hierarchy` returns the full tree with current statuses, one call, cursor-paginated over nodes when large.
|
|
||||||
- `lookup` is the `externalReference` resolver. Returns `404` when unmatched — never a partial or fuzzy match.
|
|
||||||
- Read endpoints are the partner's own verification surface for what was actually created. They read from the same store as writes — never a cache that can lag behind a create.
|
|
||||||
|
|
||||||
### 4.3 Not in this API
|
|
||||||
|
|
||||||
Company creation. A `Company` is created by us during commercial onboarding, out of band. Partners provision **inside** a company they already have.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Idempotency
|
|
||||||
|
|
||||||
Every `POST` requires an `Idempotency-Key` header. `PATCH` status changes accept one optionally.
|
|
||||||
|
|
||||||
```
|
|
||||||
Idempotency-Key: <partner-generated, opaque, <=255 chars>
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules, in order:
|
|
||||||
|
|
||||||
1. Key scope is `(partnerId, endpoint, key)`. Two partners may use the same key string without interference.
|
|
||||||
2. Same key + byte-identical request body → the **original stored response** is replayed, with the original status code. No new node.
|
|
||||||
3. Same key + different body → `409 Conflict`, error code `idempotency_key_reuse`. Nothing is created or modified.
|
|
||||||
4. Retention: 24 hours from first use. After expiry the key is free again — partners must not rely on replay beyond 24h.
|
|
||||||
5. A request that arrives while an identical key is still in flight returns `409` with `idempotency_request_in_progress`. Partner retries after a short backoff.
|
|
||||||
6. **No partial hierarchy.** A creation request either commits its node fully or commits nothing. If a partner creates project → store → payment point in three calls and the third fails, the first two remain — that is three operations, each atomic. A single call is never partially applied.
|
|
||||||
|
|
||||||
Body comparison uses a canonical hash (sorted keys, normalized whitespace) so key ordering does not cause a false `409`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Credentials and key management
|
|
||||||
|
|
||||||
### 6.1 Model — answered generically
|
|
||||||
|
|
||||||
Partners asked whether a credential is per-company, per-project, or per-store. **All three, one mechanism:** a credential is bound to **any single node**, and its authority is that node's subtree.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface PartnerCredential {
|
|
||||||
partnerId: string;
|
|
||||||
keyId: string;
|
|
||||||
scopeNodeId: string; // credential may act on this node and all descendants
|
|
||||||
environment: Environment;
|
|
||||||
algorithm: 'ed25519' | 'rsa-pss-sha256';
|
|
||||||
publicKey: string; // PEM or base64 raw, per algorithm
|
|
||||||
status: 'active' | 'rotating' | 'revoked';
|
|
||||||
createdAt: string;
|
|
||||||
expiresAt?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Partner generates the keypair. The **private key never leaves the partner** and is never transmitted to us, never logged, never accepted by any endpoint.
|
|
||||||
- Partner registers the public key; we return `partnerId` + `keyId`.
|
|
||||||
- Authority is strictly the `scopeNodeId` subtree. Any request touching a node outside it is `403`.
|
|
||||||
- A credential can never widen its own scope, register another credential at a wider scope, or create a node above its scope.
|
|
||||||
|
|
||||||
### 6.2 Endpoints
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/partner/v1/credentials -- register public key, returns partnerId + keyId
|
|
||||||
GET /api/partner/v1/credentials
|
|
||||||
POST /api/partner/v1/credentials/{keyId}/rotate -- register successor public key, overlap window
|
|
||||||
DELETE /api/partner/v1/credentials/{keyId} -- revoke, effective immediately
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Rotation:** the successor key is registered while the current key stays valid for a bounded overlap (default 7 days, configurable per profile). Both keys verify during overlap. The predecessor auto-revokes at window end.
|
|
||||||
- **Revocation is immediate and irreversible.** In-flight requests signed with a revoked key fail. Revoking a credential does not touch any node it created.
|
|
||||||
- Registration, rotation, and revocation each emit a Track S audit event. Key lifecycle actions are always attributable to a named actor.
|
|
||||||
|
|
||||||
### 6.3 Request authentication
|
|
||||||
|
|
||||||
Requests are signed, not bearer-token'd:
|
|
||||||
|
|
||||||
- Signature covers: HTTP method, path, canonical body hash, `Idempotency-Key` (when present), and a timestamp.
|
|
||||||
- Timestamp skew tolerance ±5 minutes. Outside that → `401`.
|
|
||||||
- Signature replay within the window is rejected by nonce tracking → `401`.
|
|
||||||
- `keyId` travels in the signature header so we select the right public key without trusting the body.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Payment routing
|
|
||||||
|
|
||||||
Every payment, callback, refund, and settlement row carries a routing context.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface RoutingContext {
|
|
||||||
companyId: string;
|
|
||||||
routingPath: string[]; // ordered node ids, root -> leaf, resolves to exactly one leaf
|
|
||||||
leafNodeId: string; // convenience: last element of routingPath
|
|
||||||
environment: Environment;
|
|
||||||
merchantReference: string; // partner-supplied, opaque to us, echoed on every related event
|
|
||||||
providerPaymentId: string; // our payment id, stable, unique
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Invariants
|
|
||||||
|
|
||||||
1. `routingPath` must resolve to exactly one leaf node. Ambiguous or unresolvable → the payment is rejected at creation, never accepted and reconciled later.
|
|
||||||
2. `merchantReference` is stored verbatim and echoed on **every** downstream event: payment status change, refund, settlement line, webhook.
|
|
||||||
3. A payment whose leaf node is `suspended` or `disabled` is rejected at creation.
|
|
||||||
4. Routing context is immutable for the life of the payment. Node status changes afterwards never rewrite it.
|
|
||||||
|
|
||||||
### Contract amendments this requires
|
|
||||||
|
|
||||||
`RoutingContext` must be added to:
|
|
||||||
|
|
||||||
- [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) — `Payment`, `PaymentEvent`, checkout session
|
|
||||||
- [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) — refund, reconciliation row, settlement line
|
|
||||||
|
|
||||||
**Do this before backend implements Phase 1.** Retrofitting a routing dimension onto a live payments table is materially more expensive than adding it now.
|
|
||||||
|
|
||||||
Partner-facing serialization uses the partner's own field names (§8) — `routingPath` is emitted as `projectId`/`storeId`/`paymentPointId` for a partner using those terms, without the core model knowing those words.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Partner profile — the only partner-specific surface
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface PartnerProfile {
|
|
||||||
partnerId: string;
|
|
||||||
requiredLevels: NodeLevel[]; // subset; 'company' and the leaf are always required
|
|
||||||
levelAliases: Record<NodeLevel, string>; // e.g. { project: 'Project', store: 'Store' }
|
|
||||||
routingFieldNames: Record<NodeLevel, string>; // e.g. { store: 'storeId' }
|
|
||||||
rateLimitTier: string;
|
|
||||||
keyRotationOverlapDays: number;
|
|
||||||
webhookFieldMap?: Record<string, string>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A partner with no "project" concept omits it from `requiredLevels`; their stores hang directly off the company and the hierarchy still validates. A partner calling stores "branches" changes one alias. **Adding a partner is a config row, not a deployment.**
|
|
||||||
|
|
||||||
What is deliberately **not** configurable, because configurability here breaks reconciliation or safety:
|
|
||||||
|
|
||||||
- `NodeStatus` values and their transition rules
|
|
||||||
- Idempotency semantics
|
|
||||||
- Environment partitioning
|
|
||||||
- Signature scheme and skew tolerance
|
|
||||||
- The four-level ceiling
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Operational requirements
|
|
||||||
|
|
||||||
| Requirement | Contract |
|
|
||||||
|---|---|
|
|
||||||
| OpenAPI | Machine-readable spec published per version, generated from the implementation, never hand-maintained |
|
|
||||||
| Sandbox | `TEST` environment is the sandbox. Same code path as `LIVE`, isolated data, no real money |
|
|
||||||
| Error codes | Stable string codes, documented, never renamed. HTTP status + `code` + human `message` + `requestId` |
|
|
||||||
| Rate limits | Per `partnerId`, per tier. `429` with `Retry-After`. Limits published in the spec, per Track S |
|
|
||||||
| Audit | Every write is an audit event: actor (`keyId`), action, target node, before/after status, `requestId`, timestamp. Immutable, queryable via §4.2 |
|
|
||||||
| Idempotency observability | `Idempotency-Replayed: true` response header when a stored response is replayed |
|
|
||||||
|
|
||||||
### Error codes
|
|
||||||
|
|
||||||
```
|
|
||||||
validation_failed 422
|
|
||||||
parent_not_found 404
|
|
||||||
level_skipped 422
|
|
||||||
external_reference_conflict 409
|
|
||||||
idempotency_key_reuse 409
|
|
||||||
idempotency_request_in_progress 409
|
|
||||||
scope_forbidden 403
|
|
||||||
environment_mismatch 403
|
|
||||||
node_disabled 409
|
|
||||||
signature_invalid 401
|
|
||||||
signature_expired 401
|
|
||||||
rate_limited 429
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Mapping onto our model
|
|
||||||
|
|
||||||
Decided 2026-08-18.
|
|
||||||
|
|
||||||
| Partner level | Our entity | State |
|
|
||||||
|---|---|---|
|
|
||||||
| `company` | — | **New.** No entity above `Marketplace` exists today. Legal/commercial owner, created out of band (§4.3). |
|
|
||||||
| `project` | — | **New.** A product line, e.g. `marketplaces`. One company runs several. Not the same thing as a `Marketplace`. |
|
|
||||||
| `store` | `Marketplace` ([Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)) | Exists. Gains `companyId`, `projectId`, `externalReference`. |
|
|
||||||
| `payment_point` | — | **New.** An acceptance channel: one payment method bound to one store. Many per store. |
|
|
||||||
|
|
||||||
### 10.1 PaymentPoint = acceptance channel
|
|
||||||
|
|
||||||
A `PaymentPoint` is a payment method enabled on a store, not a physical location and not a settlement account.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface PaymentPointConfig {
|
|
||||||
method: PaymentMethod; // 'qr' | 'card', extensible
|
|
||||||
currencies: string[]; // ISO 4217 subset the channel accepts
|
|
||||||
providerAccountRef?: string; // opaque provider-side binding, set during financial enablement
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Both current methods ship today — `src/app/pages/cart/cart.component.ts` (`PaymentMethod = 'qr' | 'card'`, separate create + status-poll paths per method). A store accepting both has two payment points.
|
|
||||||
|
|
||||||
Creating a payment point registers the channel. It does **not** enable it for real money — §2 invariant 6 still holds. Financial enablement sets `providerAccountRef` through a separate approved flow.
|
|
||||||
|
|
||||||
### 10.2 Seller is orthogonal
|
|
||||||
|
|
||||||
`Seller` ([Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md)) is **not** a level in this hierarchy. A multi-seller marketplace is one `store` with many sellers underneath; seller-level settlement splitting happens in [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) reconciliation, after the payment has already been routed to the store. Putting `Seller` in the partner hierarchy would force every partner to model our multi-seller concept, which most will not have.
|
|
||||||
|
|
||||||
### 10.3 Consequences
|
|
||||||
|
|
||||||
1. Two new entities: `Company`, `Project`. Both are thin — id, name, `externalReference`, status, timestamps — and both sit above `Marketplace`.
|
|
||||||
2. `Marketplace` gains `companyId` + `projectId`. Existing marketplaces need a backfill company and project.
|
|
||||||
3. `PaymentPoint` is new and is what `routingPath` terminates at (§7).
|
|
||||||
4. §7's contract amendments to Phases 1 and 7 do not depend on any of the above — start them now.
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
# Phase 1 Backend Contract — Money, FX, Price Snapshot, Payment State Machine
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 1 (Sprints 1.1–1.4) and [PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md](../PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md) §3.3/§3.5/§3.6.
|
|
||||||
|
|
||||||
**Status: unblocked (2026-08-17).** [BACKEND-API-REFERENCE.md §7](../../BACKEND-API-REFERENCE.md) previously marked the cart/payment call chain frozen. Per the delivery plan's Sprint 0.1 decision, the freeze is lifted — this contract can move to implementation. Backend ownership was answered 2026-08-18 — a separate backend developer builds against it.
|
|
||||||
|
|
||||||
This doc is the frontend's ask, in the same style as `BACKEND-API-REFERENCE.md`. It does not prescribe backend implementation (DB schema, service boundaries) — only the wire contract and the invariants the frontend needs to hold.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Why this exists
|
|
||||||
|
|
||||||
Current behaviour (`services/currency-rates.service.ts`, `pages/cart/cart.component.ts`):
|
|
||||||
|
|
||||||
- Currency conversion rates are typed by an admin into Admin Settings and persisted to browser `localStorage`. They never update and drift from market.
|
|
||||||
- The amount charged is computed **client-side** and sent as `CartPaymentRequest.amount` to `POST /cart`. The backend currently trusts this number.
|
|
||||||
- No record exists anywhere of which FX rate produced a given displayed price, or when it was captured.
|
|
||||||
|
|
||||||
Result: bank/NSPK settlement totals don't reconcile against order counts, because nothing on the backend can reconstruct *why* a given amount was charged. This document's contract exists to close that gap — it is the same complaint as Product Plan v3.1 §3.3/§3.8, and our own [§12.7](../../BACKEND-API-REFERENCE.md) raised it first.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Money representation
|
|
||||||
|
|
||||||
All money fields in every new endpoint below use minor units, never float.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Money {
|
|
||||||
amountMinor: number; // integer, no float. 4990 = 49.90 for a 2-decimal currency.
|
|
||||||
currency: string; // ISO 4217, e.g. "RUB" | "USD" | "EUR" | "AMD"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Currency | Minor unit | Decimals |
|
|
||||||
|---|---|---|
|
|
||||||
| RUB | kopeck | 2 |
|
|
||||||
| USD | cent | 2 |
|
|
||||||
| EUR | cent | 2 |
|
|
||||||
| AMD | luma | 2 |
|
|
||||||
|
|
||||||
Rounding rule for any conversion: round half up to the currency's minor-unit precision, applied once, at the point of conversion — never re-rounded on redisplay.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. FX Quote
|
|
||||||
|
|
||||||
### 3.1 Endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v2/pricing/fx-quote?base=RUB"e=USD
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"quoteId": "fxq_8a3f1c2a",
|
|
||||||
"base": "RUB",
|
|
||||||
"quote": "USD",
|
|
||||||
"rate": 0.0108,
|
|
||||||
"source": "rapira",
|
|
||||||
"observedAt": "2026-08-20T09:14:00Z",
|
|
||||||
"expiresAt": "2026-08-20T09:19:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Notes |
|
|
||||||
|---|---|
|
|
||||||
| `quoteId` | Opaque, referenced by every `PriceSnapshot` that used this quote. |
|
|
||||||
| `rate` | `1 base = rate * quote`. Float is acceptable here — it's a market rate, not a money amount. |
|
|
||||||
| `source` | Adapter name. Frontend never hardcodes a provider; treat as an opaque label for display in the backoffice reconciliation panel. |
|
|
||||||
| `expiresAt` | TTL, provider-configurable. Frontend must not use an expired quote to display or charge. |
|
|
||||||
|
|
||||||
### 3.2 Stale-quote policy
|
|
||||||
|
|
||||||
- If the frontend holds a quote past `expiresAt`, it must re-fetch before checkout can proceed.
|
|
||||||
- If the rate source is unavailable, the backend decides: **block** (`503 SERVICE_UNAVAILABLE` with `error.code: "FX_SOURCE_UNAVAILABLE"`) or serve a configured fallback quote explicitly marked `"source": "fallback"`. Which policy applies is a tenant setting, not a frontend choice — see delivery-plan Sprint 0.1 decision on FX source.
|
|
||||||
- Outlier detection (e.g. a quote >X% off the previous one) is a backend concern; the frontend has no opinion on the threshold, only on obeying `expiresAt`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. PriceSnapshot
|
|
||||||
|
|
||||||
Created once, at checkout, immutable afterward. This is what makes a total explainable months later.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface PriceSnapshot {
|
|
||||||
id: string;
|
|
||||||
offerId: string;
|
|
||||||
amount: Money; // price in the offer's base currency
|
|
||||||
displayAmount: Money; // price in the currency the customer checked out in
|
|
||||||
fxQuoteId: string | null; // null when displayAmount.currency === amount.currency
|
|
||||||
capturedAt: string; // ISO 8601
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Rule: once a `PriceSnapshot` exists on an order line, it is never recalculated — not on rate update, not on currency-setting change, not on replay. An old order shows the price it was actually charged at.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Server-authoritative checkout amount
|
|
||||||
|
|
||||||
This is the contract change with the highest priority in Phase 1 — it removes the client-trusted `amount` field entirely.
|
|
||||||
|
|
||||||
### 5.1 Current (to be replaced)
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /cart
|
|
||||||
{ "amount": 4990, "currency": "RUB", "items": [{ "itemID": 101, "price": 4990, ... }], ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
The backend trusts `amount` and each line's `price` as sent by the browser.
|
|
||||||
|
|
||||||
### 5.2 Target
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/v2/storefront/checkout
|
|
||||||
{
|
|
||||||
"offers": [{ "offerId": "off_9a1", "qty": 2 }],
|
|
||||||
"currency": "USD",
|
|
||||||
"deliveryOptionId": "del_standard"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"checkoutSessionId": "chk_7f2e",
|
|
||||||
"lines": [
|
|
||||||
{
|
|
||||||
"offerId": "off_9a1",
|
|
||||||
"qty": 2,
|
|
||||||
"unitPrice": { "amountMinor": 5390, "currency": "USD" },
|
|
||||||
"lineTotal": { "amountMinor": 10780, "currency": "USD" },
|
|
||||||
"priceSnapshotId": "snap_3b1c"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"subtotal": { "amountMinor": 10780, "currency": "USD" },
|
|
||||||
"discount": { "amountMinor": 0, "currency": "USD" },
|
|
||||||
"delivery": { "amountMinor": 500, "currency": "USD" },
|
|
||||||
"total": { "amountMinor": 11280, "currency": "USD" },
|
|
||||||
"fxQuoteId": "fxq_8a3f1c2a",
|
|
||||||
"expiresAt": "2026-08-20T09:19:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**The frontend sends offer IDs and quantities. The backend computes every price, using the offer's live price and the current FX quote. No `amount` or `price` field is ever accepted from the client for anything that affects the charge.**
|
|
||||||
|
|
||||||
`POST /api/v2/storefront/payments/intents` then references `checkoutSessionId` only — the amount charged is read server-side from the checkout session, never re-sent by the client.
|
|
||||||
|
|
||||||
### 5.3 Total formula (must be reconstructable, per line)
|
|
||||||
|
|
||||||
```
|
|
||||||
order.total = sum(line.unitPrice * line.qty)
|
|
||||||
- discounts
|
|
||||||
+ delivery
|
|
||||||
+ taxes/fees (if applicable)
|
|
||||||
```
|
|
||||||
|
|
||||||
Backoffice must be able to render this formula, with the FX quote used, for any order — this is what Product Plan §7.2 asks for and what a bank reconciliation needs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Payment state machine
|
|
||||||
|
|
||||||
### 6.1 States
|
|
||||||
|
|
||||||
```
|
|
||||||
PaymentIntent: created -> pending -> authorized/paid -> failed/cancelled
|
|
||||||
Payment: received -> confirmed -> captured/settled -> refunded/partially_refunded
|
|
||||||
Order: pending_payment -> paid -> processing -> fulfilled/completed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Required fields per transition
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface PaymentEvent {
|
|
||||||
id: string;
|
|
||||||
paymentIntentId: string;
|
|
||||||
fromState: string;
|
|
||||||
toState: string;
|
|
||||||
providerEventId: string; // idempotency key from the provider
|
|
||||||
providerTimestamp: string; // when the provider says it happened
|
|
||||||
receivedAt: string; // when our webhook received it
|
|
||||||
processedAt: string; // when our system finished processing it
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
No fixed delays anywhere in this chain. The frontend already complies with this (polls real provider status via `/qr/dynamic/{partnerId}/{qrId}` and `/card/{partnerId}/{orderId}` on an interval bounded by QR TTL) — this section documents the backend side of the same principle.
|
|
||||||
|
|
||||||
### 6.3 Webhook contract
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/providers/v1/payments/{provider}/webhook
|
|
||||||
```
|
|
||||||
|
|
||||||
- Signature verification is mandatory; reject unsigned/invalid-signature payloads with `401`, do not silently accept.
|
|
||||||
- Idempotency key = `provider + providerEventId`. A repeated delivery of the same event must be a no-op — same `PaymentEvent` row, no second order, no second notification.
|
|
||||||
- On success, emit `payment.confirmed` / `payment.failed` onto the platform event bus (Phase 2) so Order creation is driven by the event, not by the webhook handler doing double duty.
|
|
||||||
|
|
||||||
### 6.4 Idempotent order creation
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/admin/v2/orders (internal, from the payment-confirmation handler)
|
|
||||||
Idempotency-Key: <checkoutSessionId>
|
|
||||||
```
|
|
||||||
|
|
||||||
A retried call with the same `checkoutSessionId` must return the existing order, not create a second one. This is the mechanism that makes "double-click doesn't create two orders" true regardless of frontend debouncing.
|
|
||||||
|
|
||||||
### 6.5 Routing context
|
|
||||||
|
|
||||||
Added 2026-08-18. Full definition in [PARTNER-PROVISIONING-API-CONTRACT.md §7](PARTNER-PROVISIONING-API-CONTRACT.md).
|
|
||||||
|
|
||||||
```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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`RoutingContext` is a **required** field on `CheckoutSession`, `PaymentIntent`, and `Payment`. `PaymentEvent` does not carry its own copy — it inherits via `paymentIntentId` — but every event **emitted** to the bus or to a partner must include the resolved context so consumers never need a second lookup.
|
|
||||||
|
|
||||||
Invariants:
|
|
||||||
|
|
||||||
1. Resolved and frozen at checkout-session creation. Immutable for the life of the payment. Later node status changes never rewrite it.
|
|
||||||
2. `routingPath` must resolve to exactly one leaf. Ambiguous or unresolvable → reject at creation. Never accept a payment and resolve routing during reconciliation.
|
|
||||||
3. A payment whose leaf node is `suspended` or `disabled` is rejected at creation.
|
|
||||||
4. `merchantReference` is stored verbatim, never parsed, never normalized.
|
|
||||||
5. `environment` must match the credential's environment. Mismatch is `403`.
|
|
||||||
|
|
||||||
**This is why it lands now, not later.** Without it, a payment cannot be attributed to a store, and §5's reconciliation goal — reconstructing why a given amount was charged — stops one level short of who it was charged for. Adding a routing dimension to a populated payments table after launch is materially more expensive than carrying it from the first row.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. What the frontend will stop doing once this ships
|
|
||||||
|
|
||||||
- Delete `CurrencyRatesService`'s `localStorage`-persisted admin-typed rates and hardcoded `DEFAULT_RATES` fallback (`USD: 0.011`, `AMD: 4.3`).
|
|
||||||
- Delete the Admin Settings currency-rate editor UI.
|
|
||||||
- Stop sending `amount` / `price` in any checkout-related request.
|
|
||||||
- Replace client-side float conversion (`CurrencyRatesService.convert()`) with server-supplied `Money` values everywhere a price is displayed.
|
|
||||||
|
|
||||||
## 8. What the frontend will start doing
|
|
||||||
|
|
||||||
- Fetch `GET /api/v2/pricing/fx-quote` on currency switch; block checkout if the held quote has expired.
|
|
||||||
- Render the backoffice "total formula" panel (lines × qty − discounts + delivery + fees, FX quote used) once §5.2 and the admin Orders API exist (Phase 2).
|
|
||||||
- Surface `FX_SOURCE_UNAVAILABLE` and `error.code`-driven stale-quote UI per the error envelope in `BACKEND-API-REFERENCE.md §5`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Resolved / open questions (Sprint 0.1, 2026-08-17)
|
|
||||||
|
|
||||||
1. **Payment chain freeze — lifted.** §5 can proceed.
|
|
||||||
2. **FX rate source/provider — ours, in-house, as the default (not just a fallback).** No external provider committed. Backend computes and serves the quote itself; the `source` field in §3.1 can legitimately read `"internal"` as the normal case. Revisit if an external provider is chosen later — the contract shape doesn't need to change, only the value of `source`.
|
|
||||||
3. **Backend-converted prices vs. frontend-requested display currency — still open, needs confirmation before implementation.** This doc's §5.2 models the frontend sending a target `currency` and the backend returning the converted total. Confirm this is the intended flow before backend implementation starts.
|
|
||||||
4. **Backend ownership — answered 2026-08-18.** A separate backend developer implements against this contract. Note §6.5: `RoutingContext` must be carried from the first payment row, not retrofitted.
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# Phase 10 Backend Contract — Tenant Content Modules (Gorbushka-class tenants)
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 10 (Sprints 10.1–10.2). Covers plan §11.
|
|
||||||
|
|
||||||
**Status: ready to build, lowest priority.** Only after Commerce Core (Phases 1–7) is real — the plan is explicit that this tenant type does not define the platform architecture; it is one configuration of the shared runtime, not a separate build.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Entities
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Shop {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
shopCategoryId: string;
|
|
||||||
name: string;
|
|
||||||
floorId?: string;
|
|
||||||
status: 'draft' | 'published';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShopCategory {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Service {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
status: 'draft' | 'published';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Floor {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
order: number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SchemePin {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
floorId: string;
|
|
||||||
shopId?: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RentListing {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
title: string;
|
|
||||||
areaSqm: number;
|
|
||||||
floorId?: string;
|
|
||||||
status: 'available' | 'leased';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Lead {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
rentListingId?: string;
|
|
||||||
contactName: string;
|
|
||||||
contactPhone: string;
|
|
||||||
message?: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NewsPromo {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
publishedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MallSettings {
|
|
||||||
marketplaceId: string;
|
|
||||||
openingHours: Record<string, string>;
|
|
||||||
contactInfo: Record<string, string>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every entity above carries `marketplaceId`, an audit trail, and the same draft/preview/publish flow as [Phase 9's revision model](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) §5 — not a separate content pipeline.
|
|
||||||
|
|
||||||
## 2. Endpoints
|
|
||||||
|
|
||||||
```
|
|
||||||
GET/POST/PATCH/DELETE /api/admin/v2/content/shops
|
|
||||||
GET/POST/PATCH/DELETE /api/admin/v2/content/shop-categories
|
|
||||||
GET/POST/PATCH/DELETE /api/admin/v2/content/services
|
|
||||||
GET/POST/PATCH/DELETE /api/admin/v2/content/floors
|
|
||||||
GET/POST/PATCH/DELETE /api/admin/v2/content/scheme-pins
|
|
||||||
GET/POST/PATCH/DELETE /api/admin/v2/content/rent-listings
|
|
||||||
POST /api/admin/v2/content/rent-listings/{id}/leads
|
|
||||||
GET/POST/PATCH/DELETE /api/admin/v2/content/news
|
|
||||||
PATCH /api/admin/v2/content/mall-settings
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Tenant feature configuration (Gorbushka's v1 default, per plan §11.1)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"cms": true, "shops": true, "services": true, "mallScheme": true,
|
|
||||||
"rentListings": true, "news": true, "seoMedia": true,
|
|
||||||
"catalog": false, "sellerPortal": false,
|
|
||||||
"cart": false, "checkout": false, "payments": false, "orders": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Commerce modules are **platform-ready but off** — the point of Phase 10 is proving this tenant can flip `catalog`/`cart`/`checkout`/etc. to `true` later via [Phase 9's `MarketplaceFeatureSet`](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) with zero backend or storefront code changes, since the commerce core is already generic by the time Phase 10 starts.
|
|
||||||
|
|
||||||
## 4. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- Mall scheme / floor / pin editor UI.
|
|
||||||
- Rent listing + lead capture forms.
|
|
||||||
- Confirm the existing Gorbushka frontend/archive is used as UX reference only — production data and auth route through the shared platform per ADR-0001.
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
# Phase 2 Backend Contract — Canonical Orders, Event Bus, Notification Center
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 2 (Sprints 2.1–2.2). Depends on [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) (Money/PriceSnapshot/PaymentIntent) being implemented first — an Order line references a `priceSnapshotId` from that contract.
|
|
||||||
|
|
||||||
**Status: ready to build.** No open decisions block this phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Why this exists
|
|
||||||
|
|
||||||
Today `AdminOrdersLocalGateway` is a static 24-row in-memory seed with no create path — a real order can never appear. `AdminOrderWatcherService` already polls for new orders to toast/badge the admin, but is functionally inert against the mock. This contract makes both real.
|
|
||||||
|
|
||||||
## 2. Multi-seller model — Sprint 0.1 decision: unified
|
|
||||||
|
|
||||||
**One `Order` per checkout, regardless of how many sellers are represented.** Lines are grouped into per-seller `Fulfillment` entries internally. There is no parent/child order splitting, no separate order-per-seller. A seller only ever sees their own `Fulfillment` group within a shared order (see [Phase 5 contract](PHASE-5-SELLER-PORTAL-CONTRACT.md) for the seller-scoped view).
|
|
||||||
|
|
||||||
## 3. Entities
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Order {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
source: 'storefront' | 'external' | 'backoffice' | 'api_partner';
|
|
||||||
externalOrderRef?: string; // set when source === 'external', see Phase 4
|
|
||||||
customerId?: string;
|
|
||||||
currency: string;
|
|
||||||
subtotal: Money;
|
|
||||||
discount: Money;
|
|
||||||
delivery: Money;
|
|
||||||
total: Money;
|
|
||||||
paymentStatus: 'pending_payment' | 'paid' | 'failed' | 'refunded' | 'partially_refunded';
|
|
||||||
orderStatus: 'pending_payment' | 'paid' | 'processing' | 'fulfilled' | 'completed' | 'cancelled';
|
|
||||||
createdAt: string;
|
|
||||||
paidAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderLine {
|
|
||||||
id: string;
|
|
||||||
orderId: string;
|
|
||||||
offerId: string; // see Phase 3 contract
|
|
||||||
sellerId: string;
|
|
||||||
skuSnapshot: string;
|
|
||||||
titleSnapshot: string;
|
|
||||||
qty: number;
|
|
||||||
unitPrice: Money;
|
|
||||||
lineTotal: Money;
|
|
||||||
priceSnapshotId: string; // references Phase 1's PriceSnapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Fulfillment {
|
|
||||||
id: string;
|
|
||||||
orderId: string;
|
|
||||||
sellerId: string; // the seller-scoping unit for the unified-order model
|
|
||||||
type: 'manual' | 'warehouse' | 'pickup' | 'digital';
|
|
||||||
status: 'pending' | 'assigned' | 'in_progress' | 'issued' | 'shipped' | 'cancelled';
|
|
||||||
assignedTo?: string;
|
|
||||||
issuedAt?: string;
|
|
||||||
shippedAt?: string;
|
|
||||||
evidence?: { type: string; url: string }[]; // e.g. shipment proof, digital delivery receipt
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderEvent {
|
|
||||||
id: string;
|
|
||||||
orderId: string;
|
|
||||||
type: 'created' | 'paid' | 'seller_notified' | 'accepted' | 'fulfilled' | 'cancelled' | 'refunded';
|
|
||||||
actor?: string; // user/system id, null for automated system events
|
|
||||||
occurredAt: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderContactSnapshot {
|
|
||||||
orderId: string;
|
|
||||||
name: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
preferredChannel?: 'telegram' | 'vk' | 'max' | 'email' | 'sms';
|
|
||||||
capturedAt: string; // immutable after order creation, independent of later Customer profile edits
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Endpoints
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/admin/v2/orders?marketplaceId=&status=&source=&page=&pageSize=
|
|
||||||
GET /api/admin/v2/orders/{id}
|
|
||||||
PATCH /api/admin/v2/orders/{id}/status { status }
|
|
||||||
POST /api/admin/v2/orders/{id}/refund-request { reason }
|
|
||||||
POST /api/admin/v2/orders/{id}/notes { note, internal: boolean }
|
|
||||||
POST /api/admin/v2/orders/{id}/archive
|
|
||||||
POST /api/admin/v2/orders/{id}/restore
|
|
||||||
DELETE /api/admin/v2/orders/{id}
|
|
||||||
|
|
||||||
GET /api/seller/v1/orders?fulfillmentStatus=&page=&pageSize=
|
|
||||||
-> returns Order + only the Fulfillment groups belonging to the authenticated seller,
|
|
||||||
OrderLines filtered to that seller's lines. Never the full order's other-seller lines.
|
|
||||||
```
|
|
||||||
|
|
||||||
Replaces `AdminOrdersLocalGateway` behind the `ADMIN_ORDERS_GATEWAY` token already wired this session (see [BACKEND-API-REFERENCE.md §8](../../BACKEND-API-REFERENCE.md)) — no facade change needed, only binding a real `AdminOrdersApiGateway`.
|
|
||||||
|
|
||||||
## 5. Event bus
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type PlatformEvent =
|
|
||||||
| { type: 'order.created'; orderId: string; marketplaceId: string }
|
|
||||||
| { type: 'order.paid'; orderId: string; marketplaceId: string }
|
|
||||||
| { type: 'payment.failed'; orderId: string; reason: string }
|
|
||||||
| { type: 'webhook.error'; source: string; traceId: string }
|
|
||||||
| { type: 'stock.low'; offerId: string; available: number }
|
|
||||||
| { type: 'oversell'; offerId: string; requested: number; available: number }
|
|
||||||
| { type: 'refund.requested'; orderId: string; refundId: string }
|
|
||||||
| { type: 'refund.completed'; orderId: string; refundId: string }
|
|
||||||
| { type: 'external_order.imported'; orderId: string; connectorId: string };
|
|
||||||
```
|
|
||||||
|
|
||||||
Backend owns the bus implementation (queue, pub/sub, whatever fits existing infra). Frontend's only contract: the Notification entity below, and the requirement that `order.paid` always produces a backoffice notification **even if every external channel is down** (see [Phase 8](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) §5 for the messenger-side orchestration).
|
|
||||||
|
|
||||||
## 6. Notification Center
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Notification {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
entityType: 'order' | 'payment' | 'offer' | 'connector' | 'refund';
|
|
||||||
entityId: string;
|
|
||||||
severity: 'info' | 'warning' | 'critical';
|
|
||||||
eventType: PlatformEvent['type'];
|
|
||||||
read: boolean;
|
|
||||||
deepLink: string; // e.g. /admin/orders/{id}
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeliveryAttempt {
|
|
||||||
notificationId: string;
|
|
||||||
channel: 'telegram' | 'email' | 'sms' | 'vk' | 'max';
|
|
||||||
status: 'sent' | 'failed';
|
|
||||||
error?: string;
|
|
||||||
attemptedAt: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/admin/v2/notifications?marketplaceId=&unreadOnly=&eventType=
|
|
||||||
PATCH /api/admin/v2/notifications/{id}/read
|
|
||||||
```
|
|
||||||
|
|
||||||
Invariant: a `DeliveryAttempt` failure on an external channel **never** prevents the `Notification` row itself from being created and visible in the backoffice unread queue.
|
|
||||||
|
|
||||||
## 7. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- Repoint `AdminOrderWatcherService` from polling `AdminOrdersLocalGateway` to the event stream / `GET /api/admin/v2/notifications?unreadOnly=true`.
|
|
||||||
- Build the backoffice **Notifications** section (unread queue, severity, marketplace/event-type filter) — currently missing from admin nav entirely.
|
|
||||||
- Wire admin order actions (assign, resend notification, replay sync, cancel/refund, comment, export) to the endpoints in §4.
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# Phase 3 Backend Contract — Product/Offer Split, Inventory, Executability
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 3 (Sprints 3.1–3.3). The largest structural change in the programme — nothing about multi-seller commerce works without it.
|
|
||||||
|
|
||||||
**Status: ready to build.** No open decisions block this phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Why this exists
|
|
||||||
|
|
||||||
Today price, stock and currency hang directly off a single admin `Product` mock domain, unrelated to the live storefront `Item` domain. A product cannot have two sellers, two prices, or two stock levels. `Offer/Listing` does not exist in any form.
|
|
||||||
|
|
||||||
## 2. The two-layer split
|
|
||||||
|
|
||||||
`Product` describes the item itself (content). `Offer` describes one seller's commercial proposition against that product (price, stock, currency, status). One product, many offers.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Product {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
categoryId: string;
|
|
||||||
brand?: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
attributes: Record<string, unknown>;
|
|
||||||
media: string[];
|
|
||||||
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Variant {
|
|
||||||
id: string;
|
|
||||||
productId: string;
|
|
||||||
sku: string;
|
|
||||||
barcode?: string;
|
|
||||||
optionValues: Record<string, string>; // e.g. { color: 'red', size: 'M' }
|
|
||||||
dimensions?: { weight?: number; length?: number; width?: number; height?: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Category {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
parentId: string | null;
|
|
||||||
slug: string;
|
|
||||||
attributesSchema: Record<string, unknown>;
|
|
||||||
order: number;
|
|
||||||
seo: { title?: string; description?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Offer {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
sellerId: string;
|
|
||||||
variantId: string;
|
|
||||||
sellerSku: string;
|
|
||||||
price: Money; // Money type from Phase 1 contract
|
|
||||||
stockPolicy: 'track' | 'no_track' | 'preorder';
|
|
||||||
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
|
|
||||||
publishedAt?: string;
|
|
||||||
executabilityChecked: boolean; // see §5
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PriceHistory {
|
|
||||||
offerId: string;
|
|
||||||
price: Money;
|
|
||||||
changedBy: string; // user id or 'sync:{connectorId}'
|
|
||||||
changedAt: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Inventory
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface InventoryRecord {
|
|
||||||
offerId: string;
|
|
||||||
available: number;
|
|
||||||
reserved: number;
|
|
||||||
sold: number;
|
|
||||||
warehouse?: string;
|
|
||||||
source: 'manual' | 'feed_sync' | 'connector';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StockReservation {
|
|
||||||
id: string;
|
|
||||||
offerId: string;
|
|
||||||
qty: number;
|
|
||||||
reason: 'checkout' | 'pre_payment';
|
|
||||||
expiresAt: string; // TTL
|
|
||||||
released: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Invariants:
|
|
||||||
- `available`, `reserved`, `sold` are counted separately, never derived from one another implicitly.
|
|
||||||
- Reservations are created at checkout or pre-payment (tenant-configurable strategy) and expire by TTL, releasing `reserved` back to `available`.
|
|
||||||
- Seller feed stock updates are an **idempotent upsert** — a repeated webhook must not double-decrement.
|
|
||||||
- Oversell (a sale exceeding `available`) routes to a dedicated incident queue, never silently hidden or auto-corrected.
|
|
||||||
|
|
||||||
## 4. Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
draft -> moderation -> published -> paused/archived
|
|
||||||
```
|
|
||||||
|
|
||||||
Applies independently to both `Product` and `Offer`. Wires to the already-existing (mock) Admin Moderation module — no new frontend module needed, just a real gateway behind `ADMIN_MODERATION_GATEWAY` (token already added this session).
|
|
||||||
|
|
||||||
## 5. Publish-time executability
|
|
||||||
|
|
||||||
**An offer that cannot actually be fulfilled must not be publishable.** Before allowing `status: 'published'`, the backend validates:
|
|
||||||
- The offer has a valid `Fulfillment` type it can realistically satisfy (see [Phase 2 contract](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) `Fulfillment.type`).
|
|
||||||
- Stock policy is `track` with `available > 0`, or `no_track`/`preorder` explicitly.
|
|
||||||
- Required attributes for the offer's category (`Category.attributesSchema`) are present.
|
|
||||||
|
|
||||||
This is the mechanism behind the plan's §3.6/§10.2 requirement: **no branch anywhere may distinguish a normal buyer from an inspector.** The only way to guarantee that is to make every published offer genuinely executable at publish time, not to special-case checkout behavior later.
|
|
||||||
|
|
||||||
## 6. Bulk import
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/admin/v2/products/bulk-import
|
|
||||||
Content-Type: multipart/form-data (CSV) or application/json (array)
|
|
||||||
```
|
|
||||||
|
|
||||||
Response returns a **preview** of validation errors before anything is applied — required-field validation, category-attribute validation, duplicate-SKU detection — with a separate `POST .../bulk-import/{importId}/apply` to commit after review.
|
|
||||||
|
|
||||||
## 7. Endpoints
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/admin/v2/products?marketplaceId=&status=&search=&page=&pageSize=
|
|
||||||
GET /api/admin/v2/products/{id}
|
|
||||||
POST /api/admin/v2/products
|
|
||||||
PATCH /api/admin/v2/products/{id}
|
|
||||||
GET /api/admin/v2/offers?productId=&sellerId=&status=
|
|
||||||
POST /api/admin/v2/offers
|
|
||||||
PATCH /api/admin/v2/offers/{id}
|
|
||||||
POST /api/admin/v2/offers/{id}/publish -> runs §5 executability check, 422 with details[] on failure
|
|
||||||
GET /api/admin/v2/offers/lookup?sku=&sellerSku=&externalId= -- "find any offer by internal SKU, seller SKU, product ID, or external mapping" per plan §2.1
|
|
||||||
```
|
|
||||||
|
|
||||||
Replaces `AdminProductsLocalGateway` behind `ADMIN_PRODUCTS_GATEWAY` (token already wired this session).
|
|
||||||
|
|
||||||
## 8. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- Unify the admin mock product domain with the live storefront `Item` domain — currently two unrelated shapes.
|
|
||||||
- Multi-seller product page: same product card, multiple offers/sellers/prices — undefined behaviour today.
|
|
||||||
- Wire the Moderation module to real lifecycle transitions instead of mock data.
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Phase 4 Backend Contract — External Order Connector Framework
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 4 (Sprint 4.1, generic framework; Sprint 4.2 retired as "per named marketplace"). Depends on [Phase 3](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) (`Offer`/`sellerSku` must exist to map onto) and [Phase 2](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) (`Order` canonical model).
|
|
||||||
|
|
||||||
**Status: ready to build, generic by design.** Sprint 0.1 decision (2026-08-17): no fixed marketplace list — "our new ones, partners, new, etc." This contract specifies a config-driven framework, not a per-provider integration. Zero of this exists in the codebase today (`reconcil*`, `idempot*`, `hostinger` all return 0 hits).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Design principle
|
|
||||||
|
|
||||||
**A new partner connector is an onboarding action against this framework, not a code change.** Auth type, field mapping, and rate limits are configuration; the pipeline (ingest → normalize → map → idempotency-check → create/update order → notify) is fixed and shared across every connector.
|
|
||||||
|
|
||||||
## 2. Entities
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Connector {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
provider: string; // free-text label, e.g. "ozon", "wildberries" - not an enum, new values need no code change
|
|
||||||
authType: 'webhook_signed' | 'api_key' | 'oauth2';
|
|
||||||
credentialRef: string; // pointer into secret storage, never the secret itself
|
|
||||||
pollingIntervalSeconds?: number; // set only when the provider has no webhook
|
|
||||||
cursorState?: string; // opaque, connector-specific pagination/since cursor
|
|
||||||
status: 'active' | 'paused' | 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawExternalEvent {
|
|
||||||
id: string;
|
|
||||||
connectorId: string;
|
|
||||||
payload: unknown; // stored verbatim, before any parsing - the traceability anchor
|
|
||||||
receivedAt: string;
|
|
||||||
processedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExternalOrderMapping {
|
|
||||||
connectorId: string;
|
|
||||||
externalSellerId: string;
|
|
||||||
externalProductId: string;
|
|
||||||
externalSku: string;
|
|
||||||
internalSellerId: string;
|
|
||||||
internalOfferId: string; // references Phase 3's Offer
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeadLetter {
|
|
||||||
id: string;
|
|
||||||
connectorId: string;
|
|
||||||
rawEventId: string;
|
|
||||||
reason: string;
|
|
||||||
retryCount: number;
|
|
||||||
lastAttemptAt: string;
|
|
||||||
resolvedAt?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Pipeline (fixed, shared across every connector)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Connector receives webhook, or polling finds a new event via cursorState.
|
|
||||||
2. Signature/auth verified. Idempotency key = connectorId + externalOrderId/eventId.
|
|
||||||
3. Payload persisted as RawExternalEvent BEFORE any parsing.
|
|
||||||
4. Normalizer maps payload -> canonical ExternalOrderEvent shape (fixed schema, see §4).
|
|
||||||
5. SKU mapping resolves externalSku -> internal Offer via ExternalOrderMapping.
|
|
||||||
No mapping found -> event goes to the Unmatched queue (§5), does NOT fail silently.
|
|
||||||
6. Order created/updated via the Phase 2 Order API, source: 'external', externalOrderRef set.
|
|
||||||
7. external_order.imported and order.created events emitted (Phase 2 event bus).
|
|
||||||
8. Fulfillment/status changes pushed back to the external marketplace if its API supports it.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Canonical external order event (what the normalizer produces)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface ExternalOrderEvent {
|
|
||||||
connectorId: string;
|
|
||||||
externalOrderId: string;
|
|
||||||
externalCreatedAt: string;
|
|
||||||
customer: { name?: string; contact?: string };
|
|
||||||
lines: Array<{ externalSku: string; qty: number; unitPriceMinor: number; currency: string }>;
|
|
||||||
totalMinor: number;
|
|
||||||
currency: string;
|
|
||||||
rawEventId: string; // traceability back to §2
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every provider's adapter is responsible only for producing this shape from its own payload — everything downstream (§3 steps 5–8) is provider-agnostic.
|
|
||||||
|
|
||||||
## 5. Unmatched queue + retry
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/admin/v2/integrations/{connectorId}/unmatched
|
|
||||||
POST /api/admin/v2/integrations/{connectorId}/unmatched/{eventId}/resolve { internalOfferId }
|
|
||||||
POST /api/admin/v2/integrations/{connectorId}/dead-letter/{id}/replay
|
|
||||||
```
|
|
||||||
|
|
||||||
Retry policy: exponential backoff, capped attempts, then `DeadLetter` with manual replay from backoffice. No connector is allowed to silently drop an event.
|
|
||||||
|
|
||||||
## 6. Connector-agnostic SLA (applies to every provider, per plan §5.2)
|
|
||||||
|
|
||||||
- Webhook source: 99% of valid events processed in under 60 seconds.
|
|
||||||
- Polling source: delay no worse than `pollingIntervalSeconds + 60`.
|
|
||||||
- **Zero** duplicate orders on repeated event delivery (guaranteed by the idempotency key in §3 step 2).
|
|
||||||
- Every connector error carries a trace id, visible in backoffice.
|
|
||||||
|
|
||||||
## 7. Endpoints
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/providers/v1/{connector}/webhook -- generic entrypoint, connector resolved by path + auth
|
|
||||||
GET /api/admin/v2/integrations -- list all connectors + health (last success, lag, errors, backlog)
|
|
||||||
POST /api/admin/v2/integrations -- onboard a new connector: { provider, authType, credentialRef, marketplaceId }
|
|
||||||
PATCH /api/admin/v2/integrations/{id} -- pause/resume, update mapping config
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Onboarding a new partner (replaces the old "one sprint per named marketplace")
|
|
||||||
|
|
||||||
1. Register credentials in secret storage, scoped to marketplace/seller.
|
|
||||||
2. `POST /api/admin/v2/integrations` with the provider's auth type and mapping config.
|
|
||||||
3. Write the provider-specific adapter (payload → §4 canonical shape) — the only genuinely bespoke piece per partner.
|
|
||||||
4. Verify in sandbox against the fixed pipeline (§3) — nothing else changes.
|
|
||||||
|
|
||||||
## 9. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- Build the backoffice **Integrations** section (missing from admin nav today): connector list, health (last success/lag/errors/backlog/unmatched), FX sources, messaging providers.
|
|
||||||
- Trace-id surfacing on connector errors.
|
|
||||||
- Unmatched-queue resolution UI.
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# Phase 5 Backend Contract — Seller Portal
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 5 (Sprints 5.1–5.3). Depends on [Phase 3](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) (Offer) and [Phase 2](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) (unified Order + Fulfillment).
|
|
||||||
|
|
||||||
**Status: ready to build behind the launch gate.** Frontend note: Seller Management is currently a static placeholder, feature-flagged off by default, with **zero backend bytes and zero `HttpClient` reference** — this contract is a from-scratch build, not a gateway swap.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Multi-seller model reminder
|
|
||||||
|
|
||||||
Per the Phase 2 unified-orders decision: a seller never owns a separate `Order`. They see the `Fulfillment` group(s) that belong to them within shared orders, and the `OrderLine`s scoped to their `sellerId`. All endpoints below are pre-filtered server-side to the authenticated seller — never trust a frontend-supplied `sellerId` filter.
|
|
||||||
|
|
||||||
## 2. Entities
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface SellerOrganization {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
legalName: string;
|
|
||||||
status: 'pending' | 'approved' | 'suspended' | 'rejected';
|
|
||||||
bankDetailsRef: string; // pointer into secret storage, never raw account numbers over the wire
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SellerUser {
|
|
||||||
id: string;
|
|
||||||
sellerOrganizationId: string;
|
|
||||||
role: 'SELLER_OWNER' | 'SELLER_CATALOG_MANAGER' | 'SELLER_ORDER_MANAGER' | 'SELLER_FINANCE_VIEWER' | 'SELLER_VIEWER';
|
|
||||||
email: string;
|
|
||||||
status: 'active' | 'invited' | 'suspended';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SellerMarketplaceMembership {
|
|
||||||
sellerOrganizationId: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
status: 'pending' | 'approved' | 'suspended';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SellerIntegration {
|
|
||||||
sellerOrganizationId: string;
|
|
||||||
apiCredentialRef: string;
|
|
||||||
webhookUrl?: string;
|
|
||||||
lastSyncAt?: string;
|
|
||||||
lastSyncError?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Endpoints (all scoped server-side to the authenticated seller's org)
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/seller/v1/onboarding { legalName, contacts, marketplaceId }
|
|
||||||
GET /api/seller/v1/profile
|
|
||||||
GET /api/seller/v1/offers?status=&page=
|
|
||||||
POST /api/seller/v1/offers
|
|
||||||
PATCH /api/seller/v1/offers/{id}
|
|
||||||
POST /api/seller/v1/offers/bulk-price-update -- mass price/stock edit, see Phase 3 §6 for the shared bulk-import pattern
|
|
||||||
GET /api/seller/v1/orders?fulfillmentStatus=
|
|
||||||
PATCH /api/seller/v1/orders/{orderId}/fulfillment/{fulfillmentId} { status, evidence }
|
|
||||||
GET /api/seller/v1/finance/accruals
|
|
||||||
GET /api/seller/v1/finance/settlements
|
|
||||||
POST /api/seller/v1/finance/bank-details -- step-up auth + audit event required, see §5
|
|
||||||
GET /api/seller/v1/team
|
|
||||||
POST /api/seller/v1/team/invite { email, role }
|
|
||||||
GET /api/seller/v1/integrations
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Roles (fixed set, enforced backend-side)
|
|
||||||
|
|
||||||
```
|
|
||||||
SELLER_OWNER - full access within the org
|
|
||||||
SELLER_CATALOG_MANAGER - offers/catalog only
|
|
||||||
SELLER_ORDER_MANAGER - orders/fulfillment only
|
|
||||||
SELLER_FINANCE_VIEWER - read-only finance
|
|
||||||
SELLER_VIEWER - read-only everything
|
|
||||||
```
|
|
||||||
|
|
||||||
No UI-only gating. Every endpoint above checks `SellerUser.role` server-side regardless of what the frontend renders — this is the same principle as [Track S](TRACK-S-SECURITY-RBAC-CONTRACT.md), scoped to the seller domain specifically.
|
|
||||||
|
|
||||||
## 5. Sensitive-action rules
|
|
||||||
|
|
||||||
- Bank/payment detail changes (`POST .../finance/bank-details`) require step-up authentication, produce an audit event, and — if maker/checker mode is enabled for the tenant — require a second approver before taking effect.
|
|
||||||
- A seller can never query, by any endpoint or parameter manipulation, another seller's products, orders, customers, finance data, or API keys. This must be enforced at the query layer (implicit `WHERE sellerOrganizationId = :authenticatedSeller`), not left to the frontend to "not ask for it."
|
|
||||||
|
|
||||||
## 6. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- Replace the static Seller Management placeholder with real screens: Onboarding, Catalog, Prices & Stock, Orders, Finance, Team, Integrations (per plan §2.2).
|
|
||||||
- Resolve the two competing seller type shapes flagged in `GAPS-AND-IMPROVEMENTS.md` (`SellerConfig` in bootstrap models vs. `Seller`/`SellerBranding` in the domain layer) against this contract's `SellerOrganization`/`SellerUser` shapes.
|
|
||||||
- First-ever exercise of the `sellerManagement.enabled` flag at `true` — write a fixture test, since it has never been tested at its real-world-eventual value.
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Phase 6 Backend Contract — Server Cart + Checkout Session
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 6 (Sprints 6.1–6.2). Extends [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §5 (server-authoritative checkout amount) into a full server-owned cart.
|
|
||||||
|
|
||||||
**Status: ready to build** — payment chain unfrozen per Sprint 0.1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Why this exists
|
|
||||||
|
|
||||||
Cart today is `localStorage` + Telegram CloudStorage — no backend cart exists at all. `features/website/checkout/` is an empty directory; checkout lives entirely inside a 751-line cart popup component. Phase 1 §5 already specifies the server-authoritative *amount* at checkout time; this phase makes the *cart itself* server-owned, from add-to-cart onward.
|
|
||||||
|
|
||||||
## 2. Entities
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Cart {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
customerId?: string; // set for authenticated customers
|
|
||||||
sessionToken?: string; // set for guest carts
|
|
||||||
createdAt: string;
|
|
||||||
expiresAt: string; // TTL for inactive carts
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CartLine {
|
|
||||||
id: string;
|
|
||||||
cartId: string;
|
|
||||||
offerId: string; // never a client-supplied price - see Phase 1 §5
|
|
||||||
qty: number;
|
|
||||||
addedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CheckoutSession {
|
|
||||||
id: string;
|
|
||||||
cartId: string;
|
|
||||||
customerContact: { email?: string; phone?: string; verified: boolean };
|
|
||||||
deliveryOptionId: string;
|
|
||||||
status: 'open' | 'confirmed' | 'expired';
|
|
||||||
createdAt: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeliveryOption {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
label: string;
|
|
||||||
price: Money;
|
|
||||||
type: 'pickup' | 'courier' | 'digital';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Cart endpoints
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v2/storefront/cart/lines { offerId, qty }
|
|
||||||
PATCH /api/v2/storefront/cart/lines/{lineId} { qty }
|
|
||||||
DELETE /api/v2/storefront/cart/lines/{lineId}
|
|
||||||
GET /api/v2/storefront/cart
|
|
||||||
```
|
|
||||||
|
|
||||||
Invariants:
|
|
||||||
- Idempotent add/update/remove.
|
|
||||||
- Quantity validated against `Offer`/`InventoryRecord` (Phase 3) on every mutation, not just at checkout.
|
|
||||||
- Guest cart identified by `sessionToken` (cookie or header); authenticated cart bound to `customerId`. Adding to a guest cart, then logging in, must merge into the customer's cart — not silently drop items.
|
|
||||||
- Inactive carts and their `StockReservation`s (Phase 3 §3) clear on `expiresAt`.
|
|
||||||
|
|
||||||
## 4. Price-refresh rule
|
|
||||||
|
|
||||||
If an offer's price changed since it was added to the cart, `GET /api/v2/storefront/cart` returns both the line's captured price and the current price, with a `priceChanged: boolean` flag. The frontend must show this and require explicit confirmation before checkout proceeds if the total moved — this is a UX requirement on the frontend, but the backend must expose the comparison, not silently use whichever price it prefers.
|
|
||||||
|
|
||||||
## 5. Checkout session
|
|
||||||
|
|
||||||
Builds directly on [Phase 1 §5.2](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md#5-server-authoritative-checkout-amount):
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v2/storefront/checkout { cartId, currency, deliveryOptionId }
|
|
||||||
```
|
|
||||||
|
|
||||||
reads the server-owned `Cart`/`CartLine`s directly (no client-supplied offer list needed anymore, unlike the Phase 1 doc's example which pre-dates the server cart). Response shape unchanged from Phase 1 §5.2.
|
|
||||||
|
|
||||||
Additional checkout-time validation beyond Phase 1:
|
|
||||||
- Contact requirement enforced per tenant policy: email and/or phone must be present and (if the tenant requires it) verified before `CheckoutSession.status` can move to `confirmed`.
|
|
||||||
- Guest checkout allowed/disallowed per tenant policy (`MarketplaceFeatureSet`, see [Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)).
|
|
||||||
|
|
||||||
## 6. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- Build the `features/website/checkout/` module for real — currently an empty directory.
|
|
||||||
- Retire `localStorage`/Telegram-CloudStorage cart persistence.
|
|
||||||
- Show the price-refresh confirmation UI described in §4.
|
|
||||||
- Delete the client-side offer/qty tracking currently duplicated inside the cart popup component.
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# Phase 7 Backend Contract — Refunds + Reconciliation
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 7 (Sprints 7.1–7.3). Extends [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §6 (payment state machine).
|
|
||||||
|
|
||||||
**Status: ready to build.** `requestRefund(id)` exists today only as a mock gateway method; `reconcil*` and `settlement*` return zero hits anywhere in the codebase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Refunds
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Refund {
|
|
||||||
id: string;
|
|
||||||
orderId: string;
|
|
||||||
orderLineIds: string[]; // which lines this refund covers - partial refunds must specify
|
|
||||||
amount: Money;
|
|
||||||
reason: string;
|
|
||||||
actor: string; // user id who initiated it, never anonymous
|
|
||||||
status: 'requested' | 'approved' | 'processing' | 'completed' | 'failed';
|
|
||||||
requestedAt: string;
|
|
||||||
completedAt?: string;
|
|
||||||
routing: RoutingContext; // copied verbatim from the original Payment, never recomputed
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A refund always carries the routing context of the payment it reverses. It is copied, not re-resolved — a store suspended after the payment must still be refundable.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/admin/v2/orders/{orderId}/refunds { orderLineIds, amount, reason }
|
|
||||||
GET /api/admin/v2/orders/{orderId}/refunds
|
|
||||||
```
|
|
||||||
|
|
||||||
A `Refund` updates `Payment.status` to `refunded` or `partially_refunded` (Phase 1 §6.1) and emits `refund.requested`/`refund.completed` on the Phase 2 event bus.
|
|
||||||
|
|
||||||
## 2. Reconciliation
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface ReconciliationRecord {
|
|
||||||
id: string;
|
|
||||||
orderId: string;
|
|
||||||
providerPaymentId?: string;
|
|
||||||
internalAmount: Money;
|
|
||||||
providerAmount?: Money;
|
|
||||||
matchStrategy: 'provider_payment_id' | 'merchant_reference' | 'amount_currency_fallback';
|
|
||||||
result: 'matched' | 'unmatched' | 'duplicate' | 'amount_mismatch' | 'status_mismatch';
|
|
||||||
resolvedBy?: string;
|
|
||||||
resolvedAt?: string;
|
|
||||||
resolutionNote?: string;
|
|
||||||
routing: RoutingContext; // from the Payment; makes every row attributable to one payment point
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Process (per plan §7.3):
|
|
||||||
```
|
|
||||||
1. Collect internal paid orders for a period.
|
|
||||||
2. Fetch provider transactions/events for the same period.
|
|
||||||
3. Match by providerPaymentId, falling back to merchant reference, falling back to amount+currency.
|
|
||||||
4. Classify: matched / unmatched / duplicate / amount_mismatch / status_mismatch.
|
|
||||||
5. Surface the non-matched set in backoffice with controlled, audited resolution.
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/admin/v2/reconciliation/queue?marketplaceId=&companyId=&projectId=&leafNodeId=&result=
|
|
||||||
POST /api/admin/v2/reconciliation/{id}/resolve { note }
|
|
||||||
```
|
|
||||||
|
|
||||||
Step 3's `merchant_reference` strategy matches on `RoutingContext.merchantReference` — the partner-supplied value, stored verbatim (Phase 1 §6.5). The queue is filterable at every hierarchy level so an unmatched set can be narrowed to one payment point without a join the backoffice has to build itself.
|
|
||||||
|
|
||||||
## 3. Settlements
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Settlement {
|
|
||||||
id: string;
|
|
||||||
sellerId: string;
|
|
||||||
periodStart: string;
|
|
||||||
periodEnd: string;
|
|
||||||
grossAmount: Money;
|
|
||||||
commission: Money;
|
|
||||||
refunds: Money;
|
|
||||||
netPayout: Money;
|
|
||||||
status: 'pending' | 'paid';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/seller/v1/finance/settlements
|
|
||||||
GET /api/admin/v2/finance/settlements?sellerId=&companyId=&projectId=&storeId=&period=
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.1 Seller split happens after routing
|
|
||||||
|
|
||||||
Added 2026-08-18. `Seller` is deliberately **not** a level in the partner hierarchy ([PARTNER-PROVISIONING-API-CONTRACT.md §10.2](PARTNER-PROVISIONING-API-CONTRACT.md)). Order of operations:
|
|
||||||
|
|
||||||
```
|
|
||||||
payment -> routed to exactly one payment point (Phase 1 §6.5, frozen at checkout)
|
|
||||||
-> reconciled at that payment point
|
|
||||||
-> split across the sellers whose lines the order contains (this phase)
|
|
||||||
```
|
|
||||||
|
|
||||||
- A `Settlement` belongs to one seller **within one store**. A seller trading in two stores gets two settlements per period, never one merged row.
|
|
||||||
- Splitting never rewrites `RoutingContext`. The money arrived at one payment point; the split decides who is owed from it.
|
|
||||||
- `grossAmount` summed across a store's settlements for a period must reconcile against that store's matched reconciliation rows for the same period. A mismatch is a reconciliation defect, not a rounding tolerance.
|
|
||||||
|
|
||||||
## 4. Provider breadth (open business question)
|
|
||||||
|
|
||||||
Current flow supports QR and card only, via one custom provider integration. Adding wallets/BNPL is an explicit open business decision (not answered in Sprint 0.1) — this contract's `PaymentIntent`/`Payment` shapes from Phase 1 §6 are provider-agnostic already, so a new provider is a new adapter behind the same state machine, not a schema change. No action needed here until that business decision is made.
|
|
||||||
|
|
||||||
## 5. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- Wire the mock `requestRefund(id)` to a real endpoint.
|
|
||||||
- Build the backoffice **Payments & Finance** section (missing from admin nav today): payments, refunds, reconciliation queue, unmatched events, settlements.
|
|
||||||
- Reconciliation-queue resolution UI with full audit trail.
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
# Phase 8 Backend Contract — Customer Identity, VK ID, MAX/Telegram Messaging
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 8 (Sprints 8.1–8.5). Covers plan §2.9, §3.4, and all of §14 (the v3.1-only addition).
|
|
||||||
|
|
||||||
**Status: ready to build. Sprint order fixed by Sprint 0.1 decision: VK ID first, then everything else** ("do all after vk"). Sequence below follows that: identity core → VK ID → email/phone OTP → MAX/Telegram → Notification Orchestrator.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Entities
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Customer {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string; // or global identity strategy, tenant-configurable
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
status: 'active' | 'suspended';
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExternalIdentity {
|
|
||||||
customerId: string;
|
|
||||||
provider: 'vk_id' | 'telegram' | 'max';
|
|
||||||
providerUserId: string;
|
|
||||||
verifiedAt: string;
|
|
||||||
metadata: Record<string, unknown>;
|
|
||||||
lastUsedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContactMethod {
|
|
||||||
customerId: string;
|
|
||||||
type: 'email' | 'phone';
|
|
||||||
value: string;
|
|
||||||
verifiedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContactChannel {
|
|
||||||
customerId: string;
|
|
||||||
provider: 'telegram' | 'vk' | 'max';
|
|
||||||
chatId: string;
|
|
||||||
verified: boolean;
|
|
||||||
notificationsEnabled: boolean;
|
|
||||||
deliveryEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessagingConsent {
|
|
||||||
customerId: string;
|
|
||||||
channel: string;
|
|
||||||
purpose: 'marketing' | 'order_service_messages';
|
|
||||||
grantedAt?: string;
|
|
||||||
revokedAt?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Telegram is demoted from sole identity to one `ExternalIdentity` provider among several — it must remain fully functional, just no longer the only path.
|
|
||||||
|
|
||||||
## 2. Sprint 8.2 — VK ID (build first)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/identity/v1/vk/authorize -> redirects into VK's OAuth 2.1/PKCE flow
|
|
||||||
POST /api/identity/v1/vk/callback { code, codeVerifier } -> completes OAuth **backend-side**,
|
|
||||||
links ExternalIdentity, returns session
|
|
||||||
```
|
|
||||||
|
|
||||||
Invariants:
|
|
||||||
- OAuth completion happens entirely backend-side; the VK client secret never reaches the frontend.
|
|
||||||
- A repeat login for the same `providerUserId` must resolve to the same `Customer`, never create a duplicate.
|
|
||||||
- If `providerUserId` is already linked to a *different* `Customer` than the one currently authenticated (or none), this is an identity conflict — route to controlled resolution, never silently overwrite the existing binding (plan §14.3).
|
|
||||||
|
|
||||||
## 3. Sprint 8.3 — Email/phone OTP (after VK ID)
|
|
||||||
|
|
||||||
Implements the already-approved [email/phone login spec](../superpowers/specs/2026-08-15-email-phone-login-design.md). Per v3.1 §14, position this as **recovery/fallback** when a messenger channel is unavailable — not the primary login path. No new contract beyond that spec; this section exists only to fix its place in the build order relative to VK ID.
|
|
||||||
|
|
||||||
## 4. Sprint 8.4 — MAX + Telegram bot channels
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface BotConversationBinding {
|
|
||||||
customerId: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
provider: 'telegram' | 'max';
|
|
||||||
chatId: string;
|
|
||||||
state: string; // see §5 state machine
|
|
||||||
orderId?: string;
|
|
||||||
lastMessageAt: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
MAX linking flow (bot-assisted, one-time code):
|
|
||||||
```
|
|
||||||
POST /api/identity/v1/max/link-code -> { code, expiresAt } (TTL, single-use, bound to marketplace + browser session)
|
|
||||||
```
|
|
||||||
User opens the MAX bot, sends the code; a confirmed bot update on the backend calls:
|
|
||||||
```
|
|
||||||
POST /api/providers/v1/max/bot-webhook -- idempotent; a repeated update must not create a duplicate binding
|
|
||||||
```
|
|
||||||
which links the pending `Customer` session to the MAX `chatId`.
|
|
||||||
|
|
||||||
All three providers' incoming bot updates (VK, MAX, Telegram) normalize into one shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface MessagingEvent {
|
|
||||||
provider: 'telegram' | 'vk' | 'max';
|
|
||||||
chatId: string;
|
|
||||||
orderId?: string;
|
|
||||||
text?: string;
|
|
||||||
receivedAt: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Provider bot tokens/secrets never reach the frontend, ever — only the backend calls each provider's Bot API.
|
|
||||||
|
|
||||||
## 5. Sprint 8.5 — Notification Orchestrator + Delivery Conversation State Machine
|
|
||||||
|
|
||||||
On `order.paid` (Phase 2 event bus), the orchestrator picks the customer's chosen channel (captured at checkout, see [Phase 6](PHASE-6-CART-CHECKOUT-CONTRACT.md) and `OrderContactSnapshot` in [Phase 2](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md)) and drives:
|
|
||||||
|
|
||||||
```
|
|
||||||
not_started -> awaiting_customer -> details_received -> manager_assigned/auto_confirmed -> shipment_planned -> completed
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface DeliveryDetailsSnapshot {
|
|
||||||
orderId: string;
|
|
||||||
city?: string;
|
|
||||||
address?: string;
|
|
||||||
recipientName?: string;
|
|
||||||
phone?: string;
|
|
||||||
timeWindow?: string;
|
|
||||||
comment?: string;
|
|
||||||
receivedAt: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Hard rules:
|
|
||||||
- **The bot never changes financial statuses.** It can only write `DeliveryDetailsSnapshot` fields via a dedicated Delivery Service — no bot code path touches `Order.paymentStatus`/`orderStatus`.
|
|
||||||
- The backoffice `Notification` (Phase 2 §6) fires unconditionally on `order.paid`, independent of whether the customer's messenger channel is reachable.
|
|
||||||
- If the chosen channel is unavailable, log a `DeliveryAttempt` error (Phase 2 §6) and fall back per tenant-configured policy (e.g. email/SMS) — never block the order itself.
|
|
||||||
- Follow-up messages are rate-limited per tenant policy; after the configured attempt limit, hand off to a human manager instead of continuing to message.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/providers/v1/{provider}/bot-webhook -- generic entrypoint for all three providers
|
|
||||||
GET /api/admin/v2/orders/{orderId}/conversation -- message history + current state, for manager handoff
|
|
||||||
POST /api/admin/v2/orders/{orderId}/conversation/handoff
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- VK ID login button + OAuth redirect flow on storefront (primary social login).
|
|
||||||
- MAX/Telegram linking UI (one-time code flow).
|
|
||||||
- Checkout channel-choice step ("where should we send confirmation?") — VK / MAX / Telegram / email/SMS fallback.
|
|
||||||
- Manager-facing conversation view (message history, current delivery state, accept handoff).
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
# Phase 9 Backend Contract — Tenant Registry, Domain Automation, Publish Model
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 9 (Sprints 9.1–9.3). Covers plan §4.3, §8.
|
|
||||||
|
|
||||||
**Status: ready to build.** Zero `hostinger` references exist in the codebase today.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Entities
|
|
||||||
|
|
||||||
Added 2026-08-18: two levels now sit **above** `Marketplace`, introduced by [PARTNER-PROVISIONING-API-CONTRACT.md §10](PARTNER-PROVISIONING-API-CONTRACT.md).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Company {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
externalReference?: string; // partner's own id, when provisioned via the partner API
|
|
||||||
status: 'active' | 'suspended' | 'disabled';
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
companyId: string;
|
|
||||||
name: string; // a product line, e.g. "marketplaces"
|
|
||||||
externalReference?: string;
|
|
||||||
status: 'active' | 'suspended' | 'disabled';
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Both are deliberately thin — they exist to scope ownership, credentials, and payment routing, not to hold configuration. All marketplace configuration stays on `Marketplace` below.
|
|
||||||
|
|
||||||
A `Marketplace` **is** the partner hierarchy's `store` level. One project holds many marketplaces; one marketplace holds many sellers (Phase 5), and sellers are not part of that hierarchy.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Marketplace {
|
|
||||||
id: string;
|
|
||||||
companyId: string; // added 2026-08-18
|
|
||||||
projectId: string; // added 2026-08-18
|
|
||||||
externalReference?: string; // added 2026-08-18, partner's own id for this store
|
|
||||||
name: string;
|
|
||||||
code: string;
|
|
||||||
type: 'commerce' | 'mall_directory' | 'hybrid' | 'single_brand';
|
|
||||||
ownerId: string;
|
|
||||||
countries: string[];
|
|
||||||
locales: string[];
|
|
||||||
currencies: string[];
|
|
||||||
timezone: string;
|
|
||||||
lifecycleState: MarketplaceLifecycleState;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MarketplaceLifecycleState =
|
|
||||||
| 'draft' | 'configured' | 'content_ready' | 'domains_planned'
|
|
||||||
| 'staging_live' | 'qa_passed' | 'production_ready' | 'live' | 'paused' | 'archived';
|
|
||||||
|
|
||||||
interface MarketplaceDomain {
|
|
||||||
marketplaceId: string;
|
|
||||||
domain: string;
|
|
||||||
type: 'production' | 'www' | 'staging' | 'preview' | 'api' | 'seller';
|
|
||||||
status: 'planned' | 'dns_pending' | 'ssl_pending' | 'active' | 'failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarketplaceFeatureSet {
|
|
||||||
marketplaceId: string;
|
|
||||||
features: Record<string, boolean>; // e.g. { catalog: true, sellers: true, cart: true, checkout: true, payments: true, orders: true, refunds: true, directory: false, ... }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarketplaceRevision {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
status: 'draft' | 'validated' | 'preview' | 'published';
|
|
||||||
publishedAt?: string;
|
|
||||||
supersedesRevisionId?: string; // rollback creates a NEW revision, never mutates the old one
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hard invariant:** `Order`, `Payment`, `InventoryRecord`, and every financial ledger row are **not part of a `MarketplaceRevision`**. Rolling back a storefront design revision must never touch commerce data.
|
|
||||||
|
|
||||||
### 1.1 PaymentPoint
|
|
||||||
|
|
||||||
Added 2026-08-18. The leaf of the partner hierarchy: one payment method accepted at one marketplace. A marketplace taking both QR and card has two payment points.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface PaymentPoint {
|
|
||||||
id: string;
|
|
||||||
marketplaceId: string;
|
|
||||||
method: 'qr' | 'card'; // extensible; both ship today
|
|
||||||
currencies: string[]; // ISO 4217 subset this channel accepts
|
|
||||||
externalReference?: string;
|
|
||||||
status: 'active' | 'suspended' | 'disabled';
|
|
||||||
providerAccountRef?: string; // set only by financial enablement, never by provisioning
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Creating a payment point registers the channel. It does **not** enable real money — that requires `providerAccountRef`, set through a separate approved flow.
|
|
||||||
- A payment point is what `RoutingContext.leafNodeId` points at (Phase 1 §6.5).
|
|
||||||
- `MarketplaceFeatureSet.features.payments` gates whether the marketplace may have enabled payment points at all; the payment point gates which method.
|
|
||||||
|
|
||||||
### 1.2 Backfill
|
|
||||||
|
|
||||||
Existing marketplaces predate `Company` and `Project`. Migration, in this order:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Create one Company for the current owning entity.
|
|
||||||
2. Create one Project ("marketplaces") under it.
|
|
||||||
3. Set companyId + projectId on every existing Marketplace.
|
|
||||||
4. Create PaymentPoints for the methods each marketplace already accepts (qr, card).
|
|
||||||
5. Make companyId and projectId non-nullable only after 3 completes.
|
|
||||||
```
|
|
||||||
|
|
||||||
`externalReference` stays null for backfilled rows — it is only meaningful for partner-provisioned nodes.
|
|
||||||
|
|
||||||
## 2. Lifecycle state machine
|
|
||||||
|
|
||||||
```
|
|
||||||
draft -> configured -> content_ready -> domains_planned -> staging_live -> qa_passed -> production_ready -> live -> paused/archived
|
|
||||||
```
|
|
||||||
|
|
||||||
Every state transition endpoint must return the specific blocker preventing the next transition — not just "not ready."
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/admin/v2/marketplaces/{id}/lifecycle -> { currentState, nextState, blockers: string[] }
|
|
||||||
POST /api/admin/v2/marketplaces/{id}/lifecycle/advance
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Onboarding wizard (8 steps, plan §4.3)
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/admin/v2/marketplaces -- step 1: name/code/type/owner/countries/locales/currencies/timezone
|
|
||||||
PATCH /api/admin/v2/marketplaces/{id}/feature-set -- step 2
|
|
||||||
POST /api/admin/v2/marketplaces/{id}/domains -- step 3
|
|
||||||
PATCH /api/admin/v2/marketplaces/{id}/design -- step 4
|
|
||||||
POST /api/admin/v2/marketplaces/{id}/roles -- step 5
|
|
||||||
PATCH /api/admin/v2/marketplaces/{id}/integrations -- step 6
|
|
||||||
POST /api/admin/v2/marketplaces/{id}/staging-launch -- step 7, runs smoke tests
|
|
||||||
POST /api/admin/v2/marketplaces/{id}/production-launch -- step 8, requires all P0 blockers closed + explicit approval
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Domain automation (Hostinger API, per plan §8.2)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/dns/v1/zones/{domain}
|
|
||||||
POST /api/dns/v1/zones/{domain}/validate
|
|
||||||
PUT /api/dns/v1/zones/{domain}
|
|
||||||
DELETE /api/dns/v1/zones/{domain}
|
|
||||||
GET /api/dns/v1/snapshots/{domain}
|
|
||||||
GET /api/dns/v1/snapshots/{domain}/{snapshotId}
|
|
||||||
POST /api/dns/v1/snapshots/{domain}/{snapshotId}/restore
|
|
||||||
```
|
|
||||||
|
|
||||||
Process, strictly in this order:
|
|
||||||
```
|
|
||||||
1. Read current DNS zone.
|
|
||||||
2. Save a snapshot (rollback payload) BEFORE any change.
|
|
||||||
3. Build and validate a DNS plan.
|
|
||||||
4. NEVER touch MX/SPF/DKIM/DMARC/CAA records without a separate, explicitly scoped task.
|
|
||||||
5. Apply records only after production approval.
|
|
||||||
6. Verify propagation, SSL issuance, and health checks.
|
|
||||||
7. Mark the domain 'active' only after all checks in step 6 pass.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Publish model
|
|
||||||
|
|
||||||
```
|
|
||||||
draft -> validation -> preview -> publish
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/admin/v2/marketplaces/{id}/revisions -- create draft
|
|
||||||
POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/validate
|
|
||||||
POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/publish -- becomes immutable
|
|
||||||
POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/rollback -- creates a NEW revision pointing at the prior published content
|
|
||||||
```
|
|
||||||
|
|
||||||
Replaces the current builder's `localStorage`-only draft persistence and the empty `apiEndpoints.builder: {}` placeholder in bootstrap. CMS/static-page content (currently in-memory bootstrap only) gets a real write path through this same revision model.
|
|
||||||
|
|
||||||
## 6. Tenant resolution hardening
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v2/storefront/bootstrap -- resolved server-side from verified Host header
|
|
||||||
```
|
|
||||||
|
|
||||||
- Host is normalized and matched against `MarketplaceDomain` server-side — the marketplace ID from the browser is never a trust boundary.
|
|
||||||
- Unknown Host → `404`, with **no fallback to any other tenant**.
|
|
||||||
|
|
||||||
## 7. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- Build the backoffice **Marketplaces** section (missing from admin nav today): registry, type, status, domains, currencies, feature set, responsible manager.
|
|
||||||
- Build the **Domains & Releases** section: DNS/SSL status, staging/production, health checks, rollback.
|
|
||||||
- Wire the project editor/builder to real revision persistence instead of `localStorage`.
|
|
||||||
- Marketplace dashboard: GMV, paid orders, conversion, payment failure rate, moderation queue, low stock, unmatched events, integration health, domain/SSL/release status (plan §4.2).
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Backend Contracts Index — Product Plan v3.1
|
|
||||||
|
|
||||||
> **New here? Start with [BACKEND-HANDOFF.md](BACKEND-HANDOFF.md)** — reading order, current infrastructure state, auth surface, and what a working dev environment still needs.
|
|
||||||
|
|
||||||
This directory is the complete set of wire contracts for building the backend behind [Product Plan v3.1](../PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md). Each doc specifies entities, endpoints, and invariants only — never DB schema or service boundaries, which stay backend's own call.
|
|
||||||
|
|
||||||
**Read order matches build order.** Every doc after Phase 1 depends on the ones before it (noted at the top of each). All Sprint 0.1 decisions referenced throughout were 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.
|
|
||||||
|
|
||||||
## Launch-gate phases (P0 — required before production)
|
|
||||||
|
|
||||||
| Doc | Covers | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| [PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) | Money model, FX quote, price snapshot, server-authoritative checkout amount, payment state machine | Ready |
|
|
||||||
| [PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) | Canonical Order/OrderLine/Fulfillment (unified multi-seller), event bus, Notification Center | Ready |
|
|
||||||
| [PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) | Product/Offer split, inventory/reservations, publish-time executability | Ready |
|
|
||||||
| [PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md) | Generic external-order connector framework (no fixed marketplace list) | Ready |
|
|
||||||
|
|
||||||
## Post-launch-gate phases (P1/P2)
|
|
||||||
|
|
||||||
| Doc | Covers | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| [PHASE-5-SELLER-PORTAL-CONTRACT.md](PHASE-5-SELLER-PORTAL-CONTRACT.md) | Seller org/user/membership, seller-scoped order/fulfillment views | Ready |
|
|
||||||
| [PHASE-6-CART-CHECKOUT-CONTRACT.md](PHASE-6-CART-CHECKOUT-CONTRACT.md) | Server-owned cart, checkout session | Ready |
|
|
||||||
| [PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) | Refunds, reconciliation, settlements | Ready |
|
|
||||||
| [PHASE-8-IDENTITY-MESSAGING-CONTRACT.md](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) | Customer identity, VK ID (built first), OTP, MAX/Telegram bots, Notification Orchestrator | Ready |
|
|
||||||
| [PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) | Marketplace registry, Hostinger DNS automation, publish/revision model | Ready |
|
|
||||||
| [PHASE-10-CONTENT-MODULES-CONTRACT.md](PHASE-10-CONTENT-MODULES-CONTRACT.md) | Gorbushka-class mall/directory content entities | Ready, lowest priority |
|
|
||||||
|
|
||||||
## Cross-cutting tracks
|
|
||||||
|
|
||||||
| Doc | Covers | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| [TRACK-A-ANALYTICS-CONTRACT.md](TRACK-A-ANALYTICS-CONTRACT.md) | Event pipeline, funnel, operational/quality metrics, synthetic-traffic separation | Ready — start alongside Phase 1, longest lead time |
|
|
||||||
| [TRACK-S-SECURITY-RBAC-CONTRACT.md](TRACK-S-SECURITY-RBAC-CONTRACT.md) | 17 roles/3 scopes, enforcement, audit log, secrets, rate limiting, step-up auth | Ready — gates the launch |
|
|
||||||
| [PARTNER-PROVISIONING-API-CONTRACT.md](PARTNER-PROVISIONING-API-CONTRACT.md) | Inbound partner API: merchant hierarchy provisioning, idempotency, public-key credentials, payment routing context | Draft — mapping decided, needs Company/Project entities |
|
|
||||||
|
|
||||||
## What is deliberately not in this directory
|
|
||||||
|
|
||||||
- **API namespace migration** — Sprint 0.1 decision: new endpoints only use `/api/v2/...` etc; legacy endpoints (`/cart`, `/orders`, `/items`) are not being migrated as part of this contract set. See `BACKEND-API-REFERENCE.md` for the current live surface.
|
|
||||||
- **Per-connector adapters** (Ozon, Wildberries, etc.) — Sprint 0.1 decision: no fixed list. [Phase 4](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md) §8 is the onboarding runbook; each partner's adapter is written when that partner is actually onboarded.
|
|
||||||
- **Additional payment providers** (wallets, BNPL) — open business decision, not yet made. [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) §4.
|
|
||||||
|
|
||||||
## One open item across all of these
|
|
||||||
|
|
||||||
**Backend ownership — answered 2026-08-18.** A separate backend developer implements against these contracts. This repository's team owns the frontend and owns *this contract set* — the docs here are the handoff surface between the two, so a change to any contract is a change both sides must see. Keep them current; they are not a one-time deliverable.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Track A Backend Contract — Analytics Event Pipeline
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Track A. Covers plan §3.1, §6.3, §13.3.
|
|
||||||
|
|
||||||
**Status: ready to build. Start alongside Phase 1, not last** — longest lead time in the programme, and it's a P0 in the plan's own §3.1. No tracking infrastructure exists at all today; this is missing infrastructure, not a missing endpoint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Event logging spine
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface AnalyticsEvent {
|
|
||||||
eventType: string; // see §2-4 for the fixed vocabulary
|
|
||||||
marketplaceId: string;
|
|
||||||
sessionId: string;
|
|
||||||
customerId?: string;
|
|
||||||
timestamp: string;
|
|
||||||
properties: Record<string, unknown>;
|
|
||||||
isSynthetic: boolean; // see §6 - mandatory, never inferred
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v2/storefront/analytics/events { eventType, properties } -- server-side batched ingest
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend fires events client-side; backend is the source of truth for `sessionId` and `isSynthetic` — never trust a client-asserted synthetic flag without a matching signed staging/test-environment token.
|
|
||||||
|
|
||||||
## 2. Traffic events
|
|
||||||
|
|
||||||
```
|
|
||||||
session_started, page_view, product_view (with source/utm/referrer), unique users/sessions rollups
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Catalog events
|
|
||||||
|
|
||||||
```
|
|
||||||
search, category_view, product_view, seller_view
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Commerce events
|
|
||||||
|
|
||||||
```
|
|
||||||
add_to_cart, cart_view, checkout_started, payment_started, payment_success, payment_failed, order_created
|
|
||||||
```
|
|
||||||
|
|
||||||
These map directly onto the Phase 1/2/6 contracts' own state transitions — emit them from the same backend code paths that already produce `PaymentEvent`/`OrderEvent`, not a separately-maintained tracking layer that can drift.
|
|
||||||
|
|
||||||
## 5. Operational + quality metrics
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface OperationalMetric {
|
|
||||||
name: 'order_paid_to_notification_latency' | 'fulfillment_time' | 'connector_lag' | 'payment_webhook_lag';
|
|
||||||
marketplaceId: string;
|
|
||||||
value: number;
|
|
||||||
unit: 'seconds' | 'minutes';
|
|
||||||
measuredAt: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Quality events: frontend/backend errors, checkout validation failures, FX stale-rate blocks (Phase 1 §3.2).
|
|
||||||
|
|
||||||
## 6. Synthetic traffic separation (hard requirement, plan §3.1/§6.3/§10.2)
|
|
||||||
|
|
||||||
Synthetic/load-test traffic is permitted in staging and demo environments **only**, and must be technically inseparable-by-accident from production data — i.e. `isSynthetic: true` set server-side based on environment/token, never a client-settable flag that a real visit could accidentally or deliberately carry. Business reports must filter it out by construction, not by a manual exclusion query someone has to remember to add.
|
|
||||||
|
|
||||||
## 7. Endpoints
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/admin/v2/analytics/funnel?marketplaceId=&period=
|
|
||||||
GET /api/admin/v2/analytics/operational?marketplaceId=&metric=
|
|
||||||
GET /api/admin/v2/analytics/quality?marketplaceId=
|
|
||||||
GET /api/v2/storefront/search/trending?marketplaceId= -- top N queries over a recent window, closes the existing SearchTrendingService.loadTrending() stub (returns of(null) today)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Post-launch monitoring set (plan §13.3, reuses the same event stream)
|
|
||||||
|
|
||||||
```
|
|
||||||
checkout_conversion, payment_success_failure_rate, webhook_processing_lag,
|
|
||||||
order_notification_lag, external_connector_lag, fx_quote_age_errors,
|
|
||||||
unmatched_reconciliation_count, fulfillment_stuck_count
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- Replace the fully mock-composed `AdminAnalyticsFacade` with real funnel data.
|
|
||||||
- Fire the event vocabulary above from the relevant storefront interaction points.
|
|
||||||
- Bridge or replace the currently-always-zero `AdminProduct.visits` column with real tracking (see `GAPS-AND-IMPROVEMENTS.md`'s admin-product-views item — already partially speced in this session's [admin product views design](../superpowers/plans/2026-08-15-admin-product-views-column.md)).
|
|
||||||
- Wire `SearchTrendingService.loadTrending()` to the real endpoint in §7.
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# Track S Backend Contract — RBAC, Audit, Secrets, Rate Limiting
|
|
||||||
|
|
||||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Track S. Covers plan §4.4, §10.
|
|
||||||
|
|
||||||
**Status: ready to build. Gates the launch — this is the single most serious security gap identified in this session's audit.** Today the admin role model is decorative: `AdminRole` and permissions exist as types, but nothing gates any button, page, or action anywhere in the app. Any authenticated admin has full access.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Roles (17 total, 3 scopes, per plan §4.4)
|
|
||||||
|
|
||||||
```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';
|
|
||||||
```
|
|
||||||
|
|
||||||
`SellerRole` is already specified in [Phase 5's contract](PHASE-5-SELLER-PORTAL-CONTRACT.md) §4 — this doc adds the platform and marketplace scopes around it.
|
|
||||||
|
|
||||||
## 2. Enforcement (backend-side, non-negotiable)
|
|
||||||
|
|
||||||
Every `/api/admin/v2/*` and `/api/platform/v1/*` endpoint must check `(role, tenantScope)` against the acting user's session — **before** touching data, not as a post-hoc filter. `tenant scope` here means: a `MARKETPLACE_ADMIN` for marketplace A must get a `403` (not an empty result) querying marketplace B's data, never a silently-scoped response that looks like "there's just nothing here."
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/identity/v1/session/permissions -> { role, scopes: string[], marketplaceIds: string[] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend route/action guards derive from this endpoint's response — never hardcode role logic client-side beyond hiding UI affordances (which is convenience, not security).
|
|
||||||
|
|
||||||
## 3. Audit log
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface AuditEvent {
|
|
||||||
id: string;
|
|
||||||
actor: string;
|
|
||||||
action: string; // e.g. 'role.changed', 'offer.price_updated', 'refund.approved'
|
|
||||||
entityType: string;
|
|
||||||
entityId: string;
|
|
||||||
before?: unknown;
|
|
||||||
after?: unknown;
|
|
||||||
reason?: string;
|
|
||||||
occurredAt: string;
|
|
||||||
ip?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Mandatory coverage (plan §10.1): permission changes, seller status changes, catalog moderation actions, price changes, payment/refund actions, manual order overrides, integration credential changes, production launch actions.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/admin/v2/audit?marketplaceId=&entityType=&actor=&from=&to=
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Secrets
|
|
||||||
|
|
||||||
All provider/connector credentials (payment providers, external marketplace connectors, VK/MAX/Telegram bot tokens, FX source keys) live in dedicated secret storage, referenced by opaque `credentialRef` strings in every other contract in this series — never returned in any API response body, never logged in plaintext.
|
|
||||||
|
|
||||||
### 4.1 Partner credentials (inbound)
|
|
||||||
|
|
||||||
Added 2026-08-18. Partners calling our API authenticate with signed requests, not bearer tokens. Full contract: [PARTNER-PROVISIONING-API-CONTRACT.md §6](PARTNER-PROVISIONING-API-CONTRACT.md).
|
|
||||||
|
|
||||||
These are the opposite direction from the rest of §4 and follow a different rule:
|
|
||||||
|
|
||||||
- We hold only the partner's **public** key. The private key is generated by the partner and never transmitted to us, never accepted by any endpoint, never logged. There is nothing to store in secret storage on our side.
|
|
||||||
- Authority is node-scoped: a credential may act on its `scopeNodeId` and that node's descendants, nothing above or beside it. This is a separate axis from the 17 roles in §1 — partner credentials never map onto a human role, and a partner credential can never be granted an admin role.
|
|
||||||
- `TEST` and `LIVE` credentials are disjoint. A `TEST` key addressing a `LIVE` node is `403`.
|
|
||||||
- Rotation runs with a bounded overlap window (default 7 days) during which both keys verify. Revocation is immediate and irreversible.
|
|
||||||
- A credential can never widen its own scope or register another credential at a wider scope.
|
|
||||||
|
|
||||||
Audit coverage (§3) extends to: `partner_credential.registered`, `partner_credential.rotated`, `partner_credential.revoked`, and every partner-initiated node write, with `actor` set to the `keyId` that signed the request.
|
|
||||||
|
|
||||||
## 5. Rate limiting
|
|
||||||
|
|
||||||
```
|
|
||||||
429 response: { error: { code: 'RATE_LIMITED', retryAfterSeconds: number } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Applies to storefront/auth/provider endpoints. Frontend currently has **zero** 429 handling anywhere — see [BACKEND-API-REFERENCE.md §5](../../BACKEND-API-REFERENCE.md) for the full error-envelope contract this should follow.
|
|
||||||
|
|
||||||
Partner API limits are per `partnerId`, by tier, with the tier set on `PartnerProfile`. Published in the partner OpenAPI spec — a partner must be able to read its own limit rather than discover it by getting `429`.
|
|
||||||
|
|
||||||
## 6. Step-up authentication
|
|
||||||
|
|
||||||
Required before: bank/payment detail changes (Phase 5 §5), production launch (Phase 9 §3 step 8), role grants at `PLATFORM_OWNER`/`MARKETPLACE_ADMIN` level, and any manual financial override (refund approval outside normal flow, price override on a live order).
|
|
||||||
|
|
||||||
## 7. PII minimization
|
|
||||||
|
|
||||||
Customer/seller PII is exposed only to roles that need it for their scope (e.g. `FINANCE_VIEWER` sees payout totals, not raw bank account numbers unless `FINANCE_MANAGER`+). Export endpoints (`GET .../export`) are themselves audit-logged actions per §3.
|
|
||||||
|
|
||||||
## 8. Initial admin provisioning & self-service admin management
|
|
||||||
|
|
||||||
Each marketplace ships with one bootstrap `MARKETPLACE_ADMIN` account, seeded at provisioning time (Phase 9 launch step):
|
|
||||||
|
|
||||||
- `login` = marketplace slug (`projectName`)
|
|
||||||
- `password` = `{projectName}2026$`, flagged `mustChangePassword: true`
|
|
||||||
- Login succeeds but every non-auth request 403s with `PASSWORD_CHANGE_REQUIRED` until password is changed.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
|
|
||||||
```
|
|
||||||
|
|
||||||
A `MARKETPLACE_ADMIN` can then provision sub-admins scoped to their own marketplace only — mirrors the seller-team invite pattern in [Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md) (`POST /api/seller/v1/team/invite`):
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/admin/v2/team/invite { email, role: MarketplaceRole, marketplaceId }
|
|
||||||
GET /api/admin/v2/team?marketplaceId=
|
|
||||||
PATCH /api/admin/v2/team/{userId} { role }
|
|
||||||
DELETE /api/admin/v2/team/{userId}
|
|
||||||
```
|
|
||||||
|
|
||||||
Invariants:
|
|
||||||
- `role` must be one of the `MarketplaceRole` set (§1) — never `PlatformRole`. Backend rejects any attempt to grant a platform-scope role through this endpoint (`403 SCOPE_ESCALATION_DENIED`).
|
|
||||||
- `marketplaceId` is forced server-side to the caller's own tenant scope — request body value is ignored/validated, never trusted.
|
|
||||||
- Every invite/role-change/removal is an audit-logged action (§3, `action: 'admin_team.invited' | 'admin_team.role_changed' | 'admin_team.removed'`).
|
|
||||||
- Role grants at `MARKETPLACE_ADMIN` level require step-up auth (§6).
|
|
||||||
- Invited admins get their own credentials (email + set-password flow), not the shared bootstrap login — the bootstrap account is for first login only and should be rotated/retired once real admins exist.
|
|
||||||
|
|
||||||
## 9. What the frontend will start doing once this ships
|
|
||||||
|
|
||||||
- Route guards and action-level permission checks across the entire backoffice — currently none exist.
|
|
||||||
- Backoffice **Audit & Security** section (missing from admin nav today): role changes, sensitive actions, login/security events, exports.
|
|
||||||
- Reconcile `AdminRole` (already de-duplicated to one canonical type this session) against the real 17-role table from §1.
|
|
||||||
- 429 interceptor + retry-after UI.
|
|
||||||
@@ -11,9 +11,9 @@ tags: [architecture, auth, payment, monorepo]
|
|||||||
|
|
||||||
## Context
|
## 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/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) and [Phase 7](../../backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) contracts — the frontend piece is thin (gateways/tokens, no business logic).
|
`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 [TRACK-S-SECURITY-RBAC-CONTRACT.md](../../backend/TRACK-S-SECURITY-RBAC-CONTRACT.md), which already treats auth/RBAC as the single most serious cross-cutting concern.
|
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
|
## Decision
|
||||||
|
|
||||||
@@ -43,4 +43,4 @@ The Verdaccio instance still runs but nothing depends on it. Making a registry t
|
|||||||
- `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.
|
- `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.
|
- ~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.
|
- 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.
|
||||||
- [TRACK-S-SECURITY-RBAC-CONTRACT.md](../../backend/TRACK-S-SECURITY-RBAC-CONTRACT.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.
|
- [../../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.
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ Their request arrived written in their own vocabulary. Building against that voc
|
|||||||
|
|
||||||
Three facts about our current model made the ask non-trivial:
|
Three facts about our current model made the ask non-trivial:
|
||||||
|
|
||||||
1. Nothing exists above `Marketplace` ([Phase 9](../../backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)). No company, no project.
|
1. Nothing exists above `Marketplace` ([Phase 9](../../backend/BACKEND-INTEGRATION.md)). No company, no project.
|
||||||
2. Payments carry no store dimension ([Phase 1](../../backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §6). Reconciliation can reconstruct *why* an amount was charged but not *who for*.
|
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/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md) is outbound/ingest — the opposite direction.
|
3. We have no partner-facing write API at all. [Phase 4](../../backend/BACKEND-INTEGRATION.md) is outbound/ingest — the opposite direction.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
Build one generic partner provisioning API. Contract: [PARTNER-PROVISIONING-API-CONTRACT.md](../../backend/PARTNER-PROVISIONING-API-CONTRACT.md).
|
Build one generic partner provisioning API. Contract: [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md).
|
||||||
|
|
||||||
### 1. Partner-specific behaviour is config, never schema
|
### 1. Partner-specific behaviour is config, never schema
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ The partner asked us to choose between per-company, per-project, and per-store c
|
|||||||
|
|
||||||
### 5. Seller is excluded from the hierarchy
|
### 5. Seller is excluded from the hierarchy
|
||||||
|
|
||||||
`Seller` ([Phase 5](../../backend/PHASE-5-SELLER-PORTAL-CONTRACT.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/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) §3.1). Putting `Seller` in the partner hierarchy would force every partner to model our multi-seller concept, which most do not have.
|
`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
|
### 6. RoutingContext lands in Phase 1 before implementation, not after
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -11,3 +11,4 @@
|
|||||||
{"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-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-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-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"]}
|
||||||
|
|||||||
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.
|
||||||
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.
|
||||||
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'),
|
dir: require('path').join(__dirname, 'coverage'),
|
||||||
subdir: '.',
|
subdir: '.',
|
||||||
reporters: [{ type: 'text-summary' }, { type: 'html' }, { type: 'lcovonly' }],
|
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,
|
restartOnFileChange: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,9 +30,7 @@
|
|||||||
{
|
{
|
||||||
"name": "api-cache",
|
"name": "api-cache",
|
||||||
"urls": [
|
"urls": [
|
||||||
"/api/**",
|
"/api/**"
|
||||||
"https://api.dexarmarket.ru:445/**",
|
|
||||||
"https://api.novo.market:444/**"
|
|
||||||
],
|
],
|
||||||
"cacheConfig": {
|
"cacheConfig": {
|
||||||
"maxSize": 100,
|
"maxSize": 100,
|
||||||
|
|||||||
361
package-lock.json
generated
361
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"@angular/router": "22.0.8",
|
"@angular/router": "22.0.8",
|
||||||
"@angular/service-worker": "22.0.8",
|
"@angular/service-worker": "22.0.8",
|
||||||
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth",
|
"@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",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.8.0",
|
"tslib": "^2.8.0",
|
||||||
"zone.js": "~0.16.0"
|
"zone.js": "~0.16.0"
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@angular/build": "22.0.8",
|
"@angular/build": "22.0.8",
|
||||||
"@angular/cli": "22.0.8",
|
"@angular/cli": "22.0.8",
|
||||||
"@angular/compiler-cli": "22.0.8",
|
"@angular/compiler-cli": "22.0.8",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"barry-cache": "^0.9.3",
|
"barry-cache": "^0.9.3",
|
||||||
"istanbul-lib-instrument": "^6.0.3",
|
"istanbul-lib-instrument": "^6.0.3",
|
||||||
@@ -1807,16 +1809,35 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@marketplaces/auth": {
|
"node_modules/@marketplaces/auth": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"resolved": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#93f99cc7b19f88112337e7a6544c1c09d9904744",
|
"resolved": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#e8052159e97a167f4c3d8bb056013b730feb8aee",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/common": ">=22.0.0",
|
"@angular/common": ">=22.0.0",
|
||||||
"@angular/core": ">=22.0.0",
|
"@angular/core": ">=22.0.0",
|
||||||
|
"@angular/forms": ">=22.0.0",
|
||||||
"@angular/router": ">=22.0.0",
|
"@angular/router": ">=22.0.0",
|
||||||
"rxjs": ">=7.8.0"
|
"rxjs": ">=7.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@marketplaces/payment": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#61b000f43f7d4630f6dcb6ac534cc1f2d3aa6f72",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": ">=22.0.0",
|
||||||
|
"@angular/core": ">=22.0.0",
|
||||||
|
"rxjs": ">=7.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2833,6 +2854,22 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.60.2",
|
"version": "4.60.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
|
||||||
@@ -3836,6 +3873,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001760",
|
"version": "1.0.30001760",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -3987,7 +4033,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -3998,7 +4043,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/color-name": {
|
"node_modules/color-name": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
@@ -4198,6 +4242,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4229,6 +4282,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-serialize": {
|
"node_modules/dom-serialize": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4723,6 +4782,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4825,7 +4897,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/get-caller-file": {
|
"node_modules/get-caller-file": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -5898,6 +5969,18 @@
|
|||||||
"@lmdb/lmdb-win32-x64": "3.5.4"
|
"@lmdb/lmdb-win32-x64": "3.5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6612,6 +6695,33 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/p-map": {
|
"node_modules/p-map": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6623,6 +6733,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pacote": {
|
"node_modules/pacote": {
|
||||||
"version": "21.5.1",
|
"version": "21.5.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6716,6 +6835,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6804,6 +6932,62 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.23",
|
"version": "8.5.23",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6894,6 +7078,154 @@
|
|||||||
"node": ">=0.9"
|
"node": ">=0.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.14.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6949,7 +7281,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6963,6 +7294,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/requires-port": {
|
"node_modules/requires-port": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7249,6 +7586,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8030,6 +8373,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -14,15 +14,18 @@
|
|||||||
"arch:check:boundaries": "node tools/architecture/check-boundaries.mjs",
|
"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: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",
|
"arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles",
|
||||||
|
"scan:bundle": "bash scripts/ci/scan-bundle.sh",
|
||||||
"barry": "barry-cache",
|
"barry": "barry-cache",
|
||||||
"barry:validate": "barry-cache validate",
|
"barry:validate": "barry-cache validate",
|
||||||
"barry:resume": "barry-cache resume",
|
"barry:resume": "barry-cache resume",
|
||||||
"barry:finalize": "barry-cache finalize",
|
"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,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth",
|
|
||||||
"@angular/animations": "22.0.8",
|
"@angular/animations": "22.0.8",
|
||||||
"@angular/cdk": "22.0.6",
|
"@angular/cdk": "22.0.6",
|
||||||
"@angular/common": "22.0.8",
|
"@angular/common": "22.0.8",
|
||||||
@@ -32,6 +35,8 @@
|
|||||||
"@angular/platform-browser": "22.0.8",
|
"@angular/platform-browser": "22.0.8",
|
||||||
"@angular/router": "22.0.8",
|
"@angular/router": "22.0.8",
|
||||||
"@angular/service-worker": "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",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.8.0",
|
"tslib": "^2.8.0",
|
||||||
"zone.js": "~0.16.0"
|
"zone.js": "~0.16.0"
|
||||||
@@ -40,6 +45,7 @@
|
|||||||
"@angular/build": "22.0.8",
|
"@angular/build": "22.0.8",
|
||||||
"@angular/cli": "22.0.8",
|
"@angular/cli": "22.0.8",
|
||||||
"@angular/compiler-cli": "22.0.8",
|
"@angular/compiler-cli": "22.0.8",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"barry-cache": "^0.9.3",
|
"barry-cache": "^0.9.3",
|
||||||
"istanbul-lib-instrument": "^6.0.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,
|
||||||
|
},
|
||||||
|
});
|
||||||
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)"
|
||||||
@@ -115,6 +115,24 @@ certbot --nginx "${CERT_ARGS[@]}" \
|
|||||||
nginx -t
|
nginx -t
|
||||||
systemctl reload nginx
|
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"
|
echo "==> renewal timer"
|
||||||
systemctl enable --now certbot.timer
|
systemctl enable --now certbot.timer
|
||||||
systemctl status certbot.timer --no-pager | head -3 || true
|
systemctl status certbot.timer --no-pager | head -3 || true
|
||||||
|
|||||||
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"
|
||||||
@@ -128,13 +128,106 @@ ufw allow 80/tcp >/dev/null
|
|||||||
ufw allow 443/tcp >/dev/null
|
ufw allow 443/tcp >/dev/null
|
||||||
ufw --force enable >/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"
|
echo "==> nginx config test"
|
||||||
nginx -t
|
nginx -t
|
||||||
systemctl enable --now nginx
|
systemctl enable --now nginx
|
||||||
systemctl reload nginx
|
systemctl reload nginx
|
||||||
|
|
||||||
echo "==> dynamic domain reconciler"
|
|
||||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
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"
|
install -d -m 755 "$BASE/bin" /etc/marketplaces "/var/lib/marketplaces"
|
||||||
if [[ -f "$SRC_DIR/sync-domains.sh" ]]; then
|
if [[ -f "$SRC_DIR/sync-domains.sh" ]]; then
|
||||||
install -m 755 "$SRC_DIR/sync-domains.sh" "$BASE/bin/sync-domains.sh"
|
install -m 755 "$SRC_DIR/sync-domains.sh" "$BASE/bin/sync-domains.sh"
|
||||||
@@ -172,9 +265,10 @@ else
|
|||||||
echo " sync-domains.sh not found next to this script - skipping"
|
echo " sync-domains.sh not found next to this script - skipping"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> sudoers: let the deploy user reload nginx, nothing else"
|
echo "==> sudoers: deployment reload plus validated tenant API provisioning"
|
||||||
cat > /etc/sudoers.d/marketplaces-deploy <<SUDO
|
cat > /etc/sudoers.d/marketplaces-deploy <<SUDO
|
||||||
$DEPLOY_USER ALL=(root) NOPASSWD: /bin/systemctl reload nginx
|
Cmnd_Alias MARKETPLACES_DEPLOY = /bin/systemctl reload nginx, /usr/local/sbin/marketplaces-configure-api-domain *
|
||||||
|
$DEPLOY_USER ALL=(root) NOPASSWD: MARKETPLACES_DEPLOY
|
||||||
SUDO
|
SUDO
|
||||||
chmod 440 /etc/sudoers.d/marketplaces-deploy
|
chmod 440 /etc/sudoers.d/marketplaces-deploy
|
||||||
visudo -c -f /etc/sudoers.d/marketplaces-deploy
|
visudo -c -f /etc/sudoers.d/marketplaces-deploy
|
||||||
|
|||||||
@@ -4,14 +4,19 @@ import { provideHttpClient, withInterceptors, withXhr } from '@angular/common/ht
|
|||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { cacheInterceptor } from './interceptors/cache.interceptor';
|
import { cacheInterceptor } from './interceptors/cache.interceptor';
|
||||||
|
import { apiErrorInterceptor } from './core/interceptors/api-error.interceptor';
|
||||||
import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor';
|
import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor';
|
||||||
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
|
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
|
||||||
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
|
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
|
||||||
import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519VerificationService, AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth';
|
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 { provideServiceWorker } from '@angular/service-worker';
|
||||||
import { MediaRepository } from './core/media/media-repository';
|
import { MediaRepository } from './core/media/media-repository';
|
||||||
import { MockMediaRepository } from './core/media/mock-media-repository.service';
|
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 { environment } from '../environments/environment';
|
||||||
|
import { MOCK_GATEWAY_PROVIDERS } from './mock-gateway.providers';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -22,15 +27,55 @@ export const appConfig: ApplicationConfig = {
|
|||||||
withInMemoryScrolling({ scrollPositionRestoration: 'top' })
|
withInMemoryScrolling({ scrollPositionRestoration: 'top' })
|
||||||
),
|
),
|
||||||
provideHttpClient(withXhr(),
|
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: AUTH_API_URL, useValue: environment.authApiUrl },
|
{
|
||||||
|
provide: AUTH_API_URL,
|
||||||
|
useFactory: (apiConfig: ApiConfigService) => apiConfig.getBaseUrl(),
|
||||||
|
deps: [ApiConfigService]
|
||||||
|
},
|
||||||
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
|
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
|
||||||
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService },
|
// 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 },
|
{ 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', {
|
provideServiceWorker('ngsw-worker.js', {
|
||||||
enabled: !isDevMode(),
|
enabled: !isDevMode(),
|
||||||
registrationStrategy: 'registerWhenStable:30000'
|
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>
|
<p>{{ 'app.serverError' | translate }}</p>
|
||||||
<button class="retry-btn" (click)="retryConnection()">{{ 'app.retryConnection' | translate }}</button>
|
<button class="retry-btn" (click)="retryConnection()">{{ 'app.retryConnection' | translate }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (isAdminHost && !isAdminRoute()) {
|
||||||
|
<app-telegram-login mode="admin" />
|
||||||
} @else if (isAdminRoute()) {
|
} @else if (isAdminRoute()) {
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
<app-telegram-login mode="admin" />
|
<app-telegram-login mode="admin" />
|
||||||
|
|||||||
@@ -228,6 +228,15 @@ const coreRoutes: Routes = [
|
|||||||
breadcrumb: [{ labelKey: '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',
|
path: 'audit',
|
||||||
loadComponent: () => import('./features/admin/audit/pages/admin-audit-page.component').then(m => m.AdminAuditPageComponent),
|
loadComponent: () => import('./features/admin/audit/pages/admin-audit-page.component').then(m => m.AdminAuditPageComponent),
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { TelegramLoginComponent } from './components/telegram-login/telegram-log
|
|||||||
})
|
})
|
||||||
export class App implements OnInit {
|
export class App implements OnInit {
|
||||||
protected title = '';
|
protected title = '';
|
||||||
|
readonly isAdminHost = typeof window !== 'undefined'
|
||||||
|
&& window.location.hostname.toLowerCase().startsWith('admin.');
|
||||||
isHomePage = signal(true);
|
isHomePage = signal(true);
|
||||||
isAdminRoute = signal(false);
|
isAdminRoute = signal(false);
|
||||||
checkingServer = signal(true);
|
checkingServer = signal(true);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.vk-id-login {
|
.social-login-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -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" />
|
<app-icon name="lock" [size]="40" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>{{ 'auth.loginRequired' | translate }}</h2>
|
<h2>{{ (mode === 'admin' ? 'auth.adminLoginRequired' : 'auth.loginRequired') | translate }}</h2>
|
||||||
<p class="login-desc">{{ 'auth.loginDescription' | translate }}</p>
|
<p class="login-desc">{{ (mode === 'admin' ? 'auth.adminLoginDescription' : 'auth.loginDescription') | translate }}</p>
|
||||||
|
|
||||||
@if (status() === 'checking') {
|
@if (status() === 'checking') {
|
||||||
<div class="login-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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
{{ 'auth.loginWithTelegram' | translate }}
|
{{ (mode === 'admin' ? 'auth.adminLoginWithTelegram' : 'auth.loginWithTelegram') | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- @if (loginUrl()) {
|
<!-- @if (loginUrl()) {
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
|
<p class="login-note">{{ (mode === 'admin' ? 'auth.adminLoginNote' : 'auth.loginNote') | translate }}</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<button type="button" class="vk-id-login" [disabled]="loading()" (click)="startLogin()">
|
|
||||||
<app-icon name="user" [size]="18" />
|
|
||||||
<span>Continue with VK ID</span>
|
|
||||||
</button>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { take } from 'rxjs/operators';
|
|
||||||
import { VK_ID_GATEWAY } from '../../core/identity/services/vk-id-gateway.token';
|
|
||||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standalone VK ID login button, per Sprint 0.1 ("do all after vk" - VK ID
|
|
||||||
* is the primary storefront social login going forward, per v3.1 §14).
|
|
||||||
* Deliberately not wired into TelegramLoginComponent's dialog yet - that
|
|
||||||
* component is the live, working customer/admin login surface, and
|
|
||||||
* splicing a second provider into it needs its own careful pass once a
|
|
||||||
* real VK OAuth app exists to test against, not a mock-backed bolt-on.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-vk-id-login',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, IconComponent],
|
|
||||||
templateUrl: './vk-id-login.component.html',
|
|
||||||
styleUrls: ['./vk-id-login.component.scss'],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
|
||||||
})
|
|
||||||
export class VkIdLoginComponent {
|
|
||||||
private readonly gateway = inject(VK_ID_GATEWAY);
|
|
||||||
|
|
||||||
readonly loading = signal(false);
|
|
||||||
|
|
||||||
startLogin(): void {
|
|
||||||
this.loading.set(true);
|
|
||||||
this.gateway.getAuthorizeUrl().pipe(take(1)).subscribe(url => {
|
|
||||||
this.loading.set(false);
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.location.href = url;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { InjectionToken, inject } from '@angular/core';
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
import { AnalyticsGateway } from './analytics-gateway.interface';
|
import { AnalyticsGateway } from './analytics-gateway.interface';
|
||||||
import { AnalyticsLocalGateway } from './analytics-local.gateway';
|
import { AnalyticsApiGateway } from './analytics-api.gateway';
|
||||||
|
|
||||||
/** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
|
/** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
|
||||||
export const ANALYTICS_GATEWAY = new InjectionToken<AnalyticsGateway>('ANALYTICS_GATEWAY', {
|
export const ANALYTICS_GATEWAY = new InjectionToken<AnalyticsGateway>('ANALYTICS_GATEWAY', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
factory: () => inject(AnalyticsLocalGateway),
|
factory: () => inject(AnalyticsApiGateway),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { Observable } from 'rxjs';
|
||||||
import { BootstrapConfig } from '../../../shared/models/config';
|
import { BootstrapConfig } from '../../../shared/models/config';
|
||||||
import { ConfigProvider } from '../../config/config-provider.interface';
|
import { ConfigProvider } from '../../config/config-provider.interface';
|
||||||
|
import { ApiConfigService } from '../../config/api-config.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ApiBootstrapProvider implements ConfigProvider {
|
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> {
|
loadBootstrap(): Observable<BootstrapConfig> {
|
||||||
return this.http.get<BootstrapConfig>(this.bootstrapUrl);
|
return this.http.get<BootstrapConfig>(`${this.apiConfig.getBaseUrl()}/bootstrap`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { InjectionToken, inject } from '@angular/core';
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
import { ServerCartGateway } from './server-cart-gateway.interface';
|
import { ServerCartGateway } from './server-cart-gateway.interface';
|
||||||
import { ServerCartLocalGateway } from './server-cart-local.gateway';
|
import { ServerCartApiGateway } from './server-cart-api.gateway';
|
||||||
|
|
||||||
/** Swap point for docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
|
/** Swap point for docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
|
||||||
export const SERVER_CART_GATEWAY = new InjectionToken<ServerCartGateway>('SERVER_CART_GATEWAY', {
|
export const SERVER_CART_GATEWAY = new InjectionToken<ServerCartGateway>('SERVER_CART_GATEWAY', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
factory: () => inject(ServerCartLocalGateway),
|
factory: () => inject(ServerCartApiGateway),
|
||||||
});
|
});
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { ConfigService } from './config.service';
|
|
||||||
import { TenantResolverService } from './tenant-resolver.service';
|
import { TenantResolverService } from './tenant-resolver.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ApiConfigService {
|
export class ApiConfigService {
|
||||||
private readonly tenantResolver = inject(TenantResolverService);
|
private readonly tenantResolver = inject(TenantResolverService);
|
||||||
private readonly configService = inject(ConfigService);
|
|
||||||
|
|
||||||
getBaseUrl(): string {
|
getBaseUrl(): string {
|
||||||
|
const hostname = this.tenantResolver.getHostname();
|
||||||
|
const baseDomain = this.tenantResolver.getBaseDomain();
|
||||||
|
const protocol = this.tenantResolver.getProtocol();
|
||||||
const tenantKey = this.tenantResolver.getTenantKey();
|
const tenantKey = this.tenantResolver.getTenantKey();
|
||||||
const bootstrapUrl = this.resolveBootstrapApiBaseUrl();
|
|
||||||
const tenantMap = (environment as any).tenantApiBaseUrls as Record<string, string> | undefined;
|
const tenantMap = (environment as any).tenantApiBaseUrls as Record<string, string> | undefined;
|
||||||
const localhostUrl = (environment as any).localhostApiUrl as string | undefined;
|
const localhostUrl = (environment as any).localhostApiUrl as string | undefined;
|
||||||
const apiTemplate = (environment as any).tenantApiTemplate as string | undefined;
|
const apiTemplate = (environment as any).tenantApiTemplate as string | undefined;
|
||||||
@@ -19,13 +19,16 @@ export class ApiConfigService {
|
|||||||
|
|
||||||
if (this.tenantResolver.isLocalhost() && localhostUrl) {
|
if (this.tenantResolver.isLocalhost() && localhostUrl) {
|
||||||
url = localhostUrl;
|
url = localhostUrl;
|
||||||
|
} else if (tenantMap?.[hostname]) {
|
||||||
|
url = tenantMap[hostname];
|
||||||
} else if (tenantMap?.[tenantKey]) {
|
} else if (tenantMap?.[tenantKey]) {
|
||||||
url = tenantMap[tenantKey];
|
url = tenantMap[tenantKey];
|
||||||
} else if (apiTemplate) {
|
} else if (apiTemplate && hostname) {
|
||||||
url = apiTemplate.replace('{tenant}', tenantKey);
|
url = apiTemplate
|
||||||
} else if (bootstrapUrl) {
|
.replace('{protocol}', protocol)
|
||||||
// Bootstrap API override is opt-in and only for absolute URLs.
|
.replace('{baseDomain}', baseDomain)
|
||||||
url = bootstrapUrl;
|
.replace('{hostname}', hostname)
|
||||||
|
.replace('{tenant}', tenantKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.normalizeBaseUrl(url);
|
return this.normalizeBaseUrl(url);
|
||||||
@@ -54,36 +57,15 @@ export class ApiConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = this.getBaseUrl();
|
const baseUrl = this.getBaseUrl();
|
||||||
const path = url.slice('/api'.length);
|
if (baseUrl === '/') {
|
||||||
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveBootstrapApiBaseUrl(): string | null {
|
if (baseUrl === '/api' || baseUrl.endsWith('/api')) {
|
||||||
const allowBootstrapApiOverride = (environment as any).allowBootstrapApiOverride === true;
|
return `${baseUrl}${url.slice('/api'.length)}`;
|
||||||
if (!allowBootstrapApiOverride) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootstrap = this.configService.getBootstrapSnapshot() as any;
|
return `${baseUrl}${url}`;
|
||||||
if (!bootstrap) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpointBase = bootstrap?.apiEndpoints?.website?.baseUrl;
|
|
||||||
if (typeof endpointBase === 'string' && this.isAbsoluteHttpUrl(endpointBase)) {
|
|
||||||
return endpointBase;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenantBase = bootstrap?.tenant?.apiBaseUrl;
|
|
||||||
if (typeof tenantBase === 'string' && this.isAbsoluteHttpUrl(tenantBase)) {
|
|
||||||
return tenantBase;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isAbsoluteHttpUrl(url: string): boolean {
|
|
||||||
return /^https?:\/\//i.test(url.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeBaseUrl(url: string): string {
|
private normalizeBaseUrl(url: string): string {
|
||||||
|
|||||||
57
src/app/core/config/config.service.spec.ts
Normal file
57
src/app/core/config/config.service.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { Observable, of, throwError } from 'rxjs';
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
import { catchError, shareReplay, tap } from 'rxjs/operators';
|
import { catchError, map, shareReplay, tap } from 'rxjs/operators';
|
||||||
import { BootstrapConfig } from '../../shared/models/config';
|
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
|
||||||
import { CONFIG_PROVIDER } from './config-provider.token';
|
import { CONFIG_PROVIDER } from './config-provider.token';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -21,6 +21,7 @@ export class ConfigService {
|
|||||||
|
|
||||||
if (!this.bootstrap$ || forceRefresh) {
|
if (!this.bootstrap$ || forceRefresh) {
|
||||||
this.bootstrap$ = this.provider.loadBootstrap().pipe(
|
this.bootstrap$ = this.provider.loadBootstrap().pipe(
|
||||||
|
map(config => (config.published === false ? DEFAULT_BOOTSTRAP : config)),
|
||||||
tap(config => {
|
tap(config => {
|
||||||
this.bootstrapSnapshot = config;
|
this.bootstrapSnapshot = config;
|
||||||
this.revisionState.update(value => value + 1);
|
this.revisionState.update(value => value + 1);
|
||||||
|
|||||||
33
src/app/core/config/tenant-resolver.service.spec.ts
Normal file
33
src/app/core/config/tenant-resolver.service.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { TenantResolverService } from './tenant-resolver.service';
|
||||||
|
|
||||||
|
describe('TenantResolverService', () => {
|
||||||
|
function resolveBaseDomain(hostname: string): string {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
TenantResolverService,
|
||||||
|
{
|
||||||
|
provide: DOCUMENT,
|
||||||
|
useValue: { location: { hostname, protocol: 'https:' } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return TestBed.inject(TenantResolverService).getBaseDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps a root storefront domain', () => {
|
||||||
|
expect(resolveBaseDomain('example.com')).toBe('example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes tenant and www subdomains from the API domain', () => {
|
||||||
|
expect(resolveBaseDomain('store1.example.com')).toBe('example.com');
|
||||||
|
expect(resolveBaseDomain('www.example.com')).toBe('example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a country-code second-level domain', () => {
|
||||||
|
expect(resolveBaseDomain('store1.example.co.uk')).toBe('example.co.uk');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,24 @@ export class TenantResolverService {
|
|||||||
return host.toLowerCase();
|
return host.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProtocol(): string {
|
||||||
|
return this.document?.location?.protocol ?? 'https:';
|
||||||
|
}
|
||||||
|
|
||||||
|
getBaseDomain(): string {
|
||||||
|
const segments = this.getHostname().split('.').filter(Boolean);
|
||||||
|
if (segments.length <= 2) {
|
||||||
|
return segments.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const topLevelDomain = segments.at(-1) ?? '';
|
||||||
|
const secondLevelDomain = segments.at(-2) ?? '';
|
||||||
|
const usesCountryCodeSecondLevel =
|
||||||
|
topLevelDomain.length === 2 && secondLevelDomain.length <= 3;
|
||||||
|
|
||||||
|
return segments.slice(usesCountryCodeSecondLevel ? -3 : -2).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
isLocalhost(): boolean {
|
isLocalhost(): boolean {
|
||||||
const hostname = this.getHostname();
|
const hostname = this.getHostname();
|
||||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Floor, Lead, RentListing, SchemePin, Shop, ShopCategory } from '../models/mall-content.model';
|
||||||
|
import { MallContentGateway } from './mall-content-gateway.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract: docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md §2.
|
||||||
|
* Only the lead-submission and settings endpoints are spelled out explicitly
|
||||||
|
* (§ "PATCH /api/admin/v2/content/mall-settings", "POST .../rent-listings/{id}/leads");
|
||||||
|
* the read endpoints below follow that same /api/admin/v2/content/ namespace.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MallContentApiGateway implements MallContentGateway {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
loadShops(): Observable<Shop[]> {
|
||||||
|
return this.http.get<Shop[]>('/api/admin/v2/content/shops');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadShopCategories(): Observable<ShopCategory[]> {
|
||||||
|
return this.http.get<ShopCategory[]>('/api/admin/v2/content/shop-categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFloors(): Observable<Floor[]> {
|
||||||
|
return this.http.get<Floor[]>('/api/admin/v2/content/floors');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSchemePins(floorId: string): Observable<SchemePin[]> {
|
||||||
|
return this.http.get<SchemePin[]>(
|
||||||
|
`/api/admin/v2/content/floors/${encodeURIComponent(floorId)}/pins`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRentListings(): Observable<RentListing[]> {
|
||||||
|
return this.http.get<RentListing[]>('/api/admin/v2/content/rent-listings');
|
||||||
|
}
|
||||||
|
|
||||||
|
submitLead(lead: Omit<Lead, 'id' | 'createdAt'>): Observable<Lead> {
|
||||||
|
const listingId = lead.rentListingId ?? '';
|
||||||
|
return this.http.post<Lead>(
|
||||||
|
`/api/admin/v2/content/rent-listings/${encodeURIComponent(listingId)}/leads`,
|
||||||
|
lead,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { InjectionToken, inject } from '@angular/core';
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
import { MallContentGateway } from './mall-content-gateway.interface';
|
import { MallContentGateway } from './mall-content-gateway.interface';
|
||||||
import { MallContentLocalGateway } from './mall-content-local.gateway';
|
import { MallContentApiGateway } from './mall-content-api.gateway';
|
||||||
|
|
||||||
/** Swap point for docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md. */
|
/** Swap point for docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md. */
|
||||||
export const MALL_CONTENT_GATEWAY = new InjectionToken<MallContentGateway>('MALL_CONTENT_GATEWAY', {
|
export const MALL_CONTENT_GATEWAY = new InjectionToken<MallContentGateway>('MALL_CONTENT_GATEWAY', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
factory: () => inject(MallContentLocalGateway),
|
factory: () => inject(MallContentApiGateway),
|
||||||
});
|
});
|
||||||
|
|||||||
136
src/app/core/error-handling/api-error.mapper.spec.ts
Normal file
136
src/app/core/error-handling/api-error.mapper.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
||||||
|
import { toApiError } from './api-error.mapper';
|
||||||
|
import { ApiErrorCode } from './models/api-error.model';
|
||||||
|
|
||||||
|
function errorResponse(init: {
|
||||||
|
status?: number;
|
||||||
|
body?: unknown;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
url?: string;
|
||||||
|
}): HttpErrorResponse {
|
||||||
|
return new HttpErrorResponse({
|
||||||
|
status: init.status ?? 500,
|
||||||
|
error: init.body,
|
||||||
|
headers: new HttpHeaders(init.headers ?? {}),
|
||||||
|
url: init.url ?? 'https://api.example.com/api/v2/offers',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('toApiError', () => {
|
||||||
|
it('reads a full envelope', () => {
|
||||||
|
// Arrange
|
||||||
|
const response = errorResponse({
|
||||||
|
status: 422,
|
||||||
|
body: {
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_FAILED',
|
||||||
|
message: 'One or more fields are invalid.',
|
||||||
|
status: 422,
|
||||||
|
requestId: 'b3f1c2a0',
|
||||||
|
details: [{ field: 'sku', code: 'REQUIRED', message: 'SKU is required.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const error = toApiError(response);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(error.code).toBe('VALIDATION_FAILED');
|
||||||
|
expect(error.status).toBe(422);
|
||||||
|
expect(error.requestId).toBe('b3f1c2a0');
|
||||||
|
expect(error.hasEnvelope).toBeTrue();
|
||||||
|
expect(error.details.length).toBe(1);
|
||||||
|
expect(error.details[0].field).toBe('sku');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an unwrapped envelope, because some gateways strip the wrapper', () => {
|
||||||
|
const error = toApiError(
|
||||||
|
errorResponse({ status: 409, body: { code: 'CONFLICT', message: 'Already exists.', status: 409 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe('CONFLICT');
|
||||||
|
expect(error.hasEnvelope).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('synthesizes a code when the body carries no envelope', () => {
|
||||||
|
const error = toApiError(errorResponse({ status: 404, body: '<html>Not Found</html>' }));
|
||||||
|
|
||||||
|
expect(error.code).toBe(ApiErrorCode.NOT_FOUND);
|
||||||
|
expect(error.hasEnvelope).toBeFalse();
|
||||||
|
expect(error.details).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a network failure to NETWORK_UNAVAILABLE', () => {
|
||||||
|
const error = toApiError(errorResponse({ status: 0, body: null }));
|
||||||
|
|
||||||
|
expect(error.code).toBe(ApiErrorCode.NETWORK_UNAVAILABLE);
|
||||||
|
expect(error.status).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the transport status over a self-reported one', () => {
|
||||||
|
// A proxy can return 502 while the body still claims 200-era metadata.
|
||||||
|
const error = toApiError(
|
||||||
|
errorResponse({ status: 502, body: { error: { code: 'UPSTREAM', message: 'x', status: 200 } } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.status).toBe(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads Retry-After given as seconds', () => {
|
||||||
|
const error = toApiError(
|
||||||
|
errorResponse({ status: 429, body: { error: { code: 'RATE_LIMITED', message: 'slow down', status: 429 } }, headers: { 'Retry-After': '17' } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.retryAfterSeconds).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads Retry-After given as an HTTP date', () => {
|
||||||
|
const future = new Date(Date.now() + 12_000).toUTCString();
|
||||||
|
|
||||||
|
const error = toApiError(errorResponse({ status: 429, headers: { 'Retry-After': future } }));
|
||||||
|
|
||||||
|
// Allow a second of slack for clock/rounding.
|
||||||
|
expect(error.retryAfterSeconds).toBeGreaterThanOrEqual(11);
|
||||||
|
expect(error.retryAfterSeconds).toBeLessThanOrEqual(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the body retryAfterSeconds when no header is present', () => {
|
||||||
|
const error = toApiError(
|
||||||
|
errorResponse({ status: 429, body: { error: { code: 'RATE_LIMITED', message: 'x', status: 429, retryAfterSeconds: 9 } } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.retryAfterSeconds).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults a 429 with no hint to a non-zero delay, so callers cannot busy-loop', () => {
|
||||||
|
const error = toApiError(errorResponse({ status: 429, body: null }));
|
||||||
|
|
||||||
|
expect(error.retryAfterSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never sets retryAfterSeconds for errors that are not throttled', () => {
|
||||||
|
const error = toApiError(errorResponse({ status: 500, body: null }));
|
||||||
|
|
||||||
|
expect(error.retryAfterSeconds).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops malformed detail entries rather than failing', () => {
|
||||||
|
const error = toApiError(
|
||||||
|
errorResponse({
|
||||||
|
status: 422,
|
||||||
|
body: { error: { code: 'VALIDATION_FAILED', message: 'x', status: 422, details: [{ nope: 1 }, 'string', { field: 'sku' }] } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.details.length).toBe(1);
|
||||||
|
expect(error.details[0].field).toBe('sku');
|
||||||
|
expect(error.details[0].code).toBe(ApiErrorCode.VALIDATION_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an envelope with a non-string code as absent', () => {
|
||||||
|
const error = toApiError(errorResponse({ status: 400, body: { error: { code: 42, message: 'x' } } }));
|
||||||
|
|
||||||
|
expect(error.hasEnvelope).toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
130
src/app/core/error-handling/api-error.mapper.ts
Normal file
130
src/app/core/error-handling/api-error.mapper.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
ApiErrorCode,
|
||||||
|
ApiErrorDetail,
|
||||||
|
ApiErrorEnvelope,
|
||||||
|
fallbackCodeForStatus,
|
||||||
|
} from './models/api-error.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn any HttpErrorResponse into a normalized ApiError.
|
||||||
|
* Contract: BACKEND-API-REFERENCE.md §5.
|
||||||
|
*
|
||||||
|
* Deliberately total: every input produces an ApiError. A backend that answers
|
||||||
|
* with HTML, an empty body, or a differently-shaped JSON object still yields a
|
||||||
|
* usable error rather than throwing inside the error path — a throw there
|
||||||
|
* replaces the real failure with a less informative one.
|
||||||
|
*/
|
||||||
|
export function toApiError(response: HttpErrorResponse): ApiError {
|
||||||
|
const status = response.status ?? 0;
|
||||||
|
const envelope = readEnvelope(response.error);
|
||||||
|
const retryAfterSeconds = readRetryAfter(response, envelope);
|
||||||
|
|
||||||
|
if (envelope) {
|
||||||
|
return {
|
||||||
|
code: envelope.code,
|
||||||
|
message: envelope.message,
|
||||||
|
// Trust the transport status over a self-reported one: a proxy or
|
||||||
|
// gateway can produce a status the application never set.
|
||||||
|
status: status || envelope.status,
|
||||||
|
requestId: envelope.requestId,
|
||||||
|
details: envelope.details ?? [],
|
||||||
|
hasEnvelope: true,
|
||||||
|
retryAfterSeconds,
|
||||||
|
url: response.url ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: fallbackCodeForStatus(status),
|
||||||
|
message: fallbackMessage(status, response),
|
||||||
|
status,
|
||||||
|
details: [],
|
||||||
|
hasEnvelope: false,
|
||||||
|
retryAfterSeconds,
|
||||||
|
url: response.url ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvelope(body: unknown): ApiErrorEnvelope | null {
|
||||||
|
const wrapper = asRecord(body);
|
||||||
|
if (!wrapper) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept both { error: {...} } and a bare {...}: some gateways unwrap the
|
||||||
|
// envelope, and rejecting that shape would lose a code we actually have.
|
||||||
|
const candidate = asRecord(wrapper['error']) ?? wrapper;
|
||||||
|
|
||||||
|
const code = candidate['code'];
|
||||||
|
const status = candidate['status'];
|
||||||
|
if (typeof code !== 'string' || code.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message: typeof candidate['message'] === 'string' ? candidate['message'] : code,
|
||||||
|
status: typeof status === 'number' ? status : 0,
|
||||||
|
requestId: typeof candidate['requestId'] === 'string' ? candidate['requestId'] : undefined,
|
||||||
|
details: readDetails(candidate['details']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDetails(value: unknown): readonly ApiErrorDetail[] | undefined {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const details = value
|
||||||
|
.map(asRecord)
|
||||||
|
.filter((entry): entry is Record<string, unknown> => entry !== null)
|
||||||
|
.filter(entry => typeof entry['field'] === 'string')
|
||||||
|
.map(entry => ({
|
||||||
|
field: entry['field'] as string,
|
||||||
|
code: typeof entry['code'] === 'string' ? entry['code'] : ApiErrorCode.VALIDATION_FAILED,
|
||||||
|
message: typeof entry['message'] === 'string' ? entry['message'] : '',
|
||||||
|
}));
|
||||||
|
return details.length > 0 ? details : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry-After per RFC 9110: either delta-seconds or an HTTP-date. The envelope
|
||||||
|
* may also carry retryAfterSeconds (Track S §5); the header wins when both are
|
||||||
|
* present, because it is what the edge actually enforces.
|
||||||
|
*/
|
||||||
|
function readRetryAfter(response: HttpErrorResponse, envelope: ApiErrorEnvelope | null): number | undefined {
|
||||||
|
const header = response.headers?.get('Retry-After');
|
||||||
|
if (header) {
|
||||||
|
const seconds = Number(header);
|
||||||
|
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||||
|
return Math.ceil(seconds);
|
||||||
|
}
|
||||||
|
const date = Date.parse(header);
|
||||||
|
if (!Number.isNaN(date)) {
|
||||||
|
return Math.max(0, Math.ceil((date - Date.now()) / 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromBody = asRecord(asRecord(response.error)?.['error'] ?? response.error)?.['retryAfterSeconds'];
|
||||||
|
if (typeof fromBody === 'number' && Number.isFinite(fromBody) && fromBody >= 0) {
|
||||||
|
return Math.ceil(fromBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 429 with no hint still needs a delay, or callers busy-loop the endpoint
|
||||||
|
// that just asked them to stop.
|
||||||
|
return envelope?.code === ApiErrorCode.RATE_LIMITED || response.status === 429 ? 5 : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackMessage(status: number, response: HttpErrorResponse): string {
|
||||||
|
if (status === 0) {
|
||||||
|
return 'Network unavailable.';
|
||||||
|
}
|
||||||
|
return response.message || `Request failed with status ${status}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
98
src/app/core/error-handling/models/api-error.model.ts
Normal file
98
src/app/core/error-handling/models/api-error.model.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Typed shape of the backend error envelope.
|
||||||
|
* Contract: BACKEND-API-REFERENCE.md §5.
|
||||||
|
*
|
||||||
|
* Callers branch on `code`, never on `message` — the message is an English
|
||||||
|
* fallback and is free to change without notice.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Per-field validation issue. Present only on 422. */
|
||||||
|
export interface ApiErrorDetail {
|
||||||
|
readonly field: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The envelope body as the backend sends it. */
|
||||||
|
export interface ApiErrorEnvelope {
|
||||||
|
readonly code: string;
|
||||||
|
readonly message: string;
|
||||||
|
readonly status: number;
|
||||||
|
readonly requestId?: string;
|
||||||
|
readonly details?: readonly ApiErrorDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized error every HTTP caller sees.
|
||||||
|
*
|
||||||
|
* A response that carries no envelope still arrives as an ApiError, with
|
||||||
|
* `code` synthesized from the HTTP status and `hasEnvelope: false`. That way
|
||||||
|
* no caller needs a second code path for "backend answered in a shape we did
|
||||||
|
* not expect" — the most common case in practice, and the one that silently
|
||||||
|
* breaks error UIs when it is not modelled.
|
||||||
|
*/
|
||||||
|
export interface ApiError {
|
||||||
|
readonly code: string;
|
||||||
|
readonly message: string;
|
||||||
|
readonly status: number;
|
||||||
|
readonly requestId?: string;
|
||||||
|
readonly details: readonly ApiErrorDetail[];
|
||||||
|
/** False when the code was synthesized from the HTTP status. */
|
||||||
|
readonly hasEnvelope: boolean;
|
||||||
|
/** Seconds to wait before retrying. Only set for 429 and 503. */
|
||||||
|
readonly retryAfterSeconds?: number;
|
||||||
|
/** The URL that failed, for logging. Never rendered to a user. */
|
||||||
|
readonly url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codes the app branches on. Extend as the backend defines more. */
|
||||||
|
export const ApiErrorCode = {
|
||||||
|
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
||||||
|
RATE_LIMITED: 'RATE_LIMITED',
|
||||||
|
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||||
|
FORBIDDEN: 'FORBIDDEN',
|
||||||
|
NOT_FOUND: 'NOT_FOUND',
|
||||||
|
CONFLICT: 'CONFLICT',
|
||||||
|
FX_SOURCE_UNAVAILABLE: 'FX_SOURCE_UNAVAILABLE',
|
||||||
|
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
||||||
|
NETWORK_UNAVAILABLE: 'NETWORK_UNAVAILABLE',
|
||||||
|
UNKNOWN: 'UNKNOWN',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ApiErrorCodeValue = (typeof ApiErrorCode)[keyof typeof ApiErrorCode];
|
||||||
|
|
||||||
|
/** Status → code, for responses that arrive without an envelope. */
|
||||||
|
const STATUS_CODE_FALLBACK: Readonly<Record<number, string>> = {
|
||||||
|
0: ApiErrorCode.NETWORK_UNAVAILABLE,
|
||||||
|
401: ApiErrorCode.UNAUTHORIZED,
|
||||||
|
403: ApiErrorCode.FORBIDDEN,
|
||||||
|
404: ApiErrorCode.NOT_FOUND,
|
||||||
|
409: ApiErrorCode.CONFLICT,
|
||||||
|
422: ApiErrorCode.VALIDATION_FAILED,
|
||||||
|
429: ApiErrorCode.RATE_LIMITED,
|
||||||
|
503: ApiErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fallbackCodeForStatus(status: number): string {
|
||||||
|
return STATUS_CODE_FALLBACK[status] ?? ApiErrorCode.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApiError(value: unknown): value is ApiError {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
typeof (value as ApiError).code === 'string' &&
|
||||||
|
typeof (value as ApiError).status === 'number' &&
|
||||||
|
typeof (value as ApiError).hasEnvelope === 'boolean'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when retrying the identical request could plausibly succeed. */
|
||||||
|
export function isRetryable(error: ApiError): boolean {
|
||||||
|
return (
|
||||||
|
error.status === 429 ||
|
||||||
|
error.status === 503 ||
|
||||||
|
error.status === 0 ||
|
||||||
|
error.status === 504
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/app/core/error-handling/rate-limit-notifier.service.ts
Normal file
57
src/app/core/error-handling/rate-limit-notifier.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable, computed, signal } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes rate limiting visible to the UI.
|
||||||
|
*
|
||||||
|
* Without this a 429 is indistinguishable from a generic failure: the user
|
||||||
|
* sees "something went wrong" while the correct message is "you are being
|
||||||
|
* throttled, this resumes in N seconds."
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RateLimitState {
|
||||||
|
readonly active: boolean;
|
||||||
|
readonly retryAfterSeconds: number;
|
||||||
|
readonly lastUrl?: string;
|
||||||
|
readonly since?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE: RateLimitState = { active: false, retryAfterSeconds: 0 };
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RateLimitNotifier {
|
||||||
|
private readonly state = signal<RateLimitState>(IDLE);
|
||||||
|
private clearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
readonly current = this.state.asReadonly();
|
||||||
|
readonly isRateLimited = computed(() => this.state().active);
|
||||||
|
readonly retryAfterSeconds = computed(() => this.state().retryAfterSeconds);
|
||||||
|
|
||||||
|
/** Called by the HTTP interceptor when a 429 is observed. */
|
||||||
|
report(retryAfterSeconds: number, url?: string): void {
|
||||||
|
const seconds = Math.max(0, Math.ceil(retryAfterSeconds));
|
||||||
|
|
||||||
|
this.state.set({
|
||||||
|
active: true,
|
||||||
|
retryAfterSeconds: seconds,
|
||||||
|
lastUrl: url,
|
||||||
|
since: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Self-clearing: a banner that outlives the throttle is worse than no
|
||||||
|
// banner, because it trains users to ignore it.
|
||||||
|
this.cancelTimer();
|
||||||
|
this.clearTimer = setTimeout(() => this.clear(), Math.max(1, seconds) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cancelTimer();
|
||||||
|
this.state.set(IDLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelTimer(): void {
|
||||||
|
if (this.clearTimer !== null) {
|
||||||
|
clearTimeout(this.clearTimer);
|
||||||
|
this.clearTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/app/core/finance/services/finance-api.gateway.ts
Normal file
40
src/app/core/finance/services/finance-api.gateway.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { ReconciliationRecord, Refund, Settlement } from '../models/reconciliation.model';
|
||||||
|
import { FinanceGateway } from './finance-gateway.interface';
|
||||||
|
|
||||||
|
/** Contract: docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md §1-3. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class FinanceApiGateway implements FinanceGateway {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
loadRefunds(orderId?: string): Observable<Refund[]> {
|
||||||
|
// §1: refunds are scoped to one order (POST/GET .../orders/{orderId}/refunds).
|
||||||
|
// Without an orderId there is no single-order endpoint to call; return
|
||||||
|
// empty rather than guess at a global refunds list the contract doesn't define.
|
||||||
|
if (!orderId) {
|
||||||
|
return new Observable<Refund[]>(subscriber => {
|
||||||
|
subscriber.next([]);
|
||||||
|
subscriber.complete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.http.get<Refund[]>(`/api/admin/v2/orders/${encodeURIComponent(orderId)}/refunds`);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadReconciliationQueue(): Observable<ReconciliationRecord[]> {
|
||||||
|
return this.http.get<ReconciliationRecord[]>('/api/admin/v2/reconciliation/queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveReconciliation(id: string, note: string): Observable<void> {
|
||||||
|
return this.http
|
||||||
|
.post(`/api/admin/v2/reconciliation/${encodeURIComponent(id)}/resolve`, { note })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettlements(sellerId?: string): Observable<Settlement[]> {
|
||||||
|
const params = sellerId ? new HttpParams().set('sellerId', sellerId) : undefined;
|
||||||
|
return this.http.get<Settlement[]>('/api/admin/v2/finance/settlements', { params });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { InjectionToken, inject } from '@angular/core';
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
import { FinanceGateway } from './finance-gateway.interface';
|
import { FinanceGateway } from './finance-gateway.interface';
|
||||||
import { FinanceLocalGateway } from './finance-local.gateway';
|
import { FinanceApiGateway } from './finance-api.gateway';
|
||||||
|
|
||||||
/** Swap point for docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md. */
|
/** Swap point for docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md. */
|
||||||
export const FINANCE_GATEWAY = new InjectionToken<FinanceGateway>('FINANCE_GATEWAY', {
|
export const FINANCE_GATEWAY = new InjectionToken<FinanceGateway>('FINANCE_GATEWAY', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
factory: () => inject(FinanceLocalGateway),
|
factory: () => inject(FinanceApiGateway),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ export interface Customer {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExternalIdentityProvider = 'vk_id' | 'telegram' | 'max';
|
export type ExternalIdentityProvider = 'vk_id' | 'yandex_id' | 'telegram' | 'max';
|
||||||
|
|
||||||
export interface ExternalIdentity {
|
export interface ExternalIdentity {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
provider: ExternalIdentityProvider;
|
provider: ExternalIdentityProvider;
|
||||||
providerUserId: string;
|
providerUserId: string;
|
||||||
|
/** Not every provider returns one - VK frequently does not. */
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
displayName?: string;
|
||||||
verifiedAt: string;
|
verifiedAt: string;
|
||||||
lastUsedAt: string;
|
lastUsedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { ExternalIdentity, ExternalIdentityProvider } from '../models/customer-identity.model';
|
||||||
|
import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract: docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2.
|
||||||
|
*
|
||||||
|
* Provider-agnostic by construction - VK ID and Yandex ID differ only in the
|
||||||
|
* path segment, because the differences that matter (PKCE, VK's device_id,
|
||||||
|
* Yandex's Basic-auth token exchange) live entirely on the backend.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SocialIdentityApiGateway implements SocialIdentityGateway {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly base = '/api/identity/v1';
|
||||||
|
|
||||||
|
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string> {
|
||||||
|
const params = returnTo ? new HttpParams().set('returnTo', returnTo) : undefined;
|
||||||
|
return this.http
|
||||||
|
.get<{ url: string }>(`${this.base}/${provider}/authorize`, { params })
|
||||||
|
.pipe(map(response => response.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
listIdentities(): Observable<ExternalIdentity[]> {
|
||||||
|
return this.http.get<ExternalIdentity[]>(`${this.base}/me/identities`);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink(provider: ExternalIdentityProvider): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.base}/${provider}/unlink`, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ExternalIdentity, ExternalIdentityProvider } from '../models/customer-identity.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Providers this surface can start an OAuth authorize redirect for.
|
||||||
|
*
|
||||||
|
* Narrower than ExternalIdentityProvider on purpose: Telegram and MAX link
|
||||||
|
* through a bot / QR flow owned by @marketplaces/auth, not an authorize
|
||||||
|
* redirect, so they can be listed and unlinked here (FH-4.6) but never
|
||||||
|
* passed to getAuthorizeUrl().
|
||||||
|
*/
|
||||||
|
export type SocialProvider = 'vk' | 'yandex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2.
|
||||||
|
*
|
||||||
|
* One surface, one strategy per provider behind it. The client's entire
|
||||||
|
* involvement is "send me somewhere" and "tell me what is linked" - the
|
||||||
|
* OAuth exchange happens backend-side and the browser never holds a client
|
||||||
|
* secret, an access token, or a PKCE code verifier.
|
||||||
|
*
|
||||||
|
* Note what is absent: there is no completeCallback(). An earlier VK-only
|
||||||
|
* version of this interface took (code, codeVerifier) from the client, which
|
||||||
|
* forced the browser to generate and store the verifier. We are a
|
||||||
|
* confidential client; the backend owns state and verifier, handles the
|
||||||
|
* provider's callback itself, and redirects back with a session already set.
|
||||||
|
*/
|
||||||
|
export interface SocialIdentityGateway {
|
||||||
|
/**
|
||||||
|
* URL to navigate the browser to in order to start the flow. The backend
|
||||||
|
* has already minted and stored the single-use state and code verifier by
|
||||||
|
* the time this resolves.
|
||||||
|
*
|
||||||
|
* @param returnTo where to land after the callback completes, validated
|
||||||
|
* backend-side against the tenant's own origin - never used as an open
|
||||||
|
* redirect.
|
||||||
|
*/
|
||||||
|
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
|
||||||
|
|
||||||
|
/** Providers currently linked to the authenticated customer. */
|
||||||
|
listIdentities(): Observable<ExternalIdentity[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlinks a provider from the authenticated customer. Accepts any linked
|
||||||
|
* provider, not just the OAuth ones - a customer can detach Telegram or MAX
|
||||||
|
* the same way they detach VK, provided at least one identity remains
|
||||||
|
* (the backend enforces "you cannot unlink your last login").
|
||||||
|
*/
|
||||||
|
unlink(provider: ExternalIdentityProvider): Observable<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { SocialIdentityApiGateway } from './social-identity-api.gateway';
|
||||||
|
import { SocialProvider } from './social-identity-gateway.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FH-4.1/FH-4.2. The whole point of this surface is what it does NOT carry:
|
||||||
|
* no client secret, no access token, no PKCE code verifier. These tests pin
|
||||||
|
* that shape, because a future "just add completeCallback back" would look
|
||||||
|
* harmless in review and would move verifier custody into the browser.
|
||||||
|
*/
|
||||||
|
describe('SocialIdentityApiGateway', () => {
|
||||||
|
let gateway: SocialIdentityApiGateway;
|
||||||
|
let httpTesting: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [SocialIdentityApiGateway, provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
gateway = TestBed.inject(SocialIdentityApiGateway);
|
||||||
|
httpTesting = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpTesting.verify());
|
||||||
|
|
||||||
|
const providers: SocialProvider[] = ['vk', 'yandex'];
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
it(`asks the backend for the ${provider} authorize URL and sends no secret material`, () => {
|
||||||
|
let resolved: string | undefined;
|
||||||
|
gateway.getAuthorizeUrl(provider).subscribe(url => (resolved = url));
|
||||||
|
|
||||||
|
const request = httpTesting.expectOne(`/api/identity/v1/${provider}/authorize`);
|
||||||
|
expect(request.request.method).toBe('GET');
|
||||||
|
expect(request.request.body).toBeNull();
|
||||||
|
expect(request.request.urlWithParams).not.toContain('code_verifier');
|
||||||
|
expect(request.request.urlWithParams).not.toContain('client_secret');
|
||||||
|
|
||||||
|
request.flush({ url: `https://id.example.test/${provider}/authorize?state=abc` });
|
||||||
|
expect(resolved).toContain(provider);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('passes returnTo through as a query parameter', () => {
|
||||||
|
gateway.getAuthorizeUrl('vk', '/cart').subscribe();
|
||||||
|
|
||||||
|
const request = httpTesting.expectOne(r => r.url === '/api/identity/v1/vk/authorize');
|
||||||
|
expect(request.request.params.get('returnTo')).toBe('/cart');
|
||||||
|
request.flush({ url: 'https://id.example.test/vk/authorize' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads linked identities from one endpoint for every provider', () => {
|
||||||
|
gateway.listIdentities().subscribe();
|
||||||
|
|
||||||
|
const request = httpTesting.expectOne('/api/identity/v1/me/identities');
|
||||||
|
expect(request.request.method).toBe('GET');
|
||||||
|
request.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unlinks an OAuth provider by its identity name', () => {
|
||||||
|
gateway.unlink('yandex_id').subscribe();
|
||||||
|
|
||||||
|
const request = httpTesting.expectOne('/api/identity/v1/yandex_id/unlink');
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
request.flush(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unlinks a non-OAuth provider too (FH-4.6: Telegram is one identity among several)', () => {
|
||||||
|
gateway.unlink('telegram').subscribe();
|
||||||
|
|
||||||
|
const request = httpTesting.expectOne('/api/identity/v1/telegram/unlink');
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
request.flush(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
|
import { SocialIdentityGateway } from './social-identity-gateway.interface';
|
||||||
|
import { SocialIdentityApiGateway } from './social-identity-api.gateway';
|
||||||
|
|
||||||
|
/** Swap point for docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. */
|
||||||
|
export const SOCIAL_IDENTITY_GATEWAY = new InjectionToken<SocialIdentityGateway>('SOCIAL_IDENTITY_GATEWAY', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => inject(SocialIdentityApiGateway),
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { ExternalIdentity, ExternalIdentityProvider } from '../models/customer-identity.model';
|
||||||
|
import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development stand-in. No VK or Yandex OAuth application is registered yet,
|
||||||
|
* and registering one is blocked on a decision that has to be made before any
|
||||||
|
* of this can work for real: both providers validate redirect_uri against an
|
||||||
|
* exact registered list, so a multi-tenant platform needs one central
|
||||||
|
* identity host as the sole registered callback, with the tenant carried in
|
||||||
|
* the signed state. See FORK-HARVEST-TODO.md FH-0.1.
|
||||||
|
*
|
||||||
|
* Returning a data: URL rather than a fake provider URL is deliberate - it
|
||||||
|
* cannot be mistaken for a working flow if this ever runs outside dev.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SocialIdentityLocalGateway implements SocialIdentityGateway {
|
||||||
|
// Seeded so the account-identities screen has something to render in dev.
|
||||||
|
// Telegram is present because in the current app it is the only real login;
|
||||||
|
// FH-4.6 makes it one ExternalIdentity among several, which is exactly what
|
||||||
|
// this list is meant to show.
|
||||||
|
private readonly linked = signal<ExternalIdentity[]>([
|
||||||
|
{
|
||||||
|
customerId: 'customer_local',
|
||||||
|
provider: 'telegram',
|
||||||
|
providerUserId: '100200300',
|
||||||
|
displayName: 'Local Telegram User',
|
||||||
|
verifiedAt: '2026-08-01T10:00:00.000Z',
|
||||||
|
lastUsedAt: '2026-08-21T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
getAuthorizeUrl(provider: SocialProvider): Observable<string> {
|
||||||
|
return of(`about:blank#${provider}-oauth-not-configured`);
|
||||||
|
}
|
||||||
|
|
||||||
|
listIdentities(): Observable<ExternalIdentity[]> {
|
||||||
|
return of(this.linked());
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink(provider: ExternalIdentityProvider): Observable<void> {
|
||||||
|
this.linked.update(list => list.filter(identity => identity.provider !== provider));
|
||||||
|
return of(void 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Observable } from 'rxjs';
|
|
||||||
import { Customer } from '../models/customer-identity.model';
|
|
||||||
|
|
||||||
/** Per docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. OAuth completion is backend-side; this is the client-facing surface only. */
|
|
||||||
export interface VkIdGateway {
|
|
||||||
getAuthorizeUrl(): Observable<string>;
|
|
||||||
completeCallback(code: string, codeVerifier: string): Observable<Customer>;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { InjectionToken, inject } from '@angular/core';
|
|
||||||
import { VkIdGateway } from './vk-id-gateway.interface';
|
|
||||||
import { VkIdLocalGateway } from './vk-id-local.gateway';
|
|
||||||
|
|
||||||
/** Swap point for docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. */
|
|
||||||
export const VK_ID_GATEWAY = new InjectionToken<VkIdGateway>('VK_ID_GATEWAY', {
|
|
||||||
providedIn: 'root',
|
|
||||||
factory: () => inject(VkIdLocalGateway),
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { Observable, of } from 'rxjs';
|
|
||||||
import { Customer } from '../models/customer-identity.model';
|
|
||||||
import { VkIdGateway } from './vk-id-gateway.interface';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No real VK OAuth app is configured yet - this mock exists so the
|
|
||||||
* VkIdLoginButtonComponent has something to call and the flow shape is
|
|
||||||
* provable end-to-end before a real client id/secret exist. Swap
|
|
||||||
* VK_ID_GATEWAY once docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2
|
|
||||||
* ships; the real backend completes OAuth server-side, this interface never
|
|
||||||
* exposes a client secret regardless of implementation.
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class VkIdLocalGateway implements VkIdGateway {
|
|
||||||
getAuthorizeUrl(): Observable<string> {
|
|
||||||
return of('about:blank#vk-id-not-configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
completeCallback(_code: string, _codeVerifier: string): Observable<Customer> {
|
|
||||||
return of({
|
|
||||||
id: 'customer_vk_mock',
|
|
||||||
marketplaceId: 'default',
|
|
||||||
status: 'active',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
46
src/app/core/integrations/services/connector-api.gateway.ts
Normal file
46
src/app/core/integrations/services/connector-api.gateway.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { Connector, DeadLetterEntry } from '../models/connector.model';
|
||||||
|
import { ConnectorGateway } from './connector-gateway.interface';
|
||||||
|
|
||||||
|
/** Contract: docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7-8. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ConnectorApiGateway implements ConnectorGateway {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
loadConnectors(): Observable<Connector[]> {
|
||||||
|
return this.http.get<Connector[]>('/api/admin/v2/integrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDeadLetter(connectorId: string): Observable<DeadLetterEntry[]> {
|
||||||
|
// §7 defines dead-letter replay (POST .../dead-letter/{id}/replay) but not
|
||||||
|
// a GET list endpoint - inferred at the same collection path, consistent
|
||||||
|
// with the rest of this contract's REST conventions.
|
||||||
|
return this.http.get<DeadLetterEntry[]>(
|
||||||
|
`/api/admin/v2/integrations/${encodeURIComponent(connectorId)}/dead-letter`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
replay(connectorId: string, deadLetterId: string): Observable<void> {
|
||||||
|
return this.http
|
||||||
|
.post(
|
||||||
|
`/api/admin/v2/integrations/${encodeURIComponent(connectorId)}/dead-letter/${encodeURIComponent(deadLetterId)}/replay`,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(connectorId: string): Observable<void> {
|
||||||
|
return this.http
|
||||||
|
.patch(`/api/admin/v2/integrations/${encodeURIComponent(connectorId)}`, { status: 'paused' })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(connectorId: string): Observable<void> {
|
||||||
|
return this.http
|
||||||
|
.patch(`/api/admin/v2/integrations/${encodeURIComponent(connectorId)}`, { status: 'active' })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Connector, DeadLetterEntry } from '../models/connector.model';
|
|||||||
export interface ConnectorGateway {
|
export interface ConnectorGateway {
|
||||||
loadConnectors(): Observable<Connector[]>;
|
loadConnectors(): Observable<Connector[]>;
|
||||||
loadDeadLetter(connectorId: string): Observable<DeadLetterEntry[]>;
|
loadDeadLetter(connectorId: string): Observable<DeadLetterEntry[]>;
|
||||||
replay(deadLetterId: string): Observable<void>;
|
replay(connectorId: string, deadLetterId: string): Observable<void>;
|
||||||
pause(connectorId: string): Observable<void>;
|
pause(connectorId: string): Observable<void>;
|
||||||
resume(connectorId: string): Observable<void>;
|
resume(connectorId: string): Observable<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { InjectionToken, inject } from '@angular/core';
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
import { ConnectorGateway } from './connector-gateway.interface';
|
import { ConnectorGateway } from './connector-gateway.interface';
|
||||||
import { ConnectorLocalGateway } from './connector-local.gateway';
|
import { ConnectorApiGateway } from './connector-api.gateway';
|
||||||
|
|
||||||
/** Swap point for docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. */
|
/** Swap point for docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. */
|
||||||
export const CONNECTOR_GATEWAY = new InjectionToken<ConnectorGateway>('CONNECTOR_GATEWAY', {
|
export const CONNECTOR_GATEWAY = new InjectionToken<ConnectorGateway>('CONNECTOR_GATEWAY', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
factory: () => inject(ConnectorLocalGateway),
|
factory: () => inject(ConnectorApiGateway),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class ConnectorLocalGateway implements ConnectorGateway {
|
|||||||
return of([]);
|
return of([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
replay(_deadLetterId: string): Observable<void> {
|
replay(_connectorId: string, _deadLetterId: string): Observable<void> {
|
||||||
return of(void 0);
|
return of(void 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
src/app/core/interceptors/api-error.interceptor.ts
Normal file
76
src/app/core/interceptors/api-error.interceptor.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { throwError, timer } from 'rxjs';
|
||||||
|
import { catchError, mergeMap, retry } from 'rxjs/operators';
|
||||||
|
import { toApiError } from '../error-handling/api-error.mapper';
|
||||||
|
import { ApiError } from '../error-handling/models/api-error.model';
|
||||||
|
import { RateLimitNotifier } from '../error-handling/rate-limit-notifier.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes every failed response into an ApiError, and honours 429
|
||||||
|
* Retry-After instead of failing straight through.
|
||||||
|
*
|
||||||
|
* Contract: BACKEND-API-REFERENCE.md §5 (envelope), TRACK-S §5 (rate limits).
|
||||||
|
*
|
||||||
|
* Before this existed, every caller branched on raw HttpErrorResponse.status
|
||||||
|
* and nothing anywhere handled 429 — a rate-limited backend surfaced as a
|
||||||
|
* generic failure with no retry and no user-visible explanation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Retries are bounded: past this, the caller sees the error. */
|
||||||
|
const MAX_RATE_LIMIT_RETRIES = 2;
|
||||||
|
|
||||||
|
/** Never sleep longer than this on a server-suggested delay. */
|
||||||
|
const MAX_RETRY_DELAY_SECONDS = 30;
|
||||||
|
|
||||||
|
export const apiErrorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const notifier = inject(RateLimitNotifier);
|
||||||
|
|
||||||
|
return next(req).pipe(
|
||||||
|
retry({
|
||||||
|
count: MAX_RATE_LIMIT_RETRIES,
|
||||||
|
delay: (error: unknown, retryCount: number) => {
|
||||||
|
if (!(error instanceof HttpErrorResponse) || error.status !== 429) {
|
||||||
|
return throwError(() => error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only retry requests that are safe to repeat. Replaying a POST after
|
||||||
|
// a 429 can double-submit; that decision belongs to the caller, which
|
||||||
|
// knows whether it holds an idempotency key.
|
||||||
|
if (!isIdempotent(req.method)) {
|
||||||
|
return throwError(() => error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiError = toApiError(error);
|
||||||
|
const waitSeconds = Math.min(
|
||||||
|
apiError.retryAfterSeconds ?? retryCount * 5,
|
||||||
|
MAX_RETRY_DELAY_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifier.report(waitSeconds, req.url);
|
||||||
|
return timer(waitSeconds * 1000);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
if (!(error instanceof HttpErrorResponse)) {
|
||||||
|
return throwError(() => error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiError: ApiError = toApiError(error);
|
||||||
|
|
||||||
|
if (apiError.status === 429) {
|
||||||
|
notifier.report(apiError.retryAfterSeconds ?? 0, req.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return throwError(() => apiError);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function isIdempotent(method: string): boolean {
|
||||||
|
const m = method.toUpperCase();
|
||||||
|
return m === 'GET' || m === 'HEAD' || m === 'OPTIONS';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exported for tests only. */
|
||||||
|
export const __testing = { MAX_RATE_LIMIT_RETRIES, MAX_RETRY_DELAY_SECONDS, isIdempotent };
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Publish model for marketplace design/content: draft -> validate -> preview -> publish.
|
||||||
|
* Contract: docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §5.
|
||||||
|
*
|
||||||
|
* Hard invariant, stated in the contract and repeated here because it is
|
||||||
|
* the whole point of this model existing separately from commerce data:
|
||||||
|
* Order, Payment, InventoryRecord, and every financial ledger row are NOT
|
||||||
|
* part of a MarketplaceRevision. Rolling back a design revision must never
|
||||||
|
* touch commerce data. Nothing in this model, and nothing that consumes it,
|
||||||
|
* should ever reference an order, payment, or inventory record.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* §5 describes the pipeline as draft -> validation -> preview -> publish,
|
||||||
|
* but only lists 3 write endpoints (validate, publish, rollback) for these
|
||||||
|
* 4 stages - there is no dedicated "move to preview" call. This model
|
||||||
|
* assumes POST .../validate moves a revision straight to 'preview' (the
|
||||||
|
* state publish() actually requires), treating 'validated' as a transient
|
||||||
|
* value the caller may never observe rather than a distinct stored status.
|
||||||
|
* Confirm against the real backend response before relying on 'validated'
|
||||||
|
* ever being read back.
|
||||||
|
*/
|
||||||
|
export type RevisionStatus = 'draft' | 'validated' | 'preview' | 'published';
|
||||||
|
|
||||||
|
export interface MarketplaceRevision {
|
||||||
|
id: string;
|
||||||
|
marketplaceId: string;
|
||||||
|
status: RevisionStatus;
|
||||||
|
publishedAt?: string;
|
||||||
|
/** Rollback creates a NEW revision pointing here - the old one is never mutated. */
|
||||||
|
supersedesRevisionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only forward transitions in the pipeline, plus rollback (which is a new revision, not a transition). */
|
||||||
|
export function canValidate(revision: MarketplaceRevision): boolean {
|
||||||
|
return revision.status === 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canPreview(revision: MarketplaceRevision): boolean {
|
||||||
|
return revision.status === 'validated';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canPublish(revision: MarketplaceRevision): boolean {
|
||||||
|
return revision.status === 'preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A published revision is immutable (§5) - rollback is the only way to move past it, and that creates a new revision. */
|
||||||
|
export function isImmutable(revision: MarketplaceRevision): boolean {
|
||||||
|
return revision.status === 'published';
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { LifecycleAdvanceResult, Marketplace, MarketplaceDomain } from '../models/marketplace.model';
|
||||||
|
import { MarketplaceGateway } from './marketplace-gateway.interface';
|
||||||
|
|
||||||
|
/** Contract: docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §2-4. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MarketplaceApiGateway implements MarketplaceGateway {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
loadMarketplaces(): Observable<Marketplace[]> {
|
||||||
|
// The contract specifies POST/PATCH marketplace endpoints in detail but
|
||||||
|
// not the list GET explicitly - inferred at the collection root, the
|
||||||
|
// conventional counterpart to POST /api/admin/v2/marketplaces (§3 step 1).
|
||||||
|
return this.http.get<Marketplace[]>('/api/admin/v2/marketplaces');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDomains(marketplaceId: string): Observable<MarketplaceDomain[]> {
|
||||||
|
return this.http.get<MarketplaceDomain[]>(
|
||||||
|
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/domains`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLifecycle(marketplaceId: string): Observable<LifecycleAdvanceResult> {
|
||||||
|
return this.http.get<LifecycleAdvanceResult>(
|
||||||
|
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/lifecycle`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { InjectionToken, inject } from '@angular/core';
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
import { MarketplaceGateway } from './marketplace-gateway.interface';
|
import { MarketplaceGateway } from './marketplace-gateway.interface';
|
||||||
import { MarketplaceLocalGateway } from './marketplace-local.gateway';
|
import { MarketplaceApiGateway } from './marketplace-api.gateway';
|
||||||
|
|
||||||
/** Swap point for docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md. */
|
/** Swap point for docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md. */
|
||||||
export const MARKETPLACE_GATEWAY = new InjectionToken<MarketplaceGateway>('MARKETPLACE_GATEWAY', {
|
export const MARKETPLACE_GATEWAY = new InjectionToken<MarketplaceGateway>('MARKETPLACE_GATEWAY', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
factory: () => inject(MarketplaceLocalGateway),
|
factory: () => inject(MarketplaceApiGateway),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { MarketplaceRevision } from '../models/marketplace-revision.model';
|
||||||
|
import { MarketplaceRevisionGateway } from './marketplace-revision-gateway.interface';
|
||||||
|
|
||||||
|
/** Contract: docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §5. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MarketplaceRevisionApiGateway implements MarketplaceRevisionGateway {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
createDraft(marketplaceId: string): Observable<MarketplaceRevision> {
|
||||||
|
return this.http.post<MarketplaceRevision>(
|
||||||
|
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(marketplaceId: string, revisionId: string): Observable<MarketplaceRevision> {
|
||||||
|
return this.http.post<MarketplaceRevision>(
|
||||||
|
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions/${encodeURIComponent(revisionId)}/validate`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(marketplaceId: string, revisionId: string): Observable<MarketplaceRevision> {
|
||||||
|
return this.http.post<MarketplaceRevision>(
|
||||||
|
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions/${encodeURIComponent(revisionId)}/publish`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback(marketplaceId: string, revisionId: string): Observable<MarketplaceRevision> {
|
||||||
|
return this.http.post<MarketplaceRevision>(
|
||||||
|
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions/${encodeURIComponent(revisionId)}/rollback`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user