Files
marketplaces/docs/backend/BACKEND-INTEGRATION.md

483 lines
48 KiB
Markdown
Raw Normal View History

# 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 110, 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 §14 |
| 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, §1518, §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.
### 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/`.
### 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&quote=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** — Consolidated the entire `docs/backend/` set into this one file per the single-doc rule. 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. 67 |
| 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 46 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).