Files
marketplaces/docs/backend/BACKEND-INTEGRATION.md
sdarbinyan f261800159
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
feat(payment): add qrDescription/customerID fields, TTL-based QR polling
- CartPaymentRequest gains qrDescription (brandName > hostname > fallback
  text) and customerID (telegram id)
- QrCreateResponse gains qrTTL; polling window now derived from it
  (min 60s) instead of a fixed 3-minute/36-check cap
- PAYMENT_MAX_CHECKS replaced by PAYMENT_MIN_POLL_SECONDS
2026-07-23 00:26:35 +04:00

1745 lines
122 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-INTEGRATION.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](#1-architecture-overview)
2. [Authentication](#2-authentication)
3. [Security](#3-security)
4. [Bootstrap](#4-bootstrap)
5. [API conventions](#5-api-conventions)
6. [Endpoints, by domain](#6-endpoints-by-domain)
7. [DTOs](#7-dtos)
8. [State machines](#8-state-machines)
9. [Validation](#9-validation)
10. [Media](#10-media)
11. [Errors](#11-errors)
12. [Localization](#12-localization)
13. [Caching](#13-caching)
14. [Backend replacement pattern](#14-backend-replacement-pattern)
15. [Future APIs](#15-future-apis)
16. [Sequence diagrams](#16-sequence-diagrams)
17. [Developer notes](#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.
```mermaid
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. **Localhost**`environment.localhostApiUrl` (`/api`, proxied by the Angular dev server).
2. **`environment.tenantApiBaseUrls[tenantKey]`** — explicit per-tenant override map (e.g. `default`/`dexarmarket``https://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. **Fallback**`environment.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:
```ts
// 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.roleId``AdminRole.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).
### 3.6 Recommended production security checklist
- 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
**CURRENT**`GET /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`.
```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
```ts
// 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`
```ts
// 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)
```json
{
"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: UUID``UUID`/`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.
### 6.6 Search / autocomplete / trending (PLANNED)
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.
```ts
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.
```ts
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).
```ts
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.**
```ts
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.
```ts
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).
```ts
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.
```ts
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.**
```ts
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).
```ts
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`
```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`
```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`
```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:
```ts
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`
```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`
```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`
```ts
interface PermissionDefinition { key: string; description?: string; }
interface RolePermissions { role: string; permissions: string[]; }
interface PermissionsConfig { definitions: PermissionDefinition[]; roles: RolePermissions[]; }
```
### 7.7 Moderation — reviews & reports
```ts
// 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`
```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`
```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):
```ts
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`
```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`
```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:
```ts
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 `productId`s.
### 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:
```json
{
"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.
- `severity``error | 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:
```mermaid
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`):
```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)
```mermaid
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
```mermaid
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
```mermaid
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
```mermaid
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)
```mermaid
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
```mermaid
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
```mermaid
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
```mermaid
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)
```mermaid
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.