# 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.

**For what doesn't exist yet:** this doc describes the live surface only. The full set of forward-looking wire contracts for Product Plan v3.1 (money/FX, orders, catalog/offer split, connectors, seller portal, identity, tenant registry, RBAC, analytics — 10 phases + 2 tracks) lives in [docs/backend/](docs/backend/README.md).

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`.

| 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 |

```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 <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.

### 2c. Email/phone OTP login — customer (NOT IMPLEMENTED, proposed)

**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"
Response: { "requestId": "...", "expiresAt": "2026-08-15T10:15:00Z" }
```

```
POST /auth/otp/verify
Body: { "requestId": "...", "code": "482913" }
Response (on success): {
  "sessionId": "...", "userId": 8823771, "username": null,
  "displayName": "user@example.com", "active": true, "expires": "2026-08-15T11:15:00Z"
}
```

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):

| `error.code` | HTTP status | Meaning |
|---|---|---|
| `VALIDATION_FAILED` | 422 | Malformed identifier (`error.details[0]` names the field). |
| `RATE_LIMITED` | 429 | Resend cooldown not yet elapsed. |
| `CODE_EXPIRED` | 410 | 10-minute window passed. |
| `CODE_INVALID` | 401 | Wrong code, attempts remain on this `requestId`. |
| `REQUEST_NOT_FOUND` | 404 | `requestId` unknown, exhausted (5 wrong attempts), or expired. |

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.

See `docs/superpowers/specs/2026-08-15-email-phone-login-design.md` for the full design. No client code exists yet — nothing to build against a 404.

---

## 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<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`):
```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, <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) 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.

### 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` | **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[]` |
| `/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 |
| `/geo/resolve` | GET | — | `GeoIpResponse` — **not built yet**, see below |

**`/geo/resolve` — new, required.** Resolves the *caller's* IP to a coarse location so the storefront can pre-select a region. The server reads the client IP (behind the proxy, so honour `X-Forwarded-For` with `trustProxy`); the browser sends nothing and receives no third-party payload.

Response is the existing `GeoIpResponse` shape (`src/app/models/location.model.ts`): `{ city, country, countryCode, region?, timezone?, lat?, lon? }`.

Rules:
- City-level precision only. Do not return coordinates finer than the city centroid, and do not persist the lookup against a customer record — this runs for anonymous visitors.
- Any failure returns a non-2xx. The client already treats every error as "stay on the manual picker", so a degraded geo provider must never block the storefront.
- Rate-limit per IP; it is an unauthenticated endpoint.

This replaces a direct browser call to `http://ip-api.com`, which leaked every visitor's IP to a third party and — being plaintext on an HTTPS origin — was blocked as mixed content, so region auto-detect never actually worked in production. Until this endpoint ships the client silently falls back to the manual region picker, which is the same behaviour production has had all along.

### 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 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/BACKEND-INTEGRATION.md` — the server-authoritative-amount contract there replaces the client-trusted `amount`/`price` fields described below.

---

## 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 <bootstrap.widgetRegistry.manifestUrl>` (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.
- **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.

---

## 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.

### 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:
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.

### 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:
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.

### 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.:
```json
{ "trending": [{ "query": "wireless earbuds", "count": 214 }, { "query": "winter jacket", "count": 187 }] }
```
Once it exists, wire `loadTrending()` to it and map `query` -> `SearchSuggestion.title/text`.
