# Backend — Canonical Specification

**This is the ONE document a backend engineer needs.** It is the single source of truth for backend implementation — architecture, bootstrap, authentication, JWT, Ed25519 public-key login, permissions, maintenance mode, error contract, every endpoint, DTOs, request/response schemas, uploads, pagination, filters, sorting, publish workflow, media, builder, examples, and a top-to-bottom implementation checklist. Authentication (§4), the Error Model (§6), and Maintenance Mode (§10) are sections of this same document, not separate files — earlier drafts existed as standalone `AUTHENTICATION.md`/`ERROR_CONTRACT.md`/`MAINTENANCE_MODE.md` files and were fully merged in; those files no longer exist. This document also supersedes (already archived, content re-derived from current source) `docs/archive/BACKEND_API.md` and `docs/archive/BACKEND_API_REMAINING_WORK.md`.

Everything here is derived from the actual current frontend source code (branch `B2B`), not from prior/stale documentation. Primary input: `docs/context/BACKEND-AUDIT.md` (exhaustive audit of every HTTP call, gateway, facade, and model in the frontend).

**Convention used throughout:** where the frontend already implies a concrete behavior, it's documented as-is. Where the frontend has no opinion and a real backend needs one, it's marked **"Requires backend decision"** — nothing beyond what the frontend requires is invented.

## How to use this document (read this first if you're implementing from it)

You are building a real backend for an existing, feature-complete Angular
frontend (branch `B2B`) that currently runs entirely on mock/local data. This
document is the complete contract — every endpoint, DTO, status code, and
convention the frontend already expects. Treat it as your spec, not as
background reading.

1. **Work in dependency order**, not top-to-bottom document order: §9 (Backend
   Checklist) gives the actual build sequence in 6 phases (foundation → auth/
   tenant/bootstrap → read-heavy catalog → write-heavy customer domains →
   admin domains → builder/CMS → hardening). Start there.
2. **Every "Requires backend decision" marker has a Recommended default**
   attached to it (in §2.12, §3.21, §4 §12, §6's summary, and §10's list —
   the five per-section consolidated registers). Apply the recommended
   default and keep moving. Do not stop and wait for human input on these —
   they exist so you don't have to invent a convention from nothing, and
   they were chosen to match what the frontend already assumes.
3. **A small number of items are explicitly marked as business/security
   decisions, not technical ones** (e.g. order status-transition rules, the
   Ed25519 cutover strategy, refresh-token reuse detection posture). These
   are the only points where you should actually stop and ask a human,
   because they carry money, legal, or security consequences the frontend
   code can't tell you the answer to.
4. **Do not invent new endpoints, fields, or business rules beyond what's
   written here or directly implied by an existing frontend call.** If you
   find yourself needing something this document doesn't cover and no
   recommended default applies, that's a real gap — flag it explicitly
   rather than guessing silently.
5. **§8 (Real Backend Implementation Guide)** has the frontend-side
   mechanics: which DI-token gateways to swap, which facades change, worked
   DTO-mapping examples. Read it before touching any single domain's
   endpoints in §3 — it explains the pattern every domain repeats.
6. **Verify against the live frontend where you can.** Every literal
   (non-PROPOSED) path, DTO, and example in this document was checked
   against actual source on `B2B` — but the frontend evolves. If your
   implementation and this document ever disagree with what the running
   frontend actually sends/expects, the frontend's actual behavior wins;
   flag the doc as stale rather than building against a description that
   no longer matches reality.

## Table of contents

1. [Bootstrap](#1-bootstrap)
2. [Endpoint Documentation Framework](#2-endpoint-documentation-framework)
3. [CRUD Contracts](#3-crud-contracts)
4. [Authentication](#4-authentication)
5. [Security](#5-security)
6. [Error Model](#6-error-model)
7. [Uploads](#7-uploads)
8. [Real Backend Implementation Guide](#8-real-backend-implementation-guide)
9. [Backend Checklist](#9-backend-checklist)
10. [Maintenance Mode](#10-maintenance-mode)

---
## 1. Bootstrap

The bootstrap document is the single runtime-configuration payload that drives
the entire multi-tenant storefront/builder/backoffice. It is fetched once at
app startup and held in memory; nearly every feature (theme, navigation,
localization, catalog behavior, static pages, feature flags, widget registry)
reads from it rather than from dedicated per-feature endpoints.

Source of truth for this section:
`src/app/core/bootstrap/providers/api-bootstrap.provider.ts`,
`src/app/core/config/config.service.ts`,
`src/app/core/config/api-config.service.ts`,
`src/app/core/config/tenant-resolver.service.ts`,
`src/app/shared/models/config/*`, and the mock document
`src/assets/mock/bootstrap/bootstrap.json`. Cross-referenced against
`docs/context/BACKEND-AUDIT.md` §6, §2 and the prior `docs/archive/BACKEND_API.md`.

### 1.1 Request contract

| Property | Value |
|---|---|
| Method | `GET` |
| Route (API mode) | `/bootstrap` — relative; rewritten onto the resolved tenant API base by `apiBaseUrlInterceptor` (see §1.10) |
| Route (mock/local mode) | `GET /assets/mock/bootstrap/bootstrap.json` (static asset, no backend) |
| Request body | none |
| Query params | none |
| Auth requirement | **None.** Bootstrap is fetched before any login and must be publicly cacheable per tenant. It carries no `Authorization` header and no `WebSessionID` is required for it to succeed. |

**Headers.** The provider itself (`ApiBootstrapProvider`) sets no explicit
headers — it issues a plain `HttpClient.get<BootstrapConfig>('/bootstrap')`.
Because the URL is `/api`-relative only after interceptor rewrite (the literal
is `/bootstrap`, not `/api/bootstrap`), whether `apiHeadersInterceptor`'s
marketplace headers (`X-Region`, `X-Language`, `Currency`, `WebSessionID`)
attach depends on `ApiConfigService.isApiRequest()` matching the resolved URL.
In practice the backend must treat all of these as **optional** on the
bootstrap call — none are load-bearing for it, and the document is expected to
resolve from tenant/origin alone (§1.9).

> **Requires backend decision:** whether the bootstrap endpoint should honor
> an `X-Language`/`Accept-Language` hint to pre-select a localization, or
> always return the full multi-locale document (the frontend today always
> receives and holds the full multi-locale document and selects locale
> client-side).

### 1.2 Which provider actually fires

`CONFIG_PROVIDER` (`src/app/core/config/config-provider.token.ts`) is a factory
bound by `RuntimeProviderStrategyService.getBootstrapProviderMode()`:

- Returns **mock** (`MockBootstrapProvider` → static asset) when
  `environment.useMockData === true`, **or** when
  `environment.useMockBootstrapOnLocal === true` **and** the host is localhost.
- Otherwise returns **api** (`ApiBootstrapProvider` → `GET /bootstrap`).

With the current `environment.ts` (`useMockData: false`,
`useMockBootstrapOnLocal: true`), a localhost dev session reads the static
`bootstrap.json`; a deployed tenant host reads the live `GET /bootstrap`.

### 1.3 Response shape — `BootstrapConfig`

Copied faithfully from
`src/app/shared/models/config/bootstrap-config.model.ts`:

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

Required (non-optional) top-level keys the backend must always emit:
`schemaVersion, generatedAt, tenant, branding, theme, company, featureFlags,
apiEndpoints, localization, seo, permissions, navigation, pages`. Everything
marked `?` may be omitted; the frontend supplies defaults (e.g.
`DEFAULT_CATALOG_CONFIG`, `DEFAULT_USER_EXPERIENCE_CONFIG`) when absent.

Each nested field, with its source model file under
`src/app/shared/models/config/`:

- **`schemaVersion`** (`string`) — document schema version (e.g. `"1.0.0"`).
  Not enforced by a validator today; see §1.6.
- **`generatedAt`** (`string`, ISO 8601) — when the tenant's config snapshot
  was produced. Advisory/diagnostic only today.
- **`tenant`** (`TenantConfig`, `tenant.model.ts`) —
  `{ id (UUID), slug, code, host, name, websiteBaseUrl, builderBaseUrl,
  backofficeBaseUrl, defaultLocale, supportedLocales[], defaultCurrency,
  supportedCurrencies[], timezone }`. Identifies the tenant and its per-surface
  base URLs, locale/currency sets, and timezone.
- **`branding`** (`BrandingConfig`, `branding.model.ts`) —
  `{ brandName, legalName, slogan?, logoUrl, logoCompactUrl?, faviconUrl,
  appIconUrl?, socialImageUrl?, galleryUrls?, supportEmail?, supportPhone? }`.
  Drives header logo, favicon, PWA icon, social share image.
- **`theme`** (`ThemeConfig`, `theme.model.ts`) —
  `{ themeId, mode: 'light'|'dark'|'system', palette (12 named 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 }`. Consumed by `ThemeEngineService`/`BrandingEngineService` to emit
  CSS variables at runtime.
- **`company`** (`CompanyConfig`) — legal entity + address + contacts block
  (see mock JSON §1.5 for shape: `companyName, registrationNumber, taxId,
  address{country,region,city,street,postalCode}, contacts{email,phone,
  telegram,website}`).
- **`featureFlags`** (`FeatureFlagsConfig`, `feature-flags.model.ts`) — a
  `Record<string, boolean>` with well-known keys `wishlist, compare, reviews,
  questions, comments, recommendations, blog, chat, analytics, notifications,
  coupons, loyalty, giftCards, invoices` plus an open index signature for
  arbitrary custom flags. Toggle features on/off per tenant.
- **`features`** (`MarketplaceFeaturesConfig`, optional) — richer per-feature
  config beyond boolean flags.
- **`apiEndpoints`** (`ApiEndpointsConfig`, `api-endpoints.model.ts`) —
  `{ bootstrap: ApiEndpointConfig, website: Record<string,ApiEndpointConfig>,
  builder: Record<string,ApiEndpointConfig>, backoffice:
  Record<string,ApiEndpointConfig> }` where `ApiEndpointConfig =
  { path, method, timeoutMs? }`. **This is where a tenant declares its planned
  per-surface endpoint paths at runtime.** The `website/builder/backoffice`
  maps are empty `{}` in the mock today — no builder/backoffice CRUD path
  exists as a code literal anywhere (BACKEND-AUDIT §24); those endpoints, if
  ever built, are declared here rather than hard-coded.
- **`localization`** (`LocalizationConfig`, `localization.model.ts`) —
  `{ defaultLocale, supportedLocales[], currencyByLocale (Record<locale,
  currency>), dictionaries: [{ locale, dictionaryUrl, version }] }`. Note the
  three supported locales are `ru, en, hy`; the marketplace API uses codes
  `RU, EN, AM` (mapped by `apiHeadersInterceptor` / `ApiService.normalizeLang`,
  `am↔hy`).
- **`seo`** (`SeoConfig`, `seo.model.ts`) —
  `{ default: SeoPageConfig, byPageKey: Record<pageKey, SeoPageConfig> }` where
  `SeoPageConfig = { title, description, canonicalUrl?, robots?, metaTags? }`.
- **`permissions`** (`PermissionsConfig`, `permissions.model.ts`) —
  `{ definitions: [{ key, description? }], roles: [{ role, permissions[] }] }`.
  Bootstrap-level RBAC catalog. (Distinct from the Ed25519 JWT `AdminRole`
  union — see `§4 (Authentication, this document)` §9 for the flagged `AdminRole` naming
  collision.)
- **`header`** (`HeaderConfig`, optional) — header layout config.
- **`catalog`** (`CatalogConfig`, `catalog-config.model.ts`, optional) — the
  storefront catalog behavior contract:
  `{ layout, loadingStrategy: 'pagination'|'loadMore'|'infiniteScroll',
  navigationMode, defaultSort, availableSorts[], enabledFilters[],
  showBreadcrumbs, showCategoryBanner, showSubcategoryChips, showRatings,
  showDiscounts, showAvailability, suggestionsEnabled, searchHistoryEnabled }`.
  `defaultSort`/`availableSorts` enumerate `relevance | latest | price_asc |
  price_desc | rating | popular | discount` (see §2.3). Defaults in
  `DEFAULT_CATALOG_CONFIG`.
- **`layout`** (`PlatformLayoutConfig`, optional) — global layout type.
- **`navigation`** (`NavigationConfig`, `navigation.model.ts`) —
  `{ header: NavigationItemConfig[], footer: NavigationItemConfig[] |
  FooterNavigationGroupConfig[], sidebar?: NavigationItemConfig[] }`. Items
  carry `{ id, labelKey?|label?, route?, icon?, order?, visible?,
  visibleWhenFlags?, children? }` — note `visibleWhenFlags` lets a nav item be
  gated on a `featureFlags` key.
- **`footer`** (`FooterConfig`, `footer-config.model.ts`, optional) —
  `{ logoUrl?, paymentIcons?, copyrightText? (string|localized), columns?
  (FooterColumnConfig[]), socialLinks? }`. `legalPageKeys`/`staticPageKeys` are
  `@deprecated` in favor of `columns` but still resolve for older saved configs.
- **`productPage`** (`ProductPageConfig`, optional) — per-PDP config; in the
  mock: `rating{enabled}, reviews{enabled,pageSize,showSummary},
  questions{enabled,pageSize}, tabs{enabled,items[]}, relatedProducts{enabled}`.
  Note `reviews.pageSize`/`questions.pageSize` (5 in mock) are the storefront
  engagement page sizes (§2.2).
- **`userExperience`** (`UserExperienceConfig`, `user-experience-config.model.ts`,
  optional) — `{ wishlist, compare{maxItems,...}, recentlyViewed{maxItems,...},
  share, continueBrowsing, savedSearches{maxItems} }` limits/toggles for the
  guest-first UX features. Defaults in `DEFAULT_USER_EXPERIENCE_CONFIG`.
- **`pages`** (`PageConfig[]`, `page.model.ts`) — the dynamic page tree the
  renderer builds routes from: each `{ id, key, title, route{path,exact?},
  layout, sections: SectionConfig[], seoKey?, featureFlag?, visible? }`. Each
  section holds ordered `widgets` with `props` (see mock §1.5 for a full
  hero/categories/product-collection example). This is the builder's output.
- **`staticPages`** (`StaticPagesConfig`, `static-page.model.ts`, optional) —
  either `Record<slug, StaticPageConfig>` or a legacy array. Each page carries
  localized `title` + `html`/`content`, `route`, `seo`, `status:
  'draft'|'published'`, `enabled`, `visibility`, `updatedAt`, etc. Storefront
  legal/content pages live here (about-us, privacy-policy, terms-of-service in
  the mock). Content-management edits operate on this in-memory (BACKEND-AUDIT
  §16) — no dedicated content backend.
- **`widgetRegistry`** (`WidgetRegistryConfig`, optional) —
  `{ manifestUrl: string }`; the URL `WidgetManifestService` fetches the widget
  manifest from (§2 "Widget manifest" endpoint).

### 1.4 Versioning

`BootstrapConfig.schemaVersion` (string) **does** exist and is the schema/version
field. Current usage:

- The mock document sets `"schemaVersion": "1.0.0"`.
- `bootstrap-diagnostics.validator.ts:44` surfaces it as a diagnostic entry
  (`['schemaVersion', bootstrap.schemaVersion]`).
- `AdminDashboardFacade` (`admin-dashboard.facade.ts:110`) derives the
  `dashboard.healthBootstrapValid` health check purely as
  `healthy: !!current?.schemaVersion` — i.e. "bootstrap is valid" today means
  only "a schemaVersion string is present," **not** any real semantic-version
  compatibility check.

> **Requires backend decision:** whether `schemaVersion` should be enforced
> (frontend rejecting/ warning on an unknown major version). No such
> enforcement exists today — any string is accepted, and only its presence is
> checked.

### 1.5 Example JSON

Realistic example, taken from the actual mock document
`src/assets/mock/bootstrap/bootstrap.json` (abridged — pages/staticPages
trimmed for length; full versions in that file):

```json
{
  "schemaVersion": "1.0.0",
  "generatedAt": "2026-07-03T00:00:00Z",
  "tenant": {
    "id": "tenant-default-001",
    "slug": "default",
    "code": "DEFAULT",
    "host": "default.local",
    "name": "Marketplace",
    "websiteBaseUrl": "https://marketplace.local",
    "builderBaseUrl": "https://builder.marketplace.local",
    "backofficeBaseUrl": "https://backoffice.marketplace.local",
    "defaultLocale": "ru",
    "supportedLocales": ["ru", "en", "hy"],
    "defaultCurrency": "RUB",
    "supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
    "timezone": "Europe/Moscow"
  },
  "branding": {
    "brandName": "Marketplace",
    "legalName": "Marketplace LLC",
    "slogan": "Digital commerce marketplace",
    "logoUrl": "/icons/icon-192x192.png",
    "faviconUrl": "/favicon.ico",
    "supportEmail": "support@marketplace.local",
    "supportPhone": "+7-900-000-00-00"
  },
  "theme": {
    "themeId": "default-light",
    "mode": "light",
    "palette": {
      "primary": "#497671", "secondary": "#a1b4b5", "accent": "#a7ceca",
      "success": "#10b981", "warning": "#f59e0b", "danger": "#ef4444",
      "info": "#3b82f6", "textPrimary": "#1e3c38", "textSecondary": "#667a77",
      "backgroundPrimary": "#ffffff", "backgroundSecondary": "#f5f5f5",
      "border": "#d3dad9"
    },
    "typography": { "primaryFontFamily": "DM Sans, sans-serif", "baseFontSize": 16 },
    "spacing": { "unit": 4, "scale": [0, 4, 8, 12, 16, 24, 32, 48] },
    "borderRadiusScale": { "sm": "8px", "md": "12px", "lg": "16px", "xl": "22px" },
    "shadows": { "sm": "0 2px 8px rgba(0,0,0,0.1)" },
    "iconSet": "default"
  },
  "company": {
    "companyName": "Marketplace LLC",
    "registrationNumber": "1027700000000",
    "taxId": "7700000000",
    "address": { "country": "Russia", "region": "Moscow", "city": "Moscow", "street": "Tverskaya 1", "postalCode": "125009" },
    "contacts": { "email": "support@marketplace.local", "phone": "+7-900-000-00-00", "telegram": "@marketplace_support", "website": "https://marketplace.local" }
  },
  "featureFlags": {
    "wishlist": true, "compare": true, "reviews": true, "blog": false,
    "chat": false, "analytics": true, "notifications": true, "coupons": true,
    "loyalty": false, "giftCards": false, "invoices": true
  },
  "apiEndpoints": {
    "bootstrap": { "path": "/bootstrap", "method": "GET", "timeoutMs": 10000 },
    "website": {}, "builder": {}, "backoffice": {}
  },
  "localization": {
    "defaultLocale": "ru",
    "supportedLocales": ["ru", "en", "hy"],
    "currencyByLocale": { "ru": "RUB", "en": "USD", "hy": "AMD" },
    "dictionaries": [
      { "locale": "ru", "dictionaryUrl": "/assets/i18n/ru.json", "version": "1.0.0" },
      { "locale": "en", "dictionaryUrl": "/assets/i18n/en.json", "version": "1.0.0" },
      { "locale": "hy", "dictionaryUrl": "/assets/i18n/hy.json", "version": "1.0.0" }
    ]
  },
  "seo": {
    "default": { "title": "Marketplace", "description": "Digital commerce marketplace", "robots": "index,follow" },
    "byPageKey": { "home": { "title": "Marketplace - Home", "canonicalUrl": "https://marketplace.local/", "robots": "index,follow" } }
  },
  "permissions": {
    "definitions": [
      { "key": "builder.pages.edit", "description": "Edit pages in builder" },
      { "key": "backoffice.products.read", "description": "Read products in backoffice" }
    ],
    "roles": [
      { "role": "builder_admin", "permissions": ["builder.pages.edit"] },
      { "role": "backoffice_manager", "permissions": ["backoffice.products.read"] }
    ]
  },
  "catalog": {
    "layout": "grid", "navigationMode": "default", "defaultSort": "relevance",
    "availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"],
    "enabledFilters": ["price", "availability", "rating", "brand", "category", "subcategory", "discount", "new", "color", "size", "attributes"],
    "showBreadcrumbs": true, "showRatings": true, "showDiscounts": true, "showAvailability": true
  },
  "productPage": {
    "rating": { "enabled": true },
    "reviews": { "enabled": true, "pageSize": 5, "showSummary": true },
    "questions": { "enabled": true, "pageSize": 5 },
    "relatedProducts": { "enabled": true }
  },
  "navigation": {
    "header": [
      { "id": "nav-home", "labelKey": "nav.home", "route": "/", "icon": "home", "order": 1 },
      { "id": "nav-search", "labelKey": "nav.search", "route": "/search", "icon": "search", "order": 2 }
    ],
    "footer": [
      { "id": "footer-about", "labelKey": "nav.about", "route": "/about-us", "order": 1 }
    ]
  },
  "footer": {
    "copyrightText": { "ru": "© 2026 Marketplace. Все права защищены.", "en": "© 2026 Marketplace. All rights reserved." },
    "legalPageKeys": ["about-us", "privacy-policy", "terms-of-service"]
  },
  "widgetRegistry": { "manifestUrl": "/assets/mock/bootstrap/widget-manifest.json" },
  "staticPages": {
    "about-us": {
      "route": "/about-us",
      "title": { "ru": "О компании", "en": "About Us", "hy": "Մեր մասին" },
      "html": { "ru": "<h2>О компании</h2><p>…</p>", "en": "<h2>About Us</h2><p>…</p>" }
    }
  },
  "pages": [
    {
      "id": "page-home", "key": "home", "title": "Home",
      "route": { "path": "/", "exact": true },
      "layout": { "type": "default" }, "seoKey": "home", "visible": true,
      "sections": [
        {
          "id": "section-hero", "type": "hero", "order": 1,
          "layout": { "strategy": "hero", "columns": 1, "gap": "1.5rem", "align": "stretch" },
          "visible": true,
          "widgets": [
            {
              "id": "widget-hero-main", "type": "hero", "version": "1.0.0", "order": 1,
              "props": {
                "title": { "ru": "Добро пожаловать на Маркетплейс", "en": "Welcome to Marketplace Platform" },
                "ctaLabel": { "ru": "Начать покупки", "en": "Start Shopping" }
              }
            }
          ]
        }
      ]
    }
  ]
}
```

### 1.6 Lifecycle

- There is **no `APP_INITIALIZER`** wiring bootstrap fetch as a hard app
  precondition. `main.ts` calls `bootstrapApplication(App, appConfig)` with no
  initializer that blocks on config. (`§4 (Authentication, this document)` §5.2 separately
  notes the Ed25519 `restoreSession()` initializer is also not wired.)
- Bootstrap is instead loaded lazily-but-eagerly by the first consumer to need
  it. Multiple root-level services subscribe to `ConfigService.loadBootstrap()`
  at construction: `UiRuntimeFacade` (`ui-runtime.facade.ts:32`),
  `PlatformRuntimeService`, `ThemeEngineService`, `BrandingEngineService`, the
  `FooterComponent`, etc. Because `ConfigService` de-dupes (§1.7), the first
  one triggers the single HTTP call and the rest share it.
- The `languageGuard` (`src/app/guards/language.guard.ts`, wrapping every
  `:lang` route) runs on navigation and preloads the locale dictionary; it is
  **not** itself the bootstrap fetcher, but it runs in the same startup window.
  Bootstrap and language resolution are effectively concurrent — the app does
  not gate rendering on bootstrap completion, so components must tolerate a
  transient null snapshot.

### 1.7 Caching behavior (what the frontend actually does)

`ConfigService` (`src/app/core/config/config.service.ts`) is the cache, and it
is **in-memory only** — no `localStorage`, no HTTP cache header logic:

- Holds `bootstrapSnapshot: BootstrapConfig | null` (last successful value) and
  a shared `bootstrap$` observable with `shareReplay(1)`.
- `loadBootstrap(forceRefresh = false)`: returns the cached `bootstrap$` when a
  snapshot exists and `forceRefresh` is false; otherwise (re)issues the
  provider call.
- On error, it clears both `bootstrap$` and `bootstrapSnapshot` so the next
  call retries cleanly (`catchError` rethrows).
- A `bootstrapRevision` signal increments on every successful load/override —
  consumers use it to react to config changes.
- `getBootstrapSnapshot()` returns the current in-memory value synchronously
  (used by `WidgetManifestService`, diagnostics, etc.).
- The cache does **not** survive a page reload — every full page load re-fetches
  bootstrap. There is also a separate client-side `cacheInterceptor`
  (GET-response caching, BACKEND-AUDIT §3) that may cache the `/bootstrap`
  response at the HTTP layer within a session, but nothing persists it across
  sessions.

### 1.8 ETag / conditional requests

**Requires backend decision — no ETag / conditional-request logic exists in
the frontend today.** The bootstrap provider issues a plain unconditional
`GET`; nothing reads or sends `ETag`, `If-None-Match`, `Last-Modified`, or
`If-Modified-Since`. If the backend wants conditional revalidation of the
(large, per-tenant, infrequently changing) bootstrap document, both the wire
strategy and the frontend handling for it would be new work.

### 1.9 Tenant resolution

Tenant is resolved by **subdomain**, not by header or path (full detail in
`§4 (Authentication, this document)` §10; source
`src/app/core/config/tenant-resolver.service.ts`):

- `TenantResolverService.getTenantKey()`: localhost →
  `environment.fallbackTenantKey ?? 'default'`; otherwise the **first DNS
  label** of the hostname, skipping a leading `www` (e.g.
  `dexarmarket.api.dexarmarket.ru` → `dexarmarket`; `www.acme.com` → `acme`).
- That tenant key feeds `ApiConfigService.getBaseUrl()`, which picks the
  marketplace API base: localhost → `environment.localhostApiUrl` (`/api`);
  else `environment.tenantApiBaseUrls[tenantKey]`; else
  `environment.tenantApiTemplate` with `{tenant}` substituted; else (only when
  `allowBootstrapApiOverride` is true — off by default) a value read out of the
  already-loaded bootstrap (`apiEndpoints.website.baseUrl` /
  `tenant.apiBaseUrl`).
- **No `X-Tenant` header or `/tenant/{id}` path prefix is ever sent.** Tenant
  isolation for `GET /bootstrap` is achieved purely by *which origin/subdomain
  the request goes to* — the backend must infer the tenant from the request
  host, and return that tenant's bootstrap document.

### 1.10 Draft vs Published

The storefront and the builder read the **same** in-memory `BootstrapConfig`;
there is no separate "draft bootstrap" vs "live bootstrap" endpoint today
(BACKEND-AUDIT §17):

- The builder (`ProjectEditorFacade`,
  `src/app/features/project-editor/facade/project-editor.facade.ts`) loads the
  bootstrap via `ConfigService.loadBootstrap(true)` (force refresh), edits an
  in-memory `History<BootstrapConfig>` (undo/redo), and persists **drafts to
  localStorage** through `ProjectEditorDraftStorageService`.
- `ConfigService.applyBootstrapOverride(next)` swaps the in-memory snapshot
  (deep-cloned) so a preview reflects edits immediately — but this is purely
  client-side.
- **There is no publish/write HTTP call.** No `PUT /bootstrap`, no
  builder-write endpoint exists anywhere in code. "Publishing a marketplace"
  (writing the edited bootstrap back so the live storefront serves it) is
  **FUTURE / LOCAL-ONLY today**. Any builder publish endpoint would be declared
  under `apiEndpoints.builder` (runtime-declared) and is a backend build item,
  not something the frontend currently calls.

> **Requires backend decision:** the entire draft→publish write path
> (endpoint, method, optimistic-concurrency / version check on save, and
> whether draft state is server-persisted or stays localStorage-only).

### 1.11 Fallback behavior (bootstrap fails to load)

- `ConfigService` does **not** substitute a default document on failure — it
  clears its cache and rethrows, so each subscribing consumer sees the error.
- There is no global "bootstrap failed" full-screen error page. Instead,
  individual engines degrade: `ThemeEngineService`/`BrandingEngineService`
  subscribe with `take(1)` and simply do nothing extra on error (the app keeps
  its compiled default styles); optional config getters fall back to hard-coded
  defaults (`DEFAULT_CATALOG_CONFIG`, `DEFAULT_USER_EXPERIENCE_CONFIG`);
  `WidgetManifestService` falls back to the static manifest URL on any bootstrap
  error.
- Feature-specific consumers that require bootstrap data render their own
  generic empty/error states (the same `common.errorTitle` pattern described in
  `§6 (Error Model, this document)`), not a bootstrap-specific one.

> **Requires backend decision / frontend follow-up:** whether a hard bootstrap
> failure should present a dedicated tenant-level error screen. Today the
> failure is silent-ish (default theme, empty dynamic pages) rather than a
> blocking error, which is a UX gap if the backend can actually return a fatal
> bootstrap error.

### 1.12 Failure responses

The frontend does not parse a bootstrap-specific error body — a failed
`GET /bootstrap` surfaces as a bare `HttpErrorResponse` handled per §1.11. For
the standard error envelope every endpoint (including this one) should return
on non-2xx, and the per-status semantics (401/403/404/409/422/429/500/503,
plus `TENANT_DISABLED` and `MAINTENANCE_MODE`), see
**§6 (Error Model)** — that section owns the error envelope and this
section does not redefine it. Two of its rows are especially relevant to
bootstrap:

- **`TENANT_DISABLED`** (HTTP 403, `error.code: "TENANT_DISABLED"`) — the
  natural failure for `GET /bootstrap` against a known-but-inactive tenant.
  `§6` marks the frontend handling for this as "Requires backend
  decision" (no code path handles it today).
- **`MAINTENANCE_MODE`** (HTTP 503, `error.code: "MAINTENANCE_MODE"`) — if a
  tenant is down for maintenance, bootstrap is where the frontend would first
  hit it. Again, no maintenance concept exists frontend-side today.

---

## 2. Endpoint Documentation Framework

This section defines the **general contract every endpoint in the system
follows**, so the reader has the framework before the per-domain endpoint
catalog (a separate section). Where the frontend does not define something,
it is marked **"Requires backend decision"** rather than invented.

### 2.1 Standard success response envelope

**There is no `ApiResponse<T>` / `{ success, data, error }` envelope in this
codebase.** Every live caller types its `HttpClient` call to the **bare payload
type** and consumes the raw JSON directly:

- `ApiService.getItem()` → `HttpClient.get<Item>(...)` (the item object itself).
- `ApiService.getCategories()` → `get<Category[]>` (bare array).
- `ApiBootstrapProvider` → `get<BootstrapConfig>` (bare object).
- `TelegramSessionApiService` → `get<Record<string, unknown>>` then normalizes.

The generic `ApiResponse<T>` shape in the org coding-standards is **not** used
here — do not assume an envelope. Success responses are the raw resource
(object, array, or `{ items, total }` for lists — see §2.2).

> **Requires backend decision:** whether to *introduce* a success envelope
> going forward. If adopted it would be a breaking change to every existing
> live caller listed in §2.6 and BACKEND-AUDIT §4, each of which currently
> expects the bare payload. Recommendation: keep bare payloads for the existing
> live endpoints; only new endpoints could opt into an envelope, and even then
> the frontend has no envelope-unwrapping layer today (one would be new work).

### 2.2 Standard pagination contract

Two distinct pagination shapes already exist in the frontend; a backend should
align to these rather than invent a third:

**A) Offset/count style (marketplace list + search endpoints)** — used by the
live `ApiService`:

- Request query params: **`count`** (page size, default 50) and **`skip`**
  (offset, default 0). Applied on `GET /category/{id}`, `GET /searchitems`,
  `GET /items/randomitems`.
- Response for `searchItems`: `{ items: Item[], total: number }`. Other list
  endpoints (`getCategoryItems`, `getRandomItems`) return a **bare array** with
  no total.

**B) Page/pageSize style (admin lists, storefront engagement lists, media)** —
used by facade/gateway list results:

- Request: `{ page: number, pageSize: number }` (plus filters — §2.4). See
  `AdminOrderListFilters`, `EngagementListQuery`, `MediaListParams`.
- Response: `{ items: T[], total: number, page: number, pageSize: number }`.
  Canonical shapes: `EngagementListResult<T>`
  (`core/products/models/product-engagement.model.ts:55`), `AdminOrdersListResult`
  (`features/admin/orders/models/admin-order.model.ts:63`), `MediaListResult`
  (`{ items, total }` only). The UI derives `totalPages = ceil(total /
  pageSize)` client-side (e.g. `pagination.component.ts`, admin list pages).

Notes / open items:

- The two styles use different param names (`count`/`skip` vs `page`/`pageSize`)
  — a backend serving both storefront and admin must support both, or a
  reconciliation decision is needed.
- **No cursor/keyset pagination anywhere.** The only `cursor` matches in source
  are CSS `cursor:` properties. There is no `nextCursor`/`hasMore` on the wire
  (`catalog-state.model.ts` has a client-derived `hasMore` for infinite-scroll,
  computed as `skip + pageSize < total`, not a backend field).

> **Requires backend decision:** exact page-size **limits/maximums** (the
> frontend sends 50 as a default `count` but enforces no server-side cap),
> whether to standardize on offset vs page-number, and whether large lists
> should move to cursor pagination (nothing in the frontend implies or requires
> cursors today).

### 2.3 Standard sorting contract

Sorting is **enumerated in the bootstrap `catalog` config**, not free-form:

- `CatalogConfig.availableSorts` / `CatalogConfig.defaultSort` (see §1.3,
  `catalog-config.model.ts`) enumerate the allowed values:
  `relevance | latest | price_asc | price_desc | rating | popular | discount`.
- The live wire param is `sort` on `GET /searchitems`
  (`ApiService.searchItems`), whose accepted set is a subset:
  `relevance | price_asc | price_desc | popular | rating` (the code's
  `options.sort` union). `latest`/`discount` are catalog-config values without a
  confirmed search-endpoint mapping.
- Admin lists sort primarily via their filter objects / client-side; there is
  no shared admin `sort` query-param convention on the wire yet (admin CRUD is
  mock-only, BACKEND-AUDIT §14).

> **Requires backend decision:** reconcile the catalog-config sort vocabulary
> (7 values) with the search-endpoint `sort` vocabulary (5 values) — and define
> the wire encoding for admin list sorting (field + direction) when those
> endpoints are built.

### 2.4 Standard filtering contract

Filtering shapes already used by the frontend:

- **Storefront search/catalog** — `GET /searchitems` accepts optional
  `categoryIDs` (comma-joined ints), `minPrice`, `maxPrice`, `tag`, plus `sort`
  (§2.3). `CatalogConfig.enabledFilters` declares which filter UIs a tenant
  exposes (`price, availability, rating, brand, category, subcategory, discount,
  new, color, size, attributes`).
- **Admin lists** — each domain has a filters object, all following a
  `{ search: string, <field>: 'all' | <enum>, page, pageSize }` shape. Examples:
  - `AdminOrderListFilters` = `{ search, status: 'all'|AdminOrderStatus, page,
    pageSize }`.
  - `AdminMonitoringEventFilters` = `{ category: 'all'|AdminMonitoringCategory,
    search }` (note: no page/pageSize on this one).
  - `MediaListParams` = `{ page?, pageSize?, search?, folder?, tag?, kind?,
    sort? }`.
  The convention: a text `search` string plus enum facets where the sentinel
  **`'all'`** means "no filter on this facet."

> **Requires backend decision:** the wire encoding of these filters as query
> params for the (not-yet-built) admin endpoints — the shapes above are
> in-memory facade filter objects, not confirmed query-string contracts. Also
> whether `'all'` is sent literally or omitted to mean unfiltered.

### 2.5 Standard search contract

- Live full-text search: `GET /searchitems?search=<q>&count=&skip=[&categoryIDs
  &minPrice&maxPrice&tag&sort]` → `{ items: Item[], total: number }`
  (`ApiService.searchItems`). The query param is **`search`**.
- The `SearchFacade` (BACKEND-AUDIT §18) is a **client-side orchestration** over
  `ProductFacade`/`CategoryFacade` — history, trending, autocomplete, and cache
  are all localStorage/in-memory. There is **no dedicated search backend
  endpoint** beyond `/searchitems`; autocomplete/suggestions are derived
  client-side today.

> **Requires backend decision:** whether server-side autocomplete/suggestion
> and trending endpoints are wanted (nothing on the wire today), and the
> min-query-length / debounce contract if so.

### 2.6 Nullable-field convention

The frontend does **not** ascribe distinct semantics to `null` vs `undefined`
vs an omitted key on the wire — it treats all three as "absent" and applies
defaults defensively:

- Model interfaces mark optional fields with `?` (implying may be omitted);
  a handful use explicit `T | null` (e.g. `AuthSession.userId: number | null`,
  `AuthSession.username: string | null` where `null` specifically means "known
  to be absent").
- The tolerant normalizers (`ApiService.normalizeItem/normalizeCategory`,
  `TelegramSessionApiService.normalizeWebSession`) coalesce `null`/`undefined`/
  missing uniformly via `??` and default fallbacks — they never branch on
  `null` vs `undefined`.
- **Convention to follow:** the backend may omit an optional key or send `null`
  interchangeably; both are handled. Explicit `null` is only meaningful where a
  model types the field as `… | null` to signal "definitively no value" (auth
  user id/username being the notable examples).

### 2.7 JWT / authentication header format

Two coexisting mechanisms; full spec in **`§4 (Authentication, this document)`** (this
section only references it, does not redefine):

- **Telegram session auth (LIVE)** — no `Authorization: Bearer`. Identity is a
  **`WebSessionID`** header (auth session id, or a persisted anonymous 32-hex id
  from `localStorage['web_session_id']`) attached to marketplace API requests by
  `apiHeadersInterceptor`. Admin requests additionally carry an
  **`AdminWebSessionID`** header (`adminAuthHeadersInterceptor`, gating URL
  segments `/admin/`, `/backoffice/`, `/builder/`, `/media/`).
- **Ed25519 admin auth (wired, backend absent)** — issues a JWT
  `AuthTokenPair { token, refreshToken }`; the intended header is standard
  `Authorization: Bearer <token>`, but its `authInterceptor` is **not
  registered** today (§4 §2.6), so no request auto-attaches the
  bearer token yet. `adminAuthHeadersInterceptor` *does* set `Authorization:
  Bearer <token>` if an admin token happens to be stored, but nothing stores one
  in the live flow.

Refer to `§4 (Authentication, this document)` for token structure (`JwtClaims`), refresh,
rotation, expiry, role hierarchy, and tenant-scoping open items.

### 2.8 Live endpoint domains (real HTTP, already implemented)

The following domains have **real `HttpClient` calls in code today** (not
proposals). Documented per-endpoint below. (The Ed25519 admin-auth API is wired
to real HTTP but the backend does not implement it yet — its full contract is
in `§4 (Authentication, this document)` §2, not repeated here.)

#### 2.8.1 Session auth API (LIVE)

Base: `environment.authApiUrl` (= `https://api.dexarmarket.ru:445`). Source:
`src/app/services/telegram-session-api.service.ts`. Narrative flow in
`§4 (Authentication, this document)` §1.

| Endpoint | Method | Auth | Body / Headers | Response |
|---|---|---|---|---|
| `/users/sessions` | POST | none | body `{ webSessionID }` (client-generated GUID) + header `WebSessionID: <same guid>` | `{ webSessionID, url }` — normalized to `WebSessionStart`; `url` = Telegram deep link |
| `/users/sessions/{id}` | GET | none | — | Session object, field-tolerant → normalized `AuthSession` |
| `/users/sessions/{id}` | DELETE | none | header `WebSessionID: <id>` | ignored (frontend clears local state regardless) |

TypeScript interfaces (`src/app/models/auth.model.ts`):

```ts
export interface AuthSession {
  sessionId: string;
  userId: number | null;
  username: string | null;
  displayName: string;
  active: boolean;
  expires: string; // ISO 8601
}

export interface WebSessionStart {
  webSessionID: string;
  url: string;
}
```

Example — create session:

```http
POST https://api.dexarmarket.ru:445/users/sessions
WebSessionID: 3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11
Content-Type: application/json

{ "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11" }
```

```json
{
  "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11",
  "url": "https://t.me/myAMLKYCBOT?start=3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11"
}
```

Example — poll session (active):

```http
GET https://api.dexarmarket.ru:445/users/sessions/3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11
```

```json
{
  "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11",
  "status": "active",
  "user": { "id": 8823771, "username": "buyer_ivan", "firstName": "Ivan", "lastName": "P" },
  "expiresAt": "2026-07-26T05:00:00Z"
}
```

The frontend reads fields **field-tolerantly** (accepts many casings/aliases —
see `normalizeWebSession`, and `§4 (Authentication, this document)` §1.3 for the full alias
priority lists). A backend should send a real `expiresAt`/`expires` (else the
frontend fabricates `now + 3600s`).

#### 2.8.2 Marketplace / storefront API (LIVE)

Base: `ApiConfigService.getBaseUrl()` (tenant-resolved; `/api` on localhost,
else `https://{tenant}.api.dexarmarket.ru:445` / `https://api.dexarmarket.ru:445`).
Source: `src/app/services/api.service.ts` (`ApiService`). Headers attached by
`apiHeadersInterceptor`: `X-Region`, `X-Language` (RU/EN/AM), `Currency`
(default RUB), `WebSessionID`. The primary product wire DTO is `Item`
(`src/app/models/item.model.ts`), reconciled by the large tolerant
`normalizeItem()` adapter (BACKEND-AUDIT §7 — treat it as the tolerance
contract). This domain's full per-endpoint catalog belongs to the CRUD-domain
section; summarized here as the framework anchor:

| Endpoint | Method | Auth | Params / Body | Response |
|---|---|---|---|---|
| `/ping` | GET | session headers | — | `{ message }` |
| `/category` | GET | session headers | — | `Category[]` (normalized) |
| `/category/{id}` | GET | session headers | `count`, `skip` | `Item[]` |
| `/items/{id}` | GET | session headers | — | `Item` |
| `/searchitems` | GET | session headers | `search`, `count`, `skip`, `categoryIDs?`, `minPrice?`, `maxPrice?`, `tag?`, `sort?` | `{ items: Item[], total: number }` |
| `/items/randomitems` | GET | session headers | `count`, `category?` | `Item[]` (featured/random) |
| `/websession/{sessionId}` | POST | session headers | item array | cart echo |
| `/items/{id}/callback` | POST | session headers | `{ rating, comment, sessionID, timestamp }` | `{ message }` (review) |
| `/items/{id}/questiion` | POST | session headers | `{ question, sessionID, timestamp }` | `{ message }` (question — **literal typo `questiion` matches backend spec**) |
| `/purchase-email` | POST | session headers | `{ email, phone?, telegramUserId, items[] }` | `{ message }` |

#### 2.8.3 Cart / order / payment API (LIVE)

Order/cart-payment endpoints on the **marketplace base**; QR/card status on the
**payment base** `environment.qrApiUrl` (= `https://qr.vitanova.network/api`).
Source: `src/app/services/api.service.ts`. DTOs are inline in that file.

| Endpoint | Method | Base | Auth | Body | Response |
|---|---|---|---|---|---|
| `/cart` | POST | marketplace | session headers | `CartPaymentRequest` | `QrCreateResponse` |
| `/orders` | POST | marketplace | session headers | `CreateOrderRequest` | `CreateOrderResponse` (fire-and-forget after payment) |
| `/qr` | POST | `qrApiUrl` | headers `authorization-key`, `userid-value` | `QrCreateRequest` | `QrCreateResponse` |
| `/qr/dynamic/{partnerId}/{qrId}` | GET | `qrApiUrl` | — | `QrDynamicStatusResponse` |
| `/card/{partnerId}/{orderId}` | GET | `qrApiUrl` | — | `QrDynamicStatusResponse` |

Const `partnerId` = `web-97ec-9c57-4dde-9037-3a68f7f83750`
(`ApiService.cartPaymentPartnerId`). Key interfaces (copied from
`api.service.ts`):

```ts
export interface CartPaymentRequest {
  amount: number;
  currency: 'RUB';
  siteuserID: string;
  siteorderID: string;
  redirectUrl: string;
  telegramUsername: string;
  paymentMethod: 'qr' | 'card';
  qrDescription?: string;
  customerID?: string;
  items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[] }>;
}

export interface CreateOrderRequest {
  items: Array<{ productId: string; name: string; quantity: number; price: number }>;
  customer: { name: string; email: string; phone: string };
  payment?: { method: string; currency: string };
  shipping?: { address: string; method: string; trackingNumber: string };
}

export interface CreateOrderResponse {
  id: string;
  orderNumber: string;
  status: string;
  total: number;
  currency: string;
}

export interface QrCreateResponse {
  qrId?: string; qrID?: string; nspkID?: string; nspkId?: string;
  nspkurl?: string; orderID?: string; url?: string; bankUrl?: string;
  status?: string; qrStatus?: string; qrExpirationDate?: string; qrTTL?: number;
  payload?: string; Payload?: string; qrUrl?: string;
  partnerqrID?: string | number; partnerID?: string | number;
  partnerId?: string | number; PartnerID?: string | number;
}

export 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;
}
```

The `QrCreateResponse` is deliberately alias-tolerant (many casings for id /
url / partner fields) — the frontend resolves the effective id/link via
`resolvePaymentQrId()` / `resolvePaymentLink()` / `resolveBankPaymentUrl()`. A
backend can pick one canonical casing; the frontend will still read it.

Example — create cart payment:

```http
POST https://api.dexarmarket.ru:445/cart
WebSessionID: 3f1c2a0e-…
Content-Type: application/json

{
  "amount": 4990,
  "currency": "RUB",
  "siteuserID": "8823771",
  "siteorderID": "order-2026-0007",
  "redirectUrl": "https://marketplace.local/checkout/done",
  "telegramUsername": "buyer_ivan",
  "paymentMethod": "qr",
  "items": [{ "itemID": 101, "price": 4990, "name": "Wireless Keyboard", "quantity": 1 }]
}
```

```json
{ "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" }
```

#### 2.8.4 Widget manifest (LIVE)

Source: `src/app/widgets/registry/widget-manifest.service.ts`. URL comes from
`bootstrap.widgetRegistry.manifestUrl`, falling back to
`/assets/mock/bootstrap/widget-manifest.json`.

| Endpoint | Method | Auth | Response |
|---|---|---|---|
| `<bootstrap.widgetRegistry.manifestUrl>` | GET | none | `WidgetManifestFile` |

On any error it falls back to `of({ widgets: [] })` (never throws to the UI).
Interface (`src/app/widgets/contracts/widget-manifest.contract.ts`):

```ts
export interface WidgetManifestFile {
  widgets: WidgetManifestEntry[];
}

export interface WidgetManifestEntry {
  type: string;
  version: string;
  componentKey: string;
  supportedLayouts: WidgetLayoutSupport[];   // 'stack'|'grid'|'hero'|'carousel'|'split'
  supportedDataSources: WidgetDataSourceName[]; // 'featured'|'latest'|'category'|'manual'|'related'|'future'|'root'|'parent'
  settingsSchema: WidgetSettingsSchema;       // { type: 'object', properties, required? }
  defaultSettings: Record<string, unknown>;
  metadataSupport?: WidgetMetadataSupport;
  enabled?: boolean;
}
```

Example response:

```json
{
  "widgets": [
    {
      "type": "hero",
      "version": "1.0.0",
      "componentKey": "HeroWidgetComponent",
      "supportedLayouts": ["hero"],
      "supportedDataSources": ["manual"],
      "settingsSchema": { "type": "object", "properties": { "title": { "type": "string" } } },
      "defaultSettings": { "title": "Welcome" },
      "enabled": true
    }
  ]
}
```

#### 2.8.5 Regions / location (LIVE)

Source: `src/app/services/location.service.ts`.

| Endpoint | Method | Base | Auth | Response |
|---|---|---|---|---|
| `/regions` | GET | marketplace | session headers | `Region[]` |

On error, falls back **silently** to 6 hardcoded regions (moscow, spb, yerevan,
minsk, almaty, tbilisi) — no user-visible error. The selected region's `id`
feeds the `X-Region` header on subsequent marketplace calls. (An external
geo-IP call `GET http://ip-api.com/json/...` is used for auto-detect — external,
not a platform backend.) Interface (`src/app/models/location.model.ts`):

```ts
export interface Region {
  id: string;
  city: string;
  country: string;
  countryCode: string;
  timezone?: string;
}
```

Example response:

```json
[
  { "id": "moscow", "city": "Москва", "country": "Россия", "countryCode": "RU", "timezone": "Europe/Moscow" },
  { "id": "yerevan", "city": "Ереван", "country": "Армения", "countryCode": "AM", "timezone": "Asia/Yerevan" }
]
```

### 2.10 API path versioning

No endpoint the frontend calls includes a version segment (no `/v1/`, no
`Accept-Version`/`Api-Version` header). Every domain path in this document is
relative to `{base}` = `ApiConfigService.getBaseUrl()` (tenant-resolved; see
§1.9) with no version component anywhere in that resolution chain
(`tenant.apiBaseUrl` from bootstrap, or `/api` on localhost — §1.3, §1.9).

The only version field in the entire contract is `BootstrapConfig.schemaVersion`
(§1.4), which versions the **bootstrap payload shape**, not the API surface —
it's checked for presence only, not semantically enforced, and doesn't apply to
any other endpoint.

> **Requires backend decision:** whether the API surface gets a versioning
> scheme at all (URL path segment, header, or none/evergreen-only), and if so
> whether it's introduced from day one or deferred until the first breaking
> change. No frontend code currently assumes or constructs a version segment,
> so either choice is a pure backend/infra decision with no frontend rework
> unless breaking changes are introduced later.

### 2.11 Real-time / WebSocket

No WebSocket, Server-Sent Events, or other push channel exists anywhere in the
frontend. Every case that looks "live" is client-side polling on a plain
`setInterval`/RxJS `interval`, hitting a normal request/response endpoint:

| What | Mechanism | Where |
|---|---|---|
| QR/Telegram login session state | Poll `GET` session-status endpoint until `active`/expired (§4 Mechanism A, `QrLoginEngine`) | Auth |
| Cart QR/card payment status | Poll cart payment-status endpoint, bounded by response `qrTTL` (min 60s) | §3.3 Orders / payments |
| Session/token validity | Re-checked on next request or next refresh-interval tick — no push invalidation. A backend-side revocation isn't observed by an already-open session until the refresh interval elapses (§4.8 Session invalidation) | Auth |
| Maintenance-notice banner (proposed) | Would be a polling `GET /maintenance-notice` endpoint, not a push channel (§10.7) | Maintenance mode |
| Admin monitoring queue/webhook status (proposed, mock-only today) | Would be plain `GET` on facade refresh, no proposal anywhere for push (§3.19) | Monitoring |

> **Requires backend decision:** whether any of the above should become
> push-based (WebSocket/SSE) instead of polling — most relevant to payment
> status (customer-facing latency) and session revocation (security: a
> revoked admin session stays technically usable client-side for up to one
> refresh interval, §4.8). No frontend code exists for a push channel today,
> so adding one is net-new frontend work, not a swap.

### 2.12 Consolidated "Requires backend decision" items (this section)

- Bootstrap: `X-Language`/`Accept-Language` handling on `GET /bootstrap` (§1.1).
- Bootstrap: whether `schemaVersion` is enforced vs presence-only (§1.4).
- Bootstrap: ETag / conditional-request strategy — none exists today (§1.8).
- Bootstrap: the entire draft→publish write path + save-time version/concurrency
  check — no write endpoint exists (§1.10).
- Bootstrap: dedicated fatal-bootstrap-failure UX vs silent degradation (§1.11).
- Framework: whether to introduce a success envelope (breaking for all live
  callers) — none exists today (§2.1).
- Framework: pagination page-size limits/max; offset vs page-number
  standardization; cursor vs offset (nothing implies cursors) (§2.2).
- Framework: reconcile catalog-config sort vocabulary (7) vs search-endpoint
  `sort` vocabulary (5); wire encoding for admin list sorting (§2.3).
- Framework: query-string encoding for admin list filters and the `'all'`
  sentinel (§2.4).
- Framework: server-side autocomplete/trending search endpoints + min-query
  contract (§2.5).
- Framework: API path/header versioning scheme, if any — none exists today (§2.10).
- Framework: whether payment-status polling and session revocation should
  become push-based (WebSocket/SSE) instead of polling (§2.11).

---

## 3. CRUD Contracts

Exhaustive per-domain, per-endpoint contract for every backend touch-point the
Angular frontend expects. Derived from source on branch `B2B` and cross-checked
against `docs/context/BACKEND-AUDIT.md` (the this-session audit — the ground
truth for what code actually does), the prior `docs/archive/BACKEND_API.md`, and the two
sibling specs written this session:

- **Auth / headers / JWT** — see `§4 (Authentication, this document)`. This section never
  re-defines the auth header format; it references it.
- **Error response envelope + per-status semantics** — see
  `§6 (Error Model, this document)`. This section names *which* statuses apply per
  endpoint and *why*, but the wire shape of the error body is owned by that doc.

### 3.0 Conventions used in this section

**Two API base URLs are in play** (`BACKEND-AUDIT.md` §2):

- **Marketplace / tenant API** — `ApiConfigService.getBaseUrl()`
  (`src/app/core/config/api-config.service.ts`). Localhost → `/api`; otherwise a
  per-tenant origin (default `https://api.dexarmarket.ru:445`). Tenant isolation
  is **by subdomain/base-URL only** — no `X-Tenant` header, no `/tenant/{id}`
  prefix (`§4` §10). All storefront reads and all admin CRUD go
  here.
- **Payment / QR API** — `environment.qrApiUrl` (`https://qr.vitanova.network/api`).
  Only cart/order payment + status polling (§3.3).
- **Session-auth API** — `environment.authApiUrl`. Login/session only, covered by
  `§4 (Authentication, this document)`, not repeated here.

**LIVE vs PROPOSED paths.** Only the storefront catalog/cart/engagement reads and
the single `AdminCategoriesApiGateway` have **literal HTTP paths in code**. Every
other admin CRUD path below is **PROPOSED** — no URL literal exists anywhere in
the frontend for it (`BACKEND-AUDIT.md` §24: "No literal `/admin/*`, `/builder/*`,
or per-admin-domain backoffice CRUD paths exist in code"). The admin gateways are
in-memory `*LocalGateway` classes that never construct a URL. The proposals here
follow the **one real precedent** in the codebase — `AdminCategoriesApiGateway`
(`src/app/features/admin/categories/services/admin-categories-api.gateway.ts`),
which uses:

```
GET    {base}/backoffice/categories            ?search&visibility&includeDeleted
GET    {base}/backoffice/categories/{id}
POST   {base}/backoffice/categories            (body = category minus server-owned fields)
PUT    {base}/backoffice/categories/{id}
DELETE {base}/backoffice/categories/{id}        (soft delete)
POST   {base}/backoffice/categories/{id}/restore
GET    {base}/backoffice/categories/slug-taken ?slug&excludingId
```

All other admin paths below are proposed by analogy to this shape (`{base}/backoffice/<domain>`).
Builder/config domains (nav, footer, branding, languages, CMS, homepage, widgets)
are proposed under `{base}/builder/...` because the bootstrap document declares a
`apiEndpoints.builder` record for exactly this, but **no builder write call exists
in code today** — the entire builder/CMS surface is LOCAL-ONLY (in-memory
bootstrap + localStorage drafts), so those endpoints are marked **PROPOSED /
FUTURE** throughout.

**Headers (all requests).** Marketplace API requests carry `X-Region`,
`X-Language` (`RU|EN|AM`), `Currency` (default `RUB`), and `WebSessionID`
(`apiHeadersInterceptor`, `BACKEND-AUDIT.md` §3). Requests whose URL contains
`/admin/`, `/backoffice/`, `/builder/`, or `/media/` **additionally** get
`AdminWebSessionID` and, if an admin token is stored, `Authorization: Bearer
<token>` (`adminAuthHeadersInterceptor`). These header names/values are owned by
`§4` — assume them on every admin endpoint below unless stated.

**JWT requirement.** No endpoint in the app requires a verified JWT **today**.
Mechanism A (Telegram session) authenticates admins with an opaque
`AdminWebSessionID`, not a JWT; the Ed25519 JWT flow (Mechanism B) is fully wired
but dormant (`§4` §2). For every admin endpoint below, "JWT
requirement" is therefore stated as: **Today: `AdminWebSessionID` session header
(Mechanism A). Target: `Authorization: Bearer <Ed25519 JWT>` once Mechanism B is
cut over.** Server-side authorization must be enforced regardless of any
client-side guard (`§4` §11.6).

**Required permission / role.** The frontend's only permission model is the
coarse `ROLE_PERMISSIONS` table (`§4` §9.1): `backoffice.read`,
`backoffice.write`, `builder.read`, `builder.write`, `users.manage`,
`settings.manage`. The frontend does **not** enforce per-domain permissions on
admin CRUD today (the live `adminAuthGuard` only checks "is there an admin
session at all"). Where a specific permission is the obvious fit it is named as
**Proposed**; any finer per-role assignment is **Requires backend decision**
(`§4` §9.1 already flags fine-grained permissions as out of scope).

**Error responses.** Unless a domain-specific note says otherwise, every endpoint
below can return the envelope from `§6` with these statuses:
`401 UNAUTHENTICATED` (no/expired session), `403 FORBIDDEN` (wrong role/tenant),
`500 INTERNAL_ERROR`, `503 SERVICE_UNAVAILABLE`/`MAINTENANCE_MODE`. Per-endpoint
notes below add `404`, `409`, `422`, `429` only where they are meaningful. **The
frontend does not parse the error body today** (`§6` "Finding") —
admin list pages collapse every non-2xx into one generic "retry" state
(`§6` "Generic list-page error UI"). So all per-status handling
below is the *contract the backend should honor*, not behavior the current UI
distinguishes.

**Validation rules.** Admin create/edit forms
(`admin-product-form.component.ts`, `admin-category-form.component.ts`) use **no
Angular reactive `Validators`** — confirmed by source search (zero
`Validators.*` matches). Form state is built by factories
(`admin-products-form.factory.ts`, `admin-categories-form.factory.ts`) that seed
empty defaults (e.g. `slug: ''`). Therefore **the frontend enforces essentially
no field validation** on admin CRUD; every validation rule below beyond "the
field exists in the model / is non-nullable in TS" is **Requires backend
decision**. The one real client-side validation convention lives in the builder
(`ProjectValidator` → `ProjectEditorFacade.fieldError()`), which is client-only
and localStorage-scoped, not backend-fed.

**Pagination.** The admin list models that paginate use a uniform shape:
`filters { …, page: number, pageSize: number }` in, `{ items[], total, page,
pageSize }` out (products, orders, transactions, reviews). Categories, users,
monitoring, customers, analytics return **plain arrays with no pagination** —
noted per domain.

---

### 3.1 Products

Two distinct surfaces: **storefront catalog read** (LIVE) and **admin CRUD**
(MOCK-ONLY, no seam — `BACKEND-AUDIT.md` §14).

#### 3.1.a Storefront catalog read — LIVE

Backed by `ApiService` (`src/app/services/api.service.ts`) via
`ApiProductDataProvider` → `PRODUCT_DATA_PROVIDER` → `ProductFacade`. The wire
DTO is `Item` (`src/app/models/item.model.ts`); `ApiService.normalizeItem()` is
the tolerance contract (the single largest inline mapper — a backend engineer
should treat it as authoritative for accepted field name variants; see
`BACKEND-AUDIT.md` §7).

| # | Method | Path (LIVE literal) | Purpose | Success |
|---|---|---|---|---|
| P1 | GET | `{base}/items/{itemID}` | Single product | `200` single `Item` (raw wire shape) |
| P2 | GET | `{base}/category/{categoryID}?count&skip` | Products in a category | `200` `Item[]` |
| P3 | GET | `{base}/searchitems?search&count&skip[&categoryIDs&minPrice&maxPrice&tag&sort]` | Search | `200` `{ items: Item[], total: number }` |
| P4 | GET | `{base}/items/randomitems?count[&category]` | Featured/random | `200` `Item[]` |

- **Headers/JWT:** marketplace headers (§3.0). No JWT; anonymous `WebSessionID`
  is sufficient. Public read.
- **Query params (P2/P3/P4):** `count` (default 50), `skip` (default 0, offset
  pagination). P3 search: `search` (string, required), `categoryIDs`
  (comma-joined numeric ids), `minPrice`, `maxPrice` (numbers), `tag` (string),
  `sort` ∈ `relevance | price_asc | price_desc | popular | rating`. Signature:
  `ApiService.searchItems()` lines 551-590.
- **Pagination/sorting/filtering:** offset-based (`count`/`skip`); sort + price
  + category + tag filters only on P3. P2 has no sort/filter params. Response P3
  is the only one returning a `total` for page math.
- **Error responses:** `404` when `itemID` doesn't exist (P1) — but note the
  frontend does **not** distinguish 404 from any other error today
  (`§6` §404); a deleted product and a 500 render the same generic
  empty-state. `500/503` generic. Calls retry x2 with exponential backoff
  (`ApiService.retryConfig`).
- **Validation:** none (read).
- **Example P3 request:** `GET {base}/searchitems?search=phone&count=24&skip=0&categoryIDs=3,7&minPrice=1000&sort=price_asc`
- **Example P3 response:**
  ```json
  { "items": [ { "id": "1024", "name": "Phone X", "price": 62560, "currency": "RUB", "imgs": ["https://…/x.webp"], "remaining": 12 } ], "total": 87 }
  ```

**`Item` variants/options (part of P1 payload, not separate endpoints).**
`normalizeItem` accepts `variantOptions[]` (`{ key, label, labels?, options: [{
value, label?, labels?, available? }] }`) and `specificationGroups[]`. The admin
side (§3.1.b) models variants as a flat priced list (`AdminProductVariant`). There
is **no dedicated variants/options CRUD endpoint** — variants are created/updated
as part of the product create/update body. Flagged: variant shape differs between
storefront (`variantOptions` grouped) and admin (`variants` flat priced rows) —
reconciliation is **Requires backend decision** (adapter lives in
`admin-product-form.factory.ts variantsToBackendRows/variantsFromBackendRows`).

#### 3.1.b Admin products CRUD — PROPOSED (MOCK-ONLY today, no DI seam)

Contract: `AdminProductsGateway`
(`src/app/features/admin/products/services/admin-products-gateway.interface.ts`).
Impl today: `AdminProductsLocalGateway` (in-memory + localStorage), injected
**concretely** by `AdminProductsFacade` — no token, so a backend requires
introducing a DI seam first (`BACKEND-AUDIT.md` §14, key finding). DTO:
`AdminProduct` (`src/app/features/admin/products/models/admin-product.model.ts`,
41-field interface incl. `media`, `variants`, `variantAttributes`, `specifications`,
`attributes`, `translations`, `seo`, `badges`, flags, `reviews`, `questions`).

| # | Method | Path (PROPOSED) | Gateway method | Success |
|---|---|---|---|---|
| AP1 | GET | `{base}/backoffice/products?search&categoryId&visibility&stock&includeArchived&sort&page&pageSize` | `loadProducts(filters)` | `200 AdminProductsListResult` |
| AP2 | GET | `{base}/backoffice/products/{id}` | `loadProduct(id)` | `200 AdminProduct` / `404` |
| AP3 | GET | `{base}/backoffice/products/categories` | `loadCategories()` | `200 AdminProductCategoryOption[]` (`{id,title}`) |
| AP4 | POST | `{base}/backoffice/products` | `createProduct(product)` | `201 AdminProduct` |
| AP5 | PUT | `{base}/backoffice/products/{id}` | `updateProduct(product)` | `200 AdminProduct` |
| AP6 | DELETE | `{base}/backoffice/products/{id}` | `deleteProduct(id)` | `204` |
| AP7 | POST | `{base}/backoffice/products/{id}/duplicate` | `duplicateProduct(id)` | `201 AdminProduct` / `404` |
| AP8 | POST | `{base}/backoffice/products/{id}/archive` | `archiveProduct(id)` | `204` |
| AP9 | POST | `{base}/backoffice/products/{id}/restore` | `restoreProduct(id)` | `200 AdminProduct` / `404` |

- **Headers/JWT:** admin auth (§3.0). Today `AdminWebSessionID`; target Bearer JWT.
- **Permission (Proposed):** read (AP1-AP3) → `backoffice.read`; writes
  (AP4-AP9) → `backoffice.write`. Finer than that = Requires backend decision.
- **AP1 query params** (from `AdminProductListFilters`): `search` (string),
  `categoryId` (string|null), `visibility` ∈ `all|visible|hidden`, `stock` ∈
  `all|in_stock|low_stock|out_of_stock`, `includeArchived` (bool), `sort` ∈
  `title|price|priority|stock|updated`, `page` (number), `pageSize` (number).
  This is the full pagination + sort + filter + search surface — no other
  sort keys exist. Response `AdminProductsListResult { items, total, page, pageSize }`.
- **AP4/AP5 request body:** an `AdminProduct`. By the category precedent
  (`AdminCategoriesApiGateway.createCategory` strips `id, itemsCount, deletedAt,
  createdAt, updatedAt`), the create body should **omit server-owned fields**
  (`id`, `createdAt`, `updatedAt`; also `reviews`/`questions` which are derived).
  Update sends the full object with `id` in the path. **Requires backend
  decision:** exact server-owned field list for products (the frontend local
  gateway does not enforce one).
- **Validation:** none client-side (no `Validators`). Model non-nullable fields
  (`name`, `slug`, `sku`, `categoryId`, `price`, `currency`, `quantity`) are the
  only implicit "required" signal. Uniqueness of `slug`/`sku`, price ≥ 0, etc.
  are all **Requires backend decision** (no `isSlugTaken` equivalent exists for
  products, unlike categories). Recommended `422 VALIDATION_FAILED` with
  `details[].field` for `sku`/`slug`/`price`.
- **Error responses:** `404` (AP2/AP5-AP9 unknown id), `409 CONFLICT`
  (duplicate slug/sku on AP4/AP5 — Requires backend decision, no proactive
  pre-check exists), `422` (field validation), plus the common set.
- **Example AP4 request (trimmed):**
  ```json
  { "name": "Phone X", "slug": "phone-x", "sku": "PHN-X-001", "barcode": "", "brand": "Acme", "categoryId": "3", "visible": true, "archived": false, "priority": 0, "price": 62560, "discount": 0, "currency": "RUB", "quantity": 100, "stockStatus": "in_stock", "media": { "images": [], "gallery": [], "videos": [] }, "specifications": [], "attributes": [], "variantAttributes": [], "variants": [], "relatedProductIds": [], "translations": {}, "seo": { "metaTitle": "", "metaDescription": "", "keywords": "" }, "badges": [] }
  ```
- **Example AP1 response:**
  ```json
  { "items": [ { "id": "p_1024", "name": "Phone X", "slug": "phone-x", "sku": "PHN-X-001", "categoryId": "3", "price": 62560, "currency": "RUB", "quantity": 100, "stockStatus": "in_stock", "visible": true, "archived": false } ], "total": 213, "page": 1, "pageSize": 20 }
  ```

---

### 3.2 Categories

Three surfaces: storefront read (LIVE, legacy + clean stack), and admin CRUD
(the **only** admin domain with a real HTTP impl).

#### 3.2.a Storefront category read — LIVE

| # | Method | Path (LIVE literal) | Backed by | Success |
|---|---|---|---|---|
| C1 | GET | `{base}/category` | `ApiService.getCategories()` (legacy) and `ApiCategoryRepository` (clean stack, `CATEGORY_REPOSITORY`, retry x2) | `200` array of category wire objects |

- Two parallel stacks read the same endpoint (`BACKEND-AUDIT.md` §8). Legacy
  wire shape: `Category`/`Subcategory` (`src/app/models/category.model.ts`) via
  `normalizeCategory()`. Clean wire DTO: `CategoryDto`/`CategoryNameDto`
  (`src/app/core/categories/dto/category.dto.ts`) via `CategoryMapper`. Both
  tolerate `names[]` (multi-lang, incl. the `valuue` typo variant), nested
  `subcategories[]`, `img`↔`icon`, string↔numeric ids.
- **Headers/JWT:** marketplace headers, no JWT, public.
- **Response `CategoryDto` fields:** `categoryID?`, `parentID?`, `name?`,
  `icon?`, `img?`, `priority?`, `visible?`, `categoriesCount?`, `itemCount?`,
  `names?: [{ language, value|valuue }]`, `subcategories?: CategoryDto[]`.
- **Error responses:** `500/503` → falls through to empty categories (clean
  stack retries twice first). No 404 (collection endpoint).
- **Example response:**
  ```json
  [ { "categoryID": 3, "parentID": 0, "name": "Phones", "icon": "https://…/phones.svg", "priority": 10, "visible": true, "itemCount": 213, "names": [ { "language": "RU", "value": "Телефоны" }, { "language": "EN", "value": "Phones" } ], "subcategories": [] } ]
  ```
- **Note (`BACKEND-AUDIT.md` §8):** two different `Category` TS types exist —
  flag for backend/naming reconciliation.

#### 3.2.b Admin categories CRUD — LIVE (real HTTP gateway exists)

Contract: `AdminCategoriesGateway`. **Real impl:** `AdminCategoriesApiGateway`
(literal paths below — the canonical precedent for all other admin domains).
Mock impl: `AdminCategoriesLocalGateway`. Bound via `ADMIN_CATEGORIES_GATEWAY`
token (MOCK-SWAPPABLE — this domain *can* be rebound to the real gateway today).
DTO: `AdminCategory` (`src/app/features/admin/categories/models/admin-category.model.ts`).

| # | Method | Path (LIVE literal) | Gateway method | Success |
|---|---|---|---|---|
| AC1 | GET | `{base}/backoffice/categories?search&visibility&includeDeleted` | `loadCategories(filters)` | `200 AdminCategory[]` |
| AC2 | GET | `{base}/backoffice/categories/{id}` | `loadCategory(id)` | `200 AdminCategory` / `null` on error |
| AC3 | POST | `{base}/backoffice/categories` | `createCategory(cat)` | `201 AdminCategory` |
| AC4 | PUT | `{base}/backoffice/categories/{id}` | `updateCategory(cat)` | `200 AdminCategory` |
| AC5 | DELETE | `{base}/backoffice/categories/{id}` | `deleteCategory(id)` | `204` (**soft** delete) |
| AC6 | POST | `{base}/backoffice/categories/{id}/restore` | `restoreCategory(id)` | `200 AdminCategory` / `null` |
| AC7 | GET | `{base}/backoffice/categories/slug-taken?slug&excludingId` | `isSlugTaken(slug,excludingId)` | `200 { taken: boolean }` |

- **Headers/JWT:** admin auth (§3.0).
- **Permission (Proposed):** AC1/AC2/AC7 → `backoffice.read`; AC3-AC6 →
  `backoffice.write`.
- **AC1 query params** (`AdminCategoryListFilters`): `search` (string),
  `visibility` ∈ `all|visible|hidden`, `includeDeleted` (bool sent as string).
  **No pagination** — returns a plain array. No sort param.
- **AC3 request body:** `AdminCategory` **minus** `id, itemsCount, deletedAt,
  createdAt, updatedAt` (server-owned — stripped by the real gateway, line 38).
  AC4 sends the full object, `id` in path.
- **AC7:** proactive slug-uniqueness pre-check the frontend calls *before*
  submit (`excludingId` omitted on create, set to the editing id on update). This
  is the only proactive conflict check in the whole admin surface. On network
  error it returns `false` (best-effort, TOCTOU-prone — `§6` §409).
- **Validation:** slug uniqueness via AC7; everything else none client-side.
  `title`, `slug` are non-nullable in the model. Recommended `409 CONFLICT` on
  AC3/AC4 slug collision as a race backstop (`§6` §409), and
  `422` for field errors.
- **Error responses:** AC2/AC6 swallow errors → `null` (so a `404` there renders
  as "not found"/empty, not an error screen). AC5 hard `404` if unknown. `409`
  slug conflict. Common set otherwise.
- **Example AC3 request:**
  ```json
  { "parentId": null, "title": "Phones", "slug": "phones", "description": "", "icon": "", "imageUrl": "", "imageAlt": "", "order": 0, "visible": true, "status": "published", "translations": {}, "seo": { "metaTitle": "", "metaDescription": "", "keywords": "" }, "attributes": [] }
  ```
- **Example AC7 response:** `{ "taken": true }`
- **Note:** categories have **no hard delete** — only soft delete + restore
  (gateway doc comment). `deletedAt` timestamps the soft delete.

---

### 3.3 Orders

Two surfaces: **checkout order creation** (LIVE, storefront) and **admin order
management** (MOCK-ONLY, no seam). These are separate models — the storefront
`CreateOrderRequest`/`CreateOrderResponse` (inline in `api.service.ts`) is not the
admin `AdminOrder`.

#### 3.3.a Cart → payment → order creation — LIVE

Cart contents are **LOCAL-ONLY** (`CartService`, localStorage key
`marketplace_cart` + Telegram CloudStorage — no cart backend). Checkout produces
these live calls (`BACKEND-AUDIT.md` §10). Payment DTOs are inline in
`src/app/services/api.service.ts`.

| # | Method | Path | Base | Request → Response |
|---|---|---|---|---|
| O1 | POST | `/cart` | marketplace | `CartPaymentRequest` → `QrCreateResponse` (creates the QR/card payment) |
| O2 | POST | `/qr` | qrApiUrl (headers `authorization-key`, `userid-value`) | `QrCreateRequest` → `QrCreateResponse` |
| O3 | GET | `/qr/dynamic/{partnerId}/{qrId}` | qrApiUrl | → `QrDynamicStatusResponse` (poll) |
| O4 | GET | `/card/{partnerId}/{orderId}` | qrApiUrl | → `QrDynamicStatusResponse` (poll) |
| O5 | POST | `/orders` | marketplace | `CreateOrderRequest` → `CreateOrderResponse` (records paid cart; **fire-and-forget** after payment) |
| O6 | POST | `/purchase-email` | marketplace | email/phone + items → `{ message }` (receipt) |
| O7 | POST | `/websession/{sessionId}` | marketplace | item array → any (server-side cart mirror) |

- `partnerId` const = `web-97ec-9c57-4dde-9037-3a68f7f83750`.
- **Headers/JWT:** marketplace headers (O1/O5/O6/O7); O2-O4 use the payment API
  with `authorization-key`/`userid-value`. No JWT; customer session via
  `WebSessionID`.
- **`CartPaymentRequest` (O1):** `{ amount, currency:'RUB', siteuserID,
  siteorderID, redirectUrl, telegramUsername, paymentMethod:'qr'|'card',
  qrDescription?, customerID?, items:[{ itemID, price, name, quantity?,
  delivery? }] }`.
- **`CreateOrderRequest` (O5):** `{ items:[{ productId, name, quantity, price }],
  customer:{ name, email, phone }, payment?:{ method, currency }, shipping?:{
  address, method, trackingNumber } }`.
- **`CreateOrderResponse` (O5):** `{ id, orderNumber, status, total, currency }`.
- **`QrCreateResponse`** is a very tolerant union (`api.service.ts` 21-41) —
  resolvers pick the first present of `qrId/qrID/nspkID/…`. A backend can emit
  any of the documented aliases.
- **Error responses:** O5 is fire-and-forget — a failure must **never** block the
  payment-confirmed flow (source comment, line 635). `401` on a customer-facing
  `/cart`/`/orders` has **no unified re-auth UX** today (`§6` §401,
  Requires backend decision). Payment status polling handles its own timeouts.
- **Validation:** none client-side. Amounts/quantities ≥ 0, valid email/phone =
  Requires backend decision.
- **Example O5 request:**
  ```json
  { "items": [ { "productId": "1024", "name": "Phone X", "quantity": 1, "price": 62560 } ], "customer": { "name": "Ivan", "email": "ivan@example.com", "phone": "+79990000000" }, "payment": { "method": "qr", "currency": "RUB" } }
  ```
- **Example O5 response:** `{ "id": "ord_88", "orderNumber": "R-000088", "status": "pending", "total": 62560, "currency": "RUB" }`

#### 3.3.b Admin orders management — PROPOSED (MOCK-ONLY, no seam)

Contract: `AdminOrdersGateway`. Impl: `AdminOrdersLocalGateway` (injected
concretely by `AdminOrdersFacade`, and reused by `AdminCustomersFacade` and
`AdminAnalyticsFacade`). DTO: `AdminOrder`
(`src/app/features/admin/orders/models/admin-order.model.ts`).

| # | Method | Path (PROPOSED) | Gateway method | Success |
|---|---|---|---|---|
| AO1 | GET | `{base}/backoffice/orders?search&status&page&pageSize` | `loadOrders(filters)` | `200 AdminOrdersListResult` |
| AO2 | GET | `{base}/backoffice/orders/{id}` | `loadOrder(id)` | `200 AdminOrder` / `404` |
| AO3 | PATCH | `{base}/backoffice/orders/{id}/status` | `updateStatus(id,status)` | `200 AdminOrder` |
| AO4 | POST | `{base}/backoffice/orders/{id}/refund` | `requestRefund(id)` | `200 AdminOrder` |
| AO5 | POST | `{base}/backoffice/orders/{id}/notes` | `addNote(id,note,internal)` | `200 AdminOrder` |
| AO6 | POST | `{base}/backoffice/orders/{id}/archive` | `archiveOrder(id)` | `200 AdminOrder` |
| AO7 | POST | `{base}/backoffice/orders/{id}/restore` | `restoreOrder(id)` | `200 AdminOrder` |
| AO8 | DELETE | `{base}/backoffice/orders/{id}` | `deleteOrder(id)` | `204` |

- **Headers/JWT:** admin auth (§3.0).
- **Permission (Proposed):** AO1/AO2 → `backoffice.read`; AO3-AO8 →
  `backoffice.write`.
- **AO1 query params** (`AdminOrderListFilters`): `search`, `status` ∈
  `all|pending|processing|shipped|delivered|cancelled|refunded`, `page`,
  `pageSize`. Response `AdminOrdersListResult { items, total, page, pageSize }`.
  No sort param.
- **AO3 body:** `{ status: AdminOrderStatus }` where status ∈ the 6-value union
  above. **State-transition rules** (which transitions are legal, e.g.
  `delivered → pending`?) are **Requires backend decision** — the frontend
  enforces none. `payment.status` (`unpaid|paid|refund_requested|refunded`) is a
  separate axis; AO4 (`requestRefund`) is what moves it to `refund_requested`.
- **AO5 body:** `{ note: string, internal: boolean }` — `internal:true` writes
  `internalNotes`, else `notes` (both plain strings on `AdminOrder`). Appends a
  timeline entry.
- **`AdminOrder` timeline:** `timeline: [{ status, timestamp, eventKey:
  'created'|'statusChanged'|'refundRequested' }]` — server should append on each
  mutation.
- **Validation:** none client-side. Legal status transitions, refund
  eligibility = Requires backend decision.
- **Error responses:** `404` (unknown id), `409` (illegal transition — proposed),
  common set.
- **Example AO3 request:** `PATCH …/orders/ord_88/status` body `{ "status": "shipped" }`
- **Example AO2 response (trimmed):**
  ```json
  { "id": "ord_88", "orderNumber": "R-000088", "status": "shipped", "customer": { "name": "Ivan", "email": "ivan@example.com", "phone": "+79990000000" }, "payment": { "method": "qr", "status": "paid", "amount": 62560, "currency": "RUB" }, "shipping": { "address": "…", "method": "courier", "trackingNumber": "TRK1" }, "items": [ { "productId": "1024", "name": "Phone X", "quantity": 1, "price": 62560 } ], "total": 62560, "currency": "RUB", "notes": "", "internalNotes": "", "timeline": [ { "status": "pending", "timestamp": "2026-07-20T10:00:00Z", "eventKey": "created" } ], "archived": false, "createdAt": "2026-07-20T10:00:00Z", "updatedAt": "2026-07-21T09:00:00Z" }
  ```

---

### 3.4 Customers — PROPOSED (derived, no gateway of its own)

Per `BACKEND-AUDIT.md` §14 and the model comment
(`src/app/features/admin/customers/models/admin-customer.model.ts`): a customer is
**not a stored entity** — `AdminCustomersFacade` derives customers by grouping
`AdminOrder` records by `customer.email` (it injects `AdminOrdersLocalGateway`).
Every `AdminCustomer` field is a real aggregate over that customer's orders, never
fabricated.

**Frontend implication:** there is **no customer CRUD in the frontend at all** —
no create/update/delete, only a derived list/detail. A backend can satisfy this
either by (a) exposing a real customers endpoint returning the aggregate shape, or
(b) letting the frontend keep deriving from orders. This is **Requires backend
decision** (whether customers become a first-class backend resource).

| # | Method | Path (PROPOSED) | Purpose | Success |
|---|---|---|---|---|
| CU1 | GET | `{base}/backoffice/customers` | List (derived aggregate) | `200 AdminCustomer[]` |
| CU2 | GET | `{base}/backoffice/customers/{email}` | Detail (aggregate + orders) | `200 AdminCustomer` / `404` |

- **Key = `email`** (customers are keyed by email, not an id).
- **`AdminCustomer`:** `{ email, name, phone, orderCount, totalSpent, currency,
  firstOrderAt, lastOrderAt, addresses[], orders: AdminOrder[] }`.
- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`.
- **Pagination/sort/filter:** none in the model (plain array). Derivation is
  entirely client-side today.
- **Validation:** n/a (read-only, derived).
- **Error responses:** common read set; `404` if a real customers endpoint is
  built and the email is unknown.
- **Example CU1 response (trimmed):**
  ```json
  [ { "email": "ivan@example.com", "name": "Ivan", "phone": "+79990000000", "orderCount": 3, "totalSpent": 187680, "currency": "RUB", "firstOrderAt": "2026-01-10T…", "lastOrderAt": "2026-07-20T…", "addresses": ["…"], "orders": [] } ]
  ```

---

### 3.5 Users — PROPOSED (MOCK-ONLY, no seam)

Contract: `AdminUsersGateway`
(`src/app/features/admin/users/services/admin-users-gateway.interface.ts`). Impl:
`AdminUsersLocalGateway` (injected concretely by `AdminUsersFacade`). DTOs in
`admin-user.model.ts`: `AdminUser`, `AdminRole`, `AdminInvitation`, `AdminSession`,
`AdminUserAuditEntry`.

| # | Method | Path (PROPOSED) | Gateway method | Success |
|---|---|---|---|---|
| U1 | GET | `{base}/backoffice/users` | `loadUsers()` | `200 AdminUser[]` |
| U2 | GET | `{base}/backoffice/roles` | `loadRoles()` | `200 AdminRole[]` |
| U3 | GET | `{base}/backoffice/invitations` | `loadInvitations()` | `200 AdminInvitation[]` |
| U4 | GET | `{base}/backoffice/users/{userId}/sessions` | `loadSessions(userId)` | `200 AdminSession[]` |
| U5 | GET | `{base}/backoffice/users/{userId}/audit` | `loadAudit(userId)` | `200 AdminUserAuditEntry[]` |
| U6 | PATCH | `{base}/backoffice/users/{userId}/role` | `setUserRole(userId,roleId)` | `200 AdminUser` |
| U7 | PATCH | `{base}/backoffice/users/{userId}/status` | `setUserStatus(userId,status)` | `200 AdminUser` |
| U8 | POST | `{base}/backoffice/invitations` | `inviteUser(email,roleId,scope)` | `201 AdminInvitation` |
| U9 | DELETE | `{base}/backoffice/invitations/{id}` | `revokeInvitation(id)` | `204` |
| U10 | DELETE | `{base}/backoffice/sessions/{sessionId}` | `revokeSession(sessionId)` | `204` |

- **Headers/JWT:** admin auth (§3.0).
- **Permission (Proposed):** all user/role/invitation/session management →
  `users.manage` (the one permission specifically about this domain — see
  `§4` §9.1). Read-only U1-U5 could be `backoffice.read` — Requires
  backend decision.
- **U6 body:** `{ roleId: string }`. **U7 body:** `{ status: AdminUserStatus }`
  where status ∈ `active|invited|suspended`. **U8 body:** `{ email, roleId,
  scope }` where scope ∈ `marketplace|office`.
- **`AdminRole`** here is `{ id, name, permissions: string[], builtIn: boolean }`
  — **a different type from the auth `AdminRole` string-union** used by the JWT
  `role` claim (`Owner|Administrator|Editor|Support|ReadOnly`,
  `src/app/core/auth/models/permission.model.ts`). **This is the flagged
  duplicate** (`BACKEND-AUDIT.md` §14, `§4` §9). Reconciliation
  needed: treat the auth string-union as the JWT/role-claim contract; treat this
  interface as an admin-users-management row. Recommend renaming the latter (e.g.
  `AdminUserRoleRecord`). **Requires backend decision / naming reconciliation.**
- **Roles CRUD:** the gateway exposes only `loadRoles()` (read). There is **no
  create/update/delete role** endpoint in the frontend — role definitions are
  read-only from the frontend's perspective. Whether roles are backend-editable =
  Requires backend decision.
- **Sessions/audit:** `revokeSession` (U10) exists as a mock method; whether an
  Owner can remotely kill another admin's live session, and how that propagates to
  the already-logged-in client, is **Requires backend decision**
  (`§4` §8 — no push/poll mechanism exists client-side).
- **Validation:** none client-side. Email format, role existence, self-demotion
  guards = Requires backend decision.
- **Error responses:** `404` (unknown user/session/invitation), `409` (e.g.
  inviting an already-registered email — proposed), `422` (bad email/role),
  common set.
- **Example U8 request:** `{ "email": "new.admin@example.com", "roleId": "role_editor", "scope": "office" }`
- **Example U1 response (trimmed):**
  ```json
  [ { "id": "u_1", "name": "Alice", "telegramUsername": "@alice", "email": "alice@example.com", "scope": "office", "roleId": "role_owner", "status": "active", "lastLoginAt": "2026-07-24T…", "createdAt": "2026-01-01T…" } ]
  ```

---

### 3.6 Roles

Covered structurally under Users (§3.5, endpoint U2 `loadRoles`). Summary of the
reconciliation the backend must make (this is the audit's flagged item):

- **`AdminRole` #1** — string union `'Owner'|'Administrator'|'Editor'|'Support'
  |'ReadOnly'` (`src/app/core/auth/models/permission.model.ts`). Used by the JWT
  `role` claim and `PermissionService`. Ordered high→low by convention only. Maps
  to permissions via `ROLE_PERMISSIONS` (`§4` §9.1). **This is the
  role contract for authorization.**
- **`AdminRole` #2** — interface `{ id, name, permissions: string[], builtIn }`
  (`src/app/features/admin/users/models/admin-user.model.ts`). A management row for
  the users screen. `AdminUser.roleId` references `AdminRole.id` here (a
  string id like `role_owner`), **not** the string-union value.
- **The two do not reference each other.** A backend role table must decide
  whether `AdminRole.id` (#2) resolves to one of the five #1 union values (i.e.
  `builtIn` roles map onto the permission model) and whether custom
  (`builtIn:false`) roles with arbitrary `permissions[]` are allowed. **Requires
  backend decision** — the frontend does not resolve this collision.
- **No role CRUD endpoint** exists in the frontend beyond read (`loadRoles`).
  Custom-role create/edit/delete = Requires backend decision.

---

### 3.7 Media — PROPOSED (MOCK-SWAPPABLE via abstract-class token)

Contract: abstract class `MediaRepository` (`src/app/core/media/media-repository.ts`).
Bound in `app.config.ts` → `MockMediaRepository` (`useClass`). MOCK-SWAPPABLE (a
real impl drops in via the same class token — no new seam needed). DTO:
`MediaAsset` (`src/app/core/media/models/media-asset.model.ts`).

**Scope note (per task):** this section covers the **CRUD list/delete/metadata**
endpoints only. Upload *mechanics* (multipart, presigned URLs, chunking) are
deferred to a sibling "Uploads" section of the master doc — here `upload` is
listed for completeness but its transport is out of scope.

| # | Method | Path (PROPOSED) | Repository method | Success |
|---|---|---|---|---|
| M1 | GET | `{base}/media?page&pageSize&search&folder&tag&kind&sort` | `list(params)` | `200 MediaListResult { items, total }` |
| M2 | POST | `{base}/media` (multipart — see Uploads section) | `upload(file,options)` | `201 MediaAsset` |
| M3 | DELETE | `{base}/media/{id}` | `remove(id)` | `204` |
| M4 | PATCH | `{base}/media/{id}` | `update(id,patch)` | `200 MediaAsset` |
| M5 | GET | `{base}/media/folders` | `listFolders()` | `200 string[]` |

- **Headers/JWT:** the `adminAuthHeadersInterceptor` already gates `/media/`
  paths (§3.0, anticipatory) — admin auth applies. Permission (Proposed):
  `backoffice.read` (M1/M5), `backoffice.write` (M2/M3/M4).
- **M1 query params** (`MediaListParams`): `page?`, `pageSize?`, `search?`,
  `folder?`, `tag?`, `kind?` ∈ `image|svg|pdf|other`, `sort?` ∈
  `recent|name|size`. Response `{ items: MediaAsset[], total }` (offset paginated).
- **M4 patch body** — restricted to metadata: `Partial<Pick<MediaAsset, 'altText'
  | 'tags' | 'folder' | 'caption' | 'description' | 'decorative'>>`. The
  repository signature **forbids** patching `url/filename/mimeType/size/width/
  height` — a backend should reject those on PATCH.
- **`MediaAsset`:** `{ id, url, thumbnailUrl?, filename, mimeType, size, width?,
  height?, altText?: Record<locale,string>, caption?, description?, decorative?,
  tags?, folder?, createdAt }`.
- **Validation:** none client-side. Allowed mime types / max size = Requires
  backend decision (also relevant to the Uploads section).
- **Error responses:** `404` (M3/M4 unknown id), `422` (invalid patch field),
  common set. `MediaUsageService` exists client-side to warn on in-use assets —
  whether delete is blocked when an asset is referenced is Requires backend
  decision.
- **Example M4 request:** `PATCH …/media/asset_9` body `{ "altText": { "en": "Product hero", "ru": "…" }, "tags": ["hero"], "decorative": false }`
- **Example M1 response (trimmed):**
  ```json
  { "items": [ { "id": "asset_9", "url": "https://…/hero.webp", "filename": "hero.webp", "mimeType": "image/webp", "size": 48213, "width": 1200, "height": 630, "folder": "products", "createdAt": "2026-07-01T…" } ], "total": 342 }
  ```

---

### 3.8 CMS — Static pages — PROPOSED / FUTURE (LOCAL-ONLY today)

**Status: LOCAL-ONLY.** `ContentManagementFacade`
(`src/app/features/content-management/facade/content-management.facade.ts`) →
`ContentPageService` operates entirely on the **already-loaded
`BootstrapConfig.staticPages`** — there are **no dedicated CMS backend calls**
(`BACKEND-AUDIT.md` §16). Editing writes to in-memory bootstrap + localStorage
drafts; "publishing" = writing bootstrap back, for which **no client HTTP call
exists**. So every endpoint below is **PROPOSED / FUTURE**.

Backend-shaped model: `StaticPageConfig` / `StaticPagesConfig` /
`ResolvedStaticPage` (`src/app/shared/models/config/static-page.model.ts`). Editor
view model: `ContentPage` (`src/app/features/content-management/models/content-page.model.ts`);
`ContentPageService` is the adapter (`toBootstrapRecord`/`serializePages`).

| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|---|---|---|---|---|
| CM1 | GET | `{base}/builder/static-pages` | List pages | `200 StaticPagesConfig` |
| CM2 | GET | `{base}/builder/static-pages/{id}` | Single page | `200 StaticPageConfig` / `404` |
| CM3 | POST | `{base}/builder/static-pages` | Create page | `201 StaticPageConfig` |
| CM4 | PUT | `{base}/builder/static-pages/{id}` | Update page | `200 StaticPageConfig` |
| CM5 | DELETE | `{base}/builder/static-pages/{id}` | Delete page | `204` |

- **Reality check:** today the frontend gets these pages **inside `GET /bootstrap`**
  and never calls CM1-CM5. If the backend keeps CMS inside bootstrap, these
  endpoints may never exist — the alternative is a builder-publish endpoint that
  writes the whole bootstrap (see §3.15). **Requires backend decision:**
  per-page CMS endpoints vs. whole-bootstrap publish.
- **Headers/JWT:** admin/builder auth (§3.0). Permission (Proposed):
  `builder.read` (CM1/CM2), `builder.write` (CM3-CM5).
- **`StaticPageConfig` key fields:** `{ id, slug, title: string|Localized,
  showInFooter?, showInHeader?, showInSitemap?, icon?, order?, requiresAuthentication?,
  translations?: Record<locale, { title?, html?, seo? }>, html?: string|Localized,
  seo?: StaticPageSeoConfig, visible?, enabled?, status?: 'draft'|'published',
  heroImage?, gallery?, updatedAt? }`. Two lifecycle switches: `enabled` (master
  on/off) and per-page `status` (`draft` never resolves on storefront even if the
  bootstrap is published).
- **Validation:** the client-side `ContentPageService.validatePages()` +
  `ProjectValidator` convention exists (`ProjectValidationIssue { code, message,
  section, fieldKey, severity }`) and is the shape `§6` §422
  recommends aligning backend `details[]` to. Actual required fields (slug
  uniqueness, non-empty title) = Requires backend decision.
- **Error responses:** `404` (CM2/CM4/CM5), `409` (duplicate slug — proposed),
  `422` (validation), common set. None handled specially by the UI today.
- **Example CM4 request (trimmed):**
  ```json
  { "id": "about", "slug": "about-us", "title": { "en": "About us", "ru": "О нас" }, "showInFooter": true, "status": "published", "enabled": true, "html": { "en": "<h1>About</h1>" }, "seo": { "title": { "en": "About us" }, "robots": "index,follow" } }
  ```

---

### 3.9 Homepage — PROPOSED / FUTURE (LOCAL-ONLY)

**Status: LOCAL-ONLY** (project-editor, `BACKEND-AUDIT.md` §17). The homepage is a
**composition of `SectionConfig` → `WidgetConfig`** stored inside
`BootstrapConfig` (`layout`/`pages`). Edited via `ProjectEditorFacade` +
`homepage-section.component.ts` / `homepage-overview.component.ts` against
in-memory bootstrap; persisted only to localStorage drafts. No HTTP save/publish
call exists.

Models: `SectionConfig` (`src/app/shared/models/config/section.model.ts`) —
`{ id, type, order, layout?: { strategy: 'stack'|'grid'|'hero'|'carousel'|'split',
columns?, gap?, align? }, visibility?, widgets: WidgetConfig[], visible? }`.

| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|---|---|---|---|---|
| HP1 | GET | `{base}/builder/homepage` | Load section/widget composition | `200 SectionConfig[]` |
| HP2 | PUT | `{base}/builder/homepage` | Replace composition (reorder, add/remove sections & widgets) | `200 SectionConfig[]` |

- **Why a single PUT, not granular CRUD:** the editor mutates an in-memory tree
  and saves it whole (undo/redo over `History<BootstrapConfig>`); there is no
  per-section/per-widget network operation in the frontend. A whole-composition
  PUT matches how the client actually works. Granular section/widget endpoints
  would be new backend design. **Requires backend decision.**
- **Headers/JWT:** builder auth (§3.0). Permission (Proposed): `builder.read`
  (HP1), `builder.write` (HP2).
- **Section ordering** is by the numeric `order` field; widgets carry their own
  `order` within a section.
- **Validation:** `ProjectValidator` (client-side, localStorage-scoped). Section
  `type` allow-list, widget `type` must exist in the widget manifest (§3.10) =
  Requires backend decision.
- **Error responses:** `422` (invalid section/widget type or malformed tree),
  common set.
- **Example HP2 request (trimmed):**
  ```json
  [ { "id": "hero", "type": "hero", "order": 0, "layout": { "strategy": "hero" }, "visible": true, "widgets": [ { "id": "w1", "type": "hero-banner", "version": "1.0.0", "order": 0, "props": { "title": "Sale" } } ] } ]
  ```

---

### 3.10 Widgets — PROPOSED / FUTURE (LOCAL-ONLY) + widget manifest (LIVE)

Two things named "widgets": the **manifest** (LIVE) and the **widget instances**
inside sections (LOCAL-ONLY, edited via project-editor `widgets-section.component.ts`).

#### 3.10.a Widget manifest — LIVE

`WidgetManifestService` (`src/app/widgets/registry/widget-manifest.service.ts`):

| # | Method | Path (LIVE) | Success |
|---|---|---|---|
| W1 | GET | `bootstrap.widgetRegistry.manifestUrl` (fallback `/assets/mock/bootstrap/widget-manifest.json`) | `200 WidgetManifestFile` |

- Read-only registry of available widget types + their settings schema
  (`WidgetManifestEntry/File`, `WidgetSettingsSchema` — `src/app/widgets/contracts/widget-manifest.contract.ts`).
- **Headers/JWT:** the manifest URL is bootstrap-driven; public read.
- **Validation/errors:** on failure falls back to the static asset. No mutation.

#### 3.10.b Widget instances CRUD — PROPOSED / FUTURE

Widget instances (`WidgetConfig`, `src/app/shared/models/config/widget.model.ts`)
live inside `SectionConfig.widgets[]` and are created/updated/removed/reordered
purely in-memory via `ProjectEditorFacade` (localStorage drafts). There is **no
per-widget backend call** — they are saved as part of the homepage composition
(§3.9 HP2). So there is **no separate widget CRUD endpoint** unless the backend
chooses granular editing (Requires backend decision). If it does:

| # | Method | Path (PROPOSED / FUTURE) | Purpose |
|---|---|---|---|
| W2 | POST | `{base}/builder/sections/{sectionId}/widgets` | Add widget |
| W3 | PUT | `{base}/builder/sections/{sectionId}/widgets/{widgetId}` | Update widget |
| W4 | DELETE | `{base}/builder/sections/{sectionId}/widgets/{widgetId}` | Remove widget |

- **`WidgetConfig`:** `{ id, type, version, title?, subtitle?, order?, padding?,
  visibility?: { desktop?, tablet?, mobile? }, animation?, style?: Record<string,
  string>, permissions?: { requireAuthenticated?, roles?, permissions? }, props:
  Record<string, unknown>, actions?, featureFlag?, visible? }`.
- **Permission (Proposed):** `builder.write`. **Validation:** widget `type` must
  exist in the manifest (W1); `props` shape validated against
  `WidgetSettingsSchema`. Both client-side/manifest-driven; backend enforcement =
  Requires backend decision.
- **Recommendation:** treat §3.9 HP2 (whole-composition PUT) as canonical and
  W2-W4 as optional granular sugar the frontend does not need today.

---

### 3.11 Navigation — PROPOSED / FUTURE (LOCAL-ONLY via builder)

**Status: LOCAL-ONLY.** Header/footer nav is `BootstrapConfig.navigation`
(`src/app/shared/models/config/navigation.model.ts`), edited via
`ProjectEditorFacade` + `navigation-section.component.ts`. Facade methods
(all in-memory + localStorage; `project-editor.facade.ts`): `addNavLink`,
`addStaticPageNavLink`, `removeNavLink`, `updateNavLink`, `updateNavLinkLabel`,
`reorderNavLink` — all take `target: 'header' | 'footer'`. No HTTP call exists.

Model: `NavigationConfig { header: NavigationItemConfig[], footer:
NavigationItemConfig[] | FooterNavigationGroupConfig[], sidebar? }`.
`NavigationItemConfig { id, labelKey?, label?: string|Localized, route?, type?,
key?, icon?, order?, visible?, visibleWhenFlags?, children? }`.

| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|---|---|---|---|---|
| N1 | GET | `{base}/builder/navigation` | Load nav config | `200 NavigationConfig` |
| N2 | PUT | `{base}/builder/navigation` | Replace nav config (add/remove/update/reorder items) | `200 NavigationConfig` |

- **Whole-config PUT** matches the editor (it mutates the nav tree in memory and
  saves whole). Granular per-item endpoints = Requires backend decision.
- **Headers/JWT:** builder auth. Permission (Proposed): `builder.read` (N1),
  `builder.write` (N2).
- **Ordering:** `reorderNavLink(target, id, direction: -1|1)` moves an item; the
  numeric `order` field is authoritative on the wire.
- **Localized labels:** `updateNavLinkLabel(target, id, value, locale?)` writes
  into `label` as a `{ [locale]: string }` map.
- **Validation:** none client-side beyond the builder validator. Route validity,
  no orphan `children` = Requires backend decision.
- **Errors:** `422` (malformed tree), common set.
- **Example N2 request (trimmed):**
  ```json
  { "header": [ { "id": "n1", "label": { "en": "Catalog", "ru": "Каталог" }, "route": "/catalog", "order": 0, "visible": true } ], "footer": [] }
  ```

---

### 3.12 Footer — PROPOSED / FUTURE (LOCAL-ONLY via builder)

**Status: LOCAL-ONLY.** `BootstrapConfig.footer` (`FooterConfig`,
`src/app/shared/models/config/footer-config.model.ts`), edited via
`footer-section.component.ts` + `ProjectEditorFacade`. No HTTP call.

`FooterConfig { logoUrl?, paymentIcons?: [{ src, alt, width?, height? }],
copyrightText?: string|Localized, columns?: [{ id, title, links: [{ id, label,
pageKey?, url? }] }], socialLinks?: [{ id, label, url, icon? }], legalPageKeys?
(deprecated), staticPageKeys? (deprecated) }`.

| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|---|---|---|---|---|
| F1 | GET | `{base}/builder/footer` | Load footer config | `200 FooterConfig` |
| F2 | PUT | `{base}/builder/footer` | Replace footer (payment icons, columns/legal links, copyright, socials) | `200 FooterConfig` |

- **Headers/JWT:** builder auth. Permission (Proposed): `builder.read` (F1),
  `builder.write` (F2).
- **Link resolution:** `FooterLinkConfig.pageKey` (preferred, references a CMS
  page id — stays correct if the page route changes) vs. `url` (raw external).
  `legalPageKeys`/`staticPageKeys` are **deprecated**, superseded by `columns` —
  a backend should accept them for back-compat but write `columns`.
- **Validation:** none client-side. Payment-icon URL validity, non-empty column
  titles = Requires backend decision.
- **Errors:** `422`, common set.
- **Example F2 request (trimmed):**
  ```json
  { "logoUrl": "https://…/logo.svg", "paymentIcons": [ { "src": "https://…/visa.svg", "alt": "Visa", "width": 40, "height": 24 } ], "copyrightText": { "en": "© 2026 Acme" }, "columns": [ { "id": "legal", "title": "Legal", "links": [ { "id": "l1", "label": "Terms", "pageKey": "terms" } ] } ], "socialLinks": [] }
  ```

---

### 3.13 Branding / Theme — PROPOSED / FUTURE (LOCAL-ONLY via builder)

**Status: LOCAL-ONLY.** `BootstrapConfig.branding` (`BrandingConfig`,
`src/app/shared/models/config/branding.model.ts`) and `BootstrapConfig.theme`
(`theme.model.ts`), edited via `branding-section.component.ts` /
`theme-section.component.ts` / `brand-overview.component.ts`. No HTTP call.

`BrandingConfig { brandName, legalName, slogan?, logoUrl, logoCompactUrl?,
faviconUrl, appIconUrl?, socialImageUrl?, galleryUrls?, supportEmail?,
supportPhone? }`.

| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|---|---|---|---|---|
| B1 | GET | `{base}/builder/branding` | Load branding + theme | `200 { branding: BrandingConfig, theme: ThemeConfig }` |
| B2 | PUT | `{base}/builder/branding` | Update branding | `200 BrandingConfig` |
| B3 | PUT | `{base}/builder/theme` | Update theme (colors, tokens) | `200 ThemeConfig` |

- **Headers/JWT:** builder auth. Permission (Proposed): `builder.read` (B1),
  `builder.write` (B2/B3). Could be `settings.manage` if branding is treated as a
  tenant-settings concern — Requires backend decision.
- **Theme model** (`src/app/shared/models/config/theme.model.ts`, not re-listed
  here) carries color tokens; the editor computes contrast client-side
  (`sections/brand/contrast.util.ts`). Contrast/accessibility is enforced
  client-side only.
- **Validation:** none backend-driven. `brandName`, `logoUrl`, `faviconUrl`
  non-optional in the model; valid email for `supportEmail` = Requires backend
  decision.
- **Errors:** `422`, common set.
- **Example B2 request (trimmed):**
  ```json
  { "brandName": "Acme", "legalName": "Acme LLC", "logoUrl": "https://…/logo.svg", "faviconUrl": "https://…/favicon.ico", "supportEmail": "help@acme.com" }
  ```

---

### 3.14 Languages — PROPOSED / FUTURE (LOCAL-ONLY via builder)

**Status: LOCAL-ONLY.** Locale management is `BootstrapConfig.localization` +
`BootstrapConfig.tenant.supportedLocales/defaultLocale`, edited via
`languages-section.component.ts` + `ProjectEditorFacade` methods `addLocale`,
`removeLocale`, `setDefaultLocale` — all mutate in-memory bootstrap only. No HTTP.

`LocalizationConfig { defaultLocale, supportedLocales: string[], currencyByLocale:
Record<locale,currency>, dictionaries: [{ locale, dictionaryUrl, version }] }`.

| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|---|---|---|---|---|
| L1 | GET | `{base}/builder/languages` | Load locale config | `200 LocalizationConfig` |
| L2 | POST | `{base}/builder/languages` | Add a supported locale | `201 LocalizationConfig` |
| L3 | DELETE | `{base}/builder/languages/{code}` | Remove a locale | `200 LocalizationConfig` |
| L4 | PUT | `{base}/builder/languages/default` | Set default locale | `200 LocalizationConfig` |

- **Headers/JWT:** builder auth. Permission (Proposed): `builder.write` for
  L2-L4, `builder.read` for L1. Could be `settings.manage` — Requires backend
  decision.
- **Client-side rules that a backend should mirror:** locale code is lowercased
  and trimmed on add; adding an already-present locale is rejected client-side
  with `builder.languageAlreadyAdded` (so **`409 CONFLICT`** on L2 duplicate is
  the right backstop). L2 body: `{ code: string }` (a locale like `en`, `ru`,
  `hy`). L4 body: `{ code: string }` — must be an already-supported locale.
- **Removing a locale** shows a confirm dialog (destructive: strips that locale's
  content). Whether removing the *default* locale is allowed = Requires backend
  decision (the UI lets you set a new default first).
- **Validation:** non-empty lowercased code; membership check for default. Valid
  BCP-47 code = Requires backend decision.
- **Errors:** `409` (duplicate add), `422` (invalid code / removing last locale),
  common set.
- **Example L2 request:** `{ "code": "hy" }`

---

### 3.15 Marketplace Settings — PROPOSED / FUTURE

There is **no dedicated "settings" admin gateway or page** beyond the builder
sections already covered (§3.8-3.14) and `BootstrapConfig`'s remaining sub-configs
(`featureFlags`, `features`, `seo`, `catalog`, `header`, `layout`,
`productPage`, `userExperience`, `company`, `apiEndpoints`, `permissions`). All of
these are **served inside `GET /bootstrap`** and edited (where editable) via the
project-editor in-memory (LOCAL-ONLY). The only permission that names settings is
`settings.manage` (Owner-only, `§4` §9.1).

The unifying gap: **there is no client-side "publish/save bootstrap" HTTP call
anywhere** (`BACKEND-AUDIT.md` §17, §25.5). Everything in §3.8-3.15 is persisted
only to localStorage drafts + in-memory bootstrap. A real backend needs **one of**:

| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|---|---|---|---|---|
| S1 | GET | `{base}/bootstrap` | Load full config (**LIVE** — this one exists) | `200 BootstrapConfig` |
| S2 | PUT | `{base}/builder/bootstrap` (or `/builder/publish`) | Persist the edited bootstrap (publish) | `200 BootstrapConfig` |
| S3 | GET | `{base}/builder/settings` | General tenant settings (feature flags, catalog, SEO) | `200` (subset of BootstrapConfig) |
| S4 | PUT | `{base}/builder/settings` | Update general settings | `200` |

- **S1 is the only LIVE endpoint here** (`ApiBootstrapProvider`, `GET /bootstrap`).
- **S2 is the single most important FUTURE decision:** a whole-bootstrap publish
  endpoint vs. the per-section builder endpoints proposed in §3.8-3.14. The
  frontend has an `apiEndpoints.builder` record slot for exactly this but no code
  that calls it. **Requires backend decision:** whole-document publish vs.
  granular section writes (and optimistic-concurrency / versioning via
  `BootstrapConfig.schemaVersion`/`generatedAt`).
- **Headers/JWT:** builder/settings auth. Permission (Proposed): `settings.manage`
  (S2/S4), `builder.read`/`settings` read for S1/S3.
- **Validation:** `ProjectValidator` runs client-side before a (currently local)
  publish. Backend-side schema validation of the whole `BootstrapConfig` =
  Requires backend decision.
- **Errors:** `422` (invalid config), `409` (concurrent edit — proposed, if
  versioning adopted), common set.

---

### 3.16 Transactions — PROPOSED (MOCK-ONLY, no seam)

Contract: `AdminTransactionsGateway`
(`src/app/features/admin/transactions/services/admin-transactions-gateway.interface.ts`).
Impl: `AdminTransactionsLocalGateway` (injected concretely by
`AdminTransactionsFacade`). DTO: `AdminTransaction`
(`src/app/features/admin/transactions/models/admin-transaction.model.ts`).

| # | Method | Path (PROPOSED) | Gateway method | Success |
|---|---|---|---|---|
| T1 | GET | `{base}/backoffice/transactions?search&status&type&page&pageSize` | `loadTransactions(filters)` | `200 AdminTransactionsListResult` |
| T2 | POST | `{base}/backoffice/transactions/{id}/retry` | `retryFailed(id)` | `200 AdminTransaction` |
| T3 | PATCH | `{base}/backoffice/transactions/{id}/fraud-flag` | `setFraudFlag(id,flagged)` | `200 AdminTransaction` |

- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`
  (T1), `backoffice.write` (T2/T3).
- **T1 query params** (`AdminTransactionListFilters`): `search`, `status` ∈
  `all|pending|success|failed|retried`, `type` ∈ `all|payment|refund|qr_payment`,
  `page`, `pageSize`. Response `AdminTransactionsListResult { items, total, page,
  pageSize }`. No sort param.
- **T2:** only meaningful for `status:'failed'` transactions (method name
  `retryFailed`); a backend should reject retry on non-failed (proposed `409`).
- **T3 body:** `{ flagged: boolean }`.
- **`AdminTransaction`:** `{ id, orderId, orderNumber, type, method, status,
  amount, currency, fraudFlag, audit: [{ action, actor, timestamp }], createdAt,
  updatedAt }`. Transactions link to orders via `orderId` — how they relate to the
  live payment records (§3.3.a QR/card) is **Requires backend decision** (the
  live payment API and the admin transactions model are currently unconnected).
- **Validation:** none client-side.
- **Errors:** `404` (unknown id), `409` (retry on non-failed — proposed), common
  set.
- **Example T3 request:** `PATCH …/transactions/tx_5/fraud-flag` body `{ "flagged": true }`

---

### 3.17 Reviews — storefront submission (LIVE) + admin moderation (MOCK-ONLY)

#### 3.17.a Storefront review/question submission — LIVE

Via `ApiService` (`ProductDataProvider.submitReview/submitQuestion`). Reviews and
questions are **read** by deriving them from the `GET /items/{id}` payload — there
are **no dedicated list endpoints** (`BACKEND-AUDIT.md` §11). Writes:

| # | Method | Path (LIVE literal) | Request | Success |
|---|---|---|---|---|
| R1 | POST | `{base}/items/{itemID}/callback` | `{ rating, comment, sessionID, timestamp }` | `200 { message }` |
| R2 | POST | `{base}/items/{itemID}/questiion` | `{ question, sessionID, timestamp }` | `200 { message }` |

- **Note the literal typo `questiion`** (R2) — matches the backend spec, must be
  preserved (`api.service.ts` line 617).
- **Headers/JWT:** marketplace headers; customer `WebSessionID` identifies the
  reviewer. No JWT.
- **Models:** `SubmitReviewInput`/`SubmitQuestionInput`
  (`src/app/core/products/models/product-engagement.model.ts`). `rating` is
  1-5 stars (`RatingStars`).
- **Validation:** none client-side. Rating range 1-5, non-empty comment, one
  review per customer/product = Requires backend decision.
- **Errors:** `404` (unknown item), `422` (bad rating), `429` (spam throttle —
  proposed, nothing client-side), common set.
- **Example R1 request:** `{ "rating": 5, "comment": "Great", "sessionID": "…", "timestamp": "2026-07-25T12:00:00Z" }`

#### 3.17.b Admin review moderation — PROPOSED (MOCK-ONLY, no seam)

Contract: `AdminModerationGateway`
(`src/app/features/admin/moderation/services/admin-moderation-gateway.interface.ts`).
Impl: `AdminModerationLocalGateway` (injected concretely by
`AdminModerationFacade`; reused by `AdminAnalyticsFacade`). DTO: `AdminReview`
(`src/app/features/admin/moderation/models/admin-review.model.ts`).

| # | Method | Path (PROPOSED) | Gateway method | Success |
|---|---|---|---|---|
| MR1 | GET | `{base}/backoffice/moderation/reviews?search&status&rating&page&pageSize` | `loadReviews(filters)` | `200 AdminReviewsListResult` |
| MR2 | GET | `{base}/backoffice/moderation/reviews/{id}` | `loadReview(id)` | `200 AdminReview` / `404` |
| MR3 | PATCH | `{base}/backoffice/moderation/reviews/{id}/status` | `setReviewStatus(id,status,note)` | `200 AdminReview` |
| MR4 | PATCH | `{base}/backoffice/moderation/reviews/{id}/visible` | `setReviewVisible(id,visible)` | `200 AdminReview` |
| MR5 | PATCH | `{base}/backoffice/moderation/reviews/{id}/pinned` | `setReviewPinned(id,pinned)` | `200 AdminReview` |
| MR6 | PATCH | `{base}/backoffice/moderation/reviews/{id}/featured` | `setReviewFeatured(id,featured)` | `200 AdminReview` |
| MR7 | POST | `{base}/backoffice/moderation/reviews/{id}/notes` | `addModeratorNote(id,note)` | `200 AdminReview` |
| MR8 | DELETE | `{base}/backoffice/moderation/reviews/{id}` | `deleteReview(id)` | `204` |

- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`
  (MR1/MR2), `backoffice.write` (MR3-MR8).
- **MR1 query params** (`AdminReviewListFilters`): `search`, `status` ∈
  `all|pending|approved|rejected|spam`, `rating` ∈ `all` or a number 1-5, `page`,
  `pageSize`. Response `AdminReviewsListResult { items, total, page, pageSize }`.
- **MR3 (approve/reject) body:** `{ status: AdminReviewStatus, note: string }`
  where status ∈ `pending|approved|rejected|spam`. This is the approve/reject
  action — approving = `status:'approved'`, rejecting = `'rejected'`, marking spam
  = `'spam'`. The `note` is a moderator reason, appended to the review timeline.
- **`AdminReview`:** `{ id, productId, productName, customerName, customerEmail,
  rating, text, photos[], status, visible, pinned, featured, reportCount,
  moderatorNotes, timeline: [{ eventKey: 'submitted'|'statusChanged'|'restored'|
  'hidden', actor: 'admin'|'customer', status?, note, timestamp }], createdAt,
  updatedAt }`. Server appends timeline entries on each mutation.
- **Relation to storefront reviews (§3.17.a):** the admin `AdminReview` and the
  storefront `Review` (`product-engagement.model.ts`) are different types over the
  same underlying data. How a `POST /items/{id}/callback` submission surfaces in
  the moderation queue = Requires backend decision (they are unconnected today).
- **Validation:** none client-side.
- **Errors:** `404` (unknown id), common set.
- **Example MR3 request:** `{ "status": "approved", "note": "Looks legit" }`

---

### 3.18 Reports — PROPOSED (MOCK-ONLY, no seam)

Part of `AdminModerationGateway` (same gateway/facade as reviews §3.17.b). DTO:
`AdminReport` (`src/app/features/admin/moderation/models/admin-report.model.ts`).

| # | Method | Path (PROPOSED) | Gateway method | Success |
|---|---|---|---|---|
| RP1 | GET | `{base}/backoffice/moderation/reports` | `loadReports()` | `200 AdminReport[]` |
| RP2 | PATCH | `{base}/backoffice/moderation/reports/{id}/status` | `setReportStatus(id,status)` | `200 AdminReport` |

- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`
  (RP1), `backoffice.write` (RP2).
- **RP1:** returns a **plain array, no pagination/filter params** (`loadReports()`
  takes no arguments).
- **RP2 body:** `{ status: AdminReportStatus }` where status ∈
  `open|resolved|dismissed`.
- **`AdminReport`:** `{ id, targetType: 'product'|'review'|'customer'|'category'
  |'unknown', targetId, targetLabel, reason, reporterEmail, status, createdAt }`.
  A report points at another entity via `targetType`+`targetId`.
- **No report *creation* endpoint in the frontend** — reports are read + status
  changed only. Who/what creates reports (customer-facing "report this" flow) =
  Requires backend decision (no such storefront submission exists in code).
- **Validation:** none client-side. **Errors:** `404` (RP2 unknown id), common set.
- **Example RP2 request:** `{ "status": "resolved" }`

---

### 3.19 Monitoring — PROPOSED, READ-ONLY (MOCK-ONLY, no seam)

Contract: `AdminMonitoringGateway`
(`src/app/features/admin/monitoring/services/admin-monitoring-gateway.interface.ts`).
Impl: `AdminMonitoringLocalGateway` (injected concretely by
`AdminMonitoringFacade`). **Read-only** — the gateway has no mutation methods
(`BACKEND-AUDIT.md` §14). DTOs in `admin-monitoring.model.ts`.

| # | Method | Path (PROPOSED) | Gateway method | Success |
|---|---|---|---|---|
| MO1 | GET | `{base}/backoffice/monitoring/events?category&search` | `loadEvents(filters)` | `200 AdminMonitoringEvent[]` |
| MO2 | GET | `{base}/backoffice/monitoring/queues` | `loadQueues()` | `200 AdminQueue[]` |
| MO3 | GET | `{base}/backoffice/monitoring/webhooks` | `loadWebhooks()` | `200 AdminWebhookDelivery[]` |

- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`.
- **MO1 query params** (`AdminMonitoringEventFilters`): `category` ∈
  `all|audit|security|login|failed_login|api|error|warning`, `search`. **No
  pagination** (plain array) — a backend feeding a real event stream would likely
  need to add paging/time-range params; **Requires backend decision** on
  windowing.
- **Models:** `AdminMonitoringEvent { id, category, level: 'info'|'warning'
  |'error', message, technicalDetail?, actor, timestamp }`; `AdminQueue { name,
  depth, status: 'healthy'|'degraded'|'down' }`; `AdminWebhookDelivery { id,
  endpoint, event, status: 'delivered'|'failed'|'pending', timestamp }`.
- **Webhooks here are read-only observability** (delivery log), **not** webhook
  configuration CRUD — there is no create/update/delete webhook endpoint in the
  frontend. Webhook *management* = Requires backend decision (out of current scope).
- **Validation/errors:** read-only; common read error set.
- **Example MO2 response:** `[ { "name": "orders", "depth": 3, "status": "healthy" } ]`

---

### 3.20 Analytics — PROPOSED, DERIVED / NO REAL DATA SOURCE (MOCK-ONLY)

Per `BACKEND-AUDIT.md` §14: `AdminAnalyticsFacade` has **no gateway of its own** —
it derives everything by reusing orders/products/moderation local gateways +
`ADMIN_CATEGORIES_GATEWAY` + `AdminDashboardFacade`. Several analytics values are
**honestly `null` because no data source exists** (e.g. `conversionRate`,
`retentionPercent` — model comments: "null = unknown - no visitor/traffic tracking
exists yet. Never fabricated"). DTOs in `admin-analytics.model.ts`.

| # | Method | Path (PROPOSED) | Purpose | Success |
|---|---|---|---|---|
| AN1 | GET | `{base}/backoffice/analytics/summary?range` | KPI summary | `200 AdminAnalyticsSummary` |
| AN2 | GET | `{base}/backoffice/analytics/products?range` | Product analytics | `200 AdminProductAnalytics` |
| AN3 | GET | `{base}/backoffice/analytics/customers?range` | Customer analytics | `200 AdminCustomerAnalytics` |
| AN4 | GET | `{base}/backoffice/analytics/series?range&metric` | Time series | `200 AdminAnalyticsSeriesPoint[]` |

- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`.
- **`range` query param** ∈ `7 | 30 | 90` (`AdminAnalyticsDateRange`) — the only
  filter the frontend models.
- **`AdminAnalyticsSummary`:** `{ revenueTotal, currency, ordersCount,
  avgOrderValue, productsCount, categoriesCount, customersCount, conversionRate:
  number | null }`. **`conversionRate` MUST be `null` when unknown** — a backend
  must not fabricate it absent real visitor/traffic tracking. Same for
  `AdminCustomerAnalytics.retentionPercent`.
- **What has no backend data source at all (mark accordingly):** conversion rate,
  retention, and anything needing visitor/traffic/pageview data — there is **no
  traffic analytics ingestion anywhere in the frontend**. Building real analytics
  (beyond order/product aggregates) is **Requires backend decision** end to end.
- **Validation/errors:** read-only; common read set. A backend that can't compute
  a metric should return `null`, not omit or fake it.
- **Example AN1 response:**
  ```json
  { "revenueTotal": 4820000, "currency": "RUB", "ordersCount": 213, "avgOrderValue": 22629, "productsCount": 512, "categoriesCount": 24, "customersCount": 178, "conversionRate": null }
  ```

**Dashboard metrics (adjacent).** `AdminDashboardMetricsGateway.loadMetrics()`
(`ADMIN_DASHBOARD_METRICS_GATEWAY`, MOCK-SWAPPABLE token) returns
`AdminDashboardMetrics { categoriesCount, productsCount }` — the one other
token-bound admin gateway. Proposed: `GET {base}/backoffice/dashboard/metrics` →
`200 { categoriesCount, productsCount }`, permission `backoffice.read`.

---

### 3.21 Cross-cutting "Requires backend decision" register (this section)

Consolidated so the master doc can dedupe against the auth/error registers.
Each item has a **Recommended default** — apply it unless you have a specific
reason not to; only stop and ask a human where none is given or the item has
real business/money/legal weight (marked accordingly).

1. **DI seam prerequisite** — orders, products, users, transactions, monitoring,
   moderation (+ derived customers/analytics) inject their `*LocalGateway`
   concretely; a backend requires introducing a DI token first (`BACKEND-AUDIT.md`
   §14). Only categories + dashboard-metrics are token-bound today.
   **Recommended default:** this is frontend work (adding the token seam), do
   it for every domain before wiring its real gateway — mechanical, no backend
   input needed. See §8.5 for the exact per-domain pattern.
2. **All admin CRUD paths are PROPOSED** — only `/backoffice/categories*` and the
   storefront catalog/cart/engagement paths are literal. Adopt the
   `/backoffice/<domain>` convention (or reject it).
   **Recommended default:** adopt it as-is — it's already live for categories,
   changing it later is a bigger cost than any alternative naming would save.
3. **Server-owned field lists** on create/update for products/orders/etc. (only
   categories defines one, by stripping `id/itemsCount/deletedAt/createdAt/updatedAt`).
   **Recommended default:** mirror the categories pattern exactly (server strips
   `id`/counts/timestamps from client payloads; client never sends them).
4. **Field validation + error wording** — the frontend enforces essentially none
   on admin CRUD (no reactive `Validators`); all `422` field rules and messages are
   backend-owned.
   **Recommended default:** validate required/type/length server-side per the DTO
   shapes given in §3; return field errors in the §6 `422` shape (`fieldErrors`
   keyed by field path). Don't wait on frontend validation to be added first.
5. **Product slug/sku uniqueness** — no proactive check exists (unlike categories'
   `isSlugTaken`); decide `409` behavior.
   **Recommended default:** mirror categories — add an `isSlugTaken`-equivalent
   check endpoint, and return `409` with the §6 conflict shape on collision.
6. **Order status-transition legality** and refund eligibility rules.
   **Business decision, not technical** — the valid state machine and who can
   trigger which transition depends on the merchant's actual fulfillment
   process. Ask.
7. **Customers as a first-class resource** vs. derived-from-orders aggregate.
   **Recommended default:** derived-from-orders aggregate (§3.4 as written) —
   it's what the frontend already assumes and needs no new storage model. Only
   promote to first-class if a real need appears (e.g. customer accounts,
   guest-order merging).
8. **`AdminRole` duplication** — auth string-union vs. users-management interface
   (naming reconciliation; role CRUD / custom roles).
   **Recommended default:** keep the coarse `AdminRole` string union (§4 §9.1)
   as the enforced permission source; treat the users-management interface's
   richer shape as display-only until custom roles are actually requested by
   the product.
9. **Remote session revocation propagation** (`revokeSession`) to a logged-in
   client (`§4` §8 — no push/poll exists).
   **Recommended default:** accept the propagation delay (revoked session stays
   client-usable until its next natural refresh-interval tick, §4 §8) rather
   than building push/poll infrastructure for this alone — revisit only if a
   real incident makes the delay unacceptable.
10. **Media**: allowed mime/size; whether in-use assets are delete-blocked.
    **Recommended default:** allow the mime types the client already filters
    for (`image/*`, `application/pdf`, `image/svg+xml` — §7.1 `MediaAssetKind`),
    10MB per file; block delete of in-use assets with a `409` listing the
    referencing entities (safer default than silent broken references).
11. **CMS/homepage/widgets/nav/footer/branding/languages/settings are LOCAL-ONLY**
    — the single biggest decision is **whole-bootstrap publish (S2) vs. granular
    builder endpoints** (§3.15). No builder write call exists in code today.
    **Recommended default:** whole-bootstrap publish (a single `PUT`/`POST` that
    replaces the tenant's bootstrap document) — matches how the frontend already
    edits it as one in-memory draft object; granular per-section endpoints are
    strictly more backend work for no frontend benefit today.
12. **Languages**: duplicate-add `409`, removing the default/last locale, BCP-47
    validation.
    **Recommended default:** `409` on duplicate-add; reject removing the last
    remaining locale or the current default (`422`); validate against BCP-47
    syntax, not a fixed enum (frontend already treats locale as an open string).
13. **Transactions ↔ live payment records** are unconnected models; how they link.
    **Recommended default:** key `AdminTransaction` off the same order/payment id
    the live QR/card flow already returns (§3.3.a) rather than inventing a new
    identifier — one payment, one transaction record.
14. **Reviews**: storefront `Review` vs. admin `AdminReview` linkage; how a
    submitted review enters the moderation queue.
    **Recommended default:** every storefront-submitted review starts in the
    moderation queue at `pending` status; `AdminReview` is the same underlying
    record with moderation fields attached, not a separate entity.
15. **Reports**: no creation flow exists — who creates reports.
    **Business decision** — depends on whether "reports" means customer-flagged
    content, automated abuse detection, or admin-initiated audits. Ask.
16. **Monitoring**: event windowing/pagination for a real stream; webhook
    *management* (vs. read-only delivery log) is out of scope.
    **Recommended default:** offset-paginated `GET` with a `since`/time-range
    filter (matches §2.2's standard pagination contract) — no need for a
    streaming protocol for an admin-facing log view.
17. **Analytics**: `conversionRate`/`retentionPercent` must stay `null` when
    unknown; no traffic/visitor data source exists anywhere — real analytics is a
    from-scratch backend build.
    **Recommended default:** ship order/product/revenue aggregates first (real
    data, computable from orders/products already in scope); leave traffic-based
    metrics `null` until a real analytics/traffic pipeline exists — never
    fabricate a number, the frontend already renders `null` as "Unknown."
18. **Variants/options** shape reconciliation between storefront `variantOptions`
    (grouped) and admin `variants` (flat priced rows); no dedicated variants
    endpoint (created within the product body).
    **Recommended default:** keep variants embedded in the product body (no
    separate variants endpoint) — matches how the admin editor already sends
    them; the backend computes/derives the storefront's grouped
    `variantOptions` shape from the same flat rows at read time.

---

_All paths repo-relative to `F:\dx\remote\marketplaces\`. Storefront/category
literals and the `AdminCategoriesApiGateway` paths are verified in source; every
other endpoint is PROPOSED per the conventions in §3.0._

---

## 4. Authentication

Standalone, backend-implementable authentication contract for the marketplace
platform (Angular frontend, branch `B2B`). Derived directly from source —
`src/app/core/auth/**`, `src/app/core/admin-auth/**`,
`src/app/services/{auth,telegram-session-api}.service.ts`,
`src/app/components/telegram-login/**`, `src/app/guards/language.guard.ts`,
`src/app/app.routes.ts`, `src/app/app.config.ts`,
`src/app/core/config/tenant-resolver.service.ts` — plus
`docs/context/BACKEND-AUDIT.md` (this-session audit) and the prior
`docs/AUTH.md`. Where the frontend does not already imply a behavior, this
document says **"Requires backend decision"** rather than inventing one.

Two authentication mechanisms coexist in the codebase today, at different
maturity levels:

| Mechanism | Used by | Status |
|---|---|---|
| Telegram QR / deep-link session auth | Storefront customers **and** admin/backoffice (same API) | **LIVE** — real endpoints, in production use |
| Ed25519 challenge/response admin auth | Admin/backoffice (intended replacement) | **Frontend fully wired, backend endpoints do not exist yet** (404s today) |

Both are documented in full below. Nothing here should be read as "the
platform has JWTs today" — it does not, except inside the not-yet-live
Ed25519 flow.

---

### Table of contents

1. [Mechanism A — Telegram QR / session login (LIVE)](#1-mechanism-a--telegram-qr--session-login-live)
2. [Mechanism B — Ed25519 challenge/response admin auth (NOT LIVE)](#2-mechanism-b--ed25519-challengeresponse-admin-auth-not-live)
3. [JWT structure](#3-jwt-structure)
4. [Refresh token](#4-refresh-token)
5. [Token expiration handling](#5-token-expiration-handling)
6. [Token rotation](#6-token-rotation)
7. [Logout](#7-logout)
8. [Session invalidation](#8-session-invalidation)
9. [Role hierarchy](#9-role-hierarchy)
10. [Tenant isolation](#10-tenant-isolation)
11. [Permission model / route guards](#11-permission-model--route-guards)
12. [Open items — "Requires backend decision"](#12-open-items--requires-backend-decision)

---

### 1. Mechanism A — Telegram QR / session login (LIVE)

Single source for **both** customer and admin login:
`TelegramSessionApiService` (`src/app/services/telegram-session-api.service.ts`).
There is no separate admin backend endpoint — the same three calls back the
customer `AuthService` (`src/app/services/auth.service.ts`) and the admin
`AdminAuthService` (`src/app/core/admin-auth/admin-auth.service.ts`). Only the
**storage** differs (cookie name, in-memory signal), so an admin QR scan
never authenticates the customer session or vice versa.

#### 1.1 Endpoints (base = `environment.authApiUrl`, e.g. `https://api.dexarmarket.ru:445`)

| Method | Path | Request | Response |
|---|---|---|---|
| POST | `/users/sessions` | body `{ webSessionID }` (client-generated GUID), header `WebSessionID: <same guid>` | `{ webSessionID, url }` — `url` is the Telegram bot deep link |
| GET | `/users/sessions/{id}` | — | Session object, heavily field-tolerant (see §1.3) |
| DELETE | `/users/sessions/{id}` | header `WebSessionID: <id>` | ignored/discarded |

#### 1.2 Frontend-driven flow

The frontend, not the backend, generates the session id. Sequence:

1. User opens login (storefront "Sign in" or admin `/admin-login` gate).
2. Frontend generates a random GUID client-side (`generateGuid()`,
   `src/app/shared/util/guid.util.ts`) — this **is** the `webSessionID`, sent
   to the backend, not received from it.
3. `POST {authApiUrl}/users/sessions` with `{ webSessionID }` body and
   `WebSessionID` header set to the same value. Backend response's own id
   field is preferred if present (see `extractSessionId` — checks
   `webSessionID/WebSessionID/webSessionId/sessionID/SessionID/sessionId/id/ID`
   in that order), else the frontend's generated GUID is used as fallback.
4. Frontend builds two login links from the returned id:
   - Web: `https://t.me/{bot}?start={webSessionID}` (`getBotLoginUrl`)
   - App deep link: `tg://resolve?domain={bot}&start={webSessionID}`
     (`getBotAppLoginUrl`)
   - `bot` = `environment.telegramBot` (`'myAMLKYCBOT'` in current env config;
     code fallback `'DexarSupport_bot'` if the env key is absent).
5. Frontend renders both as a QR code (external image generator
   `https://api.qrserver.com/v1/create-qr-code/...` — not a backend of this
   platform, purely a QR bitmap renderer for the `url`) plus the app deep
   link for mobile. This is orchestrated by `QrLoginEngine`
   (`src/app/shared/qr-login/qr-login.engine.ts`) shared identically by both
   customer and admin modes via `TelegramLoginComponent`
   (`src/app/components/telegram-login/telegram-login.component.ts`,
   `[mode]="'customer' | 'admin'"`).
6. User scans the QR (or taps the deep link on mobile) and completes the
   Telegram bot interaction out-of-band. The **backend** is expected to mark
   that `webSessionID` as active/logged-in once the Telegram bot confirms the
   user, associating a Telegram user identity with it.
7. Frontend polls `GET /users/sessions/{id}` (`checkSessionOnce`, driven by
   `QrLoginEngine`'s polling loop) until the session normalizes to `active:
   true`, or the user cancels/times out.
8. On an active session, the frontend calls `activateSession()` internally
   (sets in-memory signal, stores the id per §1.4, schedules a re-check —
   see §5) and redirects: customer → wherever the login was triggered from;
   admin → `/{lang}/backoffice/dashboard` (hardcoded in
   `telegram-login.component.ts`).

#### 1.3 Session response normalization (backend field tolerance)

`TelegramSessionApiService.normalizeWebSession()` is deliberately
tolerant of multiple backend field-naming conventions (evidence the backend
contract was never fully pinned down). A backend implementation can emit any
of these; the frontend reads the **first key found** in this priority order:

- **Active/status**: `status`, `Status`, `active`, `Active`, `loggedIn`,
  `LoggedIn`, `isLoggedIn`, `IsLoggedIn`, `authenticated`, `Authenticated`.
  Value is considered "active" if boolean `true`/`1`, or (case-insensitive)
  one of `true, 1, active, authenticated, confirmed, success, logged_in`.
- **User object**: nested under `user`/`User`/`telegramUser`/`TelegramUser`,
  else the top-level response object itself is used as the user record.
- **Username**: `username`/`Username` (user object first, then top-level).
- **First/last name**: `firstName`/`first_name`/`FirstName`/`First_name`,
  `lastName`/`last_name`/`LastName`/`Last_name` — joined with a space if both
  present.
- **Display name**: explicit `displayName`/`DisplayName`/`name`/`Name` (user
  object or top-level) wins; else falls back to `username`; else falls back
  to the joined full name; else literal `'Telegram User'`.
- **Telegram user id**: `userId`/`telegramUserId`/`telegramUserID`/
  `TelegramUserID`/`id`/`ID` (user object), else `userId`/`telegramUserId`/
  `telegramUserID`/`TelegramUserID`/`userID`/`UserID`/`UserId` (top-level).
- **Session id**: same priority list as `extractSessionId` above.
- **Expiry**: `expiresAt`/`ExpiresAt`/`expires`/`Expires` (ISO 8601 string);
  if absent, the frontend fabricates `now + 3600s` client-side — **the
  backend should always send a real `expiresAt`/`expires`** so the frontend's
  refresh-scheduling (§5) reflects the true session lifetime rather than a
  guessed one.

Normalized shape consumed by the frontend (`AuthSession`,
`src/app/models/auth.model.ts`):

```ts
interface AuthSession {
  sessionId: string;
  userId: number | null;
  username: string | null;
  displayName: string;
  active: boolean;
  expires: string; // ISO 8601
}
```

#### 1.4 Storage (customer vs admin — kept fully separate)

| | Customer (`AuthService`) | Admin (`AdminAuthService`) |
|---|---|---|
| Cookie name | `webSessionID` | `adminSessionID` |
| Cookie attrs | `Max-Age=3600; Path=/; SameSite=Lax` (+`Secure` over HTTPS) | `Max-Age=3600; Path=/; SameSite=Strict` (+`Secure` over HTTPS) |
| In-memory state | `sessionSignal`, `statusSignal` (`unknown\|checking\|authenticated\|expired\|unauthenticated`) | Same shape, separate signals |
| Extra storage | — | Reserved JWT-pair slots `localStorage['adminToken']` / `localStorage['adminRefreshToken']` — **unused today**, see §12 |

Both send a `WebSessionID` header on every marketplace API request via
`apiHeadersInterceptor` (see `docs/context/BACKEND-AUDIT.md` §3) — this is
the anonymous-or-authenticated session identity the backend correlates
requests against; there is no `Authorization: Bearer` header in this
mechanism.

#### 1.5 Session re-check / soft refresh (not a token refresh)

Both `AuthService` and `AdminAuthService` self-schedule a re-check of
`GET /users/sessions/{id}` 60 seconds before `expires`, minimum 30s out
(`scheduleSessionRefresh`). This is **not** a refresh-token exchange — it
just re-polls the same session-status endpoint and re-activates if still
active, or clears local state if not. There is no rotation of the
`webSessionID` itself in this mechanism.

#### 1.6 Admin dev bypass (non-production only)

`AdminAuthService.devBypassLogin()` fabricates a local session
(`sessionId: 'dev-bypass-{timestamp}'`, `active: true`, 1-hour expiry) and
activates it directly, skipping the QR flow entirely. Guarded by
`environment.production` at runtime (not just build-time) — the checked
condition is inside the function body, so it is dead code in a production
build.

#### 1.7 Sequence diagram — storefront/admin Telegram-QR login

```mermaid
sequenceDiagram
    participant User as User (browser)
    participant FE as Frontend (AuthService / AdminAuthService)
    participant BE as Backend (authApiUrl)
    participant TG as Telegram bot

    User->>FE: Open login (customer checkout, or /admin-login gate)
    FE->>FE: generate webSessionID (client GUID)
    FE->>BE: POST /users/sessions { webSessionID } (header WebSessionID)
    BE-->>FE: 200 { webSessionID, ... }
    FE->>FE: build QR + tg:// deep link from webSessionID
    FE-->>User: render QR code / "Open in Telegram" button
    User->>TG: scan QR / tap deep link, confirm in bot
    TG->>BE: (out of band) associate webSessionID with Telegram user
    loop poll every N seconds
        FE->>BE: GET /users/sessions/{webSessionID}
        BE-->>FE: session (active:false while pending)
    end
    BE-->>FE: session (active:true, user fields, expires)
    FE->>FE: activateSession(): store cookie, set signals,<br/>schedule re-check at expires-60s
    alt mode = admin
        FE-->>User: redirect to /{lang}/backoffice/dashboard
    else mode = customer
        FE-->>User: close dialog, resume prior action (e.g. checkout)
    end
```

---

### 2. Mechanism B — Ed25519 challenge/response admin auth (NOT LIVE)

**Status: frontend fully implemented and wired to real `HttpClient` calls;
the backend does not implement these endpoints yet — calls 404/error today.**
No route currently requires this flow (`ed25519AuthGuard` is not referenced
by any route in `app.routes.ts`; the live admin gate is still
`adminAuthGuard` / Telegram QR, §1). This is the target contract for closing
the security gap in §1: today the Telegram session API has no concept of
"admin," so the backend cannot distinguish an admin login attempt from a
customer one at the moment of login. Ed25519 closes that by requiring proof
of possession of a specific, pre-registered private key before any session
is issued.

#### 2.1 Key generation (device-local, once per device)

`Ed25519KeypairService` (`src/app/core/auth/services/ed25519-keypair.service.ts`):

- `getOrCreateKeyPair()`: generates a **non-extractable** Ed25519 keypair via
  `crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])`
  (real WebCrypto Ed25519 — RFC 8032, not a placeholder), persists the raw
  `CryptoKey` handles in IndexedDB (`admin-auth-ed25519` DB, object store
  `keypair`, single record `id: 'device-keypair'`).
- The private key is never exported, serialized, or transmitted — by
  construction (`extractable: false`), not by convention or policy.
- `sign(message)`: signs a UTF-8-encoded string with `crypto.subtle.sign
  ('Ed25519', privateKey, ...)`, returns a base64-encoded signature.
- `clear()`: deletes the IndexedDB record ("forget this device"). A new
  keypair generated after this requires re-registration with the backend
  (§2.2) before it can complete a login.
- **Public key registration is explicitly out of scope for the frontend.**
  An Owner/Administrator must associate a new device's `publicKeyBase64`
  with an admin account through some out-of-band mechanism (backend admin
  tool, one-time enrollment link, etc.) — not prescribed here (see §12).

#### 2.2 Login flow, step by step

Orchestrated by `AuthService.login()` (`src/app/core/auth/services/auth.service.ts`,
distinct from the customer/admin `AuthService` in §1 despite the identical
class name — different module, `core/auth/` vs `services/`):

1. `GET {authApiUrl}/api/admin/auth/challenge` → `AuthChallenge { nonce,
   issuedAt, expiresAt }` (all ISO 8601 except `nonce`, an opaque string).
2. `Ed25519KeypairService.getOrCreateKeyPair()` (generates on first use).
3. `Ed25519KeypairService.sign(nonce)` — signs the **raw nonce string
   exactly as received**, no additional framing/prefix/hashing applied
   client-side.
4. `POST {authApiUrl}/api/admin/auth/verify` with body
   `VerifySignatureRequest { publicKey, signature, nonce }` (`publicKey` =
   base64 raw Ed25519 public key, `signature` = base64 signature over the
   nonce, `nonce` = the same value echoed back).
5. Backend must: re-derive the exact signed message from the nonce it
   issued, verify the signature against its own `publicKey → admin account`
   mapping, confirm the nonce hasn't expired or been used before, and only
   then issue tokens.
6. On success: `200 AuthTokenPair { token, refreshToken }`.
   `SessionService.activate(tokens)` decodes the JWT (§3), stores both
   tokens (§4), and schedules the next refresh (§5).
7. On failure: `401`/`403` → `AuthService` maps it through
   `authErrorCodeFromStatus()` to `invalid-signature` (or a more specific
   code — see §2.5) and the UI routes to
   `/admin-login/error/invalid-signature`.

#### 2.3 API contracts (all under `{environment.authApiUrl}/api/admin/auth`)

| Method | Path | Request body | Response | Notes |
|---|---|---|---|---|
| GET | `/challenge` | — | `200 AuthChallenge` | `{ nonce, issuedAt, expiresAt }` |
| POST | `/verify` | `VerifySignatureRequest { publicKey, signature, nonce }` | `200 AuthTokenPair` \| `401` \| `403` | Issues `{ token, refreshToken }` |
| POST | `/refresh` | `RefreshTokenRequest { refreshToken }` | `200 AuthTokenPair` \| `401` | Rotation expected — see §6 |
| POST | `/logout` | `{ refreshToken }` | `204` (frontend clears local state regardless of response code/body) | Should revoke server-side |

Types: `src/app/core/auth/models/auth-api.model.ts`. HTTP client:
`src/app/core/auth/services/auth-api.service.ts` (`AuthApiService`) — thin
wrapper, no retries, no fabricated mock responses.

#### 2.4 Sequence diagram — admin login with Ed25519 signing

```mermaid
sequenceDiagram
    participant Admin as Admin (browser)
    participant FE as Frontend (AuthService, core/auth)
    participant Key as Ed25519KeypairService (WebCrypto + IndexedDB)
    participant BE as Backend

    Admin->>FE: Click "Sign in"
    FE->>BE: GET /api/admin/auth/challenge
    BE-->>FE: 200 { nonce, issuedAt, expiresAt }
    FE->>Key: getOrCreateKeyPair() (generate on first use, non-extractable)
    Key-->>FE: { publicKeyBase64 }
    FE->>Key: sign(nonce)
    Key-->>FE: signature (base64)
    FE->>BE: POST /api/admin/auth/verify { publicKey, signature, nonce }
    alt signature valid & publicKey is a provisioned admin key & nonce fresh/unused
        BE-->>FE: 200 { token, refreshToken }
        FE->>FE: SessionService.activate(tokens):<br/>decode JWT claims, persist, schedule refresh
        FE-->>Admin: redirect to /backoffice
    else invalid signature / unknown key / expired or reused nonce
        BE-->>FE: 401 / 403
        FE-->>Admin: redirect to /admin-login/error/invalid-signature
    end
```

#### 2.5 Error screens

Single component `AuthErrorPageComponent` at route `/admin-login/error/:code`
renders all five, keyed by route param. `authErrorCodeFromStatus()`
(`src/app/core/auth/models/auth-error.model.ts`) maps HTTP status → code:
`401→unauthorized`, `403→forbidden`, `0→backend-unavailable`,
`5xx→backend-unavailable`, else `unauthorized`.

| Code | Trigger | User action offered |
|---|---|---|
| `session-expired` | Refresh token rejected/expired | Sign in again |
| `invalid-signature` | `verify` returns 401/403 during login, or any client-side failure in the challenge→sign→verify chain that isn't a clearer HTTP-derived code | Try again |
| `unauthorized` | Route guard sees no active session | Sign in |
| `forbidden` | `permissionGuard` denies (authenticated but insufficient role) | Back to dashboard |
| `backend-unavailable` | Network error / 5xx / status 0 | Retry |

#### 2.6 Interceptor status — NOT registered

`src/app/core/auth/interceptors/auth.interceptor.ts` exists (adds
`Authorization: Bearer` + reactive 401-refresh-and-retry, see §5) but **is
not included** in `app.config.ts`'s `withInterceptors([...])` list today.
Confirmed in `app.config.ts`:

```
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor,
  apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor])
```

`authInterceptor` is absent. Until it is registered, no request in the app
automatically attaches the Ed25519-flow JWT as a bearer token — this
confirms the mechanism is fully dormant, not partially live.

#### 2.7 Module map

```
src/app/core/auth/
├── auth.routes.ts                        # /admin-login, /admin-login/error/:code
├── models/
│   ├── auth-api.model.ts                 # AuthChallenge, VerifySignatureRequest, AuthTokenPair, JwtClaims
│   ├── auth-error.model.ts               # AuthErrorCode, authErrorCodeFromStatus()
│   └── permission.model.ts               # AdminRole, Permission, ROLE_PERMISSIONS
├── services/
│   ├── ed25519-keypair.service.ts        # WebCrypto keygen/sign, IndexedDB persistence
│   ├── auth-api.service.ts               # HttpClient calls to the 4 endpoints in §2.3
│   ├── jwt.service.ts                    # decode-only JWT parsing
│   ├── session.service.ts                # token/claims state, persistence, refresh scheduling
│   ├── permission.service.ts             # role -> permission set
│   ├── auth.service.ts                   # orchestrates challenge -> sign -> verify -> refresh -> logout
│   └── auth-facade.service.ts            # public surface for components
├── interceptors/
│   └── auth.interceptor.ts               # Authorization: Bearer + 401 refresh-and-retry (NOT registered, §2.6)
├── guards/
│   ├── ed25519-auth.guard.ts             # requires SessionService.isAuthenticated() (not referenced by any route)
│   └── permission.guard.ts               # permissionGuard(permission) factory
└── pages/
    ├── admin-login-page.component.*      # sign-in UI
    └── auth-error-page.component.*       # parameterized error screen (§2.5)
```

`AuthFacade` (`src/app/core/auth/services/auth-facade.service.ts`) is the
only thing components/pages should depend on; `AuthService`/
`SessionService`/`PermissionService` are internal collaborators.

---

### 3. JWT structure

Only defined for Mechanism B (Ed25519 flow) — Mechanism A (§1) issues no JWT,
only an opaque session id. Expected claims
(`src/app/core/auth/models/auth-api.model.ts::JwtClaims`):

```ts
interface JwtClaims {
  sub: string;        // admin account id
  role: AdminRole;     // 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly'
  iat: number;         // seconds since epoch (standard `iat`)
  exp: number;         // seconds since epoch (standard `exp`)
  publicKey: string;   // the Ed25519 public key this token was issued for
}
```

`JwtService.decode()` (`src/app/core/auth/services/jwt.service.ts`) does
**decode-only** parsing (base64url payload → JSON), and validates only the
minimal shape: `sub` is a string, `role` is a string, `exp` is a number — if
any of these three checks fail, decoding returns `null` and the caller
(`SessionService`) discards the session as malformed.

The frontend never verifies the JWT signature — it has no trusted key to
check it against; that is exclusively the backend's job on every
subsequent admin request. A decoded-but-unverified claim is UX (role-gated
menus, expiry countdowns) — never proof of authorization to any client-side
check.

---

### 4. Refresh token

Defined only for Mechanism B. `AuthTokenPair { token, refreshToken }` is
returned by both `/verify` and `/refresh`. Storage
(`SessionService`, `src/app/core/auth/services/session.service.ts`):

- `localStorage['ed25519AdminToken']` — access token (JWT)
- `localStorage['ed25519AdminRefreshToken']` — refresh token (opaque to the
  frontend; never decoded, only round-tripped)

Both are written together in `activate()` and cleared together in `clear()`.
There is no separate expiry tracked for the refresh token client-side —
the frontend only reacts to a `401` from `/refresh` (see §5/§6).

Separately, `AdminAuthService` (Mechanism A, Telegram) reserves
`localStorage['adminToken']` / `localStorage['adminRefreshToken']` with
`getAdminToken()`/`setAdminTokens()`/`clearAdminTokens()` methods —
**written by no code path today** ("reserved for once the backend issues
admin access/refresh tokens... unused until then," per the source comment).
These are a distinct, currently-dead pair of storage keys from the Ed25519
ones above; do not conflate them.

---

### 5. Token expiration handling

#### 5.1 Mechanism A (Telegram session) — expiry via re-poll

See §1.5. `expires` from the session payload drives a `setTimeout` at
`max(expiresMs - now - 60_000, 30_000)` that re-calls `GET /users/sessions/
{id}`; if the backend now reports inactive, local state is cleared to
`unauthenticated`. There is no interceptor-level reactive handling for this
mechanism — a 401/expired session surfaces only through the next explicit
`checkSessionOnce()` poll or session re-check timer, not a per-request
retry.

#### 5.2 Mechanism B (Ed25519/JWT) — proactive + reactive

`SessionService.scheduleRefresh(claims)`: computes
`refreshInMs = max(claims.exp*1000 - now - 60_000, 5_000)` and sets a timer.
When it fires, `AuthService.refresh()` runs automatically
(`session.onRefreshDue(callback)` wiring, set up once in `AuthService`'s
constructor to avoid a circular DI dependency between the two services).

`SessionService.restore()` (intended to run once at app bootstrap, from an
`APP_INITIALIZER` calling `AuthFacade.restoreSession()` — **not yet wired
into the bootstrap process today**, see §12): reads persisted tokens,
decodes claims, and either resumes with a scheduled refresh or marks
`expired` immediately without any network call, so a stale session is
caught before any component/guard runs.

`authInterceptor` (present in source, not registered — §2.6) is documented
as: catch a 401 on any admin-gated request → attempt one `refresh()` →
retry the original request once on success → route to `session-expired` on
failure. Does not retry more than once; a second 401 after an
apparently-successful refresh is treated as a server-side problem, not a
transient race.

#### 5.3 Sequence diagram — token expiration / refresh (Ed25519 flow)

```mermaid
sequenceDiagram
    participant FE as Frontend (SessionService)
    participant IC as authInterceptor (not yet registered, §2.6)
    participant BE as Backend

    Note over FE: Timer fires ~60s before JWT exp
    FE->>BE: POST /api/admin/auth/refresh { refreshToken }
    alt refresh token still valid
        BE-->>FE: 200 { token, refreshToken }
        FE->>FE: activate(tokens) - reschedules next refresh
    else refresh token expired/revoked
        BE-->>FE: 401
        FE->>FE: SessionService.markExpired()
        FE-->>FE: route to /admin-login/error/session-expired
    end

    Note over IC: Reactive path - any 401 on an admin request<br/>(inactive until authInterceptor is registered)
    IC->>BE: Admin API request (expired token)
    BE-->>IC: 401
    IC->>BE: POST /api/admin/auth/refresh (single retry)
    alt refresh succeeds
        BE-->>IC: 200 tokens
        IC->>BE: retry original request with new token
    else refresh fails
        IC-->>FE: propagate error, route to session-expired
    end
```

---

### 6. Token rotation

**Mechanism A**: no token to rotate — the `webSessionID` itself is stable
for the life of the session; expiry is handled by re-polling status (§5.1),
not by issuing a new id.

**Mechanism B**: rotation is *expected* by the frontend but not verifiable
until the backend exists. Per the source comment in `AuthApiService` and the
security notes in the prior `docs/AUTH.md`:

- Every `POST /refresh` response is expected to include a **new**
  `refreshToken`; the backend should invalidate the one just used
  (single-use refresh tokens).
- The frontend always stores whatever pair it receives from `/verify` or
  `/refresh` and never reuses an old refresh token after a successful
  rotation — there is no client-side retry logic that would resend a stale
  refresh token.
- **Requires backend decision**: refresh-token reuse detection / revocation
  cascade (e.g. if a rotated-out refresh token is presented again, should the
  backend revoke the entire token family as a compromise signal?). Nothing
  in the frontend implies or depends on this — it is a pure backend policy
  choice.

---

### 7. Logout

**Mechanism A** (`AdminAuthService.logout()` / `AuthService.logout()` in
`src/app/services/auth.service.ts`): `DELETE /users/sessions/{id}` with
`WebSessionID` header, then unconditionally clears local state (cookie,
signals, timers) regardless of the HTTP result.

**Mechanism B** (`AuthService.logout()` in `src/app/core/auth/services/`):
clears `SessionService` state **immediately and unconditionally** (before
the network call resolves), then best-effort calls
`POST /api/admin/auth/logout { refreshToken }` if a refresh token was
present; any error from that call is swallowed (`catchError(() =>
throwError(() => null))`). If no refresh token exists locally, no network
call is made at all. **Backend implication**: the frontend cannot be relied
upon to reliably deliver the logout call (network failure, tab closed
mid-request, etc.) — server-side session/token expiry must not depend on a
client-issued logout ever arriving. `AuthFacade.logout()` additionally
always navigates to `/admin-login` (default) via `finalize()`, regardless of
API outcome.

#### 7.1 Sequence diagram — logout (both mechanisms)

```mermaid
sequenceDiagram
    participant User
    participant FE as Frontend
    participant BE as Backend

    User->>FE: Click "Log out"
    FE->>FE: Clear local session state immediately<br/>(cookie / tokens / signals / timers)
    alt Mechanism A (Telegram session)
        FE->>BE: DELETE /users/sessions/{id} (header WebSessionID)
        BE-->>FE: any response (ignored)
    else Mechanism B (Ed25519/JWT) - only if a refresh token existed
        FE->>BE: POST /api/admin/auth/logout { refreshToken }
        BE-->>FE: 204 (or error, swallowed)
    end
    FE-->>User: redirect to login page
```

---

### 8. Session invalidation

Client-side triggers that clear local auth state, both mechanisms:

- Explicit logout (§7).
- Session status re-check (Mechanism A) returning `active: false` (§1.5).
- JWT decode failure on restore (Mechanism B) — a malformed/unparsable
  stored token is treated as no session at all (`SessionService.restore()`
  calls `clear()`).
- Refresh failure (Mechanism B) — any error from `/refresh` calls
  `SessionService.markExpired()`.
- `AdminAuthService.clearAuthState()` also clears the reserved
  `adminToken`/`adminRefreshToken` keys (§4) even though nothing currently
  writes them, for forward-compatibility once Mechanism A gains a token
  pair.

**Requires backend decision**: server-side session/token revocation
propagation — e.g., can an Owner revoke another admin's active session
remotely (relevant given `AdminUsersGateway.revokeSession` already exists as
a **mock-only** admin-users gateway method per
`docs/context/BACKEND-AUDIT.md` §14)? If so, the frontend has no push
mechanism (no websocket, no polling of "is my token still valid" beyond the
scheduled refresh) to learn about a remote revocation before its next
refresh/request attempt — a session could remain "authenticated" client-side
for up to the refresh interval after a backend-side revocation. If real-time
revocation is required, that is new frontend work, not something already
implied by existing code.

---

### 9. Role hierarchy

**Discrepancy flagged by the backend audit — `AdminRole` is defined twice
with different meanings. Reconciliation needed before backend
implementation:**

1. `src/app/core/auth/models/permission.model.ts` — a **string union** used
   by the Ed25519/JWT flow's `role` claim and `PermissionService`:
   ```ts
   type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly';
   ```
   Ordered highest-to-lowest privilege by convention (not enforced in code —
   `PermissionService` does not rely on ordering, only exact role → permission
   set lookup).

2. `src/app/features/admin/users/models/admin-user.model.ts` — an
   **interface** describing an admin-users-management row (`{ id, name, ...
   }`), unrelated in shape to #1 and used only by the mock admin-users
   gateway/facade (`AdminUsersGateway`, MOCK-ONLY, no backend seam per the
   audit).

These two `AdminRole` symbols do not currently reference each other and are
imported from different modules by different features. A backend
implementer should treat #1 (the permission-model union) as the JWT/role
claim contract for auth purposes, and flag #2 for a naming rename (e.g.
`AdminUserRoleRecord`) rather than assuming they describe the same concept.
This document does not resolve the collision — it is called out so a human
reconciles it before building the backend role table.

#### 9.1 Permission-to-role mapping (from `ROLE_PERMISSIONS`)

| Role | Permissions |
|---|---|
| `Owner` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage`, `settings.manage` |
| `Administrator` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage` |
| `Editor` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write` |
| `Support` | `backoffice.read` |
| `ReadOnly` | `backoffice.read`, `builder.read` |

This is deliberately coarse and mirrors the bootstrap-level
`PermissionsConfig` shape (`src/app/shared/models/config/permissions.model.ts`).
Finer-grained, per-domain permissions (e.g. "can edit prices but not delete
products") do not exist anywhere client-side and stay server-side —
**Requires backend decision** if finer granularity is ever needed.

---

### 10. Tenant isolation

`TenantResolverService` (`src/app/core/config/tenant-resolver.service.ts`)
resolves tenant by **subdomain**, not by header or path prefix:

```ts
getTenantKey(): string {
  if (isLocalhost()) return environment.fallbackTenantKey ?? 'default';
  const segments = hostname.split('.').filter(Boolean);
  if (segments.length === 0) return environment.fallbackTenantKey ?? 'default';
  if (segments[0] === 'www' && segments.length > 1) return segments[1];
  return segments[0];
}
```

- `isLocalhost()` matches `localhost`, `127.0.0.1`, `::1`.
- On a real host, the tenant key is the **first DNS label**, skipping a
  leading `www`. E.g. `dexarmarket.api.dexarmarket.ru` → tenant key
  `dexarmarket`; `www.acme.com` → `acme`.
- This tenant key feeds `ApiConfigService.getBaseUrl()`
  (`src/app/core/config/api-config.service.ts`, documented in
  `docs/context/BACKEND-AUDIT.md` §2) to pick the marketplace API base URL:
  localhost → `environment.localhostApiUrl` (`/api`); else
  `environment.tenantApiBaseUrls[tenantKey]`; else
  `environment.tenantApiTemplate` with `{tenant}` substituted; else
  (gated by `allowBootstrapApiOverride`, off by default) a value read out of
  the already-loaded bootstrap document
  (`bootstrap.apiEndpoints.website.baseUrl` /
  `bootstrap.tenant.apiBaseUrl`).
- **No `X-Tenant` header or `/tenant/{id}/...` path prefix is sent by the
  frontend anywhere** — tenant isolation for the marketplace API is achieved
  purely by **which base URL/subdomain the request is sent to**, not by a
  request attribute the backend reads per-call. Auth (both mechanisms) does
  **not** carry any tenant identifier in its request bodies or headers
  either — `POST /users/sessions`, `/api/admin/auth/challenge`, etc. are all
  called against `environment.authApiUrl`, a single fixed origin, with no
  per-tenant variation in the auth-flow code today.
- **Requires backend decision**: if auth (session creation, Ed25519
  challenge/verify) must be tenant-scoped (e.g. an admin's Ed25519 public key
  should only authorize them for one tenant's backoffice), the frontend
  currently has no mechanism to communicate which tenant a login attempt is
  for beyond whatever the backend can infer from the request's origin/
  Referer header — nothing in the auth payloads carries a tenant id
  explicitly. This would be new frontend work if required.

---

### 11. Permission model / route guards

#### 11.1 `adminAuthGuard` (live, Mechanism A) — `src/app/core/admin-auth/admin-auth.guard.ts`

```ts
export const adminAuthGuard: CanActivateFn = () => {
  const adminAuth = inject(AdminAuthService);
  if (adminAuth.isAuthenticated()) return true;
  adminAuth.requestLogin();
  return false;
};
```

Checks only `AdminAuthService.isAuthenticated()` (Telegram session status
`=== 'authenticated'`) — no role/permission check at all. Applied to
`/edit`, `/edit/:section`, and `/backoffice` (and its children) in
`app.routes.ts`. This guard **cannot** distinguish admin roles from each
other — it is purely "is there an active admin Telegram session," which is
the exact gap Mechanism B is meant to close.

#### 11.2 `ed25519AuthGuard` (dormant, Mechanism B) — `src/app/core/auth/guards/ed25519-auth.guard.ts`

Requires `SessionService.isAuthenticated()` (JWT status `=== 'authenticated'`).
Not referenced by any route in `app.routes.ts` today — confirmed by source
search. Exists purely as the cutover target (see §2's "not live" status).

#### 11.3 `permissionGuard(permission)` — `src/app/core/auth/guards/permission.guard.ts`

Factory guard that checks `PermissionService.has(permission)` against the
Mechanism B role → permission table (§9.1). Also unused by any live route
until Mechanism B is cut over, but ready to gate specific admin sub-routes
by permission once it is (e.g. `permissionGuard('users.manage')` on a users
page).

#### 11.4 `languageGuard` — `src/app/guards/language.guard.ts`

Not an authentication guard, but gates every localized route (`:lang`
segment wraps the entire route tree in `app.routes.ts`). Behavior:

- If `:lang` param is a known, enabled language: preload its translation
  pack (`TranslateService.preloadLanguage`), set it as current
  (`LanguageService.setLanguage`), allow navigation.
- If known but **disabled**: redirect to the current default language,
  preserving the rest of the path.
- If unrecognized entirely: treat the URL as a legacy no-lang-prefix URL and
  redirect to `/{defaultLang}{originalUrl}`, preserving query string/fragment
  via `router.parseUrl` (not a hand-built `UrlTree`, to avoid double-encoding
  the query string into the path segment).

#### 11.5 `canDeactivate` guards (dirty-state guards, not auth)

Also not authentication, but listed since the task asked for "what guards
check": `projectEditorDirtyGuard`, `adminProductDirtyGuard`,
`adminCategoryDirtyGuard` — all gate navigation *away* from an in-progress
editor (builder section, product editor, category editor) to warn about
unsaved changes. They read editor dirty-state signals, not auth state, and
are unrelated to session/token validity.

#### 11.6 What the frontend actually gates, summarized

| Concern | Mechanism | Guard/service |
|---|---|---|
| "Is there an active admin session at all" | Telegram (A) | `adminAuthGuard` → `AdminAuthService.isAuthenticated()` |
| "Is there an active admin JWT session" | Ed25519 (B), not live | `ed25519AuthGuard` → `SessionService.isAuthenticated()` |
| "Does this role have permission X" | Ed25519 (B), not live | `permissionGuard(permission)` → `PermissionService.has()` |
| "Is `:lang` valid/enabled" | n/a | `languageGuard` |
| "Unsaved editor changes" | n/a | `*DirtyGuard` (project editor / product / category) |

**Every one of these is a client-side UX gate only.** None of them are a
substitute for server-side authorization — the backend must independently
verify role/permission on every admin mutation regardless of what a route
guard decided, per the security note already present in the prior
`docs/AUTH.md` and repeated here: a passing client-side check is not proof
of anything to the backend.

---

### 12. Open items — "Requires backend decision"

Consolidated list of everything this document could not derive from
existing frontend code and therefore does not prescribe. Each has a
**Recommended default**; apply it unless the item is flagged as a real
business/security decision.

- **Public-key enrollment mechanism** (§2.1) — how an admin's Ed25519
  `publicKeyBase64` gets associated with an account/role server-side (admin
  tool? one-time enrollment link? manual DB entry?). Zero frontend code
  exists for this by design.
  **Recommended default:** an authenticated admin-management endpoint
  (`POST /backoffice/users/{id}/public-key`), callable only by an
  already-authenticated Owner/Admin — treat it as a users-management
  action (§3.5), not a public/unauthenticated enrollment flow.
- **Refresh-token reuse/compromise detection** (§6) — whether presenting an
  already-rotated-out refresh token should revoke the whole token family.
  Not implied by any frontend behavior.
  **Recommended default:** yes — revoke the whole token family on reuse of
  a rotated-out refresh token (standard rotation-detection practice); this
  is a security posture, not just a technical convention, so treat as
  security-review-required rather than silently skippable.
- **Session/token revocation propagation** (§8) — whether/how a
  remotely-revoked admin session (e.g. via the mock `AdminUsersGateway.
  revokeSession`) is communicated to an already-logged-in client before its
  next refresh cycle. No push/poll mechanism exists today.
  **Recommended default:** accept the propagation delay (§3.21 item 9) —
  don't build push infrastructure for this alone.
- **Tenant scoping of auth requests** (§10) — whether login/challenge/verify
  need an explicit tenant identifier in the payload, versus relying on
  request origin. Not present in any current auth payload.
  **Recommended default:** rely on request origin/host (matches how
  `ApiConfigService.getBaseUrl()` already resolves tenant elsewhere, §1.9)
  rather than adding a redundant payload field.
- **Relationship between the two mechanisms at cutover** — replace
  `adminAuthGuard` with `ed25519AuthGuard` outright, or run both and let
  role/tenant config decide? Explicitly called out in the prior
  `docs/AUTH.md` as "a product decision, not made here," and nothing has
  changed that.
  **Business decision, not technical** — ask. (This is about auth security
  posture and rollout risk, not a convention.)
- **`AdminAuthService`'s reserved JWT-pair slots** (`adminToken`/
  `adminRefreshToken`, §4) — whether Mechanism A is ever meant to gain its
  own token pair (as the reserved-but-unused storage suggests) independent
  of the Ed25519 migration, or whether that code is dead and should be
  removed. Not resolved by current usage (nothing writes to it).
  **Recommended default:** treat as dead/reserved-but-unused; don't build
  backend support for it. Remove client-side only if a dedicated frontend
  cleanup pass confirms nothing depends on the reserved slots existing.
- **`AdminRole` naming collision** (§9) — a reconciliation/rename decision
  between `core/auth/models/permission.model.ts`'s string union and
  `features/admin/users/models/admin-user.model.ts`'s interface; flagged,
  not resolved, by this document.
  **Recommended default:** same as §3.21 item 8 — keep the coarse string
  union as the enforced source, treat the richer interface as display-only.
- **Fine-grained/per-domain permissions** (§9.1) — the current model is
  intentionally coarse; whether a richer permission model is ever needed is
  a backend/product decision.
  **Recommended default:** keep it coarse (`backoffice.read`/
  `backoffice.write`/`builder.read`/`builder.write`/`settings.manage`) —
  don't build fine-grained permissions speculatively; the frontend has no
  UI that would use finer granularity today.
- **`APP_INITIALIZER` wiring for `AuthFacade.restoreSession()`** (§5.2) — the
  code comment says this should be wired in before the Ed25519 flow goes
  live, but it is not wired in today. This is frontend follow-up work, not a
  backend decision, but is listed here because it changes what "session
  restored on refresh" means in practice until it lands.
  **Recommended default:** frontend must wire this before Ed25519 goes live
  in production — flag as a blocking frontend task in the cutover plan, not
  something backend needs to wait on.

---

## 5. Security

Every claim below is grounded in current source on branch `B2B`. Where the
frontend implies nothing, the item is marked **Requires backend decision**.
Auth and error contracts are owned by §4 and §6 respectively — this section
references them rather than restating them, and only covers the
security-relevant angle.

### 5.1 Origin

The frontend calls a small, fixed set of origins, all declared in
`src/environments/environment.ts` / `environment.production.ts`:

| Origin | Source key | Used for |
|---|---|---|
| `https://api.dexarmarket.ru:445` | `tenantApiBaseUrls.default`, `authApiUrl`, and prod `apiUrl`/`localhostApiUrl` | marketplace API + session auth + Ed25519 admin auth |
| `https://{tenant}.api.dexarmarket.ru:445` | `tenantApiTemplate` | per-tenant marketplace API (subdomain-substituted) |
| `https://qr.vitanova.network/api` | `qrApiUrl` | payment / QR / card status |
| `/api` (relative) | dev `apiUrl`/`localhostApiUrl` | localhost, proxied by nginx per tenant |
| `https://api.qrserver.com/...` | literal in `ApiService` | QR **bitmap** image only (not a platform backend) |
| `http://ip-api.com/json/...` | literal in `LocationService` | external geo-IP autodetect (no key, plaintext HTTP) |

The tenant marketplace origin is chosen by **subdomain** (`TenantResolverService`,
see `§4 (Authentication, this document)` §10) — there is no `X-Tenant` header or tenant
path prefix. **Backend note:** the browser will make cross-origin requests to
`api.dexarmarket.ru:445` and `qr.vitanova.network` from whatever host the SPA
is served on (e.g. `dexarmarket.ru`), so those origins must return correct
CORS headers (§5.2). `ip-api.com` is called over plaintext HTTP — a mixed-content
and privacy concern flagged here; the frontend already tolerates its failure
(falls back to 6 hardcoded regions).

### 5.2 CORS

**Bearer/header-only, never credentialed.** No `HttpClient` call in the
codebase sets `withCredentials: true` (grep: zero matches in `src/`). Identity
travels in **headers**, not cookies:

- `WebSessionID` on every marketplace API request (`apiHeadersInterceptor`).
- `AdminWebSessionID` + `Authorization: Bearer <token>` on `/admin/`,
  `/backoffice/`, `/builder/`, `/media/` requests (`adminAuthHeadersInterceptor`).
- `authorization-key` / `userid-value` on QR payment calls.

The auth **cookies** (`webSessionID`, `adminSessionID`) are same-site,
first-party, and are **not** sent cross-origin to the API — they exist only so
the SPA can restore its own session locally. **Backend requirement:** the API
must accept these requests as CORS with `Access-Control-Allow-Origin` set to
the specific SPA origin(s) and `Access-Control-Allow-Headers` including
`WebSessionID, AdminWebSessionID, Authorization, X-Region, X-Language, Currency`
(and the QR API's `authorization-key, userid-value`). Because no request is
credentialed, `Access-Control-Allow-Credentials` is **not** required and
`Allow-Origin` may be specific per tenant.

### 5.3 CSRF

**Not applicable to the API surface, by construction.** All API authentication
is bearer/header-based (§5.2) with **no cookie ever transmitted to the API**
and `withCredentials` never set. A CSRF attack relies on the browser
auto-attaching an ambient credential (cookie) to a forged cross-site request;
since the API authenticates on a header the attacker's page cannot set
cross-origin, a classic CSRF POST cannot forge an authenticated call. There is
zero CSRF-token handling in the frontend (no XSRF interceptor, no
`X-CSRF-Token`/`X-XSRF-TOKEN` reference anywhere in `src/`), and none is
needed for the current design. **Backend note:** this guarantee holds only as
long as the API does **not** start accepting a cookie as an auth credential —
if a future endpoint authenticates via cookie, CSRF protection becomes
mandatory. Requires backend decision only if that design change is made.

### 5.4 JWT

Cross-reference `§4 (Authentication, this document)` §3–§4. Security-relevant storage
angle only:

- **Live today (Telegram session, Mechanism A):** no JWT at all — an opaque
  `webSessionID` in a cookie + in-memory signal. The reserved
  `localStorage['adminToken']` / `adminRefreshToken` slots are **dead** (nothing
  writes them).
- **Ed25519 flow (Mechanism B, dormant):** access + refresh tokens live in
  `localStorage` (`ed25519AdminToken`, `ed25519AdminRefreshToken`).
  **XSS exposure implication:** `localStorage` is readable by any script in the
  origin, so a single XSS foothold (see §5.8) exfiltrates the admin token pair
  outright — there is no `HttpOnly` cookie protecting them. This is the
  standard SPA trade-off and is acceptable **only** if the XSS surface (§5.8)
  is genuinely closed server-side. **Requires backend decision:** whether admin
  tokens should instead be delivered as `HttpOnly; Secure; SameSite` cookies
  to remove the XSS-exfiltration path — the frontend currently assumes
  `localStorage`, so this would be a coordinated frontend+backend change.

### 5.5 Refresh

Refresh tokens are protected client-side only as well as `localStorage`
protects them — i.e. not from XSS (§5.4). The frontend never decodes the
refresh token (round-tripped opaque), stores it beside the access token, and
replaces the pair on every `/verify` and `/refresh` (`§4 (Authentication, this document)`
§4, §6). Rotation is **expected** but only the backend can enforce it. There is
no client-side reuse-detection. **Requires backend decision** (already flagged
in `§4 (Authentication, this document)` §6): single-use refresh tokens + reuse/compromise
revocation cascade — nothing in the frontend implies or depends on it.

### 5.6 Rate limiting

There is **no client-side rate-limit handling** (grep: no `429` / "rate limit"
reference in `src/`; confirmed by `§6 (Error Model, this document)` §429). The frontend
does, however, apply **debounce** on user-driven request bursts, which implies
the natural request cadence a backend limiter should tolerate rather than block:

- Search-as-you-type: `debounceTime(220)` in `SearchFacade`
  (`src/app/features/search/facade/search.facade.ts:98`) — so a typing user
  produces at most ~4–5 `GET /searchitems` calls/second, not one per keystroke.
- Project-editor autosave: ~300 ms debounce before a draft commit
  (localStorage only, not a backend call today).

**Requires backend decision** on the rate-limit contract end to end (limits,
`Retry-After` header vs `retryAfterSeconds` body field, and whether the
frontend should auto-retry). Per `§6 (Error Model, this document)` §429, no retry
interceptor exists — honoring 429 is net-new frontend work, not a config
change.

### 5.7 Replay protection

The Ed25519 signing flow (`§4 (Authentication, this document)` §2) signs the **raw
backend-issued nonce string exactly as received** — `Ed25519KeypairService.sign(nonce)`
applies no client-side framing, prefix, hashing, timestamp, or
counter (`src/app/core/auth/services/auth.service.ts` login flow;
`ed25519-keypair.service.ts`). The `VerifySignatureRequest` body is
`{ publicKey, signature, nonce }` and nothing else. Therefore **all** replay
protection is the backend's responsibility via the nonce it issues:
`AuthChallenge { nonce, issuedAt, expiresAt }`. **Backend requirement:** treat
each nonce as single-use, bind it to the issuing client, reject it after
`expiresAt`, and reject any reuse. The frontend contributes no independent
freshness signal — there is no client timestamp or per-request nonce on any
other call either (the Telegram session flow has no signing at all).

### 5.8 XSS

The frontend renders **backend/CMS-controlled HTML** in three places — this is
the single most important input for what the backend must sanitize:

| Location | File | Handling today |
|---|---|---|
| Storefront static page body (CMS content) | `src/app/pages/static-page/static-page.component.ts:104-106` | Runs `DomSanitizer.sanitize(SecurityContext.HTML, html)` **first**, then `bypassSecurityTrustHtml(sanitized)`. So it is Angular-sanitized before injection into `[innerHTML]`. |
| Static-page **preview** (editor) | `src/app/features/content-management/components/static-page-preview/static-page-preview.component.ts:49` | `bypassSecurityTrustHtml(sanitized)` after a sanitize pass. |
| Payment bank-frame URL | `src/app/pages/cart/cart.component.ts:475` | `bypassSecurityTrustResourceUrl(bankUrl)` — trusts a backend-supplied iframe URL. |

The static-page path is the risky one: CMS body HTML originates from the
bootstrap document (`BootstrapConfig.staticPages`, backend-served). It **is**
run through Angular's sanitizer client-side, but Angular sanitization strips
active content for the DOM context only — it is **not** a substitute for
server-side sanitization/storage validation. **Backend requirement:** treat all
CMS static-page HTML and any product-description HTML as untrusted on write,
sanitize/allowlist server-side, and never assume the client sanitizer is the
only defense. The `bypassSecurityTrustResourceUrl` on the bank payment frame
means the backend-supplied payment URL must be strictly validated server-side
(a malicious value renders in an iframe with `ResourceUrl` trust). Product
descriptions are currently normalized to plain strings by
`ApiService.normalizeItem` and are **not** rendered via `[innerHTML]` today, so
they are lower-risk — but that holds only while descriptions stay plain-text.

Note: the project-editor HTML editor (`marketplace-html-editor.component.ts`)
uses `innerHTML` on a `contenteditable` surface for authoring; that is
author-local editor state, not backend-rendered content, so it is out of the
backend's sanitization scope.

### 5.9 CSP

**No CSP meta tag in `src/index.html`** (confirmed — only SEO/OG/Twitter/theme
meta tags are present). CSP is delivered **at the web-server layer** via
`nginx.conf`, and only on the primary tenant block:

- `dexarmarket.ru` server block (`nginx.conf:41`) sets a full
  `Content-Security-Policy`:
  `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;`
  plus `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`,
  `X-XSS-Protection: 1; mode=block`, `Referrer-Policy: strict-origin-when-cross-origin`,
  `Permissions-Policy: camera=(), microphone=(), geolocation=()`.
- **The `lovero.store` block and the new-tenant template block set the other
  security headers but omit the `Content-Security-Policy` header entirely** —
  an inconsistency the backend/ops team should reconcile so every tenant gets
  the same CSP.

**Backend/ops observations:** the current CSP is permissive — `script-src`
allows `'unsafe-inline'` and `'unsafe-eval'`, `connect-src 'self' https:`
allows connections to any HTTPS origin, and `img-src` allows any `https:` and
`data:`. This weakens the XSS defense-in-depth that would otherwise back up
§5.8. **Requires backend decision:** whether to tighten the CSP (drop
`unsafe-inline`/`unsafe-eval`, pin `connect-src` to the known API origins in
§5.1) and whether to apply it uniformly across all tenant server blocks.

### 5.10 Upload validation (client-side, in the media picker/library)

The only upload path is `MockMediaRepository.upload()`
(`src/app/core/media/mock-media-repository.service.ts`). Before storing, it
runs `validateFile(file)`:

- **Size:** rejects if `file.size > MAX_FILE_SIZE_BYTES` where
  `MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024` (**10 MB**, line 9).
- **Type:** rejects if `file.type` is not in `ALLOWED_MIME_TYPES`
  (line 12) — see §5.12 for the exact list.

It then transforms before storage: raster images are downscaled to
`MAX_IMAGE_DIMENSION = 2000` px on the longest edge at `COMPRESS_QUALITY = 0.85`
(canvas re-encode to JPEG/PNG); GIFs pass through untouched; **SVGs are
sanitized** (§5.11). Validation is keyed on `file.type` (the browser-reported
MIME), which is trivially spoofable — see §5.11. The `<input type=file>` in the
media picker does not set an `accept` attribute filtering the OS picker, so the
MIME check is the only gate.

### 5.11 Media validation (backend-side expectations)

Because the client checks above run in the browser and can be bypassed
(direct API call, forged `Content-Type`, renamed file), the backend must
**re-validate everything independently**:

- **Re-derive the real content type** from magic bytes / content sniffing, not
  the client-declared MIME or filename extension. `assetKind()` client-side
  keys purely off the declared MIME string.
- **Re-enforce the size limit** server-side (10 MB is the frontend's number,
  §5.10 / §5.12 — the backend may set its own, but must enforce one).
- **Re-sanitize SVGs.** The frontend's `sanitizeSvg()` only strips `<script>`
  elements and `on*="..."` inline event-handler attributes via regex
  (lines 124-131). This is **naive** — it misses `<foreignObject>`,
  `javascript:` URIs in `href`/`xlink:href`, CSS-based vectors, and namespaced
  handlers. SVG is served with `image/svg+xml` and can execute script when
  loaded as a document, so the backend must run a real SVG sanitizer/allowlist,
  never trust the client's regex pass.
- **Strip/normalize metadata** (EXIF, embedded payloads) on ingest.
- **Serve uploaded assets from an isolated origin / with `Content-Disposition`
  and `X-Content-Type-Options: nosniff`** so a malicious file can't be
  interpreted as active content on the app origin.

### 5.12 Maximum file sizes

Exact client-enforced constant: **10 MB** —
`MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024`
(`mock-media-repository.service.ts:9`). The user-facing error is
`"File exceeds the 10MB limit."`. Related transform constants (not rejection
limits): `MAX_IMAGE_DIMENSION = 2000` px, `COMPRESS_QUALITY = 0.85`. No
per-account or aggregate storage quota is enforced client-side — see §7 Limits.

### 5.13 Mime validation

Exact client-accepted MIME allowlist
(`ALLOWED_MIME_TYPES`, `mock-media-repository.service.ts:12`):

```
image/jpeg, image/png, image/webp, image/gif, image/svg+xml, application/pdf
```

These map to the `MediaAssetKind` union (`image` | `svg` | `pdf` | `other`)
via `assetKind()`. The media picker's type filter exposes only
`image / svg / pdf` to the user. No extension allowlist exists separately — the
check is MIME-string only. The backend allowlist should mirror this set (and
re-derive type from content, §5.11).

### 5.14 Audit logging

The admin **Monitoring** feature defines an event taxonomy the backend is
expected to populate. `AdminMonitoringCategory`
(`src/app/features/admin/monitoring/models/admin-monitoring.model.ts:1`) is:

```
'audit' | 'security' | 'login' | 'failed_login' | 'api' | 'error' | 'warning'
```

Each `AdminMonitoringEvent` carries `{ id, category, level (info|warning|error),
message, technicalDetail?, actor, timestamp }`. The page filters events by these
categories (`admin-monitoring-page.component.ts:26`). This is **MOCK-ONLY today**
(no backend seam — see `docs/context/BACKEND-AUDIT.md` §14), but it defines the
**event types the backend audit log must eventually emit** for the UI to be
meaningful:

- `audit` — admin actions taken (create/update/delete on any admin domain).
- `security` — security-relevant events (permission changes, revocations).
- `login` / `failed_login` — successful and failed authentication attempts,
  with an `actor` identity.
- `api` / `error` / `warning` — request-level diagnostics.

Additionally, `AdminUserAuditEntry`
(`src/app/features/admin/users/models/admin-user.model.ts:43`) implies a
per-user audit trail of `roleChanged` / `statusChanged` events with `actor` and
`timestamp`. **Backend requirement:** every admin mutation and auth event must
be recorded with actor + timestamp so these two audit surfaces have real data;
each event needs a stable `category`/`eventKey`, a human `message`, and an
optional `technicalDetail`. **Requires backend decision:** retention window,
whether failed-login events capture source IP/device (the `AdminSession` model
carries `device`/`ip`, so the frontend already anticipates it), and the exact
action→category mapping.

### 5.15 Permission matrix (role × domain × actions)

**Critical finding first:** the **live** admin gate does **no role check at
all**. `adminAuthGuard` (`src/app/core/admin-auth/admin-auth.guard.ts`) only
verifies `AdminAuthService.isAuthenticated()` — a binary "is there an active
Telegram admin session." No admin component conditionally shows/hides actions
by the current user's role (grep of `src/app/features/admin`: the `permission`/
`role` matches are all about **managing other users as data** — role labels,
the users-management `AdminRole` record, audit entries — never gating the
current operator's own UI). So today, any authenticated admin can invoke every
admin action the UI exposes; enforcement must be entirely server-side.

The **intended** matrix comes from the dormant Ed25519 flow's `ROLE_PERMISSIONS`
table (`src/app/core/auth/models/permission.model.ts`, mirrored in
`§4 (Authentication, this document)` §9.1). Roles are the string union
`Owner | Administrator | Editor | Support | ReadOnly`; permission domains are
`backoffice`, `builder`, `users`, `settings`:

| Role | backoffice.read | backoffice.write | builder.read | builder.write | users.manage | settings.manage |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| **Owner** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Administrator** | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| **Editor** | ✅ | ✅ | ✅ | ✅ | — | — |
| **Support** | ✅ | — | — | — | — | — |
| **ReadOnly** | ✅ | — | ✅ | — | — | — |

Guards that would eventually enforce this (`ed25519AuthGuard`,
`permissionGuard(permission)`) exist but are **not referenced by any route**
today (`§4 (Authentication, this document)` §11). The mapping is deliberately coarse —
there is no per-domain granularity (e.g. "edit prices but not delete products")
anywhere client-side.

**Backend requirements / decisions:**
- The backend **must** independently authorize every admin mutation against the
  authenticated role — a passing client guard is never proof of authorization.
- **Requires backend decision:** finer-grained per-domain permissions if ever
  needed (none implied client-side).
- **Requires backend decision:** the `AdminRole` naming collision (auth string
  union vs the users-management `AdminRole` interface with `{id, name,
  permissions[], builtIn}`) — flagged in `§4 (Authentication, this document)` §9; the
  users-management interface suggests custom/non-builtin roles with arbitrary
  permission-string sets, which the coarse 5-role union does not model. Reconcile
  before building the backend role table.

---


---

## 6. Error Model

Single unified error-response format the backend must return for every non-2xx
response across all API surfaces (marketplace API, payment/QR API, session
auth API, Ed25519 admin auth API, and any future admin/builder/backoffice
APIs). Derived by cross-referencing every place the Angular frontend
currently parses, catches, or reacts to an HTTP error — see
`docs/context/BACKEND-AUDIT.md` for the full backend-surface audit this is
based on.

**Finding: the frontend does not currently parse any backend error envelope.**
No `HttpInterceptor` in the pipeline (`src/app/app.config.ts` →
`mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor,
adminAuthHeadersInterceptor, cacheInterceptor`) inspects error responses —
all five only touch outgoing requests or successful GET caching. Every
consumer that reacts to failure does so on the RxJS/`HttpErrorResponse`
level (`error.status`, `error.message`), never on a parsed JSON error body.
The one exception is the Ed25519 admin-auth flow, which has a client-side
`AuthErrorCode` union but (see §"Known frontend gap" below) currently derives
it from **HTTP status only**, not from any body field. Because of this, the
envelope below is a **clean proposal, not a reverse-engineered contract** —
every shape decision is marked accordingly.

### The envelope

```json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "One or more fields are invalid.",
    "status": 422,
    "requestId": "b3f1c2a0-4e21-4d3a-9e77-1e8f6a2d9c11",
    "details": [
      { "field": "sku", "code": "REQUIRED", "message": "SKU is required." }
    ]
  }
}
```

**Requires backend decision: adopt this envelope.** The frontend has no
existing opinion to preserve (no code reads `error.error.code` today), so
this is a recommendation, chosen to be consistent with the shapes the
frontend *does* already have opinions about:

- Top-level `{ code, message, status }` mirrors the existing `AuthError`
  interface (`src/app/core/auth/models/auth-error.model.ts:13-18`) almost
  field-for-field — reusing that shape means the Ed25519 auth module can
  parse the new envelope with only a `status` fallback removed, not a
  rewrite.
- `details[]` entries `{ field, code, message }` mirror the existing
  client-side `ProjectValidationIssue` convention (`code, message, section,
  fieldKey, severity` — `src/app/features/project-editor/services/
  project-validator.service.ts:23-36`, consumed via `ProjectEditorFacade
  .fieldError(fieldKey)`). No backend field-error shape exists to preserve
  today (admin CRUD is 100% local/mock — see BACKEND-AUDIT.md §14), so this
  is the closest existing frontend convention to align a real one to.
- `requestId` is new (no frontend code reads it yet) — recommended so
  support/ops can correlate a user-visible failure to server logs. If
  adopted, the frontend would need a small addition to surface it in
  error-state UI (not present today).

Field notes:

| Field | Required | Notes |
|---|---|---|
| `error.code` | yes | Stable, machine-readable, `UPPER_SNAKE_CASE`. Never localized. This is what the frontend should branch on, not `message`. |
| `error.message` | yes | Human-readable fallback (English), safe to show only when the frontend has no i18n mapping for `code`. Never the sole signal for UI branching. |
| `error.status` | yes | Must equal the HTTP status of the response (redundant with the transport layer, but the frontend's own `AuthError.status` already carries this, so keep parity). |
| `error.requestId` | recommended | Opaque correlation id, echoed in logs. |
| `error.details` | only for 422 | Array of field-level issues, see §422 below. |

---

### Status-by-status contract

#### 401 — Unauthenticated / expired token

```json
{
  "error": {
    "code": "UNAUTHENTICATED",
    "message": "Authentication is required to access this resource.",
    "status": 401,
    "requestId": "…"
  }
}
```

**Frontend reaction today:**
- **Admin Ed25519 flow** (`AuthService.login()`/`refresh()` in
  `src/app/core/auth/services/auth.service.ts`): any `HttpErrorResponse` with
  status 401 is mapped via `authErrorCodeFromStatus()` → `AuthErrorCode
  'unauthorized'`, surfaced by `AuthErrorPageComponent`
  (`src/app/core/auth/pages/auth-error-page.component.ts`) with copy
  "Unauthorized… Sign in" and a button that calls `router.navigateByUrl
  ('/admin-login')`.
- **Customer Telegram session auth** (`TelegramSessionApiService`,
  `AuthService` customer-facing, `src/app/services/auth.service.ts`): no
  code branches on a 401 status anywhere — session validity is instead
  polled via `checkSessionOnce()` returning `AuthSession | null`. **Requires
  backend decision**: whether/how a mid-session 401 on a customer-facing
  marketplace call (e.g. `POST /cart`, `POST /orders`) should be surfaced —
  today it would fall through to each caller's generic `catchError`/`error:`
  handler (if any) with no unified "session expired, please re-auth" UX.
- **Admin backoffice CRUD (products/orders/users/etc.)**: these facades
  (`AdminUsersFacade`, `AdminOrdersFacade`, …) currently only ever talk to
  local/mock gateways, so no real 401 has ever reached them. Their existing
  generic `error` boolean signal + `common.errorTitle`/`common.errorDescription`
  + retry button (see "Generic list-page error UI" below) is the pattern a
  real 401 would fall into **unless** the facades are updated to branch on
  status — they don't today.

#### 403 — Forbidden (wrong role or tenant)

```json
{
  "error": {
    "code": "FORBIDDEN",
    "message": "Your account does not have permission to perform this action.",
    "status": 403,
    "requestId": "…"
  }
}
```

**Frontend reaction today:** Ed25519 admin flow only. `authErrorCodeFromStatus(403)`
→ `'forbidden'` → `AuthErrorPageComponent` copy "Forbidden… Back to
dashboard", button `router.navigateByUrl('/backoffice')`. No tenant-scoping
distinction exists in this code path — a 403 caused by wrong role and a 403
caused by wrong tenant render identical copy today. **Requires backend
decision**: if tenant-mismatch should be visually distinct from
role-mismatch, it needs its own `error.code` (e.g. `TENANT_FORBIDDEN` vs
`ROLE_FORBIDDEN`) since the frontend has no other signal to key off besides
status today.

#### 404 — Not found

```json
{
  "error": {
    "code": "NOT_FOUND",
    "message": "The requested item could not be found.",
    "status": 404,
    "requestId": "…"
  }
}
```

**Frontend reaction today:** No code path distinguishes 404 from any other
failure. `catalog-container.component.ts` and
`product-details-container.component.ts` both catch *any* load error and
render the same generic `catalog.errorTitle`/`productDetails.errorTitle`
empty-state (`en.ts:176,277`) — a real 404 (product deleted) and a 500
(server crash) look identical to the user today. **Requires backend
decision**: whether the frontend should be enhanced to show a distinct
"this product no longer exists" message for 404 specifically (would need a
status/code check added to those two containers — not present now).

#### 409 — Conflict

```json
{
  "error": {
    "code": "CONFLICT",
    "message": "A category with this slug already exists.",
    "status": 409,
    "requestId": "…"
  }
}
```

**Frontend reaction today:** no code catches or branches on 409 anywhere.
The one related concept in the codebase is `AdminCategoriesGateway
.isSlugTaken(slug, excludingId)` (BACKEND-AUDIT.md §14) — a **proactive**
pre-check call the frontend makes *before* submitting, not a reaction to a
409 conflict response. **Requires backend decision**: whether create/update
endpoints should also return 409 on the same slug/uniqueness conflict as a
race-condition backstop, and whether the frontend should add a 409 handler
that surfaces `error.details` inline (there is no such handler today —
`isSlugTaken` is the only existing conflict-avoidance mechanism, and it is
best-effort/TOCTOU-prone).

#### 422 — Validation failure

```json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "One or more fields are invalid.",
    "status": 422,
    "requestId": "…",
    "details": [
      { "field": "sku", "code": "REQUIRED", "message": "SKU is required." },
      { "field": "price", "code": "OUT_OF_RANGE", "message": "Price must be greater than 0." }
    ]
  }
}
```

**Frontend reaction today:** no admin form currently parses a backend
validation-error body — all admin CRUD is local/mock (BACKEND-AUDIT.md §14),
so there has never been a real 422 to react to. The frontend **does** have
an established field-error UI convention worth preserving: `ProjectEditorFacade
.fieldError(fieldKey): string | null`
(`src/app/features/project-editor/facade/project-editor.facade.ts:371-374`)
reads from `issuesByField` (a `Map<fieldKey, ProjectValidationIssue[]>`) and
returns the first issue's `message`, for inline per-field template binding.
That mechanism is entirely client-side validation today (`ProjectValidator`
service), not backend-driven. **Requires backend decision**: adopting
`details[].field` as the join key would let a future `fieldError()`-style
adapter merge backend 422 errors into the same inline-error UI pattern
without inventing a second one — but the adapter itself does not exist yet
and would need to be built.

#### 429 — Rate limited

```json
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Please slow down.",
    "status": 429,
    "requestId": "…",
    "retryAfterSeconds": 30
  }
}
```

**Frontend reaction today: none whatsoever.** No interceptor, facade, or
component in the codebase references `429` or "rate limit" in any form (grepped
across `src/`). **Requires backend decision** on every aspect:
- Whether the backend sends a `Retry-After` HTTP header, a body field
  (`retryAfterSeconds` above), or both.
- Whether the frontend should retry automatically (with backoff) or only
  show the user a "please wait Ns" message. Recommend: since no retry
  interceptor exists today, add one is a new build item, not a config
  change.

#### 500 — Server error

```json
{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "An unexpected error occurred. Please try again.",
    "status": 500,
    "requestId": "…"
  }
}
```

**Frontend reaction today:** falls into whichever generic catch-all a given
caller has:
- Ed25519 admin flow: `authErrorCodeFromStatus()` default branch → `status
  >= 500 ? 'backend-unavailable' : 'unauthorized'` → same
  "Backend unavailable… Retry" screen as a network-down 503 (see below) —
  the frontend does not distinguish "server is up but this request 500'd"
  from "server is completely unreachable."
- Admin list pages (`AdminUsersFacade` and siblings): generic `error`
  boolean signal set to `true` in the RxJS `error:` callback, rendering
  `common.errorTitle`/`common.errorDescription` + a retry button that
  re-invokes the same load call. No status differentiation.
- Storefront catalog/product pages: same generic empty-state pattern as 404
  above.
- `LocationService.getRegions()`-equivalent: falls back silently to 6
  hardcoded regions on *any* error (including 500), no user-visible error at
  all (`src/app/services/location.service.ts`).

#### 503 — Maintenance / unavailable

```json
{
  "error": {
    "code": "SERVICE_UNAVAILABLE",
    "message": "The service is temporarily unavailable. Please try again shortly.",
    "status": 503,
    "requestId": "…"
  }
}
```

**Frontend reaction today:** Ed25519 admin flow only, via the same
`status >= 500` branch as 500 above → `'backend-unavailable'` →
`AuthErrorPageComponent` "Backend unavailable… Retry." No other code path
reacts to 503 specifically today (marketplace API calls that 503 would just
fall into each caller's generic error handling, same as 500 above).

**Distinguishing signal from Maintenance mode (see next section):** use
`error.code`, not the HTTP status. A plain infra 503 (database down,
overload) should send `"code": "SERVICE_UNAVAILABLE"`; a deliberate
maintenance window should send `"code": "MAINTENANCE_MODE"` (still with HTTP
status 503, since it's a byte-identical "the service is not accepting
requests" situation, but a different reason). This is the only way for the
frontend to build a distinct maintenance-mode UX later, since status alone
is not enough. §10 (Maintenance Mode) owns the UX/copy for the maintenance
case — this section only fixes the wire signal it must key off
(`error.code === "MAINTENANCE_MODE"`), consistent with §10 without
duplicating UX detail here.

#### Maintenance mode

Same HTTP status as above (503), distinguished purely by `error.code`:

```json
{
  "error": {
    "code": "MAINTENANCE_MODE",
    "message": "This marketplace is temporarily down for maintenance.",
    "status": 503,
    "requestId": "…",
    "maintenanceUntil": "2026-07-26T04:00:00Z"
  }
}
```

`maintenanceUntil` (ISO 8601, optional) lets the maintenance-mode UX (sibling
doc) show an ETA if the backend has one. **Requires backend decision:**
whether `maintenanceUntil` is populated reliably enough to promise in UI, or
should be treated as advisory-only.

**Frontend reaction today:** none — no maintenance-mode concept exists in
the frontend at all currently (confirmed: no matches for "maintenance" in
`src/`). This entire row is new; the sibling `§10 (Maintenance Mode, this document)` task
should treat it as building from scratch, not preserving anything.

#### Tenant disabled

```json
{
  "error": {
    "code": "TENANT_DISABLED",
    "message": "This marketplace is not currently active.",
    "status": 403,
    "requestId": "…"
  }
}
```

**Frontend reaction today: none.** Tenant resolution
(`TenantResolverService`, `src/app/core/config/tenant-resolver.service.ts`)
only ever resolves *which* tenant a request targets (via host/subdomain); no
code path in the audited surface handles a backend telling the frontend
"this tenant exists but is disabled." **Requires backend decision** end to
end: status code (403 recommended, to reuse the existing `forbidden`
auth-error screen plumbing, vs. a dedicated status), and whether this should
route to a dedicated "tenant disabled" screen or reuse
`AuthErrorPageComponent`'s `forbidden` copy (which currently says "Your
account role does not have permission" — wrong wording for a
tenant-disabled scenario, would need a new `AuthErrorCode` entry and copy if
reused).

#### Rate limit

See **429** above — same contract, called out separately here only because
the task list asked for it as its own row. No additional distinguishing
signal needed beyond the 429 status + `RATE_LIMITED` code.

#### Expired token

```json
{
  "error": {
    "code": "TOKEN_EXPIRED",
    "message": "Your session has expired. Please sign in again.",
    "status": 401,
    "requestId": "…"
  }
}
```

**Frontend reaction today — known gap, read carefully:** `AuthService
.refresh()` (`src/app/core/auth/services/auth.service.ts:65-76`) has a
client-side `AuthErrorCode` value `'session-expired'` and passes it as
`fallbackCode` into `handleAuthError()`. **However**, `toAuthErrorShape()`
(lines 110-118) only uses `fallbackCode` when the caught error is **not** an
`HttpErrorResponse` — for an actual HTTP error it always calls
`authErrorCodeFromStatus(error.status)`, which maps 401 → `'unauthorized'`,
never `'session-expired'`, regardless of `fallbackCode`. So today, a real
backend 401 on `/refresh` renders the **generic "Unauthorized" screen**, not
"Session expired" — the "Session expired" screen is only ever reached via
the *no-refresh-token-present* client-side branch (line 67-70), never from a
real HTTP response. **Requires backend decision + frontend fix**: for a
distinct "your session expired, please sign in again" screen to actually
render on a real backend 401, either (a) the backend returns a body
`error.code: "TOKEN_EXPIRED"` and the frontend is updated to read it instead
of relying solely on `authErrorCodeFromStatus(status)`, or (b) this
distinction is accepted as unreachable today and left as future work. Flag
this gap explicitly to whoever picks up the fix — it is a pre-existing bug,
not something this contract can silently paper over.

#### Invalid signature

```json
{
  "error": {
    "code": "INVALID_SIGNATURE",
    "message": "The signed challenge could not be verified.",
    "status": 401,
    "requestId": "…"
  }
}
```

Ties to the Ed25519 admin-auth flow documented in §4 — keep the `code`
value (`INVALID_SIGNATURE`) consistent with what §4 names the failure mode;
this contract only defines the wire shape, §4 owns the auth-flow narrative.

**Frontend reaction today:** same gap as "Expired token" above.
`AuthService.login()` passes `fallbackCode: 'invalid-signature'` into
`handleAuthError()`, but `toAuthErrorShape()` discards it for any real
`HttpErrorResponse` and maps a 401 from `/verify` to the generic
`'unauthorized'` screen via `authErrorCodeFromStatus()`. The dedicated
"Invalid signature… Try again" screen
(`src/app/core/auth/pages/auth-error-page.component.ts:21-25`) exists in the
copy table but is **currently unreachable from a real backend response** for
the same reason as `session-expired` above. **Requires backend decision +
frontend fix**: backend must send a body-level `error.code:
"INVALID_SIGNATURE"` and the frontend's `toAuthErrorShape()` must be updated
to prefer a body code over the status-only mapping, or this screen stays
dead code reachable only via non-HTTP error paths.

---

### Generic list-page error UI (for reference)

Every admin backoffice list page (`AdminUsersFacade`, `AdminOrdersFacade`,
`AdminMonitoringFacade`, `AdminModerationFacade`, `AdminTransactionsFacade`,
`AdminProductsFacade`, `AdminCategoriesFacade`, `AdminAnalyticsFacade`,
`AdminCustomersFacade`, `AdminDashboardFacade`) follows the same shape,
added by RC-02 (`e153a67 fix(backoffice): add error+retry states to Users,
Monitoring, Analytics, Reports`):

```ts
readonly error = signal(false);
// on load:
error: () => { this.items.set([]); this.loading.set(false); this.error.set(true); }
```

```html
@else if (facade.error()) {
  <app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
    <span slot="actions">
      <app-button variant="primary" (click)="facade.loadAll()">{{ 'common.retry' | translate }}</app-button>
    </span>
  </app-empty-state>
}
```

This is a **boolean** error flag — it does not branch on HTTP status or
`error.code` today. Every status in this contract (401/403/404/409/422/429/
500/503) would currently collapse into the same generic "Something went
wrong / retry" UI on these pages **unless** the facades are individually
updated to inspect `error.code`/`status` and branch — none do today. Wiring
that up is out of scope for this document (it defines the wire contract);
flagging it here so whoever wires real backends into these facades knows
the current ceiling of frontend error UX is "generic retry," not
per-status handling, except in the Ed25519 admin-auth module.

---

### Summary: "Requires backend decision" items

Each has a **Recommended default** — apply it unless flagged otherwise.

- **Envelope adoption** — the `{ error: { code, message, status, requestId,
  details? } }` shape itself; no frontend code parses any envelope today.
  **Recommended default:** adopt it as specified — every status-by-status
  example in this section already uses it; it's the shape to build against.
- **401 on customer-facing marketplace calls** (`/cart`, `/orders`, etc.) —
  no unified "session expired, please re-auth" UX exists for the customer
  Telegram-session flow.
  **Recommended default:** return the standard envelope with
  `code: "UNAUTHORIZED"`; the frontend re-auth UX is a follow-up frontend
  task once the backend consistently returns this, not something to block
  the backend on.
- **403 tenant-mismatch vs role-mismatch** distinct copy/code.
  **Recommended default:** two distinct codes (`TENANT_MISMATCH`,
  `FORBIDDEN`) — the frontend can pick copy per-code once they exist;
  collapsing them into one code loses information for no benefit.
- **404 vs generic-error distinct UX** on catalog/product pages (currently
  identical).
  **Recommended default:** return real `404` for missing resources (not a
  generic `500`) — this is a backend correctness item, not a decision;
  the frontend UX unification is a separate, non-blocking follow-up.
- **409 conflict handling on submit** (today only a proactive
  `isSlugTaken` pre-check exists; no reactive 409 handler).
  **Recommended default:** return `409` with the standard envelope on any
  uniqueness/concurrency conflict; treat the proactive pre-check as
  best-effort only (§3.21 item 5 — TOCTOU-prone by nature).
- **422 `details[]` → inline field-error adapter** for admin forms (the
  client-side `fieldError()` convention exists but nothing feeds it from a
  backend response yet).
  **Recommended default:** populate `details[]` as `{ field, message }` per
  invalid field on every `422` — the client-side adapter already expects
  this shape, it just has nothing to consume yet.
- **429 rate-limit contract end to end** — header vs body, retry-after
  value, and whether the frontend auto-retries (nothing exists today).
  **Recommended default:** standard `Retry-After` header (seconds) +
  envelope body with `code: "RATE_LIMITED"`; no frontend auto-retry (the
  user retries manually) — simplest safe default, matches how the rest of
  the app already treats errors as terminal-until-user-action.
- **Maintenance-mode `maintenanceUntil` reliability** — advisory only, or
  can the frontend promise an ETA.
  **Recommended default:** advisory only — word the frontend copy as
  "expected back around X" not a guarantee, so an inaccurate estimate
  doesn't need special-case backend handling.
- **Tenant-disabled status code and screen** — reuse `forbidden` copy (wrong
  wording today) vs. add a dedicated `AuthErrorCode`.
  **Recommended default:** dedicated `code: "TENANT_DISABLED"` (already
  named consistently elsewhere in this doc, §1.12) rather than overloading
  `forbidden` — one wrong-wording bug is enough of a reason not to add a
  second overload.
- **Expired-token / invalid-signature body-code fix** — both are
  **pre-existing frontend bugs**, not just missing decisions:
  `toAuthErrorShape()` in `auth.service.ts` currently derives the error code
  from HTTP status only and ignores the `fallbackCode` for real HTTP errors,
  so the `'session-expired'` and `'invalid-signature'` screens are dead code
  from any real backend response today. Fixing this requires both a backend
  body `error.code` and a frontend change to prefer it.
  **Recommended default:** backend returns `code: "TOKEN_EXPIRED"` /
  `code: "INVALID_SIGNATURE"` on the relevant `401`s; frontend fix (prefer
  body code over status-derived fallback) is tracked separately in
  `docs/KNOWN-ISSUES.md` and is not blocking backend work — the backend
  side of this can and should ship regardless of when the frontend fix
  lands.

---

## 7. Uploads

Full media-upload lifecycle contract, derived from the actual media stack:
`MediaRepository` (abstract token), `MockMediaRepository` (the only impl),
`MediaLibraryFacade`, `MediaPickerComponent`, `MediaUsageService`, and the
`MediaAsset` model. **Structural status:** media is MOCK-SWAPPABLE — bound via
`{ provide: MediaRepository, useClass: MockMediaRepository }` in `app.config.ts`,
so a real HTTP repository drops in behind the same abstract class with no UI
change. **No real HTTP media backend exists yet**, and **no literal media
endpoint path exists anywhere in code** — every path below is **PROPOSED** to
satisfy the existing `MediaRepository` method shapes. The admin-auth interceptor
already attaches `AdminWebSessionID` + `Authorization: Bearer` to any `/media/`
URL (`docs/context/BACKEND-AUDIT.md` §3), so the backend can expect those headers.

### 7.1 The `MediaAsset` contract (real interface)

`src/app/core/media/models/media-asset.model.ts`:

```ts
export type MediaAssetKind = 'image' | 'svg' | 'pdf' | 'other';

export interface MediaAsset {
  id: string;
  url: string;
  thumbnailUrl?: string;
  filename: string;
  mimeType: string;
  size: number;             // bytes
  width?: number;
  height?: number;
  altText?: Record<string, string>;  // locale -> alt text
  caption?: string;         // plain, non-localized
  description?: string;
  decorative?: boolean;     // suppresses missing-alt-text warning
  tags?: string[];
  folder?: string;
  createdAt: string;        // ISO 8601
}

export interface MediaListResult { items: MediaAsset[]; total: number; }
export interface MediaListParams {
  page?: number; pageSize?: number; search?: string;
  folder?: string; tag?: string; kind?: MediaAssetKind; sort?: MediaSort;
}
export type MediaSort = 'recent' | 'name' | 'size';
export interface MediaUploadOptions { folder?: string; tags?: string[]; }
```

The abstract `MediaRepository` (the exact backend contract) is:

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

### 7.2 Media upload

- **Method / path (PROPOSED):** `POST /media` (or `/media/upload`).
- **Content type:** `multipart/form-data` — the frontend holds a raw `File`
  (`MediaLibraryFacade.upload(file, options)` → `repository.upload(file,
  { folder, ...options })`). The natural wire form is a multipart body with the
  binary file plus `folder` and `tags[]` fields from `MediaUploadOptions`.
- **Headers:** `AdminWebSessionID` + `Authorization: Bearer <token>` (auto-added
  for `/media/` by `adminAuthHeadersInterceptor`), plus multipart boundary.
- **Request:** the file blob + optional `folder: string` and `tags: string[]`.
  The current folder is injected by the facade when none is passed.
- **Response:** a single fully-populated `MediaAsset` (§7.1). The backend
  assigns `id`, canonical `url`, `thumbnailUrl`, `size` (post-processing),
  `width`/`height`, and `createdAt`.
- **Client pre-processing to be aware of:** the mock downscales images to
  2000 px and re-encodes at quality 0.85 before "upload." A real backend
  receives whatever the browser sends; it should **not** assume the client
  compressed anything (§5.11) and must do its own validation and any canonical
  resizing.
- **Retry:** the facade keeps the last failed `File` and exposes
  `retryUpload()` / `canRetry()`, and `cancelUpload()` uses an upload-token
  guard to ignore a stale in-flight result. So the backend should make upload
  **idempotent-safe** on retry (a retried upload after a client-perceived
  failure may duplicate) — **Requires backend decision** on dedup strategy.

### 7.3 Replace

**No distinct "replace" operation exists.** `MediaRepository` has no `replace`
method; `update()` only patches metadata (`altText, tags, folder, caption,
description, decorative`), never the binary. Changing an asset's actual file =
`remove(id)` + `upload(newFile)`, producing a **new `id` and new `url`**.
**Backend implication:** references to the old asset URL (tracked by
`MediaUsageService`, §7.5) are **not** auto-updated on replace — a true
in-place replace that preserves `id`/`url` would be new frontend+backend work.
**Requires backend decision** if in-place replace is wanted.

### 7.4 Delete (single + bulk)

- **Single:** `remove(id): Promise<void>` → **PROPOSED** `DELETE /media/{id}`.
  `MediaLibraryFacade.remove(id)` also deselects and reloads.
- **Bulk:** `MediaLibraryFacade.bulkDelete(ids[])` loops `repository.remove(id)`
  **one call per id** (no batch endpoint) — so bulk delete today is N ×
  `DELETE /media/{id}`. The confirm-dialog / selection flow (RC-02) gates this
  in the UI; the backend just sees serial deletes. **Requires backend decision:**
  whether to add a batch `DELETE /media` (body `{ ids: [] }`) to avoid N
  round-trips — the facade would need a matching `bulkRemove` method (not
  present today).
- There is also `bulkMoveToFolder(ids, folder)` = N × `update(id, { folder })`.
- **Hard vs soft delete: Requires backend decision** — the frontend calls
  `remove` and expects the asset gone from `list()`; it has no restore/trash
  concept for media (unlike admin orders/categories which do have
  archive/restore).

### 7.5 Metadata

`MediaAsset` carries this metadata (all editable via `update()` except the
system-assigned fields):

| Field | Editable by frontend? | Notes |
|---|---|---|
| `altText: Record<string,string>` | yes | **Per-locale** alt text (locale key → string). |
| `caption` | yes | Plain, non-localized. |
| `description` | yes | Plain. |
| `decorative: boolean` | yes | Suppresses the missing-alt warning. |
| `tags: string[]` | yes | Free-form; drives the tag filter. |
| `folder: string` | yes | Single folder; `listFolders()` derives the folder list. |
| `width` / `height` | no (system) | Set from image dimensions at upload. |
| `size` | no (system) | Bytes, post-processing. |
| `mimeType` | no (system) | |
| `filename` | no (system) | Original filename. |
| `createdAt` | no (system) | ISO 8601 upload timestamp. |

There is **no `uploader`/`updatedAt`/usage-count field** on `MediaAsset`.
Usage is computed **client-side on demand** by `MediaUsageService.findUsages(bootstrap, url)`
(`src/app/core/media/media-usage.service.ts`) — it walks the in-memory
`BootstrapConfig` looking for the asset's `url` as a string value and reports
where it is referenced (branding, theme, header, footer, static pages, etc.).
So "usage tracking" is a read-time scan of the config, **not** a stored
reverse-index. **Requires backend decision:** whether the backend should track
uploader identity and a stored usage/reference count (the frontend does not
require either today, but `metadata`-export and audit (§5.14) would benefit).
**Metadata update endpoint (PROPOSED):** `PATCH /media/{id}` with the
`update()` patch body, returning the updated `MediaAsset`.

### 7.6 Image variants

The frontend **does not use responsive image variants**. Zero `srcset`/`sizes`
usage in Angular templates (the only `srcset`-ish hit is unrelated). Images are
consumed as a **single `url`**, with an optional single `thumbnailUrl`. In the
mock, `thumbnailUrl === url` (same object URL) — there is no distinct thumbnail
rendition today. **Backend implication:** the contract is single-`url` +
optional single-`thumbnailUrl`; the frontend will happily consume a
backend-generated thumbnail if `thumbnailUrl` differs from `url`, but it never
requests a specific size or a `srcset` set. Adding true responsive variants
would be new frontend work.

### 7.7 Thumbnail generation

**Requires backend decision** on strategy — nothing in the frontend prescribes
sizes or formats. But the frontend gives a concrete **display target** so the
backend has a real number to aim at:

- The media library / picker renders a **grid** whose cells are ~150 px wide
  (`MediaPickerComponent.onGridKeydown` computes columns as
  `clientWidth / 150`), page size **24** items.
- A `thumbnailUrl` sized around **~150–300 px** (accounting for 2× DPI) would
  serve the grid without shipping full-resolution originals.

If the backend populates `thumbnailUrl` with such a rendition, the UI uses it
automatically (falls back to `url` when absent). No other thumbnail sizes are
consumed anywhere.

### 7.8 Limits

- **Per-file size:** 10 MB, client-enforced (§5.12) — the backend must
  re-enforce (§5.11).
- **MIME allowlist:** the 6 types in §5.13.
- **Upload count / batch size:** no client cap — `bulkDelete`/`bulkMoveToFolder`
  loop over arbitrary-length id arrays; single-file upload only (the picker's
  `<input>` takes `files?.[0]`, so **one file per upload call**).
- **Storage quota (per tenant / per account):** none client-side —
  **Requires backend decision**.
- **Pagination:** `pageSize` default **24** (`DEFAULT_PAGE_SIZE`/`PAGE_SIZE`);
  `list()` returns `{ items, total }` so the backend must return an accurate
  `total` for the pager.

### 7.9 Future S3 compatibility

The `MediaAsset`/`MediaRepository` contract is deliberately **storage-agnostic**
and this is a **design constraint for the backend, not a frontend requirement**:
the frontend only ever sees opaque `url` / `thumbnailUrl` strings and an opaque
`id`. It never constructs storage paths, never assumes a host, and never signs
or negotiates storage directly. Therefore the backend is free to back media
with S3, a CDN, local disk, or anything else — including returning
pre-signed/CDN URLs in `url`/`thumbnailUrl` — with **no frontend change**, as
long as:

- `upload()` still returns a complete `MediaAsset` synchronously with a usable
  `url` (a direct-to-S3 pre-signed **upload** flow, where the client PUTs to S3
  itself, is **not** what the current `repository.upload(file)` shape expects —
  it passes the `File` to the repository and awaits the finished asset;
  switching to browser-direct-to-S3 would be new frontend work). **Requires
  backend decision** if a pre-signed direct-upload flow is desired.
- Served asset URLs are treated as untrusted-origin content (§5.11) —
  isolated origin, `nosniff`, correct `Content-Type`/`Content-Disposition`.

---

## 8. Real Backend Implementation Guide

This section is the practical, top-to-bottom guide a backend engineer follows to replace the
frontend's mock/local data layer with real endpoints. It is derived entirely from
`docs/context/BACKEND-AUDIT.md` (the exhaustive source audit) — no business logic is invented
beyond what the frontend gateway interfaces already fix. Where the frontend has no seam or no
mapper today, that gap is stated explicitly rather than papered over.

The core principle, already proven for Categories, is: **the gateway/provider _interface_ is the
contract; a real implementation only has to satisfy that interface.** You never rewrite the
interface, and — if the domain already has a DI-token seam — you never touch the facade or the
UI. The only structural surprise, called out repeatedly below, is that **most admin domains do
not have that seam yet** (audit §1, §14, §25 #1): their facade injects the concrete
`Admin*LocalGateway` class directly, so a token must be introduced before a real gateway can be
bound at all.

### 8.1 Which frontend gateways must be replaced

Every data boundary the audit found, with its swap status. "DI token status" is the single most
important column — it tells you whether you can drop in a real class (`already token-bound`) or
must first introduce a seam (`needs token added first`).

| Gateway / provider interface | Current mock/local impl file | DI token status | Real impl class to write | Consuming facade(s) |
|---|---|---|---|---|
| `ConfigProvider` | `core/bootstrap/providers/mock-bootstrap.provider.ts` | already token-bound (`CONFIG_PROVIDER`) | **already exists & LIVE** — `ApiBootstrapProvider` (`GET /bootstrap`) | UiRuntime, WebsiteRuntime, ProjectEditor, ContentManagement, Diagnostics (via `ConfigService`) |
| `ProductDataProvider` | none bound (mock via `mockDataInterceptor` only) | already token-bound (`PRODUCT_DATA_PROVIDER`) | **already exists & LIVE** — `ApiProductDataProvider` | `ProductFacade` |
| `CategoryRepository` | none bound (mock via interceptor) | already token-bound (`CATEGORY_REPOSITORY`) | **already exists & LIVE** — `ApiCategoryRepository` (`GET /category`) | `CategoryFacade` |
| `BackofficeDataProvider` | `core/backoffice/providers/mock-backoffice-data.provider.ts` | already token-bound (`BACKOFFICE_DATA_PROVIDER`) | **already exists & LIVE** — `ApiBackofficeDataProvider` (`/api/backoffice/*`) | storefront cards |
| `AdminCategoriesGateway` | `features/admin/categories/services/admin-categories-local.gateway.ts` | already token-bound (`ADMIN_CATEGORIES_GATEWAY`) | **already exists & LIVE** — `AdminCategoriesApiGateway` | `AdminCategoriesFacade`, `AdminAnalyticsFacade` |
| `AdminDashboardMetricsGateway` | `features/admin/dashboard/services/admin-dashboard-metrics.local.gateway.ts` | already token-bound (`ADMIN_DASHBOARD_METRICS_GATEWAY`) | `AdminDashboardMetricsApiGateway` (new) | `AdminDashboardFacade` |
| `MediaRepository` (abstract class) | `core/media/mock-media-repository.service.ts` | already token-bound (abstract-class token in `app.config.ts`) | `ApiMediaRepository` (new) | `MediaLibraryFacade` |
| `UserExperienceRepository` | `core/user-experience/repositories/local-user-experience.repository.ts` | already token-bound (`USER_EXPERIENCE_REPOSITORY`) | `AuthenticatedUserExperienceRepository` (new; see §8.3 note on redesign) | `UserExperienceFacade` |
| `AdminOrdersGateway` | `features/admin/orders/services/admin-orders-local.gateway.ts` | **needs token added first** | `AdminOrdersApiGateway` (new) | `AdminOrdersFacade`, `AdminCustomersFacade`, `AdminAnalyticsFacade` |
| `AdminProductsGateway` | `features/admin/products/services/admin-products-local.gateway.ts` | **needs token added first** | `AdminProductsApiGateway` (new) | `AdminProductsFacade`, `AdminAnalyticsFacade` |
| `AdminUsersGateway` | `features/admin/users/services/admin-users-local.gateway.ts` | **needs token added first** | `AdminUsersApiGateway` (new) | `AdminUsersFacade` |
| `AdminTransactionsGateway` | `features/admin/transactions/services/admin-transactions-local.gateway.ts` | **needs token added first** | `AdminTransactionsApiGateway` (new) | `AdminTransactionsFacade` |
| `AdminMonitoringGateway` | `features/admin/monitoring/services/admin-monitoring-local.gateway.ts` | **needs token added first** | `AdminMonitoringApiGateway` (new) | `AdminMonitoringFacade` |
| `AdminModerationGateway` | `features/admin/moderation/services/admin-moderation-local.gateway.ts` | **needs token added first** | `AdminModerationApiGateway` (new) | `AdminModerationFacade`, `AdminAnalyticsFacade` |
| (Customers) | reuses `AdminOrdersLocalGateway` (derived) | **needs token added first** (inherits Orders seam) | none of its own — derive from `AdminOrdersApiGateway`, or add a `/customers` source | `AdminCustomersFacade` |
| (Analytics) | reuses orders/products/moderation local + `ADMIN_CATEGORIES_GATEWAY` + `AdminDashboardFacade` | partial (categories/dashboard token-bound; rest not) | no aggregation endpoint exists — see §8.3 | `AdminAnalyticsFacade` |
| Auth session (`TelegramSessionApiService`) | mock via `mockDataInterceptor` | n/a (concrete service, LIVE) | **already exists & LIVE** — no swap; implement server endpoints only | `AuthService`, `AdminAuthService`, `AuthFacade` |
| Ed25519 admin auth (`AuthApiService`) | none | n/a (concrete service, LIVE wiring) | **client wiring exists; backend absent** — implement `/api/admin/auth/*` server-side | `AuthService` (Ed25519 flow) |
| `SearchHistoryRepository` | localStorage impl (concrete) | injected concretely — LOCAL-ONLY by design | — (leave local; no backend) | `SearchFacade` (via `SearchHistoryService`) |

Read this table as three tiers:

1. **Done / LIVE (no gateway work):** ConfigProvider, ProductDataProvider, CategoryRepository,
   BackofficeDataProvider, AdminCategories. These already talk to real HTTP through a real
   `*Api*` class. The backend job here is only to _stand up the server endpoints_ these clients
   already call — not to touch frontend code.
2. **Token-bound, real impl missing:** DashboardMetrics, Media, UserExperience. The seam exists;
   write the `*Api*` class and bind it. No facade edit.
3. **No seam yet (the bulk of admin CRUD):** Orders, Products, Users, Transactions, Monitoring,
   Moderation (plus derived Customers/Analytics). Each needs a token introduced _and_ a facade
   injection change _before_ a real gateway can even be bound.

### 8.2 Interfaces that stay unchanged (do not touch)

The gateway/provider interface files _are_ the frozen seam. A real implementation satisfies the
existing TypeScript interface; changing an interface is a frontend contract change and forces UI
churn. **Do not edit any of these** while wiring a backend:

- `core/config/config-provider.interface.ts` (`ConfigProvider`)
- `core/products/providers/product-data-provider.interface.ts` (`ProductDataProvider`)
- `core/categories/repositories/category.repository.ts` (`CategoryRepository`)
- `core/backoffice/providers/backoffice-data-provider.interface.ts` (`BackofficeDataProvider`)
- `core/media/media-repository.ts` (abstract class `MediaRepository`)
- `core/user-experience/repositories/user-experience.repository.ts` (`UserExperienceRepository`)
- `features/admin/categories/services/admin-categories-gateway.interface.ts` (`AdminCategoriesGateway`)
- `features/admin/dashboard/services/admin-dashboard-metrics.gateway.interface.ts` (`AdminDashboardMetricsGateway`)
- `features/admin/orders/services/admin-orders-gateway.interface.ts` (`AdminOrdersGateway`)
- `features/admin/products/services/admin-products-gateway.interface.ts` (`AdminProductsGateway`)
- `features/admin/users/services/admin-users-gateway.interface.ts` (`AdminUsersGateway`)
- `features/admin/transactions/services/admin-transactions-gateway.interface.ts` (`AdminTransactionsGateway`)
- `features/admin/monitoring/services/admin-monitoring-gateway.interface.ts` (`AdminMonitoringGateway`)
- `features/admin/moderation/services/admin-moderation-gateway.interface.ts` (`AdminModerationGateway`)

The interface method lists (the shapes the backend must satisfy) are enumerated in audit §14 and
must not drift. If the backend genuinely cannot meet a method's shape, that is a conversation to
have with the frontend owner and change the interface deliberately — not something to work around
inside a mapper.

### 8.3 Facades: which stay unchanged, which need a small adjustment

The facade is the abstraction the components consume. **If a domain already has a DI-token seam
and the real gateway honors the interface, its facade needs zero changes.** That covers all of
tier 1 and tier 2 above (ConfigProvider/Product/Category/Backoffice/AdminCategories/Dashboard/
Media/UserExperience facades are all injection-by-token already).

Facades that **do** need a (small, mechanical) change:

- **`AdminOrdersFacade`, `AdminProductsFacade`, `AdminUsersFacade`, `AdminTransactionsFacade`,
  `AdminMonitoringFacade`, `AdminModerationFacade`** — each currently injects the concrete
  `Admin*LocalGateway` class directly (audit §14, §21). The one-line change is to inject the new
  DI token instead of the concrete class. This is unavoidable for these six because the seam does
  not exist yet; it is the "add a token seam" half of the migration, not a behavior change.

Facades whose current behavior is **mock-shaped** and needs more than a wiring swap:

- **`AdminCustomersFacade`** (`features/admin/customers/facade/…`) derives its customer list by
  reading `AdminOrdersLocalGateway` in memory (audit §14, §21). Once orders are real, it can keep
  deriving customers from the Orders _token_, but if the backend exposes a first-class customers
  source, prefer that. Either way the "derive from local orders" assumption is mock-specific and
  should be revisited when Orders goes real.
- **`AdminAnalyticsFacade`** (`features/admin/analytics/facade/…`) composes orders + products +
  moderation local gateways plus the categories/dashboard tokens (audit §14, §21). There is **no
  analytics aggregation endpoint and no tracking pipeline today** (audit §14; remaining-work
  #13/#17). Real analytics is not a gateway swap — it needs a data source that does not exist yet.
  Treat it as the last domain, dependent on orders/products/moderation all being real first.
- **`UserExperienceFacade`** (`facades/platform/user-experience.facade.ts`) stores fully
  denormalized wishlist/compare objects synchronously in localStorage (audit §19). The backend
  decision (remaining-work #16) is id-only sync + a `GET /items/batch?ids=` hydration endpoint,
  which requires redesigning the repository to store id-arrays and hydrate from a local product
  cache — a real refactor, not a drop-in `*Api*` class. Flagged so nobody assumes the token swap
  alone finishes wishlist sync.
- **`ContentManagementFacade`** and **`ProjectEditorFacade`** have **no backend call at all**
  today (audit §16, §17). They read/mutate the in-memory `BootstrapConfig` and persist drafts to
  localStorage; "publishing a marketplace" = writing bootstrap back, for which **no client write
  call exists**. Wiring these to a real backend means _adding_ a draft/publish HTTP path
  (`ProjectEditorIoService` currently only does JSON.stringify/parse; drafts live in
  `ProjectEditorDraftStorageService` / localStorage), not swapping a gateway.
- **Cart** (`CartService`) is local-only by design (localStorage `marketplace_cart`); checkout
  already emits real `POST /cart` / `POST /orders` calls (audit §10). No facade change — just be
  aware cart _contents_ never round-trip to a backend.

Also note the mock gateways commonly apply an **artificial delay** and keep state in
localStorage/in-memory overlays (audit §14). A real gateway should drop the fake latency; the
facades tolerate real async already (they're signal/Observable-based), so no facade edit is needed
for that specifically.

### 8.4 DTO mapping examples (worked)

The frontend already carries adapters for its LIVE domains; a backend engineer should treat those
adapters as the **tolerance contract** for the wire shape. Three representative cases:

#### Example A — Categories (clean stack, mapper already exists)

Real backend JSON (`GET /category`, shape the client already tolerates):

```json
[
  {
    "categoryID": 12,
    "names": [ { "lang": "ru", "name": "Электроника" }, { "lang": "en", "name": "Electronics" } ],
    "subcategories": [ { "categoryID": 34, "names": [ { "lang": "en", "name": "Phones" } ] } ]
  }
]
```

Frontend wire DTO: `CategoryDto`, `CategoryNameDto` (`core/categories/dto/category.dto.ts`).
Domain model: `Category`, `CategoryTranslation` (`core/categories/models/category-domain.model.ts`).
Bridge: **`CategoryMapper`** (`core/categories/mappers/category.mapper.ts`) — flattens the
subcategory tree, dedupes by id, and normalizes the language code `am → hy`. **Already written.**
Backend action: match the `CategoryDto` field names; no new mapper.

#### Example B — Products (largest existing mapper; match its tolerance)

Real backend JSON (`GET /items/{id}`) → frontend `Item` (`src/app/models/item.model.ts`).
Bridge: **`ApiService.normalizeItem()`** — the single largest inline adapter in the codebase
(audit §7). It reconciles two historical shapes and normalizes, among others:

- `id` (string) ↔ `itemID` (numeric)
- `imgs[]` ↔ `photos[]` (→ `ProductMedia`)
- `names[]` ↔ `translations`
- `description` as key/value array ↔ string
- `comments` ↔ `callbacks` (reviews)
- color `0xRRGGBB` → `#RRGGBB`; `remaining` count → stock band

Backend action: **do not "clean up" these dual shapes** — `normalizeItem`/`normalizeCategory`
define exactly what the client accepts. Producing a payload inside that tolerance envelope is the
contract; a stricter/renamed shape breaks the storefront. No new mapper needed — the adapter
exists and is LIVE.

#### Example C — Admin Orders (no mapper exists yet — one must be written)

Unlike A and B, the admin domains have **no wire DTO and no mapper** — the local gateways
construct `AdminOrder` view models directly in memory and never touch a URL (audit §14, §24).
So this is the shape you have _freedom_ to design on the backend, as long as the new
`AdminOrdersApiGateway` maps it into the existing view model.

Proposed backend JSON (`GET /backoffice/orders/:id`):

```json
{
  "id": "ord_1042",
  "status": "processing",
  "paymentStatus": "paid",
  "customer": { "id": "cus_88", "name": "…", "email": "…" },
  "items": [ { "productId": "…", "title": "…", "qty": 2, "unitPrice": 1990 } ],
  "shipping": { "method": "…", "address": "…" },
  "timeline": [ { "event": "created", "at": "2026-07-01T10:00:00Z" } ]
}
```

Frontend view model: `AdminOrder` + `AdminOrderCustomer`, `AdminOrderPayment`,
`AdminOrderShipping`, `AdminOrderItem`, `AdminOrderTimelineEntry`, `AdminOrderStatus`,
`AdminOrderPaymentStatus` (`features/admin/orders/models/admin-order.model.ts`, audit §23).
Bridge: **a new `AdminOrdersApiGateway` must contain the mapping** JSON → `AdminOrder`, honoring
the `AdminOrdersGateway` interface methods (`loadOrders`, `loadOrder`, `updateStatus`,
`requestRefund`, `addNote`, `archiveOrder`, `restoreOrder`, `deleteOrder`). The `status` field
must respect the order state machine (see the Orders CRUD contract / archive/BACKEND_API.md §8.1).
The same "no mapper exists, write one inside the new `*ApiGateway`" note applies to Products,
Users, Transactions, Monitoring, and Moderation.

### 8.5 Per-domain migration checklist (the pattern, then the table)

**Generic pattern to flip one domain from mock to real.** Steps 1–3 are only needed for the
"no seam yet" domains; token-bound domains start at step 4.

1. **Ensure the gateway interface exists** (all admin domains already have one — audit §14). If a
   domain truly has none (e.g. Customers, Analytics derive from others), decide whether to add one
   or keep deriving.
2. **Add a DI token** — `InjectionToken` + factory that selects mock vs. real off
   `RuntimeProviderStrategyService`, mirroring `admin-categories-gateway.token.ts`.
3. **Update the facade injection** from the concrete `Admin*LocalGateway` class to the new token.
4. **Implement the `*ApiGateway`** class satisfying the existing interface (contains the DTO→view
   mapper, per §8.4 Example C).
5. **Bind the token to the real impl** in the providers (factory returns the api gateway for
   `api` mode).
6. **Retire the `*LocalGateway`** — either delete it or keep it behind the existing
   `useMockData` / mode flag as a dev fixture (the factory already lets both coexist).

This is the exact pattern Categories already follows (`admin-categories-api.gateway.ts` +
`admin-categories-gateway.token.ts`); replicate it verbatim.

| Domain | Needs steps 1–3 (add seam)? | Interface exists? | Real impl exists? | Net work |
|---|---|---|---|---|
| Categories (admin) | no (token-bound) | yes | **yes** | server endpoints only |
| Dashboard metrics | no (token-bound) | yes | no | steps 4–6 |
| Media | no (token-bound, class token) | yes (abstract class) | no | steps 4–6 |
| User experience | no (token-bound) | yes | no | steps 4–6 **+ repo redesign** (id-only + batch hydrate) |
| Orders | **yes** | yes | no | steps 1–6 (skip 1) |
| Products (admin) | **yes** | yes | no | steps 1–6 (skip 1) |
| Users / roles | **yes** | yes | no | steps 1–6 (skip 1) |
| Transactions | **yes** | yes | no | steps 1–6 (skip 1) |
| Monitoring | **yes** | yes | no | steps 1–6 (skip 1) |
| Moderation | **yes** | yes | no | steps 1–6 (skip 1) |
| Customers | inherits Orders seam | no (derived) | no | derive from Orders token, or add source |
| Analytics | partial | no (derived) | no | needs data source first (last) |
| Content management | n/a (no gateway) | n/a | no | **add** draft/publish HTTP path |
| Project editor / builder | n/a (no gateway) | n/a | no | **add** draft/publish HTTP path |

### 8.6 Recommended backend implementation order (all domains)

Ordered by the frontend's actual dependency structure (audit §5–§18), not by convenience:

1. **Auth + session first.** `TelegramSessionApiService` is LIVE and every admin path is gated by
   the `adminAuthHeadersInterceptor` (`AdminWebSessionID` / `Bearer`, audit §3). Nothing
   role-gated works until session issuance and (if used) the Ed25519 `/api/admin/auth/*` flow are
   real. Blocks everything admin.
2. **Bootstrap / tenant resolution.** `GET /bootstrap` transport is done, but its **content**
   (branding/theme/nav/seo) is still stubbed (remaining-work #1). Tenant resolution and
   `ApiConfigService.getBaseUrl()` depend on it; every tenant-scoped call resolves through it.
3. **Categories.** Already LIVE for both storefront (`GET /category`) and admin
   (`AdminCategoriesApiGateway`). Products reference categories, so categories must be real and
   populated before products are meaningful. Mostly "confirm server endpoints" work.
4. **Products / catalog.** Storefront reads (`GET /items`, `/searchitems`, `/randomitems`) are
   LIVE; the **admin Products CRUD** is the first no-seam domain to build (remaining-work #3, P0),
   and it depends on categories.
5. **Media.** Products/categories editors reference media assets; a real `ApiMediaRepository`
   (remaining-work #5, P0) should land alongside/just after admin Products.
6. **Cart / Orders / Transactions.** Checkout `POST /cart` + `POST /orders` are LIVE; admin
   **Orders CRUD** (remaining-work #6) then **Transactions** (tied to orders, #7) are the P1 core
   commerce domains. Orders before Transactions/Customers/Analytics (they derive from it).
7. **Reviews / Moderation.** Customer review/question writes are LIVE; admin **Moderation**
   (#10) gates them. Depends on products existing.
8. **Users / roles / invitations** (#9) — admin governance; independent of commerce but needs
   auth (step 1).
9. **Dashboard metrics** (#11) then **Monitoring** (#12) — operational visibility over the
   domains above.
10. **Analytics** (#13/#17) — **last**: needs orders/products/moderation real _and_ a tracking
    pipeline that does not exist yet.
11. **Builder draft/publish + Content/CMS** (#2 P0 for builder transport, #14 for CMS) — these are
    net-new write paths (no client call exists, audit §16/§17). Builder publish is high priority
    for the builder product but is orthogonal to the storefront/commerce chain, so it can proceed
    in parallel once bootstrap content (step 2) is real.
12. **User-experience sync** (#16) and **search suggestions/filters** (#15) — P2/P3 enhancements
    over already-working local features.

### 8.7 Estimated effort (frontend-informed)

These are **frontend-informed** sizes only — they reflect how much of the contract is already
seamed/mapped on the client. Backend has its own unknowns (schema design, storage, infra, the
tracking pipeline) that are **out of scope for this document**; a domain marked S here can still be
L on the server.

| Domain | Size | Why |
|---|---|---|
| Categories (storefront + admin) | **S** | LIVE both sides; `CategoryMapper` + `AdminCategoriesApiGateway` exist. Server endpoints only. |
| Products storefront reads | **S** | LIVE; `normalizeItem` absorbs shape variance. |
| Bootstrap content | **M** | Transport done; producing real branding/theme/nav/seo values is the work (#1). |
| Media | **M** | Token seam + abstract interface ready; write `ApiMediaRepository` + upload pipeline (#5). |
| Admin Orders | **M** | Interface + rich view models exist, but no seam and no mapper — steps 1–6 + mapper. |
| Admin Products CRUD + variants | **L** | No seam, no mapper; largest admin model set (variants/attributes/translations/seo). |
| Admin Users/roles/invitations | **M** | No seam; also reconcile the duplicate `AdminRole` naming (audit §14/§25 #3). |
| Admin Transactions | **M** | No seam; tied to Orders shape. |
| Admin Moderation | **M** | No seam; two state machines (reviews + reports). |
| Admin Monitoring | **M** | No seam; three sub-resources (events/queues/webhooks). |
| Dashboard metrics | **S–M** | Token-bound already; single `loadMetrics` shape. |
| Customers | **S** | Derives from Orders once Orders is real. |
| Analytics | **XL** | No data source; needs tracking pipeline + aggregation, gated on everything above. |
| User-experience sync | **L** | Requires client repository redesign (id-only + batch hydrate), not a drop-in. |
| Builder draft/publish | **L** | Net-new write path; no client call exists today. |
| Content management / CMS | **M–L** | Net-new write path; adapter (`ContentPageService`) exists but no HTTP. |
| Search suggestions/filters | **M** | Client orchestration exists; backend suggestion source is new. |

---

## 9. Backend Checklist

A literal, top-to-bottom checklist. Work the phases in order; within a phase, items are roughly
independent. Section references point to the assembled backend-integration document (this doc's
§8, the CRUD-contracts sections, and `docs/archive/BACKEND_API.md` where a full shape already lives).

### Phase 1 — Foundation (nothing role-gated works until these land)

- [ ] Implement session issuance/check/logout: `POST /users/sessions`, `GET /users/sessions/:id`, `DELETE /users/sessions/:id` — per Auth contract (§5a; archive/BACKEND_API.md `/users/sessions/*`, already client-LIVE).
- [ ] Implement the Ed25519 admin-auth flow `GET /api/admin/auth/challenge`, `POST /verify`, `POST /refresh`, `POST /logout` — client wiring is LIVE and 404s today (§5b). Return `AuthChallenge` / `AuthTokenPair` shapes exactly.
- [ ] Honor the admin auth headers on every gated path: `AdminWebSessionID` + `Authorization: Bearer` for URLs containing `/admin/`, `/backoffice/`, `/builder/`, `/media/` (§3 interceptor pipeline).
- [ ] Serve real `GET /bootstrap` **content** (branding, theme, navigation, seo — not just the transport) — per Bootstrap contract (§6; archive/BACKEND_API.md §4). This is a P0 blocker.
- [ ] Populate `bootstrap.apiEndpoints.{website,builder,backoffice}` records so tenant-scoped paths resolve at runtime (§6; audit §24 — no path literals exist in client code).
- [ ] Confirm tenant resolution inputs (host/slug/code) match `TenantConfig` so `ApiConfigService.getBaseUrl()` resolves the right base (§2, §6).
- [ ] Adopt a consistent error envelope; the client maps failures to a `backend-unavailable` screen for admin auth — keep error bodies non-leaky (§5b; security guidance).
- [ ] Set the header contract: accept `X-Region`, `X-Language` (RU/EN/AM), `Currency` (default RUB), `WebSessionID` on marketplace requests (§3).

### Phase 2 — Read-heavy domains (mostly confirm; already client-LIVE)

- [ ] Stand up `GET /category` returning the `CategoryDto` shape `CategoryMapper` tolerates (§8.4 Example A; audit §8) — already LIVE client-side.
- [ ] Stand up `GET /items/:id`, `GET /category/:id`, `GET /items/randomitems`, `GET /searchitems` within the `normalizeItem` tolerance envelope (§8.4 Example B; audit §4, §7).
- [ ] Serve admin categories CRUD via the existing `AdminCategoriesApiGateway` contract: `loadCategories`, `loadCategory`, `create/update/delete/restore`, `isSlugTaken` — per Categories CRUD contract (archive/BACKEND_API.md §6.9). **Already wired client-side (DONE).**
- [ ] Serve `GET /api/backoffice/products` and `GET /api/backoffice/categories` (storefront cards) — `ApiBackofficeDataProvider` is LIVE (audit §9).
- [ ] Stand up `GET /regions` → `Region[]` (feeds the `X-Region` header; client falls back to 6 hardcoded regions) (audit §12).

### Phase 3 — Write-heavy customer domains

- [ ] Keep `POST /cart` (`CartPaymentRequest` → `QrCreateResponse`) and the frozen QR/card payment polling working unchanged (§10; payments frozen per archive/BACKEND_API.md §2.8).
- [ ] Implement `POST /orders` (`CreateOrderRequest` → `CreateOrderResponse`) — client call is LIVE, fire-and-forget after payment (§10; archive/BACKEND_API.md §16.9, marked DONE client-side).
- [ ] Implement `POST /purchase-email` (email receipt) (audit §4).
- [ ] Accept review/question writes `POST /items/:id/callback` and `POST /items/:id/questiion` (**preserve the `questiion` typo** — it matches the client literal) (§11; audit §4).

### Phase 4 — Admin domains (each needs the token seam added first — §8.5)

- [ ] Add `AdminOrdersGateway` token + `AdminOrdersApiGateway`, switch `AdminOrdersFacade` to the token; implement `GET/POST /backoffice/orders*` incl. `POST /backoffice/orders/:id/status` respecting the order state machine — per Orders CRUD contract (§8.4 Example C, §8.5; archive/BACKEND_API.md §6.11/§8.1).
- [ ] Add `AdminProductsGateway` token + `AdminProductsApiGateway`, switch `AdminProductsFacade`; implement Products CRUD + variants — per Products CRUD contract (§8.5; archive/BACKEND_API.md §6.10/§7.2).
- [ ] Add `AdminTransactionsGateway` token + api gateway, switch `AdminTransactionsFacade`; implement transactions list/detail + `retryFailed` + `setFraudFlag` (tied to orders) — per Transactions contract (§8.5; archive/BACKEND_API.md §6.12).
- [ ] Add `AdminUsersGateway` token + api gateway, switch `AdminUsersFacade`; implement users/roles/invitations/sessions/audit — per Users contract (§8.5; archive/BACKEND_API.md §6.13). Reconcile the duplicate `AdminRole` naming (audit §25 #3).
- [ ] Add `AdminModerationGateway` token + api gateway, switch `AdminModerationFacade`; implement review + report status transitions — per Moderation contract (§8.5; archive/BACKEND_API.md §6.14/§8.4/§8.5).
- [ ] Add `AdminMonitoringGateway` token + api gateway, switch `AdminMonitoringFacade`; implement events/queues/webhooks reads — per Monitoring contract (§8.5; archive/BACKEND_API.md §6.16).
- [ ] Wire `AdminDashboardMetricsApiGateway` to the existing `ADMIN_DASHBOARD_METRICS_GATEWAY` token; implement `loadMetrics` — per Dashboard contract (§8.5; archive/BACKEND_API.md §6.15).
- [ ] Resolve Customers: derive from the real Orders token (`AdminCustomersFacade`) or add a first-class customers source (§8.3).
- [ ] Defer Analytics until orders/products/moderation are real and a tracking pipeline exists; then implement the analytics summary source (§8.3, §8.6 step 10; archive/BACKEND_API.md §6.17).

### Phase 5 — Builder / CMS (net-new write paths — no client call exists today)

- [ ] Implement builder bootstrap draft/publish/validate: `GET/PUT /builder/bootstrap/draft`, `POST /builder/bootstrap/publish`, `POST /builder/bootstrap/validate` — and add the client-side write call in `ProjectEditorFacade`/`ProjectEditorIoService` (§8.3, §17; archive/BACKEND_API.md §6.7). P0 for the builder.
- [ ] Implement content pages / CMS write path and wire `ContentManagementFacade` beyond in-memory bootstrap (§8.3, §16; archive/BACKEND_API.md §6.8).
- [ ] Implement the media upload/delete/replace pipeline behind `ApiMediaRepository` bound to the `MediaRepository` token (§8.5; archive/BACKEND_API.md §6.18/§10).

### Phase 6 — Hardening

- [ ] Add rate limiting on all write endpoints (orders, reviews, admin CRUD, media upload) (security guidance).
- [ ] Set CSP / security headers for the frontend origin (frontend + API share a domain by decision — remaining-work "Explicitly not in this list").
- [ ] Add audit logging for admin mutations (order status, role changes, moderation actions, publish) — the client already models `*AuditEntry` / timeline shapes (audit §23).
- [ ] Implement maintenance-mode / graceful `backend-unavailable` responses the client can surface (§5b).
- [ ] Implement `GET /items/batch?ids=` to unblock the user-experience id-only sync redesign (remaining-work #16; §8.3).
- [ ] Add search suggestions/catalog-filter source if pursuing #15 (§8.6 step 12; archive/BACKEND_API.md §6.6).
- [ ] Plan dynamic sitemap generation (server-side, no frontend action) (remaining-work #18).

---

---

## 10. Maintenance Mode

Frontend contract for backend maintenance/availability signals: global, per-tenant,
per-module, read-only, scheduled, and single-feature-disable scenarios. Written from
the current source tree (branch `B2B`) — see `docs/context/BACKEND-AUDIT.md` for the
full backend surface this builds on.

**Existing frontend handling today: none.** There is no maintenance concept anywhere
in the frontend — no model field, no interceptor branch, no route, no component. This
document proposes a contract and marks every open question explicitly as either
"Requires backend decision" (the backend hasn't decided the signal shape) or "No
frontend UI currently exists for this - requires a future frontend task" (the signal
is plausible but no UI has been built to react to it).

The one adjacent, already-built pattern worth reusing is `AuthErrorPageComponent`
(`src/app/core/auth/pages/auth-error-page.component.ts`): a single component keyed by
an error-code route param, rendering `EmptyStateComponent` +
`ButtonComponent`, with a `Record<Code, {title, description, actionLabel}>` copy table
and a `retry()` handler. Section 7 proposes the maintenance screens follow this exact
shape rather than inventing a new one.

---

### 1. Global maintenance

Whole platform down for all tenants.

**What the backend should send:** `503 Service Unavailable` on every endpoint
(including `GET /bootstrap`), with a `Retry-After` header (seconds) and a structured
JSON body (see §7 for exact shape). `GET /bootstrap` is the critical path — it is the
first call the frontend makes (`ApiBootstrapProvider.loadBootstrap()`,
`src/app/core/bootstrap/providers/api-bootstrap.provider.ts`, `GET /bootstrap`) and
every facade that renders anything (`UiRuntimeFacade`, `WebsiteRuntimeFacade`,
`ProjectEditorFacade`, `ContentManagementFacade`, `DiagnosticsFacade`) depends on it
resolving.

**What the frontend currently does:** nothing maintenance-specific. Tracing the call
chain in `src/app/core/config/config.service.ts`:

```ts
this.bootstrap$ = this.provider.loadBootstrap().pipe(
  tap(config => { this.bootstrapSnapshot = config; ... }),
  shareReplay(1),
  catchError(error => {
    this.bootstrap$ = undefined;
    this.bootstrapSnapshot = null;
    return throwError(() => error);
  })
);
```

Any bootstrap failure (503 or otherwise) just rethrows. Every one of the ~14 call
sites of `configService.loadBootstrap()` (footer, theme engine, branding engine,
platform-runtime, page-resolver, static-page-resolver, footer-resolver, diagnostics,
etc. — see `Grep` results for `loadBootstrap()` across `src/app`) either does not
subscribe to the error channel at all, or handles it locally and inconsistently.
There is no global "the whole app is down" screen.

**No frontend UI currently exists for this — requires a future frontend task.** A
clean contract would intercept a `503` on the bootstrap call specifically (distinct
from a 503 on a leaf endpoint, which should degrade that one section instead — see
§3) and route to a full-page takeover, structurally identical to
`AuthErrorPageComponent`: a `maintenance-page.component.ts` using
`EmptyStateComponent` + `ButtonComponent`, keyed off the response body's `reason`
(§7), with a retry button that calls `configService.loadBootstrap(true)`.

**Requires backend decision:** whether maintenance state is signaled by response
status alone (`503` on `/bootstrap`) or also via a dedicated
`GET /status` / `GET /maintenance` probe the frontend could poll while showing the
takeover screen, to auto-recover without the user manually retrying.

---

### 2. Per-tenant maintenance

Single tenant disabled while others operate normally.

This ties directly into tenant resolution: `TenantResolverService`
(`src/app/core/config/tenant-resolver.service.ts`) determines the tenant key before
`ApiConfigService.getBaseUrl()` resolves which base URL to call (`tenantApiBaseUrls`
map, or `tenantApiTemplate` with `{tenant}` substituted — see
`docs/context/BACKEND-AUDIT.md` §2). Because tenant resolution happens client-side
before any network call, a per-tenant maintenance signal can only surface through the
response to that tenant's own `GET /bootstrap` call — there is no separate
"is this tenant up" check today.

**What the backend should send:** the *same* `503` + structured body as global
maintenance (§7) on that tenant's `/bootstrap` response. The frontend has no way to
distinguish "this tenant is down" from "the whole platform is down" except by the
response body's content — so the body must carry enough to tell (e.g. a `scope` field:
`"global" | "tenant"`).

**What the frontend currently does:** nothing. `ConfigService.loadBootstrap()` is
tenant-agnostic from the frontend's point of view — it just calls whatever base URL
`ApiConfigService` resolved and doesn't know if a 503 means "this tenant" vs.
"everything."

**Requires backend decision:** the `scope` discriminator mentioned above, and whether
a disabled tenant's static/marketing content (branding, footer) should still resolve
from a cached/last-known bootstrap so the takeover page can show the tenant's own
logo, or whether it's a fully generic (unbranded) page. Given
`BootstrapConfig.branding`/`theme` are only available *after* a successful bootstrap
load, a tenant-branded maintenance page is not achievable without a design decision
here (e.g. serving branding via a separate lightweight endpoint that stays up even
when the tenant is otherwise disabled).

**No frontend UI currently exists for this — requires a future frontend task.** Same
takeover component as §1 can likely serve both scopes once the backend supplies
`scope`, but nothing renders differently for tenant-vs-global today because nothing
renders a maintenance screen at all yet.

---

### 3. Per-module maintenance

E.g. payments down but catalog still browsable.

**Existing granularity concept:** `BootstrapConfig.featureFlags`
(`FeatureFlagsConfig`, `src/app/shared/models/config/feature-flags.model.ts`) —
a flat `Record<string, boolean>` with known keys `wishlist, compare, reviews,
questions, comments, recommendations, blog, chat, analytics, notifications, coupons,
loyalty, giftCards, invoices` and an index signature for tenant-specific extras. This
is a **static, bootstrap-time** on/off switch per feature — not a live "is this
service currently degraded" signal, and it has no `payments` or `catalog` key today.
It's read once at bootstrap load and doesn't change until the next bootstrap refresh.

There is no separate "module health" concept distinct from `featureFlags`. The admin
dashboard's `healthChecks()` / `homeHealthChecks()` (`AdminDashboardFacade`,
`src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts`) are **not**
module-availability checks — they validate the *local bootstrap document itself*
(schema version present, no missing translations, no invalid colors/widget refs/
layouts, draft-exists, etc.), entirely client-side, with no backend health probe
behind any row except product/category counts (which reflect load success/failure of
`ProductFacade`/`CategoryFacade`, not an explicit "payments module is down" signal).
`AdminMonitoringPageComponent` reuses the same boolean-shaped `healthChecks()` — it is
not a live service-status board either.

**What the backend should send:** each domain-specific endpoint (e.g. `POST /cart`,
`POST /orders`, `{qrApiUrl}/qr`) should independently return `503` with the structured
body (§7) with `scope: "module"` and a `module` field (e.g. `"payments"`) when that
subsystem specifically is down, while unrelated endpoints (`GET /category`,
`GET /items/{id}`) keep responding normally. This requires no new bootstrap field —
it's a per-request response behavior, consistent with REST conventions (the resource
itself is unavailable, not the whole API).

**What the frontend currently does:** nothing differentiates a per-module outage from
any other request failure. `ApiService` (`src/app/services/api.service.ts`) has no
per-endpoint error branching for 503; a failed `createCartPayment()`/`createOrder()`
call surfaces through whatever generic error handling the checkout components already
have for network failures (out of scope for this section — see §6 for the
general error-response shape).

**No frontend UI currently exists for this — requires a future frontend task.** The
checkout flow would need a "payments unavailable" inline state (banner or disabled
submit + tooltip, per §7) distinct from a generic error toast, and catalog browsing
would need to keep working untouched — which it structurally already would, since
`ProductFacade`/`CategoryFacade` and the payment calls are fully independent code
paths today (no shared failure state). That independence is a real asset: a payments
outage cannot accidentally break catalog browsing given the current facade
separation, but no UI exists yet to *tell the user* payments specifically are down
rather than "something went wrong."

---

### 4. Read-only mode

Writes disabled, reads still work.

**Does the frontend already assume this is possible?** Partially, structurally, but
not deliberately. Cart state is `LOCAL-ONLY` (`CartService`,
`src/app/services/cart.service.ts`, signal-based, persisted to `localStorage` key
`marketplace_cart`) — adding items to cart, changing quantities, and browsing the cart
UI works entirely client-side with **no backend call at all** until checkout. The
only writes that hit a backend are at the checkout boundary: `POST /cart`
(`createCartPayment`), `POST /orders` (`createOrder`), `POST /purchase-email`, and the
QR/card payment polling. So today, if the backend rejected writes only, catalog
browsing, search, wishlist/compare (also `LOCAL-ONLY`,
`LocalUserExperienceRepository`), and cart-building would all continue working simply
because they never touch the backend — but reviews (`POST /items/{id}/callback`) and
questions (`POST /items/{id}/questiion`) are also writes and would fail the same as
checkout, since both are LIVE endpoints via `ProductDataProvider`.

There is no code today that *checks for* a read-only flag and proactively disables
write UI (e.g. graying out "Add to cart" or the checkout button ahead of time). A
write attempt would only be discovered to be blocked when the write call itself
fails.

**What the backend should send:** `503` (or `403`, see note below) with the
structured body (§7), `scope: "readonly"`, on write endpoints specifically —
`POST /cart`, `POST /orders`, `POST /purchase-email`, `POST /items/{id}/callback`,
`POST /items/{id}/questiion`, `POST /websession/{sessionId}` (cart sync) — while GET
endpoints keep working. `403 Forbidden` is arguably more correct REST semantics for
"this resource forbids this method during a maintenance window" than `503`, but `503`
+ `Retry-After` communicates "temporary" more clearly to a client and is
recommended so the frontend can offer a countdown/retry consistent with §5's pattern.
**Requires backend decision:** which status code is authoritative — this should be
pinned down jointly with whatever `§6` settles on for its 5xx
conventions, since read-only is really "a subset of write endpoints return
maintenance-503."

**No frontend UI currently exists for this — requires a future frontend task.** No
bootstrap flag exists to proactively disable checkout/review/question submission
ahead of a failed request (e.g. `featureFlags.readOnly` or a dedicated
`platformStatus.readOnly` field would need to be added to `BootstrapConfig` if the
product wants a proactive banner instead of a reactive failure). Reactive handling
(showing an error when the write call 503s) can reuse the same inline
error-state pattern as §3/§6 once `§6` defines the generic error body
handling.

---

### 5. Scheduled maintenance

Advance notice pattern (banner / countdown) ahead of a maintenance window.

**What exists in the frontend today:** nothing. No banner component, no countdown
component, no bootstrap field for an upcoming maintenance window.

**Requires backend decision — proposed minimal contract:** add an optional field to
`BootstrapConfig` (loaded once per session/on refresh via `GET /bootstrap`), e.g.:

```ts
interface ScheduledMaintenanceNotice {
  startsAt: string;   // ISO 8601
  endsAt?: string;    // ISO 8601, optional if duration is unknown
  scope: 'global' | 'tenant' | 'module';
  module?: string;    // present when scope === 'module'
  messageKey?: string; // optional i18n key/translated string for custom copy
}
```

surfaced as `bootstrap.maintenanceNotice?: ScheduledMaintenanceNotice | null`. This
keeps the mechanism consistent with how the platform already declares other
runtime-configured, backend-authored state (feature flags, tenant config, API
endpoint records all live in the bootstrap document per
`docs/context/BACKEND-AUDIT.md` §6) rather than inventing a new polling endpoint. A
polling `GET /maintenance-notice` endpoint is an alternative if the notice needs to
appear/change without a full bootstrap refresh — that tradeoff is the backend
decision.

**No frontend UI currently exists for this — requires a future frontend task.** A
dismissible banner component reading `bootstrap.maintenanceNotice` and showing a
localized "maintenance starts in Xh Ym" countdown would need to be built and mounted
at a layout level (header or a global banner slot) — no such banner or countdown
component exists in `src/app/shared/ui/` today.

---

### 6. Temporary feature disable

Single feature toggled off without full maintenance — e.g. reviews temporarily
disabled while the rest of the product page works.

**This is the one scenario the frontend already has a real mechanism for**, via
`BootstrapConfig.featureFlags` (§3). Setting `featureFlags.reviews = false` in the
bootstrap document is exactly the existing, live mechanism for "reviews are off right
now" — it's read by whatever consumes `FeatureConfigService`
(`src/app/core/config/*`) and gates the relevant UI. This is a **deploy/config-time**
toggle (changes on next bootstrap load), not a live incident-response toggle, but
structurally it is the same shape a backend team would use to kill a misbehaving
feature quickly: update the bootstrap document (or whatever backend-side config
drives it), and the next bootstrap fetch picks it up.

**Recommendation:** reuse `featureFlags` for this scenario rather than introducing a
parallel mechanism — it already exists, is already wired through to the UI in the
relevant places, and matches the "temporary, single-feature, not a full outage"
framing exactly. No backend decision needed for the *mechanism*; only for *process*
(how fast a flag flip propagates — depends on bootstrap cache/refresh cadence, which
is outside this doc's scope).

**Gap:** `featureFlags` has no `payments` or `catalog` key and is a boolean only — it
can't express "reviews disabled with reason X, back at time Y" the way §5's proposed
`maintenanceNotice` can. If product wants a "reviews are temporarily unavailable —
back tomorrow" message rather than the feature silently disappearing, that needs the
richer shape from §5, scoped to `module`, not a plain `featureFlags` boolean.

---

### 7. Recommended API responses

All maintenance-scenario responses use HTTP `503 Service Unavailable` (except the
read-only debate in §4) with a `Retry-After` header (seconds, standard HTTP) and a
JSON body, nested under the §6 error envelope (`{ error: { code, message, ... } }`)
rather than as a competing top-level shape. The fields below are what the frontend needs regardless
of the outer envelope:

```json
{
  "status": 503,
  "code": "maintenance",
  "scope": "global",
  "module": null,
  "reason": "scheduled",
  "message": "The marketplace is temporarily unavailable for scheduled maintenance.",
  "retryAfter": 1800,
  "startedAt": "2026-07-26T02:00:00Z",
  "expectedEndAt": "2026-07-26T03:00:00Z"
}
```

Field notes:
- `scope`: `"global" | "tenant" | "module" | "readonly"` — lets the frontend pick the
  right UI (full takeover vs. inline banner vs. disabled control) without guessing
  from status code alone.
- `module`: present only when `scope === "module"` (e.g. `"payments"`, `"reviews"`).
- `reason`: `"scheduled" | "incident" | "disabled"` — free-form enough for the
  frontend to choose copy tone (planned vs. unplanned) without needing new fields
  per scenario.
- `retryAfter`: mirrors the `Retry-After` header in the body too, so a client that
  only reads JSON (not headers) still gets it — useful since some HttpClient error
  paths surface the body more readily than headers depending on interceptor
  structure.
- `startedAt` / `expectedEndAt`: optional, ISO 8601, for countdown/banner copy (§5).

Per-scenario summary:

| Scenario | Status | `scope` | Notes |
|---|---|---|---|
| Global | 503 | `"global"` | On every endpoint, especially `/bootstrap` |
| Per-tenant | 503 | `"tenant"` | On that tenant's `/bootstrap` and all its endpoints |
| Per-module | 503 | `"module"` | Only on that module's endpoints (e.g. `/cart`, `/orders`) |
| Read-only | 503 or 403 | `"readonly"` | Only on write endpoints; GETs unaffected — pin down with `§6` |
| Scheduled (advance notice) | 200, via `bootstrap.maintenanceNotice` | n/a | Not an error response — a proactive field on the normal `/bootstrap` payload, see §5 |
| Temporary feature disable | 200, via `bootstrap.featureFlags.<key> = false` | n/a | Not an error response — existing bootstrap mechanism, see §6 |

---

### 8. Frontend behavior

Grounded in the UI patterns that already exist (`EmptyStateComponent`
(`src/app/shared/ui/empty-state/empty-state.component.ts`), the `errorTitle` /
`error` / `retry` i18n-key convention used across catalog, product details, and
generic list widgets (`src/app/i18n/en.ts`), and `AuthErrorPageComponent`'s
code-keyed full-page pattern). No new UI concepts are invented below beyond composing
these.

| Scenario | Recommended UI | Existing pattern reused | Status |
|---|---|---|---|
| Global maintenance | Full-page takeover, replaces the entire app shell (no header/footer, since branding may be unavailable — see §2) | `AuthErrorPageComponent` shape: `EmptyStateComponent` + `ButtonComponent`, code-keyed copy, `retry()` action | No frontend UI currently exists for this — requires a future frontend task |
| Per-tenant maintenance | Same full-page takeover as global, ideally tenant-branded if the backend decision in §2 allows branding to still resolve | Same as above | No frontend UI currently exists for this — requires a future frontend task |
| Per-module maintenance | Inline empty-state/banner scoped to the affected section only (e.g. checkout step shows `EmptyStateComponent` with `errorTitle`/`error`/`retry` copy; catalog pages untouched) | `EmptyStateComponent` + the `errorTitle`/`error`/`retry` i18n triple already used in `catalog`/`productDetails`/generic-list translations | No frontend UI currently exists for this — requires a future frontend task |
| Read-only mode | Disabled write control (e.g. "Add to cart" / "Submit review" button) + tooltip explaining why, OR a reactive error state on submit if no proactive flag exists (§4) | Disabled-button-plus-tooltip is a common pattern in the design system but not wired to any maintenance signal today | No frontend UI currently exists for this — requires a future frontend task |
| Scheduled maintenance | Dismissible banner at layout/header level with countdown copy | No banner/countdown component exists in `src/app/shared/ui/` today | No frontend UI currently exists for this — requires a future frontend task |
| Temporary feature disable | Feature's own UI simply doesn't render (existing `featureFlags` gating), optionally with a short "temporarily unavailable" note if `messageKey` (§5) is present | Existing `featureFlags` boolean gating (already live) | Existing mechanism works; richer messaging is the only gap |

---

### Summary: what's proposed/new vs. what already exists

**Already exists and can be reused as-is:**
- `BootstrapConfig.featureFlags` — static per-feature kill switch (§3, §6).
- `EmptyStateComponent` + `errorTitle`/`error`/`retry` i18n convention — the inline
  error-state building block for any scenario.
- `AuthErrorPageComponent` — the full-page-takeover shape (code-keyed copy record,
  `EmptyStateComponent` + `ButtonComponent`, `retry()` handler) to model a maintenance
  page after.
- Cart's `LOCAL-ONLY` design already means most of "read-only browsing" works
  incidentally, since browsing/cart-building never call the backend.

**Proposed/new (this document introduces):**
- The `scope`/`module`/`reason` structured 503 body (§7).
- `bootstrap.maintenanceNotice` (§5) for scheduled-maintenance advance notice.
- A dedicated `maintenance-page.component.ts` full-page takeover (§1/§2).
- Inline per-module/read-only error and disabled-control states wired to the new 503
  shape (§3/§4).

---

### Requires backend decision (full list)

Each has a **Recommended default** — apply it unless flagged otherwise.

- §1: whether a dedicated `GET /status`/`GET /maintenance` probe should exist for
  auto-recovery polling, beyond a plain 503 on `/bootstrap`.
  **Recommended default:** no dedicated probe — reuse `GET /bootstrap`'s
  existing 503/200 as the recovery signal; a second endpoint duplicates it.
- §2: the `scope` discriminator (`"global"` vs `"tenant"`) so the frontend can tell
  the two apart from a single tenant's bootstrap response; and whether a
  disabled tenant's branding can still resolve for a branded takeover page.
  **Recommended default:** include `scope` in the 503 body (§7 shape);
  serve branding for a disabled-but-known tenant so the takeover page can
  still be branded (only truly unknown tenants get the unbranded default).
- §3: none beyond adopting the §7 response shape per-endpoint — this one is mostly
  frontend-gap, not backend-undecided.
- §4: which status code is authoritative for read-only (`503` vs `403`) — to be
  pinned down jointly with §6.
  **Recommended default:** `503` with `code: "READ_ONLY"` — it's a temporary
  service-state condition, not a permissions failure, so it belongs in the
  5xx family alongside the rest of maintenance mode, not 4xx.
- §5: whether scheduled-maintenance notice ships via a `bootstrap.maintenanceNotice`
  field (proposed) or a separate polling endpoint.
  **Recommended default:** the `bootstrap.maintenanceNotice` field — it
  rides the existing bootstrap fetch/cache lifecycle for free; a separate
  polling endpoint adds a new recurring request for no added value.
- §7: how this document's 503 body nests inside whatever outer envelope
  §6 defines.
  **Recommended default:** already resolved — nest under the §6 envelope
  (`{ error: { code, message, status, requestId, ... } }`) as shown in this
  section's JSON examples; no separate top-level shape.

### No frontend UI currently exists for this — requires a future frontend task (full list)

- Global maintenance full-page takeover component.
- Per-tenant maintenance takeover (branded or not, pending §2's backend decision).
- Per-module inline maintenance banner/empty-state wiring on checkout/payment flows.
- Proactive read-only disabling of write controls (Add to cart / Submit review /
  Submit question / Checkout) ahead of a failed request.
- Scheduled-maintenance banner + countdown component at the layout/header level.
- Richer "temporarily unavailable, back at X" messaging for `featureFlags`-gated
  features (today they just silently don't render — no explanatory copy).

---

## Appendix: `docs/TODO.md` items merged into this document (2026-07-26)

Final Project Closeout moved every backend-shaped item out of `docs/TODO.md` into this
document. None were duplicated as raw new bullets — each is already covered by an
existing section above:

| TODO item | Covered by |
|---|---|
| `bootstrap.json` real content (branding/theme/nav/seo) | §1 Bootstrap |
| Builder bootstrap draft/publish/validate | §1 (Draft vs Published), §8, §9 Phase 5 |
| Backoffice Products CRUD | §3 Products, §8, §9 Phase 4 |
| Media upload/delete/replace pipeline | §7 Uploads, §9 Phase 5 |
| Backoffice Orders CRUD + status transitions | §3 Orders, §8, §9 Phase 4 |
| Backoffice Transactions | §3 Transactions, §9 Phase 4 |
| Backoffice Users/roles/invitations | §3 Users/Roles, §9 Phase 4 |
| Backoffice Moderation (reviews/reports) | §3 Reviews/Reports, §9 Phase 4 |
| Backend Ready sprint / no real API contract | This entire document |

`docs/TODO.md` is now empty of blockers — see that file.
