# Backend Integration — Master Specification

Status: canonical. This is the single authoritative backend/API contract for this Angular multi-tenant marketplace platform. It supersedes and replaces `docs/BACKEND.md`, `docs/BACKEND-INTEGRATION.md` (old), `docs/BACKEND-INTEGRATION-PROMPT.md`, `docs/BACKEND-DIFF-VS-MAIN.md`, `docs/architecture/backend/Backend-Platform-API-Spec.md`, and the API-contract portions of `docs/BOOTSTRAP.md`. Those files are deleted; this document is where all of that content now lives, verified against the current source tree on branch `B2B`.

Audience: a backend engineer implementing this platform's API with no other context, and any frontend engineer who needs the ground truth for what the client sends and expects.

## How to read this document

Every endpoint, DTO, and behavior below is tagged with a status:

- **CURRENT** — real HTTP call today, verified in code (file path cited). Shape is frozen; do not change it.
- **PLANNED** — declared in the bootstrap model (`apiEndpoints.builder` / `apiEndpoints.backoffice`) or implied by a gateway interface, but not wired to real HTTP yet. Served today by a `*LocalGateway` (in-memory/localStorage/IndexedDB mock) bound via an Angular DI token. Implementing the real endpoint means writing one `*ApiGateway` class against the documented interface and rebinding the token — no UI change.
- **FUTURE** — reserved contract only. Nothing exists client-side yet beyond a placeholder or a "coming soon" page. Speculative shape, subject to change once real requirements exist.

Never mix these tags. If you can't find a field in the cited model file, it isn't real — ask rather than invent (see Assumptions below for the few places a judgment call was necessary).

## Assumptions (only where the source code was genuinely ambiguous)

1. **Bootstrap path.** Code call sites use `GET /bootstrap` relative to the resolved API base (`ApiConfigService.getBaseUrl()`), not `/api/v1/bootstrap`. This document uses `GET /bootstrap` throughout, consistent with `docs/architecture/backend/Backend-Platform-API-Spec.md`'s draft and every existing doc. If a backend team versions it, `GET /v1/bootstrap` is a compatible evolution — do not silently rename without a schema/version bump story (see §5).
2. **`builder/*` and `backoffice/*` path prefixes.** No literal string constant for these prefixes exists in code (the mock gateways never build a URL at all — they're pure in-memory/localStorage). The prefixes used throughout §6 follow the naming already established in `docs/BACKEND.md`/`docs/BACKEND-INTEGRATION-PROMPT.md` and match `ApiEndpointsConfig`'s `builder`/`backoffice` buckets (`src/app/shared/models/config/api-endpoints.model.ts`). Treat every concrete path in §6.7–§6.16 as a **PLANNED contract proposal**, not a verified literal string — the *shapes* (request/response bodies, field names, enums) are verified against the real TypeScript interfaces; the *paths* are a reasonable default you're free to adjust as long as `apiEndpoints.backoffice`/`apiEndpoints.builder` in the published bootstrap stays in sync with whatever you choose.
3. **Auth/payment endpoints are explicitly frozen** by ADR-010 and out of scope for change — documented here for completeness (§2, §6.2) but treat as read-only reference, not a request for redesign.

---

## Table of contents

1. [Architecture overview](#1-architecture-overview)
2. [Authentication](#2-authentication)
3. [Security](#3-security)
4. [Bootstrap](#4-bootstrap)
5. [API conventions](#5-api-conventions)
6. [Endpoints, by domain](#6-endpoints-by-domain)
7. [DTOs](#7-dtos)
8. [State machines](#8-state-machines)
9. [Validation](#9-validation)
10. [Media](#10-media)
11. [Errors](#11-errors)
12. [Localization](#12-localization)
13. [Caching](#13-caching)
14. [Backend replacement pattern](#14-backend-replacement-pattern)
15. [Future APIs](#15-future-apis)
16. [Sequence diagrams](#16-sequence-diagrams)
17. [Developer notes](#17-developer-notes)

---

## 1. Architecture overview

This is not a single marketplace — it is a multi-tenant platform. One Angular codebase serves unlimited tenants ("marketplaces"), each configured entirely at runtime from a backend-served `BootstrapConfig` document. No marketplace-specific code exists in the frontend (`docs/context/adrs/ADR-0001-marketplace-platform-vision.md`).

Every tenant has three logical surfaces sharing one domain:

- **Website** — the public storefront (catalog, product, cart, checkout-adjacent flows).
- **Builder** (Project Editor) — the tenant admin's visual editor for the same `BootstrapConfig` the storefront renders from. No parallel model.
- **Backoffice** — operational admin: products, categories, orders, transactions, users, monitoring, analytics, media.

```mermaid
flowchart TD
    Host["HTTP Host header"] --> TenantResolve["Tenant resolution (backend, before auth/routing)"]
    TenantResolve --> Bootstrap["GET /bootstrap"]
    Bootstrap --> ConfigService["ConfigService (Angular, loads once, shareReplay(1))"]
    ConfigService --> Runtime["PlatformRuntimeService (theme, branding, nav, pages)"]
    Runtime --> Website["Website (public storefront)"]
    Runtime --> Builder["Builder / Project Editor (draft BootstrapConfig)"]
    Runtime --> Backoffice["Backoffice (admin CRUD)"]
    Website --> API["Storefront REST API (category/items/search/cart/reviews)"]
    Builder --> BuilderAPI["Builder REST API (draft/publish)"]
    Backoffice --> BackofficeAPI["Backoffice REST API (per-domain CRUD)"]
    API --> DB[("Database")]
    BuilderAPI --> DB
    BackofficeAPI --> DB
```

### Request flow (every storefront call)

1. Application code calls `this.http.get('/api/category')` or builds an absolute URL via `ApiConfigService`.
2. **`apiBaseUrlInterceptor`** (`src/app/interceptors/api-base-url.interceptor.ts`) rewrites any `/api/...` URL to the resolved tenant base via `ApiConfigService.toApiUrl()`.
3. **`apiHeadersInterceptor`** (`src/app/interceptors/api-headers.interceptor.ts`) attaches `X-Region`, `X-Language`, `Currency`, `WebSessionID` to every request whose URL resolves as an API request (`ApiConfigService.isApiRequest()`).
4. **`adminAuthHeadersInterceptor`** (`src/app/core/admin-auth/admin-auth-headers.interceptor.ts`) additionally attaches `AdminWebSessionID` and, if present, `Authorization: Bearer <token>` — but only when the URL contains `/admin/`.
5. **`cacheInterceptor`** (`src/app/interceptors/cache.interceptor.ts`) short-circuits GET requests to `/category`, `/category/:id`, `/items/:id` against an in-memory TTL cache (see §13).
6. **`mockDataInterceptor`** (`src/app/interceptors/mock-data.interceptor.ts` / `.production.ts`) — dev-only; when `environment.useMockData` is true, intercepts before any of the above and serves fixture JSON. Not part of the real contract; ignore for backend implementation.
7. Request reaches the resolved absolute base (see base-URL resolution below).

Interceptor registration order (as configured, `mockData → apiBaseUrl → apiHeaders → adminAuth → cache`) matters: base-URL rewriting must happen before headers are attached (headers only attach to recognized API requests), and admin headers only attach to `/admin/` URLs so customer requests never carry an `AdminWebSessionID`.

### Base-URL resolution (`ApiConfigService.getBaseUrl`, `src/app/core/config/api-config.service.ts`)

Priority order, first match wins:

1. **Localhost** → `environment.localhostApiUrl` (`/api`, proxied by the Angular dev server).
2. **`environment.tenantApiBaseUrls[tenantKey]`** — explicit per-tenant override map (e.g. `default`/`dexarmarket` → `https://api.dexarmarket.ru:445`).
3. **`environment.tenantApiTemplate`** — host-pattern template, e.g. `https://{tenant}.api.dexarmarket.ru:445`.
4. **Bootstrap override** (opt-in only) — `bootstrap.apiEndpoints.website.baseUrl` or `bootstrap.tenant.apiBaseUrl`, used only when `environment.allowBootstrapApiOverride === true` **and** the value is an absolute `http(s)://` URL. Disabled by default in both `environment.ts` and `environment.production.ts`.
5. **Fallback** → `environment.apiUrl`.

Tenant identity itself comes from `TenantResolverService` (host-based), never a path or query parameter — the backend must resolve tenant from the `Host` header the same way, before authorization and routing (mirrors `docs/architecture/backend/Backend-Platform-API-Spec.md` §1).

**Backend impact for a new tenant:** either register it in `tenantApiBaseUrls`, or serve it at the `tenantApiTemplate` host pattern — no frontend rebuild required if the template host pattern holds.

### Layered feature architecture (why the UI never touches an endpoint directly)

Every admin domain (categories, products, orders, transactions, users, monitoring, dashboard) follows: `*Gateway` interface → `*LocalGateway` (mock, bound today) / `*ApiGateway` (real, to be built) → DI token → Facade → Page components. See §14 for the full replacement mechanics. This is why implementing a backend for any one domain is additive: write one class, rebind one token, ship.

---

## 2. Authentication

**Scope note (ADR-010):** authentication, payment, and authorization *behavior and contracts* are frozen — this section documents the existing contract for completeness and integration correctness. It is not a request to redesign the auth flow.

### 2.1 Why Telegram, why sessions, why not just JWT-on-login

The platform authenticates end users (both customers and admins) via **Telegram**, not a password. A user scans a QR code (desktop) or taps a deep link (mobile) that opens the platform's Telegram bot with a `start` payload equal to a server-generated session id. The bot, on the backend side, associates that Telegram identity with the session id. The frontend never sees Telegram credentials — it only polls "is this session id now associated with a logged-in Telegram user?" This is why the flow is **session-first**: the session id exists *before* the user is authenticated, and authentication is something that happens to an already-existing session, not something that produces one.

**Why not a JWT immediately on session creation:** the session id is created anonymously (no identity yet) so the frontend has something to encode into a QR code before any identity exists. A JWT would need a subject; there isn't one until the Telegram bot round-trip completes.

**Why sessions (cookie) rather than only a bearer token in memory:** the customer and admin session ids are stored as cookies (`webSessionID`, `adminSessionID`) specifically so a page refresh or new tab doesn't lose login state — a pure in-memory token would not survive navigation. See §2.6 for exact cookie policy.

**Why Telegram alone isn't enough for admin:** completing the Telegram QR flow only proves "this is a real Telegram account holder." It proves nothing about whether that account is *authorized* to act as a marketplace admin. See §2.5 — this is the platform's most significant open security gap today.

### 2.2 The one session API (CURRENT)

Both customer and admin login are, today, **the same backend endpoint**: `{authApiUrl}/users/sessions` (`environment.authApiUrl`, e.g. `https://users.vitanova.network:456`), called exclusively through `TelegramSessionApiService` (`src/app/services/telegram-session-api.service.ts`). There is no separate admin session endpoint.

| Method | Endpoint | Purpose | Caller |
|---|---|---|---|
| `POST` | `{authApiUrl}/users/sessions` | Create an anonymous session, get the Telegram bot deep-link | `TelegramSessionApiService.createSession()` |
| `GET` | `{authApiUrl}/users/sessions/:webSessionID` | Poll session status (has the Telegram bot round-trip completed?) | `TelegramSessionApiService.checkSessionOnce()` |
| `DELETE` | `{authApiUrl}/users/sessions/:webSessionID` | Logout / invalidate session | `TelegramSessionApiService.logout()` |

**`POST /users/sessions`** — request body `{ webSessionID: string }` (a client-generated GUID, `generateGuid()` from `src/app/shared/util/guid.util.ts`), header `WebSessionID: <same guid>`. Response is normalized defensively (`normalizeWebSession`) against many possible field-name variants — the client tolerates `webSessionID`/`WebSessionID`/`webSessionId`/`sessionID`/`SessionID`/`sessionId`/`id`/`ID` for the session id field, and similar variant lists for `user`, `status`/`active`/`loggedIn`, `username`, `firstName`/`first_name`, `expiresAt`/`expires`. **Backend implementers: pick one canonical field name per concept; the client's tolerance is defensive, not a suggestion to send multiple.**

The frontend generates the QR/deep-link URL itself (not from a backend field): `https://t.me/{telegramBot}?start={webSessionID}` for the QR/browser flow, `tg://resolve?domain={telegramBot}&start={webSessionID}` for the in-app deep link. `telegramBot` is a frontend environment constant (`environment.telegramBot`, e.g. `myAMLKYCBOT` / `DexarSupport_bot`), not backend-supplied.

**`GET /users/sessions/:webSessionID`** — polled every 5 seconds (`POLL_INTERVAL_MS`, `QrLoginEngine`, `src/app/shared/qr-login/qr-login.engine.ts`), up to 100 polls (`MAX_POLLS`) before the client gives up and shows `expired`. Also re-checked on `visibilitychange`/`focus`/`pageshow` events (the "user returned from the Telegram app" recovery path — `openAppLogin()` navigates away via `window.location.href`, so the SPA needs to re-verify session state whenever the tab regains focus). Response shape normalizes to:

```ts
// src/app/shared/models/auth.model.ts (AuthSession)
interface AuthSession {
  sessionId: string;
  userId: number | null;
  username: string | null;
  displayName: string;
  active: boolean;
  expires: string; // ISO timestamp
}
```

If the backend response omits an expiry, the client defaults to `now + 3600s` (`SESSION_MAX_AGE_SECONDS = 60 * 60`) — this is a client-side fallback only; the backend should always send a real `expiresAt`.

**`DELETE /users/sessions/:webSessionID`** — best-effort; client clears local state regardless of response (`catchError(() => of(null))`), so a network failure on logout never blocks the UI from appearing logged out.

### 2.3 Customer login (CURRENT)

`AuthService` (`src/app/services/auth.service.ts`) wraps `TelegramSessionApiService` and owns:

- Cookie `webSessionID`, `SameSite=Lax`, `Max-Age=3600`, `Secure` when served over HTTPS, `Path=/`.
- A `status` signal: `unknown | checking | authenticated | unauthenticated`.
- Auto session-refresh: schedules a re-check 60 seconds before `expires` (minimum 30s out), so an active tab silently re-validates before expiry rather than surprising the user with a sudden logout.
- On app init, always calls `checkSession()` once against whatever cookie is present.

### 2.4 Admin login (CURRENT, same backend as customer)

`AdminAuthService` (`src/app/core/admin-auth/admin-auth.service.ts`) is a **structurally identical** wrapper around the same `TelegramSessionApiService` — same `createSession`/`checkSessionOnce`/`logout` calls, same polling engine (`QrLoginEngine`) — with only the *storage* kept separate:

| | Customer (`AuthService`) | Admin (`AdminAuthService`) |
|---|---|---|
| Cookie name | `webSessionID` | `adminSessionID` |
| Cookie `SameSite` | `Lax` | `Strict` |
| Extra token storage | — | `localStorage: adminToken` / `adminRefreshToken` (reserved for a future JWT pair, currently unused — see below) |
| Guard | (route-level, customer flows) | `adminAuthGuard` (`src/app/core/admin-auth/admin-auth.guard.ts`) |
| Header interceptor | `apiHeadersInterceptor` sets `WebSessionID` | `adminAuthHeadersInterceptor` sets `AdminWebSessionID` (+ `Authorization: Bearer` if a token exists) |

**`AdminAuthService.getAdminToken()`/`setAdminTokens()`/`clearAdminTokens()`** exist and are wired into the `Authorization` header, but nothing currently calls `setAdminTokens()` — this is dead-but-ready plumbing for once the backend issues a real JWT access/refresh pair on top of the Telegram session. Treat this as a **PLANNED** extension point, not a current contract.

**Dev-only bypass:** `AdminAuthService.devBypassLogin()` fabricates a local session (`dev-bypass-<timestamp>`) and activates it directly, skipping the QR flow — but is a runtime no-op when `environment.production === true` (checked at call time, not just build time). Reachable via `?devBypassAdmin=true` per `docs/BACKEND-DIFF-VS-MAIN.md`. Not a backend concern beyond knowing it exists — it never calls any endpoint.

### 2.5 The admin authorization gap (CRITICAL — security-relevant, unresolved)

Because admin and customer login hit the **identical** `POST /users/sessions` endpoint, **the backend currently has no way to know, at the moment the QR is scanned, that this is an admin login attempt versus a customer one.** The frontend's only decision is *where to store* the resulting session id (`adminSessionID` cookie vs. `webSessionID` cookie) — it cannot and does not decide, and cannot enforce, whether the Telegram user who completed the scan is actually authorized to act as an admin.

**Concretely: any Telegram user who completes the QR flow while the admin login screen happens to be showing receives a valid `adminSessionID`.** There is currently no server-side check that rejects a non-admin Telegram user's session when it's used with `AdminWebSessionID`.

**Required fix (server-side, not fixable from the frontend):** when an API call arrives carrying `AdminWebSessionID`, the backend must look up whether that Telegram user id is in the admin/role registry and reject (401/403) if not. This is a hard requirement before this system is production-safe for any tenant with real admin/customer separation.

**Also prepared, not wired:** `Ed25519VerificationService` (`src/app/core/admin-auth/ed25519-verification.model.ts`) defines a `requestChallenge()`/`verify()` contract for a future non-Telegram, challenge/signature-based admin auth path (`Ed25519Challenge { nonce, timestamp, payload }` → `Ed25519SignedResponse { challenge, publicKey, signature }` → `Ed25519VerificationResult { valid, reason? }`). The current binding is `NoopEd25519VerificationService`, which **throws** rather than silently accepting anything — a deliberate fail-closed placeholder, safe to leave wired until a real challenge/verify endpoint exists. **FUTURE** — no endpoint exists for this today.

### 2.6 Cookies, headers, and session identity summary

| Concern | Customer | Admin |
|---|---|---|
| Cookie | `webSessionID` | `adminSessionID` |
| `SameSite` | `Lax` | `Strict` |
| `Secure` | yes, when `https:` | yes, when `https:` |
| `Max-Age` | 3600s | 3600s |
| Header sent on API calls | `WebSessionID` (all API requests) | `AdminWebSessionID` (only `/admin/` requests) + optional `Authorization: Bearer <adminToken>` |
| Anonymous fallback | 32-char hex, `localStorage: web_session_id`, generated client-side if no session exists | none — unauthenticated admin routes redirect to login |

### 2.7 Refresh, expiry, unauthorized flows

- **Refresh:** not a token-refresh in the OAuth sense — it's a periodic re-poll of `GET /users/sessions/:id` scheduled ~60s before the session's `expires` timestamp. If still active, the client silently re-activates the session (same cookie, updated in-memory signal); if not, the client transitions to `unauthenticated` and clears the cookie.
- **Expired token flow:** `checkSessionOnce` returning `active: false` (or erroring) clears all local auth state (`clearAuthState('unauthenticated')`) — cookie removed, signals reset, refresh timer cancelled. No automatic re-login; the user must re-scan.
- **Unauthorized flow (admin):** `adminAuthGuard` blocks navigation and calls `requestLogin()` (shows the login dialog) rather than a hard redirect — consistent with this being an SPA-level gate, not a route change.

### 2.8 Payments (frozen, documented for completeness)

`ApiService` (`src/app/services/api.service.ts`) also exposes the payment surface — **frozen per ADR-010**, not to be changed:

| Method | Endpoint | Notes |
|---|---|---|
| `createPayment()` | `POST {qrApiUrl}/qr` | QR dynamic payment creation. Headers `authorization-key`, `userid-value` optional. |
| `createCartPayment()` | `POST /cart` | Cart-scoped payment (card or QR), fixed `partnerqrID = 'web-97ec-9c57-4dde-9037-3a68f7f83750'`. |
| `checkCartPaymentStatus()` | `GET {qrApiUrl}/qr/dynamic/{partnerId}/:qrId` | |
| `checkCartCardPaymentStatus()` | `GET {qrApiUrl}/card/{partnerId}/:orderId` | |
| `checkPaymentStatus()` | `GET {qrApiUrl}/qr/dynamic/:partnerQrId/:qrId` | |

`QrCreateRequest`/`QrCreateResponse`/`CartPaymentRequest`/`QrDynamicStatusResponse` interfaces are in `api.service.ts` lines 10–66 — reproduced in §7.9. The response normalizer tolerates many field-name casings (`qrId`/`qrID`, `nspkID`/`nspkId`, etc.) for the same reason as session responses: multiple backend versions/typos have shipped over time and the client absorbs them rather than breaking.

---

## 3. Security

### 3.1 Origin is not authentication

The frontend sends no CSRF token and relies on cookies (`webSessionID`/`adminSessionID`, `SameSite=Lax`/`Strict`) plus header-based session ids (`WebSessionID`, `AdminWebSessionID`) as the actual authorization signal — **`Origin`/`Referer` headers must never be treated as an authentication mechanism.** They are trivially spoofable by any non-browser client (curl, a compromised browser extension, a malicious script running same-origin) and provide no proof of user identity or intent. Legitimate uses of `Origin` are limited to: (a) CORS allow-listing for browser-enforced preflight, and (b) defense-in-depth logging/anomaly detection — never as a substitute for verifying `WebSessionID`/`AdminWebSessionID` against a real, backend-held session record.

### 3.2 CORS

Not configured in this repository (no `nginx.conf` CORS block for the actual API hosts `api.dexarmarket.ru:445` / `users.vitanova.network:456` — those are separate servers, not part of this repo). Recommended production posture:
- Allow-list exact tenant storefront origins (from the tenant registry, not `*`).
- `Access-Control-Allow-Credentials: true` (cookies are in play).
- Preflight `OPTIONS` handling for any endpoint that receives custom headers (`X-Region`, `X-Language`, `Currency`, `WebSessionID`, `AdminWebSessionID`).

### 3.3 Endpoint authorization tiers

| Tier | Examples | Requirement |
|---|---|---|
| Public | `GET /bootstrap`, `GET /category`, `GET /items/:id`, `GET /searchitems` | No auth. Never leak per-user/per-session data through these. |
| Session-required (customer) | `POST /websession/:id` (cart), `POST /items/:id/callback` (review), `POST /purchase-email` | Valid `WebSessionID`; anonymous sessions are allowed (cart works for guests). |
| JWT/admin-session-required | `builder/*`, `backoffice/*` | Valid `AdminWebSessionID` **and** (§2.5) server-side verification that the underlying Telegram identity is actually provisioned as an admin. |
| Admin-role-required (finer-grained) | e.g. `users`/`roles` write endpoints vs. read-only `products` list | `AdminUser.roleId` → `AdminRole.permissions[]` (see §7.6) should gate specific mutations once a real permission model exists server-side. `PermissionsConfig` (`src/app/shared/models/config/permissions.model.ts`) is the bootstrap-level skeleton for this (`definitions: PermissionDefinition[]`, `roles: RolePermissions[]`) but is not yet enforced anywhere in the frontend beyond existing.

### 3.4 Rate limiting, replay protection, CSRF

None of these exist today, client- or server-side, for this platform. Recommendations for a production backend:
- **Rate limiting:** at minimum on `POST /users/sessions` (session creation is unauthenticated and cheap to hammer) and on write endpoints (`builder/bootstrap/publish`, any `backoffice/*` mutation).
- **Replay protection:** session ids are long-lived (1 hour) bearer-equivalent values sent in a custom header — treat header leakage (e.g. via logs, proxies) as a real risk; short expiry + refresh (already the pattern, §2.7) is the primary mitigation.
- **CSRF:** cookies are `SameSite=Lax` (customer) / `Strict` (admin), which meaningfully reduces cross-site request risk for the cookie itself, but the actual authorization value doubles as a **header** (`WebSessionID`/`AdminWebSessionID`) read from the cookie by JS and reattached — a state-changing endpoint that only checks the header, not the cookie, reopens CSRF risk from any origin that can trick a script into replaying a known session id. Recommend the backend validate that the header value matches a cookie-bound session, not just "does this header value exist in the sessions table."

### 3.5 Bearer tokens, HTTPS, headers

- `Authorization: Bearer <adminToken>` is sent when `AdminAuthService.getAdminToken()` returns non-null (currently always null in practice — see §2.4). Once real JWTs are issued, use standard `Authorization: Bearer` semantics; don't invent a custom scheme.
- Every environment config uses `https://` for `apiUrl`/`authApiUrl`/`qrApiUrl` in production; only localhost dev proxies over `/api`. HTTPS is mandatory in production — cookies rely on `Secure` being meaningful.
- Custom headers the backend must read: `X-Region`, `X-Language`, `Currency`, `WebSessionID` (all requests); `AdminWebSessionID`, `Authorization` (admin requests only).

### 3.6 Recommended production security checklist

- Enforce admin authorization server-side per §2.5 — this is the single highest-priority item in this entire document.
- Validate `WebSessionID`/`AdminWebSessionID` against a real backend-held session, never trust the header value alone as proof.
- Rate-limit session creation and all write endpoints.
- Serve `GET /bootstrap` with no secrets, ever (see §4) — it is public by design.
- Populate `apiEndpoints.builder`/`apiEndpoints.backoffice` in bootstrap only with paths that are actually protected server-side; publishing an endpoint in bootstrap is not itself a security boundary.
- CORS allow-list per tenant host, not wildcard, when credentials are involved.

---

## 4. Bootstrap

### 4.1 Endpoint

**CURRENT** — `GET /bootstrap`, resolved against the tenant API base (§1). No query params or body. Tenant resolved server-side from the `Host` header — **the frontend never sends a tenant id.** Loaded once at app startup by `ConfigService.loadBootstrap()` (`src/app/core/config/config.service.ts`) and cached via `shareReplay(1)` — repeat calls to `loadBootstrap()` within the same session return the cached observable unless `forceRefresh: true` is passed.

On localhost (or when `environment.useMockData === true`), `MockBootstrapProvider` serves `src/assets/mock/bootstrap/bootstrap.json` instead of a real HTTP call, so the app runs fully offline (`npm run dexar`). This mock file is the **canonical field-for-field reference payload** — match it exactly to avoid client-side normalization surprises. `ApiBootstrapProvider` is the real-HTTP implementation, both behind `CONFIG_PROVIDER` DI token per ADR-004.

### 4.2 Response caching

No explicit `ETag`/`304` handling exists in the frontend today — the entire response is cached client-side for the session lifetime via `shareReplay(1)`. A backend implementer is free to add `ETag`/`Cache-Control` (recommended, see §13) since the client doesn't currently send conditional-request headers for this endpoint — adding them is additive and safe.

### 4.3 Top-level shape

Source: `src/app/shared/models/config/bootstrap-config.model.ts`.

```ts
interface BootstrapConfig {
  schemaVersion: string;
  generatedAt: string;
  tenant: TenantConfig;                 // required
  branding: BrandingConfig;              // required
  theme: ThemeConfig;                    // required
  company: CompanyConfig;                // required
  featureFlags: FeatureFlagsConfig;      // required
  features?: MarketplaceFeaturesConfig;  // optional, newer centralized feature surface
  apiEndpoints: ApiEndpointsConfig;      // required
  localization: LocalizationConfig;      // required
  seo: SeoConfig;                        // required
  permissions: PermissionsConfig;        // required
  header?: HeaderConfig;
  catalog?: CatalogConfig;
  layout?: PlatformLayoutConfig;
  navigation: NavigationConfig;          // required
  footer?: FooterConfig;
  productPage?: ProductPageConfig;
  userExperience?: UserExperienceConfig;
  pages: PageConfig[];                   // required
  staticPages?: StaticPagesConfig;
  widgetRegistry?: WidgetRegistryConfig;
}
```

**Rule (ADR-0001):** bootstrap contains only what's needed *before the app starts* — branding, theme, languages, navigation, footer/static-page references, homepage layout, enabled widgets/features. It must **never** contain products, orders, cart contents, or user-specific data. Product/category *data* is fetched separately (`GET /category`, `GET /items/:id`, etc.) — `catalog`/`productPage`/`userExperience` in bootstrap are feature/UI configuration only, never data.

### 4.4 Field-by-field reference

| Key | Source file | Notes |
|---|---|---|
| `schemaVersion` | — | Contract version string. Breaking changes require a bump; frontend must stay compatible within a minor line (see §5.1). |
| `generatedAt` | — | Payload generation timestamp, ISO 8601. |
| `tenant` | `tenant.model.ts` | `id` (UUID), `slug`, `code`, `host`, `name`, `websiteBaseUrl`, `builderBaseUrl`, `backofficeBaseUrl`, `defaultLocale`, `supportedLocales[]`, `defaultCurrency`, `supportedCurrencies[]`, `timezone`. Resolved by domain only — never re-derived client-side. |
| `branding` | `branding.model.ts` | `brandName`, `legalName`, `slogan?`, `logoUrl`, `logoCompactUrl?`, `faviconUrl`, `appIconUrl?`, `socialImageUrl?`, `galleryUrls?[]`, `supportEmail?`, `supportPhone?`. |
| `theme` | `theme.model.ts` | `themeId`, `mode: light\|dark\|system`, `palette` (12 semantic colors: primary/secondary/accent/success/warning/danger/info/textPrimary/textSecondary/backgroundPrimary/backgroundSecondary/border), `typography` (`primaryFontFamily`, `headingFontFamily?`, `baseFontSize`), `spacing` (`unit`, `scale[]`), `borderRadiusScale: Record<string,string>`, `shadows: Record<string,string>`, `iconSet`. |
| `company` | `company.model.ts` | `companyName`, `registrationNumber?`, `taxId?`, `address` (`country`, `region?`, `city`, `street?`, `postalCode?`), `contacts` (`email`, `phone?`, `telegram?`, `website?`, `additionalPhones?[]`, `additionalEmails?[]`). Used in footer legal/contact info. |
| `featureFlags` | `feature-flags.model.ts` | Boolean map with 14 named keys (`wishlist`, `compare`, `reviews`, `questions`, `comments`, `recommendations`, `blog`, `chat`, `analytics`, `notifications`, `coupons`, `loyalty`, `giftCards`, `invoices`) plus an open `[key: string]: boolean` index — new flags can be added without a model change. |
| `features` | `features-config.model.ts` | Newer, more granular `MarketplaceFeaturesConfig` (16 booleans: wishlist/compare/reviews/comments/questions/recommendations/recentlyViewed/searchHistory/recentlySearched/ratings/share/brands/manufacturers/availability/discounts/badges). Resolvers fall back to `featureFlags`/`productPage`/`userExperience`/`catalog` for older bootstraps lacking this field — **do not remove `featureFlags` when adding `features`; both must be sent for backward compatibility until every consumer is confirmed on the new surface.** |
| `apiEndpoints` | `api-endpoints.model.ts` | `{ bootstrap: ApiEndpointConfig, website: Record<string, ApiEndpointConfig>, builder: Record<string, ApiEndpointConfig>, backoffice: Record<string, ApiEndpointConfig> }` where `ApiEndpointConfig = { path, method, timeoutMs? }`. **Never contains secrets.** Populate `builder`/`backoffice` buckets as you implement each endpoint so the frontend can (eventually) discover them. |
| `localization` | `localization.model.ts` | `defaultLocale`, `supportedLocales[]`, `currencyByLocale: Record<string,string>`, `dictionaries: LocalizationDictionaryRef[]` (`{locale, dictionaryUrl, version}`). |
| `seo` | `seo.model.ts` | `default: SeoPageConfig`, `byPageKey: Record<string, SeoPageConfig>`. `SeoPageConfig = { title, description, canonicalUrl?, robots?, metaTags?: {name?, property?, content}[] }`. |
| `permissions` | `permissions.model.ts` | `definitions: {key, description?}[]`, `roles: {role, permissions[]}[]`. Currently minimal/skeletal — real enforcement is server-side (§3.3, §2.5). |
| `header` | `header-config.model.ts` | All-boolean toggles: `showLogo`, `showSearch`, `showCategories`, `showLanguages`, `showCart`, `showProfile`, `showWishlist`, `showCompare`, `showRegion`, plus `sticky?: boolean`, `layout?: 'default'\|'centered'`. |
| `catalog` | `catalog-config.model.ts` | UI/feature config only, **no product data**. `layout` (8 grid/list modes), `loadingStrategy` (`pagination\|loadMore\|infiniteScroll`), `navigationMode` (4 modes), `defaultSort`/`availableSorts` (7 sort keys), `enabledFilters: string[]`, and 7 `show*`/`*Enabled` booleans. |
| `layout` | `layout.model.ts` | `{ type: 'default'\|'sidebar-left'\|'carousel-home'\|'minimal'\|string, options?: Record<string,unknown> }`. Global page-chrome mode. |
| `navigation` | `navigation.model.ts` | `header: NavigationItemConfig[]`, `footer: NavigationItemConfig[] \| FooterNavigationGroupConfig[]` (two accepted shapes — flat list or grouped), optional `sidebar[]`. Each item: `id`, `label` (string or `{[locale]: string}`), `route?`, `type?`, `key?` (e.g. `'staticPage'` + a page id — see §12), `icon?`, `order?`, `visible?`, `visibleWhenFlags?: string[]`, `children?`. |
| `footer` | `footer-config.model.ts` | `logoUrl?`, `paymentIcons?: {src, alt, width?, height?}[]`, `copyrightText?` (string or per-locale), `columns?: FooterColumnConfig[]` (`{id, title, links: {id,label,pageKey?,url?}[]}`), `socialLinks?: {id,label,url,icon?}[]`. `legalPageKeys`/`staticPageKeys` are **deprecated**, superseded by `columns` — still read for already-saved configs, do not emit them for new tenants. |
| `productPage` | `product-page-config.model.ts` | Feature config only for PDP — `rating`, `reviews` (`pageSize`, `showSummary`, `mode: pages\|load-more`), `questions` (`pageSize`, `allowSubmission`), `tabs` (`items[]` from a fixed enum), `relatedProducts`, `actions` (`addToCart`,`buyNow`,`wishlist`,`compare`,`share`,`notifyMe`). No review/question *data* here. |
| `userExperience` | `user-experience-config.model.ts` | Feature config only — `wishlist` (`headerBadgeEnabled`), `compare` (`maxItems`, `hideIdenticalDefault`, `highlightDifferencesDefault`), `recentlyViewed` (`maxItems`, `widgetEnabled`), `share`, `continueBrowsing`, `savedSearches` (`maxItems`). Never user-specific lists — those are client-local today (§6.13). |
| `pages` | `page.model.ts` | `{id, key, title, route: {path, exact?}, layout: string \| PlatformLayoutConfig, sections: SectionConfig[], seoKey?, featureFlag?, visible?}`. |
| `staticPages` | `static-page.model.ts` | See §4.6. |
| `widgetRegistry` | `widget-registry.model.ts` | `{ manifestUrl: string }` — pointer to the widget manifest (see `docs/ARCHITECTURE.md` widget engine; not itself a backend endpoint documented here). |

### 4.5 Section and widget config

```ts
// section.model.ts
type SectionLayoutStrategy = 'stack' | 'grid' | 'hero' | 'carousel' | 'split';

interface SectionConfig {
  id: string; type: string; order: number;
  layout?: { strategy?: SectionLayoutStrategy; columns?: number; gap?: string; align?: 'start'|'center'|'end'|'stretch' };
  visibility?: { desktop?: boolean; tablet?: boolean; mobile?: boolean };
  widgets: WidgetConfig[];
  featureFlag?: string;
  visible?: boolean;
}

// widget.model.ts
interface WidgetConfig {
  id: string; type: string; version: string;
  title?: string; subtitle?: string; order?: number; padding?: string;
  visibility?: { desktop?: boolean; tablet?: boolean; mobile?: boolean };
  animation?: { name?: string; duration?: string; delay?: string; timingFunction?: string };
  style?: Record<string, string>;
  permissions?: { requireAuthenticated?: boolean; roles?: string[]; permissions?: string[] };
  props: Record<string, unknown>;
  actions?: Record<string, { type: 'navigate'|'open'|'submit'|'custom'; target?: string; payload?: Record<string, unknown> }>;
  featureFlag?: string;
  visible?: boolean;
}
```

Typed editors exist in the Project Editor for `hero`, `categories`, `product-collection` widget types; every other type edits `props` as raw JSON.

### 4.6 `staticPages`

```ts
// static-page.model.ts
type StaticPagesConfig = Record<string, StaticPageConfig> | LegacyStaticPageConfig[]; // both accepted

interface StaticPageConfig {
  id: string;
  slug: string;
  title: string | Record<string, string>;
  showInFooter?: boolean; showInHeader?: boolean; showInSitemap?: boolean;
  icon?: string; order?: number;
  visibility?: { desktop?: boolean; tablet?: boolean; mobile?: boolean };
  requiresAuthentication?: boolean;
  footerGroup?: string;
  translations?: Record<string, { title?: string; html?: string; seo?: {...} }>;
  html?: string | Record<string, string>;
  seo?: StaticPageSeoConfig;
  visible?: boolean;
  route?: string;
  content?: Record<string, string>;
  enabled?: boolean;               // master on/off switch
  status?: 'draft' | 'published';  // per-page publish lifecycle, independent of whole-bootstrap publish
  customTemplate?: string;
  heroImage?: string; heroImageAlt?: string; heroImageCaption?: string;
  thumbnail?: string; gallery?: string[];
  updatedAt?: string;
}
```

**Resolution rule:** a static page resolves on the storefront only when `enabled === true` **and** `status === 'published'` — independent of whether the surrounding bootstrap has been published. A page with `status: 'draft'` stays invisible even after the whole tenant config is published. Compatibility default when normalizing legacy data (no `enabled`/`status` present): `enabled: true, status: 'published'` — existing tenants are never silently un-published.

**Known data inconsistency:** the model requires `slug`, but the reference mock (`src/assets/mock/bootstrap/bootstrap.json`) leaves `slug` empty for some pages and only populates `route` (e.g. `/about-us`). The frontend's duplicate-detection falls back to `route` when `slug` is empty. **Decide and standardize:** either always populate `slug` server-side, or formally make `slug` derivable from `route` — do not leave both optional indefinitely.

### 4.7 Representative example (trimmed)

```json
{
  "schemaVersion": "2.1.0",
  "generatedAt": "2026-07-05T10:30:00Z",
  "tenant": {
    "id": "tenant-dexar-ru", "slug": "dexar-ru", "code": "dexar-ru", "host": "dexarmarket.ru",
    "name": "Dexar Market", "websiteBaseUrl": "https://dexarmarket.ru",
    "builderBaseUrl": "https://dexarmarket.ru/edit", "backofficeBaseUrl": "https://dexarmarket.ru/admin",
    "defaultLocale": "ru", "supportedLocales": ["ru", "en", "hy"],
    "defaultCurrency": "RUB", "supportedCurrencies": ["RUB", "USD", "AMD"], "timezone": "Europe/Moscow"
  },
  "branding": { "brandName": "Dexar Market", "legalName": "OOO Dexar", "logoUrl": "/assets/brand/logo.svg", "faviconUrl": "/assets/brand/favicon.ico" },
  "theme": {
    "themeId": "dexar-light", "mode": "light",
    "palette": { "primary": "#2F6E5D", "secondary": "#8FA9A2", "accent": "#E7A33E", "success": "#2F9E44", "warning": "#F08C00", "danger": "#E03131", "info": "#1C7ED6", "textPrimary": "#1F322D", "textSecondary": "#5C6B66", "backgroundPrimary": "#FFFFFF", "backgroundSecondary": "#F4F6F5", "border": "#DDE3E1" },
    "typography": { "primaryFontFamily": "DM Sans, sans-serif", "baseFontSize": 16 },
    "spacing": { "unit": 4, "scale": [0,4,8,12,16,24,32] },
    "borderRadiusScale": { "sm": "6px", "md": "12px", "lg": "20px" },
    "shadows": { "md": "0 4px 12px rgba(0,0,0,0.15)" },
    "iconSet": "default"
  },
  "company": { "companyName": "OOO Dexar", "address": { "country": "RU", "city": "Moscow" }, "contacts": { "email": "info@dexarmarket.ru", "phone": "+7 926 459 31 57" } },
  "featureFlags": { "wishlist": true, "compare": true, "reviews": true, "questions": true, "comments": true, "recommendations": true, "blog": false, "chat": false, "analytics": true, "notifications": true, "coupons": false, "loyalty": false, "giftCards": false, "invoices": false },
  "apiEndpoints": { "bootstrap": { "path": "/bootstrap", "method": "GET" }, "website": {}, "builder": {}, "backoffice": {} },
  "localization": { "defaultLocale": "ru", "supportedLocales": ["ru","en","hy"], "currencyByLocale": { "ru": "RUB", "en": "USD", "hy": "AMD" }, "dictionaries": [] },
  "seo": { "default": { "title": "Dexar Market", "description": "Multi-category marketplace" }, "byPageKey": {} },
  "permissions": { "definitions": [], "roles": [] },
  "layout": { "type": "default" },
  "catalog": { "layout": "grid-4", "navigationMode": "default", "defaultSort": "relevance", "showRatings": true },
  "navigation": {
    "header": [ { "id": "nav-home", "label": "Home", "route": "/", "order": 1, "visible": true } ],
    "footer": [ { "id": "nav-privacy", "label": "Privacy", "route": "/privacy-policy", "order": 1, "visible": true } ]
  },
  "pages": [
    {
      "id": "page-home", "key": "home", "title": "Home", "route": { "path": "/", "exact": true }, "layout": "default",
      "sections": [
        { "id": "home-hero", "type": "hero", "order": 1, "layout": { "strategy": "hero" },
          "widgets": [ { "id": "w-hero", "type": "hero", "version": "1.0.0", "props": { "title": "Welcome", "layout": "full-bleed" } } ] },
        { "id": "home-categories", "type": "categories", "order": 2, "layout": { "strategy": "grid", "columns": 4 },
          "widgets": [ { "id": "w-categories", "type": "categories", "version": "1.0.0", "props": { "columns": 4 } } ] }
      ]
    }
  ],
  "staticPages": {
    "static-about": { "id": "static-about", "slug": "about", "title": { "en": "About Us" }, "showInFooter": true, "showInHeader": false, "enabled": true, "status": "published", "translations": { "en": { "html": "<h1>About Us</h1>" } } }
  }
}
```

### 4.8 Lifecycle: how the frontend builds itself from it

1. `ConfigService.loadBootstrap()` fetches (or, mock mode, reads local JSON) and parses `BootstrapConfig`. Cached via `shareReplay(1)`.
2. `PlatformRuntimeService` applies theme tokens as CSS variables, sets branding, and exposes parsed pages/navigation/footer/static-pages to the rest of the app.
3. The Section Engine / Widget Host render pages from `bootstrap.pages` on route match.
4. `PlatformRuntimeService.reloadFromBootstrap(next)` re-applies an entire new `BootstrapConfig` in-memory without a full page reload — this is what the Project Editor's Publish/Preview actions use (§6.7).

The Project Editor edits an **in-memory copy of this exact same `BootstrapConfig`** — there is no parallel editor model or DTO translation layer (ADR-0001's core rule).

---

## 5. API conventions

### 5.1 Versioning

No path-based versioning exists today (`/bootstrap`, not `/v1/bootstrap`) — `schemaVersion` inside the bootstrap payload is the actual contract version signal. Recommendation for new `builder/*`/`backoffice/*` endpoints: keep them unversioned in the path (matching existing convention) and rely on additive-only changes (new optional fields) within a schema-version line; bump `schemaVersion`'s major segment for breaking changes and coordinate a frontend release.

### 5.2 Naming and pluralization

Existing storefront endpoints are **inconsistently pluralized** by historical accident (`/category` singular-collection, `/items/:id` plural-collection, `/searchitems` compound) — **do not "fix" this**, it's a frozen contract (§6.1). For all **new** `builder/*`/`backoffice/*` endpoints, use plural resource collections (`/backoffice/products`, `/backoffice/orders`) consistently — this document's proposed paths in §6.7–§6.16 already follow that convention.

### 5.3 Status codes

| Code | Meaning | Used for |
|---|---|---|
| 200 | Success | GET, successful PUT/PATCH/POST that returns a body |
| 201 | Created | POST that creates a resource (e.g. `POST /backoffice/products`) |
| 204 | No Content | DELETE, or POST/PUT with no meaningful response body |
| 400 | Bad Request | Malformed request (not a validation failure — see 422) |
| 401 | Unauthorized | Missing/invalid session |
| 403 | Forbidden | Valid session, insufficient role/permission (including the admin-authorization gap fix, §2.5) |
| 404 | Not Found | Resource / tenant not found |
| 409 | Conflict | Slug/SKU uniqueness violation, concurrent-edit conflict |
| 422 | Unprocessable Entity | Validation failure (see §11 error shape) |
| 429 | Too Many Requests | Rate limit |
| 500 | Internal Server Error | Unhandled backend failure |
| 503 | Service Unavailable | Maintenance mode, dependency down (see §11.3) |

### 5.4 Pagination

Two patterns exist in the frontend's expectations, both real — pick per-domain, be consistent within a domain:

- **Offset-based** (storefront): `count`/`skip` query params — `getCategoryItems(id, count=50, skip=0)`, `searchItems(..., count, skip)`, `getRandomItems(count)`.
- **Page-based** (admin lists): `page`/`pageSize` in filter objects (`AdminProductListFilters`, `AdminOrderListFilters`, `AdminReviewListFilters`, `AdminTransactionListFilters` all carry `page: number; pageSize: number`), response shape `{ items: T[], total: number, page: number, pageSize: number }` (`AdminProductsListResult`, `AdminOrdersListResult`, etc. — this exact 4-field envelope repeats across every paginated admin list; treat it as the standard admin list-response envelope for any new domain too).

### 5.5 Sorting, filtering, searching

- Storefront search (`GET /searchitems`) accepts `sort: relevance|price_asc|price_desc|popular|rating`, plus `categoryIDs` (comma-joined), `minPrice`, `maxPrice`, `tag`.
- Admin product list sort (`AdminProductSort`): `title|price|priority|stock|updated`.
- Every admin `*ListFilters` interface has a free-text `search: string` — treat it as a substring match across the domain's primary human-readable fields (name/title/email/order number as applicable) unless a domain-specific note says otherwise.

### 5.6 PATCH vs PUT, DELETE semantics

- **PUT** — full-resource replace or explicit "save current state" (`PUT /builder/bootstrap/draft` — whole document).
- **PATCH** — partial update by convention for status-only or single-field admin mutations (`updateStatus`, `setUserRole`, `setReviewVisible`, etc. — even though the gateway interfaces are transport-agnostic, model these as PATCH since they touch one field/aspect, not the whole entity).
- **DELETE** — categories/products/orders all expose an explicit `delete*` method returning `Observable<void>` (→ `204`), **separate from** `archive*`/`restore*` (soft delete — see below). Reviews (`deleteReview`) and orders (`deleteOrder`) also have hard-delete methods distinct from any archive flow — confirm with product/legal whether hard delete should really be permitted for orders (audit-trail implications) before implementing it as literal data removal versus a tombstone.

### 5.7 Soft delete

Present as a first-class concept in two domains today:
- **Categories** (`AdminCategory.deletedAt: string | null` + `restoreCategory(id)`).
- **Products** (`archived: boolean` + `archiveProduct(id)`/`restoreProduct(id)` — boolean flag, not a timestamp).
- **Orders** (`archived: boolean` + `archiveOrder(id)`/`restoreOrder(id)`).

No universal convention between `deletedAt: string|null` and `archived: boolean` — both are real, keep them domain-consistent with what's documented in §7 rather than unifying speculatively.

### 5.8 Idempotency

Not addressed anywhere in the current frontend contract. Recommend `Idempotency-Key` header support on `POST /builder/bootstrap/publish` and any payment-adjacent endpoint at minimum, since a retried publish/payment call must not double-apply.

### 5.9 Response/error format

See §11 for the single standard error envelope. Success responses have no forced envelope — most endpoints return the resource (or resource array) directly, not wrapped in `{ success, data }`. Follow the shapes documented per-endpoint in §6, not a generic wrapper.

### 5.10 Dates, timezone, UUID

- All timestamps in existing DTOs are ISO 8601 strings (`createdAt`, `updatedAt`, `expiresAt`, `generatedAt`, etc.) — never epoch numbers, except `AdminDashboardActivityEntry.timestamp: number` and `AdminMonitoringEvent`-adjacent internal caching (`Date.now()`), which are frontend-local, not wire-format precedent.
- `TenantConfig.timezone` (IANA string, e.g. `Europe/Moscow`) exists but nothing currently converts times server-side based on it — treat display-timezone conversion as a frontend concern unless otherwise decided.
- `TenantConfig.id: UUID` — `UUID`/`UrlString` are branded string types (`src/app/shared/types/primitive.types.ts`), i.e. plain strings at the wire level; no special serialization.

### 5.11 LocaleMap and translatable fields

The platform-wide rule (ADR-0001): **all user-facing text is translatable via a `translations.{lang}` shape, never a flat hardcoded field.** This repeats across the codebase as `Record<string, T>` keyed by locale code (`ru`/`en`/`hy` in this deployment, but the model itself is not hardcoded to those three): `AdminProduct.translations: Record<string, AdminProductTranslation>`, `AdminCategory.translations: Record<string, AdminCategoryTranslation>`, `StaticPageConfig.translations: Record<string, StaticPageTranslationConfig>`, `NavigationItemConfig.label: string | Record<string, string>`, `FooterConfig.copyrightText: string | Record<string, string>`. When adding/removing a supported locale, every translatable object must expose/drop that locale's key — generically, never per-field hardcoding.

### 5.12 Enums

Enums throughout this document are TypeScript string-literal unions, not numeric — send/return the literal string values exactly as documented (e.g. `"processing"`, not `2`).

---

## 6. Endpoints, by domain

### 6.1 Storefront reads (CURRENT — frozen shapes, `src/app/services/api.service.ts`)

`ApiService` normalizes both legacy and "backOffice" response variants defensively (dual field names, `0x`-hex colours → CSS hex, `names[]` → `translations`, `imgs[]` → `photos`, tolerance for the literal Go-side typo `valuue`). **Do not "fix" these variant shapes on the backend without confirming the frontend normalizer is updated in lockstep** — the normalizer exists precisely because multiple backend response shapes have shipped historically.

| Method | Endpoint | Notes |
|---|---|---|
| `ping()` | `GET /ping` | Health check. |
| `getCategories()` | `GET /category` | Retried ×2 with exponential backoff (`retry({count:2, delay: 2^n * 500ms})`). Cached client-side (§13). |
| `getCategoryItems(id, count=50, skip=0)` | `GET /category/:id?count=&skip=` | Cached client-side. |
| `getItem(id)` | `GET /items/:id` | Rating/reviews/questions derived from the item payload itself (embedded `comments`/`questions`), not a separate call. Cached client-side. |
| `searchItems(search, count=50, skip=0, opts)` | `GET /searchitems?search=&count=&skip=&categoryIDs=&minPrice=&maxPrice=&tag=&sort=` | Returns `{ items: Item[], total: number }`. |
| `getRandomItems(count=5, categoryID?)` | `GET /items/randomitems?count=&category=` | Used for featured/related product rails. |

### 6.2 Storefront writes (CURRENT — frozen shapes)

| Method | Endpoint | Body | Response |
|---|---|---|---|
| `addToCart(sessionId, items)` | `POST /websession/:sessionId` | `Array<{itemID, quantity, colour?, size?, price?}>` | provider-defined |
| `submitReview({...})` | `POST /items/:id/callback` | `{rating, comment, sessionID, timestamp}` | `{message: string}` |
| `submitQuestion({...})` | `POST /items/:id/questiion` | `{question, sessionID, timestamp}` | `{message: string}` — **note the literal path typo `questiion`, keep it; it is a frozen contract, not a bug to fix.** |
| `submitPurchaseEmail({...})` | `POST /purchase-email` | `{email, phone?, telegramUserId, items: [{itemID,name,price,currency,quantity?,delivery?}]}` | `{message: string}` |

Payment endpoints: see §2.8.

### 6.3 Bootstrap — see §4 in full.

### 6.4 Authentication — see §2 in full. Summary table:

| Method | Endpoint | Status |
|---|---|---|
| `POST {authApiUrl}/users/sessions` | Create session | CURRENT |
| `GET {authApiUrl}/users/sessions/:id` | Poll/check session | CURRENT |
| `DELETE {authApiUrl}/users/sessions/:id` | Logout | CURRENT |
| Ed25519 challenge/verify | — | FUTURE (§2.5) |

### 6.5 Product engagement — rating / reviews / questions (CURRENT, per-product, confirm contract)

The frontend already expects and renders against these; if they already exist on the backend, this is a contract-confirmation item, not new work:

| Method | Endpoint | Notes |
|---|---|---|
| GET | `/products/{id}/rating` | Aggregate rating. |
| GET | `/products/{id}/reviews?page=&pageSize=` | Paginated per `productPage.reviews.pageSize` config. |
| GET | `/products/{id}/questions?page=&pageSize=` | Paginated per `productPage.questions.pageSize` config. |
| POST | `/products/{id}/reviews` | `{rating, title, text, anonymous}`. |
| POST | `/products/{id}/questions` | `{text, anonymous}`. |

Toggled per-tenant via `bootstrap.productPage.reviews`/`.questions` (§4.4). If these don't exist yet, they block the Reviews/Questions UI.

### 6.6 Search / autocomplete / trending (PLANNED)

Current: in-memory products/categories/tags power autocomplete (`SearchAutocompleteService`); trending returns `null` (UI hides gracefully, never fabricates data); search history is `LocalSearchHistoryRepository` (guest, localStorage) with a `BackendSearchHistoryRepository` **placeholder class already defined but unimplemented** (`src/app/features/search/services/search-history.repository.ts` — both classes exist side by side, only the backend one is a no-op today).

| Method | Endpoint | Status |
|---|---|---|
| GET | `/search/suggestions?q={term}` | PLANNED |
| GET | `/catalog/filters?category={id}&q={term}` | PLANNED |
| GET/POST/DELETE | `/me/wishlist`, `/me/compare`, `/me/saved-searches`, `/me/recently-viewed` | PLANNED — only needed if authenticated cross-device sync of these lists is wanted; today they're 100% localStorage (`LocalUserExperienceRepository`, keys `marketplace.ux.wishlist` etc.) |

Swapping any of these live requires only replacing the repository implementation — facade/UI unaffected by design.

### 6.7 Builder — Bootstrap draft/publish/validate (PLANNED, highest priority)

The Project Editor edits the same `BootstrapConfig` the storefront reads (§4.8). Today: **load** is real (`GET /bootstrap`), **save** is `localStorage` only (`ProjectEditorDraftStorageService`, key `projectEditor.draftBootstrap.v1`, scoped by `tenant.id` — reload in another browser/tab and it's gone), **publish** re-applies the config in-memory via `PlatformRuntimeService.reloadFromBootstrap()` and flips a local `status` flag — **no backend call happens today.** `ProjectEditorIoService` today only does `JSON.stringify`/`JSON.parse` for manual export/import, no HTTP.

| Method | Endpoint | Body | Response | Notes |
|---|---|---|---|---|
| GET | `/builder/bootstrap/draft` | — | `BootstrapConfig`, or 404/empty if none (draft = published) | Tenant resolved by host, no `projectId` param — one domain = one tenant = one draft = one published bootstrap. |
| PUT | `/builder/bootstrap/draft` | `BootstrapConfig` | 200, updated draft metadata | Persists the draft. **Does not** affect `GET /bootstrap` (storefront-facing) until publish. |
| POST | `/builder/bootstrap/publish` | `BootstrapConfig` (or none, if publish always promotes the stored draft) | 200 | Validates (§9.1), then makes this `BootstrapConfig` what `GET /bootstrap` returns. **Only** endpoint that affects the live storefront. |
| POST | `/builder/bootstrap/validate` | `BootstrapConfig` | `ProjectValidationIssue[]` (see §9.1 for the exact shape) | Optional but recommended — mirrors the client validator so the editor can preview server-side issues before attempting publish. |

**Mandatory:** server-side re-validation on publish, equivalent to `ProjectValidator` (§9.1) at minimum. The client validator is not a trust boundary — a malicious or stale client can bypass all client-side checks.

Frontend files that change once this exists: `features/project-editor/facade/project-editor.facade.ts` (replace local save/publish with these endpoints, same public method signatures), `services/project-editor-draft-storage.service.ts` (becomes a fallback/offline cache, not the primary store).

### 6.8 Builder — Content pages / CMS (PLANNED)

Static pages are today edited entirely client-side and written only into the in-memory/localStorage bootstrap draft (§6.7) — no dedicated backend exists.

| Method | Endpoint | Notes |
|---|---|---|
| GET | `/builder/content-pages` | List, same shape as `bootstrap.staticPages`. |
| PUT | `/builder/content-pages` | Replace the set. |
| POST | `/builder/content-pages/import` | Bulk import. |
| GET | `/builder/content-pages/export` | Bulk export. |
| POST | `/builder/content-pages/validate` | Server-side HTML/SEO validation (§9.5). |

Backend must also support **content moderation/validation on publish** (disallow dangerous tags/attributes) and **revision history** — the frontend only sanitizes at render time (`MarketplaceHtmlEditorComponent` emits raw HTML with no sanitization by design; sanitization is a storefront-render concern, not an authoring concern), never at authoring time.

### 6.9 Backoffice — Categories (PLANNED)

Interface: `AdminCategoriesGateway` (`src/app/features/admin/categories/services/admin-categories-gateway.interface.ts`). Today: `AdminCategoriesLocalGateway`, in-memory, seeded once from `BackofficeDataService.loadCategories()` — nothing persists across reload. `GET /category` (§6.1, storefront-facing) is unaffected and stays as-is.

```ts
interface AdminCategoriesGateway {
  loadCategories(filters: AdminCategoryListFilters): Observable<AdminCategory[]>;
  loadCategory(id: string): Observable<AdminCategory | null>;
  createCategory(category: AdminCategory): Observable<AdminCategory>;
  updateCategory(category: AdminCategory): Observable<AdminCategory>;
  deleteCategory(id: string): Observable<void>;
  restoreCategory(id: string): Observable<AdminCategory | null>;
  isSlugTaken(slug: string, excludingId: string | null): Observable<boolean>;
}
```

| Method | Endpoint | Body | Response |
|---|---|---|---|
| GET | `/backoffice/categories?search=&visibility=&includeDeleted=` | — | `AdminCategory[]` |
| GET | `/backoffice/categories/:id` | — | `AdminCategory \| 404` |
| POST | `/backoffice/categories` | `AdminCategory` | `201 AdminCategory` |
| PUT | `/backoffice/categories/:id` | `AdminCategory` | `200 AdminCategory` |
| DELETE | `/backoffice/categories/:id` | — | `204` (soft delete — sets `deletedAt`) |
| POST | `/backoffice/categories/:id/restore` | — | `200 AdminCategory` (clears `deletedAt`) |
| GET | `/backoffice/categories/slug-taken?slug=&excludingId=` | — | `{taken: boolean}` |
| PATCH | `/backoffice/categories/reorder` | `{id, order}[]` | `204` — bulk reorder endpoint, not in the gateway interface today but needed since `AdminCategory.order` exists and the editor drag-reorders |

DTO: see §7.1.

### 6.10 Backoffice — Products (PLANNED)

Interface: `AdminProductsGateway` (`src/app/features/admin/products/services/admin-products-gateway.interface.ts`). Today: `AdminProductsLocalGateway`, in-memory.

```ts
interface AdminProductsGateway {
  loadProducts(filters: AdminProductListFilters): Observable<AdminProductsListResult>;
  loadProduct(id: string): Observable<AdminProduct | null>;
  loadCategories(): Observable<AdminProductCategoryOption[]>;
  createProduct(product: AdminProduct): Observable<AdminProduct>;
  updateProduct(product: AdminProduct): Observable<AdminProduct>;
  deleteProduct(id: string): Observable<void>;
  duplicateProduct(id: string): Observable<AdminProduct | null>;
  archiveProduct(id: string): Observable<void>;
  restoreProduct(id: string): Observable<AdminProduct | null>;
}
```

| Method | Endpoint | Body | Response |
|---|---|---|---|
| GET | `/backoffice/products?search=&categoryId=&visibility=&stock=&includeArchived=&sort=&page=&pageSize=` | — | `AdminProductsListResult` (`{items, total, page, pageSize}`) |
| GET | `/backoffice/products/:id` | — | `AdminProduct \| 404` |
| GET | `/backoffice/products/categories` | — | `AdminProductCategoryOption[]` (`{id, title}`) — **still wired to its own seed today, not `AdminCategoriesGateway`; unify when both backends exist.** |
| POST | `/backoffice/products` | `AdminProduct` | `201 AdminProduct` |
| PUT | `/backoffice/products/:id` | `AdminProduct` | `200 AdminProduct` |
| DELETE | `/backoffice/products/:id` | — | `204` (hard delete) |
| POST | `/backoffice/products/:id/duplicate` | — | `201 AdminProduct` |
| POST | `/backoffice/products/:id/archive` | — | `204` (soft archive) |
| POST | `/backoffice/products/:id/restore` | — | `200 AdminProduct` |
| PATCH | `/backoffice/products/bulk` | `{ids: string[], visible?: boolean, delete?: boolean}` | `204` — matches `applyBulkVisibility`/`applyBulkDelete` semantics referenced in the facade |

DTO: see §7.2, including the production-matching flat-variant shape (`AdminProductVariant`) — this is the highest-fidelity DTO in this document; read §7.2 carefully before implementing.

### 6.11 Backoffice — Orders (PLANNED)

Interface: `AdminOrdersGateway`. Today: `AdminOrdersLocalGateway` fabricates 24 synthetic in-memory orders — **no real order data exists anywhere in this system today.** The dashboard's Orders/Revenue cards intentionally render `pending-backend` rather than reading from this mock — they are not wired to it on purpose (the mock is order-management UI scaffolding, not a metrics source).

```ts
interface AdminOrdersGateway {
  loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult>;
  loadOrder(id: string): Observable<AdminOrder | null>;
  updateStatus(id: string, status: AdminOrderStatus, note: string): Observable<AdminOrder | null>;
  requestRefund(id: string): Observable<AdminOrder | null>;
  addNote(id: string, note: string, internal: boolean): Observable<AdminOrder | null>;
  archiveOrder(id: string): Observable<AdminOrder | null>;
  restoreOrder(id: string): Observable<AdminOrder | null>;
  deleteOrder(id: string): Observable<void>;
}
```

| Method | Endpoint | Body | Response |
|---|---|---|---|
| GET | `/backoffice/orders?search=&status=&page=&pageSize=` | — | `AdminOrdersListResult` |
| GET | `/backoffice/orders/:id` | — | `AdminOrder \| 404` |
| PATCH | `/backoffice/orders/:id/status` | `{status: AdminOrderStatus, note: string}` | `200 AdminOrder` — must enforce the order state machine (§8.1) |
| POST | `/backoffice/orders/:id/refund` | — | `200 AdminOrder` (sets `payment.status = 'refund_requested'`) |
| POST | `/backoffice/orders/:id/notes` | `{note: string, internal: boolean}` | `200 AdminOrder` |
| POST | `/backoffice/orders/:id/archive` | — | `200 AdminOrder` |
| POST | `/backoffice/orders/:id/restore` | — | `200 AdminOrder` |
| DELETE | `/backoffice/orders/:id` | — | `204` |
| GET | `/backoffice/orders/export.csv` | — | CSV — UI has a CSV-export action |
| GET | `/backoffice/orders/:id/invoice` | — | printable invoice — UI has a print-invoice action |
| GET | `/backoffice/dashboard/revenue-summary` | — | aggregation feeding the dashboard's currently-`pending-backend` Orders/Revenue cards |

DTO: see §7.3, state machine: §8.1.

### 6.12 Backoffice — Transactions (PLANNED)

Interface: `AdminTransactionsGateway`. Today: `AdminTransactionsLocalGateway` derives one synthetic transaction per seeded mock order — **no real payment/transaction data exists.**

```ts
interface AdminTransactionsGateway {
  loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult>;
  retryFailed(id: string): Observable<AdminTransaction | null>;
  setFraudFlag(id: string, flagged: boolean): Observable<AdminTransaction | null>;
}
```

| Method | Endpoint | Body | Response |
|---|---|---|---|
| GET | `/backoffice/transactions?search=&status=&type=&page=&pageSize=` | — | `AdminTransactionsListResult` |
| POST | `/backoffice/transactions/:id/retry` | — | `200 AdminTransaction` — semantics depend on whatever the real payment provider supports for retry |
| PATCH | `/backoffice/transactions/:id/fraud-flag` | `{flagged: boolean}` | `200 AdminTransaction` |
| GET | `/backoffice/transactions/export.csv` | — | CSV export |
| GET | `/backoffice/transactions/:id/audit` | — | `AdminTransactionAuditEntry[]` |

Needed: a real payments/transactions domain (card, QR, cash-on-delivery), linked to orders, with fraud-flag persistence. DTO: §7.4.

### 6.13 Backoffice — Users, roles, invitations (PLANNED)

Interface: `AdminUsersGateway`. Today: `AdminUsersLocalGateway`, fully synthetic. **Passwordless login itself is real** (§2.4) — only the roles/permissions/invitations/multi-session-listing layer on top is mocked.

```ts
interface AdminUsersGateway {
  loadUsers(): Observable<AdminUser[]>;
  loadRoles(): Observable<AdminRole[]>;
  loadInvitations(): Observable<AdminInvitation[]>;
  loadSessions(userId: string): Observable<AdminSession[]>;
  loadAudit(userId: string): Observable<AdminUserAuditEntry[]>;
  setUserRole(userId: string, roleId: string): Observable<AdminUser | null>;
  setUserStatus(userId: string, status: AdminUserStatus): Observable<AdminUser | null>;
  inviteUser(email: string, roleId: string, scope: AdminUserScope): Observable<AdminInvitation>;
  revokeInvitation(id: string): Observable<void>;
  revokeSession(sessionId: string): Observable<void>;
}
```

| Method | Endpoint | Body | Response |
|---|---|---|---|
| GET | `/backoffice/users` | — | `AdminUser[]` |
| GET | `/backoffice/roles` | — | `AdminRole[]` |
| GET | `/backoffice/invitations` | — | `AdminInvitation[]` |
| GET | `/backoffice/users/:id/sessions` | — | `AdminSession[]` — **must reflect real multi-device sessions**, unlike today's `AdminAuthService` which only knows the current browser's session |
| GET | `/backoffice/users/:id/audit` | — | `AdminUserAuditEntry[]` |
| PATCH | `/backoffice/users/:id/role` | `{roleId: string}` | `200 AdminUser` |
| PATCH | `/backoffice/users/:id/status` | `{status: AdminUserStatus}` | `200 AdminUser` |
| POST | `/backoffice/invitations` | `{email, roleId, scope}` | `201 AdminInvitation` — must trigger a real invitation email |
| DELETE | `/backoffice/invitations/:id` | — | `204` |
| DELETE | `/backoffice/sessions/:id` | — | `204` — revoke a specific device session |

Ties directly to the §2.5 admin-authorization gap — role assignment here is meaningless until the auth layer actually enforces roles. DTO: §7.5–§7.6.

### 6.14 Backoffice — Moderation (reviews & reports) (PLANNED)

Interface: `AdminModerationGateway`. No `*LocalGateway` file distinct from the interface was found under a separate name in this pass beyond `admin-moderation-local.gateway.ts` — same pattern as everywhere else (mock today, swappable).

```ts
interface AdminModerationGateway {
  loadReviews(filters: AdminReviewListFilters): Observable<AdminReviewsListResult>;
  loadReview(id: string): Observable<AdminReview | null>;
  setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable<AdminReview | null>;
  setReviewVisible(id: string, visible: boolean): Observable<AdminReview | null>;
  setReviewPinned(id: string, pinned: boolean): Observable<AdminReview | null>;
  setReviewFeatured(id: string, featured: boolean): Observable<AdminReview | null>;
  addModeratorNote(id: string, note: string): Observable<AdminReview | null>;
  deleteReview(id: string): Observable<void>;
  loadReports(): Observable<AdminReport[]>;
  setReportStatus(id: string, status: AdminReportStatus): Observable<AdminReport | null>;
}
```

| Method | Endpoint | Body | Response |
|---|---|---|---|
| GET | `/backoffice/reviews?search=&status=&rating=&page=&pageSize=` | — | `AdminReviewsListResult` |
| GET | `/backoffice/reviews/:id` | — | `AdminReview \| 404` |
| PATCH | `/backoffice/reviews/:id/status` | `{status: AdminReviewStatus, note: string}` | `200 AdminReview` (state machine §8.4) |
| PATCH | `/backoffice/reviews/:id/visible` | `{visible: boolean}` | `200 AdminReview` |
| PATCH | `/backoffice/reviews/:id/pinned` | `{pinned: boolean}` | `200 AdminReview` |
| PATCH | `/backoffice/reviews/:id/featured` | `{featured: boolean}` | `200 AdminReview` |
| POST | `/backoffice/reviews/:id/notes` | `{note: string}` | `200 AdminReview` |
| DELETE | `/backoffice/reviews/:id` | — | `204` |
| GET | `/backoffice/reports` | — | `AdminReport[]` |
| PATCH | `/backoffice/reports/:id/status` | `{status: AdminReportStatus}` | `200 AdminReport` |

DTO: §7.7.

### 6.15 Backoffice — Dashboard metrics & recent activity (PLANNED)

Interface: `AdminDashboardMetricsGateway`. Today: `AdminDashboardMetricsLocalGateway` composes `BackofficeDataService.loadCategories()/loadProducts()` client-side into counts; everything else on the dashboard (marketplace status, theme, languages, last publish/save, bootstrap version, active layout, enabled widgets, system health) is derived from `ProjectEditorFacade` state, not a metrics endpoint. Recent Activity is `AdminDashboardHistoryService`, `localStorage`-backed, scoped per tenant (`adminDashboard.activityHistory.v1`) — will never show another editor's activity.

```ts
interface AdminDashboardMetricsGateway {
  loadMetrics(): Observable<AdminDashboardMetrics>; // today only { categoriesCount, productsCount }
}
```

| Method | Endpoint | Response |
|---|---|---|
| GET | `/backoffice/dashboard/summary` | Real-time counts and trend deltas — richer than today's `{categoriesCount, productsCount}` |
| GET | `/backoffice/dashboard/activity` | Real audit-log-backed activity feed, readable by multiple concurrent admin sessions (unlike today's per-browser localStorage) |

DTO: §7.8.

### 6.16 Backoffice — Monitoring (PLANNED except Health)

Interface: `AdminMonitoringGateway`. Health section reads real data (`AdminDashboardFacade.healthChecks`) already — unaffected. Everything else (audit/security/login/failed-login/API/error/warning event feed, queue depths, webhook deliveries) is synthetic — **no logging, queue, or webhook infrastructure exists anywhere in this system today.**

```ts
interface AdminMonitoringGateway {
  loadEvents(filters: AdminMonitoringEventFilters): Observable<AdminMonitoringEvent[]>;
  loadQueues(): Observable<AdminQueue[]>;
  loadWebhooks(): Observable<AdminWebhookDelivery[]>;
}
```

| Method | Endpoint | Response |
|---|---|---|
| GET | `/backoffice/monitoring/events?category=&search=` | `AdminMonitoringEvent[]` — real structured logging with a query API by category/level/actor/time-range |
| GET | `/backoffice/monitoring/queues` | `AdminQueue[]` — real queue introspection, once a job runner exists |
| GET | `/backoffice/monitoring/webhooks` | `AdminWebhookDelivery[]` — real webhook delivery tracking, once webhooks exist as a feature at all (§15) |

DTO: §7.9.

### 6.17 Backoffice — Analytics (mostly FUTURE — no data source)

`features/admin/analytics/` computes real revenue/orders/top-products aggregations from mock order data (§6.11), but visitor traffic, conversion funnels, and heatmaps have **zero data source anywhere in this system** — no analytics/tracking pipeline, no event collection. The page renders `pending-backend` badges rather than fabricated numbers; no gateway method exists for this yet, unlike every other mocked domain.

| Method | Endpoint | Status |
|---|---|---|
| GET | `/backoffice/analytics/summary` | PLANNED — `AdminAnalyticsSummary`, revenue/orders/customers real once orders are real |
| GET | `/backoffice/analytics/traffic` | FUTURE — needs an actual tracking pipeline |
| GET | `/backoffice/analytics/funnels` | FUTURE |
| GET | `/backoffice/analytics/heatmaps` | FUTURE |

DTO: §7.10.

### 6.18 Media (PLANNED — ADR-0002)

Domain model and contract are already spec'd in `docs/context/adrs/ADR-0002-media-manager-contract.md`; reproduced here as the canonical copy. `MediaRepository` (`src/app/core/media/media-repository.ts`) abstract class, two implementations selected via DI token: `MockMediaRepository` (IndexedDB — not localStorage, binary blobs need it) and `HttpMediaRepository` (to be built).

```ts
abstract class MediaRepository {
  abstract list(params?: MediaListParams): Promise<MediaListResult>;
  abstract upload(file: File, options?: MediaUploadOptions): Promise<MediaAsset>;
  abstract remove(id: string): Promise<void>;
  abstract update(id: string, patch: Partial<Pick<MediaAsset,'altText'|'tags'|'folder'|'caption'|'description'|'decorative'>>): Promise<MediaAsset>;
  abstract listFolders(): Promise<string[]>;
}
```

| Method | Endpoint | Body | Response |
|---|---|---|---|
| GET | `/media?page=&pageSize=&search=&folder=&tag=&kind=&sort=` | — | `MediaListResult` (`{items: MediaAsset[], total: number}`) |
| POST | `/media/upload` | multipart, `+ folder?, tags?[]` | `201 MediaAsset` |
| DELETE | `/media/:id` | — | `204` |
| PATCH | `/media/:id` | `{altText?, tags?, folder?, caption?, description?, decorative?}` | `200 MediaAsset` |
| GET | `/media/folders` | — | `string[]` |

**Media never enters the Bootstrap model** — like products/orders/users, media assets are runtime admin data, not tenant configuration (ADR-0001). DTO: §7.11.

### 6.19 Sitemap (FUTURE — static baseline only today)

`public/sitemap.xml` currently lists only the statically-known top-level routes (home/catalog/search/wishlist/compare) for the default locale, referenced from `public/robots.txt`. This platform is multi-tenant and config-driven — supported locales, categories, products, static pages are all resolved at runtime per tenant, not enumerable from the frontend at build time. A real per-tenant sitemap covering `/:lang/product/:id`, `/:lang/catalog/:categoryId`, `/:lang/:staticPath` needs a build-time or server-side job reading the same per-tenant data source (categories/products/static pages) and regenerating/serving this file dynamically — not something the SPA can produce correctly on its own.

---

## 7. DTOs

Every interface below is lifted directly from its cited source file — field lists, optionality, and nullability are exact, not paraphrased.

### 7.1 Categories — `src/app/features/admin/categories/models/admin-category.model.ts`

```ts
type AdminCategoryStatus = 'draft' | 'published';

interface AdminCategoryTranslation { title?: string; description?: string; seoTitle?: string; seoDescription?: string; }
interface AdminCategorySeo { metaTitle: string; metaDescription: string; keywords: string; }
interface AdminCategoryAttribute { key: string; value: string; }

interface AdminCategory {
  id: string;
  parentId: string | null;         // hierarchy
  title: string;
  slug: string;
  description: string;
  icon: string;
  imageUrl: string;
  imageAlt: string;
  order: number;
  visible: boolean;
  status: AdminCategoryStatus;     // draft | published — generated field, editor-set
  itemsCount: number;              // generated field, backend-computed
  translations: Record<string, AdminCategoryTranslation>;
  seo: AdminCategorySeo;
  attributes: AdminCategoryAttribute[];
  deletedAt: string | null;        // generated, soft-delete
  createdAt: string;               // generated
  updatedAt: string;               // generated
}

interface AdminCategoryListFilters { search: string; visibility: 'all'|'visible'|'hidden'; includeDeleted: boolean; }
```

Note vs. the storefront-facing `CategoryDto`/`CategoryCardConfig` (`src/app/features/admin/**` seed source): those lack `parentId`, `slug`, `icon`, `imageUrl`, `status`, `deletedAt`, `seo`, `translations` — the admin model is a superset. Reconciling the two into one backend category table is a real design decision for the backend team; this document only certifies what the frontend needs, not how storage should be normalized.

### 7.2 Products — `src/app/features/admin/products/models/admin-product.model.ts`

```ts
type AdminProductStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock';
type AdminProductSort = 'title' | 'price' | 'priority' | 'stock' | 'updated';

interface AdminProductMedia { images: string[]; gallery: string[]; videos: string[]; }
interface AdminProductSpecification { key: string; value: string; }

/**
 * Matches the production variant shape: a flat list of attribute-value
 * combinations, each priced per currency, e.g.
 * {color:'0x8B4513', size:'S', price:62560, currency:'RUB', remaining:100}.
 * The admin UI groups rows sharing the same attribute combo into one
 * AdminProductVariant with multiple prices, then flattens back on save.
 */
interface AdminProductVariantPrice { currency: string; price: number; }
interface AdminProductVariant {
  id: string;
  attributes: Record<string, string>;  // e.g. { color: '0x8B4513', size: 'S' }
  sku: string;
  image: string;
  remaining: number;
  prices: AdminProductVariantPrice[];
}
interface AdminProductVariantAttributeDef {
  key: string;       // lowercase, backend field name (e.g. 'color', 'size')
  label: string;
  isColor: boolean;
  values: string[];
}

interface AdminProductAttribute { key: string; value: string; }
interface AdminProductTranslation { name?: string; shortDescription?: string; htmlDescription?: string; seoTitle?: string; seoDescription?: string; }
interface AdminProductSeo { metaTitle: string; metaDescription: string; keywords: string; }
interface AdminProductReview { id: string; author: string; rating: number; text: string; }
interface AdminProductQuestion { id: string; question: string; answer?: string; }

interface AdminProduct {
  id: string; name: string; slug: string; sku: string; barcode: string; brand: string;
  categoryId: string;
  visible: boolean; archived: boolean; priority: number;
  media: AdminProductMedia;
  price: number; discount: number; currency: string; quantity: number;
  stockStatus: AdminProductStockStatus; availability: string;
  shortDescription: string; htmlDescription: string;
  specifications: AdminProductSpecification[];
  attributes: AdminProductAttribute[];
  variantAttributes: AdminProductVariantAttributeDef[];
  variants: AdminProductVariant[];
  relatedProductIds: string[];
  translations: Record<string, AdminProductTranslation>;
  seo: AdminProductSeo;
  featured: boolean; recommended: boolean; isNew: boolean; bestseller: boolean;
  badges: string[];
  reviews: AdminProductReview[]; questions: AdminProductQuestion[];
  createdAt: string; updatedAt: string;
}

interface AdminProductListFilters {
  search: string; categoryId: string | null; visibility: 'all'|'visible'|'hidden';
  stock: 'all' | AdminProductStockStatus; includeArchived: boolean; sort: AdminProductSort;
  page: number; pageSize: number;
}
interface AdminProductsListResult { items: AdminProduct[]; total: number; page: number; pageSize: number; }
interface AdminProductCategoryOption { id: string; title: string; }
```

**Validation notes for the backend:** `sku` must be unique per tenant (§9.4). `variants[].sku` should also be unique among a product's own variants at minimum. `price`/`currency` required at the top level even when `variants` carries its own per-combination `prices` — top-level price is the "from" price shown on listing cards.

### 7.3 Orders — `src/app/features/admin/orders/models/admin-order.model.ts`

```ts
type AdminOrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
type AdminOrderPaymentStatus = 'unpaid' | 'paid' | 'refund_requested' | 'refunded';

interface AdminOrderCustomer { name: string; email: string; phone: string; }
interface AdminOrderPayment { method: string; status: AdminOrderPaymentStatus; amount: number; currency: string; }
interface AdminOrderShipping { address: string; method: string; trackingNumber: string; }
interface AdminOrderItem { productId: string; name: string; quantity: number; price: number; }
interface AdminOrderTimelineEntry { status: AdminOrderStatus; timestamp: string; note: string; }

interface AdminOrder {
  id: string; orderNumber: string; status: AdminOrderStatus;
  customer: AdminOrderCustomer; payment: AdminOrderPayment; shipping: AdminOrderShipping;
  items: AdminOrderItem[]; total: number; currency: string;
  notes: string; internalNotes: string;
  timeline: AdminOrderTimelineEntry[];  // audit trail of every status change
  archived: boolean;
  createdAt: string; updatedAt: string;
}

interface AdminOrderListFilters { search: string; status: 'all'|AdminOrderStatus; page: number; pageSize: number; }
interface AdminOrdersListResult { items: AdminOrder[]; total: number; page: number; pageSize: number; }
```

`AdminCustomer` (`src/app/features/admin/customers/models/admin-customer.model.ts`) is **not its own stored entity** — it's derived by grouping `AdminOrder` records by `customer.email`, since no dedicated customer gateway/backend exists:

```ts
interface AdminCustomer {
  email: string; name: string; phone: string;
  orderCount: number; totalSpent: number; currency: string;
  firstOrderAt: string; lastOrderAt: string;
  addresses: string[]; orders: AdminOrder[];
}
```

If/when a real customer domain is built, this becomes a real GET endpoint rather than a client-side aggregation — every field here should map 1:1 to a real column/aggregate, never fabricated.

### 7.4 Transactions — `src/app/features/admin/transactions/models/admin-transaction.model.ts`

```ts
type AdminTransactionType = 'payment' | 'refund' | 'qr_payment';
type AdminTransactionStatus = 'pending' | 'success' | 'failed' | 'retried';

interface AdminTransactionAuditEntry { action: string; actor: string; timestamp: string; }

interface AdminTransaction {
  id: string; orderId: string; orderNumber: string;
  type: AdminTransactionType; method: string; status: AdminTransactionStatus;
  amount: number; currency: string;
  fraudFlag: boolean;
  audit: AdminTransactionAuditEntry[];
  createdAt: string; updatedAt: string;
}

interface AdminTransactionListFilters { search: string; status: 'all'|AdminTransactionStatus; type: 'all'|AdminTransactionType; page: number; pageSize: number; }
interface AdminTransactionsListResult { items: AdminTransaction[]; total: number; page: number; pageSize: number; }
```

### 7.5 Users, roles, invitations, sessions — `src/app/features/admin/users/models/admin-user.model.ts`

```ts
type AdminUserScope = 'marketplace' | 'office';
type AdminUserStatus = 'active' | 'invited' | 'suspended';
type AdminInvitationStatus = 'pending' | 'accepted' | 'expired' | 'revoked';

interface AdminRole { id: string; name: string; permissions: string[]; builtIn: boolean; }

interface AdminUser {
  id: string; name: string; telegramUsername: string; email: string;
  scope: AdminUserScope; roleId: string; status: AdminUserStatus;
  lastLoginAt: string | null; createdAt: string;
}

interface AdminInvitation { id: string; email: string; roleId: string; scope: AdminUserScope; status: AdminInvitationStatus; invitedAt: string; expiresAt: string; }
interface AdminSession { id: string; userId: string; device: string; ip: string; lastActiveAt: string; current: boolean; }
interface AdminUserAuditEntry { action: string; actor: string; timestamp: string; }
```

### 7.6 Permissions (bootstrap-level skeleton) — `src/app/shared/models/config/permissions.model.ts`

```ts
interface PermissionDefinition { key: string; description?: string; }
interface RolePermissions { role: string; permissions: string[]; }
interface PermissionsConfig { definitions: PermissionDefinition[]; roles: RolePermissions[]; }
```

### 7.7 Moderation — reviews & reports

```ts
// admin-review.model.ts
type AdminReviewStatus = 'pending' | 'approved' | 'rejected' | 'spam';
interface AdminReviewTimelineEntry { action: string; actor: string; note: string; timestamp: string; }
interface AdminReview {
  id: string; productId: string; productName: string;
  customerName: string; customerEmail: string;
  rating: number; text: string; photos: string[];
  status: AdminReviewStatus; visible: boolean; pinned: boolean; featured: boolean;
  reportCount: number; moderatorNotes: string;
  timeline: AdminReviewTimelineEntry[];
  createdAt: string; updatedAt: string;
}
interface AdminReviewListFilters { search: string; status: 'all'|AdminReviewStatus; rating: 'all'|number; page: number; pageSize: number; }
interface AdminReviewsListResult { items: AdminReview[]; total: number; page: number; pageSize: number; }

// admin-report.model.ts
type AdminReportTargetType = 'product' | 'review' | 'customer' | 'category' | 'unknown';
type AdminReportStatus = 'open' | 'resolved' | 'dismissed';
interface AdminReport { id: string; targetType: AdminReportTargetType; targetId: string; targetLabel: string; reason: string; reporterEmail: string; status: AdminReportStatus; createdAt: string; }
```

### 7.8 Dashboard — `src/app/features/admin/dashboard/models/admin-dashboard.model.ts`

```ts
type AdminDashboardCardStatus = 'loading' | 'ready' | 'empty' | 'error' | 'pending-backend';
interface AdminDashboardCardState<T> { status: AdminDashboardCardStatus; value: T | null; }
interface AdminDashboardMetrics { categoriesCount: number; productsCount: number; } // today's real shape — expand per §6.15

type AdminDashboardHealthStatus = 'healthy' | 'attention' | 'unhealthy' | 'unknown' | 'loading';
interface AdminDashboardHomeHealthCheck { code: string; labelKey: string; status: AdminDashboardHealthStatus; displayValue?: string | null; }
interface AdminDashboardActivityEntry { id: string; type: 'draft-saved'|'published'; timestamp: number; }
```

`pending-backend` is a real, intentional card status — it means "this sprint has no data source for this metric yet," rendered honestly instead of a fabricated number. Any new metrics endpoint should let the frontend distinguish "zero" from "no data source" the same way (e.g. omit the field, or use `null`, rather than sending `0`).

### 7.9 Monitoring — `src/app/features/admin/monitoring/models/admin-monitoring.model.ts`

```ts
type AdminMonitoringCategory = 'audit' | 'security' | 'login' | 'failed_login' | 'api' | 'error' | 'warning';
type AdminMonitoringLevel = 'info' | 'warning' | 'error';
type AdminQueueStatus = 'healthy' | 'degraded' | 'down';
type AdminWebhookStatus = 'delivered' | 'failed' | 'pending';

interface AdminMonitoringEvent { id: string; category: AdminMonitoringCategory; level: AdminMonitoringLevel; message: string; actor: string; timestamp: string; }
interface AdminMonitoringEventFilters { category: 'all'|AdminMonitoringCategory; search: string; }
interface AdminQueue { name: string; depth: number; status: AdminQueueStatus; }
interface AdminWebhookDelivery { id: string; endpoint: string; event: string; status: AdminWebhookStatus; timestamp: string; }
```

Payment/QR types (`src/app/services/api.service.ts` lines 10–66, frozen — §2.8):

```ts
interface QrCreateRequest { qrtype: 'QRDynamic'; amount: number; currency: 'RUB'; partnerqrID?: string; qrDescription?: string; Userid?: string; Reference?: string; RedirectUrl?: string; }
interface QrCreateResponse { qrId?: string; qrID?: string; nspkID?: string; nspkId?: string; nspkurl?: string; orderID?: string; url?: string; bankUrl?: string; status?: string; qrStatus?: string; qrExpirationDate?: string; payload?: string; Payload?: string; qrUrl?: string; partnerqrID?: string|number; partnerID?: string|number; partnerId?: string|number; PartnerID?: string|number; }
interface CartPaymentRequest { amount: number; currency: 'RUB'; siteuserID: string; siteorderID: string; redirectUrl: string; telegramUsername: string; paymentMethod: 'qr'|'card'; items: Array<{itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[]}>; }
interface QrDynamicStatusResponse { additionalInfo: string; paymentPurpose: string; amount: number; code: string; createDate: string; currency: string; order: string; status: string; qrId: string; transactionDate: string; transactionId: number; qrExpirationDate: string; }
```

### 7.10 Analytics — `src/app/features/admin/analytics/models/admin-analytics.model.ts`

```ts
type AdminAnalyticsDateRange = 7 | 30 | 90;
interface AdminAnalyticsSummary {
  revenueTotal: number; currency: string; ordersCount: number; avgOrderValue: number;
  productsCount: number; categoriesCount: number; customersCount: number;
  conversionRate: number | null; // null = unknown, no visitor/traffic tracking exists yet — never fabricated
}
interface AdminAnalyticsSeriesPoint { date: string; value: number; }
interface AdminAnalyticsTopProduct { productId: string; name: string; quantity: number; revenue: number; }
interface AdminLowStockProduct { productId: string; name: string; quantity: number; stockStatus: 'low_stock'|'out_of_stock'; }
interface AdminRecentActivityEntry { id: string; labelKey: string; timestamp: number; }
type AdminMarketplaceHealthStatus = 'healthy' | 'attention' | 'unhealthy' | 'unknown';
interface AdminMarketplaceHealthCheck { code: string; labelKey: string; status: AdminMarketplaceHealthStatus; displayValue?: string | null; actionRoute?: string[]; }
interface AdminProductAnalyticsRow { productId: string; name: string; value: number; }
interface AdminProductAnalytics { topSelling: AdminAnalyticsTopProduct[]; mostReviewed: AdminProductAnalyticsRow[]; worstRated: AdminProductAnalyticsRow[]; hiddenCount: number; archivedCount: number; }
interface AdminCustomerAnalytics { newCustomers: number; returningCustomers: number; averageSpend: number; currency: string; retentionPercent: number | null; }
type AdminRecommendationSeverity = 'info' | 'warning' | 'critical';
interface AdminRecommendationCard { id: string; labelKey: string; descriptionKey?: string; severity: AdminRecommendationSeverity; route: string[]; }
```

`conversionRate: number | null` and `retentionPercent: number | null` are the platform's explicit convention for "no data source yet" — `null` is a first-class, meaningful value here, not an oversight. Replicate this pattern for any new metric without a real source rather than defaulting to `0`.

### 7.11 Media — `src/app/core/media/models/media-asset.model.ts`

```ts
type MediaAssetKind = 'image' | 'svg' | 'pdf' | 'other';
interface MediaAsset {
  id: string; url: string; thumbnailUrl?: string;
  filename: string; mimeType: string; size: number;
  width?: number; height?: number;
  altText?: Record<string, string>;   // translations.{lang} rule
  caption?: string; description?: string;
  decorative?: boolean;                // suppresses missing-alt-text warning
  tags?: string[]; folder?: string;
  createdAt: string;
}
type MediaSort = 'recent' | 'name' | 'size';
interface MediaListParams { page?: number; pageSize?: number; search?: string; folder?: string; tag?: string; kind?: MediaAssetKind; sort?: MediaSort; }
interface MediaUploadOptions { folder?: string; tags?: string[]; }
interface MediaListResult { items: MediaAsset[]; total: number; }
```

### 7.12 Bootstrap DTOs — see §4.3–§4.6 for the complete `BootstrapConfig` tree (not repeated here to avoid duplication).

---

## 8. State machines

### 8.1 Orders — `AdminOrderStatus`

```
pending → processing → shipped → delivered
pending → cancelled
processing → cancelled
shipped → delivered
delivered → refunded   (via payment.status refund_requested → refunded, not a direct status jump)
* → refunded            (only through the refund request flow, never a direct status write)
```

Invalid transitions to reject server-side: `delivered → pending`, `cancelled → *` (terminal), `refunded → *` (terminal), skipping `shipped` to jump straight `processing → delivered` is allowed by the model (no `shipped` requirement enforced client-side) but should be a deliberate backend policy decision, not silently permitted. Every transition must append an `AdminOrderTimelineEntry {status, timestamp, note}` — the timeline is the audit trail, never overwritten.

### 8.2 Payment status — `AdminOrderPaymentStatus`

```
unpaid → paid
paid → refund_requested
refund_requested → refunded
refund_requested → paid   (refund rejected, reverts)
```

### 8.3 Products — implicit via `visible`/`archived` booleans, not an enum

```
(new) → visible: true, archived: false
visible: true ⇄ visible: false        (toggle, reversible, any time)
archived: false → archived: true      (via archiveProduct — soft archive)
archived: true → archived: false      (via restoreProduct)
archived: true → (hard delete)        (via deleteProduct — irreversible)
```

`stockStatus` (`in_stock|low_stock|out_of_stock`) is derived from `quantity`, not independently settable — treat it as backend-computed, never accept it as client input on create/update.

### 8.4 Reviews — `AdminReviewStatus`

```
pending → approved
pending → rejected
pending → spam
approved ⇄ rejected   (moderator can reverse a decision)
* → spam               (any state can be flagged spam)
```

Every status change should append an `AdminReviewTimelineEntry`. `visible`, `pinned`, `featured` are independent booleans layered on top of `status` — a `rejected` review should probably force `visible: false` server-side regardless of what the client sends (defense in depth), even though nothing in the current frontend enforces that coupling.

### 8.5 Reports — `AdminReportStatus`

```
open → resolved
open → dismissed
resolved ⇄ dismissed   (reversible, e.g. re-opened as a mistake)
```

### 8.6 Static pages — `status: 'draft' | 'published'` (per-page, independent of whole-bootstrap draft/publish)

```
draft → published
published → draft   (unpublish)
```

Independent from the category/product `draft|published` pattern — no shared enum type, don't unify speculatively.

### 8.7 Sessions — `AuthStatus` / `AdminAuthStatus`

```
unknown → checking → authenticated
unknown → checking → unauthenticated
authenticated → unauthenticated   (logout, or expiry check fails)
```

### 8.8 Users — `AdminUserStatus`

```
invited → active     (invitation accepted)
active ⇄ suspended
invited → (invitation revoked, never becomes a user)
```

### 8.9 Invitations — `AdminInvitationStatus`

```
pending → accepted
pending → expired    (time-based, backend job)
pending → revoked    (admin action)
```

---

## 9. Validation

### 9.1 Backend vs. frontend validation — the client validator is not a trust boundary

`ProjectValidator` (`src/app/features/project-editor/services/project-validator.service.ts`) runs entirely client-side and blocks the Publish button, but **a malicious or stale client can bypass all of it.** `POST /builder/bootstrap/publish` must re-run equivalent checks server-side. The exact issue shape to mirror:

```ts
interface ProjectValidationIssue {
  code: string;               // stable machine code
  message: string;            // i18n key, not a rendered string
  section?: string;           // editor section for UI badges
  fieldKey?: string;          // schema field key for inline errors
  severity: 'error' | 'warning'; // error blocks publishing; warning is advisory
}
```

Checks the client runs today (replicate server-side, at minimum the `error`-severity ones):

| Code | Severity | Rule |
|---|---|---|
| `missing-logo` | error | `branding.logoUrl` must be non-empty |
| `no-languages` | error | `localization.supportedLocales` must be non-empty |
| `default-locale-not-supported` | error | `localization.defaultLocale` must be in `supportedLocales` |
| `invalid-url` | error | `tenant.websiteBaseUrl` must be `http(s)://...` if present |
| `duplicate-slugs` | error | static-page `slug` (or `route` fallback) must be unique |
| `duplicate-routes` | warning | `pages[].route.path` must be unique; static-page routes must not collide with page routes |
| `empty-homepage` | error | the `home` page must have ≥1 section |
| `missing-widget` | error | every homepage widget must have a non-empty `type` |
| `invalid-widget-config` | error | every widget on every page needs `id`, `type`, `version`, and an object `props` |
| `duplicate-nav-links` | error | `navigation.header` items must be unique by `(label, route)` |
| `invalid-colors` | error | every `theme.palette` value must be a valid hex color |
| `invalid-css` | warning | `<style>` blocks inside static-page HTML must be syntactically valid CSS |
| `missing-translations` | error | every non-default supported locale needs header-nav and static-page translations |
| `invalid-layouts` | error | `layout.type` and every `section.layout.strategy` must be from the known enum set |
| `invalid-contact-email` | error | `company.contacts.email` must be a valid email if present |
| `invalid-social-link-url` | warning | `footer.socialLinks[].url` must be `http(s)://...` if present |
| `incomplete-payment-icon` | warning | `footer.paymentIcons[]` rows need both `src` and `alt` or neither |

### 9.2 Duplicate validation (client + server, deliberately)

Slug/route uniqueness, hex-color format, and URL format are validated **both** client-side (fast UX feedback) and must be re-validated server-side (real trust boundary) — this is intentional duplication, not a bug to eliminate on either side.

### 9.3 Required vs. generated fields

Generated (backend-set, never accept as client input on create): `id`, `createdAt`, `updatedAt`, `deletedAt`, `itemsCount` (categories), `stockStatus` (products, derived from `quantity`), order `timeline[]` entries, transaction `audit[]` entries, review `timeline[]` entries.

Required on create (reject with 422 if missing): category `title`/`slug`; product `name`/`sku`/`categoryId`/`price`/`currency`; order items must reference valid `productId`s.

### 9.4 Slug / SKU / barcode uniqueness

- Category `slug` — unique per tenant. `isSlugTaken(slug, excludingId)` gateway method exists precisely for this — mirror it as `GET /backoffice/categories/slug-taken`.
- Product `sku` — unique per tenant (§7.2 note).
- Product `barcode` — present in the model (Sprint 21 addition) but no explicit uniqueness-check gateway method exists yet; recommend uniqueness per tenant anyway unless barcodes are intentionally shared across variants.

### 9.5 Media validation

File size/type constraints and malware scanning before publish are **recommended, not yet specified precisely** — no size/type limit constants exist in the current frontend code (`docs/architecture/backend/Backend-Platform-API-Spec.md`'s draft §10 also just says "File size/type constraints" without numbers). Pick sane limits (e.g. 10MB images, common web-safe MIME types) and document them in the response envelope so the frontend can render a real client-side pre-check.

### 9.6 Language validation

Every translatable field must expose the full set of `localization.supportedLocales` — a locale missing from `translations` on a required-translation object (nav labels, static-page content per §9.1's `missing-translations` check) should be a validation error, not silently defaulting to the base language, so authors get an explicit signal.

---

## 10. Media

Covered in full in §6.18 (endpoints) and §7.11 (DTO). Summary of concerns not already stated:

- **Upload** is multipart `POST /media/upload`; response is a full `MediaAsset` including server-computed `width`/`height` for images and a `thumbnailUrl`.
- **Delete** is a hard delete (`DELETE /media/:id` → 204) — no soft-delete/restore concept exists for media today; if that's needed, it's a net-new decision, not something to infer from the current contract.
- **Replace** — no dedicated "replace" endpoint; the frontend pattern is delete + re-upload, or `PATCH` for metadata-only changes (alt text, tags, folder, caption, description, decorative flag) without touching the binary.
- **Metadata**: `altText` is a `Record<locale, string>` (translatable, ADR-0001 rule) — never a flat string. `decorative: boolean` intentionally suppresses missing-alt-text warnings for images that are genuinely decorative.
- **Compression/formats/limits/CDN/thumbnail generation** — none of this is specified in the frontend contract today; these are backend implementation choices as long as the `MediaAsset` response shape is honored (`url`, `thumbnailUrl?`, `mimeType`, `size`, `width?`, `height?`).
- **Future storage**: `MockMediaRepository` uses IndexedDB client-side purely as an interim store — this has zero bearing on backend storage choice (S3-compatible object storage, local disk, CDN-fronted, etc. are all compatible with the `MediaAsset.url` contract).

---

## 11. Errors

### 11.1 Standard error envelope

No single error shape is enforced by any HTTP client code today (the frontend mostly just surfaces `error.message` generically) — this is a **recommended standard**, consistent with `docs/architecture/backend/Backend-Platform-API-Spec.md`'s draft, extended to match the fuller field set the spec brief calls for:

```json
{
  "code": "validation_error",
  "title": "Validation failed",
  "message": "One or more fields are invalid.",
  "severity": "error",
  "details": [
    { "field": "sku", "message": "SKU already exists for this tenant" }
  ],
  "fieldErrors": {
    "sku": ["SKU already exists for this tenant"]
  },
  "retryAfter": null,
  "traceId": "trc-8f3a2c1e"
}
```

- `code` — stable machine-readable identifier (snake_case), for programmatic handling.
- `title` — short human summary.
- `message` — fuller human-readable explanation, safe to show a user.
- `severity` — `error | warning | info` (mirrors `ProjectValidationIssue.severity`, §9.1, for consistency between builder-validation issues and generic API errors).
- `details` — array form for multi-field validation failures (matches the draft spec's shape).
- `fieldErrors` — map form of the same information, keyed by field path, for forms that want O(1) per-field lookup instead of scanning `details`.
- `retryAfter` — seconds, populated on 429/503 responses; `null` otherwise.
- `traceId` — correlates a client-visible error to backend logs.

### 11.2 Common codes

`tenant_not_found`, `tenant_suspended`, `unauthorized`, `forbidden`, `validation_error`, `conflict`, `not_found`, `internal_error`, plus the reliability-mode codes below.

### 11.3 HTTP status meanings (repeated from §5.3 with error-body expectations)

| Status | When | Body |
|---|---|---|
| 400 | Malformed request (bad JSON, wrong content-type) | error envelope, `code: bad_request` |
| 401 | No/invalid session | error envelope, `code: unauthorized` |
| 403 | Valid session, insufficient permission | error envelope, `code: forbidden` |
| 404 | Resource/tenant not found | error envelope, `code: not_found` or `tenant_not_found` |
| 409 | Slug/SKU conflict, concurrent-edit conflict | error envelope, `code: conflict`, `details` naming the conflicting field |
| 422 | Validation failure | error envelope, `code: validation_error`, `details`/`fieldErrors` populated |
| 429 | Rate limited | error envelope, `code: rate_limited`, `retryAfter` populated |
| 500 | Unhandled error | error envelope, `code: internal_error` — never leak stack traces or internals in `message` |
| 503 | Service degraded/down | error envelope, `code` from the maintenance-mode set below, `retryAfter` if known |

### 11.4 Degraded-service modes

Not implemented today but recommended so the frontend can render an honest state instead of a generic failure:

- `maintenance_mode` — planned downtime.
- `construction_mode` — tenant not yet fully configured/launched.
- `read_only_mode` — writes disabled, reads still served.
- `feature_disabled` — a specific feature flag is off; distinct from a generic 403.
- `backend_unavailable` / `database_unavailable` / `queue_unavailable` — dependency-specific outage codes so ops/on-call can triage faster than a generic 500.
- **Graceful degradation:** where possible, prefer serving stale/cached data with a warning over a hard failure (e.g. bootstrap should be aggressively cached client + edge side specifically so a backend blip doesn't blank the entire storefront — see §13).

### 11.5 The one known reliability issue in production today

Intermittent `502`/`504 Bad Gateway` on page refresh and browser back-navigation in production. **Root cause is not in this repository.** `environment.production.ts` points the frontend at the backend API via **absolute URLs directly** (`apiUrl: 'https://api.dexarmarket.ru:445'`, `authApiUrl: 'https://users.vitanova.network:456'`), bypassing this repo's own `nginx.conf` entirely (that config only proxies `/api` for one specific tenant host, not `dexarmarket.ru`). The 502/504 originates from **that backend API's own reverse proxy** (ports 445/456, servers not part of this repository). Refresh and back-navigation both re-fire session-check and bootstrap-load calls on mount (`AdminAuthService.checkSession()`, `ConfigService.loadBootstrap()`, `TelegramSessionApiService`), which is the likely trigger if that backend's app server or reverse proxy is crashing, overloaded, or misconfigured on those specific endpoints. **This needs DevOps/backend investigation of upstream health, timeout settings, and concurrent-connection handling around session-check and bootstrap endpoints** — nothing in this frontend repository can fix it.

---

## 12. Localization

- `LocalizationConfig` (§4.4): `defaultLocale`, `supportedLocales[]`, `currencyByLocale: Record<string,string>`, `dictionaries: {locale, dictionaryUrl, version}[]`.
- **LocaleMap convention** (§5.11): every translatable field is `Record<locale, string>` (or a `translations[locale]` object for richer content) — never a flat hardcoded field. This applies to navigation labels, static-page title/html/seo, product/category translations, footer copyright text.
- **Fallback:** the client resolves a missing translation by falling back toward `defaultLocale` in most rendering paths (e.g. category/item `name` falls back to the first available `names[]` entry, preferring `ru` — see `ApiService.normalizeCategory`/`normalizeItem`, §6.1) — but this is a display-layer fallback for legacy/partial data, not a substitute for actually authoring translations. §9.1's `missing-translations` validation exists specifically to push authors toward complete translations rather than relying on fallback.
- **Default language:** `localization.defaultLocale` must always be a member of `localization.supportedLocales` — enforced client-side (`default-locale-not-supported`, §9.1) and must be enforced server-side too.
- **`Accept-Language` vs. `X-Language`:** the frontend does **not** send `Accept-Language`; it sends its own resolved `X-Language` header (`RU`/`EN`/`AM`, mapped from the user's in-app language selection, §1 step 3) on every API request. Backend should key localized responses off `X-Language`, not the browser's `Accept-Language`.
- **Changing language:** a pure frontend state change (`LanguageService.currentLanguage`) — no backend call is made purely to switch language; the next API request simply carries the new `X-Language` header.
- **Static pages:** localized via `translations[locale].{title,html,seo}` (§4.6) — SEO fields can also be locale-specific (`StaticPageTranslationConfig.seo`), overriding the page-level `seo` block per locale.
- **SEO:** `SeoConfig.default` + `SeoConfig.byPageKey` (§4.4) at the bootstrap level; per-static-page SEO nested under each page's `seo`/`translations[locale].seo` (§4.6). No separate SEO-only endpoint exists — SEO data travels inside bootstrap and static-page payloads.

---

## 13. Caching

| Layer | What | Mechanism today | Recommendation |
|---|---|---|---|
| Bootstrap | `GET /bootstrap` | `ConfigService` caches the whole response in-memory for the app session via `shareReplay(1)`; no HTTP-level caching | Add `ETag`/`Cache-Control: private, max-age=60` (or similar) — safe additive change, client doesn't send conditional headers today so this is a pure improvement, not a breaking change |
| Products/categories (storefront) | `GET /category`, `GET /category/:id`, `GET /items/:id` | `cacheInterceptor` (`src/app/interceptors/cache.interceptor.ts`) — in-memory `Map`, TTL from `CACHE_DURATION_MS` (category list) / `CATEGORY_CACHE_DURATION_MS` (category items + single item), keyed by exact request URL, cleaned up lazily on each access | Backend should still set its own `Cache-Control`/`ETag` for CDN/edge caching — the frontend's cache is per-tab, in-memory, and does not survive a reload |
| Images | media assets | none specified client-side | CDN-fronted with long `max-age` + content-hashed URLs recommended (not currently required by any frontend code) |
| Analytics | dashboard/analytics reads | none — always live | fine to cache short-TTL (seconds) server-side given these are aggregation-heavy |
| Browser cache | general | standard browser HTTP cache applies to anything the backend marks cacheable | — |
| Invalidation | — | today: none — the in-memory `cacheInterceptor` map is purely TTL-based, no explicit invalidation hook exists anywhere (e.g. a product update does not proactively bust the `GET /items/:id` cache entry) | if strong consistency after an admin write matters, either shorten `CATEGORY_CACHE_DURATION_MS` further or add an explicit cache-bust mechanism — not present today |

**Bootstrap caching is the most consequential** — since it's loaded once and held for the entire session, any admin publish (§6.7) will not be visible to already-open customer tabs until they reload. Consider a low-TTL `Cache-Control` on `GET /bootstrap` plus (future) a lightweight "config changed" push/poll signal if near-real-time propagation matters — no such mechanism exists in the frontend today.

---

## 14. Backend replacement pattern

This is the mechanical pattern **every** domain in this document follows, and the reason implementing a backend for any one domain is low-risk and additive:

```mermaid
flowchart LR
    Page["Page component"] --> Facade["Domain Facade"]
    Facade --> Token["DI Token (e.g. ADMIN_PRODUCTS_GATEWAY)"]
    Token -.bound today.-> Mock["*LocalGateway (mock/localStorage/IndexedDB)"]
    Token -.rebind to.-> Api["*ApiGateway (real HTTP, implements same interface)"]
    Api --> Backend["Backend REST API"]
```

1. A `*Gateway` TypeScript interface defines the domain's contract (e.g. `AdminProductsGateway`, `AdminCategoriesGateway`, `MediaRepository`).
2. A `*LocalGateway` (or, for media, `MockMediaRepository`) implements that interface today, bound via an Angular DI token/injection.
3. To go live: implement a `*ApiGateway` class against the **same interface** (same method signatures, same DTOs — §7), hitting the real endpoints documented in §6.
4. Rebind the DI token to the new class in the app's provider config (`app.config.ts` or the relevant feature's providers).
5. **Facades and page components do not change.** This is the whole point of the pattern (ADR-004's "configuration provider abstraction" generalizes to every domain, not just bootstrap).

Provider *selection* (mock vs. api) for bootstrap/backoffice/products/categories specifically goes through `RuntimeProviderStrategyService` (`src/app/core/providers/runtime-provider-strategy.service.ts`):

```ts
type RuntimeProviderMode = 'mock' | 'api' | 'remote-config';

class RuntimeProviderStrategyService {
  getBootstrapProviderMode(): RuntimeProviderMode {
    // 'mock' if environment.useMockData, OR (useMockBootstrapOnLocal && running on localhost)
    // 'api' otherwise
  }
  getBackofficeProviderMode(): RuntimeProviderMode { /* 'mock' iff environment.useMockData, else 'api' */ }
  getProductProviderMode(): RuntimeProviderMode { /* same */ }
  getCategoryProviderMode(): RuntimeProviderMode { /* same */ }
}
```

Note the asymmetry: bootstrap can be mocked on localhost independent of `useMockData` (`useMockBootstrapOnLocal: true` by default) so local dev never needs a live bootstrap endpoint, while product/category/backoffice mocking is a single global `useMockData` switch. `'remote-config'` is a declared-but-unused third mode — reserved, not implemented anywhere today.

---

## 15. Future APIs

Reserved sections only — **do not implement speculative endpoints for these**; document intent, not a contract, until real requirements exist. No frontend code references any of the following today.

| Domain | Notes |
|---|---|
| Coupons | `featureFlags.coupons` boolean already exists in bootstrap (unused beyond the flag itself) |
| Discounts | `AdminProduct.discount: number` exists (§7.2) as a static per-product value; a real discount/promotion *engine* (rules, date ranges, stacking) does not exist |
| Promotions | no model anywhere |
| Notifications | `featureFlags.notifications` boolean exists; no delivery mechanism (email/push/in-app) specified |
| Marketplace Partners | not modeled — this platform is single-tenant-per-marketplace, not itself a marketplace-of-marketplaces |
| Inventory | `AdminProduct.quantity`/`stockStatus` exist per-product; no warehouse/location-level inventory model |
| Warehouses | not modeled |
| Delivery | `DeliveryOption` exists for storefront cart display (`src/app/models`, referenced by `ApiService`) — a full delivery/logistics domain (carriers, rates, tracking beyond `AdminOrderShipping.trackingNumber`) does not exist |
| Invoices | `featureFlags.invoices` boolean exists; `AdminOrdersGateway` UI has a "print invoice" action today but it's client-rendered from existing order data, not a backend invoice document |
| Refunds | `requestRefund()` exists (§6.11, §8.2) as a status transition; a full refund-processing domain (partial refunds, refund reasons, ledger) does not |
| Webhooks | `AdminWebhookDelivery` model exists for *displaying* delivery status (§7.9) but no webhook *registration/configuration* surface exists anywhere |
| Audit Logs | `AdminMonitoringEvent`/`AdminUserAuditEntry`/order `timeline[]`/review `timeline[]`/transaction `audit[]` are all narrow, per-domain audit trails today; a unified cross-domain audit log does not exist |
| Roles | `AdminRole`/`PermissionsConfig` exist as data shapes (§7.5–§7.6) but are not enforced anywhere server-side yet (§2.5, §3.3) |
| Permissions | same as Roles — modeled, not enforced |
| Feature Flags (management API) | `FeatureFlagsConfig`/`MarketplaceFeaturesConfig` are read via bootstrap only; no dedicated `GET/PUT /feature-flags` endpoint is called by any frontend code today (the draft spec in the old `Backend-Platform-API-Spec.md` proposed one — reasonable to build, just not yet consumed) |

---

## 16. Sequence diagrams

### 16.1 Authentication (customer or admin — identical backend flow, different client storage)

```mermaid
sequenceDiagram
    participant U as User (browser)
    participant FE as Frontend (AuthService / AdminAuthService)
    participant TG as Telegram Bot
    participant BE as Backend (users/sessions)

    FE->>BE: POST /users/sessions {webSessionID}
    BE-->>FE: {webSessionID, ...}
    FE->>U: Render QR / deep link (t.me/{bot}?start={webSessionID})
    U->>TG: Scan QR / tap deep link
    TG->>BE: (out of band) associate Telegram identity with webSessionID
    loop every 5s, up to 100x
        FE->>BE: GET /users/sessions/:webSessionID
        BE-->>FE: {active: false, ...}
    end
    TG->>BE: user completes bot interaction
    FE->>BE: GET /users/sessions/:webSessionID
    BE-->>FE: {active: true, user: {...}, expiresAt}
    FE->>FE: activateSession() -> set cookie, start refresh timer
```

### 16.2 Bootstrap load

```mermaid
sequenceDiagram
    participant App as Angular App
    participant CS as ConfigService
    participant Prov as ApiBootstrapProvider / MockBootstrapProvider
    participant BE as Backend

    App->>CS: loadBootstrap()
    alt already cached this session
        CS-->>App: cached BootstrapConfig (shareReplay)
    else not cached
        CS->>Prov: loadBootstrap()
        alt mock mode / localhost
            Prov-->>CS: bootstrap.json (local asset)
        else real
            Prov->>BE: GET /bootstrap (Host header resolves tenant)
            BE-->>Prov: BootstrapConfig JSON
        end
        CS-->>App: BootstrapConfig
        App->>App: PlatformRuntimeService applies theme/branding/nav
    end
```

### 16.3 Product editing (admin) — PLANNED, once `AdminProductsApiGateway` exists

```mermaid
sequenceDiagram
    participant Admin as Admin user
    participant Page as Product editor page
    participant Facade as AdminProductsFacade
    participant GW as AdminProductsApiGateway
    participant BE as Backend

    Admin->>Page: edit fields, save
    Page->>Facade: updateProduct(product)
    Facade->>GW: updateProduct(product)
    GW->>BE: PUT /backoffice/products/:id
    BE-->>GW: 200 AdminProduct (server-validated)
    GW-->>Facade: AdminProduct
    Facade-->>Page: updated state
```

### 16.4 Publishing (Project Editor) — PLANNED

```mermaid
sequenceDiagram
    participant Editor as Tenant admin
    participant PE as ProjectEditorFacade
    participant Val as ProjectValidator (client)
    participant BE as Backend (builder/bootstrap)
    participant Store as ConfigService (storefront)

    Editor->>PE: click Publish
    PE->>Val: validate(bootstrap)
    alt has error-severity issues
        Val-->>PE: issues[]
        PE-->>Editor: block publish, show issues
    else clean
        PE->>BE: POST /builder/bootstrap/publish {BootstrapConfig}
        BE->>BE: server-side re-validation (mandatory, §9.1)
        alt server rejects
            BE-->>PE: 422 {details: [...]}
            PE-->>Editor: show server-side issues
        else accepted
            BE-->>PE: 200
            PE->>Store: reloadFromBootstrap() (in-memory preview)
            Note over Store: next GET /bootstrap by any client now returns the new config
        end
    end
```

### 16.5 Checkout / cart (CURRENT, frozen)

```mermaid
sequenceDiagram
    participant U as Shopper
    participant FE as Frontend (CartService)
    participant BE as Backend

    U->>FE: add item to cart
    FE->>BE: POST /websession/:sessionId [{itemID, quantity, ...}]
    BE-->>FE: 200
    U->>FE: initiate payment
    FE->>BE: POST /cart (or {qrApiUrl}/qr) - CartPaymentRequest
    BE-->>FE: QrCreateResponse {qrId/nspkurl/bankUrl/...}
    FE->>U: render QR or redirect to bankUrl
    loop poll
        FE->>BE: GET {qrApiUrl}/qr/dynamic/:partnerId/:qrId
        BE-->>FE: QrDynamicStatusResponse {status}
    end
```

### 16.6 Media upload — PLANNED

```mermaid
sequenceDiagram
    participant Admin as Admin user
    participant Picker as MediaPickerComponent
    participant Repo as HttpMediaRepository
    participant BE as Backend

    Admin->>Picker: select file
    Picker->>Repo: upload(file, {folder, tags})
    Repo->>BE: POST /media/upload (multipart)
    BE->>BE: validate size/type, scan, compute width/height, generate thumbnail
    BE-->>Repo: 201 MediaAsset
    Repo-->>Picker: MediaAsset
    Picker-->>Admin: asset selected / inserted
```

### 16.7 Review moderation — PLANNED

```mermaid
sequenceDiagram
    participant Mod as Moderator
    participant Page as Moderation page
    participant GW as AdminModerationApiGateway
    participant BE as Backend

    Mod->>Page: set review status = approved, add note
    Page->>GW: setReviewStatus(id, 'approved', note)
    GW->>BE: PATCH /backoffice/reviews/:id/status {status, note}
    BE->>BE: append AdminReviewTimelineEntry, enforce state machine (§8.4)
    BE-->>GW: 200 AdminReview
    GW-->>Page: updated review
```

### 16.8 Report resolution — PLANNED

```mermaid
sequenceDiagram
    participant Mod as Moderator
    participant Page as Moderation page (Reports tab)
    participant GW as AdminModerationApiGateway
    participant BE as Backend

    Mod->>Page: mark report resolved
    Page->>GW: setReportStatus(id, 'resolved')
    GW->>BE: PATCH /backoffice/reports/:id/status {status: 'resolved'}
    BE-->>GW: 200 AdminReport
```

### 16.9 Order creation — FUTURE (no order-creation endpoint exists anywhere yet; orders today are admin-managed post-hoc, not created via the documented API)

```mermaid
sequenceDiagram
    participant Shopper
    participant FE as Frontend (checkout, not yet built against a real order endpoint)
    participant BE as Backend

    Note over Shopper,BE: No frontend code creates an AdminOrder today. Checkout today only calls the cart-payment endpoints (§16.5). Order creation as a distinct backend concept (order number, items snapshot, timeline) is a FUTURE integration point once checkout and the admin order domain are connected.
    Shopper->>FE: complete checkout
    FE->>BE: (future) POST /orders {items, customer, payment, shipping}
    BE-->>FE: (future) 201 AdminOrder
```

---

## 17. Developer notes

### Performance
- `GET /category`/`GET /category/:id`/`GET /items/:id` are hit frequently and already client-cached (§13) — backend should still index these for fast reads (category id, item id are the obvious indexes) since the client cache is per-tab and short-lived.
- Paginated admin list endpoints (§5.4) must support `page`/`pageSize` server-side, not return-everything-then-paginate-client-side — several mock gateways currently return full arrays and let the facade slice, which will not scale once real data volumes exist.

### Scaling
- Tenant resolution happens on every request (host header lookup) — this must be a fast, cached lookup (e.g. an in-memory or Redis-backed host→tenant map), not a database round-trip per request, given it gates literally everything including the public bootstrap endpoint.
- `BackofficeDataService` (frontend) already shares one in-flight request per endpoint across multiple consumers via `shareReplay` — backend need not defend against duplicate near-simultaneous requests from a single client session, but should still handle normal concurrent-tenant load.

### Indexes
- Category: `slug` (unique per tenant), `parentId` (hierarchy traversal), `deletedAt` (soft-delete filtering).
- Product: `sku` (unique per tenant), `categoryId`, `barcode`, `visible`+`archived` (list filtering).
- Order: `orderNumber`, `customer.email` (customer aggregation, §7.3), `status`, `createdAt` (range queries for analytics).
- Session: `webSessionID`/`adminSessionID` (primary lookup key on every authenticated request).

### Transactions & concurrency
- `PUT /builder/bootstrap/draft` and `POST /builder/bootstrap/publish` are whole-document writes — guard against two concurrent editors clobbering each other. No optimistic-locking field (`version`/`etag`) exists in `BootstrapConfig` today; adding one (e.g. `schemaVersion` bumped or a separate `revision` field checked on write) is recommended before multi-editor concurrent use is expected, since the current contract has no way to detect a stale-draft overwrite.
- Order status transitions (§8.1) should be guarded by a DB-level transaction covering the status write + timeline-entry append, so the two never diverge.
- Category `deletedAt`/product `archived` toggles plus any cascading effects (e.g. hiding a category's products) should be transactional if such cascades are implemented.

### Versioning
- `schemaVersion` in `BootstrapConfig` is the real version signal (§5.1) — bump it deliberately, coordinate with frontend releases for anything beyond additive optional fields.
- No API-path versioning exists (`/bootstrap`, not `/v1/bootstrap`) — introducing path versioning later is a breaking change to every existing integration; if it's ever needed, prefer a new parallel path family over renaming the existing one.
