# Backend — Canonical Specification **This is the ONE document a backend engineer needs.** It is the single source of truth for backend implementation — architecture, bootstrap, authentication, JWT, Ed25519 public-key login, permissions, maintenance mode, error contract, every endpoint, DTOs, request/response schemas, uploads, pagination, filters, sorting, publish workflow, media, builder, examples, and a top-to-bottom implementation checklist. It supersedes and fully merges `AUTHENTICATION.md`, `ERROR_CONTRACT.md`, `MAINTENANCE_MODE.md`, and (already archived, content re-derived from current source) `docs/archive/BACKEND_API.md` and `docs/archive/BACKEND_API_REMAINING_WORK.md`. Those standalone files no longer exist — everything they contained lives here. 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) 10. [Maintenance Mode](#10-maintenance-mode) --- ## 1. Bootstrap The bootstrap document is the single runtime-configuration payload that drives the entire multi-tenant storefront/builder/backoffice. It is fetched once at app startup and held in memory; nearly every feature (theme, navigation, localization, catalog behavior, static pages, feature flags, widget registry) reads from it rather than from dedicated per-feature endpoints. Source of truth for this section: `src/app/core/bootstrap/providers/api-bootstrap.provider.ts`, `src/app/core/config/config.service.ts`, `src/app/core/config/api-config.service.ts`, `src/app/core/config/tenant-resolver.service.ts`, `src/app/shared/models/config/*`, and the mock document `src/assets/mock/bootstrap/bootstrap.json`. Cross-referenced against `docs/context/BACKEND-AUDIT.md` §6, §2 and the prior `docs/archive/BACKEND_API.md`. ### 1.1 Request contract | Property | Value | |---|---| | Method | `GET` | | Route (API mode) | `/bootstrap` — relative; rewritten onto the resolved tenant API base by `apiBaseUrlInterceptor` (see §1.10) | | Route (mock/local mode) | `GET /assets/mock/bootstrap/bootstrap.json` (static asset, no backend) | | Request body | none | | Query params | none | | Auth requirement | **None.** Bootstrap is fetched before any login and must be publicly cacheable per tenant. It carries no `Authorization` header and no `WebSessionID` is required for it to succeed. | **Headers.** The provider itself (`ApiBootstrapProvider`) sets no explicit headers — it issues a plain `HttpClient.get('/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/archive/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 `