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<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.
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`.
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.
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):
**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.
**Gap:** customer storefront login/checkout requires Telegram (Mechanism A) — shoppers without Telegram have no way to identify themselves. Raised as a real usability problem, not a hypothetical.
**Ask:** a third, independent auth mechanism (coexists with 2a/2b, replaces neither):
```
POST /auth/otp/request
Body: { "identifier": "user@example.com" } // or E.164 phone, e.g. "+79991234567"
The success response must be shaped identically to the existing `AuthSession` (`sessionId, userId, username, displayName, active, expires`, §2a's client model) — this lets every existing downstream consumer (guards, session signals, cart/checkout) work unchanged regardless of which mechanism produced the session.
Rate limiting/expiry, explicit so nothing is left to guesswork: 60s resend cooldown per identifier between `/request` calls; code expires 10 minutes after issuance; `requestId` allows up to 5 verify attempts before it's invalidated (consumed on success, on the 5th wrong attempt, or on expiry) — not single-use-per-attempt, so one mistyped digit doesn't force a full 60s wait for a new code.
**Error responses must use the existing envelope** (§5), with these codes on `/verify` (the client maps each to distinct UX — see the design doc):
Admin can toggle which login methods (Telegram/Email/Phone) are shown to shoppers — this is a client-only UI gate (Admin Settings, `LocalStorageService`-persisted), not a backend flag; all endpoints stay available regardless of the toggle state.
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.
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`):
**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.
**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.
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.
**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) now reads `error.error.code` from the body when present (`authErrorCodeFromBackendCode()`), falling back to HTTP status only when no body code is sent. 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.
| `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` | **Fixed** — `toAuthErrorShape()` (`core/auth/services/auth.service.ts`) now reads `error.error.code` via `authErrorCodeFromBackendCode()` before falling back to HTTP status. A backend 401 on `/refresh` sending `error.code: "TOKEN_EXPIRED"` reaches the dedicated "Session expired" screen. |
| 401 (bad signature) | `INVALID_SIGNATURE` | **Fixed**, same mechanism — reaches the dedicated screen when the backend sends `error.code: "INVALID_SIGNATURE"`. |
**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[]` |
| `/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.
- **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.
Cart **contents** are LOCAL-ONLY (localStorage `marketplace_cart`, + Telegram CloudStorage in-app) — there is no backend cart. Checkout produces real payment + order calls.
`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.
**Payments were frozen; unfrozen 2026-08-17** (Sprint 0.1 decision, see `docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md`). This call chain is now in scope for the Phase 1 rework specified in `docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` — the server-authoritative-amount contract there replaces the client-trusted `amount`/`price` fields described below.
**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 |
### 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[]` |
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:
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.
`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.
- **Currency conversion / display rates** — `CurrencyRatesService` holds RUB-based conversion rates in-memory, admin-editable via Admin Settings, persisted to `localStorage` only. `CurrencyConvertPipe` applies them client-side wherever a storefront price is rendered. The `Currency` request header (§6) is still sent on every call, but nothing round-trips a rate from the backend — see §12.7.
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).
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).
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):
`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:
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:
**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.
**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:
1. Backend returns prices already converted per the `Currency` header (removes client-side conversion entirely, most correct).
2. 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`'s `amount`, §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.
**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:
1. Once admin Products gets a real backend (§10 step 4), include a per-product view/visit count in the response.
2. Bridge `AdminProduct.visits` to the storefront's already-live `Item.visits` by product id, if a unified product identity exists between the storefront and admin domains — smaller change than building new tracking infrastructure.
**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.: