Adds published: boolean to the bootstrap wire contract. ConfigService swaps to a new DEFAULT_BOOTSTRAP constant (all feature flags on, generic branding/theme/pages) whenever the backend reports published: false, so an unpublished marketplace renders a working demo instead of a blank or broken page. Missing published field stays backward compatible (treated as true). Documents the brand bootstrap wire shape for backend/ops use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
50 KiB
Backend — the whole thing, one file
Date: 2026-08-22 · Branch of record: improvements/fork-harvest
This is the single source of truth for the marketplaces backend. It replaces the former docs/backend/ set (Phase 1–10, Track A/S, the handoffs, the partner and harvest docs) — all of it is folded in here. The frontend is Angular 22, built and waiting; there is no backend yet. Everything below is the wire contract and the invariants the frontend needs, never DB schema or service boundaries, which stay the backend's own call.
The rule (keep this file alive). When a backend need is added, a contract changes, or something ships, update THIS file in the same change — the relevant section and the change log at the bottom (§14). One file, always current. Do not create a new backend
.md; add a section here.
0. Map
| § | Area | Was |
|---|---|---|
| 1 | System shape — multi-tenancy, auth, infra state | Handoff §1–4 |
| 2 | Release invariants (the gate) | Handoff §0 |
| 3 | How work lands — PR & release discipline | Handoff §0a |
| 4 | Cross-cutting mechanisms | Phase 1/Track S/Partner |
| 5 | Money, FX, payment state machine | Phase 1 |
| 6 | Cart & checkout | Phase 6 |
| 7 | Payments, reconciliation, refunds, settlements | Phase 7 |
| 8 | Catalog, offers, inventory, fulfillment | Phase 3 |
| 9 | Orders, events, notifications | Phase 2 |
| 10 | Identity & messaging (VK/Yandex/Telegram/MAX) | Phase 8 |
| 11 | Tenant registry, domains, publish | Phase 9 |
| 12 | Sellers · connectors · content · analytics · partner API | Phase 5/4/10, Track A, Partner |
| 13 | Infra, tenant routing, deploy | Tenant-API handoff + hardening |
| — | Acceptance tests · build order · dev setup · open decisions · change log | §2 end, §15–18, §14 |
New endpoints use /api/v2/...; legacy endpoints (documented in ../../BACKEND-API-REFERENCE.md) are not being migrated.
1. System shape
1.1 Multi-tenancy — shapes every endpoint
One deployed bundle serves every customer domain; there is no per-tenant build. The chain: TenantResolverService reads the browser hostname → ApiConfigService uses one API host per base domain (example.com and store1.example.com both use api.example.com) → nginx validates the browser origin and forwards the full storefront hostname as X-Storefront-Host → the backend resolves the tenant from that trusted header, never from the shared API Host, and treats the frontend-supplied hostname as an untrusted hint, deriving real scope from the authenticated session. A tenant must never read another tenant's data — return 403, not an empty result. Bootstrap carries only what's needed before app start (branding, languages, homepage layout, navigation, enabled widgets, footer pages); never products, orders, cart, or users.
published: boolean — required top-level field on the bootstrap response (2026-08-22). true once the marketplace has a publishedRevision (§11); false while it has none — every other field may then be anything, including a partial/placeholder row, since the frontend ignores the rest of the body and renders its own built-in all-features-on placeholder instead (ConfigService, see Brand-bootstrap design). Field absent (old backend) is read as true for backward compatibility — do not omit it once built.
1.2 Auth — read before writing any endpoint
Auth lives in @marketplaces/auth (published from vitanovaPackages; see ../PACKAGES-USAGE.md). Two mechanisms exist client-side:
- Telegram QR/session (live).
{authApiUrl}/users/sessions—POSTcreate,GET /{id}poll,DELETE /{id}logout. A clean implementation returns{ webSessionID, user: { userId, username, firstName, lastName }, status, expiresAt }. - Ed25519 challenge/response (not built).
GET /api/admin/auth/challenge,POST /api/admin/auth/verify|refresh|logout.
The critical gap: the session API has no concept of "admin." The frontend only chooses where to store the result. Every admin endpoint must independently verify authorization server-side — client-side guards are UI convenience, never security. Admin requests carry AdminWebSessionID: <sessionId> (and Authorization: Bearer <token> once admin JWTs exist) on paths containing /admin/, /backoffice/, /builder/, /media/.
Admin credential (login/password) auth — required, not yet built. admin.gorbushka.market can authenticate via Telegram today; login/password is not implemented, so the frontend must not validate or embed admin credentials, and the Ed25519 /admin-login page is not production-ready (its challenge/verify endpoints don't exist). Tenant identity comes only from nginx's trusted X-Storefront-Host — never from the login body.
POST /api/identity/v1/session { login, password }
-> { accessToken (short JWT), refreshToken (rotating opaque), expiresAt,
mustChangePassword, user: { id, login, displayName, roles[], tenantId } }
POST /api/identity/v1/session/refresh
DELETE /api/identity/v1/session
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
GET /api/identity/v1/session/permissions
Errors: 400 malformed; 401 INVALID_CREDENTIALS (one generic message for unknown login and wrong password); 403 TENANT_DISABLED / TENANT_MISMATCH; 429 RATE_LIMITED with Retry-After. While mustChangePassword is true, every non-auth admin endpoint returns 403 PASSWORD_CHANGE_REQUIRED. Provisioning: random one-time bootstrap password (never {slug}2026$), store only an Argon2id hash with a unique salt, never log passwords/refresh tokens/authorization headers/session ids, rate-limit by tenant+login+source IP with backoff, rotate refresh tokens and revoke the full family on reuse, audit login success/failure + password change + refresh reuse + logout + lockout. nginx must preserve proxy_set_header X-Storefront-Host $storefront_host; proxy_set_header Origin ""; and, for Origin: https://admin.gorbushka.market, resolve $storefront_host to gorbushka.market; API upstream stays https://127.0.0.1:445.
1.3 Infrastructure state (dev server 213.21.246.138, user seto)
| Thing | State |
|---|---|
| nginx 1.24 | Running. Proxies /api/ → 127.0.0.1:8080; /health = ok. |
| Backend on :8080 | Not running. /api/ currently 502s. |
| PostgreSQL | Installed, inactive. Needs db, user, schema. |
@marketplaces/auth |
Installs over plain git, no credentials. |
| TLS / certbot | Not installed. Plain HTTP today. Multi-tenant needs per-domain or wildcard certs. |
| DNS / subdomains | Not set up. No domain points at the server. Phase-equivalent target in §11. |
| Frontend CD | Push to main deploys nothing today; deploy.yml exists (§13). |
Host hardening (sshd, fail2ban, sysctl) is applied on the frontend deploy — see ../DEPLOYMENT.md §3.2.
2. Release invariants (the gate)
A release that violates any one of these does not ship. Each is falsifiable; the acceptance tests are in §15.
- Public tenant is determined by verified
Hostalone. No public endpoint accepts amarketplaceIdfrom the browser. - The price of an order is computed by the backend. A price in a request is ignored, never validated-and-used.
- Stock and reservation change atomically — two buyers racing for the last unit produce exactly one payable order.
- Payment creation and webhook receipt are idempotent, enforced by unique constraints, not handler logic.
- Provider credentials never leave the backend — not in a response, not in a bundle, not in a log.
- A published revision is immutable. Rollback creates a new revision; history is never rewritten.
- Rolling back design does not roll back live inventory, orders, or payments.
- No user reads a marketplace they are not assigned to — through the UI or a direct API call.
- 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 carryexpiresAt/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,
revokedAtset, pastexpiresAt, 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)
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:
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 403s 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
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).
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; setprocessedAtafter applying (a crash between insert and apply shows as unprocessed, not lost).event_key= provider event id, elsesha256(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
pendingin 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.
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:
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 OrderLines.
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.
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),publishedRevisionpointer 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 (
400naming the cycle). - Preview (FH-2.6):
POST .../{id}/preview-token -> { url, expiresAt }. HMAC over{marketplaceId, expiresAt, nonce}, 15-min TTL,storefront_previewHttpOnly cookie, constant-time compare, invalid/expired →404(an unpublished storefront doesn't confirm its existence). While the preview cookie is present, every non-GETon the public API →404(hook ahead of routing). Responses carryX-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 OrderLines within shared orders, pre-filtered server-side (never trust a frontend sellerId).
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.
interface Connector { id; marketplaceId; provider; authType:'webhook_signed'|'api_key'|'oauth2'; credentialRef; pollingIntervalSeconds?; cursorState?; status:'active'|'paused'|'error' }
interface RawExternalEvent { id; connectorId; payload; receivedAt; processedAt? }
interface ExternalOrderMapping { connectorId; externalSellerId; externalProductId; externalSku; internalSellerId; internalOfferId }
interface DeadLetter { id; connectorId; rawEventId; reason; retryCount; lastAttemptAt; resolvedAt? }
interface ExternalOrderEvent { connectorId; externalOrderId; externalCreatedAt; customer; lines[{externalSku,qty,unitPriceMinor,currency}]; totalMinor; currency; rawEventId }
Idempotency key = connectorId + externalOrderId/eventId; zero duplicate orders on repeated delivery. POST /api/providers/v1/{connector}/webhook, GET/POST/PATCH /api/admin/v2/integrations[/{id}], GET /{id}/unmatched, POST /{id}/unmatched/{eventId}/resolve, POST /{id}/dead-letter/{id}/replay. SLA: webhook 99% under 60 s; polling delay ≤ interval + 60; every error carries a trace id.
12.3 Content modules — mall-class tenants (Phase 10)
Lowest priority, only after commerce core is real. Entities (all carry marketplaceId, audit, and the §11 draft/publish flow): Shop, ShopCategory, Service, Floor, SchemePin, RentListing, Lead, NewsPromo, MallSettings. GET/POST/PATCH/DELETE /api/admin/v2/content/{shops|shop-categories|services|floors|scheme-pins|rent-listings|news}, POST /content/rent-listings/{id}/leads, PATCH /content/mall-settings. Commerce modules are platform-ready but off via MarketplaceFeatureSet — the point is proving a tenant flips catalog/cart/checkout to true later with zero code change.
Server-side content validation (FH-2.10). The server re-runs the editor's rules on write. Clamp-and-fallback: clamp out-of-range numbers, fall back an invalid colour, blank a URL that isn't a same-origin path or https://, trim/truncate text. Structural violations (unknown block type, malformed id, too many blocks/ids) → 400. Limits published as one schema both sides read. Referential checks (block → deleted category / unpublished offer) are publish blockers unless a fallback is declared.
12.4 Analytics (Track A) — start early, longest lead time
AnalyticsEvent { eventType, marketplaceId, sessionId, customerId?, timestamp, properties, isSynthetic }. POST /api/v2/storefront/analytics/events. Backend is source of truth for sessionId and isSynthetic — never trust a client synthetic flag. Vocabulary: traffic (session_started, page_view, product_view), catalog (search, category_view, seller_view), commerce (add_to_cart, checkout_started, payment_started|success|failed, order_created) — emitted from the same code paths that produce PaymentEvent/OrderEvent, not a drifting parallel layer. OperationalMetric for latencies/lag. Synthetic traffic is staging/demo only, isSynthetic:true set server-side by environment/token — reports filter it by construction. GET /api/admin/v2/analytics/funnel|operational|quality, GET /api/v2/storefront/search/trending.
12.5 Partner provisioning — inbound (/api/partner/v1/)
Partners provision their own merchant hierarchy, then payments route back to the correct leaf. Deliberately generic — no partner name in any entity/field/endpoint; partner-specific behaviour lives in a PartnerProfile config row.
Four fixed levels Company → Project → Store(=Marketplace) → PaymentPoint; middle levels optional per profile. ProvisioningNode { id, level, parentId, companyId, path[], environment:'TEST'|'LIVE', status:'active'|'suspended'|'disabled', externalReference, displayName, ... }. path is server-computed; nodes never re-parent (move = disable + create); disable cascades terminally, suspend cascades reversibly by cascade id; creating a node never enables money. TEST/LIVE are a hard partition (cross-env → 403).
Write: POST /companies/{id}/projects, /projects/{id}/stores, /stores/{id}/payment-points, PATCH /nodes/{id}/status, POST /nodes/{id}/disable. Read: GET /nodes/{id}, /companies/{id}/hierarchy, /nodes/lookup?externalReference=, /companies/{id}/audit. Every POST needs Idempotency-Key (scope (partnerId, endpoint, key), 24 h, same key+body → replay, +different body → 409, no partial hierarchy). Signed requests (ed25519/rsa-pss), private key never transmitted, ±5 min skew, nonce replay rejected; authority is the credential's scopeNodeId subtree, a credential can never widen its own scope. POST/GET/rotate/DELETE /credentials. Stable error codes (validation_failed 422, scope_forbidden 403, environment_mismatch 403, node_disabled 409, signature_invalid 401, …). Partner-facing serialization uses the partner's own field names via PartnerProfile.routingFieldNames.
13. Infra, tenant routing, deploy
Deterministic hostname rule. One API hostname per base domain: example.com, store1.example.com, www.example.com all use https://api.example.com. Localhost is the only exception (local /api proxy).
Backend must, for every request on the shared api.<base-domain>: use X-Storefront-Host (nginx derives it from a validated browser Origin, sends it as upstream Host, keeps the shared API host in X-Forwarded-Host); not infer a subdomain tenant from the API Host; resolve the normalized storefront hostname through the domain registry; reject unknown/disabled/unverified domains with 403 before reading tenant data (never fall back to a default tenant); bind the session to the resolved tenant and reject a mismatch; trust X-Storefront-Host/X-Forwarded-* only from the known proxy; return JSON for /bootstrap with a tenant identity matching the domain (HTML or a default-tenant response is a fault).
CORS. Echo the exact validated storefront origin, Access-Control-Allow-Credentials: true, Vary: Origin, methods GET,POST,PUT,PATCH,DELETE,OPTIONS, headers Authorization, Content-Type, AdminWebSessionID, X-Requested-With, preflight 204. Never * with credentials.
nginx/TLS. scripts/deploy/configure-api-domain.sh --domain … --email … --upstream https://127.0.0.1:445 (idempotent, root) creates the shared api.<domain>, issues/renews its cert, configures CORS, proxies all paths. Subdomains need no extra API DNS/cert.
CI/CD. deploy.yml runs the same configurator before activating a frontend release. Secrets: DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY, DEPLOY_KNOWN_HOSTS, STOREFRONT_DOMAINS, CERTBOT_EMAIL, BACKEND_UPSTREAM. One-time server-setup.sh installs the root-owned configurator and host hardening (../DEPLOYMENT.md §3.2).
Structural DB isolation (FH-D.2). Data network internal: true, API bound to loopback, no-new-privileges on every service. Restore drill (FH-D.1): WAL archiving (wal_level=replica, archive_mode=on, archive_timeout=300) plus a scheduled restore-check that restores into a clean environment and records the result.
Acceptance: curl -fsS https://api.example.com/bootstrap | jq -e 'type=="object"' and an OPTIONS preflight both pass; the bundle contains no fixed marketplace API hostname; unknown domains 403; API never returns the Angular index.html fallback.
14. Change log
Append here whenever a section changes. Newest first.
- 2026-08-22 — Added
published: booleanto the bootstrap response contract (§1.1): frontend now renders a built-in generic placeholder (all feature flags on) for any marketplace with no published revision, decided from this one field rather than HTTP status. See Brand-bootstrap design. - 2026-08-22 — Consolidated the entire
docs/backend/set into this one file per the single-doc rule; folded in the admin credential (login/password) auth handoff (§1.2). No contract content changed; the former per-phase files are removed. - 2026-08-21 — Harvest additions (
FH-*) folded in across §4–§13, from the parallel-platform review (ADR-0006): 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
- 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.
- Publish & content: §11 (preview/revision/clone/tenant hardening), §12.3 content validation.
- Identity: unblock FH-0.1, then §10 in order VK → Yandex → Telegram migration → OTP. Frontend already built.
- Digital goods & manager contour: §8 code pools, §4.9.
- Continuous: §13 ops.
17. Dev setup (day one)
- Start/configure PostgreSQL; create db + user.
- Design the schema from these contracts (schema is the backend's own call; tenant scoping from day one).
- Build the API service on
127.0.0.1:8080— nginx already proxies/api/. - Implement the bootstrap config endpoint (§1.1) — without it the frontend can't render.
- Implement the Telegram session endpoints — login is fully built client-side, blocked only on these.
- Implement
GET /api/identity/v1/session/permissions(§4.6) — frontend guards derive from it. - 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).