Files
marketplaces/docs/BACKEND.md
sdarbinyan fa6e5cd68b docs(backend): add Seller Management section to BACKEND.md
docs/BACKEND_API.md no longer exists as a live file (merged into
BACKEND.md in an earlier consolidation pass, per that doc's own intro
- only docs/archive/BACKEND_API.md remains, historical only). This
mission's "update BACKEND_API.md" instruction is fulfilled by
extending the doc that actually supersedes it: new §11 "Seller
Management (Optional Capability)", added to the top-of-file table of
contents, no existing section renumbered or altered.

Every subsection explicitly tagged Implemented / Planned / Future,
matching the same legend used in docs/architecture/foundation/
Seller-Management.md (the frontend-side capability doc this section
is the backend counterpart to):

- 11.2 Future entities: Marketplace (Implemented, existing
  TenantConfig unchanged), Seller/SellerBranding (Planned - frontend
  types exist, no backend schema), SellerUser/SellerSettings/
  SellerInvitation (Future - no type, no concept, named for roadmap
  completeness only).
- 11.3 Future endpoints: Seller CRUD/Activation/Invitations/Branding/
  Analytics/Dashboard - all Future, none designed, each noted as
  following the existing mock-to-API-gateway pattern (SS8) once built.
- 11.4 Authentication: SellerPermissionRole (4 roles) explicitly
  flagged as a separate vocabulary from the existing, live AdminRole -
  not merged, no guard wired, zero auth change.
- 11.5 Domain resolution: market.com -> Marketplace is Implemented
  today (ADR-001, backend-Host-resolved); nike.market.com -> Marketplace
  -> Seller is Future, no backend resolves it - and per the storefront
  audit, needs no frontend routing change once it does.
- 11.6 Bootstrap additions: modules/modules.sellerManagement documented
  as Implemented-as-contract (typed, always false/absent today);
  sellerScope/sellerBranding as Planned with an explicitly flagged open
  question (SellerConfig vs SellerBranding nesting not reconciled);
  permissions noted as existing/unrelated today.
- 11.7 Checkout modes: Unified Order vs Split Orders - Future, not
  designed, flagged as the single most consequential undecided item
  for backend design given payments/refunds/reporting all depend on it.
- 11.8 Product ownership: sellerId? on Item/AdminProduct/AdminOrder -
  Implemented as schema only (optional, absent = marketplace-owned,
  verified backward-compatible via tsc staying clean). Existing
  products remain valid with no migration required - NULL/absent
  ownership documented as a permanent state, not transitional.

No backend implemented. No frontend code touched. Documentation only.
2026-07-26 22:41:57 +04:00

282 KiB
Raw Blame History

Backend — Canonical Specification

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

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

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

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

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

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

Table of contents

  1. Bootstrap
  2. Endpoint Documentation Framework
  3. CRUD Contracts
  4. Authentication
  5. Security
  6. Error Model
  7. Uploads
  8. Real Backend Implementation Guide
  9. Backend Checklist
  10. Maintenance Mode
  11. Seller Management (Optional Capability)

1. Bootstrap

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

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

1.1 Request contract

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

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

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

1.2 Which provider actually fires

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

  • Returns mock (MockBootstrapProvider → static asset) when environment.useMockData === true, or when environment.useMockBootstrapOnLocal === true and the host is localhost.
  • Otherwise returns api (ApiBootstrapProviderGET /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:

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

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

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

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

1.4 Versioning

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

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

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

1.5 Example JSON

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

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

1.6 Lifecycle

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

1.7 Caching behavior (what the frontend actually does)

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

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

1.8 ETag / conditional requests

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

1.9 Tenant resolution

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

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

1.10 Draft vs Published

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

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

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

1.11 Fallback behavior (bootstrap fails to load)

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

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

1.12 Failure responses

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

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

2. Endpoint Documentation Framework

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

2.1 Standard success response envelope

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

  • ApiService.getItem()HttpClient.get<Item>(...) (the item object itself).
  • ApiService.getCategories()get<Category[]> (bare array).
  • ApiBootstrapProviderget<BootstrapConfig> (bare object).
  • TelegramSessionApiServiceget<Record<string, unknown>> then normalizes.

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

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

2.2 Standard pagination contract

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

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

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

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

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

Notes / open items:

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

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

2.3 Standard sorting contract

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

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

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

2.4 Standard filtering contract

Filtering shapes already used by the frontend:

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

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

2.5 Standard search contract

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

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

2.6 Nullable-field convention

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

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

2.7 JWT / authentication header format

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

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

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

2.8 Live endpoint domains (real HTTP, already implemented)

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

2.8.1 Session auth API (LIVE)

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

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

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

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:

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

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

Example — poll session (active):

GET https://api.dexarmarket.ru:445/users/sessions/3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11
{
  "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11",
  "status": "active",
  "user": { "id": 8823771, "username": "buyer_ivan", "firstName": "Ivan", "lastName": "P" },
  "expiresAt": "2026-07-26T05:00:00Z"
}

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

2.8.2 Marketplace / storefront API (LIVE)

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

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

2.8.3 Cart / order / payment API (LIVE)

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

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

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

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:

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 }]
}
{ "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" }

2.8.4 Widget manifest (LIVE)

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

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

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

export interface WidgetManifestFile {
  widgets: WidgetManifestEntry[];
}

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

Example response:

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

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

Example response:

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

2.10 API path versioning

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

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

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

2.11 Real-time / WebSocket

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

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

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

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

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

3. CRUD Contracts

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

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

3.0 Conventions used in this section

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

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

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

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

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

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

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

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

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

Validation rules. Admin create/edit forms (admin-product-form.component.ts, admin-category-form.component.ts) use no Angular reactive Validators — confirmed by source search (zero Validators.* matches). Form state is built by factories (admin-products-form.factory.ts, admin-categories-form.factory.ts) that seed empty defaults (e.g. slug: ''). Therefore the frontend enforces essentially no field validation on admin CRUD; every validation rule below beyond "the field exists in the model / is non-nullable in TS" is Requires backend decision. The one real client-side validation convention lives in the builder (ProjectValidatorProjectEditorFacade.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 ApiProductDataProviderPRODUCT_DATA_PROVIDERProductFacade. 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), sortrelevance | price_asc | price_desc | popular | rating. Signature: ApiService.searchItems() lines 551-590.
  • Pagination/sorting/filtering: offset-based (count/skip); sort + price
    • category + tag filters only on P3. P2 has no sort/filter params. Response P3 is the only one returning a total for page math.
  • Error responses: 404 when itemID doesn't exist (P1) — but note the frontend does not distinguish 404 from any other error today (§6 §404); a deleted product and a 500 render the same generic empty-state. 500/503 generic. Calls retry x2 with exponential backoff (ApiService.retryConfig).
  • Validation: none (read).
  • Example P3 request: GET {base}/searchitems?search=phone&count=24&skip=0&categoryIDs=3,7&minPrice=1000&sort=price_asc
  • Example P3 response:
    { "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), visibilityall|visible|hidden, stockall|in_stock|low_stock|out_of_stock, includeArchived (bool), sorttitle|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):
    { "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:
    { "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[], imgicon, 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:
    [ { "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), visibilityall|visible|hidden, includeDeleted (bool sent as string). No pagination — returns a plain array. No sort param.
  • AC3 request body: AdminCategory minus id, itemsCount, deletedAt, createdAt, updatedAt (server-owned — stripped by the real gateway, line 38). AC4 sends the full object, id in path.
  • AC7: proactive slug-uniqueness pre-check the frontend calls before submit (excludingId omitted on create, set to the editing id on update). This is the only proactive conflict check in the whole admin surface. On network error it returns false (best-effort, TOCTOU-prone — §6 §409).
  • Validation: slug uniqueness via AC7; everything else none client-side. title, slug are non-nullable in the model. Recommended 409 CONFLICT on AC3/AC4 slug collision as a race backstop (§6 §409), and 422 for field errors.
  • Error responses: AC2/AC6 swallow errors → null (so a 404 there renders as "not found"/empty, not an error screen). AC5 hard 404 if unknown. 409 slug conflict. Common set otherwise.
  • Example AC3 request:
    { "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 CartPaymentRequestQrCreateResponse (creates the QR/card payment)
O2 POST /qr qrApiUrl (headers authorization-key, userid-value) QrCreateRequestQrCreateResponse
O3 GET /qr/dynamic/{partnerId}/{qrId} qrApiUrl QrDynamicStatusResponse (poll)
O4 GET /card/{partnerId}/{orderId} qrApiUrl QrDynamicStatusResponse (poll)
O5 POST /orders marketplace CreateOrderRequestCreateOrderResponse (records paid cart; fire-and-forget after payment)
O6 POST /purchase-email marketplace email/phone + items → { message } (receipt)
O7 POST /websession/{sessionId} marketplace item array → any (server-side cart mirror)
  • partnerId const = web-97ec-9c57-4dde-9037-3a68f7f83750.
  • Headers/JWT: marketplace headers (O1/O5/O6/O7); O2-O4 use the payment API with authorization-key/userid-value. No JWT; customer session via WebSessionID.
  • CartPaymentRequest (O1): { amount, currency:'RUB', siteuserID, siteorderID, redirectUrl, telegramUsername, paymentMethod:'qr'|'card', qrDescription?, customerID?, items:[{ itemID, price, name, quantity?, delivery? }] }.
  • CreateOrderRequest (O5): { items:[{ productId, name, quantity, price }], customer:{ name, email, phone }, payment?:{ method, currency }, shipping?:{ address, method, trackingNumber } }.
  • CreateOrderResponse (O5): { id, orderNumber, status, total, currency }.
  • QrCreateResponse is a very tolerant union (api.service.ts 21-41) — resolvers pick the first present of qrId/qrID/nspkID/…. A backend can emit any of the documented aliases.
  • Error responses: O5 is fire-and-forget — a failure must never block the payment-confirmed flow (source comment, line 635). 401 on a customer-facing /cart//orders has no unified re-auth UX today (§6 §401, Requires backend decision). Payment status polling handles its own timeouts.
  • Validation: none client-side. Amounts/quantities ≥ 0, valid email/phone = Requires backend decision.
  • Example O5 request:
    { "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, statusall|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):
    { "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 entityAdminCustomersFacade 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):
    [ { "email": "ivan@example.com", "name": "Ivan", "phone": "+79990000000", "orderCount": 3, "totalSpent": 187680, "currency": "RUB", "firstOrderAt": "2026-01-10T…", "lastOrderAt": "2026-07-20T…", "addresses": ["…"], "orders": [] } ]
    

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

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

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

3.6 Roles

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

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

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

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

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

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

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

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

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

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

3.9 Homepage — PROPOSED / FUTURE (LOCAL-ONLY)

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

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

# Method Path (PROPOSED / FUTURE) Purpose Success
HP1 GET {base}/builder/homepage Load section/widget composition 200 SectionConfig[]
HP2 PUT {base}/builder/homepage Replace composition (reorder, add/remove sections & widgets) 200 SectionConfig[]
  • Why a single PUT, not granular CRUD: the editor mutates an in-memory tree and saves it whole (undo/redo over History<BootstrapConfig>); there is no per-section/per-widget network operation in the frontend. A whole-composition PUT matches how the client actually works. Granular section/widget endpoints would be new backend design. Requires backend decision.
  • Headers/JWT: builder auth (§3.0). Permission (Proposed): builder.read (HP1), builder.write (HP2).
  • Section ordering is by the numeric order field; widgets carry their own order within a section.
  • Validation: ProjectValidator (client-side, localStorage-scoped). Section type allow-list, widget type must exist in the widget manifest (§3.10) = Requires backend decision.
  • Error responses: 422 (invalid section/widget type or malformed tree), common set.
  • Example HP2 request (trimmed):
    [ { "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, WidgetSettingsSchemasrc/app/widgets/contracts/widget-manifest.contract.ts).
  • Headers/JWT: the manifest URL is bootstrap-driven; public read.
  • Validation/errors: on failure falls back to the static asset. No mutation.

3.10.b Widget instances CRUD — PROPOSED / FUTURE

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

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

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

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

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

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

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):
    { "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):
    { "brandName": "Acme", "legalName": "Acme LLC", "logoUrl": "https://…/logo.svg", "faviconUrl": "https://…/favicon.ico", "supportEmail": "help@acme.com" }
    

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

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

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

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

3.15 Marketplace Settings — PROPOSED / FUTURE

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

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

# Method Path (PROPOSED / FUTURE) Purpose Success
S1 GET {base}/bootstrap Load full config (LIVE — this one exists) 200 BootstrapConfig
S2 PUT {base}/builder/bootstrap (or /builder/publish) Persist the edited bootstrap (publish) 200 BootstrapConfig
S3 GET {base}/builder/settings General tenant settings (feature flags, catalog, SEO) 200 (subset of BootstrapConfig)
S4 PUT {base}/builder/settings Update general settings 200
  • S1 is the only LIVE endpoint here (ApiBootstrapProvider, GET /bootstrap).
  • S2 is the single most important FUTURE decision: a whole-bootstrap publish endpoint vs. the per-section builder endpoints proposed in §3.8-3.14. The frontend has an apiEndpoints.builder record slot for exactly this but no code that calls it. Requires backend decision: whole-document publish vs. granular section writes (and optimistic-concurrency / versioning via BootstrapConfig.schemaVersion/generatedAt).
  • Headers/JWT: builder/settings auth. Permission (Proposed): settings.manage (S2/S4), builder.read/settings read for S1/S3.
  • Validation: ProjectValidator runs client-side before a (currently local) publish. Backend-side schema validation of the whole BootstrapConfig = Requires backend decision.
  • Errors: 422 (invalid config), 409 (concurrent edit — proposed, if versioning adopted), common set.

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

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

# Method Path (PROPOSED) Gateway method Success
T1 GET {base}/backoffice/transactions?search&status&type&page&pageSize loadTransactions(filters) 200 AdminTransactionsListResult
T2 POST {base}/backoffice/transactions/{id}/retry retryFailed(id) 200 AdminTransaction
T3 PATCH {base}/backoffice/transactions/{id}/fraud-flag setFraudFlag(id,flagged) 200 AdminTransaction
  • Headers/JWT: admin auth (§3.0). Permission (Proposed): backoffice.read (T1), backoffice.write (T2/T3).
  • T1 query params (AdminTransactionListFilters): search, statusall|pending|success|failed|retried, typeall|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, statusall|pending|approved|rejected|spam, ratingall 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): categoryall|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 param7 | 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:
    { "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/metrics200 { categoriesCount, productsCount }, permission backoffice.read.


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

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

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

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


4. Authentication

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

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

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

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


Table of contents

  1. Mechanism A — Telegram QR / session login (LIVE)
  2. Mechanism B — Ed25519 challenge/response admin auth (NOT LIVE)
  3. JWT structure
  4. Refresh token
  5. Token expiration handling
  6. Token rotation
  7. Logout
  8. Session invalidation
  9. Role hierarchy
  10. Tenant isolation
  11. Permission model / route guards
  12. Open items — "Requires backend decision"

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

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

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

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

1.2 Frontend-driven flow

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

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

1.3 Session response normalization (backend field tolerance)

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

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

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

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

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

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

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

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

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

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

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

2.2 Login flow, step by step

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

  1. GET {authApiUrl}/api/admin/auth/challengeAuthChallenge { 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/403AuthService 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

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

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

2.5 Error screens

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

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

2.6 Interceptor status — NOT registered

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

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

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

2.7 Module map

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

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


3. JWT structure

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

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)

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

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

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

6. Token rotation

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

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

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

7. Logout

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

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

7.1 Sequence diagram — logout (both mechanisms)

sequenceDiagram
    participant User
    participant FE as Frontend
    participant BE as Backend

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

8. Session invalidation

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

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

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


9. Role hierarchy

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

  1. src/app/core/auth/models/permission.model.ts — a string union used by the Ed25519/JWT flow's role claim and PermissionService:

    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:

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.comacme.
  • 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

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 languageGuardsrc/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) adminAuthGuardAdminAuthService.isAuthenticated()
"Is there an active admin JWT session" Ed25519 (B), not live ed25519AuthGuardSessionService.isAuthenticated()
"Does this role have permission X" Ed25519 (B), not live permissionGuard(permission)PermissionService.has()
"Is :lang valid/enabled" n/a languageGuard
"Unsaved editor changes" n/a *DirtyGuard (project editor / product / category)

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


12. Open items — "Requires backend decision"

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

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

5. Security

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

5.1 Origin

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

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

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

5.2 CORS

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

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

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

5.3 CSRF

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

5.4 JWT

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

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

5.5 Refresh

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

5.6 Rate limiting

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

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

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

5.7 Replay protection

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

5.8 XSS

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

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

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

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

5.9 CSP

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

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

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

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

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

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

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

5.11 Media validation (backend-side expectations)

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

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

5.12 Maximum file sizes

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

5.13 Mime validation

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

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

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

5.14 Audit logging

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

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

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

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

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

5.15 Permission matrix (role × domain × actions)

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

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

Role backoffice.read backoffice.write builder.read builder.write users.manage settings.manage
Owner
Administrator
Editor
Support
ReadOnly

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

Backend requirements / decisions:

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


6. Error Model

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

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

The envelope

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

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

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

Field notes:

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

Status-by-status contract

401 — Unauthenticated / expired token

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

Frontend reaction today:

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

403 — Forbidden (wrong role or tenant)

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

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

404 — Not found

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

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

409 — Conflict

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

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

422 — Validation failure

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

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

429 — Rate limited

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

Frontend reaction today: none whatsoever. No interceptor, facade, or component in the codebase references 429 or "rate limit" in any form (grepped across src/). Requires backend decision on every aspect:

  • Whether the backend sends a Retry-After HTTP header, a body field (retryAfterSeconds above), or both.
  • Whether the frontend should retry automatically (with backoff) or only show the user a "please wait Ns" message. Recommend: since no retry interceptor exists today, add one is a new build item, not a config change.

500 — Server error

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

Frontend reaction today: falls into whichever generic catch-all a given caller has:

  • Ed25519 admin flow: authErrorCodeFromStatus() default branch → `status

    = 500 ? 'backend-unavailable' : 'unauthorized'` → same "Backend unavailable… Retry" screen as a network-down 503 (see below) — the frontend does not distinguish "server is up but this request 500'd" from "server is completely unreachable."

  • Admin list pages (AdminUsersFacade and siblings): generic error boolean signal set to true in the RxJS error: callback, rendering common.errorTitle/common.errorDescription + a retry button that re-invokes the same load call. No status differentiation.
  • Storefront catalog/product pages: same generic empty-state pattern as 404 above.
  • LocationService.getRegions()-equivalent: falls back silently to 6 hardcoded regions on any error (including 500), no user-visible error at all (src/app/services/location.service.ts).

503 — Maintenance / unavailable

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

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

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

Maintenance mode

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

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

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

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

Tenant disabled

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

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

Rate limit

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

Expired token

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

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

Invalid signature

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

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

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


Generic list-page error UI (for reference)

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

readonly error = signal(false);
// on load:
error: () => { this.items.set([]); this.loading.set(false); this.error.set(true); }
@else if (facade.error()) {
  <app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
    <span slot="actions">
      <app-button variant="primary" (click)="facade.loadAll()">{{ 'common.retry' | translate }}</app-button>
    </span>
  </app-empty-state>
}

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


Summary: "Requires backend decision" items

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

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

7. Uploads

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

7.1 The MediaAsset contract (real interface)

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

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

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

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

The abstract MediaRepository (the exact backend contract) is:

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

7.2 Media upload

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

7.3 Replace

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

7.4 Delete (single + bulk)

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

7.5 Metadata

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

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

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

7.6 Image variants

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

7.7 Thumbnail generation

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

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

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

7.8 Limits

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

7.9 Future S3 compatibility

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

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

8. Real Backend Implementation Guide

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

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

8.1 Which frontend gateways must be replaced

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

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

Read this table as three tiers:

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

8.2 Interfaces that stay unchanged (do not touch)

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

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

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

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

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

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

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

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

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

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

8.4 DTO mapping examples (worked)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

8.7 Estimated effort (frontend-informed)

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

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

9. Backend Checklist

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

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

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

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

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

Phase 3 — Write-heavy customer domains

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

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

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

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

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

Phase 6 — Hardening

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


10. Maintenance Mode

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

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

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


1. Global maintenance

Whole platform down for all tenants.

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

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

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

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

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

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


2. Per-tenant maintenance

Single tenant disabled while others operate normally.

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

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

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

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

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


3. Per-module maintenance

E.g. payments down but catalog still browsable.

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

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

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

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

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


4. Read-only mode

Writes disabled, reads still work.

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

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

What the backend should send: 503 (or 403, see note below) with the structured body (§7), scope: "readonly", on write endpoints specifically — POST /cart, POST /orders, POST /purchase-email, POST /items/{id}/callback, POST /items/{id}/questiion, POST /websession/{sessionId} (cart sync) — while GET endpoints keep working. 403 Forbidden is arguably more correct REST semantics for "this resource forbids this method during a maintenance window" than 503, but 503

  • Retry-After communicates "temporary" more clearly to a client and is recommended so the frontend can offer a countdown/retry consistent with §5's pattern. Requires backend decision: which status code is authoritative — this should be pinned down jointly with whatever §6 settles on for its 5xx conventions, since read-only is really "a subset of write endpoints return maintenance-503."

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


5. Scheduled maintenance

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

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

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

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

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

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


6. Temporary feature disable

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

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

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

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


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

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

Field notes:

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

Per-scenario summary:

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

8. Frontend behavior

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

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

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

Already exists and can be reused as-is:

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

Proposed/new (this document introduces):

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

Requires backend decision (full list)

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

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

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

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

11. Seller Management (Optional Capability)

Status legend for this section only (matches docs/architecture/foundation/Seller-Management.md, the frontend-side capability doc this section is the backend counterpart to):

  • Implemented — exists in frontend source today, verified.
  • Planned — a typed contract or explicit ADR decision exists; no backend or runtime behavior exists yet.
  • Future — named for roadmap completeness only. No shape, contract, or decision exists. Do not build against a Future item without a new ADR.

Nothing in this section is implemented on the backend, because no backend exists yet for anything in this document. Frontend-side preparation (typed models, a disabled-by-default feature flag, one Backoffice placeholder page) is real and is marked Implemented per item below; backend work is entirely Planned or Future. This section documents the frontend's existing preparation and the shape a backend should target — it does not invent new frontend behavior, and per this section's mission, it must not be read as backend implementation guidance beyond documentation.

11.1 Overview

Seller Management is an optional platform capability: a marketplace (tenant) may host zero or more independent sellers, each scoped beneath it. It is not a second tenancy tier — see §11.5 Domain Resolution. Full frontend-side detail: docs/architecture/foundation/Seller-Management.md and its companion ADR-011.

11.2 Future entities

None of these have a backend schema, migration, or storage decision yet. Frontend typed equivalents (where they exist) are cited per entity.

  • MarketplaceImplemented (existing TenantConfig, unchanged by this work). The plus a new Planned frontend-only reference type, MarketplaceRef (core/sellers/models/marketplace-ref.model.ts): {id, slug, name}, the minimal view of a marketplace as seen from a seller record. Not a backend entity of its own — it's a projection of the existing Marketplace/tenant entity.
  • SellerPlanned (frontend type exists: Seller, core/sellers/models/seller.model.tsid, marketplace: MarketplaceRef, name, slug, status: SellerStatus, optional branding: SellerBranding, createdAt/updatedAt). No backend table, migration, or endpoint exists.
  • SellerUserFuture. No frontend type exists for a user record scoped to a specific seller (distinct from the existing marketplace-level AdminUser, features/admin/users/models/admin-user.model.ts). Named here for roadmap completeness only — shape not decided.
  • SellerBrandingPlanned (frontend type exists: SellerBranding, core/sellers/models/seller-branding.model.ts — logo, banner, description, contacts, address, theme overrides, every field optional). No backend storage or rendering/precedence logic exists (frontend side of precedence — falling back to marketplace branding — is also undecided, per Seller-Management.md §6).
  • SellerSettingsFuture. No frontend type, no backend concept. Distinct from SellerBranding (visual) — would cover operational settings (e.g. notification preferences, payout details) if this capability is ever built out. Not designed.
  • SellerInvitationFuture. No frontend type, no backend concept. The onboarding mechanism implied by the existing Phase 1 UI's "Request Access" form (features/admin/seller-management/pages/ admin-seller-management-page.component.ts) is a mocked, client-side-only submission today — no email is sent, no invitation record is created, nothing persists past the browser session. A real invitation flow (backend-issued invitation token, acceptance, seller-account creation) is entirely Future.

11.3 Future endpoints

No endpoint below exists. Each would follow this codebase's established mock↔API-gateway pattern (§8) once built — that pattern is a statement of intent here, not a designed URL/DTO contract.

  • Seller CRUDFuture. No SellerRepository/SellerGateway interface exists yet (unlike e.g. Categories, §3.2, which already has this seam). Would need list/get/create/update/delete following the conventions in §2 (pagination, filtering, error envelope) once designed.
  • Seller ActivationFuture. Ties to SellerStatus ('pending' | 'active' | 'suspended' | 'disabled', frontend type only, no transition logic anywhere) and to SellerInvitation above. No state-machine, no endpoint, no decision on who can activate/suspend a seller.
  • Seller InvitationsFuture. See SellerInvitation above — this would be the endpoint surface for that entity, once it exists.
  • Seller BrandingFuture (endpoint). The SellerBranding type is Planned (§11.2) but no read/write endpoint exists to serve or persist it.
  • Seller AnalyticsFuture. The existing AdminAnalyticsFacade (§3.20) already has no real aggregation endpoint for the marketplace as a whole — per-seller analytics is a further layer on top of a gap that isn't solved yet even without sellers. Not designed.
  • Seller DashboardFuture. Same reasoning as Seller Analytics; the existing AdminDashboardMetricsGateway.loadMetrics() (§3, Dashboard) is a bare no-arg call with no parameter to scope by seller even syntactically — a seller dashboard is new surface, not an extension.

11.4 Authentication

No authentication change of any kind exists in this work. The four roles below are a Planned vocabulary only (SellerPermissionRole, core/sellers/models/seller-permissions.model.ts): marketplaceOwner, seller, sellerStaff, platformAdmin. This is explicitly a separate vocabulary from the existing, live AdminRole (Owner/Manager/Support/ReadOnly, core/auth/models/permission.model.ts, §4) — not merged, not wired into any guard, no route currently checks it.

Closest existing hook (per Seller-Management-Backoffice-Readiness-Audit.md): AdminUser already carries an AdminUserScope field ('marketplace' | 'office' in seed data) and a permission-array shape on AdminRole — the natural future home for these four roles, but today it's used for display labels only, nothing gates on it. Future: deciding whether SellerPermissionRole extends, replaces, or sits alongside AdminRole; wiring any guard/interceptor to check it; deciding how a Seller/Seller Staff session authenticates at all (same Telegram/Ed25519 mechanisms as today, §4, or something new — not decided).

11.5 Domain resolution

market.com              → Marketplace                (Implemented — ADR-001, live today)
nike.market.com         → Marketplace → Seller        (Future — no backend resolves this yet)

Implemented today: tenant (Marketplace) resolution is entirely backend-side by request Host (ADR-001, §1.9). The frontend never resolves tenant identity itself — it calls GET /bootstrap and renders whatever comes back for whatever hostname it's running on.

Future: a second-level Host pattern ({seller}.{marketplace-domain}) resolving to a seller scope beneath that marketplace. No backend logic for this exists. Per the frontend-side storefront audit (Seller-Management-Storefront-Audit.md), the frontend's existing discipline — nothing hardcodes hostname, canonical URLs already derive from location.origin dynamically — means this pattern needs no frontend routing change once a backend resolves it; the open work is entirely backend resolution logic plus which bootstrap fields carry the resolved seller data (see §11.6).

11.6 Bootstrap additions

All Implemented as typed contracts, always absent/false in every real bootstrap response today — no backend populates any of these fields anywhere:

  • modulesBootstrapConfig.modules?: PlatformModulesConfig (shared/models/config/platform-modules.model.ts). Optional; absent means every module is disabled.
  • modules.sellerManagement{enabled: boolean}, defaults to false (DEFAULT_PLATFORM_MODULES_CONFIG). Read by the Phase 1 page today; no backend ever sets it true.
  • sellerScopePlanned. The frontend has a domain-level SellerScope type (core/sellers/models/seller-scope.model.ts: {sellerId, marketplaceId}) and a bootstrap-wire counterpart, BootstrapConfig.seller?: SellerConfig (shared/models/config/seller.model.ts) — present only when the backend has resolved a seller-level request scope (§11.5). No backend does this yet; the field is optional and always absent today.
  • sellerBrandingPlanned. Carried as SellerConfig's branding shape would need to be, or as BootstrapConfig.seller?.branding — the exact nesting isn't finalized, since SellerConfig (bootstrap wire shape) and SellerBranding (domain type) are currently separate types that haven't been reconciled (see Seller-Management-Domain-Models.md). Flagged as an open question here rather than guessed at.
  • permissionsImplemented (existing, unchanged) — BootstrapConfig.permissions: PermissionsConfig already exists and is unrelated to Seller Management's future permission model; the two are not connected today. Future: whether/how SellerPermissionRole (§11.4) ever surfaces through this existing field or a new one — not decided.

11.7 Checkout modes

Future — not designed. Two named concepts, no decision between them:

  • Unified Order — one customer purchase, regardless of how many sellers' items are in the cart, produces one order record.
  • Split Orders — one customer purchase spanning multiple sellers produces one order record per seller.

Current, unrelated to sellers: checkout today is a single inline popup flow (pages/cart/cart.component.ts) producing exactly one order via one POST /orders-equivalent call — there is no multi-seller cart concept of any kind to split or unify, because every product is marketplace-owned. This decision has real consequences (payments, refunds, reporting) that depend on it — explicitly listed as open in Seller-Management.md §6 and repeated here because it's the single most consequential undecided item for backend design once this capability moves past preparation.

11.8 Product ownership

Implemented (schema only), Future (logic). sellerId?: string was added as an optional field to three existing frontend types: Item (storefront product, models/item.model.ts), AdminProduct (features/admin/products/models/admin-product.model.ts), and AdminOrder (features/admin/orders/models/admin-order.model.ts). In every case, absent means marketplace-owned — exactly what every existing product and order is today. No frontend code reads or writes this field anywhere. No backend column, migration, or filter exists for it either.

Compatibility guarantee, verified not assumed: adding this optional field required zero changes to any consumer of these three types — confirmed by tsc --noEmit staying clean after the change (Seller-Management-Domain-Models.md). The same guarantee applies to any future backend schema change: a nullable seller_id column (or equivalent) that defaults to NULL/absent preserves every existing product/order exactly as-is. Existing products remain valid with no migration required to backfill a value — NULL/absent seller ownership is a first-class, permanent state, not a transitional one.


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

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

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

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