diff --git a/docs/BACKEND_INTEGRATION.md b/docs/BACKEND_INTEGRATION.md new file mode 100644 index 0000000..222a5f9 --- /dev/null +++ b/docs/BACKEND_INTEGRATION.md @@ -0,0 +1,4349 @@ +# Backend Integration — Canonical Specification + +**This is the single source of truth for backend implementation.** It supersedes and merges `docs/BACKEND_API.md`, `docs/AUTH.md`, `docs/ADMIN.md`, and `docs/BACKEND_API_REMAINING_WORK.md` (all archived — see `docs/archive/` and the note at the end of this file). It incorporates `docs/AUTHENTICATION.md` (§4) and `docs/ERROR_CONTRACT.md` (§6) in full; those files remain in place as standalone references but this document is authoritative. `docs/MAINTENANCE_MODE.md` is a companion doc, referenced from §6. + +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. + +## 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) + +--- +## 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/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('/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), shadows (Record), + 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` 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, + builder: Record, backoffice: + Record }` 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), 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 }` 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 `docs/AUTHENTICATION.md` §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` 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": "

О компании

", "en": "

About Us

" } + } + }, + "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. (`docs/AUTHENTICATION.md` §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 +`docs/AUTHENTICATION.md` §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` (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 + `docs/ERROR_CONTRACT.md`), 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 +**`docs/ERROR_CONTRACT.md`** — that document 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. + `ERROR_CONTRACT.md` 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` / `{ 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(...)` (the item object itself). +- `ApiService.getCategories()` → `get` (bare array). +- `ApiBootstrapProvider` → `get` (bare object). +- `TelegramSessionApiService` → `get>` then normalizes. + +The generic `ApiResponse` 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` + (`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, : 'all' | , 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=&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 **`docs/AUTHENTICATION.md`** (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 `, but its `authInterceptor` is **not + registered** today (AUTHENTICATION.md §2.6), so no request auto-attaches the + bearer token yet. `adminAuthHeadersInterceptor` *does* set `Authorization: + Bearer ` if an admin token happens to be stored, but nothing stores one + in the live flow. + +Refer to `docs/AUTHENTICATION.md` 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 `docs/AUTHENTICATION.md` §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 +`docs/AUTHENTICATION.md` §1. + +| Endpoint | Method | Auth | Body / Headers | Response | +|---|---|---|---|---| +| `/users/sessions` | POST | none | body `{ webSessionID }` (client-generated GUID) + header `WebSessionID: ` | `{ 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: ` | 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 `docs/AUTHENTICATION.md` §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 | +|---|---|---|---| +| `` | 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; + 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.9 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). + +--- + +## 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/BACKEND_API.md`, and the two +sibling specs written this session: + +- **Auth / headers / JWT** — see `docs/AUTHENTICATION.md`. This section never + re-defines the auth header format; it references it. +- **Error response envelope + per-status semantics** — see + `docs/ERROR_CONTRACT.md`. 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 (`AUTHENTICATION.md` §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 + `docs/AUTHENTICATION.md`, 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/`). +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 +` (`adminAuthHeadersInterceptor`). These header names/values are owned by +`AUTHENTICATION.md` — 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 (`AUTHENTICATION.md` §2). For every admin endpoint below, "JWT +requirement" is therefore stated as: **Today: `AdminWebSessionID` session header +(Mechanism A). Target: `Authorization: Bearer ` once Mechanism B is +cut over.** Server-side authorization must be enforced regardless of any +client-side guard (`AUTHENTICATION.md` §11.6). + +**Required permission / role.** The frontend's only permission model is the +coarse `ROLE_PERMISSIONS` table (`AUTHENTICATION.md` §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** +(`AUTHENTICATION.md` §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 `ERROR_CONTRACT.md` 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** (`ERROR_CONTRACT.md` "Finding") — +admin list pages collapse every non-2xx into one generic "retry" state +(`ERROR_CONTRACT.md` "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 + (`ERROR_CONTRACT.md` §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 — `ERROR_CONTRACT.md` §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 (`ERROR_CONTRACT.md` §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 (`ERROR_CONTRACT.md` §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 + `AUTHENTICATION.md` §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, `AUTHENTICATION.md` §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** + (`AUTHENTICATION.md` §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` (`AUTHENTICATION.md` §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>`. 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, 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, 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 `ERROR_CONTRACT.md` §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": "

About

" }, "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`); 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, permissions?: { requireAuthenticated?, roles?, permissions? }, props: + Record, 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, 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, `AUTHENTICATION.md` §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: + +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. +2. **All admin CRUD paths are PROPOSED** — only `/backoffice/categories*` and the + storefront catalog/cart/engagement paths are literal. Adopt the + `/backoffice/` convention (or reject it). +3. **Server-owned field lists** on create/update for products/orders/etc. (only + categories defines one, by stripping `id/itemsCount/deletedAt/createdAt/updatedAt`). +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. +5. **Product slug/sku uniqueness** — no proactive check exists (unlike categories' + `isSlugTaken`); decide `409` behavior. +6. **Order status-transition legality** and refund eligibility rules. +7. **Customers as a first-class resource** vs. derived-from-orders aggregate. +8. **`AdminRole` duplication** — auth string-union vs. users-management interface + (naming reconciliation; role CRUD / custom roles). +9. **Remote session revocation propagation** (`revokeSession`) to a logged-in + client (`AUTHENTICATION.md` §8 — no push/poll exists). +10. **Media**: allowed mime/size; whether in-use assets are delete-blocked. +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. +12. **Languages**: duplicate-add `409`, removing the default/last locale, BCP-47 + validation. +13. **Transactions ↔ live payment records** are unconnected models; how they link. +14. **Reviews**: storefront `Review` vs. admin `AdminReview` linkage; how a + submitted review enters the moderation queue. +15. **Reports**: no creation flow exists — who creates reports. +16. **Monitoring**: event windowing/pagination for a real stream; webhook + *management* (vs. read-only delivery log) is out of scope. +17. **Analytics**: `conversionRate`/`retentionPercent` must stay `null` when + unknown; no traffic/visitor data source exists anywhere — real analytics is a + from-scratch backend build. +18. **Variants/options** shape reconciliation between storefront `variantOptions` + (grouped) and admin `variants` (flat priced rows); no dedicated variants + endpoint (created within the product body). + +--- + +_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: ` | `{ 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: ` | 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,
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):
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
(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
(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: + +- **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. +- **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. +- **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. +- **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. +- **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. +- **`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). +- **`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. +- **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. +- **`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. + +--- + +## 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 sibling docs — this section references +`docs/AUTHENTICATION.md` and `docs/ERROR_CONTRACT.md` 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 `docs/AUTHENTICATION.md` §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 ` 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 `docs/AUTHENTICATION.md` §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` (`docs/AUTHENTICATION.md` +§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 `docs/AUTHENTICATION.md` §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 `docs/ERROR_CONTRACT.md` §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 `docs/ERROR_CONTRACT.md` §429, no retry +interceptor exists — honoring 429 is net-new frontend work, not a config +change. + +### 5.7 Replay protection + +The Ed25519 signing flow (`docs/AUTHENTICATION.md` §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 `` 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 `