Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
48 KiB
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
- No response envelope. There is no
{ success, data, error }wrapper anywhere. Every call is typed to the bare payload —HttpClient.get<Item>(...),get<Category[]>(...),get<BootstrapConfig>(...). 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. - No API versioning. No
/v1/segment, noAccept-Versionheader, anywhere. The only version field in the whole contract isBootstrapConfig.schemaVersion, and it's checked for presence only, not semantically enforced. - No WebSocket / SSE. Every "live" feeling feature (QR login polling, payment status) is plain
setInterval/RxJS polling against a normal request/response endpoint. - Tenant resolution is 100% by hostname, not by header or path.
TenantResolverServicereads the first DNS label (skippingwww) and uses it to pick a base URL. NoX-Tenantheader, no/tenant/{id}/...prefix, ever. Auth requests carry no tenant identifier either — origin is the only signal. - Two independent API bases exist, plus a third for auth:
- Marketplace/tenant API —
ApiConfigService.getBaseUrl()— defaulthttps://api.dexarmarket.ru:445(or per-tenant subdomain),/apion localhost. - Payment/QR API —
environment.qrApiUrl=https://qr.vitanova.network/api. - Session auth API —
environment.authApiUrl(currently same host as the marketplace API).
- Marketplace/tenant API —
- Two independent mock mechanisms coexist — don't conflate them. (a)
mock-data.interceptor.tsglobally short-circuits a hardcoded URL list (/ping,/users/sessions*,/category,/items/*,/searchitems,/cart,/qr*,/websession/*) whenenvironment.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 perRuntimeProviderStrategyService.PRODUCT_DATA_PROVIDERandCATEGORY_REPOSITORYalways 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_GATEWAYalways resolves to the local/mock class the other direction — no real implementation is bound yet even though the token exists. - GET retries:
ApiService/ApiCategoryRepositorywrap reads in a sharedretry({ count: 2, delay: exponential from 500ms })— expect up to 3 attempts per read before a caller sees a failure. - 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. - Backend engineers should not "clean up" the tolerant adapters.
ApiService.normalizeItem()/normalizeCategory()andTelegramSessionApiService.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. - Nullable fields: the frontend treats
null,undefined, and an omitted key as the same "absent" signal everywhere except a handful of fields explicitly typedT | null(e.g.AuthSession.userId) wherenullspecifically means "known to be absent." Omit or sendnullinterchangeably elsewhere. - Do not invent endpoints, fields, or business rules beyond what a real frontend call already implies. Every open question below is flagged
Requires backend decisionwith 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: <same guid> |
{ 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: <id> |
ignored — client clears local state regardless of response |
POST https://api.dexarmarket.ru:445/users/sessions
WebSessionID: 3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11
Content-Type: application/json
{ "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11" }
{ "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):
{
"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):
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):
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 <token>, 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 |
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<string, { path, method, timeoutMs? }>) — 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):
{
"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) andskip(offset, default 0).searchItemsreturns{ 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 derivestotalPages = 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, <field>: 'all' | <enum>, 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=<q>&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
{
"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[]↔translationsdescriptionas a key/value array ↔ a plain stringcomments↔callbacks(reviews)- color
0xRRGGBB→ normalized#RRGGBB remainingcount → 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: [...] }) →CategoryMapperflattens the tree, dedupes by id, normalizesam→hy→ domainCategory. - Legacy stack: the same
/categoryresponse also feedsApiService.normalizeCategory()→ a differentCategorytype (src/app/models/category.model.ts). Two unrelatedCategorytypes 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.
[{ "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.
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.
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 }] }
{ "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:
{
"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 <bootstrap.widgetRegistry.manifestUrl> (default /assets/mock/bootstrap/widget-manifest.json), falls back to { widgets: [] } on any error, never throws to the UI.
{ "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.staticPagesin-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 tolocalStorageonly. "Publish" today only promotes the local draft signal. A builder API is declared only as an emptyapiEndpoints.builder: {}placeholder in bootstrap. - Search —
SearchFacadeis a client-side orchestration over the product/category providers (history, trending, autocomplete, cache all local). The only real backend traffic underneath it isGET /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
/pinghealth check. - Cart contents — see §7, real payment/order calls exist, cart state never round-trips to a backend.
- Currency conversion / display rates —
CurrencyRatesServiceholds RUB-based conversion rates in-memory, admin-editable via Admin Settings, persisted tolocalStorageonly.CurrencyConvertPipeapplies them client-side wherever a storefront price is rendered. TheCurrencyrequest header (§6) is still sent on every call, but nothing round-trips a rate from the backend — see §12.7.
10. Backend build order (dependency-driven, not document order)
- Auth + session — blocks everything admin-gated.
- Bootstrap content (branding/theme/nav/seo) — transport (
GET /bootstrap) already works; the content is still default stubs. Tenant resolution depends on it. - Categories — already LIVE both storefront and admin; products reference categories.
- Products / catalog — storefront reads are LIVE; admin Products CRUD is the first no-seam admin domain to build.
- Media — products/categories editors reference media assets.
- Cart / Orders / Transactions — checkout is LIVE; admin Orders CRUD, then Transactions (derives from Orders).
- Reviews / Moderation — customer writes are LIVE; admin Moderation gates them.
- Users / roles / invitations — independent of commerce, needs auth.
- Dashboard metrics, then Monitoring — operational visibility layers.
- 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).
- Builder draft/publish + CMS — net-new write paths, can proceed in parallel once bootstrap content (step 2) is real.
- 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
AdminRoledefined twice with unrelated shapes (§2b) — auth string-union vs. Users-page display interface.Categorydefined twice (§6.2) — legacy vs. clean-stack, both fed by the same/categoryresponse.- Duplicate search models under
features/search/models/andcore/search/models/. submitQuestionendpoint path has a literal typo (questiion, notquestion) — this matches the real backend spec, do not "fix" it.- The Ed25519 error-code bug (§5) —
TOKEN_EXPIRED/INVALID_SIGNATUREscreens 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_GATEWAYandUSER_EXPERIENCE_REPOSITORYtoken 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.
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):
{
"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:
{
"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:
{
"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:
{ "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.
12.7 Currency conversion / FX rates
Gap: the backend has no per-currency pricing — it sends prices in one base currency (RUB) regardless of the Currency header (§6), and there's no exchange-rate endpoint. Client fix already shipped: admin manually enters a RUB-based rate per supported currency (Admin Settings → Currency rates), and every storefront price display converts client-side via that static, admin-typed number. Rates never update themselves and can drift from the real market rate.
Ask: this was raised as a real accounting concern (bank settlement totals not reconciling against order counts) — two options, not mutually exclusive:
- Backend returns prices already converted per the
Currencyheader (removes client-side conversion entirely, most correct). - Backend exposes a live/periodically-updated FX-rate endpoint (e.g. pegged to Rapira or another exchange) that the frontend polls instead of relying on an admin-typed static number — smaller change, keeps pricing display client-side but removes the manual-entry drift.
Either way, the authoritative amount charged (
createCartPayment'samount, §12.3) must be computed/validated server-side against whichever rate source is authoritative — a client-side conversion (current or future) must never be trusted for the actual charge amount.
12.8 Admin purchase notifications depend on Orders CRUD being real
Gap: AdminOrderWatcherService (new — polls for new orders to toast/badge the admin) polls AdminOrdersGateway.loadOrders() (§8), which is bound to the mock AdminOrdersLocalGateway — a static, 24-row in-memory seed with no create path (see §8's gateway table, "Orders … MOCK-ONLY, no seam"). No genuinely new order can ever appear today, so the feature is functionally inert until Orders CRUD gets a real backend (§10 step 6).
Ask: nothing new beyond what §10/§11 already ask for — once a real AdminOrdersApiGateway is bound, this feature starts working with no additional frontend change. Flagging here only so nobody spends time debugging "why doesn't the notification ever fire" against the mock.
12.9 Admin product view counts
Gap: Admin Products (§8) runs on a fully separate mock domain from the storefront's live catalog — AdminProduct.visits is a new field added to support a "Views" column in Admin Products, but the mock gateway always defaults it to 0 because there is no real tracking source available to the admin domain today. This is unrelated to the storefront's Item.visits field (§6, /items/{id}), which is live-wired but never displayed anywhere in the UI.
Ask: two options, not mutually exclusive:
- Once admin Products gets a real backend (§10 step 4), include a per-product view/visit count in the response.
- Bridge
AdminProduct.visitsto the storefront's already-liveItem.visitsby product id, if a unified product identity exists between the storefront and admin domains — smaller change than building new tracking infrastructure.
12.10 Trending search terms
Gap: SearchTrendingService.loadTrending() is a stub returning of(null) - no trending-searches endpoint exists. It already degrades gracefully (UI hides the trending section rather than showing an error), so this is purely a missing-feature gap, not a bug.
Ask: an endpoint returning the top N search queries over some recent window, e.g.:
{ "trending": [{ "query": "wireless earbuds", "count": 214 }, { "query": "winter jacket", "count": 187 }] }
Once it exists, wire loadTrending() to it and map query -> SearchSuggestion.title/text.