Goal: this document alone, pasted into a fresh Claude session, should be enough for a backend dev to implement against without getting stuck or inventing conventions the frontend doesn't actually need. Fixed a real bug: ~65 cross-references throughout the document pointed to docs/AUTHENTICATION.md, docs/ERROR_CONTRACT.md, docs/MAINTENANCE_MODE.md - three sibling docs that were deleted and fully merged into this document's own §4/§6/§10 during the earlier doc-consolidation pass, but the in-text references were never updated. A fresh agent following those links would hit dead ends repeatedly. Bulk-replaced with in- document section references; hand-fixed ~4 sentences that framed §6/ §10 as "sibling task, in progress" (stale - both are complete, this document's §6/§10 already are the settled contract, nothing to wait on). Added "Recommended default" to every item across the document's 5 consolidated "Requires backend decision" registers (§2.12 framework, §3.21 CRUD cross-cutting - 18 items, §4 §12 auth open items - 9 items, §6 error-model summary - 9 items, §10 maintenance-mode list - 5 items). Each default is derived from what the frontend already implies or standard REST/security convention - no invented APIs or business rules. ~6 items are explicitly flagged as real business/security decisions instead (order state-machine rules, Ed25519 cutover strategy, refresh-token reuse-detection posture) since those carry consequences no amount of frontend-code-reading can resolve. Added a "How to use this document" preamble up front: work in §9's dependency order not document order, apply recommended defaults and keep moving, only stop for the explicitly-flagged business/security items, don't invent beyond what's written or directly implied. Frontend side: confirmed nothing else is left. docs/TODO.md already has zero blockers; docs/KNOWN-ISSUES.md's one open item (unreachable Ed25519 error-code UI) is correctly left open and documented rather than faked closed - fixing it needs a real backend emitting real distinguishable error codes, which doesn't exist yet and can't be fabricated without inventing an API contract. No frontend code touched. No architecture changed. No APIs invented.
270 KiB
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.
- 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.
- 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.
- 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.
- 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.
- §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.
- 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
- Bootstrap
- Endpoint Documentation Framework
- CRUD Contracts
- Authentication
- Security
- Error Model
- Uploads
- Real Backend Implementation Guide
- Backend Checklist
- Maintenance Mode
1. Bootstrap
The bootstrap document is the single runtime-configuration payload that drives the entire multi-tenant storefront/builder/backoffice. It is fetched once at app startup and held in memory; nearly every feature (theme, navigation, localization, catalog behavior, static pages, feature flags, widget registry) reads from it rather than from dedicated per-feature endpoints.
Source of truth for this section:
src/app/core/bootstrap/providers/api-bootstrap.provider.ts,
src/app/core/config/config.service.ts,
src/app/core/config/api-config.service.ts,
src/app/core/config/tenant-resolver.service.ts,
src/app/shared/models/config/*, and the mock document
src/assets/mock/bootstrap/bootstrap.json. Cross-referenced against
docs/context/BACKEND-AUDIT.md §6, §2 and the prior docs/archive/BACKEND_API.md.
1.1 Request contract
| Property | Value |
|---|---|
| Method | GET |
| Route (API mode) | /bootstrap — relative; rewritten onto the resolved tenant API base by apiBaseUrlInterceptor (see §1.10) |
| Route (mock/local mode) | GET /assets/mock/bootstrap/bootstrap.json (static asset, no backend) |
| Request body | none |
| Query params | none |
| Auth requirement | None. Bootstrap is fetched before any login and must be publicly cacheable per tenant. It carries no Authorization header and no WebSessionID is required for it to succeed. |
Headers. The provider itself (ApiBootstrapProvider) sets no explicit
headers — it issues a plain HttpClient.get<BootstrapConfig>('/bootstrap').
Because the URL is /api-relative only after interceptor rewrite (the literal
is /bootstrap, not /api/bootstrap), whether apiHeadersInterceptor's
marketplace headers (X-Region, X-Language, Currency, WebSessionID)
attach depends on ApiConfigService.isApiRequest() matching the resolved URL.
In practice the backend must treat all of these as optional on the
bootstrap call — none are load-bearing for it, and the document is expected to
resolve from tenant/origin alone (§1.9).
Requires backend decision: whether the bootstrap endpoint should honor an
X-Language/Accept-Languagehint 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) whenenvironment.useMockData === true, or whenenvironment.useMockBootstrapOnLocal === trueand the host is localhost. - Otherwise returns api (
ApiBootstrapProvider→GET /bootstrap).
With the current environment.ts (useMockData: false,
useMockBootstrapOnLocal: true), a localhost dev session reads the static
bootstrap.json; a deployed tenant host reads the live GET /bootstrap.
1.3 Response shape — BootstrapConfig
Copied faithfully from
src/app/shared/models/config/bootstrap-config.model.ts:
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 byThemeEngineService/BrandingEngineServiceto 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) — aRecord<string, boolean>with well-known keyswishlist, compare, reviews, questions, comments, recommendations, blog, chat, analytics, notifications, coupons, loyalty, giftCards, invoicesplus 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> }whereApiEndpointConfig = { path, method, timeoutMs? }. This is where a tenant declares its planned per-surface endpoint paths at runtime. Thewebsite/builder/backofficemaps 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 areru, en, hy; the marketplace API uses codesRU, EN, AM(mapped byapiHeadersInterceptor/ApiService.normalizeLang,am↔hy).seo(SeoConfig,seo.model.ts) —{ default: SeoPageConfig, byPageKey: Record<pageKey, SeoPageConfig> }whereSeoPageConfig = { title, description, canonicalUrl?, robots?, metaTags? }.permissions(PermissionsConfig,permissions.model.ts) —{ definitions: [{ key, description? }], roles: [{ role, permissions[] }] }. Bootstrap-level RBAC catalog. (Distinct from the Ed25519 JWTAdminRoleunion — see§4 (Authentication, this document)§9 for the flaggedAdminRolenaming 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/availableSortsenumeraterelevance | latest | price_asc | price_desc | rating | popular | discount(see §2.3). Defaults inDEFAULT_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? }— notevisibleWhenFlagslets a nav item be gated on afeatureFlagskey.footer(FooterConfig,footer-config.model.ts, optional) —{ logoUrl?, paymentIcons?, copyrightText? (string|localized), columns? (FooterColumnConfig[]), socialLinks? }.legalPageKeys/staticPageKeysare@deprecatedin favor ofcolumnsbut 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}. Notereviews.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 inDEFAULT_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 orderedwidgetswithprops(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) — eitherRecord<slug, StaticPageConfig>or a legacy array. Each page carries localizedtitle+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 URLWidgetManifestServicefetches 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:44surfaces it as a diagnostic entry (['schemaVersion', bootstrap.schemaVersion]).AdminDashboardFacade(admin-dashboard.facade.ts:110) derives thedashboard.healthBootstrapValidhealth check purely ashealthy: !!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
schemaVersionshould 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_INITIALIZERwiring bootstrap fetch as a hard app precondition.main.tscallsbootstrapApplication(App, appConfig)with no initializer that blocks on config. (§4 (Authentication, this document)§5.2 separately notes the Ed25519restoreSession()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, theFooterComponent, etc. BecauseConfigServicede-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:langroute) 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 sharedbootstrap$observable withshareReplay(1). loadBootstrap(forceRefresh = false): returns the cachedbootstrap$when a snapshot exists andforceRefreshis false; otherwise (re)issues the provider call.- On error, it clears both
bootstrap$andbootstrapSnapshotso the next call retries cleanly (catchErrorrethrows). - A
bootstrapRevisionsignal increments on every successful load/override — consumers use it to react to config changes. getBootstrapSnapshot()returns the current in-memory value synchronously (used byWidgetManifestService, 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/bootstrapresponse 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 leadingwww(e.g.dexarmarket.api.dexarmarket.ru→dexarmarket;www.acme.com→acme).- That tenant key feeds
ApiConfigService.getBaseUrl(), which picks the marketplace API base: localhost →environment.localhostApiUrl(/api); elseenvironment.tenantApiBaseUrls[tenantKey]; elseenvironment.tenantApiTemplatewith{tenant}substituted; else (only whenallowBootstrapApiOverrideis true — off by default) a value read out of the already-loaded bootstrap (apiEndpoints.website.baseUrl/tenant.apiBaseUrl). - No
X-Tenantheader or/tenant/{id}path prefix is ever sent. Tenant isolation forGET /bootstrapis 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 viaConfigService.loadBootstrap(true)(force refresh), edits an in-memoryHistory<BootstrapConfig>(undo/redo), and persists drafts to localStorage throughProjectEditorDraftStorageService. 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 underapiEndpoints.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)
ConfigServicedoes 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/BrandingEngineServicesubscribe withtake(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);WidgetManifestServicefalls 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.errorTitlepattern 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 forGET /bootstrapagainst a known-but-inactive tenant.§6marks the frontend handling for this as "Requires backend decision" (no code path handles it today).MAINTENANCE_MODE(HTTP 503,error.code: "MAINTENANCE_MODE") — if a tenant is down for maintenance, bootstrap is where the frontend would first hit it. Again, no maintenance concept exists frontend-side today.
2. Endpoint Documentation Framework
This section defines the general contract every endpoint in the system follows, so the reader has the framework before the per-domain endpoint catalog (a separate section). Where the frontend does not define something, it is marked "Requires backend decision" rather than invented.
2.1 Standard success response envelope
There is no ApiResponse<T> / { success, data, error } envelope in this
codebase. Every live caller types its HttpClient call to the bare payload
type and consumes the raw JSON directly:
ApiService.getItem()→HttpClient.get<Item>(...)(the item object itself).ApiService.getCategories()→get<Category[]>(bare array).ApiBootstrapProvider→get<BootstrapConfig>(bare object).TelegramSessionApiService→get<Record<string, unknown>>then normalizes.
The generic ApiResponse<T> shape in the org coding-standards is not used
here — do not assume an envelope. Success responses are the raw resource
(object, array, or { items, total } for lists — see §2.2).
Requires backend decision: whether to introduce a success envelope going forward. If adopted it would be a breaking change to every existing live caller listed in §2.6 and BACKEND-AUDIT §4, each of which currently expects the bare payload. Recommendation: keep bare payloads for the existing live endpoints; only new endpoints could opt into an envelope, and even then the frontend has no envelope-unwrapping layer today (one would be new work).
2.2 Standard pagination contract
Two distinct pagination shapes already exist in the frontend; a backend should align to these rather than invent a third:
A) Offset/count style (marketplace list + search endpoints) — used by the
live ApiService:
- Request query params:
count(page size, default 50) andskip(offset, default 0). Applied onGET /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). SeeAdminOrderListFilters,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 derivestotalPages = ceil(total / pageSize)client-side (e.g.pagination.component.ts, admin list pages).
Notes / open items:
- The two styles use different param names (
count/skipvspage/pageSize) — a backend serving both storefront and admin must support both, or a reconciliation decision is needed. - No cursor/keyset pagination anywhere. The only
cursormatches in source are CSScursor:properties. There is nonextCursor/hasMoreon the wire (catalog-state.model.tshas a client-derivedhasMorefor infinite-scroll, computed asskip + pageSize < total, not a backend field).
Requires backend decision: exact page-size limits/maximums (the frontend sends 50 as a default
countbut 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
sortonGET /searchitems(ApiService.searchItems), whose accepted set is a subset:relevance | price_asc | price_desc | popular | rating(the code'soptions.sortunion).latest/discountare catalog-config values without a confirmed search-endpoint mapping. - Admin lists sort primarily via their filter objects / client-side; there is
no shared admin
sortquery-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
sortvocabulary (5 values) — and define the wire encoding for admin list sorting (field + direction) when those endpoints are built.
2.4 Standard filtering contract
Filtering shapes already used by the frontend:
- Storefront search/catalog —
GET /searchitemsaccepts optionalcategoryIDs(comma-joined ints),minPrice,maxPrice,tag, plussort(§2.3).CatalogConfig.enabledFiltersdeclares 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 textsearchstring 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 issearch. - The
SearchFacade(BACKEND-AUDIT §18) is a client-side orchestration overProductFacade/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 explicitT | null(e.g.AuthSession.userId: number | null,AuthSession.username: string | nullwherenullspecifically means "known to be absent"). - The tolerant normalizers (
ApiService.normalizeItem/normalizeCategory,TelegramSessionApiService.normalizeWebSession) coalescenull/undefined/ missing uniformly via??and default fallbacks — they never branch onnullvsundefined. - Convention to follow: the backend may omit an optional key or send
nullinterchangeably; both are handled. Explicitnullis only meaningful where a model types the field as… | nullto 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 aWebSessionIDheader (auth session id, or a persisted anonymous 32-hex id fromlocalStorage['web_session_id']) attached to marketplace API requests byapiHeadersInterceptor. Admin requests additionally carry anAdminWebSessionIDheader (adminAuthHeadersInterceptor, gating URL segments/admin/,/backoffice/,/builder/,/media/). - Ed25519 admin auth (wired, backend absent) — issues a JWT
AuthTokenPair { token, refreshToken }; the intended header is standardAuthorization: Bearer <token>, but itsauthInterceptoris not registered today (§4 §2.6), so no request auto-attaches the bearer token yet.adminAuthHeadersInterceptordoes setAuthorization: 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-Languagehandling onGET /bootstrap(§1.1). - Bootstrap: whether
schemaVersionis 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
sortvocabulary (5); wire encoding for admin list sorting (§2.3). - Framework: query-string encoding for admin list filters and the
'all'sentinel (§2.4). - Framework: server-side autocomplete/trending search endpoints + min-query contract (§2.5).
- Framework: API path/header versioning scheme, if any — none exists today (§2.10).
- Framework: whether payment-status polling and session revocation should become push-based (WebSocket/SSE) instead of polling (§2.11).
3. CRUD Contracts
Exhaustive per-domain, per-endpoint contract for every backend touch-point the
Angular frontend expects. Derived from source on branch B2B and cross-checked
against docs/context/BACKEND-AUDIT.md (the this-session audit — the ground
truth for what code actually does), the prior docs/archive/BACKEND_API.md, and the two
sibling specs written this session:
- Auth / headers / JWT — see
§4 (Authentication, this document). This section never re-defines the auth header format; it references it. - Error response envelope + per-status semantics — see
§6 (Error Model, this document). This section names which statuses apply per endpoint and why, but the wire shape of the error body is owned by that doc.
3.0 Conventions used in this section
Two API base URLs are in play (BACKEND-AUDIT.md §2):
- Marketplace / tenant API —
ApiConfigService.getBaseUrl()(src/app/core/config/api-config.service.ts). Localhost →/api; otherwise a per-tenant origin (defaulthttps://api.dexarmarket.ru:445). Tenant isolation is by subdomain/base-URL only — noX-Tenantheader, no/tenant/{id}prefix (§4§10). All storefront reads and all admin CRUD go here. - Payment / QR API —
environment.qrApiUrl(https://qr.vitanova.network/api). Only cart/order payment + status polling (§3.3). - Session-auth API —
environment.authApiUrl. Login/session only, covered by§4 (Authentication, this document), not repeated here.
LIVE vs PROPOSED paths. Only the storefront catalog/cart/engagement reads and
the single AdminCategoriesApiGateway have literal HTTP paths in code. Every
other admin CRUD path below is PROPOSED — no URL literal exists anywhere in
the frontend for it (BACKEND-AUDIT.md §24: "No literal /admin/*, /builder/*,
or per-admin-domain backoffice CRUD paths exist in code"). The admin gateways are
in-memory *LocalGateway classes that never construct a URL. The proposals here
follow the one real precedent in the codebase — AdminCategoriesApiGateway
(src/app/features/admin/categories/services/admin-categories-api.gateway.ts),
which uses:
GET {base}/backoffice/categories ?search&visibility&includeDeleted
GET {base}/backoffice/categories/{id}
POST {base}/backoffice/categories (body = category minus server-owned fields)
PUT {base}/backoffice/categories/{id}
DELETE {base}/backoffice/categories/{id} (soft delete)
POST {base}/backoffice/categories/{id}/restore
GET {base}/backoffice/categories/slug-taken ?slug&excludingId
All other admin paths below are proposed by analogy to this shape ({base}/backoffice/<domain>).
Builder/config domains (nav, footer, branding, languages, CMS, homepage, widgets)
are proposed under {base}/builder/... because the bootstrap document declares a
apiEndpoints.builder record for exactly this, but no builder write call exists
in code today — the entire builder/CMS surface is LOCAL-ONLY (in-memory
bootstrap + localStorage drafts), so those endpoints are marked PROPOSED /
FUTURE throughout.
Headers (all requests). Marketplace API requests carry X-Region,
X-Language (RU|EN|AM), Currency (default RUB), and WebSessionID
(apiHeadersInterceptor, BACKEND-AUDIT.md §3). Requests whose URL contains
/admin/, /backoffice/, /builder/, or /media/ additionally get
AdminWebSessionID and, if an admin token is stored, Authorization: Bearer <token> (adminAuthHeadersInterceptor). These header names/values are owned by
§4 — assume them on every admin endpoint below unless stated.
JWT requirement. No endpoint in the app requires a verified JWT today.
Mechanism A (Telegram session) authenticates admins with an opaque
AdminWebSessionID, not a JWT; the Ed25519 JWT flow (Mechanism B) is fully wired
but dormant (§4 §2). For every admin endpoint below, "JWT
requirement" is therefore stated as: Today: AdminWebSessionID session header
(Mechanism A). Target: Authorization: Bearer <Ed25519 JWT> once Mechanism B is
cut over. Server-side authorization must be enforced regardless of any
client-side guard (§4 §11.6).
Required permission / role. The frontend's only permission model is the
coarse ROLE_PERMISSIONS table (§4 §9.1): backoffice.read,
backoffice.write, builder.read, builder.write, users.manage,
settings.manage. The frontend does not enforce per-domain permissions on
admin CRUD today (the live adminAuthGuard only checks "is there an admin
session at all"). Where a specific permission is the obvious fit it is named as
Proposed; any finer per-role assignment is Requires backend decision
(§4 §9.1 already flags fine-grained permissions as out of scope).
Error responses. Unless a domain-specific note says otherwise, every endpoint
below can return the envelope from §6 with these statuses:
401 UNAUTHENTICATED (no/expired session), 403 FORBIDDEN (wrong role/tenant),
500 INTERNAL_ERROR, 503 SERVICE_UNAVAILABLE/MAINTENANCE_MODE. Per-endpoint
notes below add 404, 409, 422, 429 only where they are meaningful. The
frontend does not parse the error body today (§6 "Finding") —
admin list pages collapse every non-2xx into one generic "retry" state
(§6 "Generic list-page error UI"). So all per-status handling
below is the contract the backend should honor, not behavior the current UI
distinguishes.
Validation rules. Admin create/edit forms
(admin-product-form.component.ts, admin-category-form.component.ts) use no
Angular reactive Validators — confirmed by source search (zero
Validators.* matches). Form state is built by factories
(admin-products-form.factory.ts, admin-categories-form.factory.ts) that seed
empty defaults (e.g. slug: ''). Therefore the frontend enforces essentially
no field validation on admin CRUD; every validation rule below beyond "the
field exists in the model / is non-nullable in TS" is Requires backend
decision. The one real client-side validation convention lives in the builder
(ProjectValidator → ProjectEditorFacade.fieldError()), which is client-only
and localStorage-scoped, not backend-fed.
Pagination. The admin list models that paginate use a uniform shape:
filters { …, page: number, pageSize: number } in, { items[], total, page, pageSize } out (products, orders, transactions, reviews). Categories, users,
monitoring, customers, analytics return plain arrays with no pagination —
noted per domain.
3.1 Products
Two distinct surfaces: storefront catalog read (LIVE) and admin CRUD
(MOCK-ONLY, no seam — BACKEND-AUDIT.md §14).
3.1.a Storefront catalog read — LIVE
Backed by ApiService (src/app/services/api.service.ts) via
ApiProductDataProvider → PRODUCT_DATA_PROVIDER → ProductFacade. The wire
DTO is Item (src/app/models/item.model.ts); ApiService.normalizeItem() is
the tolerance contract (the single largest inline mapper — a backend engineer
should treat it as authoritative for accepted field name variants; see
BACKEND-AUDIT.md §7).
| # | Method | Path (LIVE literal) | Purpose | Success |
|---|---|---|---|---|
| P1 | GET | {base}/items/{itemID} |
Single product | 200 single Item (raw wire shape) |
| P2 | GET | {base}/category/{categoryID}?count&skip |
Products in a category | 200 Item[] |
| P3 | GET | {base}/searchitems?search&count&skip[&categoryIDs&minPrice&maxPrice&tag&sort] |
Search | 200 { items: Item[], total: number } |
| P4 | GET | {base}/items/randomitems?count[&category] |
Featured/random | 200 Item[] |
- Headers/JWT: marketplace headers (§3.0). No JWT; anonymous
WebSessionIDis sufficient. Public read. - Query params (P2/P3/P4):
count(default 50),skip(default 0, offset pagination). P3 search:search(string, required),categoryIDs(comma-joined numeric ids),minPrice,maxPrice(numbers),tag(string),sort∈relevance | price_asc | price_desc | popular | rating. Signature:ApiService.searchItems()lines 551-590. - Pagination/sorting/filtering: offset-based (
count/skip); sort + price- category + tag filters only on P3. P2 has no sort/filter params. Response P3
is the only one returning a
totalfor page math.
- category + tag filters only on P3. P2 has no sort/filter params. Response P3
is the only one returning a
- Error responses:
404whenitemIDdoesn'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/503generic. 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),visibility∈all|visible|hidden,stock∈all|in_stock|low_stock|out_of_stock,includeArchived(bool),sort∈title|price|priority|stock|updated,page(number),pageSize(number). This is the full pagination + sort + filter + search surface — no other sort keys exist. ResponseAdminProductsListResult { items, total, page, pageSize }. - AP4/AP5 request body: an
AdminProduct. By the category precedent (AdminCategoriesApiGateway.createCategorystripsid, itemsCount, deletedAt, createdAt, updatedAt), the create body should omit server-owned fields (id,createdAt,updatedAt; alsoreviews/questionswhich are derived). Update sends the full object withidin 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 ofslug/sku, price ≥ 0, etc. are all Requires backend decision (noisSlugTakenequivalent exists for products, unlike categories). Recommended422 VALIDATION_FAILEDwithdetails[].fieldforsku/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) vianormalizeCategory(). Clean wire DTO:CategoryDto/CategoryNameDto(src/app/core/categories/dto/category.dto.ts) viaCategoryMapper. Both toleratenames[](multi-lang, incl. thevaluuetypo variant), nestedsubcategories[],img↔icon, string↔numeric ids. - Headers/JWT: marketplace headers, no JWT, public.
- Response
CategoryDtofields: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 differentCategoryTS types exist — flag for backend/naming reconciliation.
3.2.b Admin categories CRUD — LIVE (real HTTP gateway exists)
Contract: AdminCategoriesGateway. Real impl: AdminCategoriesApiGateway
(literal paths below — the canonical precedent for all other admin domains).
Mock impl: AdminCategoriesLocalGateway. Bound via ADMIN_CATEGORIES_GATEWAY
token (MOCK-SWAPPABLE — this domain can be rebound to the real gateway today).
DTO: AdminCategory (src/app/features/admin/categories/models/admin-category.model.ts).
| # | Method | Path (LIVE literal) | Gateway method | Success |
|---|---|---|---|---|
| AC1 | GET | {base}/backoffice/categories?search&visibility&includeDeleted |
loadCategories(filters) |
200 AdminCategory[] |
| AC2 | GET | {base}/backoffice/categories/{id} |
loadCategory(id) |
200 AdminCategory / null on error |
| AC3 | POST | {base}/backoffice/categories |
createCategory(cat) |
201 AdminCategory |
| AC4 | PUT | {base}/backoffice/categories/{id} |
updateCategory(cat) |
200 AdminCategory |
| AC5 | DELETE | {base}/backoffice/categories/{id} |
deleteCategory(id) |
204 (soft delete) |
| AC6 | POST | {base}/backoffice/categories/{id}/restore |
restoreCategory(id) |
200 AdminCategory / null |
| AC7 | GET | {base}/backoffice/categories/slug-taken?slug&excludingId |
isSlugTaken(slug,excludingId) |
200 { taken: boolean } |
- Headers/JWT: admin auth (§3.0).
- Permission (Proposed): AC1/AC2/AC7 →
backoffice.read; AC3-AC6 →backoffice.write. - AC1 query params (
AdminCategoryListFilters):search(string),visibility∈all|visible|hidden,includeDeleted(bool sent as string). No pagination — returns a plain array. No sort param. - AC3 request body:
AdminCategoryminusid, itemsCount, deletedAt, createdAt, updatedAt(server-owned — stripped by the real gateway, line 38). AC4 sends the full object,idin path. - AC7: proactive slug-uniqueness pre-check the frontend calls before
submit (
excludingIdomitted 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 returnsfalse(best-effort, TOCTOU-prone —§6§409). - Validation: slug uniqueness via AC7; everything else none client-side.
title,slugare non-nullable in the model. Recommended409 CONFLICTon AC3/AC4 slug collision as a race backstop (§6§409), and422for field errors. - Error responses: AC2/AC6 swallow errors →
null(so a404there renders as "not found"/empty, not an error screen). AC5 hard404if unknown.409slug 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).
deletedAttimestamps the soft delete.
3.3 Orders
Two surfaces: checkout order creation (LIVE, storefront) and admin order
management (MOCK-ONLY, no seam). These are separate models — the storefront
CreateOrderRequest/CreateOrderResponse (inline in api.service.ts) is not the
admin AdminOrder.
3.3.a Cart → payment → order creation — LIVE
Cart contents are LOCAL-ONLY (CartService, localStorage key
marketplace_cart + Telegram CloudStorage — no cart backend). Checkout produces
these live calls (BACKEND-AUDIT.md §10). Payment DTOs are inline in
src/app/services/api.service.ts.
| # | Method | Path | Base | Request → Response |
|---|---|---|---|---|
| O1 | POST | /cart |
marketplace | CartPaymentRequest → QrCreateResponse (creates the QR/card payment) |
| O2 | POST | /qr |
qrApiUrl (headers authorization-key, userid-value) |
QrCreateRequest → QrCreateResponse |
| O3 | GET | /qr/dynamic/{partnerId}/{qrId} |
qrApiUrl | → QrDynamicStatusResponse (poll) |
| O4 | GET | /card/{partnerId}/{orderId} |
qrApiUrl | → QrDynamicStatusResponse (poll) |
| O5 | POST | /orders |
marketplace | CreateOrderRequest → CreateOrderResponse (records paid cart; fire-and-forget after payment) |
| O6 | POST | /purchase-email |
marketplace | email/phone + items → { message } (receipt) |
| O7 | POST | /websession/{sessionId} |
marketplace | item array → any (server-side cart mirror) |
partnerIdconst =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 viaWebSessionID. 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 }.QrCreateResponseis a very tolerant union (api.service.ts21-41) — resolvers pick the first present ofqrId/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).
401on a customer-facing/cart//ordershas 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,status∈all|pending|processing|shipped|delivered|cancelled|refunded,page,pageSize. ResponseAdminOrdersListResult { 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 torefund_requested. - AO5 body:
{ note: string, internal: boolean }—internal:truewritesinternalNotes, elsenotes(both plain strings onAdminOrder). Appends a timeline entry. AdminOrdertimeline: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/statusbody{ "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 entity — AdminCustomersFacade derives customers by grouping
AdminOrder records by customer.email (it injects AdminOrdersLocalGateway).
Every AdminCustomer field is a real aggregate over that customer's orders, never
fabricated.
Frontend implication: there is no customer CRUD in the frontend at all — no create/update/delete, only a derived list/detail. A backend can satisfy this either by (a) exposing a real customers endpoint returning the aggregate shape, or (b) letting the frontend keep deriving from orders. This is Requires backend decision (whether customers become a first-class backend resource).
| # | Method | Path (PROPOSED) | Purpose | Success |
|---|---|---|---|---|
| CU1 | GET | {base}/backoffice/customers |
List (derived aggregate) | 200 AdminCustomer[] |
| CU2 | GET | {base}/backoffice/customers/{email} |
Detail (aggregate + orders) | 200 AdminCustomer / 404 |
- Key =
email(customers are keyed by email, not an id). AdminCustomer:{ email, name, phone, orderCount, totalSpent, currency, firstOrderAt, lastOrderAt, addresses[], orders: AdminOrder[] }.- Headers/JWT: admin auth (§3.0). Permission (Proposed):
backoffice.read. - Pagination/sort/filter: none in the model (plain array). Derivation is entirely client-side today.
- Validation: n/a (read-only, derived).
- Error responses: common read set;
404if 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 bebackoffice.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. AdminRolehere is{ id, name, permissions: string[], builtIn: boolean }— a different type from the authAdminRolestring-union used by the JWTroleclaim (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 JWTroleclaim andPermissionService. Ordered high→low by convention only. Maps to permissions viaROLE_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.roleIdreferencesAdminRole.idhere (a string id likerole_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.builtInroles map onto the permission model) and whether custom (builtIn:false) roles with arbitrarypermissions[]are allowed. Requires backend decision — the frontend does not resolve this collision. - No role CRUD endpoint exists in the frontend beyond read (
loadRoles). Custom-role create/edit/delete = Requires backend decision.
3.7 Media — PROPOSED (MOCK-SWAPPABLE via abstract-class token)
Contract: abstract class MediaRepository (src/app/core/media/media-repository.ts).
Bound in app.config.ts → MockMediaRepository (useClass). MOCK-SWAPPABLE (a
real impl drops in via the same class token — no new seam needed). DTO:
MediaAsset (src/app/core/media/models/media-asset.model.ts).
Scope note (per task): this section covers the CRUD list/delete/metadata
endpoints only. Upload mechanics (multipart, presigned URLs, chunking) are
deferred to a sibling "Uploads" section of the master doc — here upload is
listed for completeness but its transport is out of scope.
| # | Method | Path (PROPOSED) | Repository method | Success |
|---|---|---|---|---|
| M1 | GET | {base}/media?page&pageSize&search&folder&tag&kind&sort |
list(params) |
200 MediaListResult { items, total } |
| M2 | POST | {base}/media (multipart — see Uploads section) |
upload(file,options) |
201 MediaAsset |
| M3 | DELETE | {base}/media/{id} |
remove(id) |
204 |
| M4 | PATCH | {base}/media/{id} |
update(id,patch) |
200 MediaAsset |
| M5 | GET | {base}/media/folders |
listFolders() |
200 string[] |
- Headers/JWT: the
adminAuthHeadersInterceptoralready 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 patchingurl/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.MediaUsageServiceexists 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_9body{ "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 /bootstrapand 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). StaticPageConfigkey 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-pagestatus(draftnever resolves on storefront even if the bootstrap is published).- Validation: the client-side
ContentPageService.validatePages()+ProjectValidatorconvention exists (ProjectValidationIssue { code, message, section, fieldKey, severity }) and is the shape§6§422 recommends aligning backenddetails[]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 SectionConfig → WidgetConfig stored inside
BootstrapConfig (layout/pages). Edited via ProjectEditorFacade +
homepage-section.component.ts / homepage-overview.component.ts against
in-memory bootstrap; persisted only to localStorage drafts. No HTTP save/publish
call exists.
Models: SectionConfig (src/app/shared/models/config/section.model.ts) —
{ id, type, order, layout?: { strategy: 'stack'|'grid'|'hero'|'carousel'|'split', columns?, gap?, align? }, visibility?, widgets: WidgetConfig[], visible? }.
| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|---|---|---|---|---|
| HP1 | GET | {base}/builder/homepage |
Load section/widget composition | 200 SectionConfig[] |
| HP2 | PUT | {base}/builder/homepage |
Replace composition (reorder, add/remove sections & widgets) | 200 SectionConfig[] |
- Why a single PUT, not granular CRUD: the editor mutates an in-memory tree
and saves it whole (undo/redo over
History<BootstrapConfig>); there is no per-section/per-widget network operation in the frontend. A whole-composition PUT matches how the client actually works. Granular section/widget endpoints would be new backend design. Requires backend decision. - Headers/JWT: builder auth (§3.0). Permission (Proposed):
builder.read(HP1),builder.write(HP2). - Section ordering is by the numeric
orderfield; widgets carry their ownorderwithin a section. - Validation:
ProjectValidator(client-side, localStorage-scoped). Sectiontypeallow-list, widgettypemust 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,WidgetSettingsSchema—src/app/widgets/contracts/widget-manifest.contract.ts). - Headers/JWT: the manifest URL is bootstrap-driven; public read.
- Validation/errors: on failure falls back to the static asset. No mutation.
3.10.b Widget instances CRUD — PROPOSED / FUTURE
Widget instances (WidgetConfig, src/app/shared/models/config/widget.model.ts)
live inside SectionConfig.widgets[] and are created/updated/removed/reordered
purely in-memory via ProjectEditorFacade (localStorage drafts). There is no
per-widget backend call — they are saved as part of the homepage composition
(§3.9 HP2). So there is no separate widget CRUD endpoint unless the backend
chooses granular editing (Requires backend decision). If it does:
| # | Method | Path (PROPOSED / FUTURE) | Purpose |
|---|---|---|---|
| W2 | POST | {base}/builder/sections/{sectionId}/widgets |
Add widget |
| W3 | PUT | {base}/builder/sections/{sectionId}/widgets/{widgetId} |
Update widget |
| W4 | DELETE | {base}/builder/sections/{sectionId}/widgets/{widgetId} |
Remove widget |
WidgetConfig:{ id, type, version, title?, subtitle?, order?, padding?, visibility?: { desktop?, tablet?, mobile? }, animation?, style?: Record<string, string>, permissions?: { requireAuthenticated?, roles?, permissions? }, props: Record<string, unknown>, actions?, featureFlag?, visible? }.- Permission (Proposed):
builder.write. Validation: widgettypemust exist in the manifest (W1);propsshape validated againstWidgetSettingsSchema. 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 numericorderfield is authoritative on the wire. - Localized labels:
updateNavLinkLabel(target, id, value, locale?)writes intolabelas 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": [] }
3.12 Footer — PROPOSED / FUTURE (LOCAL-ONLY via builder)
Status: LOCAL-ONLY. BootstrapConfig.footer (FooterConfig,
src/app/shared/models/config/footer-config.model.ts), edited via
footer-section.component.ts + ProjectEditorFacade. No HTTP call.
FooterConfig { logoUrl?, paymentIcons?: [{ src, alt, width?, height? }], copyrightText?: string|Localized, columns?: [{ id, title, links: [{ id, label, pageKey?, url? }] }], socialLinks?: [{ id, label, url, icon? }], legalPageKeys? (deprecated), staticPageKeys? (deprecated) }.
| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|---|---|---|---|---|
| F1 | GET | {base}/builder/footer |
Load footer config | 200 FooterConfig |
| F2 | PUT | {base}/builder/footer |
Replace footer (payment icons, columns/legal links, copyright, socials) | 200 FooterConfig |
- Headers/JWT: builder auth. Permission (Proposed):
builder.read(F1),builder.write(F2). - Link resolution:
FooterLinkConfig.pageKey(preferred, references a CMS page id — stays correct if the page route changes) vs.url(raw external).legalPageKeys/staticPageKeysare deprecated, superseded bycolumns— a backend should accept them for back-compat but writecolumns. - 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 besettings.manageif 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,faviconUrlnon-optional in the model; valid email forsupportEmail= 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.writefor L2-L4,builder.readfor L1. Could besettings.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(so409 CONFLICTon L2 duplicate is the right backstop). L2 body:{ code: string }(a locale likeen,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.builderrecord slot for exactly this but no code that calls it. Requires backend decision: whole-document publish vs. granular section writes (and optimistic-concurrency / versioning viaBootstrapConfig.schemaVersion/generatedAt). - Headers/JWT: builder/settings auth. Permission (Proposed):
settings.manage(S2/S4),builder.read/settingsread for S1/S3. - Validation:
ProjectValidatorruns client-side before a (currently local) publish. Backend-side schema validation of the wholeBootstrapConfig= Requires backend decision. - Errors:
422(invalid config),409(concurrent edit — proposed, if versioning adopted), common set.
3.16 Transactions — PROPOSED (MOCK-ONLY, no seam)
Contract: AdminTransactionsGateway
(src/app/features/admin/transactions/services/admin-transactions-gateway.interface.ts).
Impl: AdminTransactionsLocalGateway (injected concretely by
AdminTransactionsFacade). DTO: AdminTransaction
(src/app/features/admin/transactions/models/admin-transaction.model.ts).
| # | Method | Path (PROPOSED) | Gateway method | Success |
|---|---|---|---|---|
| T1 | GET | {base}/backoffice/transactions?search&status&type&page&pageSize |
loadTransactions(filters) |
200 AdminTransactionsListResult |
| T2 | POST | {base}/backoffice/transactions/{id}/retry |
retryFailed(id) |
200 AdminTransaction |
| T3 | PATCH | {base}/backoffice/transactions/{id}/fraud-flag |
setFraudFlag(id,flagged) |
200 AdminTransaction |
- Headers/JWT: admin auth (§3.0). Permission (Proposed):
backoffice.read(T1),backoffice.write(T2/T3). - T1 query params (
AdminTransactionListFilters):search,status∈all|pending|success|failed|retried,type∈all|payment|refund|qr_payment,page,pageSize. ResponseAdminTransactionsListResult { items, total, page, pageSize }. No sort param. - T2: only meaningful for
status:'failed'transactions (method nameretryFailed); a backend should reject retry on non-failed (proposed409). - T3 body:
{ flagged: boolean }. AdminTransaction:{ id, orderId, orderNumber, type, method, status, amount, currency, fraudFlag, audit: [{ action, actor, timestamp }], createdAt, updatedAt }. Transactions link to orders viaorderId— 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-flagbody{ "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.tsline 617). - Headers/JWT: marketplace headers; customer
WebSessionIDidentifies the reviewer. No JWT. - Models:
SubmitReviewInput/SubmitQuestionInput(src/app/core/products/models/product-engagement.model.ts).ratingis 1-5 stars (RatingStars). - Validation: none client-side. Rating range 1-5, non-empty comment, one review per customer/product = Requires backend decision.
- Errors:
404(unknown item),422(bad rating),429(spam throttle — proposed, nothing client-side), common set. - Example R1 request:
{ "rating": 5, "comment": "Great", "sessionID": "…", "timestamp": "2026-07-25T12:00:00Z" }
3.17.b Admin review moderation — PROPOSED (MOCK-ONLY, no seam)
Contract: AdminModerationGateway
(src/app/features/admin/moderation/services/admin-moderation-gateway.interface.ts).
Impl: AdminModerationLocalGateway (injected concretely by
AdminModerationFacade; reused by AdminAnalyticsFacade). DTO: AdminReview
(src/app/features/admin/moderation/models/admin-review.model.ts).
| # | Method | Path (PROPOSED) | Gateway method | Success |
|---|---|---|---|---|
| MR1 | GET | {base}/backoffice/moderation/reviews?search&status&rating&page&pageSize |
loadReviews(filters) |
200 AdminReviewsListResult |
| MR2 | GET | {base}/backoffice/moderation/reviews/{id} |
loadReview(id) |
200 AdminReview / 404 |
| MR3 | PATCH | {base}/backoffice/moderation/reviews/{id}/status |
setReviewStatus(id,status,note) |
200 AdminReview |
| MR4 | PATCH | {base}/backoffice/moderation/reviews/{id}/visible |
setReviewVisible(id,visible) |
200 AdminReview |
| MR5 | PATCH | {base}/backoffice/moderation/reviews/{id}/pinned |
setReviewPinned(id,pinned) |
200 AdminReview |
| MR6 | PATCH | {base}/backoffice/moderation/reviews/{id}/featured |
setReviewFeatured(id,featured) |
200 AdminReview |
| MR7 | POST | {base}/backoffice/moderation/reviews/{id}/notes |
addModeratorNote(id,note) |
200 AdminReview |
| MR8 | DELETE | {base}/backoffice/moderation/reviews/{id} |
deleteReview(id) |
204 |
- Headers/JWT: admin auth (§3.0). Permission (Proposed):
backoffice.read(MR1/MR2),backoffice.write(MR3-MR8). - MR1 query params (
AdminReviewListFilters):search,status∈all|pending|approved|rejected|spam,rating∈allor a number 1-5,page,pageSize. ResponseAdminReviewsListResult { 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'. Thenoteis 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
AdminReviewand the storefrontReview(product-engagement.model.ts) are different types over the same underlying data. How aPOST /items/{id}/callbacksubmission 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 viatargetType+targetId.- No report creation endpoint in the frontend — reports are read + status changed only. Who/what creates reports (customer-facing "report this" flow) = Requires backend decision (no such storefront submission exists in code).
- Validation: none client-side. Errors:
404(RP2 unknown id), common set. - Example RP2 request:
{ "status": "resolved" }
3.19 Monitoring — PROPOSED, READ-ONLY (MOCK-ONLY, no seam)
Contract: AdminMonitoringGateway
(src/app/features/admin/monitoring/services/admin-monitoring-gateway.interface.ts).
Impl: AdminMonitoringLocalGateway (injected concretely by
AdminMonitoringFacade). Read-only — the gateway has no mutation methods
(BACKEND-AUDIT.md §14). DTOs in admin-monitoring.model.ts.
| # | Method | Path (PROPOSED) | Gateway method | Success |
|---|---|---|---|---|
| MO1 | GET | {base}/backoffice/monitoring/events?category&search |
loadEvents(filters) |
200 AdminMonitoringEvent[] |
| MO2 | GET | {base}/backoffice/monitoring/queues |
loadQueues() |
200 AdminQueue[] |
| MO3 | GET | {base}/backoffice/monitoring/webhooks |
loadWebhooks() |
200 AdminWebhookDelivery[] |
- Headers/JWT: admin auth (§3.0). Permission (Proposed):
backoffice.read. - MO1 query params (
AdminMonitoringEventFilters):category∈all|audit|security|login|failed_login|api|error|warning,search. No pagination (plain array) — a backend feeding a real event stream would likely need to add paging/time-range params; Requires backend decision on windowing. - Models:
AdminMonitoringEvent { id, category, level: 'info'|'warning' |'error', message, technicalDetail?, actor, timestamp };AdminQueue { name, depth, status: 'healthy'|'degraded'|'down' };AdminWebhookDelivery { id, endpoint, event, status: 'delivered'|'failed'|'pending', timestamp }. - Webhooks here are read-only observability (delivery log), not webhook configuration CRUD — there is no create/update/delete webhook endpoint in the frontend. Webhook management = Requires backend decision (out of current scope).
- Validation/errors: read-only; common read error set.
- Example MO2 response:
[ { "name": "orders", "depth": 3, "status": "healthy" } ]
3.20 Analytics — PROPOSED, DERIVED / NO REAL DATA SOURCE (MOCK-ONLY)
Per BACKEND-AUDIT.md §14: AdminAnalyticsFacade has no gateway of its own —
it derives everything by reusing orders/products/moderation local gateways +
ADMIN_CATEGORIES_GATEWAY + AdminDashboardFacade. Several analytics values are
honestly null because no data source exists (e.g. conversionRate,
retentionPercent — model comments: "null = unknown - no visitor/traffic tracking
exists yet. Never fabricated"). DTOs in admin-analytics.model.ts.
| # | Method | Path (PROPOSED) | Purpose | Success |
|---|---|---|---|---|
| AN1 | GET | {base}/backoffice/analytics/summary?range |
KPI summary | 200 AdminAnalyticsSummary |
| AN2 | GET | {base}/backoffice/analytics/products?range |
Product analytics | 200 AdminProductAnalytics |
| AN3 | GET | {base}/backoffice/analytics/customers?range |
Customer analytics | 200 AdminCustomerAnalytics |
| AN4 | GET | {base}/backoffice/analytics/series?range&metric |
Time series | 200 AdminAnalyticsSeriesPoint[] |
- Headers/JWT: admin auth (§3.0). Permission (Proposed):
backoffice.read. rangequery param ∈7 | 30 | 90(AdminAnalyticsDateRange) — the only filter the frontend models.AdminAnalyticsSummary:{ revenueTotal, currency, ordersCount, avgOrderValue, productsCount, categoriesCount, customersCount, conversionRate: number | null }.conversionRateMUST benullwhen unknown — a backend must not fabricate it absent real visitor/traffic tracking. Same forAdminCustomerAnalytics.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/metrics →
200 { categoriesCount, productsCount }, permission backoffice.read.
3.21 Cross-cutting "Requires backend decision" register (this section)
Consolidated so the master doc can dedupe against the auth/error registers. Each item has a Recommended default — apply it unless you have a specific reason not to; only stop and ask a human where none is given or the item has real business/money/legal weight (marked accordingly).
- DI seam prerequisite — orders, products, users, transactions, monitoring,
moderation (+ derived customers/analytics) inject their
*LocalGatewayconcretely; 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. - 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. - 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 stripsid/counts/timestamps from client payloads; client never sends them). - Field validation + error wording — the frontend enforces essentially none
on admin CRUD (no reactive
Validators); all422field 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 §6422shape (fieldErrorskeyed by field path). Don't wait on frontend validation to be added first. - Product slug/sku uniqueness — no proactive check exists (unlike categories'
isSlugTaken); decide409behavior. Recommended default: mirror categories — add anisSlugTaken-equivalent check endpoint, and return409with the §6 conflict shape on collision. - 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.
- 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).
AdminRoleduplication — auth string-union vs. users-management interface (naming reconciliation; role CRUD / custom roles). Recommended default: keep the coarseAdminRolestring 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.- 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. - 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.1MediaAssetKind), 10MB per file; block delete of in-use assets with a409listing the referencing entities (safer default than silent broken references). - 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/POSTthat 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. - Languages: duplicate-add
409, removing the default/last locale, BCP-47 validation. Recommended default:409on 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). - Transactions ↔ live payment records are unconnected models; how they link.
Recommended default: key
AdminTransactionoff 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. - Reviews: storefront
Reviewvs. adminAdminReviewlinkage; how a submitted review enters the moderation queue. Recommended default: every storefront-submitted review starts in the moderation queue atpendingstatus;AdminReviewis the same underlying record with moderation fields attached, not a separate entity. - 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.
- Monitoring: event windowing/pagination for a real stream; webhook
management (vs. read-only delivery log) is out of scope.
Recommended default: offset-paginated
GETwith asince/time-range filter (matches §2.2's standard pagination contract) — no need for a streaming protocol for an admin-facing log view. - Analytics:
conversionRate/retentionPercentmust staynullwhen 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 metricsnulluntil a real analytics/traffic pipeline exists — never fabricate a number, the frontend already rendersnullas "Unknown." - Variants/options shape reconciliation between storefront
variantOptions(grouped) and adminvariants(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 groupedvariantOptionsshape 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
- Mechanism A — Telegram QR / session login (LIVE)
- Mechanism B — Ed25519 challenge/response admin auth (NOT LIVE)
- JWT structure
- Refresh token
- Token expiration handling
- Token rotation
- Logout
- Session invalidation
- Role hierarchy
- Tenant isolation
- Permission model / route guards
- 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:
- User opens login (storefront "Sign in" or admin
/admin-logingate). - Frontend generates a random GUID client-side (
generateGuid(),src/app/shared/util/guid.util.ts) — this is thewebSessionID, sent to the backend, not received from it. POST {authApiUrl}/users/sessionswith{ webSessionID }body andWebSessionIDheader set to the same value. Backend response's own id field is preferred if present (seeextractSessionId— checkswebSessionID/WebSessionID/webSessionId/sessionID/SessionID/sessionId/id/IDin that order), else the frontend's generated GUID is used as fallback.- 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).
- Web:
- 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 theurl) plus the app deep link for mobile. This is orchestrated byQrLoginEngine(src/app/shared/qr-login/qr-login.engine.ts) shared identically by both customer and admin modes viaTelegramLoginComponent(src/app/components/telegram-login/telegram-login.component.ts,[mode]="'customer' | 'admin'"). - 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
webSessionIDas active/logged-in once the Telegram bot confirms the user, associating a Telegram user identity with it. - Frontend polls
GET /users/sessions/{id}(checkSessionOnce, driven byQrLoginEngine's polling loop) until the session normalizes toactive: true, or the user cancels/times out. - 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 intelegram-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 booleantrue/1, or (case-insensitive) one oftrue, 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 tousername; else falls back to the joined full name; else literal'Telegram User'. - Telegram user id:
userId/telegramUserId/telegramUserID/TelegramUserID/id/ID(user object), elseuserId/telegramUserId/telegramUserID/TelegramUserID/userID/UserID/UserId(top-level). - Session id: same priority list as
extractSessionIdabove. - Expiry:
expiresAt/ExpiresAt/expires/Expires(ISO 8601 string); if absent, the frontend fabricatesnow + 3600sclient-side — the backend should always send a realexpiresAt/expiresso 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 viacrypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])(real WebCrypto Ed25519 — RFC 8032, not a placeholder), persists the rawCryptoKeyhandles in IndexedDB (admin-auth-ed25519DB, object storekeypair, single recordid: '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 withcrypto.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
publicKeyBase64with 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/):
GET {authApiUrl}/api/admin/auth/challenge→AuthChallenge { nonce, issuedAt, expiresAt }(all ISO 8601 exceptnonce, an opaque string).Ed25519KeypairService.getOrCreateKeyPair()(generates on first use).Ed25519KeypairService.sign(nonce)— signs the raw nonce string exactly as received, no additional framing/prefix/hashing applied client-side.POST {authApiUrl}/api/admin/auth/verifywith bodyVerifySignatureRequest { publicKey, signature, nonce }(publicKey= base64 raw Ed25519 public key,signature= base64 signature over the nonce,nonce= the same value echoed back).- Backend must: re-derive the exact signed message from the nonce it
issued, verify the signature against its own
publicKey → admin accountmapping, confirm the nonce hasn't expired or been used before, and only then issue tokens. - On success:
200 AuthTokenPair { token, refreshToken }.SessionService.activate(tokens)decodes the JWT (§3), stores both tokens (§4), and schedules the next refresh (§5). - On failure:
401/403→AuthServicemaps it throughauthErrorCodeFromStatus()toinvalid-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 /refreshresponse is expected to include a newrefreshToken; the backend should invalidate the one just used (single-use refresh tokens). - The frontend always stores whatever pair it receives from
/verifyor/refreshand 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()callsclear()). - Refresh failure (Mechanism B) — any error from
/refreshcallsSessionService.markExpired(). AdminAuthService.clearAuthState()also clears the reservedadminToken/adminRefreshTokenkeys (§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:
-
src/app/core/auth/models/permission.model.ts— a string union used by the Ed25519/JWT flow'sroleclaim andPermissionService:type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly';Ordered highest-to-lowest privilege by convention (not enforced in code —
PermissionServicedoes not rely on ordering, only exact role → permission set lookup). -
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()matcheslocalhost,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 keydexarmarket;www.acme.com→acme. - This tenant key feeds
ApiConfigService.getBaseUrl()(src/app/core/config/api-config.service.ts, documented indocs/context/BACKEND-AUDIT.md§2) to pick the marketplace API base URL: localhost →environment.localhostApiUrl(/api); elseenvironment.tenantApiBaseUrls[tenantKey]; elseenvironment.tenantApiTemplatewith{tenant}substituted; else (gated byallowBootstrapApiOverride, off by default) a value read out of the already-loaded bootstrap document (bootstrap.apiEndpoints.website.baseUrl/bootstrap.tenant.apiBaseUrl). - No
X-Tenantheader 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 againstenvironment.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 languageGuard — src/app/guards/language.guard.ts
Not an authentication guard, but gates every localized route (:lang
segment wraps the entire route tree in app.routes.ts). Behavior:
- If
:langparam 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 viarouter.parseUrl(not a hand-builtUrlTree, to avoid double-encoding the query string into the path segment).
11.5 canDeactivate guards (dirty-state guards, not auth)
Also not authentication, but listed since the task asked for "what guards
check": projectEditorDirtyGuard, adminProductDirtyGuard,
adminCategoryDirtyGuard — all gate navigation away from an in-progress
editor (builder section, product editor, category editor) to warn about
unsaved changes. They read editor dirty-state signals, not auth state, and
are unrelated to session/token validity.
11.6 What the frontend actually gates, summarized
| Concern | Mechanism | Guard/service |
|---|---|---|
| "Is there an active admin session at all" | Telegram (A) | adminAuthGuard → AdminAuthService.isAuthenticated() |
| "Is there an active admin JWT session" | Ed25519 (B), not live | ed25519AuthGuard → SessionService.isAuthenticated() |
| "Does this role have permission X" | Ed25519 (B), not live | permissionGuard(permission) → PermissionService.has() |
"Is :lang valid/enabled" |
n/a | languageGuard |
| "Unsaved editor changes" | n/a | *DirtyGuard (project editor / product / category) |
Every one of these is a client-side UX gate only. None of them are a
substitute for server-side authorization — the backend must independently
verify role/permission on every admin mutation regardless of what a route
guard decided, per the security note already present in the prior
docs/AUTH.md and repeated here: a passing client-side check is not proof
of anything to the backend.
12. Open items — "Requires backend decision"
Consolidated list of everything this document could not derive from existing frontend code and therefore does not prescribe. Each has a Recommended default; apply it unless the item is flagged as a real business/security decision.
- Public-key enrollment mechanism (§2.1) — how an admin's Ed25519
publicKeyBase64gets 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
adminAuthGuardwithed25519AuthGuardoutright, or run both and let role/tenant config decide? Explicitly called out in the priordocs/AUTH.mdas "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.AdminRolenaming collision (§9) — a reconciliation/rename decision betweencore/auth/models/permission.model.ts's string union andfeatures/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_INITIALIZERwiring forAuthFacade.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:
WebSessionIDon every marketplace API request (apiHeadersInterceptor).AdminWebSessionID+Authorization: Bearer <token>on/admin/,/backoffice/,/builder/,/media/requests (adminAuthHeadersInterceptor).authorization-key/userid-valueon 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
webSessionIDin a cookie + in-memory signal. The reservedlocalStorage['adminToken']/adminRefreshTokenslots are dead (nothing writes them). - Ed25519 flow (Mechanism B, dormant): access + refresh tokens live in
localStorage(ed25519AdminToken,ed25519AdminRefreshToken). XSS exposure implication:localStorageis readable by any script in the origin, so a single XSS foothold (see §5.8) exfiltrates the admin token pair outright — there is noHttpOnlycookie 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 asHttpOnly; Secure; SameSitecookies to remove the XSS-exfiltration path — the frontend currently assumeslocalStorage, 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)inSearchFacade(src/app/features/search/facade/search.facade.ts:98) — so a typing user produces at most ~4–5GET /searchitemscalls/second, not one per keystroke. - Project-editor autosave: ~300 ms debounce before a draft commit (localStorage only, not a backend call today).
Requires backend decision on the rate-limit contract end to end (limits,
Retry-After header vs retryAfterSeconds body field, and whether the
frontend should auto-retry). Per §6 (Error Model, this document) §429, no retry
interceptor exists — honoring 429 is net-new frontend work, not a config
change.
5.7 Replay protection
The Ed25519 signing flow (§4 (Authentication, this document) §2) signs the raw
backend-issued nonce string exactly as received — Ed25519KeypairService.sign(nonce)
applies no client-side framing, prefix, hashing, timestamp, or
counter (src/app/core/auth/services/auth.service.ts login flow;
ed25519-keypair.service.ts). The VerifySignatureRequest body is
{ publicKey, signature, nonce } and nothing else. Therefore all replay
protection is the backend's responsibility via the nonce it issues:
AuthChallenge { nonce, issuedAt, expiresAt }. Backend requirement: treat
each nonce as single-use, bind it to the issuing client, reject it after
expiresAt, and reject any reuse. The frontend contributes no independent
freshness signal — there is no client timestamp or per-request nonce on any
other call either (the Telegram session flow has no signing at all).
5.8 XSS
The frontend renders backend/CMS-controlled HTML in three places — this is the single most important input for what the backend must sanitize:
| Location | File | Handling today |
|---|---|---|
| Storefront static page body (CMS content) | src/app/pages/static-page/static-page.component.ts:104-106 |
Runs DomSanitizer.sanitize(SecurityContext.HTML, html) first, then bypassSecurityTrustHtml(sanitized). So it is Angular-sanitized before injection into [innerHTML]. |
| Static-page preview (editor) | src/app/features/content-management/components/static-page-preview/static-page-preview.component.ts:49 |
bypassSecurityTrustHtml(sanitized) after a sanitize pass. |
| Payment bank-frame URL | src/app/pages/cart/cart.component.ts:475 |
bypassSecurityTrustResourceUrl(bankUrl) — trusts a backend-supplied iframe URL. |
The static-page path is the risky one: CMS body HTML originates from the
bootstrap document (BootstrapConfig.staticPages, backend-served). It is
run through Angular's sanitizer client-side, but Angular sanitization strips
active content for the DOM context only — it is not a substitute for
server-side sanitization/storage validation. Backend requirement: treat all
CMS static-page HTML and any product-description HTML as untrusted on write,
sanitize/allowlist server-side, and never assume the client sanitizer is the
only defense. The bypassSecurityTrustResourceUrl on the bank payment frame
means the backend-supplied payment URL must be strictly validated server-side
(a malicious value renders in an iframe with ResourceUrl trust). Product
descriptions are currently normalized to plain strings by
ApiService.normalizeItem and are not rendered via [innerHTML] today, so
they are lower-risk — but that holds only while descriptions stay plain-text.
Note: the project-editor HTML editor (marketplace-html-editor.component.ts)
uses innerHTML on a contenteditable surface for authoring; that is
author-local editor state, not backend-rendered content, so it is out of the
backend's sanitization scope.
5.9 CSP
No CSP meta tag in src/index.html (confirmed — only SEO/OG/Twitter/theme
meta tags are present). CSP is delivered at the web-server layer via
nginx.conf, and only on the primary tenant block:
dexarmarket.ruserver block (nginx.conf:41) sets a fullContent-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;plusX-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.storeblock and the new-tenant template block set the other security headers but omit theContent-Security-Policyheader 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_BYTESwhereMAX_FILE_SIZE_BYTES = 10 * 1024 * 1024(10 MB, line 9). - Type: rejects if
file.typeis not inALLOWED_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 andon*="..."inline event-handler attributes via regex (lines 124-131). This is naive — it misses<foreignObject>,javascript:URIs inhref/xlink:href, CSS-based vectors, and namespaced handlers. SVG is served withimage/svg+xmland 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-DispositionandX-Content-Type-Options: nosniffso a malicious file can't be interpreted as active content on the app origin.
5.12 Maximum file sizes
Exact client-enforced constant: 10 MB —
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
(mock-media-repository.service.ts:9). The user-facing error is
"File exceeds the 10MB limit.". Related transform constants (not rejection
limits): MAX_IMAGE_DIMENSION = 2000 px, COMPRESS_QUALITY = 0.85. No
per-account or aggregate storage quota is enforced client-side — see §7 Limits.
5.13 Mime validation
Exact client-accepted MIME allowlist
(ALLOWED_MIME_TYPES, mock-media-repository.service.ts:12):
image/jpeg, image/png, image/webp, image/gif, image/svg+xml, application/pdf
These map to the MediaAssetKind union (image | svg | pdf | other)
via assetKind(). The media picker's type filter exposes only
image / svg / pdf to the user. No extension allowlist exists separately — the
check is MIME-string only. The backend allowlist should mirror this set (and
re-derive type from content, §5.11).
5.14 Audit logging
The admin Monitoring feature defines an event taxonomy the backend is
expected to populate. AdminMonitoringCategory
(src/app/features/admin/monitoring/models/admin-monitoring.model.ts:1) is:
'audit' | 'security' | 'login' | 'failed_login' | 'api' | 'error' | 'warning'
Each AdminMonitoringEvent carries { id, category, level (info|warning|error), message, technicalDetail?, actor, timestamp }. The page filters events by these
categories (admin-monitoring-page.component.ts:26). This is MOCK-ONLY today
(no backend seam — see docs/context/BACKEND-AUDIT.md §14), but it defines the
event types the backend audit log must eventually emit for the UI to be
meaningful:
audit— admin actions taken (create/update/delete on any admin domain).security— security-relevant events (permission changes, revocations).login/failed_login— successful and failed authentication attempts, with anactoridentity.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
AdminRolenaming collision (auth string union vs the users-managementAdminRoleinterface with{id, name, permissions[], builtIn}) — flagged in§4 (Authentication, this document)§9; the users-management interface suggests custom/non-builtin roles with arbitrary permission-string sets, which the coarse 5-role union does not model. Reconcile before building the backend role table.
6. Error Model
Single unified error-response format the backend must return for every non-2xx
response across all API surfaces (marketplace API, payment/QR API, session
auth API, Ed25519 admin auth API, and any future admin/builder/backoffice
APIs). Derived by cross-referencing every place the Angular frontend
currently parses, catches, or reacts to an HTTP error — see
docs/context/BACKEND-AUDIT.md for the full backend-surface audit this is
based on.
Finding: the frontend does not currently parse any backend error envelope.
No HttpInterceptor in the pipeline (src/app/app.config.ts →
mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor) inspects error responses —
all five only touch outgoing requests or successful GET caching. Every
consumer that reacts to failure does so on the RxJS/HttpErrorResponse
level (error.status, error.message), never on a parsed JSON error body.
The one exception is the Ed25519 admin-auth flow, which has a client-side
AuthErrorCode union but (see §"Known frontend gap" below) currently derives
it from HTTP status only, not from any body field. Because of this, the
envelope below is a clean proposal, not a reverse-engineered contract —
every shape decision is marked accordingly.
The envelope
{
"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 existingAuthErrorinterface (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 astatusfallback removed, not a rewrite. details[]entries{ field, code, message }mirror the existing client-sideProjectValidationIssueconvention (code, message, section, fieldKey, severity—src/app/features/project-editor/services/ project-validator.service.ts:23-36, consumed viaProjectEditorFacade .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.requestIdis 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()insrc/app/core/auth/services/auth.service.ts): anyHttpErrorResponsewith status 401 is mapped viaauthErrorCodeFromStatus()→AuthErrorCode 'unauthorized', surfaced byAuthErrorPageComponent(src/app/core/auth/pages/auth-error-page.component.ts) with copy "Unauthorized… Sign in" and a button that callsrouter.navigateByUrl ('/admin-login'). - Customer Telegram session auth (
TelegramSessionApiService,AuthServicecustomer-facing,src/app/services/auth.service.ts): no code branches on a 401 status anywhere — session validity is instead polled viacheckSessionOnce()returningAuthSession | 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 genericcatchError/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 genericerrorboolean 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-AfterHTTP header, a body field (retryAfterSecondsabove), 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 (
AdminUsersFacadeand siblings): genericerrorboolean signal set totruein the RxJSerror:callback, renderingcommon.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 withcode: "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
404for missing resources (not a generic500) — 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
isSlugTakenpre-check exists; no reactive 409 handler). Recommended default: return409with 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-sidefieldError()convention exists but nothing feeds it from a backend response yet). Recommended default: populatedetails[]as{ field, message }per invalid field on every422— 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-Afterheader (seconds) + envelope body withcode: "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
maintenanceUntilreliability — 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
forbiddencopy (wrong wording today) vs. add a dedicatedAuthErrorCode. Recommended default: dedicatedcode: "TENANT_DISABLED"(already named consistently elsewhere in this doc, §1.12) rather than overloadingforbidden— 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()inauth.service.tscurrently derives the error code from HTTP status only and ignores thefallbackCodefor 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 bodyerror.codeand a frontend change to prefer it. Recommended default: backend returnscode: "TOKEN_EXPIRED"/code: "INVALID_SIGNATURE"on the relevant401s; frontend fix (prefer body code over status-derived fallback) is tracked separately indocs/KNOWN-ISSUES.mdand 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 rawFile(MediaLibraryFacade.upload(file, options)→repository.upload(file, { folder, ...options })). The natural wire form is a multipart body with the binary file plusfolderandtags[]fields fromMediaUploadOptions. - Headers:
AdminWebSessionID+Authorization: Bearer <token>(auto-added for/media/byadminAuthHeadersInterceptor), plus multipart boundary. - Request: the file blob + optional
folder: stringandtags: string[]. The current folder is injected by the facade when none is passed. - Response: a single fully-populated
MediaAsset(§7.1). The backend assignsid, canonicalurl,thumbnailUrl,size(post-processing),width/height, andcreatedAt. - 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
Fileand exposesretryUpload()/canRetry(), andcancelUpload()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>→ PROPOSEDDELETE /media/{id}.MediaLibraryFacade.remove(id)also deselects and reloads. - Bulk:
MediaLibraryFacade.bulkDelete(ids[])loopsrepository.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 batchDELETE /media(body{ ids: [] }) to avoid N round-trips — the facade would need a matchingbulkRemovemethod (not present today). - There is also
bulkMoveToFolder(ids, folder)= N ×update(id, { folder }). - Hard vs soft delete: Requires backend decision — the frontend calls
removeand expects the asset gone fromlist(); 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.onGridKeydowncomputes columns asclientWidth / 150), page size 24 items. - A
thumbnailUrlsized around ~150–300 px (accounting for 2× DPI) would serve the grid without shipping full-resolution originals.
If the backend populates thumbnailUrl with such a rendition, the UI uses it
automatically (falls back to url when absent). No other thumbnail sizes are
consumed anywhere.
7.8 Limits
- Per-file size: 10 MB, client-enforced (§5.12) — the backend must re-enforce (§5.11).
- MIME allowlist: the 6 types in §5.13.
- Upload count / batch size: no client cap —
bulkDelete/bulkMoveToFolderloop over arbitrary-length id arrays; single-file upload only (the picker's<input>takesfiles?.[0], so one file per upload call). - Storage quota (per tenant / per account): none client-side — Requires backend decision.
- Pagination:
pageSizedefault 24 (DEFAULT_PAGE_SIZE/PAGE_SIZE);list()returns{ items, total }so the backend must return an accuratetotalfor 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 completeMediaAssetsynchronously with a usableurl(a direct-to-S3 pre-signed upload flow, where the client PUTs to S3 itself, is not what the currentrepository.upload(file)shape expects — it passes theFileto 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, correctContent-Type/Content-Disposition.
8. Real Backend Implementation Guide
This section is the practical, top-to-bottom guide a backend engineer follows to replace the
frontend's mock/local data layer with real endpoints. It is derived entirely from
docs/context/BACKEND-AUDIT.md (the exhaustive source audit) — no business logic is invented
beyond what the frontend gateway interfaces already fix. Where the frontend has no seam or no
mapper today, that gap is stated explicitly rather than papered over.
The core principle, already proven for Categories, is: the gateway/provider interface is the
contract; a real implementation only has to satisfy that interface. You never rewrite the
interface, and — if the domain already has a DI-token seam — you never touch the facade or the
UI. The only structural surprise, called out repeatedly below, is that most admin domains do
not have that seam yet (audit §1, §14, §25 #1): their facade injects the concrete
Admin*LocalGateway class directly, so a token must be introduced before a real gateway can be
bound at all.
8.1 Which frontend gateways must be replaced
Every data boundary the audit found, with its swap status. "DI token status" is the single most
important column — it tells you whether you can drop in a real class (already token-bound) or
must first introduce a seam (needs token added first).
| Gateway / provider interface | Current mock/local impl file | DI token status | Real impl class to write | Consuming facade(s) |
|---|---|---|---|---|
ConfigProvider |
core/bootstrap/providers/mock-bootstrap.provider.ts |
already token-bound (CONFIG_PROVIDER) |
already exists & LIVE — ApiBootstrapProvider (GET /bootstrap) |
UiRuntime, WebsiteRuntime, ProjectEditor, ContentManagement, Diagnostics (via ConfigService) |
ProductDataProvider |
none bound (mock via mockDataInterceptor only) |
already token-bound (PRODUCT_DATA_PROVIDER) |
already exists & LIVE — ApiProductDataProvider |
ProductFacade |
CategoryRepository |
none bound (mock via interceptor) | already token-bound (CATEGORY_REPOSITORY) |
already exists & LIVE — ApiCategoryRepository (GET /category) |
CategoryFacade |
BackofficeDataProvider |
core/backoffice/providers/mock-backoffice-data.provider.ts |
already token-bound (BACKOFFICE_DATA_PROVIDER) |
already exists & LIVE — ApiBackofficeDataProvider (/api/backoffice/*) |
storefront cards |
AdminCategoriesGateway |
features/admin/categories/services/admin-categories-local.gateway.ts |
already token-bound (ADMIN_CATEGORIES_GATEWAY) |
already exists & LIVE — AdminCategoriesApiGateway |
AdminCategoriesFacade, AdminAnalyticsFacade |
AdminDashboardMetricsGateway |
features/admin/dashboard/services/admin-dashboard-metrics.local.gateway.ts |
already token-bound (ADMIN_DASHBOARD_METRICS_GATEWAY) |
AdminDashboardMetricsApiGateway (new) |
AdminDashboardFacade |
MediaRepository (abstract class) |
core/media/mock-media-repository.service.ts |
already token-bound (abstract-class token in app.config.ts) |
ApiMediaRepository (new) |
MediaLibraryFacade |
UserExperienceRepository |
core/user-experience/repositories/local-user-experience.repository.ts |
already token-bound (USER_EXPERIENCE_REPOSITORY) |
AuthenticatedUserExperienceRepository (new; see §8.3 note on redesign) |
UserExperienceFacade |
AdminOrdersGateway |
features/admin/orders/services/admin-orders-local.gateway.ts |
needs token added first | AdminOrdersApiGateway (new) |
AdminOrdersFacade, AdminCustomersFacade, AdminAnalyticsFacade |
AdminProductsGateway |
features/admin/products/services/admin-products-local.gateway.ts |
needs token added first | AdminProductsApiGateway (new) |
AdminProductsFacade, AdminAnalyticsFacade |
AdminUsersGateway |
features/admin/users/services/admin-users-local.gateway.ts |
needs token added first | AdminUsersApiGateway (new) |
AdminUsersFacade |
AdminTransactionsGateway |
features/admin/transactions/services/admin-transactions-local.gateway.ts |
needs token added first | AdminTransactionsApiGateway (new) |
AdminTransactionsFacade |
AdminMonitoringGateway |
features/admin/monitoring/services/admin-monitoring-local.gateway.ts |
needs token added first | AdminMonitoringApiGateway (new) |
AdminMonitoringFacade |
AdminModerationGateway |
features/admin/moderation/services/admin-moderation-local.gateway.ts |
needs token added first | AdminModerationApiGateway (new) |
AdminModerationFacade, AdminAnalyticsFacade |
| (Customers) | reuses AdminOrdersLocalGateway (derived) |
needs token added first (inherits Orders seam) | none of its own — derive from AdminOrdersApiGateway, or add a /customers source |
AdminCustomersFacade |
| (Analytics) | reuses orders/products/moderation local + ADMIN_CATEGORIES_GATEWAY + AdminDashboardFacade |
partial (categories/dashboard token-bound; rest not) | no aggregation endpoint exists — see §8.3 | AdminAnalyticsFacade |
Auth session (TelegramSessionApiService) |
mock via mockDataInterceptor |
n/a (concrete service, LIVE) | already exists & LIVE — no swap; implement server endpoints only | AuthService, AdminAuthService, AuthFacade |
Ed25519 admin auth (AuthApiService) |
none | n/a (concrete service, LIVE wiring) | client wiring exists; backend absent — implement /api/admin/auth/* server-side |
AuthService (Ed25519 flow) |
SearchHistoryRepository |
localStorage impl (concrete) | injected concretely — LOCAL-ONLY by design | — (leave local; no backend) | SearchFacade (via SearchHistoryService) |
Read this table as three tiers:
- 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. - Token-bound, real impl missing: DashboardMetrics, Media, UserExperience. The seam exists;
write the
*Api*class and bind it. No facade edit. - 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 classMediaRepository)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 concreteAdmin*LocalGatewayclass 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 readingAdminOrdersLocalGatewayin 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 + aGET /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.ContentManagementFacadeandProjectEditorFacadehave no backend call at all today (audit §16, §17). They read/mutate the in-memoryBootstrapConfigand 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 (ProjectEditorIoServicecurrently only does JSON.stringify/parse; drafts live inProjectEditorDraftStorageService/ localStorage), not swapping a gateway.- Cart (
CartService) is local-only by design (localStoragemarketplace_cart); checkout already emits realPOST /cart/POST /orderscalls (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[]↔translationsdescriptionas key/value array ↔ stringcomments↔callbacks(reviews)- color
0xRRGGBB→#RRGGBB;remainingcount → stock band
Backend action: do not "clean up" these dual shapes — normalizeItem/normalizeCategory
define exactly what the client accepts. Producing a payload inside that tolerance envelope is the
contract; a stricter/renamed shape breaks the storefront. No new mapper needed — the adapter
exists and is LIVE.
Example C — Admin Orders (no mapper exists yet — one must be written)
Unlike A and B, the admin domains have no wire DTO and no mapper — the local gateways
construct AdminOrder view models directly in memory and never touch a URL (audit §14, §24).
So this is the shape you have freedom to design on the backend, as long as the new
AdminOrdersApiGateway maps it into the existing view model.
Proposed backend JSON (GET /backoffice/orders/:id):
{
"id": "ord_1042",
"status": "processing",
"paymentStatus": "paid",
"customer": { "id": "cus_88", "name": "…", "email": "…" },
"items": [ { "productId": "…", "title": "…", "qty": 2, "unitPrice": 1990 } ],
"shipping": { "method": "…", "address": "…" },
"timeline": [ { "event": "created", "at": "2026-07-01T10:00:00Z" } ]
}
Frontend view model: AdminOrder + AdminOrderCustomer, AdminOrderPayment,
AdminOrderShipping, AdminOrderItem, AdminOrderTimelineEntry, AdminOrderStatus,
AdminOrderPaymentStatus (features/admin/orders/models/admin-order.model.ts, audit §23).
Bridge: a new AdminOrdersApiGateway must contain the mapping JSON → AdminOrder, honoring
the AdminOrdersGateway interface methods (loadOrders, loadOrder, updateStatus,
requestRefund, addNote, archiveOrder, restoreOrder, deleteOrder). The status field
must respect the order state machine (see the Orders CRUD contract / archive/BACKEND_API.md §8.1).
The same "no mapper exists, write one inside the new *ApiGateway" note applies to Products,
Users, Transactions, Monitoring, and Moderation.
8.5 Per-domain migration checklist (the pattern, then the table)
Generic pattern to flip one domain from mock to real. Steps 1–3 are only needed for the "no seam yet" domains; token-bound domains start at step 4.
- 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.
- Add a DI token —
InjectionToken+ factory that selects mock vs. real offRuntimeProviderStrategyService, mirroringadmin-categories-gateway.token.ts. - Update the facade injection from the concrete
Admin*LocalGatewayclass to the new token. - Implement the
*ApiGatewayclass satisfying the existing interface (contains the DTO→view mapper, per §8.4 Example C). - Bind the token to the real impl in the providers (factory returns the api gateway for
apimode). - Retire the
*LocalGateway— either delete it or keep it behind the existinguseMockData/ mode flag as a dev fixture (the factory already lets both coexist).
This is the exact pattern Categories already follows (admin-categories-api.gateway.ts +
admin-categories-gateway.token.ts); replicate it verbatim.
| Domain | Needs steps 1–3 (add seam)? | Interface exists? | Real impl exists? | Net work |
|---|---|---|---|---|
| Categories (admin) | no (token-bound) | yes | yes | server endpoints only |
| Dashboard metrics | no (token-bound) | yes | no | steps 4–6 |
| Media | no (token-bound, class token) | yes (abstract class) | no | steps 4–6 |
| User experience | no (token-bound) | yes | no | steps 4–6 + repo redesign (id-only + batch hydrate) |
| Orders | yes | yes | no | steps 1–6 (skip 1) |
| Products (admin) | yes | yes | no | steps 1–6 (skip 1) |
| Users / roles | yes | yes | no | steps 1–6 (skip 1) |
| Transactions | yes | yes | no | steps 1–6 (skip 1) |
| Monitoring | yes | yes | no | steps 1–6 (skip 1) |
| Moderation | yes | yes | no | steps 1–6 (skip 1) |
| Customers | inherits Orders seam | no (derived) | no | derive from Orders token, or add source |
| Analytics | partial | no (derived) | no | needs data source first (last) |
| Content management | n/a (no gateway) | n/a | no | add draft/publish HTTP path |
| Project editor / builder | n/a (no gateway) | n/a | no | add draft/publish HTTP path |
8.6 Recommended backend implementation order (all domains)
Ordered by the frontend's actual dependency structure (audit §5–§18), not by convenience:
- Auth + session first.
TelegramSessionApiServiceis LIVE and every admin path is gated by theadminAuthHeadersInterceptor(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. - Bootstrap / tenant resolution.
GET /bootstraptransport is done, but its content (branding/theme/nav/seo) is still stubbed (remaining-work #1). Tenant resolution andApiConfigService.getBaseUrl()depend on it; every tenant-scoped call resolves through it. - 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. - 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. - Media. Products/categories editors reference media assets; a real
ApiMediaRepository(remaining-work #5, P0) should land alongside/just after admin Products. - Cart / Orders / Transactions. Checkout
POST /cart+POST /ordersare 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). - Reviews / Moderation. Customer review/question writes are LIVE; admin Moderation (#10) gates them. Depends on products existing.
- Users / roles / invitations (#9) — admin governance; independent of commerce but needs auth (step 1).
- Dashboard metrics (#11) then Monitoring (#12) — operational visibility over the domains above.
- Analytics (#13/#17) — last: needs orders/products/moderation real and a tracking pipeline that does not exist yet.
- 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.
- User-experience sync (#16) and search suggestions/filters (#15) — P2/P3 enhancements over already-working local features.
8.7 Estimated effort (frontend-informed)
These are frontend-informed sizes only — they reflect how much of the contract is already seamed/mapped on the client. Backend has its own unknowns (schema design, storage, infra, the tracking pipeline) that are out of scope for this document; a domain marked S here can still be L on the server.
| Domain | Size | Why |
|---|---|---|
| Categories (storefront + admin) | S | LIVE both sides; CategoryMapper + AdminCategoriesApiGateway exist. Server endpoints only. |
| Products storefront reads | S | LIVE; normalizeItem absorbs shape variance. |
| Bootstrap content | M | Transport done; producing real branding/theme/nav/seo values is the work (#1). |
| Media | M | Token seam + abstract interface ready; write ApiMediaRepository + upload pipeline (#5). |
| Admin Orders | M | Interface + rich view models exist, but no seam and no mapper — steps 1–6 + mapper. |
| Admin Products CRUD + variants | L | No seam, no mapper; largest admin model set (variants/attributes/translations/seo). |
| Admin Users/roles/invitations | M | No seam; also reconcile the duplicate AdminRole naming (audit §14/§25 #3). |
| Admin Transactions | M | No seam; tied to Orders shape. |
| Admin Moderation | M | No seam; two state machines (reviews + reports). |
| Admin Monitoring | M | No seam; three sub-resources (events/queues/webhooks). |
| Dashboard metrics | S–M | Token-bound already; single loadMetrics shape. |
| Customers | S | Derives from Orders once Orders is real. |
| Analytics | XL | No data source; needs tracking pipeline + aggregation, gated on everything above. |
| User-experience sync | L | Requires client repository redesign (id-only + batch hydrate), not a drop-in. |
| Builder draft/publish | L | Net-new write path; no client call exists today. |
| Content management / CMS | M–L | Net-new write path; adapter (ContentPageService) exists but no HTTP. |
| Search suggestions/filters | M | Client orchestration exists; backend suggestion source is new. |
9. Backend Checklist
A literal, top-to-bottom checklist. Work the phases in order; within a phase, items are roughly
independent. Section references point to the assembled backend-integration document (this doc's
§8, the CRUD-contracts sections, and docs/archive/BACKEND_API.md where a full shape already lives).
Phase 1 — Foundation (nothing role-gated works until these land)
- Implement session issuance/check/logout:
POST /users/sessions,GET /users/sessions/:id,DELETE /users/sessions/:id— per Auth contract (§5a; archive/BACKEND_API.md/users/sessions/*, already client-LIVE). - Implement the Ed25519 admin-auth flow
GET /api/admin/auth/challenge,POST /verify,POST /refresh,POST /logout— client wiring is LIVE and 404s today (§5b). ReturnAuthChallenge/AuthTokenPairshapes exactly. - Honor the admin auth headers on every gated path:
AdminWebSessionID+Authorization: Bearerfor URLs containing/admin/,/backoffice/,/builder/,/media/(§3 interceptor pipeline). - Serve real
GET /bootstrapcontent (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
TenantConfigsoApiConfigService.getBaseUrl()resolves the right base (§2, §6). - Adopt a consistent error envelope; the client maps failures to a
backend-unavailablescreen 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),WebSessionIDon marketplace requests (§3).
Phase 2 — Read-heavy domains (mostly confirm; already client-LIVE)
- Stand up
GET /categoryreturning theCategoryDtoshapeCategoryMappertolerates (§8.4 Example A; audit §8) — already LIVE client-side. - Stand up
GET /items/:id,GET /category/:id,GET /items/randomitems,GET /searchitemswithin thenormalizeItemtolerance envelope (§8.4 Example B; audit §4, §7). - Serve admin categories CRUD via the existing
AdminCategoriesApiGatewaycontract: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/productsandGET /api/backoffice/categories(storefront cards) —ApiBackofficeDataProvideris LIVE (audit §9). - Stand up
GET /regions→Region[](feeds theX-Regionheader; client falls back to 6 hardcoded regions) (audit §12).
Phase 3 — Write-heavy customer domains
- Keep
POST /cart(CartPaymentRequest→QrCreateResponse) and the frozen QR/card payment polling working unchanged (§10; payments frozen per archive/BACKEND_API.md §2.8). - Implement
POST /orders(CreateOrderRequest→CreateOrderResponse) — client call is LIVE, fire-and-forget after payment (§10; archive/BACKEND_API.md §16.9, marked DONE client-side). - Implement
POST /purchase-email(email receipt) (audit §4). - Accept review/question writes
POST /items/:id/callbackandPOST /items/:id/questiion(preserve thequestiiontypo — it matches the client literal) (§11; audit §4).
Phase 4 — Admin domains (each needs the token seam added first — §8.5)
- Add
AdminOrdersGatewaytoken +AdminOrdersApiGateway, switchAdminOrdersFacadeto the token; implementGET/POST /backoffice/orders*incl.POST /backoffice/orders/:id/statusrespecting the order state machine — per Orders CRUD contract (§8.4 Example C, §8.5; archive/BACKEND_API.md §6.11/§8.1). - Add
AdminProductsGatewaytoken +AdminProductsApiGateway, switchAdminProductsFacade; implement Products CRUD + variants — per Products CRUD contract (§8.5; archive/BACKEND_API.md §6.10/§7.2). - Add
AdminTransactionsGatewaytoken + api gateway, switchAdminTransactionsFacade; implement transactions list/detail +retryFailed+setFraudFlag(tied to orders) — per Transactions contract (§8.5; archive/BACKEND_API.md §6.12). - Add
AdminUsersGatewaytoken + api gateway, switchAdminUsersFacade; implement users/roles/invitations/sessions/audit — per Users contract (§8.5; archive/BACKEND_API.md §6.13). Reconcile the duplicateAdminRolenaming (audit §25 #3). - Add
AdminModerationGatewaytoken + api gateway, switchAdminModerationFacade; implement review + report status transitions — per Moderation contract (§8.5; archive/BACKEND_API.md §6.14/§8.4/§8.5). - Add
AdminMonitoringGatewaytoken + api gateway, switchAdminMonitoringFacade; implement events/queues/webhooks reads — per Monitoring contract (§8.5; archive/BACKEND_API.md §6.16). - Wire
AdminDashboardMetricsApiGatewayto the existingADMIN_DASHBOARD_METRICS_GATEWAYtoken; implementloadMetrics— 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 inProjectEditorFacade/ProjectEditorIoService(§8.3, §17; archive/BACKEND_API.md §6.7). P0 for the builder. - Implement content pages / CMS write path and wire
ContentManagementFacadebeyond in-memory bootstrap (§8.3, §16; archive/BACKEND_API.md §6.8). - Implement the media upload/delete/replace pipeline behind
ApiMediaRepositorybound to theMediaRepositorytoken (§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-unavailableresponses 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-Aftercommunicates "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§6settles 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.
7. Recommended API responses
All maintenance-scenario responses use HTTP 503 Service Unavailable (except the
read-only debate in §4) with a Retry-After header (seconds, standard HTTP) and a
JSON body, nested under the §6 error envelope ({ error: { code, message, ... } })
rather than as a competing top-level shape. The fields below are what the frontend needs regardless
of the outer envelope:
{
"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 whenscope === "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 theRetry-Afterheader 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/retryi18n 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-ONLYdesign 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/reasonstructured 503 body (§7). bootstrap.maintenanceNotice(§5) for scheduled-maintenance advance notice.- A dedicated
maintenance-page.component.tsfull-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 /maintenanceprobe should exist for auto-recovery polling, beyond a plain 503 on/bootstrap. Recommended default: no dedicated probe — reuseGET /bootstrap's existing 503/200 as the recovery signal; a second endpoint duplicates it. - §2: the
scopediscriminator ("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: includescopein 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 (
503vs403) — to be pinned down jointly with §6. Recommended default:503withcode: "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.maintenanceNoticefield (proposed) or a separate polling endpoint. Recommended default: thebootstrap.maintenanceNoticefield — it rides the existing bootstrap fetch/cache lifecycle for free; a separate polling endpoint adds a new recurring request for no added value. - §7: how this document's 503 body nests inside whatever outer envelope
§6 defines.
Recommended default: already resolved — nest under the §6 envelope
(
{ error: { code, message, status, requestId, ... } }) as shown in this section's JSON examples; no separate top-level shape.
No frontend UI currently exists for this — requires a future frontend task (full list)
- Global maintenance full-page takeover component.
- Per-tenant maintenance takeover (branded or not, pending §2's backend decision).
- Per-module inline maintenance banner/empty-state wiring on checkout/payment flows.
- Proactive read-only disabling of write controls (Add to cart / Submit review / Submit question / Checkout) ahead of a failed request.
- Scheduled-maintenance banner + countdown component at the layout/header level.
- Richer "temporarily unavailable, back at X" messaging for
featureFlags-gated features (today they just silently don't render — no explanatory copy).
Appendix: docs/TODO.md items merged into this document (2026-07-26)
Final Project Closeout moved every backend-shaped item out of docs/TODO.md into this
document. None were duplicated as raw new bullets — each is already covered by an
existing section above:
| TODO item | Covered by |
|---|---|
bootstrap.json real content (branding/theme/nav/seo) |
§1 Bootstrap |
| Builder bootstrap draft/publish/validate | §1 (Draft vs Published), §8, §9 Phase 5 |
| Backoffice Products CRUD | §3 Products, §8, §9 Phase 4 |
| Media upload/delete/replace pipeline | §7 Uploads, §9 Phase 5 |
| Backoffice Orders CRUD + status transitions | §3 Orders, §8, §9 Phase 4 |
| Backoffice Transactions | §3 Transactions, §9 Phase 4 |
| Backoffice Users/roles/invitations | §3 Users/Roles, §9 Phase 4 |
| Backoffice Moderation (reviews/reports) | §3 Reviews/Reports, §9 Phase 4 |
| Backend Ready sprint / no real API contract | This entire document |
docs/TODO.md is now empty of blockers — see that file.