Files
marketplaces/docs/archive/BACKEND_API.md
sdarbinyan 99f7bace2d docs: assemble BACKEND_INTEGRATION.md, single canonical backend spec
4349 lines, 9 numbered sections per the Backend Finalization Sprint
spec: Bootstrap, Endpoint Framework, CRUD Contracts (~102 endpoints
across 20 domains), Authentication (spliced from AUTHENTICATION.md),
Security, Error Model (spliced from ERROR_CONTRACT.md), Uploads, Real
Backend Implementation Guide, Backend Checklist (34 items).

Everything traced to docs/context/BACKEND-AUDIT.md and actual current
source - proposed (unverified) paths explicitly marked as such,
everything the frontend has no opinion on marked "Requires backend
decision" rather than invented.

Archived the three docs this supersedes (BACKEND_API.md, AUTH.md,
BACKEND_API_REMAINING_WORK.md) to docs/archive/ with pointers back to
this file. AUTHENTICATION.md, ERROR_CONTRACT.md, MAINTENANCE_MODE.md
kept in place as standalone companion references (their content is
also inlined/cross-referenced here). ADMIN.md left untouched - it's a
frontend admin-UI sprint doc, not a backend spec, no overlap.

Verified via repo-wide search: no other backend/API spec docs remain
outside archive/ and this canonical file.
2026-07-26 12:17:51 +04:00

122 KiB
Raw Blame History

ARCHIVED 2026-07-26. Superseded by docs/BACKEND_INTEGRATION.md, the single canonical backend integration document. Kept for history only — do not implement against this file.

Backend Integration — Master Specification

Status: canonical. This is the single authoritative backend/API contract for this Angular multi-tenant marketplace platform. It supersedes and replaces docs/BACKEND.md, docs/BACKEND_API.md (old), docs/BACKEND-INTEGRATION-PROMPT.md, docs/BACKEND-DIFF-VS-MAIN.md, docs/architecture/backend/Backend-Platform-API-Spec.md, and the API-contract portions of docs/BOOTSTRAP.md. Those files are deleted; this document is where all of that content now lives, verified against the current source tree on branch B2B.

Audience: a backend engineer implementing this platform's API with no other context, and any frontend engineer who needs the ground truth for what the client sends and expects.

How to read this document

Every endpoint, DTO, and behavior below is tagged with a status:

  • CURRENT — real HTTP call today, verified in code (file path cited). Shape is frozen; do not change it.
  • PLANNED — declared in the bootstrap model (apiEndpoints.builder / apiEndpoints.backoffice) or implied by a gateway interface, but not wired to real HTTP yet. Served today by a *LocalGateway (in-memory/localStorage/IndexedDB mock) bound via an Angular DI token. Implementing the real endpoint means writing one *ApiGateway class against the documented interface and rebinding the token — no UI change.
  • FUTURE — reserved contract only. Nothing exists client-side yet beyond a placeholder or a "coming soon" page. Speculative shape, subject to change once real requirements exist.

Never mix these tags. If you can't find a field in the cited model file, it isn't real — ask rather than invent (see Assumptions below for the few places a judgment call was necessary).

Assumptions (only where the source code was genuinely ambiguous)

  1. Bootstrap path. Code call sites use GET /bootstrap relative to the resolved API base (ApiConfigService.getBaseUrl()), not /api/v1/bootstrap. This document uses GET /bootstrap throughout, consistent with docs/architecture/backend/Backend-Platform-API-Spec.md's draft and every existing doc. If a backend team versions it, GET /v1/bootstrap is a compatible evolution — do not silently rename without a schema/version bump story (see §5).
  2. builder/* and backoffice/* path prefixes. No literal string constant for these prefixes exists in code (the mock gateways never build a URL at all — they're pure in-memory/localStorage). The prefixes used throughout §6 follow the naming already established in docs/BACKEND.md/docs/BACKEND-INTEGRATION-PROMPT.md and match ApiEndpointsConfig's builder/backoffice buckets (src/app/shared/models/config/api-endpoints.model.ts). Treat every concrete path in §6.7§6.16 as a PLANNED contract proposal, not a verified literal string — the shapes (request/response bodies, field names, enums) are verified against the real TypeScript interfaces; the paths are a reasonable default you're free to adjust as long as apiEndpoints.backoffice/apiEndpoints.builder in the published bootstrap stays in sync with whatever you choose.
  3. Auth/payment endpoints are explicitly frozen by ADR-010 and out of scope for change — documented here for completeness (§2, §6.2) but treat as read-only reference, not a request for redesign.

Table of contents

  1. Architecture overview
  2. Authentication
  3. Security
  4. Bootstrap
  5. API conventions
  6. Endpoints, by domain
  7. DTOs
  8. State machines
  9. Validation
  10. Media
  11. Errors
  12. Localization
  13. Caching
  14. Backend replacement pattern
  15. Future APIs
  16. Sequence diagrams
  17. Developer notes

1. Architecture overview

This is not a single marketplace — it is a multi-tenant platform. One Angular codebase serves unlimited tenants ("marketplaces"), each configured entirely at runtime from a backend-served BootstrapConfig document. No marketplace-specific code exists in the frontend (docs/context/adrs/ADR-0001-marketplace-platform-vision.md).

Every tenant has three logical surfaces sharing one domain:

  • Website — the public storefront (catalog, product, cart, checkout-adjacent flows).
  • Builder (Project Editor) — the tenant admin's visual editor for the same BootstrapConfig the storefront renders from. No parallel model.
  • Backoffice — operational admin: products, categories, orders, transactions, users, monitoring, analytics, media.
flowchart TD
    Host["HTTP Host header"] --> TenantResolve["Tenant resolution (backend, before auth/routing)"]
    TenantResolve --> Bootstrap["GET /bootstrap"]
    Bootstrap --> ConfigService["ConfigService (Angular, loads once, shareReplay(1))"]
    ConfigService --> Runtime["PlatformRuntimeService (theme, branding, nav, pages)"]
    Runtime --> Website["Website (public storefront)"]
    Runtime --> Builder["Builder / Project Editor (draft BootstrapConfig)"]
    Runtime --> Backoffice["Backoffice (admin CRUD)"]
    Website --> API["Storefront REST API (category/items/search/cart/reviews)"]
    Builder --> BuilderAPI["Builder REST API (draft/publish)"]
    Backoffice --> BackofficeAPI["Backoffice REST API (per-domain CRUD)"]
    API --> DB[("Database")]
    BuilderAPI --> DB
    BackofficeAPI --> DB

Request flow (every storefront call)

  1. Application code calls this.http.get('/api/category') or builds an absolute URL via ApiConfigService.
  2. apiBaseUrlInterceptor (src/app/interceptors/api-base-url.interceptor.ts) rewrites any /api/... URL to the resolved tenant base via ApiConfigService.toApiUrl().
  3. apiHeadersInterceptor (src/app/interceptors/api-headers.interceptor.ts) attaches X-Region, X-Language, Currency, WebSessionID to every request whose URL resolves as an API request (ApiConfigService.isApiRequest()).
  4. adminAuthHeadersInterceptor (src/app/core/admin-auth/admin-auth-headers.interceptor.ts) additionally attaches AdminWebSessionID and, if present, Authorization: Bearer <token> — but only when the URL contains /admin/.
  5. cacheInterceptor (src/app/interceptors/cache.interceptor.ts) short-circuits GET requests to /category, /category/:id, /items/:id against an in-memory TTL cache (see §13).
  6. mockDataInterceptor (src/app/interceptors/mock-data.interceptor.ts / .production.ts) — dev-only; when environment.useMockData is true, intercepts before any of the above and serves fixture JSON. Not part of the real contract; ignore for backend implementation.
  7. Request reaches the resolved absolute base (see base-URL resolution below).

Interceptor registration order (as configured, mockData → apiBaseUrl → apiHeaders → adminAuth → cache) matters: base-URL rewriting must happen before headers are attached (headers only attach to recognized API requests), and admin headers only attach to /admin/ URLs so customer requests never carry an AdminWebSessionID.

Base-URL resolution (ApiConfigService.getBaseUrl, src/app/core/config/api-config.service.ts)

Priority order, first match wins:

  1. Localhostenvironment.localhostApiUrl (/api, proxied by the Angular dev server).
  2. environment.tenantApiBaseUrls[tenantKey] — explicit per-tenant override map (e.g. default/dexarmarkethttps://api.dexarmarket.ru:445).
  3. environment.tenantApiTemplate — host-pattern template, e.g. https://{tenant}.api.dexarmarket.ru:445.
  4. Bootstrap override (opt-in only) — bootstrap.apiEndpoints.website.baseUrl or bootstrap.tenant.apiBaseUrl, used only when environment.allowBootstrapApiOverride === true and the value is an absolute http(s):// URL. Disabled by default in both environment.ts and environment.production.ts.
  5. Fallbackenvironment.apiUrl.

Tenant identity itself comes from TenantResolverService (host-based), never a path or query parameter — the backend must resolve tenant from the Host header the same way, before authorization and routing (mirrors docs/architecture/backend/Backend-Platform-API-Spec.md §1).

Backend impact for a new tenant: either register it in tenantApiBaseUrls, or serve it at the tenantApiTemplate host pattern — no frontend rebuild required if the template host pattern holds.

Layered feature architecture (why the UI never touches an endpoint directly)

Every admin domain (categories, products, orders, transactions, users, monitoring, dashboard) follows: *Gateway interface → *LocalGateway (mock, bound today) / *ApiGateway (real, to be built) → DI token → Facade → Page components. See §14 for the full replacement mechanics. This is why implementing a backend for any one domain is additive: write one class, rebind one token, ship.


2. Authentication

Scope note (ADR-010): authentication, payment, and authorization behavior and contracts are frozen — this section documents the existing contract for completeness and integration correctness. It is not a request to redesign the auth flow.

2.1 Why Telegram, why sessions, why not just JWT-on-login

The platform authenticates end users (both customers and admins) via Telegram, not a password. A user scans a QR code (desktop) or taps a deep link (mobile) that opens the platform's Telegram bot with a start payload equal to a server-generated session id. The bot, on the backend side, associates that Telegram identity with the session id. The frontend never sees Telegram credentials — it only polls "is this session id now associated with a logged-in Telegram user?" This is why the flow is session-first: the session id exists before the user is authenticated, and authentication is something that happens to an already-existing session, not something that produces one.

Why not a JWT immediately on session creation: the session id is created anonymously (no identity yet) so the frontend has something to encode into a QR code before any identity exists. A JWT would need a subject; there isn't one until the Telegram bot round-trip completes.

Why sessions (cookie) rather than only a bearer token in memory: the customer and admin session ids are stored as cookies (webSessionID, adminSessionID) specifically so a page refresh or new tab doesn't lose login state — a pure in-memory token would not survive navigation. See §2.6 for exact cookie policy.

Why Telegram alone isn't enough for admin: completing the Telegram QR flow only proves "this is a real Telegram account holder." It proves nothing about whether that account is authorized to act as a marketplace admin. See §2.5 — this is the platform's most significant open security gap today.

2.2 The one session API (CURRENT)

Both customer and admin login are, today, the same backend endpoint: {authApiUrl}/users/sessions (environment.authApiUrl, e.g. https://users.vitanova.network:456), called exclusively through TelegramSessionApiService (src/app/services/telegram-session-api.service.ts). There is no separate admin session endpoint.

Method Endpoint Purpose Caller
POST {authApiUrl}/users/sessions Create an anonymous session, get the Telegram bot deep-link TelegramSessionApiService.createSession()
GET {authApiUrl}/users/sessions/:webSessionID Poll session status (has the Telegram bot round-trip completed?) TelegramSessionApiService.checkSessionOnce()
DELETE {authApiUrl}/users/sessions/:webSessionID Logout / invalidate session TelegramSessionApiService.logout()

POST /users/sessions — request body { webSessionID: string } (a client-generated GUID, generateGuid() from src/app/shared/util/guid.util.ts), header WebSessionID: <same guid>. Response is normalized defensively (normalizeWebSession) against many possible field-name variants — the client tolerates webSessionID/WebSessionID/webSessionId/sessionID/SessionID/sessionId/id/ID for the session id field, and similar variant lists for user, status/active/loggedIn, username, firstName/first_name, expiresAt/expires. Backend implementers: pick one canonical field name per concept; the client's tolerance is defensive, not a suggestion to send multiple.

The frontend generates the QR/deep-link URL itself (not from a backend field): https://t.me/{telegramBot}?start={webSessionID} for the QR/browser flow, tg://resolve?domain={telegramBot}&start={webSessionID} for the in-app deep link. telegramBot is a frontend environment constant (environment.telegramBot, e.g. myAMLKYCBOT / DexarSupport_bot), not backend-supplied.

GET /users/sessions/:webSessionID — polled every 5 seconds (POLL_INTERVAL_MS, QrLoginEngine, src/app/shared/qr-login/qr-login.engine.ts), up to 100 polls (MAX_POLLS) before the client gives up and shows expired. Also re-checked on visibilitychange/focus/pageshow events (the "user returned from the Telegram app" recovery path — openAppLogin() navigates away via window.location.href, so the SPA needs to re-verify session state whenever the tab regains focus). Response shape normalizes to:

// src/app/shared/models/auth.model.ts (AuthSession)
interface AuthSession {
  sessionId: string;
  userId: number | null;
  username: string | null;
  displayName: string;
  active: boolean;
  expires: string; // ISO timestamp
}

If the backend response omits an expiry, the client defaults to now + 3600s (SESSION_MAX_AGE_SECONDS = 60 * 60) — this is a client-side fallback only; the backend should always send a real expiresAt.

DELETE /users/sessions/:webSessionID — best-effort; client clears local state regardless of response (catchError(() => of(null))), so a network failure on logout never blocks the UI from appearing logged out.

2.3 Customer login (CURRENT)

AuthService (src/app/services/auth.service.ts) wraps TelegramSessionApiService and owns:

  • Cookie webSessionID, SameSite=Lax, Max-Age=3600, Secure when served over HTTPS, Path=/.
  • A status signal: unknown | checking | authenticated | unauthenticated.
  • Auto session-refresh: schedules a re-check 60 seconds before expires (minimum 30s out), so an active tab silently re-validates before expiry rather than surprising the user with a sudden logout.
  • On app init, always calls checkSession() once against whatever cookie is present.

2.4 Admin login (CURRENT, same backend as customer)

AdminAuthService (src/app/core/admin-auth/admin-auth.service.ts) is a structurally identical wrapper around the same TelegramSessionApiService — same createSession/checkSessionOnce/logout calls, same polling engine (QrLoginEngine) — with only the storage kept separate:

Customer (AuthService) Admin (AdminAuthService)
Cookie name webSessionID adminSessionID
Cookie SameSite Lax Strict
Extra token storage localStorage: adminToken / adminRefreshToken (reserved for a future JWT pair, currently unused — see below)
Guard (route-level, customer flows) adminAuthGuard (src/app/core/admin-auth/admin-auth.guard.ts)
Header interceptor apiHeadersInterceptor sets WebSessionID adminAuthHeadersInterceptor sets AdminWebSessionID (+ Authorization: Bearer if a token exists)

AdminAuthService.getAdminToken()/setAdminTokens()/clearAdminTokens() exist and are wired into the Authorization header, but nothing currently calls setAdminTokens() — this is dead-but-ready plumbing for once the backend issues a real JWT access/refresh pair on top of the Telegram session. Treat this as a PLANNED extension point, not a current contract.

Dev-only bypass: AdminAuthService.devBypassLogin() fabricates a local session (dev-bypass-<timestamp>) and activates it directly, skipping the QR flow — but is a runtime no-op when environment.production === true (checked at call time, not just build time). Reachable via ?devBypassAdmin=true per docs/BACKEND-DIFF-VS-MAIN.md. Not a backend concern beyond knowing it exists — it never calls any endpoint.

2.5 The admin authorization gap (CRITICAL — security-relevant, unresolved)

Because admin and customer login hit the identical POST /users/sessions endpoint, the backend currently has no way to know, at the moment the QR is scanned, that this is an admin login attempt versus a customer one. The frontend's only decision is where to store the resulting session id (adminSessionID cookie vs. webSessionID cookie) — it cannot and does not decide, and cannot enforce, whether the Telegram user who completed the scan is actually authorized to act as an admin.

Concretely: any Telegram user who completes the QR flow while the admin login screen happens to be showing receives a valid adminSessionID. There is currently no server-side check that rejects a non-admin Telegram user's session when it's used with AdminWebSessionID.

Required fix (server-side, not fixable from the frontend): when an API call arrives carrying AdminWebSessionID, the backend must look up whether that Telegram user id is in the admin/role registry and reject (401/403) if not. This is a hard requirement before this system is production-safe for any tenant with real admin/customer separation.

Also prepared, not wired: Ed25519VerificationService (src/app/core/admin-auth/ed25519-verification.model.ts) defines a requestChallenge()/verify() contract for a future non-Telegram, challenge/signature-based admin auth path (Ed25519Challenge { nonce, timestamp, payload }Ed25519SignedResponse { challenge, publicKey, signature }Ed25519VerificationResult { valid, reason? }). The current binding is NoopEd25519VerificationService, which throws rather than silently accepting anything — a deliberate fail-closed placeholder, safe to leave wired until a real challenge/verify endpoint exists. FUTURE — no endpoint exists for this today.

2.6 Cookies, headers, and session identity summary

Concern Customer Admin
Cookie webSessionID adminSessionID
SameSite Lax Strict
Secure yes, when https: yes, when https:
Max-Age 3600s 3600s
Header sent on API calls WebSessionID (all API requests) AdminWebSessionID (only /admin/ requests) + optional Authorization: Bearer <adminToken>
Anonymous fallback 32-char hex, localStorage: web_session_id, generated client-side if no session exists none — unauthenticated admin routes redirect to login

2.7 Refresh, expiry, unauthorized flows

  • Refresh: not a token-refresh in the OAuth sense — it's a periodic re-poll of GET /users/sessions/:id scheduled ~60s before the session's expires timestamp. If still active, the client silently re-activates the session (same cookie, updated in-memory signal); if not, the client transitions to unauthenticated and clears the cookie.
  • Expired token flow: checkSessionOnce returning active: false (or erroring) clears all local auth state (clearAuthState('unauthenticated')) — cookie removed, signals reset, refresh timer cancelled. No automatic re-login; the user must re-scan.
  • Unauthorized flow (admin): adminAuthGuard blocks navigation and calls requestLogin() (shows the login dialog) rather than a hard redirect — consistent with this being an SPA-level gate, not a route change.

2.8 Payments (frozen, documented for completeness)

ApiService (src/app/services/api.service.ts) also exposes the payment surface — frozen per ADR-010, not to be changed:

Method Endpoint Notes
createPayment() POST {qrApiUrl}/qr QR dynamic payment creation. Headers authorization-key, userid-value optional.
createCartPayment() POST /cart Cart-scoped payment (card or QR), fixed partnerqrID = 'web-97ec-9c57-4dde-9037-3a68f7f83750'.
checkCartPaymentStatus() GET {qrApiUrl}/qr/dynamic/{partnerId}/:qrId
checkCartCardPaymentStatus() GET {qrApiUrl}/card/{partnerId}/:orderId
checkPaymentStatus() GET {qrApiUrl}/qr/dynamic/:partnerQrId/:qrId

QrCreateRequest/QrCreateResponse/CartPaymentRequest/QrDynamicStatusResponse interfaces are in api.service.ts lines 1066 — reproduced in §7.9. The response normalizer tolerates many field-name casings (qrId/qrID, nspkID/nspkId, etc.) for the same reason as session responses: multiple backend versions/typos have shipped over time and the client absorbs them rather than breaking.


3. Security

3.1 Origin is not authentication

The frontend sends no CSRF token and relies on cookies (webSessionID/adminSessionID, SameSite=Lax/Strict) plus header-based session ids (WebSessionID, AdminWebSessionID) as the actual authorization signal — Origin/Referer headers must never be treated as an authentication mechanism. They are trivially spoofable by any non-browser client (curl, a compromised browser extension, a malicious script running same-origin) and provide no proof of user identity or intent. Legitimate uses of Origin are limited to: (a) CORS allow-listing for browser-enforced preflight, and (b) defense-in-depth logging/anomaly detection — never as a substitute for verifying WebSessionID/AdminWebSessionID against a real, backend-held session record.

3.2 CORS

Not configured in this repository (no nginx.conf CORS block for the actual API hosts api.dexarmarket.ru:445 / users.vitanova.network:456 — those are separate servers, not part of this repo). Recommended production posture:

  • Allow-list exact tenant storefront origins (from the tenant registry, not *).
  • Access-Control-Allow-Credentials: true (cookies are in play).
  • Preflight OPTIONS handling for any endpoint that receives custom headers (X-Region, X-Language, Currency, WebSessionID, AdminWebSessionID).

3.3 Endpoint authorization tiers

Tier Examples Requirement
Public GET /bootstrap, GET /category, GET /items/:id, GET /searchitems No auth. Never leak per-user/per-session data through these.
Session-required (customer) POST /websession/:id (cart), POST /items/:id/callback (review), POST /purchase-email Valid WebSessionID; anonymous sessions are allowed (cart works for guests).
JWT/admin-session-required builder/*, backoffice/* Valid AdminWebSessionID and (§2.5) server-side verification that the underlying Telegram identity is actually provisioned as an admin.
Admin-role-required (finer-grained) e.g. users/roles write endpoints vs. read-only products list AdminUser.roleIdAdminRole.permissions[] (see §7.6) should gate specific mutations once a real permission model exists server-side. PermissionsConfig (src/app/shared/models/config/permissions.model.ts) is the bootstrap-level skeleton for this (definitions: PermissionDefinition[], roles: RolePermissions[]) but is not yet enforced anywhere in the frontend beyond existing.

3.4 Rate limiting, replay protection, CSRF

None of these exist today, client- or server-side, for this platform. Recommendations for a production backend:

  • Rate limiting: at minimum on POST /users/sessions (session creation is unauthenticated and cheap to hammer) and on write endpoints (builder/bootstrap/publish, any backoffice/* mutation).
  • Replay protection: session ids are long-lived (1 hour) bearer-equivalent values sent in a custom header — treat header leakage (e.g. via logs, proxies) as a real risk; short expiry + refresh (already the pattern, §2.7) is the primary mitigation.
  • CSRF: cookies are SameSite=Lax (customer) / Strict (admin), which meaningfully reduces cross-site request risk for the cookie itself, but the actual authorization value doubles as a header (WebSessionID/AdminWebSessionID) read from the cookie by JS and reattached — a state-changing endpoint that only checks the header, not the cookie, reopens CSRF risk from any origin that can trick a script into replaying a known session id. Recommend the backend validate that the header value matches a cookie-bound session, not just "does this header value exist in the sessions table."

3.5 Bearer tokens, HTTPS, headers

  • Authorization: Bearer <adminToken> is sent when AdminAuthService.getAdminToken() returns non-null (currently always null in practice — see §2.4). Once real JWTs are issued, use standard Authorization: Bearer semantics; don't invent a custom scheme.
  • Every environment config uses https:// for apiUrl/authApiUrl/qrApiUrl in production; only localhost dev proxies over /api. HTTPS is mandatory in production — cookies rely on Secure being meaningful.
  • Custom headers the backend must read: X-Region, X-Language, Currency, WebSessionID (all requests); AdminWebSessionID, Authorization (admin requests only).
  • Enforce admin authorization server-side per §2.5 — this is the single highest-priority item in this entire document.
  • Validate WebSessionID/AdminWebSessionID against a real backend-held session, never trust the header value alone as proof.
  • Rate-limit session creation and all write endpoints.
  • Serve GET /bootstrap with no secrets, ever (see §4) — it is public by design.
  • Populate apiEndpoints.builder/apiEndpoints.backoffice in bootstrap only with paths that are actually protected server-side; publishing an endpoint in bootstrap is not itself a security boundary.
  • CORS allow-list per tenant host, not wildcard, when credentials are involved.

4. Bootstrap

4.1 Endpoint

CURRENTGET /bootstrap, resolved against the tenant API base (§1). No query params or body. Tenant resolved server-side from the Host header — the frontend never sends a tenant id. Loaded once at app startup by ConfigService.loadBootstrap() (src/app/core/config/config.service.ts) and cached via shareReplay(1) — repeat calls to loadBootstrap() within the same session return the cached observable unless forceRefresh: true is passed.

On localhost (or when environment.useMockData === true), MockBootstrapProvider serves src/assets/mock/bootstrap/bootstrap.json instead of a real HTTP call, so the app runs fully offline (npm run dexar). This mock file is the canonical field-for-field reference payload — match it exactly to avoid client-side normalization surprises. ApiBootstrapProvider is the real-HTTP implementation, both behind CONFIG_PROVIDER DI token per ADR-004.

4.2 Response caching

No explicit ETag/304 handling exists in the frontend today — the entire response is cached client-side for the session lifetime via shareReplay(1). A backend implementer is free to add ETag/Cache-Control (recommended, see §13) since the client doesn't currently send conditional-request headers for this endpoint — adding them is additive and safe.

4.3 Top-level shape

Source: src/app/shared/models/config/bootstrap-config.model.ts.

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

Rule (ADR-0001): bootstrap contains only what's needed before the app starts — branding, theme, languages, navigation, footer/static-page references, homepage layout, enabled widgets/features. It must never contain products, orders, cart contents, or user-specific data. Product/category data is fetched separately (GET /category, GET /items/:id, etc.) — catalog/productPage/userExperience in bootstrap are feature/UI configuration only, never data.

4.4 Field-by-field reference

Key Source file Notes
schemaVersion Contract version string. Breaking changes require a bump; frontend must stay compatible within a minor line (see §5.1).
generatedAt Payload generation timestamp, ISO 8601.
tenant tenant.model.ts id (UUID), slug, code, host, name, websiteBaseUrl, builderBaseUrl, backofficeBaseUrl, defaultLocale, supportedLocales[], defaultCurrency, supportedCurrencies[], timezone. Resolved by domain only — never re-derived client-side.
branding branding.model.ts brandName, legalName, slogan?, logoUrl, logoCompactUrl?, faviconUrl, appIconUrl?, socialImageUrl?, galleryUrls?[], supportEmail?, supportPhone?.
theme theme.model.ts themeId, mode: light|dark|system, palette (12 semantic 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.
company company.model.ts companyName, registrationNumber?, taxId?, address (country, region?, city, street?, postalCode?), contacts (email, phone?, telegram?, website?, additionalPhones?[], additionalEmails?[]). Used in footer legal/contact info.
featureFlags feature-flags.model.ts Boolean map with 14 named keys (wishlist, compare, reviews, questions, comments, recommendations, blog, chat, analytics, notifications, coupons, loyalty, giftCards, invoices) plus an open [key: string]: boolean index — new flags can be added without a model change.
features features-config.model.ts Newer, more granular MarketplaceFeaturesConfig (16 booleans: wishlist/compare/reviews/comments/questions/recommendations/recentlyViewed/searchHistory/recentlySearched/ratings/share/brands/manufacturers/availability/discounts/badges). Resolvers fall back to featureFlags/productPage/userExperience/catalog for older bootstraps lacking this field — do not remove featureFlags when adding features; both must be sent for backward compatibility until every consumer is confirmed on the new surface.
apiEndpoints api-endpoints.model.ts { bootstrap: ApiEndpointConfig, website: Record<string, ApiEndpointConfig>, builder: Record<string, ApiEndpointConfig>, backoffice: Record<string, ApiEndpointConfig> } where ApiEndpointConfig = { path, method, timeoutMs? }. Never contains secrets. Populate builder/backoffice buckets as you implement each endpoint so the frontend can (eventually) discover them.
localization localization.model.ts defaultLocale, supportedLocales[], currencyByLocale: Record<string,string>, dictionaries: LocalizationDictionaryRef[] ({locale, dictionaryUrl, version}).
seo seo.model.ts default: SeoPageConfig, byPageKey: Record<string, SeoPageConfig>. SeoPageConfig = { title, description, canonicalUrl?, robots?, metaTags?: {name?, property?, content}[] }.
permissions permissions.model.ts definitions: {key, description?}[], roles: {role, permissions[]}[]. Currently minimal/skeletal — real enforcement is server-side (§3.3, §2.5).
header header-config.model.ts All-boolean toggles: showLogo, showSearch, showCategories, showLanguages, showCart, showProfile, showWishlist, showCompare, showRegion, plus sticky?: boolean, layout?: 'default'|'centered'.
catalog catalog-config.model.ts UI/feature config only, no product data. layout (8 grid/list modes), loadingStrategy (pagination|loadMore|infiniteScroll), navigationMode (4 modes), defaultSort/availableSorts (7 sort keys), enabledFilters: string[], and 7 show*/*Enabled booleans.
layout layout.model.ts { type: 'default'|'sidebar-left'|'carousel-home'|'minimal'|string, options?: Record<string,unknown> }. Global page-chrome mode.
navigation navigation.model.ts header: NavigationItemConfig[], footer: NavigationItemConfig[] | FooterNavigationGroupConfig[] (two accepted shapes — flat list or grouped), optional sidebar[]. Each item: id, label (string or {[locale]: string}), route?, type?, key? (e.g. 'staticPage' + a page id — see §12), icon?, order?, visible?, visibleWhenFlags?: string[], children?.
footer footer-config.model.ts logoUrl?, paymentIcons?: {src, alt, width?, height?}[], copyrightText? (string or per-locale), columns?: FooterColumnConfig[] ({id, title, links: {id,label,pageKey?,url?}[]}), socialLinks?: {id,label,url,icon?}[]. legalPageKeys/staticPageKeys are deprecated, superseded by columns — still read for already-saved configs, do not emit them for new tenants.
productPage product-page-config.model.ts Feature config only for PDP — rating, reviews (pageSize, showSummary, mode: pages|load-more), questions (pageSize, allowSubmission), tabs (items[] from a fixed enum), relatedProducts, actions (addToCart,buyNow,wishlist,compare,share,notifyMe). No review/question data here.
userExperience user-experience-config.model.ts Feature config only — wishlist (headerBadgeEnabled), compare (maxItems, hideIdenticalDefault, highlightDifferencesDefault), recentlyViewed (maxItems, widgetEnabled), share, continueBrowsing, savedSearches (maxItems). Never user-specific lists — those are client-local today (§6.13).
pages page.model.ts {id, key, title, route: {path, exact?}, layout: string | PlatformLayoutConfig, sections: SectionConfig[], seoKey?, featureFlag?, visible?}.
staticPages static-page.model.ts See §4.6.
widgetRegistry widget-registry.model.ts { manifestUrl: string } — pointer to the widget manifest (see docs/ARCHITECTURE.md widget engine; not itself a backend endpoint documented here).

4.5 Section and widget config

// section.model.ts
type SectionLayoutStrategy = 'stack' | 'grid' | 'hero' | 'carousel' | 'split';

interface SectionConfig {
  id: string; type: string; order: number;
  layout?: { strategy?: SectionLayoutStrategy; columns?: number; gap?: string; align?: 'start'|'center'|'end'|'stretch' };
  visibility?: { desktop?: boolean; tablet?: boolean; mobile?: boolean };
  widgets: WidgetConfig[];
  featureFlag?: string;
  visible?: boolean;
}

// widget.model.ts
interface WidgetConfig {
  id: string; type: string; version: string;
  title?: string; subtitle?: string; order?: number; padding?: string;
  visibility?: { desktop?: boolean; tablet?: boolean; mobile?: boolean };
  animation?: { name?: string; duration?: string; delay?: string; timingFunction?: string };
  style?: Record<string, string>;
  permissions?: { requireAuthenticated?: boolean; roles?: string[]; permissions?: string[] };
  props: Record<string, unknown>;
  actions?: Record<string, { type: 'navigate'|'open'|'submit'|'custom'; target?: string; payload?: Record<string, unknown> }>;
  featureFlag?: string;
  visible?: boolean;
}

Typed editors exist in the Project Editor for hero, categories, product-collection widget types; every other type edits props as raw JSON.

4.6 staticPages

// static-page.model.ts
type StaticPagesConfig = Record<string, StaticPageConfig> | LegacyStaticPageConfig[]; // both accepted

interface StaticPageConfig {
  id: string;
  slug: string;
  title: string | Record<string, string>;
  showInFooter?: boolean; showInHeader?: boolean; showInSitemap?: boolean;
  icon?: string; order?: number;
  visibility?: { desktop?: boolean; tablet?: boolean; mobile?: boolean };
  requiresAuthentication?: boolean;
  footerGroup?: string;
  translations?: Record<string, { title?: string; html?: string; seo?: {...} }>;
  html?: string | Record<string, string>;
  seo?: StaticPageSeoConfig;
  visible?: boolean;
  route?: string;
  content?: Record<string, string>;
  enabled?: boolean;               // master on/off switch
  status?: 'draft' | 'published';  // per-page publish lifecycle, independent of whole-bootstrap publish
  customTemplate?: string;
  heroImage?: string; heroImageAlt?: string; heroImageCaption?: string;
  thumbnail?: string; gallery?: string[];
  updatedAt?: string;
}

Resolution rule: a static page resolves on the storefront only when enabled === true and status === 'published' — independent of whether the surrounding bootstrap has been published. A page with status: 'draft' stays invisible even after the whole tenant config is published. Compatibility default when normalizing legacy data (no enabled/status present): enabled: true, status: 'published' — existing tenants are never silently un-published.

Known data inconsistency: the model requires slug, but the reference mock (src/assets/mock/bootstrap/bootstrap.json) leaves slug empty for some pages and only populates route (e.g. /about-us). The frontend's duplicate-detection falls back to route when slug is empty. Decide and standardize: either always populate slug server-side, or formally make slug derivable from route — do not leave both optional indefinitely.

4.7 Representative example (trimmed)

{
  "schemaVersion": "2.1.0",
  "generatedAt": "2026-07-05T10:30:00Z",
  "tenant": {
    "id": "tenant-dexar-ru", "slug": "dexar-ru", "code": "dexar-ru", "host": "dexarmarket.ru",
    "name": "Dexar Market", "websiteBaseUrl": "https://dexarmarket.ru",
    "builderBaseUrl": "https://dexarmarket.ru/edit", "backofficeBaseUrl": "https://dexarmarket.ru/admin",
    "defaultLocale": "ru", "supportedLocales": ["ru", "en", "hy"],
    "defaultCurrency": "RUB", "supportedCurrencies": ["RUB", "USD", "AMD"], "timezone": "Europe/Moscow"
  },
  "branding": { "brandName": "Dexar Market", "legalName": "OOO Dexar", "logoUrl": "/assets/brand/logo.svg", "faviconUrl": "/assets/brand/favicon.ico" },
  "theme": {
    "themeId": "dexar-light", "mode": "light",
    "palette": { "primary": "#2F6E5D", "secondary": "#8FA9A2", "accent": "#E7A33E", "success": "#2F9E44", "warning": "#F08C00", "danger": "#E03131", "info": "#1C7ED6", "textPrimary": "#1F322D", "textSecondary": "#5C6B66", "backgroundPrimary": "#FFFFFF", "backgroundSecondary": "#F4F6F5", "border": "#DDE3E1" },
    "typography": { "primaryFontFamily": "DM Sans, sans-serif", "baseFontSize": 16 },
    "spacing": { "unit": 4, "scale": [0,4,8,12,16,24,32] },
    "borderRadiusScale": { "sm": "6px", "md": "12px", "lg": "20px" },
    "shadows": { "md": "0 4px 12px rgba(0,0,0,0.15)" },
    "iconSet": "default"
  },
  "company": { "companyName": "OOO Dexar", "address": { "country": "RU", "city": "Moscow" }, "contacts": { "email": "info@dexarmarket.ru", "phone": "+7 926 459 31 57" } },
  "featureFlags": { "wishlist": true, "compare": true, "reviews": true, "questions": true, "comments": true, "recommendations": true, "blog": false, "chat": false, "analytics": true, "notifications": true, "coupons": false, "loyalty": false, "giftCards": false, "invoices": false },
  "apiEndpoints": { "bootstrap": { "path": "/bootstrap", "method": "GET" }, "website": {}, "builder": {}, "backoffice": {} },
  "localization": { "defaultLocale": "ru", "supportedLocales": ["ru","en","hy"], "currencyByLocale": { "ru": "RUB", "en": "USD", "hy": "AMD" }, "dictionaries": [] },
  "seo": { "default": { "title": "Dexar Market", "description": "Multi-category marketplace" }, "byPageKey": {} },
  "permissions": { "definitions": [], "roles": [] },
  "layout": { "type": "default" },
  "catalog": { "layout": "grid-4", "navigationMode": "default", "defaultSort": "relevance", "showRatings": true },
  "navigation": {
    "header": [ { "id": "nav-home", "label": "Home", "route": "/", "order": 1, "visible": true } ],
    "footer": [ { "id": "nav-privacy", "label": "Privacy", "route": "/privacy-policy", "order": 1, "visible": true } ]
  },
  "pages": [
    {
      "id": "page-home", "key": "home", "title": "Home", "route": { "path": "/", "exact": true }, "layout": "default",
      "sections": [
        { "id": "home-hero", "type": "hero", "order": 1, "layout": { "strategy": "hero" },
          "widgets": [ { "id": "w-hero", "type": "hero", "version": "1.0.0", "props": { "title": "Welcome", "layout": "full-bleed" } } ] },
        { "id": "home-categories", "type": "categories", "order": 2, "layout": { "strategy": "grid", "columns": 4 },
          "widgets": [ { "id": "w-categories", "type": "categories", "version": "1.0.0", "props": { "columns": 4 } } ] }
      ]
    }
  ],
  "staticPages": {
    "static-about": { "id": "static-about", "slug": "about", "title": { "en": "About Us" }, "showInFooter": true, "showInHeader": false, "enabled": true, "status": "published", "translations": { "en": { "html": "<h1>About Us</h1>" } } }
  }
}

4.8 Lifecycle: how the frontend builds itself from it

  1. ConfigService.loadBootstrap() fetches (or, mock mode, reads local JSON) and parses BootstrapConfig. Cached via shareReplay(1).
  2. PlatformRuntimeService applies theme tokens as CSS variables, sets branding, and exposes parsed pages/navigation/footer/static-pages to the rest of the app.
  3. The Section Engine / Widget Host render pages from bootstrap.pages on route match.
  4. PlatformRuntimeService.reloadFromBootstrap(next) re-applies an entire new BootstrapConfig in-memory without a full page reload — this is what the Project Editor's Publish/Preview actions use (§6.7).

The Project Editor edits an in-memory copy of this exact same BootstrapConfig — there is no parallel editor model or DTO translation layer (ADR-0001's core rule).


5. API conventions

5.1 Versioning

No path-based versioning exists today (/bootstrap, not /v1/bootstrap) — schemaVersion inside the bootstrap payload is the actual contract version signal. Recommendation for new builder/*/backoffice/* endpoints: keep them unversioned in the path (matching existing convention) and rely on additive-only changes (new optional fields) within a schema-version line; bump schemaVersion's major segment for breaking changes and coordinate a frontend release.

5.2 Naming and pluralization

Existing storefront endpoints are inconsistently pluralized by historical accident (/category singular-collection, /items/:id plural-collection, /searchitems compound) — do not "fix" this, it's a frozen contract (§6.1). For all new builder/*/backoffice/* endpoints, use plural resource collections (/backoffice/products, /backoffice/orders) consistently — this document's proposed paths in §6.7§6.16 already follow that convention.

5.3 Status codes

Code Meaning Used for
200 Success GET, successful PUT/PATCH/POST that returns a body
201 Created POST that creates a resource (e.g. POST /backoffice/products)
204 No Content DELETE, or POST/PUT with no meaningful response body
400 Bad Request Malformed request (not a validation failure — see 422)
401 Unauthorized Missing/invalid session
403 Forbidden Valid session, insufficient role/permission (including the admin-authorization gap fix, §2.5)
404 Not Found Resource / tenant not found
409 Conflict Slug/SKU uniqueness violation, concurrent-edit conflict
422 Unprocessable Entity Validation failure (see §11 error shape)
429 Too Many Requests Rate limit
500 Internal Server Error Unhandled backend failure
503 Service Unavailable Maintenance mode, dependency down (see §11.3)

5.4 Pagination

Two patterns exist in the frontend's expectations, both real — pick per-domain, be consistent within a domain:

  • Offset-based (storefront): count/skip query params — getCategoryItems(id, count=50, skip=0), searchItems(..., count, skip), getRandomItems(count).
  • Page-based (admin lists): page/pageSize in filter objects (AdminProductListFilters, AdminOrderListFilters, AdminReviewListFilters, AdminTransactionListFilters all carry page: number; pageSize: number), response shape { items: T[], total: number, page: number, pageSize: number } (AdminProductsListResult, AdminOrdersListResult, etc. — this exact 4-field envelope repeats across every paginated admin list; treat it as the standard admin list-response envelope for any new domain too).

5.5 Sorting, filtering, searching

  • Storefront search (GET /searchitems) accepts sort: relevance|price_asc|price_desc|popular|rating, plus categoryIDs (comma-joined), minPrice, maxPrice, tag.
  • Admin product list sort (AdminProductSort): title|price|priority|stock|updated.
  • Every admin *ListFilters interface has a free-text search: string — treat it as a substring match across the domain's primary human-readable fields (name/title/email/order number as applicable) unless a domain-specific note says otherwise.

5.6 PATCH vs PUT, DELETE semantics

  • PUT — full-resource replace or explicit "save current state" (PUT /builder/bootstrap/draft — whole document).
  • PATCH — partial update by convention for status-only or single-field admin mutations (updateStatus, setUserRole, setReviewVisible, etc. — even though the gateway interfaces are transport-agnostic, model these as PATCH since they touch one field/aspect, not the whole entity).
  • DELETE — categories/products/orders all expose an explicit delete* method returning Observable<void> (→ 204), separate from archive*/restore* (soft delete — see below). Reviews (deleteReview) and orders (deleteOrder) also have hard-delete methods distinct from any archive flow — confirm with product/legal whether hard delete should really be permitted for orders (audit-trail implications) before implementing it as literal data removal versus a tombstone.

5.7 Soft delete

Present as a first-class concept in two domains today:

  • Categories (AdminCategory.deletedAt: string | null + restoreCategory(id)).
  • Products (archived: boolean + archiveProduct(id)/restoreProduct(id) — boolean flag, not a timestamp).
  • Orders (archived: boolean + archiveOrder(id)/restoreOrder(id)).

No universal convention between deletedAt: string|null and archived: boolean — both are real, keep them domain-consistent with what's documented in §7 rather than unifying speculatively.

5.8 Idempotency

Not addressed anywhere in the current frontend contract. Recommend Idempotency-Key header support on POST /builder/bootstrap/publish and any payment-adjacent endpoint at minimum, since a retried publish/payment call must not double-apply.

5.9 Response/error format

See §11 for the single standard error envelope. Success responses have no forced envelope — most endpoints return the resource (or resource array) directly, not wrapped in { success, data }. Follow the shapes documented per-endpoint in §6, not a generic wrapper.

5.10 Dates, timezone, UUID

  • All timestamps in existing DTOs are ISO 8601 strings (createdAt, updatedAt, expiresAt, generatedAt, etc.) — never epoch numbers, except AdminDashboardActivityEntry.timestamp: number and AdminMonitoringEvent-adjacent internal caching (Date.now()), which are frontend-local, not wire-format precedent.
  • TenantConfig.timezone (IANA string, e.g. Europe/Moscow) exists but nothing currently converts times server-side based on it — treat display-timezone conversion as a frontend concern unless otherwise decided.
  • TenantConfig.id: UUIDUUID/UrlString are branded string types (src/app/shared/types/primitive.types.ts), i.e. plain strings at the wire level; no special serialization.

5.11 LocaleMap and translatable fields

The platform-wide rule (ADR-0001): all user-facing text is translatable via a translations.{lang} shape, never a flat hardcoded field. This repeats across the codebase as Record<string, T> keyed by locale code (ru/en/hy in this deployment, but the model itself is not hardcoded to those three): AdminProduct.translations: Record<string, AdminProductTranslation>, AdminCategory.translations: Record<string, AdminCategoryTranslation>, StaticPageConfig.translations: Record<string, StaticPageTranslationConfig>, NavigationItemConfig.label: string | Record<string, string>, FooterConfig.copyrightText: string | Record<string, string>. When adding/removing a supported locale, every translatable object must expose/drop that locale's key — generically, never per-field hardcoding.

5.12 Enums

Enums throughout this document are TypeScript string-literal unions, not numeric — send/return the literal string values exactly as documented (e.g. "processing", not 2).


6. Endpoints, by domain

6.1 Storefront reads (CURRENT — frozen shapes, src/app/services/api.service.ts)

ApiService normalizes both legacy and "backOffice" response variants defensively (dual field names, 0x-hex colours → CSS hex, names[]translations, imgs[]photos, tolerance for the literal Go-side typo valuue). Do not "fix" these variant shapes on the backend without confirming the frontend normalizer is updated in lockstep — the normalizer exists precisely because multiple backend response shapes have shipped historically.

Method Endpoint Notes
ping() GET /ping Health check.
getCategories() GET /category Retried ×2 with exponential backoff (retry({count:2, delay: 2^n * 500ms})). Cached client-side (§13).
getCategoryItems(id, count=50, skip=0) GET /category/:id?count=&skip= Cached client-side.
getItem(id) GET /items/:id Rating/reviews/questions derived from the item payload itself (embedded comments/questions), not a separate call. Cached client-side.
searchItems(search, count=50, skip=0, opts) GET /searchitems?search=&count=&skip=&categoryIDs=&minPrice=&maxPrice=&tag=&sort= Returns { items: Item[], total: number }.
getRandomItems(count=5, categoryID?) GET /items/randomitems?count=&category= Used for featured/related product rails.

6.2 Storefront writes (CURRENT — frozen shapes)

Method Endpoint Body Response
addToCart(sessionId, items) POST /websession/:sessionId Array<{itemID, quantity, colour?, size?, price?}> provider-defined
submitReview({...}) POST /items/:id/callback {rating, comment, sessionID, timestamp} {message: string}
submitQuestion({...}) POST /items/:id/questiion {question, sessionID, timestamp} {message: string}note the literal path typo questiion, keep it; it is a frozen contract, not a bug to fix.
submitPurchaseEmail({...}) POST /purchase-email {email, phone?, telegramUserId, items: [{itemID,name,price,currency,quantity?,delivery?}]} {message: string}

Payment endpoints: see §2.8.

6.3 Bootstrap — see §4 in full.

6.4 Authentication — see §2 in full. Summary table:

Method Endpoint Status
POST {authApiUrl}/users/sessions Create session CURRENT
GET {authApiUrl}/users/sessions/:id Poll/check session CURRENT
DELETE {authApiUrl}/users/sessions/:id Logout CURRENT
Ed25519 challenge/verify FUTURE (§2.5)

6.5 Product engagement — rating / reviews / questions (CURRENT, per-product, confirm contract)

The frontend already expects and renders against these; if they already exist on the backend, this is a contract-confirmation item, not new work:

Method Endpoint Notes
GET /products/{id}/rating Aggregate rating.
GET /products/{id}/reviews?page=&pageSize= Paginated per productPage.reviews.pageSize config.
GET /products/{id}/questions?page=&pageSize= Paginated per productPage.questions.pageSize config.
POST /products/{id}/reviews {rating, title, text, anonymous}.
POST /products/{id}/questions {text, anonymous}.

Toggled per-tenant via bootstrap.productPage.reviews/.questions (§4.4). If these don't exist yet, they block the Reviews/Questions UI.

Current: in-memory products/categories/tags power autocomplete (SearchAutocompleteService); trending returns null (UI hides gracefully, never fabricates data); search history is LocalSearchHistoryRepository (guest, localStorage) with a BackendSearchHistoryRepository placeholder class already defined but unimplemented (src/app/features/search/services/search-history.repository.ts — both classes exist side by side, only the backend one is a no-op today).

Method Endpoint Status
GET /search/suggestions?q={term} PLANNED
GET /catalog/filters?category={id}&q={term} PLANNED
GET/POST/DELETE /me/wishlist, /me/compare, /me/saved-searches, /me/recently-viewed PLANNED — only needed if authenticated cross-device sync of these lists is wanted; today they're 100% localStorage (LocalUserExperienceRepository, keys marketplace.ux.wishlist etc.)

Swapping any of these live requires only replacing the repository implementation — facade/UI unaffected by design.

6.7 Builder — Bootstrap draft/publish/validate (PLANNED, highest priority)

The Project Editor edits the same BootstrapConfig the storefront reads (§4.8). Today: load is real (GET /bootstrap), save is localStorage only (ProjectEditorDraftStorageService, key projectEditor.draftBootstrap.v1, scoped by tenant.id — reload in another browser/tab and it's gone), publish re-applies the config in-memory via PlatformRuntimeService.reloadFromBootstrap() and flips a local status flag — no backend call happens today. ProjectEditorIoService today only does JSON.stringify/JSON.parse for manual export/import, no HTTP.

Method Endpoint Body Response Notes
GET /builder/bootstrap/draft BootstrapConfig, or 404/empty if none (draft = published) Tenant resolved by host, no projectId param — one domain = one tenant = one draft = one published bootstrap.
PUT /builder/bootstrap/draft BootstrapConfig 200, updated draft metadata Persists the draft. Does not affect GET /bootstrap (storefront-facing) until publish.
POST /builder/bootstrap/publish BootstrapConfig (or none, if publish always promotes the stored draft) 200 Validates (§9.1), then makes this BootstrapConfig what GET /bootstrap returns. Only endpoint that affects the live storefront.
POST /builder/bootstrap/validate BootstrapConfig ProjectValidationIssue[] (see §9.1 for the exact shape) Optional but recommended — mirrors the client validator so the editor can preview server-side issues before attempting publish.

Mandatory: server-side re-validation on publish, equivalent to ProjectValidator (§9.1) at minimum. The client validator is not a trust boundary — a malicious or stale client can bypass all client-side checks.

Frontend files that change once this exists: features/project-editor/facade/project-editor.facade.ts (replace local save/publish with these endpoints, same public method signatures), services/project-editor-draft-storage.service.ts (becomes a fallback/offline cache, not the primary store).

6.8 Builder — Content pages / CMS (PLANNED)

Static pages are today edited entirely client-side and written only into the in-memory/localStorage bootstrap draft (§6.7) — no dedicated backend exists.

Method Endpoint Notes
GET /builder/content-pages List, same shape as bootstrap.staticPages.
PUT /builder/content-pages Replace the set.
POST /builder/content-pages/import Bulk import.
GET /builder/content-pages/export Bulk export.
POST /builder/content-pages/validate Server-side HTML/SEO validation (§9.5).

Backend must also support content moderation/validation on publish (disallow dangerous tags/attributes) and revision history — the frontend only sanitizes at render time (MarketplaceHtmlEditorComponent emits raw HTML with no sanitization by design; sanitization is a storefront-render concern, not an authoring concern), never at authoring time.

6.9 Backoffice — Categories (PLANNED)

Interface: AdminCategoriesGateway (src/app/features/admin/categories/services/admin-categories-gateway.interface.ts). Today: AdminCategoriesLocalGateway, in-memory, seeded once from BackofficeDataService.loadCategories() — nothing persists across reload. GET /category (§6.1, storefront-facing) is unaffected and stays as-is.

interface AdminCategoriesGateway {
  loadCategories(filters: AdminCategoryListFilters): Observable<AdminCategory[]>;
  loadCategory(id: string): Observable<AdminCategory | null>;
  createCategory(category: AdminCategory): Observable<AdminCategory>;
  updateCategory(category: AdminCategory): Observable<AdminCategory>;
  deleteCategory(id: string): Observable<void>;
  restoreCategory(id: string): Observable<AdminCategory | null>;
  isSlugTaken(slug: string, excludingId: string | null): Observable<boolean>;
}
Method Endpoint Body Response
GET /backoffice/categories?search=&visibility=&includeDeleted= AdminCategory[]
GET /backoffice/categories/:id AdminCategory | 404
POST /backoffice/categories AdminCategory 201 AdminCategory
PUT /backoffice/categories/:id AdminCategory 200 AdminCategory
DELETE /backoffice/categories/:id 204 (soft delete — sets deletedAt)
POST /backoffice/categories/:id/restore 200 AdminCategory (clears deletedAt)
GET /backoffice/categories/slug-taken?slug=&excludingId= {taken: boolean}
PATCH /backoffice/categories/reorder {id, order}[] 204 — bulk reorder endpoint, not in the gateway interface today but needed since AdminCategory.order exists and the editor drag-reorders

DTO: see §7.1.

6.10 Backoffice — Products (PLANNED)

Interface: AdminProductsGateway (src/app/features/admin/products/services/admin-products-gateway.interface.ts). Today: AdminProductsLocalGateway, in-memory.

interface AdminProductsGateway {
  loadProducts(filters: AdminProductListFilters): Observable<AdminProductsListResult>;
  loadProduct(id: string): Observable<AdminProduct | null>;
  loadCategories(): Observable<AdminProductCategoryOption[]>;
  createProduct(product: AdminProduct): Observable<AdminProduct>;
  updateProduct(product: AdminProduct): Observable<AdminProduct>;
  deleteProduct(id: string): Observable<void>;
  duplicateProduct(id: string): Observable<AdminProduct | null>;
  archiveProduct(id: string): Observable<void>;
  restoreProduct(id: string): Observable<AdminProduct | null>;
}
Method Endpoint Body Response
GET /backoffice/products?search=&categoryId=&visibility=&stock=&includeArchived=&sort=&page=&pageSize= AdminProductsListResult ({items, total, page, pageSize})
GET /backoffice/products/:id AdminProduct | 404
GET /backoffice/products/categories AdminProductCategoryOption[] ({id, title}) — still wired to its own seed today, not AdminCategoriesGateway; unify when both backends exist.
POST /backoffice/products AdminProduct 201 AdminProduct
PUT /backoffice/products/:id AdminProduct 200 AdminProduct
DELETE /backoffice/products/:id 204 (hard delete)
POST /backoffice/products/:id/duplicate 201 AdminProduct
POST /backoffice/products/:id/archive 204 (soft archive)
POST /backoffice/products/:id/restore 200 AdminProduct
PATCH /backoffice/products/bulk {ids: string[], visible?: boolean, delete?: boolean} 204 — matches applyBulkVisibility/applyBulkDelete semantics referenced in the facade

DTO: see §7.2, including the production-matching flat-variant shape (AdminProductVariant) — this is the highest-fidelity DTO in this document; read §7.2 carefully before implementing.

6.11 Backoffice — Orders (PLANNED)

Interface: AdminOrdersGateway. Today: AdminOrdersLocalGateway fabricates 24 synthetic in-memory orders — no real order data exists anywhere in this system today. The dashboard's Orders/Revenue cards intentionally render pending-backend rather than reading from this mock — they are not wired to it on purpose (the mock is order-management UI scaffolding, not a metrics source).

interface AdminOrdersGateway {
  loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult>;
  loadOrder(id: string): Observable<AdminOrder | null>;
  updateStatus(id: string, status: AdminOrderStatus, note: string): Observable<AdminOrder | null>;
  requestRefund(id: string): Observable<AdminOrder | null>;
  addNote(id: string, note: string, internal: boolean): Observable<AdminOrder | null>;
  archiveOrder(id: string): Observable<AdminOrder | null>;
  restoreOrder(id: string): Observable<AdminOrder | null>;
  deleteOrder(id: string): Observable<void>;
}
Method Endpoint Body Response
GET /backoffice/orders?search=&status=&page=&pageSize= AdminOrdersListResult
GET /backoffice/orders/:id AdminOrder | 404
PATCH /backoffice/orders/:id/status {status: AdminOrderStatus, note: string} 200 AdminOrder — must enforce the order state machine (§8.1)
POST /backoffice/orders/:id/refund 200 AdminOrder (sets payment.status = 'refund_requested')
POST /backoffice/orders/:id/notes {note: string, internal: boolean} 200 AdminOrder
POST /backoffice/orders/:id/archive 200 AdminOrder
POST /backoffice/orders/:id/restore 200 AdminOrder
DELETE /backoffice/orders/:id 204
GET /backoffice/orders/export.csv CSV — UI has a CSV-export action
GET /backoffice/orders/:id/invoice printable invoice — UI has a print-invoice action
GET /backoffice/dashboard/revenue-summary aggregation feeding the dashboard's currently-pending-backend Orders/Revenue cards

DTO: see §7.3, state machine: §8.1.

6.12 Backoffice — Transactions (PLANNED)

Interface: AdminTransactionsGateway. Today: AdminTransactionsLocalGateway derives one synthetic transaction per seeded mock order — no real payment/transaction data exists.

interface AdminTransactionsGateway {
  loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult>;
  retryFailed(id: string): Observable<AdminTransaction | null>;
  setFraudFlag(id: string, flagged: boolean): Observable<AdminTransaction | null>;
}
Method Endpoint Body Response
GET /backoffice/transactions?search=&status=&type=&page=&pageSize= AdminTransactionsListResult
POST /backoffice/transactions/:id/retry 200 AdminTransaction — semantics depend on whatever the real payment provider supports for retry
PATCH /backoffice/transactions/:id/fraud-flag {flagged: boolean} 200 AdminTransaction
GET /backoffice/transactions/export.csv CSV export
GET /backoffice/transactions/:id/audit AdminTransactionAuditEntry[]

Needed: a real payments/transactions domain (card, QR, cash-on-delivery), linked to orders, with fraud-flag persistence. DTO: §7.4.

6.13 Backoffice — Users, roles, invitations (PLANNED)

Interface: AdminUsersGateway. Today: AdminUsersLocalGateway, fully synthetic. Passwordless login itself is real (§2.4) — only the roles/permissions/invitations/multi-session-listing layer on top is mocked.

interface AdminUsersGateway {
  loadUsers(): Observable<AdminUser[]>;
  loadRoles(): Observable<AdminRole[]>;
  loadInvitations(): Observable<AdminInvitation[]>;
  loadSessions(userId: string): Observable<AdminSession[]>;
  loadAudit(userId: string): Observable<AdminUserAuditEntry[]>;
  setUserRole(userId: string, roleId: string): Observable<AdminUser | null>;
  setUserStatus(userId: string, status: AdminUserStatus): Observable<AdminUser | null>;
  inviteUser(email: string, roleId: string, scope: AdminUserScope): Observable<AdminInvitation>;
  revokeInvitation(id: string): Observable<void>;
  revokeSession(sessionId: string): Observable<void>;
}
Method Endpoint Body Response
GET /backoffice/users AdminUser[]
GET /backoffice/roles AdminRole[]
GET /backoffice/invitations AdminInvitation[]
GET /backoffice/users/:id/sessions AdminSession[]must reflect real multi-device sessions, unlike today's AdminAuthService which only knows the current browser's session
GET /backoffice/users/:id/audit AdminUserAuditEntry[]
PATCH /backoffice/users/:id/role {roleId: string} 200 AdminUser
PATCH /backoffice/users/:id/status {status: AdminUserStatus} 200 AdminUser
POST /backoffice/invitations {email, roleId, scope} 201 AdminInvitation — must trigger a real invitation email
DELETE /backoffice/invitations/:id 204
DELETE /backoffice/sessions/:id 204 — revoke a specific device session

Ties directly to the §2.5 admin-authorization gap — role assignment here is meaningless until the auth layer actually enforces roles. DTO: §7.5§7.6.

6.14 Backoffice — Moderation (reviews & reports) (PLANNED)

Interface: AdminModerationGateway. No *LocalGateway file distinct from the interface was found under a separate name in this pass beyond admin-moderation-local.gateway.ts — same pattern as everywhere else (mock today, swappable).

interface AdminModerationGateway {
  loadReviews(filters: AdminReviewListFilters): Observable<AdminReviewsListResult>;
  loadReview(id: string): Observable<AdminReview | null>;
  setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable<AdminReview | null>;
  setReviewVisible(id: string, visible: boolean): Observable<AdminReview | null>;
  setReviewPinned(id: string, pinned: boolean): Observable<AdminReview | null>;
  setReviewFeatured(id: string, featured: boolean): Observable<AdminReview | null>;
  addModeratorNote(id: string, note: string): Observable<AdminReview | null>;
  deleteReview(id: string): Observable<void>;
  loadReports(): Observable<AdminReport[]>;
  setReportStatus(id: string, status: AdminReportStatus): Observable<AdminReport | null>;
}
Method Endpoint Body Response
GET /backoffice/reviews?search=&status=&rating=&page=&pageSize= AdminReviewsListResult
GET /backoffice/reviews/:id AdminReview | 404
PATCH /backoffice/reviews/:id/status {status: AdminReviewStatus, note: string} 200 AdminReview (state machine §8.4)
PATCH /backoffice/reviews/:id/visible {visible: boolean} 200 AdminReview
PATCH /backoffice/reviews/:id/pinned {pinned: boolean} 200 AdminReview
PATCH /backoffice/reviews/:id/featured {featured: boolean} 200 AdminReview
POST /backoffice/reviews/:id/notes {note: string} 200 AdminReview
DELETE /backoffice/reviews/:id 204
GET /backoffice/reports AdminReport[]
PATCH /backoffice/reports/:id/status {status: AdminReportStatus} 200 AdminReport

DTO: §7.7.

6.15 Backoffice — Dashboard metrics & recent activity (PLANNED)

Interface: AdminDashboardMetricsGateway. Today: AdminDashboardMetricsLocalGateway composes BackofficeDataService.loadCategories()/loadProducts() client-side into counts; everything else on the dashboard (marketplace status, theme, languages, last publish/save, bootstrap version, active layout, enabled widgets, system health) is derived from ProjectEditorFacade state, not a metrics endpoint. Recent Activity is AdminDashboardHistoryService, localStorage-backed, scoped per tenant (adminDashboard.activityHistory.v1) — will never show another editor's activity.

interface AdminDashboardMetricsGateway {
  loadMetrics(): Observable<AdminDashboardMetrics>; // today only { categoriesCount, productsCount }
}
Method Endpoint Response
GET /backoffice/dashboard/summary Real-time counts and trend deltas — richer than today's {categoriesCount, productsCount}
GET /backoffice/dashboard/activity Real audit-log-backed activity feed, readable by multiple concurrent admin sessions (unlike today's per-browser localStorage)

DTO: §7.8.

6.16 Backoffice — Monitoring (PLANNED except Health)

Interface: AdminMonitoringGateway. Health section reads real data (AdminDashboardFacade.healthChecks) already — unaffected. Everything else (audit/security/login/failed-login/API/error/warning event feed, queue depths, webhook deliveries) is synthetic — no logging, queue, or webhook infrastructure exists anywhere in this system today.

interface AdminMonitoringGateway {
  loadEvents(filters: AdminMonitoringEventFilters): Observable<AdminMonitoringEvent[]>;
  loadQueues(): Observable<AdminQueue[]>;
  loadWebhooks(): Observable<AdminWebhookDelivery[]>;
}
Method Endpoint Response
GET /backoffice/monitoring/events?category=&search= AdminMonitoringEvent[] — real structured logging with a query API by category/level/actor/time-range
GET /backoffice/monitoring/queues AdminQueue[] — real queue introspection, once a job runner exists
GET /backoffice/monitoring/webhooks AdminWebhookDelivery[] — real webhook delivery tracking, once webhooks exist as a feature at all (§15)

DTO: §7.9.

6.17 Backoffice — Analytics (mostly FUTURE — no data source)

features/admin/analytics/ computes real revenue/orders/top-products aggregations from mock order data (§6.11), but visitor traffic, conversion funnels, and heatmaps have zero data source anywhere in this system — no analytics/tracking pipeline, no event collection. The page renders pending-backend badges rather than fabricated numbers; no gateway method exists for this yet, unlike every other mocked domain.

Method Endpoint Status
GET /backoffice/analytics/summary PLANNED — AdminAnalyticsSummary, revenue/orders/customers real once orders are real
GET /backoffice/analytics/traffic FUTURE — needs an actual tracking pipeline
GET /backoffice/analytics/funnels FUTURE
GET /backoffice/analytics/heatmaps FUTURE

DTO: §7.10.

6.18 Media (PLANNED — ADR-0002)

Domain model and contract are already spec'd in docs/context/adrs/ADR-0002-media-manager-contract.md; reproduced here as the canonical copy. MediaRepository (src/app/core/media/media-repository.ts) abstract class, two implementations selected via DI token: MockMediaRepository (IndexedDB — not localStorage, binary blobs need it) and HttpMediaRepository (to be built).

abstract class MediaRepository {
  abstract list(params?: MediaListParams): Promise<MediaListResult>;
  abstract upload(file: File, options?: MediaUploadOptions): Promise<MediaAsset>;
  abstract remove(id: string): Promise<void>;
  abstract update(id: string, patch: Partial<Pick<MediaAsset,'altText'|'tags'|'folder'|'caption'|'description'|'decorative'>>): Promise<MediaAsset>;
  abstract listFolders(): Promise<string[]>;
}
Method Endpoint Body Response
GET /media?page=&pageSize=&search=&folder=&tag=&kind=&sort= MediaListResult ({items: MediaAsset[], total: number})
POST /media/upload multipart, + folder?, tags?[] 201 MediaAsset
DELETE /media/:id 204
PATCH /media/:id {altText?, tags?, folder?, caption?, description?, decorative?} 200 MediaAsset
GET /media/folders string[]

Media never enters the Bootstrap model — like products/orders/users, media assets are runtime admin data, not tenant configuration (ADR-0001). DTO: §7.11.

6.19 Sitemap (FUTURE — static baseline only today)

public/sitemap.xml currently lists only the statically-known top-level routes (home/catalog/search/wishlist/compare) for the default locale, referenced from public/robots.txt. This platform is multi-tenant and config-driven — supported locales, categories, products, static pages are all resolved at runtime per tenant, not enumerable from the frontend at build time. A real per-tenant sitemap covering /:lang/product/:id, /:lang/catalog/:categoryId, /:lang/:staticPath needs a build-time or server-side job reading the same per-tenant data source (categories/products/static pages) and regenerating/serving this file dynamically — not something the SPA can produce correctly on its own.


7. DTOs

Every interface below is lifted directly from its cited source file — field lists, optionality, and nullability are exact, not paraphrased.

7.1 Categories — src/app/features/admin/categories/models/admin-category.model.ts

type AdminCategoryStatus = 'draft' | 'published';

interface AdminCategoryTranslation { title?: string; description?: string; seoTitle?: string; seoDescription?: string; }
interface AdminCategorySeo { metaTitle: string; metaDescription: string; keywords: string; }
interface AdminCategoryAttribute { key: string; value: string; }

interface AdminCategory {
  id: string;
  parentId: string | null;         // hierarchy
  title: string;
  slug: string;
  description: string;
  icon: string;
  imageUrl: string;
  imageAlt: string;
  order: number;
  visible: boolean;
  status: AdminCategoryStatus;     // draft | published — generated field, editor-set
  itemsCount: number;              // generated field, backend-computed
  translations: Record<string, AdminCategoryTranslation>;
  seo: AdminCategorySeo;
  attributes: AdminCategoryAttribute[];
  deletedAt: string | null;        // generated, soft-delete
  createdAt: string;               // generated
  updatedAt: string;               // generated
}

interface AdminCategoryListFilters { search: string; visibility: 'all'|'visible'|'hidden'; includeDeleted: boolean; }

Note vs. the storefront-facing CategoryDto/CategoryCardConfig (src/app/features/admin/** seed source): those lack parentId, slug, icon, imageUrl, status, deletedAt, seo, translations — the admin model is a superset. Reconciling the two into one backend category table is a real design decision for the backend team; this document only certifies what the frontend needs, not how storage should be normalized.

7.2 Products — src/app/features/admin/products/models/admin-product.model.ts

type AdminProductStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock';
type AdminProductSort = 'title' | 'price' | 'priority' | 'stock' | 'updated';

interface AdminProductMedia { images: string[]; gallery: string[]; videos: string[]; }
interface AdminProductSpecification { key: string; value: string; }

/**
 * Matches the production variant shape: a flat list of attribute-value
 * combinations, each priced per currency, e.g.
 * {color:'0x8B4513', size:'S', price:62560, currency:'RUB', remaining:100}.
 * The admin UI groups rows sharing the same attribute combo into one
 * AdminProductVariant with multiple prices, then flattens back on save.
 */
interface AdminProductVariantPrice { currency: string; price: number; }
interface AdminProductVariant {
  id: string;
  attributes: Record<string, string>;  // e.g. { color: '0x8B4513', size: 'S' }
  sku: string;
  image: string;
  remaining: number;
  prices: AdminProductVariantPrice[];
}
interface AdminProductVariantAttributeDef {
  key: string;       // lowercase, backend field name (e.g. 'color', 'size')
  label: string;
  isColor: boolean;
  values: string[];
}

interface AdminProductAttribute { key: string; value: string; }
interface AdminProductTranslation { name?: string; shortDescription?: string; htmlDescription?: string; seoTitle?: string; seoDescription?: string; }
interface AdminProductSeo { metaTitle: string; metaDescription: string; keywords: string; }
interface AdminProductReview { id: string; author: string; rating: number; text: string; }
interface AdminProductQuestion { id: string; question: string; answer?: string; }

interface AdminProduct {
  id: string; name: string; slug: string; sku: string; barcode: string; brand: string;
  categoryId: string;
  visible: boolean; archived: boolean; priority: number;
  media: AdminProductMedia;
  price: number; discount: number; currency: string; quantity: number;
  stockStatus: AdminProductStockStatus; availability: string;
  shortDescription: string; htmlDescription: string;
  specifications: AdminProductSpecification[];
  attributes: AdminProductAttribute[];
  variantAttributes: AdminProductVariantAttributeDef[];
  variants: AdminProductVariant[];
  relatedProductIds: string[];
  translations: Record<string, AdminProductTranslation>;
  seo: AdminProductSeo;
  featured: boolean; recommended: boolean; isNew: boolean; bestseller: boolean;
  badges: string[];
  reviews: AdminProductReview[]; questions: AdminProductQuestion[];
  createdAt: string; updatedAt: string;
}

interface AdminProductListFilters {
  search: string; categoryId: string | null; visibility: 'all'|'visible'|'hidden';
  stock: 'all' | AdminProductStockStatus; includeArchived: boolean; sort: AdminProductSort;
  page: number; pageSize: number;
}
interface AdminProductsListResult { items: AdminProduct[]; total: number; page: number; pageSize: number; }
interface AdminProductCategoryOption { id: string; title: string; }

Validation notes for the backend: sku must be unique per tenant (§9.4). variants[].sku should also be unique among a product's own variants at minimum. price/currency required at the top level even when variants carries its own per-combination prices — top-level price is the "from" price shown on listing cards.

7.3 Orders — src/app/features/admin/orders/models/admin-order.model.ts

type AdminOrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
type AdminOrderPaymentStatus = 'unpaid' | 'paid' | 'refund_requested' | 'refunded';

interface AdminOrderCustomer { name: string; email: string; phone: string; }
interface AdminOrderPayment { method: string; status: AdminOrderPaymentStatus; amount: number; currency: string; }
interface AdminOrderShipping { address: string; method: string; trackingNumber: string; }
interface AdminOrderItem { productId: string; name: string; quantity: number; price: number; }
interface AdminOrderTimelineEntry { status: AdminOrderStatus; timestamp: string; note: string; }

interface AdminOrder {
  id: string; orderNumber: string; status: AdminOrderStatus;
  customer: AdminOrderCustomer; payment: AdminOrderPayment; shipping: AdminOrderShipping;
  items: AdminOrderItem[]; total: number; currency: string;
  notes: string; internalNotes: string;
  timeline: AdminOrderTimelineEntry[];  // audit trail of every status change
  archived: boolean;
  createdAt: string; updatedAt: string;
}

interface AdminOrderListFilters { search: string; status: 'all'|AdminOrderStatus; page: number; pageSize: number; }
interface AdminOrdersListResult { items: AdminOrder[]; total: number; page: number; pageSize: number; }

AdminCustomer (src/app/features/admin/customers/models/admin-customer.model.ts) is not its own stored entity — it's derived by grouping AdminOrder records by customer.email, since no dedicated customer gateway/backend exists:

interface AdminCustomer {
  email: string; name: string; phone: string;
  orderCount: number; totalSpent: number; currency: string;
  firstOrderAt: string; lastOrderAt: string;
  addresses: string[]; orders: AdminOrder[];
}

If/when a real customer domain is built, this becomes a real GET endpoint rather than a client-side aggregation — every field here should map 1:1 to a real column/aggregate, never fabricated.

7.4 Transactions — src/app/features/admin/transactions/models/admin-transaction.model.ts

type AdminTransactionType = 'payment' | 'refund' | 'qr_payment';
type AdminTransactionStatus = 'pending' | 'success' | 'failed' | 'retried';

interface AdminTransactionAuditEntry { action: string; actor: string; timestamp: string; }

interface AdminTransaction {
  id: string; orderId: string; orderNumber: string;
  type: AdminTransactionType; method: string; status: AdminTransactionStatus;
  amount: number; currency: string;
  fraudFlag: boolean;
  audit: AdminTransactionAuditEntry[];
  createdAt: string; updatedAt: string;
}

interface AdminTransactionListFilters { search: string; status: 'all'|AdminTransactionStatus; type: 'all'|AdminTransactionType; page: number; pageSize: number; }
interface AdminTransactionsListResult { items: AdminTransaction[]; total: number; page: number; pageSize: number; }

7.5 Users, roles, invitations, sessions — src/app/features/admin/users/models/admin-user.model.ts

type AdminUserScope = 'marketplace' | 'office';
type AdminUserStatus = 'active' | 'invited' | 'suspended';
type AdminInvitationStatus = 'pending' | 'accepted' | 'expired' | 'revoked';

interface AdminRole { id: string; name: string; permissions: string[]; builtIn: boolean; }

interface AdminUser {
  id: string; name: string; telegramUsername: string; email: string;
  scope: AdminUserScope; roleId: string; status: AdminUserStatus;
  lastLoginAt: string | null; createdAt: string;
}

interface AdminInvitation { id: string; email: string; roleId: string; scope: AdminUserScope; status: AdminInvitationStatus; invitedAt: string; expiresAt: string; }
interface AdminSession { id: string; userId: string; device: string; ip: string; lastActiveAt: string; current: boolean; }
interface AdminUserAuditEntry { action: string; actor: string; timestamp: string; }

7.6 Permissions (bootstrap-level skeleton) — src/app/shared/models/config/permissions.model.ts

interface PermissionDefinition { key: string; description?: string; }
interface RolePermissions { role: string; permissions: string[]; }
interface PermissionsConfig { definitions: PermissionDefinition[]; roles: RolePermissions[]; }

7.7 Moderation — reviews & reports

// admin-review.model.ts
type AdminReviewStatus = 'pending' | 'approved' | 'rejected' | 'spam';
interface AdminReviewTimelineEntry { action: string; actor: string; note: string; timestamp: string; }
interface AdminReview {
  id: string; productId: string; productName: string;
  customerName: string; customerEmail: string;
  rating: number; text: string; photos: string[];
  status: AdminReviewStatus; visible: boolean; pinned: boolean; featured: boolean;
  reportCount: number; moderatorNotes: string;
  timeline: AdminReviewTimelineEntry[];
  createdAt: string; updatedAt: string;
}
interface AdminReviewListFilters { search: string; status: 'all'|AdminReviewStatus; rating: 'all'|number; page: number; pageSize: number; }
interface AdminReviewsListResult { items: AdminReview[]; total: number; page: number; pageSize: number; }

// admin-report.model.ts
type AdminReportTargetType = 'product' | 'review' | 'customer' | 'category' | 'unknown';
type AdminReportStatus = 'open' | 'resolved' | 'dismissed';
interface AdminReport { id: string; targetType: AdminReportTargetType; targetId: string; targetLabel: string; reason: string; reporterEmail: string; status: AdminReportStatus; createdAt: string; }

7.8 Dashboard — src/app/features/admin/dashboard/models/admin-dashboard.model.ts

type AdminDashboardCardStatus = 'loading' | 'ready' | 'empty' | 'error' | 'pending-backend';
interface AdminDashboardCardState<T> { status: AdminDashboardCardStatus; value: T | null; }
interface AdminDashboardMetrics { categoriesCount: number; productsCount: number; } // today's real shape — expand per §6.15

type AdminDashboardHealthStatus = 'healthy' | 'attention' | 'unhealthy' | 'unknown' | 'loading';
interface AdminDashboardHomeHealthCheck { code: string; labelKey: string; status: AdminDashboardHealthStatus; displayValue?: string | null; }
interface AdminDashboardActivityEntry { id: string; type: 'draft-saved'|'published'; timestamp: number; }

pending-backend is a real, intentional card status — it means "this sprint has no data source for this metric yet," rendered honestly instead of a fabricated number. Any new metrics endpoint should let the frontend distinguish "zero" from "no data source" the same way (e.g. omit the field, or use null, rather than sending 0).

7.9 Monitoring — src/app/features/admin/monitoring/models/admin-monitoring.model.ts

type AdminMonitoringCategory = 'audit' | 'security' | 'login' | 'failed_login' | 'api' | 'error' | 'warning';
type AdminMonitoringLevel = 'info' | 'warning' | 'error';
type AdminQueueStatus = 'healthy' | 'degraded' | 'down';
type AdminWebhookStatus = 'delivered' | 'failed' | 'pending';

interface AdminMonitoringEvent { id: string; category: AdminMonitoringCategory; level: AdminMonitoringLevel; message: string; actor: string; timestamp: string; }
interface AdminMonitoringEventFilters { category: 'all'|AdminMonitoringCategory; search: string; }
interface AdminQueue { name: string; depth: number; status: AdminQueueStatus; }
interface AdminWebhookDelivery { id: string; endpoint: string; event: string; status: AdminWebhookStatus; timestamp: string; }

Payment/QR types (src/app/services/api.service.ts lines 1066, frozen — §2.8):

interface QrCreateRequest { qrtype: 'QRDynamic'; amount: number; currency: 'RUB'; partnerqrID?: string; qrDescription?: string; Userid?: string; Reference?: string; RedirectUrl?: string; }
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; }
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[]}>; }
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; }

7.10 Analytics — src/app/features/admin/analytics/models/admin-analytics.model.ts

type AdminAnalyticsDateRange = 7 | 30 | 90;
interface AdminAnalyticsSummary {
  revenueTotal: number; currency: string; ordersCount: number; avgOrderValue: number;
  productsCount: number; categoriesCount: number; customersCount: number;
  conversionRate: number | null; // null = unknown, no visitor/traffic tracking exists yet — never fabricated
}
interface AdminAnalyticsSeriesPoint { date: string; value: number; }
interface AdminAnalyticsTopProduct { productId: string; name: string; quantity: number; revenue: number; }
interface AdminLowStockProduct { productId: string; name: string; quantity: number; stockStatus: 'low_stock'|'out_of_stock'; }
interface AdminRecentActivityEntry { id: string; labelKey: string; timestamp: number; }
type AdminMarketplaceHealthStatus = 'healthy' | 'attention' | 'unhealthy' | 'unknown';
interface AdminMarketplaceHealthCheck { code: string; labelKey: string; status: AdminMarketplaceHealthStatus; displayValue?: string | null; actionRoute?: string[]; }
interface AdminProductAnalyticsRow { productId: string; name: string; value: number; }
interface AdminProductAnalytics { topSelling: AdminAnalyticsTopProduct[]; mostReviewed: AdminProductAnalyticsRow[]; worstRated: AdminProductAnalyticsRow[]; hiddenCount: number; archivedCount: number; }
interface AdminCustomerAnalytics { newCustomers: number; returningCustomers: number; averageSpend: number; currency: string; retentionPercent: number | null; }
type AdminRecommendationSeverity = 'info' | 'warning' | 'critical';
interface AdminRecommendationCard { id: string; labelKey: string; descriptionKey?: string; severity: AdminRecommendationSeverity; route: string[]; }

conversionRate: number | null and retentionPercent: number | null are the platform's explicit convention for "no data source yet" — null is a first-class, meaningful value here, not an oversight. Replicate this pattern for any new metric without a real source rather than defaulting to 0.

7.11 Media — src/app/core/media/models/media-asset.model.ts

type MediaAssetKind = 'image' | 'svg' | 'pdf' | 'other';
interface MediaAsset {
  id: string; url: string; thumbnailUrl?: string;
  filename: string; mimeType: string; size: number;
  width?: number; height?: number;
  altText?: Record<string, string>;   // translations.{lang} rule
  caption?: string; description?: string;
  decorative?: boolean;                // suppresses missing-alt-text warning
  tags?: string[]; folder?: string;
  createdAt: string;
}
type MediaSort = 'recent' | 'name' | 'size';
interface MediaListParams { page?: number; pageSize?: number; search?: string; folder?: string; tag?: string; kind?: MediaAssetKind; sort?: MediaSort; }
interface MediaUploadOptions { folder?: string; tags?: string[]; }
interface MediaListResult { items: MediaAsset[]; total: number; }

7.12 Bootstrap DTOs — see §4.3§4.6 for the complete BootstrapConfig tree (not repeated here to avoid duplication).


8. State machines

8.1 Orders — AdminOrderStatus

pending → processing → shipped → delivered
pending → cancelled
processing → cancelled
shipped → delivered
delivered → refunded   (via payment.status refund_requested → refunded, not a direct status jump)
* → refunded            (only through the refund request flow, never a direct status write)

Invalid transitions to reject server-side: delivered → pending, cancelled → * (terminal), refunded → * (terminal), skipping shipped to jump straight processing → delivered is allowed by the model (no shipped requirement enforced client-side) but should be a deliberate backend policy decision, not silently permitted. Every transition must append an AdminOrderTimelineEntry {status, timestamp, note} — the timeline is the audit trail, never overwritten.

8.2 Payment status — AdminOrderPaymentStatus

unpaid → paid
paid → refund_requested
refund_requested → refunded
refund_requested → paid   (refund rejected, reverts)

8.3 Products — implicit via visible/archived booleans, not an enum

(new) → visible: true, archived: false
visible: true ⇄ visible: false        (toggle, reversible, any time)
archived: false → archived: true      (via archiveProduct — soft archive)
archived: true → archived: false      (via restoreProduct)
archived: true → (hard delete)        (via deleteProduct — irreversible)

stockStatus (in_stock|low_stock|out_of_stock) is derived from quantity, not independently settable — treat it as backend-computed, never accept it as client input on create/update.

8.4 Reviews — AdminReviewStatus

pending → approved
pending → rejected
pending → spam
approved ⇄ rejected   (moderator can reverse a decision)
* → spam               (any state can be flagged spam)

Every status change should append an AdminReviewTimelineEntry. visible, pinned, featured are independent booleans layered on top of status — a rejected review should probably force visible: false server-side regardless of what the client sends (defense in depth), even though nothing in the current frontend enforces that coupling.

8.5 Reports — AdminReportStatus

open → resolved
open → dismissed
resolved ⇄ dismissed   (reversible, e.g. re-opened as a mistake)

8.6 Static pages — status: 'draft' | 'published' (per-page, independent of whole-bootstrap draft/publish)

draft → published
published → draft   (unpublish)

Independent from the category/product draft|published pattern — no shared enum type, don't unify speculatively.

8.7 Sessions — AuthStatus / AdminAuthStatus

unknown → checking → authenticated
unknown → checking → unauthenticated
authenticated → unauthenticated   (logout, or expiry check fails)

8.8 Users — AdminUserStatus

invited → active     (invitation accepted)
active ⇄ suspended
invited → (invitation revoked, never becomes a user)

8.9 Invitations — AdminInvitationStatus

pending → accepted
pending → expired    (time-based, backend job)
pending → revoked    (admin action)

9. Validation

9.1 Backend vs. frontend validation — the client validator is not a trust boundary

ProjectValidator (src/app/features/project-editor/services/project-validator.service.ts) runs entirely client-side and blocks the Publish button, but a malicious or stale client can bypass all of it. POST /builder/bootstrap/publish must re-run equivalent checks server-side. The exact issue shape to mirror:

interface ProjectValidationIssue {
  code: string;               // stable machine code
  message: string;            // i18n key, not a rendered string
  section?: string;           // editor section for UI badges
  fieldKey?: string;          // schema field key for inline errors
  severity: 'error' | 'warning'; // error blocks publishing; warning is advisory
}

Checks the client runs today (replicate server-side, at minimum the error-severity ones):

Code Severity Rule
missing-logo error branding.logoUrl must be non-empty
no-languages error localization.supportedLocales must be non-empty
default-locale-not-supported error localization.defaultLocale must be in supportedLocales
invalid-url error tenant.websiteBaseUrl must be http(s)://... if present
duplicate-slugs error static-page slug (or route fallback) must be unique
duplicate-routes warning pages[].route.path must be unique; static-page routes must not collide with page routes
empty-homepage error the home page must have ≥1 section
missing-widget error every homepage widget must have a non-empty type
invalid-widget-config error every widget on every page needs id, type, version, and an object props
duplicate-nav-links error navigation.header items must be unique by (label, route)
invalid-colors error every theme.palette value must be a valid hex color
invalid-css warning <style> blocks inside static-page HTML must be syntactically valid CSS
missing-translations error every non-default supported locale needs header-nav and static-page translations
invalid-layouts error layout.type and every section.layout.strategy must be from the known enum set
invalid-contact-email error company.contacts.email must be a valid email if present
invalid-social-link-url warning footer.socialLinks[].url must be http(s)://... if present
incomplete-payment-icon warning footer.paymentIcons[] rows need both src and alt or neither

9.2 Duplicate validation (client + server, deliberately)

Slug/route uniqueness, hex-color format, and URL format are validated both client-side (fast UX feedback) and must be re-validated server-side (real trust boundary) — this is intentional duplication, not a bug to eliminate on either side.

9.3 Required vs. generated fields

Generated (backend-set, never accept as client input on create): id, createdAt, updatedAt, deletedAt, itemsCount (categories), stockStatus (products, derived from quantity), order timeline[] entries, transaction audit[] entries, review timeline[] entries.

Required on create (reject with 422 if missing): category title/slug; product name/sku/categoryId/price/currency; order items must reference valid productIds.

9.4 Slug / SKU / barcode uniqueness

  • Category slug — unique per tenant. isSlugTaken(slug, excludingId) gateway method exists precisely for this — mirror it as GET /backoffice/categories/slug-taken.
  • Product sku — unique per tenant (§7.2 note).
  • Product barcode — present in the model (Sprint 21 addition) but no explicit uniqueness-check gateway method exists yet; recommend uniqueness per tenant anyway unless barcodes are intentionally shared across variants.

9.5 Media validation

File size/type constraints and malware scanning before publish are recommended, not yet specified precisely — no size/type limit constants exist in the current frontend code (docs/architecture/backend/Backend-Platform-API-Spec.md's draft §10 also just says "File size/type constraints" without numbers). Pick sane limits (e.g. 10MB images, common web-safe MIME types) and document them in the response envelope so the frontend can render a real client-side pre-check.

9.6 Language validation

Every translatable field must expose the full set of localization.supportedLocales — a locale missing from translations on a required-translation object (nav labels, static-page content per §9.1's missing-translations check) should be a validation error, not silently defaulting to the base language, so authors get an explicit signal.


10. Media

Covered in full in §6.18 (endpoints) and §7.11 (DTO). Summary of concerns not already stated:

  • Upload is multipart POST /media/upload; response is a full MediaAsset including server-computed width/height for images and a thumbnailUrl.
  • Delete is a hard delete (DELETE /media/:id → 204) — no soft-delete/restore concept exists for media today; if that's needed, it's a net-new decision, not something to infer from the current contract.
  • Replace — no dedicated "replace" endpoint; the frontend pattern is delete + re-upload, or PATCH for metadata-only changes (alt text, tags, folder, caption, description, decorative flag) without touching the binary.
  • Metadata: altText is a Record<locale, string> (translatable, ADR-0001 rule) — never a flat string. decorative: boolean intentionally suppresses missing-alt-text warnings for images that are genuinely decorative.
  • Compression/formats/limits/CDN/thumbnail generation — none of this is specified in the frontend contract today; these are backend implementation choices as long as the MediaAsset response shape is honored (url, thumbnailUrl?, mimeType, size, width?, height?).
  • Future storage: MockMediaRepository uses IndexedDB client-side purely as an interim store — this has zero bearing on backend storage choice (S3-compatible object storage, local disk, CDN-fronted, etc. are all compatible with the MediaAsset.url contract).

11. Errors

11.1 Standard error envelope

No single error shape is enforced by any HTTP client code today (the frontend mostly just surfaces error.message generically) — this is a recommended standard, consistent with docs/architecture/backend/Backend-Platform-API-Spec.md's draft, extended to match the fuller field set the spec brief calls for:

{
  "code": "validation_error",
  "title": "Validation failed",
  "message": "One or more fields are invalid.",
  "severity": "error",
  "details": [
    { "field": "sku", "message": "SKU already exists for this tenant" }
  ],
  "fieldErrors": {
    "sku": ["SKU already exists for this tenant"]
  },
  "retryAfter": null,
  "traceId": "trc-8f3a2c1e"
}
  • code — stable machine-readable identifier (snake_case), for programmatic handling.
  • title — short human summary.
  • message — fuller human-readable explanation, safe to show a user.
  • severityerror | warning | info (mirrors ProjectValidationIssue.severity, §9.1, for consistency between builder-validation issues and generic API errors).
  • details — array form for multi-field validation failures (matches the draft spec's shape).
  • fieldErrors — map form of the same information, keyed by field path, for forms that want O(1) per-field lookup instead of scanning details.
  • retryAfter — seconds, populated on 429/503 responses; null otherwise.
  • traceId — correlates a client-visible error to backend logs.

11.2 Common codes

tenant_not_found, tenant_suspended, unauthorized, forbidden, validation_error, conflict, not_found, internal_error, plus the reliability-mode codes below.

11.3 HTTP status meanings (repeated from §5.3 with error-body expectations)

Status When Body
400 Malformed request (bad JSON, wrong content-type) error envelope, code: bad_request
401 No/invalid session error envelope, code: unauthorized
403 Valid session, insufficient permission error envelope, code: forbidden
404 Resource/tenant not found error envelope, code: not_found or tenant_not_found
409 Slug/SKU conflict, concurrent-edit conflict error envelope, code: conflict, details naming the conflicting field
422 Validation failure error envelope, code: validation_error, details/fieldErrors populated
429 Rate limited error envelope, code: rate_limited, retryAfter populated
500 Unhandled error error envelope, code: internal_error — never leak stack traces or internals in message
503 Service degraded/down error envelope, code from the maintenance-mode set below, retryAfter if known

11.4 Degraded-service modes

Not implemented today but recommended so the frontend can render an honest state instead of a generic failure:

  • maintenance_mode — planned downtime.
  • construction_mode — tenant not yet fully configured/launched.
  • read_only_mode — writes disabled, reads still served.
  • feature_disabled — a specific feature flag is off; distinct from a generic 403.
  • backend_unavailable / database_unavailable / queue_unavailable — dependency-specific outage codes so ops/on-call can triage faster than a generic 500.
  • Graceful degradation: where possible, prefer serving stale/cached data with a warning over a hard failure (e.g. bootstrap should be aggressively cached client + edge side specifically so a backend blip doesn't blank the entire storefront — see §13).

11.5 The one known reliability issue in production today

Intermittent 502/504 Bad Gateway on page refresh and browser back-navigation in production. Root cause is not in this repository. environment.production.ts points the frontend at the backend API via absolute URLs directly (apiUrl: 'https://api.dexarmarket.ru:445', authApiUrl: 'https://users.vitanova.network:456'), bypassing this repo's own nginx.conf entirely (that config only proxies /api for one specific tenant host, not dexarmarket.ru). The 502/504 originates from that backend API's own reverse proxy (ports 445/456, servers not part of this repository). Refresh and back-navigation both re-fire session-check and bootstrap-load calls on mount (AdminAuthService.checkSession(), ConfigService.loadBootstrap(), TelegramSessionApiService), which is the likely trigger if that backend's app server or reverse proxy is crashing, overloaded, or misconfigured on those specific endpoints. This needs DevOps/backend investigation of upstream health, timeout settings, and concurrent-connection handling around session-check and bootstrap endpoints — nothing in this frontend repository can fix it.


12. Localization

  • LocalizationConfig (§4.4): defaultLocale, supportedLocales[], currencyByLocale: Record<string,string>, dictionaries: {locale, dictionaryUrl, version}[].
  • LocaleMap convention (§5.11): every translatable field is Record<locale, string> (or a translations[locale] object for richer content) — never a flat hardcoded field. This applies to navigation labels, static-page title/html/seo, product/category translations, footer copyright text.
  • Fallback: the client resolves a missing translation by falling back toward defaultLocale in most rendering paths (e.g. category/item name falls back to the first available names[] entry, preferring ru — see ApiService.normalizeCategory/normalizeItem, §6.1) — but this is a display-layer fallback for legacy/partial data, not a substitute for actually authoring translations. §9.1's missing-translations validation exists specifically to push authors toward complete translations rather than relying on fallback.
  • Default language: localization.defaultLocale must always be a member of localization.supportedLocales — enforced client-side (default-locale-not-supported, §9.1) and must be enforced server-side too.
  • Accept-Language vs. X-Language: the frontend does not send Accept-Language; it sends its own resolved X-Language header (RU/EN/AM, mapped from the user's in-app language selection, §1 step 3) on every API request. Backend should key localized responses off X-Language, not the browser's Accept-Language.
  • Changing language: a pure frontend state change (LanguageService.currentLanguage) — no backend call is made purely to switch language; the next API request simply carries the new X-Language header.
  • Static pages: localized via translations[locale].{title,html,seo} (§4.6) — SEO fields can also be locale-specific (StaticPageTranslationConfig.seo), overriding the page-level seo block per locale.
  • SEO: SeoConfig.default + SeoConfig.byPageKey (§4.4) at the bootstrap level; per-static-page SEO nested under each page's seo/translations[locale].seo (§4.6). No separate SEO-only endpoint exists — SEO data travels inside bootstrap and static-page payloads.

13. Caching

Layer What Mechanism today Recommendation
Bootstrap GET /bootstrap ConfigService caches the whole response in-memory for the app session via shareReplay(1); no HTTP-level caching Add ETag/Cache-Control: private, max-age=60 (or similar) — safe additive change, client doesn't send conditional headers today so this is a pure improvement, not a breaking change
Products/categories (storefront) GET /category, GET /category/:id, GET /items/:id cacheInterceptor (src/app/interceptors/cache.interceptor.ts) — in-memory Map, TTL from CACHE_DURATION_MS (category list) / CATEGORY_CACHE_DURATION_MS (category items + single item), keyed by exact request URL, cleaned up lazily on each access Backend should still set its own Cache-Control/ETag for CDN/edge caching — the frontend's cache is per-tab, in-memory, and does not survive a reload
Images media assets none specified client-side CDN-fronted with long max-age + content-hashed URLs recommended (not currently required by any frontend code)
Analytics dashboard/analytics reads none — always live fine to cache short-TTL (seconds) server-side given these are aggregation-heavy
Browser cache general standard browser HTTP cache applies to anything the backend marks cacheable
Invalidation today: none — the in-memory cacheInterceptor map is purely TTL-based, no explicit invalidation hook exists anywhere (e.g. a product update does not proactively bust the GET /items/:id cache entry) if strong consistency after an admin write matters, either shorten CATEGORY_CACHE_DURATION_MS further or add an explicit cache-bust mechanism — not present today

Bootstrap caching is the most consequential — since it's loaded once and held for the entire session, any admin publish (§6.7) will not be visible to already-open customer tabs until they reload. Consider a low-TTL Cache-Control on GET /bootstrap plus (future) a lightweight "config changed" push/poll signal if near-real-time propagation matters — no such mechanism exists in the frontend today.


14. Backend replacement pattern

This is the mechanical pattern every domain in this document follows, and the reason implementing a backend for any one domain is low-risk and additive:

flowchart LR
    Page["Page component"] --> Facade["Domain Facade"]
    Facade --> Token["DI Token (e.g. ADMIN_PRODUCTS_GATEWAY)"]
    Token -.bound today.-> Mock["*LocalGateway (mock/localStorage/IndexedDB)"]
    Token -.rebind to.-> Api["*ApiGateway (real HTTP, implements same interface)"]
    Api --> Backend["Backend REST API"]
  1. A *Gateway TypeScript interface defines the domain's contract (e.g. AdminProductsGateway, AdminCategoriesGateway, MediaRepository).
  2. A *LocalGateway (or, for media, MockMediaRepository) implements that interface today, bound via an Angular DI token/injection.
  3. To go live: implement a *ApiGateway class against the same interface (same method signatures, same DTOs — §7), hitting the real endpoints documented in §6.
  4. Rebind the DI token to the new class in the app's provider config (app.config.ts or the relevant feature's providers).
  5. Facades and page components do not change. This is the whole point of the pattern (ADR-004's "configuration provider abstraction" generalizes to every domain, not just bootstrap).

Provider selection (mock vs. api) for bootstrap/backoffice/products/categories specifically goes through RuntimeProviderStrategyService (src/app/core/providers/runtime-provider-strategy.service.ts):

type RuntimeProviderMode = 'mock' | 'api' | 'remote-config';

class RuntimeProviderStrategyService {
  getBootstrapProviderMode(): RuntimeProviderMode {
    // 'mock' if environment.useMockData, OR (useMockBootstrapOnLocal && running on localhost)
    // 'api' otherwise
  }
  getBackofficeProviderMode(): RuntimeProviderMode { /* 'mock' iff environment.useMockData, else 'api' */ }
  getProductProviderMode(): RuntimeProviderMode { /* same */ }
  getCategoryProviderMode(): RuntimeProviderMode { /* same */ }
}

Note the asymmetry: bootstrap can be mocked on localhost independent of useMockData (useMockBootstrapOnLocal: true by default) so local dev never needs a live bootstrap endpoint, while product/category/backoffice mocking is a single global useMockData switch. 'remote-config' is a declared-but-unused third mode — reserved, not implemented anywhere today.


15. Future APIs

Reserved sections only — do not implement speculative endpoints for these; document intent, not a contract, until real requirements exist. No frontend code references any of the following today.

Domain Notes
Coupons featureFlags.coupons boolean already exists in bootstrap (unused beyond the flag itself)
Discounts AdminProduct.discount: number exists (§7.2) as a static per-product value; a real discount/promotion engine (rules, date ranges, stacking) does not exist
Promotions no model anywhere
Notifications featureFlags.notifications boolean exists; no delivery mechanism (email/push/in-app) specified
Marketplace Partners not modeled — this platform is single-tenant-per-marketplace, not itself a marketplace-of-marketplaces
Inventory AdminProduct.quantity/stockStatus exist per-product; no warehouse/location-level inventory model
Warehouses not modeled
Delivery DeliveryOption exists for storefront cart display (src/app/models, referenced by ApiService) — a full delivery/logistics domain (carriers, rates, tracking beyond AdminOrderShipping.trackingNumber) does not exist
Invoices featureFlags.invoices boolean exists; AdminOrdersGateway UI has a "print invoice" action today but it's client-rendered from existing order data, not a backend invoice document
Refunds requestRefund() exists (§6.11, §8.2) as a status transition; a full refund-processing domain (partial refunds, refund reasons, ledger) does not
Webhooks AdminWebhookDelivery model exists for displaying delivery status (§7.9) but no webhook registration/configuration surface exists anywhere
Audit Logs AdminMonitoringEvent/AdminUserAuditEntry/order timeline[]/review timeline[]/transaction audit[] are all narrow, per-domain audit trails today; a unified cross-domain audit log does not exist
Roles AdminRole/PermissionsConfig exist as data shapes (§7.5§7.6) but are not enforced anywhere server-side yet (§2.5, §3.3)
Permissions same as Roles — modeled, not enforced
Feature Flags (management API) FeatureFlagsConfig/MarketplaceFeaturesConfig are read via bootstrap only; no dedicated GET/PUT /feature-flags endpoint is called by any frontend code today (the draft spec in the old Backend-Platform-API-Spec.md proposed one — reasonable to build, just not yet consumed)

16. Sequence diagrams

16.1 Authentication (customer or admin — identical backend flow, different client storage)

sequenceDiagram
    participant U as User (browser)
    participant FE as Frontend (AuthService / AdminAuthService)
    participant TG as Telegram Bot
    participant BE as Backend (users/sessions)

    FE->>BE: POST /users/sessions {webSessionID}
    BE-->>FE: {webSessionID, ...}
    FE->>U: Render QR / deep link (t.me/{bot}?start={webSessionID})
    U->>TG: Scan QR / tap deep link
    TG->>BE: (out of band) associate Telegram identity with webSessionID
    loop every 5s, up to 100x
        FE->>BE: GET /users/sessions/:webSessionID
        BE-->>FE: {active: false, ...}
    end
    TG->>BE: user completes bot interaction
    FE->>BE: GET /users/sessions/:webSessionID
    BE-->>FE: {active: true, user: {...}, expiresAt}
    FE->>FE: activateSession() -> set cookie, start refresh timer

16.2 Bootstrap load

sequenceDiagram
    participant App as Angular App
    participant CS as ConfigService
    participant Prov as ApiBootstrapProvider / MockBootstrapProvider
    participant BE as Backend

    App->>CS: loadBootstrap()
    alt already cached this session
        CS-->>App: cached BootstrapConfig (shareReplay)
    else not cached
        CS->>Prov: loadBootstrap()
        alt mock mode / localhost
            Prov-->>CS: bootstrap.json (local asset)
        else real
            Prov->>BE: GET /bootstrap (Host header resolves tenant)
            BE-->>Prov: BootstrapConfig JSON
        end
        CS-->>App: BootstrapConfig
        App->>App: PlatformRuntimeService applies theme/branding/nav
    end

16.3 Product editing (admin) — PLANNED, once AdminProductsApiGateway exists

sequenceDiagram
    participant Admin as Admin user
    participant Page as Product editor page
    participant Facade as AdminProductsFacade
    participant GW as AdminProductsApiGateway
    participant BE as Backend

    Admin->>Page: edit fields, save
    Page->>Facade: updateProduct(product)
    Facade->>GW: updateProduct(product)
    GW->>BE: PUT /backoffice/products/:id
    BE-->>GW: 200 AdminProduct (server-validated)
    GW-->>Facade: AdminProduct
    Facade-->>Page: updated state

16.4 Publishing (Project Editor) — PLANNED

sequenceDiagram
    participant Editor as Tenant admin
    participant PE as ProjectEditorFacade
    participant Val as ProjectValidator (client)
    participant BE as Backend (builder/bootstrap)
    participant Store as ConfigService (storefront)

    Editor->>PE: click Publish
    PE->>Val: validate(bootstrap)
    alt has error-severity issues
        Val-->>PE: issues[]
        PE-->>Editor: block publish, show issues
    else clean
        PE->>BE: POST /builder/bootstrap/publish {BootstrapConfig}
        BE->>BE: server-side re-validation (mandatory, §9.1)
        alt server rejects
            BE-->>PE: 422 {details: [...]}
            PE-->>Editor: show server-side issues
        else accepted
            BE-->>PE: 200
            PE->>Store: reloadFromBootstrap() (in-memory preview)
            Note over Store: next GET /bootstrap by any client now returns the new config
        end
    end

16.5 Checkout / cart (CURRENT, frozen)

sequenceDiagram
    participant U as Shopper
    participant FE as Frontend (CartService)
    participant BE as Backend

    U->>FE: add item to cart
    FE->>BE: POST /websession/:sessionId [{itemID, quantity, ...}]
    BE-->>FE: 200
    U->>FE: initiate payment
    FE->>BE: POST /cart (or {qrApiUrl}/qr) - CartPaymentRequest
    BE-->>FE: QrCreateResponse {qrId/nspkurl/bankUrl/...}
    FE->>U: render QR or redirect to bankUrl
    loop poll
        FE->>BE: GET {qrApiUrl}/qr/dynamic/:partnerId/:qrId
        BE-->>FE: QrDynamicStatusResponse {status}
    end

16.6 Media upload — PLANNED

sequenceDiagram
    participant Admin as Admin user
    participant Picker as MediaPickerComponent
    participant Repo as HttpMediaRepository
    participant BE as Backend

    Admin->>Picker: select file
    Picker->>Repo: upload(file, {folder, tags})
    Repo->>BE: POST /media/upload (multipart)
    BE->>BE: validate size/type, scan, compute width/height, generate thumbnail
    BE-->>Repo: 201 MediaAsset
    Repo-->>Picker: MediaAsset
    Picker-->>Admin: asset selected / inserted

16.7 Review moderation — PLANNED

sequenceDiagram
    participant Mod as Moderator
    participant Page as Moderation page
    participant GW as AdminModerationApiGateway
    participant BE as Backend

    Mod->>Page: set review status = approved, add note
    Page->>GW: setReviewStatus(id, 'approved', note)
    GW->>BE: PATCH /backoffice/reviews/:id/status {status, note}
    BE->>BE: append AdminReviewTimelineEntry, enforce state machine (§8.4)
    BE-->>GW: 200 AdminReview
    GW-->>Page: updated review

16.8 Report resolution — PLANNED

sequenceDiagram
    participant Mod as Moderator
    participant Page as Moderation page (Reports tab)
    participant GW as AdminModerationApiGateway
    participant BE as Backend

    Mod->>Page: mark report resolved
    Page->>GW: setReportStatus(id, 'resolved')
    GW->>BE: PATCH /backoffice/reports/:id/status {status: 'resolved'}
    BE-->>GW: 200 AdminReport

16.9 Order creation — FUTURE (no order-creation endpoint exists anywhere yet; orders today are admin-managed post-hoc, not created via the documented API)

sequenceDiagram
    participant Shopper
    participant FE as Frontend (checkout, not yet built against a real order endpoint)
    participant BE as Backend

    Note over Shopper,BE: No frontend code creates an AdminOrder today. Checkout today only calls the cart-payment endpoints (§16.5). Order creation as a distinct backend concept (order number, items snapshot, timeline) is a FUTURE integration point once checkout and the admin order domain are connected.
    Shopper->>FE: complete checkout
    FE->>BE: (future) POST /orders {items, customer, payment, shipping}
    BE-->>FE: (future) 201 AdminOrder

17. Developer notes

Performance

  • GET /category/GET /category/:id/GET /items/:id are hit frequently and already client-cached (§13) — backend should still index these for fast reads (category id, item id are the obvious indexes) since the client cache is per-tab and short-lived.
  • Paginated admin list endpoints (§5.4) must support page/pageSize server-side, not return-everything-then-paginate-client-side — several mock gateways currently return full arrays and let the facade slice, which will not scale once real data volumes exist.

Scaling

  • Tenant resolution happens on every request (host header lookup) — this must be a fast, cached lookup (e.g. an in-memory or Redis-backed host→tenant map), not a database round-trip per request, given it gates literally everything including the public bootstrap endpoint.
  • BackofficeDataService (frontend) already shares one in-flight request per endpoint across multiple consumers via shareReplay — backend need not defend against duplicate near-simultaneous requests from a single client session, but should still handle normal concurrent-tenant load.

Indexes

  • Category: slug (unique per tenant), parentId (hierarchy traversal), deletedAt (soft-delete filtering).
  • Product: sku (unique per tenant), categoryId, barcode, visible+archived (list filtering).
  • Order: orderNumber, customer.email (customer aggregation, §7.3), status, createdAt (range queries for analytics).
  • Session: webSessionID/adminSessionID (primary lookup key on every authenticated request).

Transactions & concurrency

  • PUT /builder/bootstrap/draft and POST /builder/bootstrap/publish are whole-document writes — guard against two concurrent editors clobbering each other. No optimistic-locking field (version/etag) exists in BootstrapConfig today; adding one (e.g. schemaVersion bumped or a separate revision field checked on write) is recommended before multi-editor concurrent use is expected, since the current contract has no way to detect a stale-draft overwrite.
  • Order status transitions (§8.1) should be guarded by a DB-level transaction covering the status write + timeline-entry append, so the two never diverge.
  • Category deletedAt/product archived toggles plus any cascading effects (e.g. hiding a category's products) should be transactional if such cascades are implemented.

Versioning

  • schemaVersion in BootstrapConfig is the real version signal (§5.1) — bump it deliberately, coordinate with frontend releases for anything beyond additive optional fields.
  • No API-path versioning exists (/bootstrap, not /v1/bootstrap) — introducing path versioning later is a breaking change to every existing integration; if it's ever needed, prefer a new parallel path family over renaming the existing one.