# Backend API Reference One document, everyone reads it: product, backend, frontend, QA. It answers three questions for every domain — **what does the frontend already call**, **what shape does it send/expect**, and **is it real or mocked today**. Generated from the actual Angular frontend source (this repo has no backend code — it is a pure client consuming an external API), cross-checked against the frontend's own tolerant adapters, not aspirational. Maturity tags used throughout: | Tag | Meaning | |---|---| | **LIVE** | Real `HttpClient` call exists in code today, hits a real endpoint. | | **MOCK-SWAPPABLE** | Interface + DI token exist; a real implementation can be dropped in without touching UI. May or may not have a real impl yet. | | **MOCK-ONLY (no seam)** | A mock/local implementation exists but the facade injects the concrete mock class directly — no DI token. A backend needs a token introduced first before it can be wired in. | | **LOCAL-ONLY** | Never talks to a backend by design — localStorage / in-memory / derived from bootstrap. | --- ## 1. Core principles 1. **No response envelope.** There is no `{ success, data, error }` wrapper anywhere. Every call is typed to the bare payload — `HttpClient.get(...)`, `get(...)`, `get(...)`. Success = the raw resource (object, array, or `{ items, total }` for lists). Do not wrap new endpoints in an envelope unless it's a deliberate, coordinated breaking change. 2. **No API versioning.** No `/v1/` segment, no `Accept-Version` header, anywhere. The only version field in the whole contract is `BootstrapConfig.schemaVersion`, and it's checked for presence only, not semantically enforced. 3. **No WebSocket / SSE.** Every "live" feeling feature (QR login polling, payment status) is plain `setInterval`/RxJS polling against a normal request/response endpoint. 4. **Tenant resolution is 100% by hostname, not by header or path.** `TenantResolverService` reads the first DNS label (skipping `www`) and uses it to pick a base URL. No `X-Tenant` header, no `/tenant/{id}/...` prefix, ever. Auth requests carry no tenant identifier either — origin is the only signal. 5. **Two independent API bases exist**, plus a third for auth: - Marketplace/tenant API — `ApiConfigService.getBaseUrl()` — default `https://api.dexarmarket.ru:445` (or per-tenant subdomain), `/api` on localhost. - Payment/QR API — `environment.qrApiUrl` = `https://qr.vitanova.network/api`. - Session auth API — `environment.authApiUrl` (currently same host as the marketplace API). 6. **Two independent mock mechanisms coexist — don't conflate them.** (a) `mock-data.interceptor.ts` globally short-circuits a hardcoded URL list (`/ping`, `/users/sessions*`, `/category`, `/items/*`, `/searchitems`, `/cart`, `/qr*`, `/websession/*`) when `environment.useMockData=true` — off in both shipped environments today. (b) Per-domain DI-token factories (`CONFIG_PROVIDER`, `CATEGORY_REPOSITORY`, `PRODUCT_DATA_PROVIDER`, `BACKOFFICE_DATA_PROVIDER`, `ADMIN_CATEGORIES_GATEWAY`) pick a mock vs. real class per `RuntimeProviderStrategyService`. **`PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY` always resolve to the real API implementation regardless of mode** — their mock branch is dead code (`product-data-provider.token.ts:12-18`, `category-repository.token.ts:12-19`). `ADMIN_DASHBOARD_METRICS_GATEWAY` always resolves to the local/mock class the other direction — no real implementation is bound yet even though the token exists. 7. **GET retries:** `ApiService`/`ApiCategoryRepository` wrap reads in a shared `retry({ count: 2, delay: exponential from 500ms })` — expect up to 3 attempts per read before a caller sees a failure. 8. **Dead scaffolding, not missing files:** `src/app/core/error-handling/`, `src/app/core/guards/`, `src/app/core/interceptors/` each contain only a `.gitkeep` — reserved directory structure for a centralized error-handling layer that was never built. Every error today is handled ad hoc at the call site. 9. **Backend engineers should not "clean up" the tolerant adapters.** `ApiService.normalizeItem()`/`normalizeCategory()` and `TelegramSessionApiService.normalizeWebSession()` accept multiple historical field-name casings/aliases on purpose (see §7 Products). A payload landing anywhere inside that tolerance envelope works; a stricter renamed shape breaks the client. 10. **Nullable fields:** the frontend treats `null`, `undefined`, and an omitted key as the same "absent" signal everywhere except a handful of fields explicitly typed `T | null` (e.g. `AuthSession.userId`) where `null` specifically means "known to be absent." Omit or send `null` interchangeably elsewhere. 11. **Do not invent endpoints, fields, or business rules beyond what a real frontend call already implies.** Every open question below is flagged `Requires backend decision` with a recommended default — apply the default and move on unless it's flagged as a business/security decision. --- ## 2. Authentication Two **independent, coexisting** mechanisms. Neither is a stand-in for the other; they authenticate different populations today. ### 2a. Telegram QR / session login — customer AND admin (LIVE) Single mechanism for both; only client-side storage differs (separate cookie/signals per surface). Source: `src/app/services/telegram-session-api.service.ts`. | Endpoint | Method | Auth | Body / Headers | Response | |---|---|---|---|---| | `/users/sessions` | POST | none | body `{ webSessionID }` (client-generated GUID) + header `WebSessionID: ` | `{ webSessionID, url }` — `url` is a `https://t.me/{bot}?start={id}` deep link | | `/users/sessions/{id}` | GET | none | — | Session object, field-tolerant, normalized to `AuthSession` | | `/users/sessions/{id}` | DELETE | none | header `WebSessionID: ` | ignored — client clears local state regardless of response | ```http POST https://api.dexarmarket.ru:445/users/sessions WebSessionID: 3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11 Content-Type: application/json { "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11" } ``` ```json { "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11", "url": "https://t.me/myAMLKYCBOT?start=3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11" } ``` Poll response (field-tolerant — send real field names, the client accepts many aliases): ```json { "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11", "status": "active", "user": { "id": 8823771, "username": "buyer_ivan", "firstName": "Ivan", "lastName": "P" }, "expiresAt": "2026-07-26T05:00:00Z" } ``` Send a real `expiresAt`/`expires` — if absent, the client fabricates `now + 3600s`. Client-side model (`src/app/models/auth.model.ts`): ```ts interface AuthSession { sessionId: string; userId: number | null; username: string | null; displayName: string; active: boolean; expires: string; } interface WebSessionStart { webSessionID: string; url: string; } ``` Expiry handling: `expires` drives a client timer that re-polls `GET /users/sessions/{id}` shortly before expiry; if the backend reports inactive, local state clears. There is no reactive 401 handling for this mechanism — expiry is only discovered on the next explicit poll. ### 2b. Ed25519 challenge/response admin auth (wired client-side, backend not implemented — calls 404 today) Source: `src/app/core/auth/services/auth-api.service.ts`. Base `{authApiUrl}/api/admin/auth`. | Endpoint | Method | Request | Response | |---|---|---|---| | `/challenge` | GET | — | `AuthChallenge { nonce, issuedAt, expiresAt }` | | `/verify` | POST | `VerifySignatureRequest { publicKey, signature, nonce }` | `AuthTokenPair { token, refreshToken }` | | `/refresh` | POST | `RefreshTokenRequest { refreshToken }` | `AuthTokenPair` | | `/logout` | POST | `{ refreshToken }` | void | JWT claims (`JwtClaims`, decode-only client-side — the frontend never verifies the signature, that's the backend's job on every request): ```ts interface JwtClaims { sub: string; role: AdminRole; iat: number; exp: number; publicKey: string; } type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly'; ``` Storage: `localStorage['ed25519AdminToken']` (access), `localStorage['ed25519AdminRefreshToken']` (refresh, opaque, never decoded client-side). **Header:** intended as standard `Authorization: Bearer `, but the interceptor that would auto-attach it (`authInterceptor`) is **not registered** in `app.config.ts` today — no request currently attaches the bearer token automatically. `adminAuthHeadersInterceptor` sets it *if* a token happens to be in storage, but nothing populates one in the live flow yet. **Refresh:** client proactively refreshes ~60s before `exp` via a scheduled timer, and (once `authInterceptor` is registered) would reactively refresh once on any 401 before giving up. Every `/refresh` response is expected to return a **new** `refreshToken` (rotation) — the backend should invalidate the one just used. **Role → permission table** (`ROLE_PERMISSIONS`, coarse, enforced client-side only for UX — backend must independently authorize every mutation): | Role | Permissions | |---|---| | `Owner` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage`, `settings.manage` | | `Administrator` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage` | | `Editor` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write` | | `Support` | `backoffice.read` | | `ReadOnly` | `backoffice.read`, `builder.read` | **Known naming collision:** `AdminRole` is defined twice — the string union above (`core/auth/models/permission.model.ts`, the real JWT/auth contract) and an unrelated interface in `features/admin/users/models/admin-user.model.ts` (display-only labels in the Users admin page, not connected to auth). Treat the string union as the authoritative role for auth purposes; the interface needs a rename (e.g. `AdminUserRoleRecord`) — this is flagged, not yet fixed. **Route guards:** `adminAuthGuard` (live, checks only "is there an active Telegram session," no role check) gates `/edit`, `/edit/:section`, `/backoffice`. `ed25519AuthGuard` and `permissionGuard(permission)` exist and are fully built but attached to **no route today** — dormant until Mechanism B cuts over. Every guard is a client-side UX gate only; the backend must independently verify authorization on every admin mutation regardless of what a guard decided. **Open decision (business, not technical — ask a human):** whether Mechanism A is retired outright in favor of Mechanism B at cutover, or both run in parallel gated by role/tenant config. --- ## 3. Bootstrap — the runtime config document The single payload that drives the entire multi-tenant storefront/builder/backoffice. Fetched once at app startup, held in memory; nearly every feature reads from it instead of a dedicated endpoint. | | | |---|---| | Method / Route | `GET /bootstrap` (relative, rewritten onto the tenant base) | | Auth | **None** — must be publicly cacheable per tenant, fetched before any login | | Query / body | none | ```ts interface BootstrapConfig { schemaVersion: string; generatedAt: string; tenant: TenantConfig; branding: BrandingConfig; theme: ThemeConfig; company: CompanyConfig; featureFlags: FeatureFlagsConfig; features?: MarketplaceFeaturesConfig; apiEndpoints: ApiEndpointsConfig; localization: LocalizationConfig; seo: SeoConfig; permissions: PermissionsConfig; header?: HeaderConfig; catalog?: CatalogConfig; layout?: PlatformLayoutConfig; navigation: NavigationConfig; footer?: FooterConfig; productPage?: ProductPageConfig; userExperience?: UserExperienceConfig; pages: PageConfig[]; staticPages?: StaticPagesConfig; widgetRegistry?: WidgetRegistryConfig; } ``` Required top-level keys (must always be emitted): `schemaVersion, generatedAt, tenant, branding, theme, company, featureFlags, apiEndpoints, localization, seo, permissions, navigation, pages`. Everything marked `?` may be omitted — the client applies defaults. `apiEndpoints.{website,builder,backoffice}` is where a tenant is meant to declare its per-surface endpoint paths at runtime (`Record`) — **these are empty `{}` in the mock today; no builder/backoffice CRUD path exists as a hardcoded literal anywhere in the client.** Any concrete admin CRUD path in this document is a proposal, not a verified literal, until populated here. Abridged real example (from `src/assets/mock/bootstrap/bootstrap.json`): ```json { "schemaVersion": "1.0.0", "generatedAt": "2026-07-03T00:00:00Z", "tenant": { "id": "tenant-default-001", "slug": "default", "code": "DEFAULT", "host": "default.local", "name": "Marketplace", "websiteBaseUrl": "https://marketplace.local", "builderBaseUrl": "https://builder.marketplace.local", "backofficeBaseUrl": "https://backoffice.marketplace.local", "defaultLocale": "ru", "supportedLocales": ["ru", "en", "hy"], "defaultCurrency": "RUB", "supportedCurrencies": ["RUB", "USD", "EUR", "AMD"], "timezone": "Europe/Moscow", "documentationUrl": "https://docs.marketplace.local" }, "branding": { "brandName": "Marketplace", "logoUrl": "/icons/icon-192x192.png", "faviconUrl": "/favicon.ico", "supportEmail": "support@marketplace.local" }, "theme": { "themeId": "default-light", "mode": "light", "palette": { "primary": "#497671", "secondary": "#a1b4b5", "success": "#10b981", "warning": "#f59e0b", "danger": "#ef4444", "textPrimary": "#1e3c38", "backgroundPrimary": "#ffffff", "border": "#d3dad9" }, "typography": { "primaryFontFamily": "DM Sans, sans-serif", "baseFontSize": 16 }, "spacing": { "unit": 4, "scale": [0, 4, 8, 12, 16, 24, 32, 48] } }, "featureFlags": { "wishlist": true, "compare": true, "reviews": true, "blog": false, "chat": false, "coupons": true }, "apiEndpoints": { "bootstrap": { "path": "/bootstrap", "method": "GET", "timeoutMs": 10000 }, "website": {}, "builder": {}, "backoffice": {} }, "localization": { "defaultLocale": "ru", "supportedLocales": ["ru", "en", "hy"], "currencyByLocale": { "ru": "RUB", "en": "USD", "hy": "AMD" } }, "catalog": { "layout": "grid", "defaultSort": "relevance", "availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"] }, "navigation": { "header": [{ "id": "nav-home", "labelKey": "nav.home", "route": "/", "order": 1 }], "footer": [{ "id": "footer-about", "labelKey": "nav.about", "route": "/about-us", "order": 1 }] }, "widgetRegistry": { "manifestUrl": "/assets/mock/bootstrap/widget-manifest.json" }, "pages": [{ "id": "page-home", "key": "home", "title": "Home", "route": { "path": "/", "exact": true }, "visible": true, "sections": [{ "id": "section-hero", "type": "hero", "order": 1, "layout": { "strategy": "hero", "columns": 1 }, "widgets": [{ "id": "widget-hero-main", "type": "hero", "version": "1.0.0", "order": 1, "props": { "title": { "ru": "Добро пожаловать", "en": "Welcome" } } }] }] }] } ``` **Which provider fires:** mock (`GET /assets/mock/bootstrap/bootstrap.json`) when `useMockData=true`, or when `useMockBootstrapOnLocal=true` and host is localhost; otherwise real `GET /bootstrap`. **No write path exists.** Publishing a marketplace (builder "Publish") only promotes an in-memory/localStorage draft signal today — nothing reaches a backend. See §8 Builder. **Requires backend decision:** `X-Language`/`Accept-Language` pre-selection on this call (today the client always gets and holds the full multi-locale document); whether `schemaVersion` is ever semantically enforced (today presence-only); ETag/conditional-request caching (none exists); the entire draft→publish write path. --- ## 4. Pagination, sorting, filtering, search — conventions **Two pagination styles coexist — support both, they are not interchangeable:** - **Offset/count** (marketplace storefront reads) — query params `count` (page size, default 50) and `skip` (offset, default 0). `searchItems` returns `{ items, total }`; other list reads (`getCategoryItems`, `getRandomItems`) return a bare array with no total. - **Page/pageSize** (admin lists, storefront engagement lists, media) — request `{ page, pageSize, ...filters }`, response `{ items, total, page, pageSize }`. Client derives `totalPages = ceil(total / pageSize)` itself. No cursor/keyset pagination exists anywhere. No server-side page-size cap is enforced by the client (it just sends 50 as a default) — **requires backend decision** on max page size. **Sorting:** enumerated in bootstrap `catalog.availableSorts`: `relevance | latest | price_asc | price_desc | rating | popular | discount` (7 values). The live `sort` query param on `GET /searchitems` only accepts a 5-value subset: `relevance | price_asc | price_desc | popular | rating` — `latest`/`discount` have no confirmed search-endpoint mapping. **Requires backend decision** to reconcile these two vocabularies, and to define wire encoding for admin-list sorting (no convention exists yet — admin CRUD is mock-only). **Filtering:** storefront search accepts `categoryIDs` (comma-joined ints), `minPrice`, `maxPrice`, `tag`. Admin list filter objects (in-memory today, not confirmed wire contracts) all follow `{ search: string, : 'all' | , page, pageSize }` — `'all'` is the "no filter on this facet" sentinel. **Requires backend decision:** whether `'all'` is sent literally or the param omitted. **Search:** `GET /searchitems?search=&count=&skip=[&categoryIDs&minPrice&maxPrice&tag&sort]` → `{ items, total }`. No dedicated autocomplete/suggestion/trending backend endpoint exists — those are derived client-side from already-loaded catalog data today. --- ## 5. Error model **The frontend does not currently parse any backend error envelope for any real endpoint** — no interceptor inspects error responses; every caller reacts at the raw `HttpErrorResponse.status`/`.message` level. The one partial exception (Ed25519 admin auth) derives its error code from **HTTP status only**, ignoring any body field, which is itself a known bug (see below). Everything in this section is therefore a **recommended envelope to adopt going forward**, not something already wired end-to-end — apply it to new endpoints and treat the frontend gaps below as follow-up work, not something this doc can silently paper over. ### The envelope ```json { "error": { "code": "VALIDATION_FAILED", "message": "One or more fields are invalid.", "status": 422, "requestId": "b3f1c2a0-4e21-4d3a-9e77-1e8f6a2d9c11", "details": [{ "field": "sku", "code": "REQUIRED", "message": "SKU is required." }] } } ``` | Field | Required | Notes | |---|---|---| | `error.code` | yes | Stable, `UPPER_SNAKE_CASE`, never localized — this is what code should branch on, never `message`. | | `error.message` | yes | Human-readable English fallback only. | | `error.status` | yes | Mirrors the HTTP status. | | `error.requestId` | recommended | Correlation id for support/ops, echoed in logs. | | `error.details` | only on 422 | `{ field, code, message }[]` — matches the client's existing local-validation issue shape, so a future adapter can merge backend 422s into the same inline-error UI without inventing a second mechanism. | ### Status-by-status | Status | `code` | Frontend reaction today | |---|---|---| | 401 | `UNAUTHENTICATED` | Ed25519 flow → generic "Unauthorized, sign in" screen. Customer Telegram auth: no 401 branch anywhere — session validity is only ever discovered by polling. Admin CRUD facades: none have ever seen a real 401 (all mock). | | 403 | `FORBIDDEN` | Ed25519 flow → "Forbidden, back to dashboard." No tenant-vs-role distinction exists — both render identical copy. | | 404 | `NOT_FOUND` | No code path distinguishes 404 from any other failure — a deleted product and a 500 render the identical generic empty-state today. | | 409 | `CONFLICT` | Nothing reacts to 409 anywhere. Only related mechanism: `AdminCategoriesGateway.isSlugTaken()`, a proactive pre-check, not a 409 handler. | | 422 | `VALIDATION_FAILED` + `details[]` | No admin form parses a backend validation body today (all mock). Client's own `ProjectEditorFacade.fieldError(fieldKey)` inline-error pattern is the convention to align a future adapter to. | | 429 | `RATE_LIMITED` (+`retryAfterSeconds`) | **Zero handling anywhere** — no interceptor, facade, or component references 429 at all. | | 500 | `INTERNAL_ERROR` | Falls into whatever generic catch-all a given caller has (retry-button empty state, or — for `LocationService.getRegions()` — silently falls back to 6 hardcoded regions with no visible error at all). | | 503 (infra down) | `SERVICE_UNAVAILABLE` | Same "backend unavailable, retry" screen as 500, on the Ed25519 flow only. | | 503 (maintenance) | `MAINTENANCE_MODE` (+`maintenanceUntil`) | **No maintenance-mode concept exists in the frontend at all today.** Same HTTP status as infra-down 503 — `error.code` is the only way to distinguish them. | | 403 (tenant disabled) | `TENANT_DISABLED` | **No handling exists.** No code path today distinguishes "tenant exists but is disabled" from any other 403. | | 401 (token expired) | `TOKEN_EXPIRED` | **Known bug, not just a gap:** the client has a dedicated "Session expired" screen wired and ready, but `toAuthErrorShape()` only reaches it via a no-refresh-token-present client-side branch — a *real* backend 401 on `/refresh` always renders the generic "Unauthorized" screen instead, because the mapping function ignores any body code and derives purely from HTTP status. Fix requires the backend to send `error.code: "TOKEN_EXPIRED"` **and** a small frontend change to prefer it. | | 401 (bad signature) | `INVALID_SIGNATURE` | Same bug class as above — dedicated screen exists, unreachable from a real HTTP response for the identical reason. | **Every admin backoffice list page** (Users/Orders/Monitoring/Moderation/Transactions/Products/Categories/Analytics/Customers/Dashboard) shares one generic pattern: a boolean `error` signal → "Something went wrong" + retry button. None of them branch on status or `code` today — every status above collapses into the same generic UI until facades are individually updated. --- ## 6. Marketplace / storefront API (LIVE) Base: `ApiConfigService.getBaseUrl()`. Headers on every call (`apiHeadersInterceptor`): `X-Region`, `X-Language` (`ru→RU, en→EN, hy→AM`), `Currency` (default `RUB`), `WebSessionID`. Source: `src/app/services/api.service.ts`. | Endpoint | Method | Params / Body | Response | |---|---|---|---| | `/ping` | GET | — | `{ message }` | | `/category` | GET | — | `Category[]` (normalized) | | `/category/{id}` | GET | `count`, `skip` | `Item[]` | | `/items/{id}` | GET | — | `Item` | | `/items/randomitems` | GET | `count`, `category?` | `Item[]` (featured/random) | | `/searchitems` | GET | `search`, `count`, `skip`, `categoryIDs?`, `minPrice?`, `maxPrice?`, `tag?`, `sort?` | `{ items: Item[], total: number }` | | `/websession/{sessionId}` | POST | item array | cart echo | | `/items/{id}/callback` | POST | `{ rating, comment, sessionID, timestamp }` | `{ message }` — review | | `/items/{id}/questiion` | POST | `{ question, sessionID, timestamp }` | `{ message }` — **literal typo `questiion`, preserve it, matches the client** | | `/purchase-email` | POST | `{ email, phone?, telegramUserId, items[] }` | `{ message }` | | `/regions` | GET | — | `Region[]` — client falls back **silently** to 6 hardcoded regions on any error | ### 6.1 Products — the tolerance contract The wire DTO `Item` (`src/app/models/item.model.ts`) is reconciled by `ApiService.normalizeItem()` — the single largest inline adapter in the codebase. It tolerates **two historical shapes at once**: - `id` (string) ↔ `itemID` (numeric) - `imgs[]` ↔ `photos[]` - `names[]` ↔ `translations` - `description` as a key/value array ↔ a plain string - `comments` ↔ `callbacks` (reviews) - color `0xRRGGBB` → normalized `#RRGGBB` - `remaining` count → a stock band **A real backend can send either historical shape — do not invent a third, cleaner shape.** `normalizeCategory()` does the same job for categories. ### 6.2 Categories — two parallel stacks exist - **Clean stack (real, LIVE):** `GET /category` → `CategoryDto[]` (`{ categoryID, names: [{lang,name}], subcategories: [...] }`) → `CategoryMapper` flattens the tree, dedupes by id, normalizes `am→hy` → domain `Category`. - **Legacy stack:** the same `/category` response also feeds `ApiService.normalizeCategory()` → a *different* `Category` type (`src/app/models/category.model.ts`). **Two unrelated `Category` types exist in the codebase with the same name** — a known duplication, not a bug to silently fix on the backend side; just be aware both consume the same wire shape. ```json [{ "categoryID": 12, "names": [{ "lang": "ru", "name": "Электроника" }, { "lang": "en", "name": "Electronics" }], "subcategories": [{ "categoryID": 34, "names": [{ "lang": "en", "name": "Phones" }] }] }] ``` --- ## 7. Cart / Orders / Payments (LIVE) Cart **contents** are LOCAL-ONLY (localStorage `marketplace_cart`, + Telegram CloudStorage in-app) — there is no backend cart. Checkout produces real payment + order calls. | Endpoint | Method | Base | Body | Response | |---|---|---|---|---| | `/cart` | POST | marketplace | `CartPaymentRequest` | `QrCreateResponse` | | `/orders` | POST | marketplace | `CreateOrderRequest` | `CreateOrderResponse` — fire-and-forget after payment succeeds, doesn't touch the payment call chain | | `/qr` | POST | `qrApiUrl` | `QrCreateRequest` (headers `authorization-key`, `userid-value`) | `QrCreateResponse` | | `/qr/dynamic/{partnerId}/{qrId}` | GET | `qrApiUrl` | — | `QrDynamicStatusResponse` | | `/card/{partnerId}/{orderId}` | GET | `qrApiUrl` | — | `QrDynamicStatusResponse` | Const `partnerId` = `web-97ec-9c57-4dde-9037-3a68f7f83750`. ```ts interface CartPaymentRequest { amount: number; currency: 'RUB'; siteuserID: string; siteorderID: string; redirectUrl: string; telegramUsername: string; paymentMethod: 'qr' | 'card'; qrDescription?: string; customerID?: string; items: Array<{ itemID: number; price: number; name: string; quantity?: number }>; } interface CreateOrderRequest { items: Array<{ productId: string; name: string; quantity: number; price: number }>; customer: { name: string; email: string; phone: string }; payment?: { method: string; currency: string }; shipping?: { address: string; method: string; trackingNumber: string }; } interface CreateOrderResponse { id: string; orderNumber: string; status: string; total: number; currency: string; } ``` `QrCreateResponse` is deliberately alias-tolerant — many casings accepted for id/url/partner fields (`qrId`/`qrID`, `nspkurl`/`nspkId`, `partnerID`/`partnerId`/`PartnerID`, etc). Pick one canonical casing on the backend; the client resolves whichever it gets. ```http POST https://api.dexarmarket.ru:445/cart WebSessionID: 3f1c2a0e-… { "amount": 4990, "currency": "RUB", "siteuserID": "8823771", "siteorderID": "order-2026-0007", "redirectUrl": "https://marketplace.local/checkout/done", "telegramUsername": "buyer_ivan", "paymentMethod": "qr", "items": [{ "itemID": 101, "price": 4990, "name": "Wireless Keyboard", "quantity": 1 }] } ``` ```json { "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" } ``` **Payments are frozen** — this call chain is explicitly out of scope for changes; document only, don't modify. --- ## 8. Admin (backoffice) domains **Structural finding, the single most important fact in this section:** of 11 admin gateway domains, only **Categories** and **Dashboard-metrics** are bound through a DI token — a real backend can be dropped in for those two with zero facade changes. **Every other admin domain's facade injects its mock `*LocalGateway` class directly**, so a token has to be added before a real backend can be wired in at all, regardless of whether the endpoint itself is easy to build. Only one real admin HTTP implementation exists anywhere: `AdminCategoriesApiGateway`. | Domain | Interface | Real impl? | DI token? | Facade | Seam status | |---|---|---|---|---|---| | Categories | `AdminCategoriesGateway` | **yes** (`admin-categories-api.gateway.ts`) | yes (`ADMIN_CATEGORIES_GATEWAY`) | `AdminCategoriesFacade` | MOCK-SWAPPABLE, done | | Dashboard metrics | `AdminDashboardMetricsGateway` | no | yes (`ADMIN_DASHBOARD_METRICS_GATEWAY`) | `AdminDashboardFacade` | MOCK-SWAPPABLE, token only | | Orders | `AdminOrdersGateway` | no | **none** | `AdminOrdersFacade` | MOCK-ONLY, no seam | | Products | `AdminProductsGateway` | no | **none** | `AdminProductsFacade` | MOCK-ONLY, no seam | | Users | `AdminUsersGateway` | no | **none** | `AdminUsersFacade` | MOCK-ONLY, no seam | | Transactions | `AdminTransactionsGateway` | no | **none** | `AdminTransactionsFacade` | MOCK-ONLY, no seam | | Monitoring | `AdminMonitoringGateway` | no | **none** | `AdminMonitoringFacade` | MOCK-ONLY, no seam | | Moderation | `AdminModerationGateway` | no | **none** | `AdminModerationFacade` | MOCK-ONLY, no seam | | Customers | *(none — derived)* | no | n/a | `AdminCustomersFacade` | derives from Orders' mock gateway | | Analytics | *(none — derived)* | no | partial | `AdminAnalyticsFacade` | composes 5 other gateways, no data source | | Media | abstract class `MediaRepository` | no | yes (class token) | `MediaLibraryFacade` | MOCK-SWAPPABLE | ### Gateway interface method contracts (what a real backend must satisfy) - **Categories** — `loadCategories(filters)`, `loadCategory(id)`, `createCategory`, `updateCategory`, `deleteCategory`, `restoreCategory`, `isSlugTaken(slug, excludingId)`. - **Dashboard metrics** — `loadMetrics(): AdminDashboardMetrics` (no params — a seller/scope filter would need a new parameter, no object to extend). - **Orders** — `loadOrders(filters)`, `loadOrder(id)`, `updateStatus(id, status)`, `requestRefund(id)`, `addNote(id, note, internal)`, `archiveOrder`, `restoreOrder`, `deleteOrder`. - **Products** — `loadProducts(filters)`, `loadProduct(id)`, `loadCategories()`, `createProduct`, `updateProduct`, `deleteProduct`, `duplicateProduct`, `archiveProduct`, `restoreProduct`. - **Users** — `loadUsers`, `loadRoles`, `loadInvitations`, `loadSessions(userId)`, `loadAudit(userId)`, `setUserRole`, `setUserStatus`, `inviteUser(email, roleId, scope)`, `revokeInvitation`, `revokeSession`. - **Transactions** — `loadTransactions(filters)`, `retryFailed(id)`, `setFraudFlag(id, flagged)`. - **Monitoring** — `loadEvents(filters)`, `loadQueues()`, `loadWebhooks()`. - **Moderation** — `loadReviews(filters)`, `loadReview(id)`, `setReviewStatus`, `setReviewVisible`, `setReviewPinned`, `setReviewFeatured`, `addModeratorNote`, `deleteReview`, `loadReports()`, `setReportStatus(id, status)`. - **Media** — `list(params?)`, `upload(file, options?)`, `remove(id)`, `update(id, patch)`, `listFolders()`. ### The one real admin endpoint — Categories, exact paths Base: `${apiConfig.getBaseUrl()}/backoffice/categories`. Mode-switched between this and the local mock via `getCategoryProviderMode()` — mock in local dev (no reachable backoffice API there), real API in production. | Method | Path | Body | Response | |---|---|---|---| | GET | `/backoffice/categories?search=&visibility=&includeDeleted=` | — | `AdminCategory[]` | | GET | `/backoffice/categories/{id}` | — | `AdminCategory \| null` (404 → null) | | POST | `/backoffice/categories` | `AdminCategory` minus `{id, itemsCount, deletedAt, createdAt, updatedAt}` | `AdminCategory` | | PUT | `/backoffice/categories/{id}` | full `AdminCategory` | `AdminCategory` | | DELETE | `/backoffice/categories/{id}` | — | `void` — **soft delete only, no hard delete exists** | | POST | `/backoffice/categories/{id}/restore` | `{}` | `AdminCategory \| null` | | GET | `/backoffice/categories/slug-taken?slug=&excludingId=` | — | `{ taken: boolean }` | Use this exact path shape as the template for every other admin domain in §8.5's build order — it's the only one proven end-to-end. ### Worked example — Admin Orders (no mapper exists yet, backend has freedom here) Unlike Categories/Products (which have a wire DTO to match), admin domains other than Categories have **no wire DTO and no mapper today** — the mock gateways build view models directly in memory. This means the JSON shape below is a *proposal* the new `AdminOrdersApiGateway` would map into the existing `AdminOrder` view model, not a shape already fixed by an adapter: ```json { "id": "ord_1042", "status": "processing", "paymentStatus": "paid", "customer": { "id": "cus_88", "name": "…", "email": "…" }, "items": [{ "productId": "…", "title": "…", "qty": 2, "unitPrice": 1990 }], "shipping": { "method": "…", "address": "…" }, "timeline": [{ "event": "created", "at": "2026-07-01T10:00:00Z" }] } ``` The same "no mapper exists, write one inside the new `*ApiGateway`" note applies to Products, Users, Transactions, Monitoring, Moderation. ### Widget manifest (LIVE — static/remote JSON, separate from admin CRUD) `GET ` (default `/assets/mock/bootstrap/widget-manifest.json`), falls back to `{ widgets: [] }` on any error, never throws to the UI. ```json { "widgets": [{ "type": "hero", "version": "1.0.0", "componentKey": "HeroWidgetComponent", "supportedLayouts": ["hero"], "supportedDataSources": ["manual"], "settingsSchema": { "type": "object", "properties": { "title": { "type": "string" } } }, "defaultSettings": { "title": "Welcome" }, "enabled": true }] } ``` ### Backoffice storefront cards (LIVE — distinct from admin CRUD above) `GET /api/backoffice/products`, `GET /api/backoffice/categories` — feeds storefront product/category card widgets, not the admin panel. --- ## 9. Everything that is LOCAL-ONLY (no backend call exists at all) Worth knowing explicitly, so nobody assumes a gateway swap will "just work" for these: - **Content management / static pages (CMS)** — reads/writes `BootstrapConfig.staticPages` in-memory. No dedicated backend call. Publishing = writing bootstrap back, for which no client write call exists. - **Project editor / builder** — edits an in-memory `BootstrapConfig`, persists drafts to `localStorage` only. "Publish" today only promotes the local draft signal. A builder API is declared only as an empty `apiEndpoints.builder: {}` placeholder in bootstrap. - **Search** — `SearchFacade` is a client-side orchestration over the product/category providers (history, trending, autocomplete, cache all local). The only real backend traffic underneath it is `GET /searchitems`. - **User experience (wishlist/compare/recently-viewed/saved-searches)** — fully denormalized objects in `localStorage`, guest-first. A DI token exists for a future authenticated repository, but nothing is bound to it — comment in code notes it "can be switched to authenticated repository later." - **Diagnostics** — inspects runtime/bootstrap/widget state locally; the one live-ish probe is a `/ping` health check. - **Cart contents** — see §7, real payment/order calls exist, cart *state* never round-trips to a backend. --- ## 10. Backend build order (dependency-driven, not document order) 1. **Auth + session** — blocks everything admin-gated. 2. **Bootstrap content** (branding/theme/nav/seo) — transport (`GET /bootstrap`) already works; the *content* is still default stubs. Tenant resolution depends on it. 3. **Categories** — already LIVE both storefront and admin; products reference categories. 4. **Products / catalog** — storefront reads are LIVE; admin Products CRUD is the first no-seam admin domain to build. 5. **Media** — products/categories editors reference media assets. 6. **Cart / Orders / Transactions** — checkout is LIVE; admin Orders CRUD, then Transactions (derives from Orders). 7. **Reviews / Moderation** — customer writes are LIVE; admin Moderation gates them. 8. **Users / roles / invitations** — independent of commerce, needs auth. 9. **Dashboard metrics, then Monitoring** — operational visibility layers. 10. **Analytics — last.** Needs orders/products/moderation real *and* a tracking pipeline that doesn't exist yet anywhere (not just a missing endpoint — no data source at all). 11. **Builder draft/publish + CMS** — net-new write paths, can proceed in parallel once bootstrap content (step 2) is real. 12. **User-experience sync, search suggestions** — enhancements over already-working local features. Per-domain migration pattern for the six no-seam admin domains (Orders, Products, Users, Transactions, Monitoring, Moderation): add a DI token → switch the facade to inject the token instead of the concrete mock class → implement the `*ApiGateway` (contains the DTO→view-model mapper) → bind the token → retire or keep the mock behind the existing `useMockData` flag. This is the exact pattern already proven by Categories — replicate it, don't redesign it per domain. --- ## 11. Known discrepancies to reconcile before/while building - **`AdminRole` defined twice** with unrelated shapes (§2b) — auth string-union vs. Users-page display interface. - **`Category` defined twice** (§6.2) — legacy vs. clean-stack, both fed by the same `/category` response. - **Duplicate search models** under `features/search/models/` and `core/search/models/`. - **`submitQuestion` endpoint path has a literal typo** (`questiion`, not `question`) — this matches the real backend spec, do not "fix" it. - **The Ed25519 error-code bug** (§5) — `TOKEN_EXPIRED`/`INVALID_SIGNATURE` screens are fully built and unreachable from real HTTP responses today because the client only reads HTTP status, never a body code. Needs a coordinated backend + frontend fix, not backend alone. - **`ADMIN_DASHBOARD_METRICS_GATEWAY` and `USER_EXPERIENCE_REPOSITORY` token factories return the mock/local class in every mode** — a real implementation must be written *and* explicitly bound; the seam existing does not mean a real backend is one line away. For open product/business decisions this document deliberately does not resolve (rate limiting posture, refresh-token reuse detection, tenant-scoped auth, API versioning scheme, etc.), see [GAPS-AND-IMPROVEMENTS.md](GAPS-AND-IMPROVEMENTS.md). --- ## 12. Frontend-blocked TODOs — needs backend Raised during the Phase 0 security hardening pass (see the sprint plan). Each of these has a client-side mitigation already in place where one exists, but none of them close the actual gap without a backend change. ### 12.1 Admin role claim on the session **Gap:** `adminAuthGuard` (Mechanism A, Telegram/QR) only checks "is there an active session" — the session API has no concept of admin role at all, so the frontend cannot enforce permissions server-authoritatively. Client mitigation: `AdminPermissionsService` derives a cosmetic permission set by matching the Telegram username against the mock Users domain locally — this is UI-only and trivially bypassed by calling the API directly. **Ask:** either (a) add a `role` field to the existing `GET /users/sessions/{id}` response when the session belongs to a registered admin, or (b) finish Mechanism B (Ed25519 challenge/response, already wired client-side, `/challenge` and `/verify` currently 404) so the JWT `role` claim becomes real. Whichever is chosen, every admin-mutating endpoint must independently authorize the request — a role claim on the session is necessary but not sufficient. Proposed minimal shape for option (a), added to the existing poll response (§2a): ```json { "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11", "status": "active", "user": { "id": 8823771, "username": "buyer_ivan", "firstName": "Ivan", "lastName": "P" }, "expiresAt": "2026-07-26T05:00:00Z", "adminRole": "admin" } ``` `adminRole` absent/null → treat as non-admin regardless of what `/backoffice/**` UI is reachable client-side. ### 12.2 HttpOnly session cookie **Gap:** the customer session cookie (`webSessionID`, `services/auth.service.ts`) is set via `document.cookie` from the frontend, which means it cannot be `HttpOnly` — only a `Set-Cookie` response header from the backend can set that flag, and JS-set cookies are readable by any injected script. Client mitigation: CSP hardened on all three nginx tenant blocks (was missing entirely on two of three) as defense-in-depth, but this does not close the gap. **Ask:** `POST /users/sessions` and `GET /users/sessions/{id}` issue the session id via `Set-Cookie: webSessionID=…; HttpOnly; Secure; SameSite=Lax; Max-Age=…` instead of (or in addition to, during migration) returning it in the JSON body. Once that ships, the frontend stops writing `document.cookie` itself and relies on the browser sending the cookie automatically; `credentials: 'include'` needs enabling on the relevant HTTP calls. ### 12.3 Server-side order pricing **Gap:** `POST` order creation (§7) let the client send a computed, discount-applied `price` per line item with no server-side revalidation. Client fix already shipped: `CreateOrderRequest.items` no longer sends `price` — only `{ productId, name, quantity }`. **Ask:** the order-creation endpoint must price every line item itself by looking up `productId` in its own catalog (applying whatever discount/promo logic is authoritative server-side), and reject/[400] if the resulting total doesn't reconcile with what the client displayed (or just recompute and use the server total as-of-record, ignoring any client total entirely). Example of the request shape now sent: ```json { "items": [{ "productId": "prod_1042", "name": "Sample Product", "quantity": 2 }], "customer": { "name": "Ivan P", "email": "ivan@example.com", "phone": "79991234567" }, "payment": { "method": "card", "currency": "RUB" } } ``` Separately, `createCartPayment()` (payment-gateway charge creation) still sends a client-computed `amount` — that field can't simply be dropped, since it's what tells the payment provider how much to charge. That endpoint must independently revalidate `amount` against its own pricing before creating the charge, and reject on mismatch. ### 12.4 Real order audit trail **Gap:** `AdminOrder` had no actor/audit field at all. Client fix already shipped: `AdminOrderTimelineEntry.actor` now exists and is populated from the signed-in admin's display name in the local mock gateway — but that's client-only bookkeeping with no server-side record. **Ask:** when admin Orders CRUD gets a real backend (§10, step 6), every mutating endpoint (`updateStatus`, `requestRefund`, `addNote`, etc.) should record who performed the action server-side (from the authenticated session/JWT, not a client-supplied field) and return it in the order/timeline response: ```json { "timeline": [ { "status": "processing", "timestamp": "2026-08-13T10:15:00Z", "eventKey": "statusChanged", "actor": "anna@dexar.market" } ] } ``` `actor` must be derived server-side from the authenticated caller, never trusted from the request body. ### 12.5 Back-in-stock ("Notify Me") subscription **Gap:** the "Notify Me" button on out-of-stock products had no real subscription mechanism at all - it just toggled wishlist. Client fix already shipped: `notifyMe()` now calls `POST /items/{id}/notify-me` and, if that fails (today it always will - the endpoint doesn't exist), falls back to a local-only record in `localStorage['restockSubscriptions']` so the request isn't silently dropped while waiting on the backend. The shopper sees the same confirmation either way. **Ask:** implement `POST /items/{id}/notify-me`, plus whatever mechanism actually sends the notification once the item restocks (Telegram message, most likely, given the rest of the auth stack). Request body sent today: ```json { "telegramUserId": "8823771" } ``` `telegramUserId` may be `null` for a non-Telegram web session - decide whether to also accept an email address as an alternative identifier (the frontend has no email capture on this flow today, so that would need a small frontend addition too). Once this ships, the frontend's localStorage fallback becomes purely a resilience path rather than the common case, and could optionally sync any locally-queued subscriptions on next successful call.