Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Collapses the entire docs/backend/ set - Phase 1-10, Track A/S, the partner API, the two handoffs, the frontend surface inventory, and the harvest requirements - into a single source of truth, docs/backend/BACKEND-INTEGRATION.md. Every contract's entities, endpoints, and invariants are preserved, reorganised by domain rather than by sprint. The nine release invariants, the FH-* harvest mechanisms, the RBAC/audit/secrets cross-cutting rules, the tenant-routing infra contract, the 15 acceptance tests, build order, dev setup, and open decisions are all in the one file, with a change log (§14) at the bottom. The file opens with the maintenance rule: any new backend need, contract change, or shipped item updates this file in the same change - the affected section and the change log. No new backend .md files. Inbound links from BACKEND-API-REFERENCE, the ADRs, the fork docs, DEPLOYMENT, PACKAGES-USAGE, the delivery plan, and e2e/README are repointed at the single doc (section anchors collapse to the file; the prose section refs remain as context). Also recorded the rule in the repo CLAUDE.md. 17 backend docs removed, 1 added. No implementation changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
483 lines
48 KiB
Markdown
483 lines
48 KiB
Markdown
# 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.
|
||
|
||
### 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"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** — 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. 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).
|