Reviewed BACKEND.md top to bottom (4775 lines, 10 sections) against the full backend-handoff checklist (auth, bootstrap, every endpoint, media, all domains, pagination/filter/sort/search, error contract, maintenance mode, status codes, versioning, rate limits, CORS, security, websocket/events, mock-to-api migration). Confirmed already covered, no action: Authentication (§4, all sub-items), Bootstrap (§1, full), every domain's CRUD contract (§3.1- 3.20, includes Moderation under 3.17.b), Media (§7), SEO (bootstrap SeoConfig + per-page seo + sitemap tracked as remaining work), Error Model (§6), Maintenance Mode (§10), Migration guide (§8). Added (genuine gaps, not covered anywhere in the doc): - §2.10 API path versioning - no endpoint has a version segment/header anywhere; only BootstrapConfig.schemaVersion exists and that only versions the bootstrap payload shape, not the API surface. Flagged as a backend/infra decision with zero frontend impact either way. - §2.11 Real-time/WebSocket - confirmed no WebSocket/SSE exists anywhere in the frontend; consolidated the 5 places that look "live" (QR/Telegram login, payment status, session validity, maintenance notice, admin monitoring) into one table, all client-side polling. Flagged push-vs-poll as a backend decision, most relevant to payment latency and the session-revocation propagation delay. - Renumbered the section's "Consolidated requires-backend-decision" list 2.9 -> 2.12 (moved after the two new subsections, no other content changed) and added both new items to it. No other §2.x cross-references existed elsewhere in the doc to update. No duplication found requiring merge; docs/archive/BACKEND_API.md cross-references are intentional (superseded-but-kept historical detail, per the doc's own stated design), not obsolete/duplicate content.
4820 lines
259 KiB
Markdown
4820 lines
259 KiB
Markdown
# Backend — Canonical Specification
|
||
|
||
**This is the ONE document a backend engineer needs.** It is the single source of truth for backend implementation — architecture, bootstrap, authentication, JWT, Ed25519 public-key login, permissions, maintenance mode, error contract, every endpoint, DTOs, request/response schemas, uploads, pagination, filters, sorting, publish workflow, media, builder, examples, and a top-to-bottom implementation checklist. It supersedes and fully merges `AUTHENTICATION.md`, `ERROR_CONTRACT.md`, `MAINTENANCE_MODE.md`, and (already archived, content re-derived from current source) `docs/archive/BACKEND_API.md` and `docs/archive/BACKEND_API_REMAINING_WORK.md`. Those standalone files no longer exist — everything they contained lives here.
|
||
|
||
Everything here is derived from the actual current frontend source code (branch `B2B`), not from prior/stale documentation. Primary input: `docs/context/BACKEND-AUDIT.md` (exhaustive audit of every HTTP call, gateway, facade, and model in the frontend).
|
||
|
||
**Convention used throughout:** where the frontend already implies a concrete behavior, it's documented as-is. Where the frontend has no opinion and a real backend needs one, it's marked **"Requires backend decision"** — nothing beyond what the frontend requires is invented.
|
||
|
||
## Table of contents
|
||
|
||
1. [Bootstrap](#1-bootstrap)
|
||
2. [Endpoint Documentation Framework](#2-endpoint-documentation-framework)
|
||
3. [CRUD Contracts](#3-crud-contracts)
|
||
4. [Authentication](#4-authentication)
|
||
5. [Security](#5-security)
|
||
6. [Error Model](#6-error-model)
|
||
7. [Uploads](#7-uploads)
|
||
8. [Real Backend Implementation Guide](#8-real-backend-implementation-guide)
|
||
9. [Backend Checklist](#9-backend-checklist)
|
||
10. [Maintenance Mode](#10-maintenance-mode)
|
||
|
||
---
|
||
## 1. Bootstrap
|
||
|
||
The bootstrap document is the single runtime-configuration payload that drives
|
||
the entire multi-tenant storefront/builder/backoffice. It is fetched once at
|
||
app startup and held in memory; nearly every feature (theme, navigation,
|
||
localization, catalog behavior, static pages, feature flags, widget registry)
|
||
reads from it rather than from dedicated per-feature endpoints.
|
||
|
||
Source of truth for this section:
|
||
`src/app/core/bootstrap/providers/api-bootstrap.provider.ts`,
|
||
`src/app/core/config/config.service.ts`,
|
||
`src/app/core/config/api-config.service.ts`,
|
||
`src/app/core/config/tenant-resolver.service.ts`,
|
||
`src/app/shared/models/config/*`, and the mock document
|
||
`src/assets/mock/bootstrap/bootstrap.json`. Cross-referenced against
|
||
`docs/context/BACKEND-AUDIT.md` §6, §2 and the prior `docs/archive/BACKEND_API.md`.
|
||
|
||
### 1.1 Request contract
|
||
|
||
| Property | Value |
|
||
|---|---|
|
||
| Method | `GET` |
|
||
| Route (API mode) | `/bootstrap` — relative; rewritten onto the resolved tenant API base by `apiBaseUrlInterceptor` (see §1.10) |
|
||
| Route (mock/local mode) | `GET /assets/mock/bootstrap/bootstrap.json` (static asset, no backend) |
|
||
| Request body | none |
|
||
| Query params | none |
|
||
| Auth requirement | **None.** Bootstrap is fetched before any login and must be publicly cacheable per tenant. It carries no `Authorization` header and no `WebSessionID` is required for it to succeed. |
|
||
|
||
**Headers.** The provider itself (`ApiBootstrapProvider`) sets no explicit
|
||
headers — it issues a plain `HttpClient.get<BootstrapConfig>('/bootstrap')`.
|
||
Because the URL is `/api`-relative only after interceptor rewrite (the literal
|
||
is `/bootstrap`, not `/api/bootstrap`), whether `apiHeadersInterceptor`'s
|
||
marketplace headers (`X-Region`, `X-Language`, `Currency`, `WebSessionID`)
|
||
attach depends on `ApiConfigService.isApiRequest()` matching the resolved URL.
|
||
In practice the backend must treat all of these as **optional** on the
|
||
bootstrap call — none are load-bearing for it, and the document is expected to
|
||
resolve from tenant/origin alone (§1.9).
|
||
|
||
> **Requires backend decision:** whether the bootstrap endpoint should honor
|
||
> an `X-Language`/`Accept-Language` hint to pre-select a localization, or
|
||
> always return the full multi-locale document (the frontend today always
|
||
> receives and holds the full multi-locale document and selects locale
|
||
> client-side).
|
||
|
||
### 1.2 Which provider actually fires
|
||
|
||
`CONFIG_PROVIDER` (`src/app/core/config/config-provider.token.ts`) is a factory
|
||
bound by `RuntimeProviderStrategyService.getBootstrapProviderMode()`:
|
||
|
||
- Returns **mock** (`MockBootstrapProvider` → static asset) when
|
||
`environment.useMockData === true`, **or** when
|
||
`environment.useMockBootstrapOnLocal === true` **and** the host is localhost.
|
||
- Otherwise returns **api** (`ApiBootstrapProvider` → `GET /bootstrap`).
|
||
|
||
With the current `environment.ts` (`useMockData: false`,
|
||
`useMockBootstrapOnLocal: true`), a localhost dev session reads the static
|
||
`bootstrap.json`; a deployed tenant host reads the live `GET /bootstrap`.
|
||
|
||
### 1.3 Response shape — `BootstrapConfig`
|
||
|
||
Copied faithfully from
|
||
`src/app/shared/models/config/bootstrap-config.model.ts`:
|
||
|
||
```ts
|
||
export interface BootstrapConfig {
|
||
schemaVersion: string;
|
||
generatedAt: string;
|
||
tenant: TenantConfig;
|
||
branding: BrandingConfig;
|
||
theme: ThemeConfig;
|
||
company: CompanyConfig;
|
||
featureFlags: FeatureFlagsConfig;
|
||
features?: MarketplaceFeaturesConfig;
|
||
apiEndpoints: ApiEndpointsConfig;
|
||
localization: LocalizationConfig;
|
||
seo: SeoConfig;
|
||
permissions: PermissionsConfig;
|
||
header?: HeaderConfig;
|
||
catalog?: CatalogConfig;
|
||
layout?: PlatformLayoutConfig;
|
||
navigation: NavigationConfig;
|
||
footer?: FooterConfig;
|
||
productPage?: ProductPageConfig;
|
||
userExperience?: UserExperienceConfig;
|
||
pages: PageConfig[];
|
||
staticPages?: StaticPagesConfig;
|
||
widgetRegistry?: WidgetRegistryConfig;
|
||
}
|
||
```
|
||
|
||
Required (non-optional) top-level keys the backend must always emit:
|
||
`schemaVersion, generatedAt, tenant, branding, theme, company, featureFlags,
|
||
apiEndpoints, localization, seo, permissions, navigation, pages`. Everything
|
||
marked `?` may be omitted; the frontend supplies defaults (e.g.
|
||
`DEFAULT_CATALOG_CONFIG`, `DEFAULT_USER_EXPERIENCE_CONFIG`) when absent.
|
||
|
||
Each nested field, with its source model file under
|
||
`src/app/shared/models/config/`:
|
||
|
||
- **`schemaVersion`** (`string`) — document schema version (e.g. `"1.0.0"`).
|
||
Not enforced by a validator today; see §1.6.
|
||
- **`generatedAt`** (`string`, ISO 8601) — when the tenant's config snapshot
|
||
was produced. Advisory/diagnostic only today.
|
||
- **`tenant`** (`TenantConfig`, `tenant.model.ts`) —
|
||
`{ id (UUID), slug, code, host, name, websiteBaseUrl, builderBaseUrl,
|
||
backofficeBaseUrl, defaultLocale, supportedLocales[], defaultCurrency,
|
||
supportedCurrencies[], timezone }`. Identifies the tenant and its per-surface
|
||
base URLs, locale/currency sets, and timezone.
|
||
- **`branding`** (`BrandingConfig`, `branding.model.ts`) —
|
||
`{ brandName, legalName, slogan?, logoUrl, logoCompactUrl?, faviconUrl,
|
||
appIconUrl?, socialImageUrl?, galleryUrls?, supportEmail?, supportPhone? }`.
|
||
Drives header logo, favicon, PWA icon, social share image.
|
||
- **`theme`** (`ThemeConfig`, `theme.model.ts`) —
|
||
`{ themeId, mode: 'light'|'dark'|'system', palette (12 named colors:
|
||
primary/secondary/accent/success/warning/danger/info/textPrimary/
|
||
textSecondary/backgroundPrimary/backgroundSecondary/border), typography
|
||
(primaryFontFamily, headingFontFamily?, baseFontSize), spacing (unit, scale[]),
|
||
borderRadiusScale (Record<string,string>), shadows (Record<string,string>),
|
||
iconSet }`. Consumed by `ThemeEngineService`/`BrandingEngineService` to emit
|
||
CSS variables at runtime.
|
||
- **`company`** (`CompanyConfig`) — legal entity + address + contacts block
|
||
(see mock JSON §1.5 for shape: `companyName, registrationNumber, taxId,
|
||
address{country,region,city,street,postalCode}, contacts{email,phone,
|
||
telegram,website}`).
|
||
- **`featureFlags`** (`FeatureFlagsConfig`, `feature-flags.model.ts`) — a
|
||
`Record<string, boolean>` with well-known keys `wishlist, compare, reviews,
|
||
questions, comments, recommendations, blog, chat, analytics, notifications,
|
||
coupons, loyalty, giftCards, invoices` plus an open index signature for
|
||
arbitrary custom flags. Toggle features on/off per tenant.
|
||
- **`features`** (`MarketplaceFeaturesConfig`, optional) — richer per-feature
|
||
config beyond boolean flags.
|
||
- **`apiEndpoints`** (`ApiEndpointsConfig`, `api-endpoints.model.ts`) —
|
||
`{ bootstrap: ApiEndpointConfig, website: Record<string,ApiEndpointConfig>,
|
||
builder: Record<string,ApiEndpointConfig>, backoffice:
|
||
Record<string,ApiEndpointConfig> }` where `ApiEndpointConfig =
|
||
{ path, method, timeoutMs? }`. **This is where a tenant declares its planned
|
||
per-surface endpoint paths at runtime.** The `website/builder/backoffice`
|
||
maps are empty `{}` in the mock today — no builder/backoffice CRUD path
|
||
exists as a code literal anywhere (BACKEND-AUDIT §24); those endpoints, if
|
||
ever built, are declared here rather than hard-coded.
|
||
- **`localization`** (`LocalizationConfig`, `localization.model.ts`) —
|
||
`{ defaultLocale, supportedLocales[], currencyByLocale (Record<locale,
|
||
currency>), dictionaries: [{ locale, dictionaryUrl, version }] }`. Note the
|
||
three supported locales are `ru, en, hy`; the marketplace API uses codes
|
||
`RU, EN, AM` (mapped by `apiHeadersInterceptor` / `ApiService.normalizeLang`,
|
||
`am↔hy`).
|
||
- **`seo`** (`SeoConfig`, `seo.model.ts`) —
|
||
`{ default: SeoPageConfig, byPageKey: Record<pageKey, SeoPageConfig> }` where
|
||
`SeoPageConfig = { title, description, canonicalUrl?, robots?, metaTags? }`.
|
||
- **`permissions`** (`PermissionsConfig`, `permissions.model.ts`) —
|
||
`{ definitions: [{ key, description? }], roles: [{ role, permissions[] }] }`.
|
||
Bootstrap-level RBAC catalog. (Distinct from the Ed25519 JWT `AdminRole`
|
||
union — see `docs/AUTHENTICATION.md` §9 for the flagged `AdminRole` naming
|
||
collision.)
|
||
- **`header`** (`HeaderConfig`, optional) — header layout config.
|
||
- **`catalog`** (`CatalogConfig`, `catalog-config.model.ts`, optional) — the
|
||
storefront catalog behavior contract:
|
||
`{ layout, loadingStrategy: 'pagination'|'loadMore'|'infiniteScroll',
|
||
navigationMode, defaultSort, availableSorts[], enabledFilters[],
|
||
showBreadcrumbs, showCategoryBanner, showSubcategoryChips, showRatings,
|
||
showDiscounts, showAvailability, suggestionsEnabled, searchHistoryEnabled }`.
|
||
`defaultSort`/`availableSorts` enumerate `relevance | latest | price_asc |
|
||
price_desc | rating | popular | discount` (see §2.3). Defaults in
|
||
`DEFAULT_CATALOG_CONFIG`.
|
||
- **`layout`** (`PlatformLayoutConfig`, optional) — global layout type.
|
||
- **`navigation`** (`NavigationConfig`, `navigation.model.ts`) —
|
||
`{ header: NavigationItemConfig[], footer: NavigationItemConfig[] |
|
||
FooterNavigationGroupConfig[], sidebar?: NavigationItemConfig[] }`. Items
|
||
carry `{ id, labelKey?|label?, route?, icon?, order?, visible?,
|
||
visibleWhenFlags?, children? }` — note `visibleWhenFlags` lets a nav item be
|
||
gated on a `featureFlags` key.
|
||
- **`footer`** (`FooterConfig`, `footer-config.model.ts`, optional) —
|
||
`{ logoUrl?, paymentIcons?, copyrightText? (string|localized), columns?
|
||
(FooterColumnConfig[]), socialLinks? }`. `legalPageKeys`/`staticPageKeys` are
|
||
`@deprecated` in favor of `columns` but still resolve for older saved configs.
|
||
- **`productPage`** (`ProductPageConfig`, optional) — per-PDP config; in the
|
||
mock: `rating{enabled}, reviews{enabled,pageSize,showSummary},
|
||
questions{enabled,pageSize}, tabs{enabled,items[]}, relatedProducts{enabled}`.
|
||
Note `reviews.pageSize`/`questions.pageSize` (5 in mock) are the storefront
|
||
engagement page sizes (§2.2).
|
||
- **`userExperience`** (`UserExperienceConfig`, `user-experience-config.model.ts`,
|
||
optional) — `{ wishlist, compare{maxItems,...}, recentlyViewed{maxItems,...},
|
||
share, continueBrowsing, savedSearches{maxItems} }` limits/toggles for the
|
||
guest-first UX features. Defaults in `DEFAULT_USER_EXPERIENCE_CONFIG`.
|
||
- **`pages`** (`PageConfig[]`, `page.model.ts`) — the dynamic page tree the
|
||
renderer builds routes from: each `{ id, key, title, route{path,exact?},
|
||
layout, sections: SectionConfig[], seoKey?, featureFlag?, visible? }`. Each
|
||
section holds ordered `widgets` with `props` (see mock §1.5 for a full
|
||
hero/categories/product-collection example). This is the builder's output.
|
||
- **`staticPages`** (`StaticPagesConfig`, `static-page.model.ts`, optional) —
|
||
either `Record<slug, StaticPageConfig>` or a legacy array. Each page carries
|
||
localized `title` + `html`/`content`, `route`, `seo`, `status:
|
||
'draft'|'published'`, `enabled`, `visibility`, `updatedAt`, etc. Storefront
|
||
legal/content pages live here (about-us, privacy-policy, terms-of-service in
|
||
the mock). Content-management edits operate on this in-memory (BACKEND-AUDIT
|
||
§16) — no dedicated content backend.
|
||
- **`widgetRegistry`** (`WidgetRegistryConfig`, optional) —
|
||
`{ manifestUrl: string }`; the URL `WidgetManifestService` fetches the widget
|
||
manifest from (§2 "Widget manifest" endpoint).
|
||
|
||
### 1.4 Versioning
|
||
|
||
`BootstrapConfig.schemaVersion` (string) **does** exist and is the schema/version
|
||
field. Current usage:
|
||
|
||
- The mock document sets `"schemaVersion": "1.0.0"`.
|
||
- `bootstrap-diagnostics.validator.ts:44` surfaces it as a diagnostic entry
|
||
(`['schemaVersion', bootstrap.schemaVersion]`).
|
||
- `AdminDashboardFacade` (`admin-dashboard.facade.ts:110`) derives the
|
||
`dashboard.healthBootstrapValid` health check purely as
|
||
`healthy: !!current?.schemaVersion` — i.e. "bootstrap is valid" today means
|
||
only "a schemaVersion string is present," **not** any real semantic-version
|
||
compatibility check.
|
||
|
||
> **Requires backend decision:** whether `schemaVersion` should be enforced
|
||
> (frontend rejecting/ warning on an unknown major version). No such
|
||
> enforcement exists today — any string is accepted, and only its presence is
|
||
> checked.
|
||
|
||
### 1.5 Example JSON
|
||
|
||
Realistic example, taken from the actual mock document
|
||
`src/assets/mock/bootstrap/bootstrap.json` (abridged — pages/staticPages
|
||
trimmed for length; full versions in that file):
|
||
|
||
```json
|
||
{
|
||
"schemaVersion": "1.0.0",
|
||
"generatedAt": "2026-07-03T00:00:00Z",
|
||
"tenant": {
|
||
"id": "tenant-default-001",
|
||
"slug": "default",
|
||
"code": "DEFAULT",
|
||
"host": "default.local",
|
||
"name": "Marketplace",
|
||
"websiteBaseUrl": "https://marketplace.local",
|
||
"builderBaseUrl": "https://builder.marketplace.local",
|
||
"backofficeBaseUrl": "https://backoffice.marketplace.local",
|
||
"defaultLocale": "ru",
|
||
"supportedLocales": ["ru", "en", "hy"],
|
||
"defaultCurrency": "RUB",
|
||
"supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
|
||
"timezone": "Europe/Moscow"
|
||
},
|
||
"branding": {
|
||
"brandName": "Marketplace",
|
||
"legalName": "Marketplace LLC",
|
||
"slogan": "Digital commerce marketplace",
|
||
"logoUrl": "/icons/icon-192x192.png",
|
||
"faviconUrl": "/favicon.ico",
|
||
"supportEmail": "support@marketplace.local",
|
||
"supportPhone": "+7-900-000-00-00"
|
||
},
|
||
"theme": {
|
||
"themeId": "default-light",
|
||
"mode": "light",
|
||
"palette": {
|
||
"primary": "#497671", "secondary": "#a1b4b5", "accent": "#a7ceca",
|
||
"success": "#10b981", "warning": "#f59e0b", "danger": "#ef4444",
|
||
"info": "#3b82f6", "textPrimary": "#1e3c38", "textSecondary": "#667a77",
|
||
"backgroundPrimary": "#ffffff", "backgroundSecondary": "#f5f5f5",
|
||
"border": "#d3dad9"
|
||
},
|
||
"typography": { "primaryFontFamily": "DM Sans, sans-serif", "baseFontSize": 16 },
|
||
"spacing": { "unit": 4, "scale": [0, 4, 8, 12, 16, 24, 32, 48] },
|
||
"borderRadiusScale": { "sm": "8px", "md": "12px", "lg": "16px", "xl": "22px" },
|
||
"shadows": { "sm": "0 2px 8px rgba(0,0,0,0.1)" },
|
||
"iconSet": "default"
|
||
},
|
||
"company": {
|
||
"companyName": "Marketplace LLC",
|
||
"registrationNumber": "1027700000000",
|
||
"taxId": "7700000000",
|
||
"address": { "country": "Russia", "region": "Moscow", "city": "Moscow", "street": "Tverskaya 1", "postalCode": "125009" },
|
||
"contacts": { "email": "support@marketplace.local", "phone": "+7-900-000-00-00", "telegram": "@marketplace_support", "website": "https://marketplace.local" }
|
||
},
|
||
"featureFlags": {
|
||
"wishlist": true, "compare": true, "reviews": true, "blog": false,
|
||
"chat": false, "analytics": true, "notifications": true, "coupons": true,
|
||
"loyalty": false, "giftCards": false, "invoices": true
|
||
},
|
||
"apiEndpoints": {
|
||
"bootstrap": { "path": "/bootstrap", "method": "GET", "timeoutMs": 10000 },
|
||
"website": {}, "builder": {}, "backoffice": {}
|
||
},
|
||
"localization": {
|
||
"defaultLocale": "ru",
|
||
"supportedLocales": ["ru", "en", "hy"],
|
||
"currencyByLocale": { "ru": "RUB", "en": "USD", "hy": "AMD" },
|
||
"dictionaries": [
|
||
{ "locale": "ru", "dictionaryUrl": "/assets/i18n/ru.json", "version": "1.0.0" },
|
||
{ "locale": "en", "dictionaryUrl": "/assets/i18n/en.json", "version": "1.0.0" },
|
||
{ "locale": "hy", "dictionaryUrl": "/assets/i18n/hy.json", "version": "1.0.0" }
|
||
]
|
||
},
|
||
"seo": {
|
||
"default": { "title": "Marketplace", "description": "Digital commerce marketplace", "robots": "index,follow" },
|
||
"byPageKey": { "home": { "title": "Marketplace - Home", "canonicalUrl": "https://marketplace.local/", "robots": "index,follow" } }
|
||
},
|
||
"permissions": {
|
||
"definitions": [
|
||
{ "key": "builder.pages.edit", "description": "Edit pages in builder" },
|
||
{ "key": "backoffice.products.read", "description": "Read products in backoffice" }
|
||
],
|
||
"roles": [
|
||
{ "role": "builder_admin", "permissions": ["builder.pages.edit"] },
|
||
{ "role": "backoffice_manager", "permissions": ["backoffice.products.read"] }
|
||
]
|
||
},
|
||
"catalog": {
|
||
"layout": "grid", "navigationMode": "default", "defaultSort": "relevance",
|
||
"availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"],
|
||
"enabledFilters": ["price", "availability", "rating", "brand", "category", "subcategory", "discount", "new", "color", "size", "attributes"],
|
||
"showBreadcrumbs": true, "showRatings": true, "showDiscounts": true, "showAvailability": true
|
||
},
|
||
"productPage": {
|
||
"rating": { "enabled": true },
|
||
"reviews": { "enabled": true, "pageSize": 5, "showSummary": true },
|
||
"questions": { "enabled": true, "pageSize": 5 },
|
||
"relatedProducts": { "enabled": true }
|
||
},
|
||
"navigation": {
|
||
"header": [
|
||
{ "id": "nav-home", "labelKey": "nav.home", "route": "/", "icon": "home", "order": 1 },
|
||
{ "id": "nav-search", "labelKey": "nav.search", "route": "/search", "icon": "search", "order": 2 }
|
||
],
|
||
"footer": [
|
||
{ "id": "footer-about", "labelKey": "nav.about", "route": "/about-us", "order": 1 }
|
||
]
|
||
},
|
||
"footer": {
|
||
"copyrightText": { "ru": "© 2026 Marketplace. Все права защищены.", "en": "© 2026 Marketplace. All rights reserved." },
|
||
"legalPageKeys": ["about-us", "privacy-policy", "terms-of-service"]
|
||
},
|
||
"widgetRegistry": { "manifestUrl": "/assets/mock/bootstrap/widget-manifest.json" },
|
||
"staticPages": {
|
||
"about-us": {
|
||
"route": "/about-us",
|
||
"title": { "ru": "О компании", "en": "About Us", "hy": "Մեր մասին" },
|
||
"html": { "ru": "<h2>О компании</h2><p>…</p>", "en": "<h2>About Us</h2><p>…</p>" }
|
||
}
|
||
},
|
||
"pages": [
|
||
{
|
||
"id": "page-home", "key": "home", "title": "Home",
|
||
"route": { "path": "/", "exact": true },
|
||
"layout": { "type": "default" }, "seoKey": "home", "visible": true,
|
||
"sections": [
|
||
{
|
||
"id": "section-hero", "type": "hero", "order": 1,
|
||
"layout": { "strategy": "hero", "columns": 1, "gap": "1.5rem", "align": "stretch" },
|
||
"visible": true,
|
||
"widgets": [
|
||
{
|
||
"id": "widget-hero-main", "type": "hero", "version": "1.0.0", "order": 1,
|
||
"props": {
|
||
"title": { "ru": "Добро пожаловать на Маркетплейс", "en": "Welcome to Marketplace Platform" },
|
||
"ctaLabel": { "ru": "Начать покупки", "en": "Start Shopping" }
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### 1.6 Lifecycle
|
||
|
||
- There is **no `APP_INITIALIZER`** wiring bootstrap fetch as a hard app
|
||
precondition. `main.ts` calls `bootstrapApplication(App, appConfig)` with no
|
||
initializer that blocks on config. (`docs/AUTHENTICATION.md` §5.2 separately
|
||
notes the Ed25519 `restoreSession()` initializer is also not wired.)
|
||
- Bootstrap is instead loaded lazily-but-eagerly by the first consumer to need
|
||
it. Multiple root-level services subscribe to `ConfigService.loadBootstrap()`
|
||
at construction: `UiRuntimeFacade` (`ui-runtime.facade.ts:32`),
|
||
`PlatformRuntimeService`, `ThemeEngineService`, `BrandingEngineService`, the
|
||
`FooterComponent`, etc. Because `ConfigService` de-dupes (§1.7), the first
|
||
one triggers the single HTTP call and the rest share it.
|
||
- The `languageGuard` (`src/app/guards/language.guard.ts`, wrapping every
|
||
`:lang` route) runs on navigation and preloads the locale dictionary; it is
|
||
**not** itself the bootstrap fetcher, but it runs in the same startup window.
|
||
Bootstrap and language resolution are effectively concurrent — the app does
|
||
not gate rendering on bootstrap completion, so components must tolerate a
|
||
transient null snapshot.
|
||
|
||
### 1.7 Caching behavior (what the frontend actually does)
|
||
|
||
`ConfigService` (`src/app/core/config/config.service.ts`) is the cache, and it
|
||
is **in-memory only** — no `localStorage`, no HTTP cache header logic:
|
||
|
||
- Holds `bootstrapSnapshot: BootstrapConfig | null` (last successful value) and
|
||
a shared `bootstrap$` observable with `shareReplay(1)`.
|
||
- `loadBootstrap(forceRefresh = false)`: returns the cached `bootstrap$` when a
|
||
snapshot exists and `forceRefresh` is false; otherwise (re)issues the
|
||
provider call.
|
||
- On error, it clears both `bootstrap$` and `bootstrapSnapshot` so the next
|
||
call retries cleanly (`catchError` rethrows).
|
||
- A `bootstrapRevision` signal increments on every successful load/override —
|
||
consumers use it to react to config changes.
|
||
- `getBootstrapSnapshot()` returns the current in-memory value synchronously
|
||
(used by `WidgetManifestService`, diagnostics, etc.).
|
||
- The cache does **not** survive a page reload — every full page load re-fetches
|
||
bootstrap. There is also a separate client-side `cacheInterceptor`
|
||
(GET-response caching, BACKEND-AUDIT §3) that may cache the `/bootstrap`
|
||
response at the HTTP layer within a session, but nothing persists it across
|
||
sessions.
|
||
|
||
### 1.8 ETag / conditional requests
|
||
|
||
**Requires backend decision — no ETag / conditional-request logic exists in
|
||
the frontend today.** The bootstrap provider issues a plain unconditional
|
||
`GET`; nothing reads or sends `ETag`, `If-None-Match`, `Last-Modified`, or
|
||
`If-Modified-Since`. If the backend wants conditional revalidation of the
|
||
(large, per-tenant, infrequently changing) bootstrap document, both the wire
|
||
strategy and the frontend handling for it would be new work.
|
||
|
||
### 1.9 Tenant resolution
|
||
|
||
Tenant is resolved by **subdomain**, not by header or path (full detail in
|
||
`docs/AUTHENTICATION.md` §10; source
|
||
`src/app/core/config/tenant-resolver.service.ts`):
|
||
|
||
- `TenantResolverService.getTenantKey()`: localhost →
|
||
`environment.fallbackTenantKey ?? 'default'`; otherwise the **first DNS
|
||
label** of the hostname, skipping a leading `www` (e.g.
|
||
`dexarmarket.api.dexarmarket.ru` → `dexarmarket`; `www.acme.com` → `acme`).
|
||
- That tenant key feeds `ApiConfigService.getBaseUrl()`, which picks the
|
||
marketplace API base: localhost → `environment.localhostApiUrl` (`/api`);
|
||
else `environment.tenantApiBaseUrls[tenantKey]`; else
|
||
`environment.tenantApiTemplate` with `{tenant}` substituted; else (only when
|
||
`allowBootstrapApiOverride` is true — off by default) a value read out of the
|
||
already-loaded bootstrap (`apiEndpoints.website.baseUrl` /
|
||
`tenant.apiBaseUrl`).
|
||
- **No `X-Tenant` header or `/tenant/{id}` path prefix is ever sent.** Tenant
|
||
isolation for `GET /bootstrap` is achieved purely by *which origin/subdomain
|
||
the request goes to* — the backend must infer the tenant from the request
|
||
host, and return that tenant's bootstrap document.
|
||
|
||
### 1.10 Draft vs Published
|
||
|
||
The storefront and the builder read the **same** in-memory `BootstrapConfig`;
|
||
there is no separate "draft bootstrap" vs "live bootstrap" endpoint today
|
||
(BACKEND-AUDIT §17):
|
||
|
||
- The builder (`ProjectEditorFacade`,
|
||
`src/app/features/project-editor/facade/project-editor.facade.ts`) loads the
|
||
bootstrap via `ConfigService.loadBootstrap(true)` (force refresh), edits an
|
||
in-memory `History<BootstrapConfig>` (undo/redo), and persists **drafts to
|
||
localStorage** through `ProjectEditorDraftStorageService`.
|
||
- `ConfigService.applyBootstrapOverride(next)` swaps the in-memory snapshot
|
||
(deep-cloned) so a preview reflects edits immediately — but this is purely
|
||
client-side.
|
||
- **There is no publish/write HTTP call.** No `PUT /bootstrap`, no
|
||
builder-write endpoint exists anywhere in code. "Publishing a marketplace"
|
||
(writing the edited bootstrap back so the live storefront serves it) is
|
||
**FUTURE / LOCAL-ONLY today**. Any builder publish endpoint would be declared
|
||
under `apiEndpoints.builder` (runtime-declared) and is a backend build item,
|
||
not something the frontend currently calls.
|
||
|
||
> **Requires backend decision:** the entire draft→publish write path
|
||
> (endpoint, method, optimistic-concurrency / version check on save, and
|
||
> whether draft state is server-persisted or stays localStorage-only).
|
||
|
||
### 1.11 Fallback behavior (bootstrap fails to load)
|
||
|
||
- `ConfigService` does **not** substitute a default document on failure — it
|
||
clears its cache and rethrows, so each subscribing consumer sees the error.
|
||
- There is no global "bootstrap failed" full-screen error page. Instead,
|
||
individual engines degrade: `ThemeEngineService`/`BrandingEngineService`
|
||
subscribe with `take(1)` and simply do nothing extra on error (the app keeps
|
||
its compiled default styles); optional config getters fall back to hard-coded
|
||
defaults (`DEFAULT_CATALOG_CONFIG`, `DEFAULT_USER_EXPERIENCE_CONFIG`);
|
||
`WidgetManifestService` falls back to the static manifest URL on any bootstrap
|
||
error.
|
||
- Feature-specific consumers that require bootstrap data render their own
|
||
generic empty/error states (the same `common.errorTitle` pattern described in
|
||
`docs/ERROR_CONTRACT.md`), not a bootstrap-specific one.
|
||
|
||
> **Requires backend decision / frontend follow-up:** whether a hard bootstrap
|
||
> failure should present a dedicated tenant-level error screen. Today the
|
||
> failure is silent-ish (default theme, empty dynamic pages) rather than a
|
||
> blocking error, which is a UX gap if the backend can actually return a fatal
|
||
> bootstrap error.
|
||
|
||
### 1.12 Failure responses
|
||
|
||
The frontend does not parse a bootstrap-specific error body — a failed
|
||
`GET /bootstrap` surfaces as a bare `HttpErrorResponse` handled per §1.11. For
|
||
the standard error envelope every endpoint (including this one) should return
|
||
on non-2xx, and the per-status semantics (401/403/404/409/422/429/500/503,
|
||
plus `TENANT_DISABLED` and `MAINTENANCE_MODE`), see
|
||
**`docs/ERROR_CONTRACT.md`** — that document owns the error envelope and this
|
||
section does not redefine it. Two of its rows are especially relevant to
|
||
bootstrap:
|
||
|
||
- **`TENANT_DISABLED`** (HTTP 403, `error.code: "TENANT_DISABLED"`) — the
|
||
natural failure for `GET /bootstrap` against a known-but-inactive tenant.
|
||
`ERROR_CONTRACT.md` marks the frontend handling for this as "Requires backend
|
||
decision" (no code path handles it today).
|
||
- **`MAINTENANCE_MODE`** (HTTP 503, `error.code: "MAINTENANCE_MODE"`) — if a
|
||
tenant is down for maintenance, bootstrap is where the frontend would first
|
||
hit it. Again, no maintenance concept exists frontend-side today.
|
||
|
||
---
|
||
|
||
## 2. Endpoint Documentation Framework
|
||
|
||
This section defines the **general contract every endpoint in the system
|
||
follows**, so the reader has the framework before the per-domain endpoint
|
||
catalog (a separate section). Where the frontend does not define something,
|
||
it is marked **"Requires backend decision"** rather than invented.
|
||
|
||
### 2.1 Standard success response envelope
|
||
|
||
**There is no `ApiResponse<T>` / `{ success, data, error }` envelope in this
|
||
codebase.** Every live caller types its `HttpClient` call to the **bare payload
|
||
type** and consumes the raw JSON directly:
|
||
|
||
- `ApiService.getItem()` → `HttpClient.get<Item>(...)` (the item object itself).
|
||
- `ApiService.getCategories()` → `get<Category[]>` (bare array).
|
||
- `ApiBootstrapProvider` → `get<BootstrapConfig>` (bare object).
|
||
- `TelegramSessionApiService` → `get<Record<string, unknown>>` then normalizes.
|
||
|
||
The generic `ApiResponse<T>` shape in the org coding-standards is **not** used
|
||
here — do not assume an envelope. Success responses are the raw resource
|
||
(object, array, or `{ items, total }` for lists — see §2.2).
|
||
|
||
> **Requires backend decision:** whether to *introduce* a success envelope
|
||
> going forward. If adopted it would be a breaking change to every existing
|
||
> live caller listed in §2.6 and BACKEND-AUDIT §4, each of which currently
|
||
> expects the bare payload. Recommendation: keep bare payloads for the existing
|
||
> live endpoints; only new endpoints could opt into an envelope, and even then
|
||
> the frontend has no envelope-unwrapping layer today (one would be new work).
|
||
|
||
### 2.2 Standard pagination contract
|
||
|
||
Two distinct pagination shapes already exist in the frontend; a backend should
|
||
align to these rather than invent a third:
|
||
|
||
**A) Offset/count style (marketplace list + search endpoints)** — used by the
|
||
live `ApiService`:
|
||
|
||
- Request query params: **`count`** (page size, default 50) and **`skip`**
|
||
(offset, default 0). Applied on `GET /category/{id}`, `GET /searchitems`,
|
||
`GET /items/randomitems`.
|
||
- Response for `searchItems`: `{ items: Item[], total: number }`. Other list
|
||
endpoints (`getCategoryItems`, `getRandomItems`) return a **bare array** with
|
||
no total.
|
||
|
||
**B) Page/pageSize style (admin lists, storefront engagement lists, media)** —
|
||
used by facade/gateway list results:
|
||
|
||
- Request: `{ page: number, pageSize: number }` (plus filters — §2.4). See
|
||
`AdminOrderListFilters`, `EngagementListQuery`, `MediaListParams`.
|
||
- Response: `{ items: T[], total: number, page: number, pageSize: number }`.
|
||
Canonical shapes: `EngagementListResult<T>`
|
||
(`core/products/models/product-engagement.model.ts:55`), `AdminOrdersListResult`
|
||
(`features/admin/orders/models/admin-order.model.ts:63`), `MediaListResult`
|
||
(`{ items, total }` only). The UI derives `totalPages = ceil(total /
|
||
pageSize)` client-side (e.g. `pagination.component.ts`, admin list pages).
|
||
|
||
Notes / open items:
|
||
|
||
- The two styles use different param names (`count`/`skip` vs `page`/`pageSize`)
|
||
— a backend serving both storefront and admin must support both, or a
|
||
reconciliation decision is needed.
|
||
- **No cursor/keyset pagination anywhere.** The only `cursor` matches in source
|
||
are CSS `cursor:` properties. There is no `nextCursor`/`hasMore` on the wire
|
||
(`catalog-state.model.ts` has a client-derived `hasMore` for infinite-scroll,
|
||
computed as `skip + pageSize < total`, not a backend field).
|
||
|
||
> **Requires backend decision:** exact page-size **limits/maximums** (the
|
||
> frontend sends 50 as a default `count` but enforces no server-side cap),
|
||
> whether to standardize on offset vs page-number, and whether large lists
|
||
> should move to cursor pagination (nothing in the frontend implies or requires
|
||
> cursors today).
|
||
|
||
### 2.3 Standard sorting contract
|
||
|
||
Sorting is **enumerated in the bootstrap `catalog` config**, not free-form:
|
||
|
||
- `CatalogConfig.availableSorts` / `CatalogConfig.defaultSort` (see §1.3,
|
||
`catalog-config.model.ts`) enumerate the allowed values:
|
||
`relevance | latest | price_asc | price_desc | rating | popular | discount`.
|
||
- The live wire param is `sort` on `GET /searchitems`
|
||
(`ApiService.searchItems`), whose accepted set is a subset:
|
||
`relevance | price_asc | price_desc | popular | rating` (the code's
|
||
`options.sort` union). `latest`/`discount` are catalog-config values without a
|
||
confirmed search-endpoint mapping.
|
||
- Admin lists sort primarily via their filter objects / client-side; there is
|
||
no shared admin `sort` query-param convention on the wire yet (admin CRUD is
|
||
mock-only, BACKEND-AUDIT §14).
|
||
|
||
> **Requires backend decision:** reconcile the catalog-config sort vocabulary
|
||
> (7 values) with the search-endpoint `sort` vocabulary (5 values) — and define
|
||
> the wire encoding for admin list sorting (field + direction) when those
|
||
> endpoints are built.
|
||
|
||
### 2.4 Standard filtering contract
|
||
|
||
Filtering shapes already used by the frontend:
|
||
|
||
- **Storefront search/catalog** — `GET /searchitems` accepts optional
|
||
`categoryIDs` (comma-joined ints), `minPrice`, `maxPrice`, `tag`, plus `sort`
|
||
(§2.3). `CatalogConfig.enabledFilters` declares which filter UIs a tenant
|
||
exposes (`price, availability, rating, brand, category, subcategory, discount,
|
||
new, color, size, attributes`).
|
||
- **Admin lists** — each domain has a filters object, all following a
|
||
`{ search: string, <field>: 'all' | <enum>, page, pageSize }` shape. Examples:
|
||
- `AdminOrderListFilters` = `{ search, status: 'all'|AdminOrderStatus, page,
|
||
pageSize }`.
|
||
- `AdminMonitoringEventFilters` = `{ category: 'all'|AdminMonitoringCategory,
|
||
search }` (note: no page/pageSize on this one).
|
||
- `MediaListParams` = `{ page?, pageSize?, search?, folder?, tag?, kind?,
|
||
sort? }`.
|
||
The convention: a text `search` string plus enum facets where the sentinel
|
||
**`'all'`** means "no filter on this facet."
|
||
|
||
> **Requires backend decision:** the wire encoding of these filters as query
|
||
> params for the (not-yet-built) admin endpoints — the shapes above are
|
||
> in-memory facade filter objects, not confirmed query-string contracts. Also
|
||
> whether `'all'` is sent literally or omitted to mean unfiltered.
|
||
|
||
### 2.5 Standard search contract
|
||
|
||
- Live full-text search: `GET /searchitems?search=<q>&count=&skip=[&categoryIDs
|
||
&minPrice&maxPrice&tag&sort]` → `{ items: Item[], total: number }`
|
||
(`ApiService.searchItems`). The query param is **`search`**.
|
||
- The `SearchFacade` (BACKEND-AUDIT §18) is a **client-side orchestration** over
|
||
`ProductFacade`/`CategoryFacade` — history, trending, autocomplete, and cache
|
||
are all localStorage/in-memory. There is **no dedicated search backend
|
||
endpoint** beyond `/searchitems`; autocomplete/suggestions are derived
|
||
client-side today.
|
||
|
||
> **Requires backend decision:** whether server-side autocomplete/suggestion
|
||
> and trending endpoints are wanted (nothing on the wire today), and the
|
||
> min-query-length / debounce contract if so.
|
||
|
||
### 2.6 Nullable-field convention
|
||
|
||
The frontend does **not** ascribe distinct semantics to `null` vs `undefined`
|
||
vs an omitted key on the wire — it treats all three as "absent" and applies
|
||
defaults defensively:
|
||
|
||
- Model interfaces mark optional fields with `?` (implying may be omitted);
|
||
a handful use explicit `T | null` (e.g. `AuthSession.userId: number | null`,
|
||
`AuthSession.username: string | null` where `null` specifically means "known
|
||
to be absent").
|
||
- The tolerant normalizers (`ApiService.normalizeItem/normalizeCategory`,
|
||
`TelegramSessionApiService.normalizeWebSession`) coalesce `null`/`undefined`/
|
||
missing uniformly via `??` and default fallbacks — they never branch on
|
||
`null` vs `undefined`.
|
||
- **Convention to follow:** the backend may omit an optional key or send `null`
|
||
interchangeably; both are handled. Explicit `null` is only meaningful where a
|
||
model types the field as `… | null` to signal "definitively no value" (auth
|
||
user id/username being the notable examples).
|
||
|
||
### 2.7 JWT / authentication header format
|
||
|
||
Two coexisting mechanisms; full spec in **`docs/AUTHENTICATION.md`** (this
|
||
section only references it, does not redefine):
|
||
|
||
- **Telegram session auth (LIVE)** — no `Authorization: Bearer`. Identity is a
|
||
**`WebSessionID`** header (auth session id, or a persisted anonymous 32-hex id
|
||
from `localStorage['web_session_id']`) attached to marketplace API requests by
|
||
`apiHeadersInterceptor`. Admin requests additionally carry an
|
||
**`AdminWebSessionID`** header (`adminAuthHeadersInterceptor`, gating URL
|
||
segments `/admin/`, `/backoffice/`, `/builder/`, `/media/`).
|
||
- **Ed25519 admin auth (wired, backend absent)** — issues a JWT
|
||
`AuthTokenPair { token, refreshToken }`; the intended header is standard
|
||
`Authorization: Bearer <token>`, but its `authInterceptor` is **not
|
||
registered** today (AUTHENTICATION.md §2.6), so no request auto-attaches the
|
||
bearer token yet. `adminAuthHeadersInterceptor` *does* set `Authorization:
|
||
Bearer <token>` if an admin token happens to be stored, but nothing stores one
|
||
in the live flow.
|
||
|
||
Refer to `docs/AUTHENTICATION.md` for token structure (`JwtClaims`), refresh,
|
||
rotation, expiry, role hierarchy, and tenant-scoping open items.
|
||
|
||
### 2.8 Live endpoint domains (real HTTP, already implemented)
|
||
|
||
The following domains have **real `HttpClient` calls in code today** (not
|
||
proposals). Documented per-endpoint below. (The Ed25519 admin-auth API is wired
|
||
to real HTTP but the backend does not implement it yet — its full contract is
|
||
in `docs/AUTHENTICATION.md` §2, not repeated here.)
|
||
|
||
#### 2.8.1 Session auth API (LIVE)
|
||
|
||
Base: `environment.authApiUrl` (= `https://api.dexarmarket.ru:445`). Source:
|
||
`src/app/services/telegram-session-api.service.ts`. Narrative flow in
|
||
`docs/AUTHENTICATION.md` §1.
|
||
|
||
| Endpoint | Method | Auth | Body / Headers | Response |
|
||
|---|---|---|---|---|
|
||
| `/users/sessions` | POST | none | body `{ webSessionID }` (client-generated GUID) + header `WebSessionID: <same guid>` | `{ webSessionID, url }` — normalized to `WebSessionStart`; `url` = Telegram deep link |
|
||
| `/users/sessions/{id}` | GET | none | — | Session object, field-tolerant → normalized `AuthSession` |
|
||
| `/users/sessions/{id}` | DELETE | none | header `WebSessionID: <id>` | ignored (frontend clears local state regardless) |
|
||
|
||
TypeScript interfaces (`src/app/models/auth.model.ts`):
|
||
|
||
```ts
|
||
export interface AuthSession {
|
||
sessionId: string;
|
||
userId: number | null;
|
||
username: string | null;
|
||
displayName: string;
|
||
active: boolean;
|
||
expires: string; // ISO 8601
|
||
}
|
||
|
||
export interface WebSessionStart {
|
||
webSessionID: string;
|
||
url: string;
|
||
}
|
||
```
|
||
|
||
Example — create session:
|
||
|
||
```http
|
||
POST https://api.dexarmarket.ru:445/users/sessions
|
||
WebSessionID: 3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11
|
||
Content-Type: application/json
|
||
|
||
{ "webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11" }
|
||
```
|
||
|
||
```json
|
||
{
|
||
"webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11",
|
||
"url": "https://t.me/myAMLKYCBOT?start=3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11"
|
||
}
|
||
```
|
||
|
||
Example — poll session (active):
|
||
|
||
```http
|
||
GET https://api.dexarmarket.ru:445/users/sessions/3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11
|
||
```
|
||
|
||
```json
|
||
{
|
||
"webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11",
|
||
"status": "active",
|
||
"user": { "id": 8823771, "username": "buyer_ivan", "firstName": "Ivan", "lastName": "P" },
|
||
"expiresAt": "2026-07-26T05:00:00Z"
|
||
}
|
||
```
|
||
|
||
The frontend reads fields **field-tolerantly** (accepts many casings/aliases —
|
||
see `normalizeWebSession`, and `docs/AUTHENTICATION.md` §1.3 for the full alias
|
||
priority lists). A backend should send a real `expiresAt`/`expires` (else the
|
||
frontend fabricates `now + 3600s`).
|
||
|
||
#### 2.8.2 Marketplace / storefront API (LIVE)
|
||
|
||
Base: `ApiConfigService.getBaseUrl()` (tenant-resolved; `/api` on localhost,
|
||
else `https://{tenant}.api.dexarmarket.ru:445` / `https://api.dexarmarket.ru:445`).
|
||
Source: `src/app/services/api.service.ts` (`ApiService`). Headers attached by
|
||
`apiHeadersInterceptor`: `X-Region`, `X-Language` (RU/EN/AM), `Currency`
|
||
(default RUB), `WebSessionID`. The primary product wire DTO is `Item`
|
||
(`src/app/models/item.model.ts`), reconciled by the large tolerant
|
||
`normalizeItem()` adapter (BACKEND-AUDIT §7 — treat it as the tolerance
|
||
contract). This domain's full per-endpoint catalog belongs to the CRUD-domain
|
||
section; summarized here as the framework anchor:
|
||
|
||
| Endpoint | Method | Auth | Params / Body | Response |
|
||
|---|---|---|---|---|
|
||
| `/ping` | GET | session headers | — | `{ message }` |
|
||
| `/category` | GET | session headers | — | `Category[]` (normalized) |
|
||
| `/category/{id}` | GET | session headers | `count`, `skip` | `Item[]` |
|
||
| `/items/{id}` | GET | session headers | — | `Item` |
|
||
| `/searchitems` | GET | session headers | `search`, `count`, `skip`, `categoryIDs?`, `minPrice?`, `maxPrice?`, `tag?`, `sort?` | `{ items: Item[], total: number }` |
|
||
| `/items/randomitems` | GET | session headers | `count`, `category?` | `Item[]` (featured/random) |
|
||
| `/websession/{sessionId}` | POST | session headers | item array | cart echo |
|
||
| `/items/{id}/callback` | POST | session headers | `{ rating, comment, sessionID, timestamp }` | `{ message }` (review) |
|
||
| `/items/{id}/questiion` | POST | session headers | `{ question, sessionID, timestamp }` | `{ message }` (question — **literal typo `questiion` matches backend spec**) |
|
||
| `/purchase-email` | POST | session headers | `{ email, phone?, telegramUserId, items[] }` | `{ message }` |
|
||
|
||
#### 2.8.3 Cart / order / payment API (LIVE)
|
||
|
||
Order/cart-payment endpoints on the **marketplace base**; QR/card status on the
|
||
**payment base** `environment.qrApiUrl` (= `https://qr.vitanova.network/api`).
|
||
Source: `src/app/services/api.service.ts`. DTOs are inline in that file.
|
||
|
||
| Endpoint | Method | Base | Auth | Body | Response |
|
||
|---|---|---|---|---|---|
|
||
| `/cart` | POST | marketplace | session headers | `CartPaymentRequest` | `QrCreateResponse` |
|
||
| `/orders` | POST | marketplace | session headers | `CreateOrderRequest` | `CreateOrderResponse` (fire-and-forget after payment) |
|
||
| `/qr` | POST | `qrApiUrl` | headers `authorization-key`, `userid-value` | `QrCreateRequest` | `QrCreateResponse` |
|
||
| `/qr/dynamic/{partnerId}/{qrId}` | GET | `qrApiUrl` | — | `QrDynamicStatusResponse` |
|
||
| `/card/{partnerId}/{orderId}` | GET | `qrApiUrl` | — | `QrDynamicStatusResponse` |
|
||
|
||
Const `partnerId` = `web-97ec-9c57-4dde-9037-3a68f7f83750`
|
||
(`ApiService.cartPaymentPartnerId`). Key interfaces (copied from
|
||
`api.service.ts`):
|
||
|
||
```ts
|
||
export interface CartPaymentRequest {
|
||
amount: number;
|
||
currency: 'RUB';
|
||
siteuserID: string;
|
||
siteorderID: string;
|
||
redirectUrl: string;
|
||
telegramUsername: string;
|
||
paymentMethod: 'qr' | 'card';
|
||
qrDescription?: string;
|
||
customerID?: string;
|
||
items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[] }>;
|
||
}
|
||
|
||
export interface CreateOrderRequest {
|
||
items: Array<{ productId: string; name: string; quantity: number; price: number }>;
|
||
customer: { name: string; email: string; phone: string };
|
||
payment?: { method: string; currency: string };
|
||
shipping?: { address: string; method: string; trackingNumber: string };
|
||
}
|
||
|
||
export interface CreateOrderResponse {
|
||
id: string;
|
||
orderNumber: string;
|
||
status: string;
|
||
total: number;
|
||
currency: string;
|
||
}
|
||
|
||
export interface QrCreateResponse {
|
||
qrId?: string; qrID?: string; nspkID?: string; nspkId?: string;
|
||
nspkurl?: string; orderID?: string; url?: string; bankUrl?: string;
|
||
status?: string; qrStatus?: string; qrExpirationDate?: string; qrTTL?: number;
|
||
payload?: string; Payload?: string; qrUrl?: string;
|
||
partnerqrID?: string | number; partnerID?: string | number;
|
||
partnerId?: string | number; PartnerID?: string | number;
|
||
}
|
||
|
||
export interface QrDynamicStatusResponse {
|
||
additionalInfo: string; paymentPurpose: string; amount: number; code: string;
|
||
createDate: string; currency: string; order: string; status: string;
|
||
qrId: string; transactionDate: string; transactionId: number; qrExpirationDate: string;
|
||
}
|
||
```
|
||
|
||
The `QrCreateResponse` is deliberately alias-tolerant (many casings for id /
|
||
url / partner fields) — the frontend resolves the effective id/link via
|
||
`resolvePaymentQrId()` / `resolvePaymentLink()` / `resolveBankPaymentUrl()`. A
|
||
backend can pick one canonical casing; the frontend will still read it.
|
||
|
||
Example — create cart payment:
|
||
|
||
```http
|
||
POST https://api.dexarmarket.ru:445/cart
|
||
WebSessionID: 3f1c2a0e-…
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"amount": 4990,
|
||
"currency": "RUB",
|
||
"siteuserID": "8823771",
|
||
"siteorderID": "order-2026-0007",
|
||
"redirectUrl": "https://marketplace.local/checkout/done",
|
||
"telegramUsername": "buyer_ivan",
|
||
"paymentMethod": "qr",
|
||
"items": [{ "itemID": 101, "price": 4990, "name": "Wireless Keyboard", "quantity": 1 }]
|
||
}
|
||
```
|
||
|
||
```json
|
||
{ "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" }
|
||
```
|
||
|
||
#### 2.8.4 Widget manifest (LIVE)
|
||
|
||
Source: `src/app/widgets/registry/widget-manifest.service.ts`. URL comes from
|
||
`bootstrap.widgetRegistry.manifestUrl`, falling back to
|
||
`/assets/mock/bootstrap/widget-manifest.json`.
|
||
|
||
| Endpoint | Method | Auth | Response |
|
||
|---|---|---|---|
|
||
| `<bootstrap.widgetRegistry.manifestUrl>` | GET | none | `WidgetManifestFile` |
|
||
|
||
On any error it falls back to `of({ widgets: [] })` (never throws to the UI).
|
||
Interface (`src/app/widgets/contracts/widget-manifest.contract.ts`):
|
||
|
||
```ts
|
||
export interface WidgetManifestFile {
|
||
widgets: WidgetManifestEntry[];
|
||
}
|
||
|
||
export interface WidgetManifestEntry {
|
||
type: string;
|
||
version: string;
|
||
componentKey: string;
|
||
supportedLayouts: WidgetLayoutSupport[]; // 'stack'|'grid'|'hero'|'carousel'|'split'
|
||
supportedDataSources: WidgetDataSourceName[]; // 'featured'|'latest'|'category'|'manual'|'related'|'future'|'root'|'parent'
|
||
settingsSchema: WidgetSettingsSchema; // { type: 'object', properties, required? }
|
||
defaultSettings: Record<string, unknown>;
|
||
metadataSupport?: WidgetMetadataSupport;
|
||
enabled?: boolean;
|
||
}
|
||
```
|
||
|
||
Example response:
|
||
|
||
```json
|
||
{
|
||
"widgets": [
|
||
{
|
||
"type": "hero",
|
||
"version": "1.0.0",
|
||
"componentKey": "HeroWidgetComponent",
|
||
"supportedLayouts": ["hero"],
|
||
"supportedDataSources": ["manual"],
|
||
"settingsSchema": { "type": "object", "properties": { "title": { "type": "string" } } },
|
||
"defaultSettings": { "title": "Welcome" },
|
||
"enabled": true
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
#### 2.8.5 Regions / location (LIVE)
|
||
|
||
Source: `src/app/services/location.service.ts`.
|
||
|
||
| Endpoint | Method | Base | Auth | Response |
|
||
|---|---|---|---|---|
|
||
| `/regions` | GET | marketplace | session headers | `Region[]` |
|
||
|
||
On error, falls back **silently** to 6 hardcoded regions (moscow, spb, yerevan,
|
||
minsk, almaty, tbilisi) — no user-visible error. The selected region's `id`
|
||
feeds the `X-Region` header on subsequent marketplace calls. (An external
|
||
geo-IP call `GET http://ip-api.com/json/...` is used for auto-detect — external,
|
||
not a platform backend.) Interface (`src/app/models/location.model.ts`):
|
||
|
||
```ts
|
||
export interface Region {
|
||
id: string;
|
||
city: string;
|
||
country: string;
|
||
countryCode: string;
|
||
timezone?: string;
|
||
}
|
||
```
|
||
|
||
Example response:
|
||
|
||
```json
|
||
[
|
||
{ "id": "moscow", "city": "Москва", "country": "Россия", "countryCode": "RU", "timezone": "Europe/Moscow" },
|
||
{ "id": "yerevan", "city": "Ереван", "country": "Армения", "countryCode": "AM", "timezone": "Asia/Yerevan" }
|
||
]
|
||
```
|
||
|
||
### 2.10 API path versioning
|
||
|
||
No endpoint the frontend calls includes a version segment (no `/v1/`, no
|
||
`Accept-Version`/`Api-Version` header). Every domain path in this document is
|
||
relative to `{base}` = `ApiConfigService.getBaseUrl()` (tenant-resolved; see
|
||
§1.9) with no version component anywhere in that resolution chain
|
||
(`tenant.apiBaseUrl` from bootstrap, or `/api` on localhost — §1.3, §1.9).
|
||
|
||
The only version field in the entire contract is `BootstrapConfig.schemaVersion`
|
||
(§1.4), which versions the **bootstrap payload shape**, not the API surface —
|
||
it's checked for presence only, not semantically enforced, and doesn't apply to
|
||
any other endpoint.
|
||
|
||
> **Requires backend decision:** whether the API surface gets a versioning
|
||
> scheme at all (URL path segment, header, or none/evergreen-only), and if so
|
||
> whether it's introduced from day one or deferred until the first breaking
|
||
> change. No frontend code currently assumes or constructs a version segment,
|
||
> so either choice is a pure backend/infra decision with no frontend rework
|
||
> unless breaking changes are introduced later.
|
||
|
||
### 2.11 Real-time / WebSocket
|
||
|
||
No WebSocket, Server-Sent Events, or other push channel exists anywhere in the
|
||
frontend. Every case that looks "live" is client-side polling on a plain
|
||
`setInterval`/RxJS `interval`, hitting a normal request/response endpoint:
|
||
|
||
| What | Mechanism | Where |
|
||
|---|---|---|
|
||
| QR/Telegram login session state | Poll `GET` session-status endpoint until `active`/expired (§4 Mechanism A, `QrLoginEngine`) | Auth |
|
||
| Cart QR/card payment status | Poll cart payment-status endpoint, bounded by response `qrTTL` (min 60s) | §3.3 Orders / payments |
|
||
| Session/token validity | Re-checked on next request or next refresh-interval tick — no push invalidation. A backend-side revocation isn't observed by an already-open session until the refresh interval elapses (§4.8 Session invalidation) | Auth |
|
||
| Maintenance-notice banner (proposed) | Would be a polling `GET /maintenance-notice` endpoint, not a push channel (§10.7) | Maintenance mode |
|
||
| Admin monitoring queue/webhook status (proposed, mock-only today) | Would be plain `GET` on facade refresh, no proposal anywhere for push (§3.19) | Monitoring |
|
||
|
||
> **Requires backend decision:** whether any of the above should become
|
||
> push-based (WebSocket/SSE) instead of polling — most relevant to payment
|
||
> status (customer-facing latency) and session revocation (security: a
|
||
> revoked admin session stays technically usable client-side for up to one
|
||
> refresh interval, §4.8). No frontend code exists for a push channel today,
|
||
> so adding one is net-new frontend work, not a swap.
|
||
|
||
### 2.12 Consolidated "Requires backend decision" items (this section)
|
||
|
||
- Bootstrap: `X-Language`/`Accept-Language` handling on `GET /bootstrap` (§1.1).
|
||
- Bootstrap: whether `schemaVersion` is enforced vs presence-only (§1.4).
|
||
- Bootstrap: ETag / conditional-request strategy — none exists today (§1.8).
|
||
- Bootstrap: the entire draft→publish write path + save-time version/concurrency
|
||
check — no write endpoint exists (§1.10).
|
||
- Bootstrap: dedicated fatal-bootstrap-failure UX vs silent degradation (§1.11).
|
||
- Framework: whether to introduce a success envelope (breaking for all live
|
||
callers) — none exists today (§2.1).
|
||
- Framework: pagination page-size limits/max; offset vs page-number
|
||
standardization; cursor vs offset (nothing implies cursors) (§2.2).
|
||
- Framework: reconcile catalog-config sort vocabulary (7) vs search-endpoint
|
||
`sort` vocabulary (5); wire encoding for admin list sorting (§2.3).
|
||
- Framework: query-string encoding for admin list filters and the `'all'`
|
||
sentinel (§2.4).
|
||
- Framework: server-side autocomplete/trending search endpoints + min-query
|
||
contract (§2.5).
|
||
- Framework: API path/header versioning scheme, if any — none exists today (§2.10).
|
||
- Framework: whether payment-status polling and session revocation should
|
||
become push-based (WebSocket/SSE) instead of polling (§2.11).
|
||
|
||
---
|
||
|
||
## 3. CRUD Contracts
|
||
|
||
Exhaustive per-domain, per-endpoint contract for every backend touch-point the
|
||
Angular frontend expects. Derived from source on branch `B2B` and cross-checked
|
||
against `docs/context/BACKEND-AUDIT.md` (the this-session audit — the ground
|
||
truth for what code actually does), the prior `docs/archive/BACKEND_API.md`, and the two
|
||
sibling specs written this session:
|
||
|
||
- **Auth / headers / JWT** — see `docs/AUTHENTICATION.md`. This section never
|
||
re-defines the auth header format; it references it.
|
||
- **Error response envelope + per-status semantics** — see
|
||
`docs/ERROR_CONTRACT.md`. This section names *which* statuses apply per
|
||
endpoint and *why*, but the wire shape of the error body is owned by that doc.
|
||
|
||
### 3.0 Conventions used in this section
|
||
|
||
**Two API base URLs are in play** (`BACKEND-AUDIT.md` §2):
|
||
|
||
- **Marketplace / tenant API** — `ApiConfigService.getBaseUrl()`
|
||
(`src/app/core/config/api-config.service.ts`). Localhost → `/api`; otherwise a
|
||
per-tenant origin (default `https://api.dexarmarket.ru:445`). Tenant isolation
|
||
is **by subdomain/base-URL only** — no `X-Tenant` header, no `/tenant/{id}`
|
||
prefix (`AUTHENTICATION.md` §10). All storefront reads and all admin CRUD go
|
||
here.
|
||
- **Payment / QR API** — `environment.qrApiUrl` (`https://qr.vitanova.network/api`).
|
||
Only cart/order payment + status polling (§3.3).
|
||
- **Session-auth API** — `environment.authApiUrl`. Login/session only, covered by
|
||
`docs/AUTHENTICATION.md`, not repeated here.
|
||
|
||
**LIVE vs PROPOSED paths.** Only the storefront catalog/cart/engagement reads and
|
||
the single `AdminCategoriesApiGateway` have **literal HTTP paths in code**. Every
|
||
other admin CRUD path below is **PROPOSED** — no URL literal exists anywhere in
|
||
the frontend for it (`BACKEND-AUDIT.md` §24: "No literal `/admin/*`, `/builder/*`,
|
||
or per-admin-domain backoffice CRUD paths exist in code"). The admin gateways are
|
||
in-memory `*LocalGateway` classes that never construct a URL. The proposals here
|
||
follow the **one real precedent** in the codebase — `AdminCategoriesApiGateway`
|
||
(`src/app/features/admin/categories/services/admin-categories-api.gateway.ts`),
|
||
which uses:
|
||
|
||
```
|
||
GET {base}/backoffice/categories ?search&visibility&includeDeleted
|
||
GET {base}/backoffice/categories/{id}
|
||
POST {base}/backoffice/categories (body = category minus server-owned fields)
|
||
PUT {base}/backoffice/categories/{id}
|
||
DELETE {base}/backoffice/categories/{id} (soft delete)
|
||
POST {base}/backoffice/categories/{id}/restore
|
||
GET {base}/backoffice/categories/slug-taken ?slug&excludingId
|
||
```
|
||
|
||
All other admin paths below are proposed by analogy to this shape (`{base}/backoffice/<domain>`).
|
||
Builder/config domains (nav, footer, branding, languages, CMS, homepage, widgets)
|
||
are proposed under `{base}/builder/...` because the bootstrap document declares a
|
||
`apiEndpoints.builder` record for exactly this, but **no builder write call exists
|
||
in code today** — the entire builder/CMS surface is LOCAL-ONLY (in-memory
|
||
bootstrap + localStorage drafts), so those endpoints are marked **PROPOSED /
|
||
FUTURE** throughout.
|
||
|
||
**Headers (all requests).** Marketplace API requests carry `X-Region`,
|
||
`X-Language` (`RU|EN|AM`), `Currency` (default `RUB`), and `WebSessionID`
|
||
(`apiHeadersInterceptor`, `BACKEND-AUDIT.md` §3). Requests whose URL contains
|
||
`/admin/`, `/backoffice/`, `/builder/`, or `/media/` **additionally** get
|
||
`AdminWebSessionID` and, if an admin token is stored, `Authorization: Bearer
|
||
<token>` (`adminAuthHeadersInterceptor`). These header names/values are owned by
|
||
`AUTHENTICATION.md` — assume them on every admin endpoint below unless stated.
|
||
|
||
**JWT requirement.** No endpoint in the app requires a verified JWT **today**.
|
||
Mechanism A (Telegram session) authenticates admins with an opaque
|
||
`AdminWebSessionID`, not a JWT; the Ed25519 JWT flow (Mechanism B) is fully wired
|
||
but dormant (`AUTHENTICATION.md` §2). For every admin endpoint below, "JWT
|
||
requirement" is therefore stated as: **Today: `AdminWebSessionID` session header
|
||
(Mechanism A). Target: `Authorization: Bearer <Ed25519 JWT>` once Mechanism B is
|
||
cut over.** Server-side authorization must be enforced regardless of any
|
||
client-side guard (`AUTHENTICATION.md` §11.6).
|
||
|
||
**Required permission / role.** The frontend's only permission model is the
|
||
coarse `ROLE_PERMISSIONS` table (`AUTHENTICATION.md` §9.1): `backoffice.read`,
|
||
`backoffice.write`, `builder.read`, `builder.write`, `users.manage`,
|
||
`settings.manage`. The frontend does **not** enforce per-domain permissions on
|
||
admin CRUD today (the live `adminAuthGuard` only checks "is there an admin
|
||
session at all"). Where a specific permission is the obvious fit it is named as
|
||
**Proposed**; any finer per-role assignment is **Requires backend decision**
|
||
(`AUTHENTICATION.md` §9.1 already flags fine-grained permissions as out of scope).
|
||
|
||
**Error responses.** Unless a domain-specific note says otherwise, every endpoint
|
||
below can return the envelope from `ERROR_CONTRACT.md` with these statuses:
|
||
`401 UNAUTHENTICATED` (no/expired session), `403 FORBIDDEN` (wrong role/tenant),
|
||
`500 INTERNAL_ERROR`, `503 SERVICE_UNAVAILABLE`/`MAINTENANCE_MODE`. Per-endpoint
|
||
notes below add `404`, `409`, `422`, `429` only where they are meaningful. **The
|
||
frontend does not parse the error body today** (`ERROR_CONTRACT.md` "Finding") —
|
||
admin list pages collapse every non-2xx into one generic "retry" state
|
||
(`ERROR_CONTRACT.md` "Generic list-page error UI"). So all per-status handling
|
||
below is the *contract the backend should honor*, not behavior the current UI
|
||
distinguishes.
|
||
|
||
**Validation rules.** Admin create/edit forms
|
||
(`admin-product-form.component.ts`, `admin-category-form.component.ts`) use **no
|
||
Angular reactive `Validators`** — confirmed by source search (zero
|
||
`Validators.*` matches). Form state is built by factories
|
||
(`admin-products-form.factory.ts`, `admin-categories-form.factory.ts`) that seed
|
||
empty defaults (e.g. `slug: ''`). Therefore **the frontend enforces essentially
|
||
no field validation** on admin CRUD; every validation rule below beyond "the
|
||
field exists in the model / is non-nullable in TS" is **Requires backend
|
||
decision**. The one real client-side validation convention lives in the builder
|
||
(`ProjectValidator` → `ProjectEditorFacade.fieldError()`), which is client-only
|
||
and localStorage-scoped, not backend-fed.
|
||
|
||
**Pagination.** The admin list models that paginate use a uniform shape:
|
||
`filters { …, page: number, pageSize: number }` in, `{ items[], total, page,
|
||
pageSize }` out (products, orders, transactions, reviews). Categories, users,
|
||
monitoring, customers, analytics return **plain arrays with no pagination** —
|
||
noted per domain.
|
||
|
||
---
|
||
|
||
### 3.1 Products
|
||
|
||
Two distinct surfaces: **storefront catalog read** (LIVE) and **admin CRUD**
|
||
(MOCK-ONLY, no seam — `BACKEND-AUDIT.md` §14).
|
||
|
||
#### 3.1.a Storefront catalog read — LIVE
|
||
|
||
Backed by `ApiService` (`src/app/services/api.service.ts`) via
|
||
`ApiProductDataProvider` → `PRODUCT_DATA_PROVIDER` → `ProductFacade`. The wire
|
||
DTO is `Item` (`src/app/models/item.model.ts`); `ApiService.normalizeItem()` is
|
||
the tolerance contract (the single largest inline mapper — a backend engineer
|
||
should treat it as authoritative for accepted field name variants; see
|
||
`BACKEND-AUDIT.md` §7).
|
||
|
||
| # | Method | Path (LIVE literal) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| P1 | GET | `{base}/items/{itemID}` | Single product | `200` single `Item` (raw wire shape) |
|
||
| P2 | GET | `{base}/category/{categoryID}?count&skip` | Products in a category | `200` `Item[]` |
|
||
| P3 | GET | `{base}/searchitems?search&count&skip[&categoryIDs&minPrice&maxPrice&tag&sort]` | Search | `200` `{ items: Item[], total: number }` |
|
||
| P4 | GET | `{base}/items/randomitems?count[&category]` | Featured/random | `200` `Item[]` |
|
||
|
||
- **Headers/JWT:** marketplace headers (§3.0). No JWT; anonymous `WebSessionID`
|
||
is sufficient. Public read.
|
||
- **Query params (P2/P3/P4):** `count` (default 50), `skip` (default 0, offset
|
||
pagination). P3 search: `search` (string, required), `categoryIDs`
|
||
(comma-joined numeric ids), `minPrice`, `maxPrice` (numbers), `tag` (string),
|
||
`sort` ∈ `relevance | price_asc | price_desc | popular | rating`. Signature:
|
||
`ApiService.searchItems()` lines 551-590.
|
||
- **Pagination/sorting/filtering:** offset-based (`count`/`skip`); sort + price
|
||
+ category + tag filters only on P3. P2 has no sort/filter params. Response P3
|
||
is the only one returning a `total` for page math.
|
||
- **Error responses:** `404` when `itemID` doesn't exist (P1) — but note the
|
||
frontend does **not** distinguish 404 from any other error today
|
||
(`ERROR_CONTRACT.md` §404); a deleted product and a 500 render the same generic
|
||
empty-state. `500/503` generic. Calls retry x2 with exponential backoff
|
||
(`ApiService.retryConfig`).
|
||
- **Validation:** none (read).
|
||
- **Example P3 request:** `GET {base}/searchitems?search=phone&count=24&skip=0&categoryIDs=3,7&minPrice=1000&sort=price_asc`
|
||
- **Example P3 response:**
|
||
```json
|
||
{ "items": [ { "id": "1024", "name": "Phone X", "price": 62560, "currency": "RUB", "imgs": ["https://…/x.webp"], "remaining": 12 } ], "total": 87 }
|
||
```
|
||
|
||
**`Item` variants/options (part of P1 payload, not separate endpoints).**
|
||
`normalizeItem` accepts `variantOptions[]` (`{ key, label, labels?, options: [{
|
||
value, label?, labels?, available? }] }`) and `specificationGroups[]`. The admin
|
||
side (§3.1.b) models variants as a flat priced list (`AdminProductVariant`). There
|
||
is **no dedicated variants/options CRUD endpoint** — variants are created/updated
|
||
as part of the product create/update body. Flagged: variant shape differs between
|
||
storefront (`variantOptions` grouped) and admin (`variants` flat priced rows) —
|
||
reconciliation is **Requires backend decision** (adapter lives in
|
||
`admin-product-form.factory.ts variantsToBackendRows/variantsFromBackendRows`).
|
||
|
||
#### 3.1.b Admin products CRUD — PROPOSED (MOCK-ONLY today, no DI seam)
|
||
|
||
Contract: `AdminProductsGateway`
|
||
(`src/app/features/admin/products/services/admin-products-gateway.interface.ts`).
|
||
Impl today: `AdminProductsLocalGateway` (in-memory + localStorage), injected
|
||
**concretely** by `AdminProductsFacade` — no token, so a backend requires
|
||
introducing a DI seam first (`BACKEND-AUDIT.md` §14, key finding). DTO:
|
||
`AdminProduct` (`src/app/features/admin/products/models/admin-product.model.ts`,
|
||
41-field interface incl. `media`, `variants`, `variantAttributes`, `specifications`,
|
||
`attributes`, `translations`, `seo`, `badges`, flags, `reviews`, `questions`).
|
||
|
||
| # | Method | Path (PROPOSED) | Gateway method | Success |
|
||
|---|---|---|---|---|
|
||
| AP1 | GET | `{base}/backoffice/products?search&categoryId&visibility&stock&includeArchived&sort&page&pageSize` | `loadProducts(filters)` | `200 AdminProductsListResult` |
|
||
| AP2 | GET | `{base}/backoffice/products/{id}` | `loadProduct(id)` | `200 AdminProduct` / `404` |
|
||
| AP3 | GET | `{base}/backoffice/products/categories` | `loadCategories()` | `200 AdminProductCategoryOption[]` (`{id,title}`) |
|
||
| AP4 | POST | `{base}/backoffice/products` | `createProduct(product)` | `201 AdminProduct` |
|
||
| AP5 | PUT | `{base}/backoffice/products/{id}` | `updateProduct(product)` | `200 AdminProduct` |
|
||
| AP6 | DELETE | `{base}/backoffice/products/{id}` | `deleteProduct(id)` | `204` |
|
||
| AP7 | POST | `{base}/backoffice/products/{id}/duplicate` | `duplicateProduct(id)` | `201 AdminProduct` / `404` |
|
||
| AP8 | POST | `{base}/backoffice/products/{id}/archive` | `archiveProduct(id)` | `204` |
|
||
| AP9 | POST | `{base}/backoffice/products/{id}/restore` | `restoreProduct(id)` | `200 AdminProduct` / `404` |
|
||
|
||
- **Headers/JWT:** admin auth (§3.0). Today `AdminWebSessionID`; target Bearer JWT.
|
||
- **Permission (Proposed):** read (AP1-AP3) → `backoffice.read`; writes
|
||
(AP4-AP9) → `backoffice.write`. Finer than that = Requires backend decision.
|
||
- **AP1 query params** (from `AdminProductListFilters`): `search` (string),
|
||
`categoryId` (string|null), `visibility` ∈ `all|visible|hidden`, `stock` ∈
|
||
`all|in_stock|low_stock|out_of_stock`, `includeArchived` (bool), `sort` ∈
|
||
`title|price|priority|stock|updated`, `page` (number), `pageSize` (number).
|
||
This is the full pagination + sort + filter + search surface — no other
|
||
sort keys exist. Response `AdminProductsListResult { items, total, page, pageSize }`.
|
||
- **AP4/AP5 request body:** an `AdminProduct`. By the category precedent
|
||
(`AdminCategoriesApiGateway.createCategory` strips `id, itemsCount, deletedAt,
|
||
createdAt, updatedAt`), the create body should **omit server-owned fields**
|
||
(`id`, `createdAt`, `updatedAt`; also `reviews`/`questions` which are derived).
|
||
Update sends the full object with `id` in the path. **Requires backend
|
||
decision:** exact server-owned field list for products (the frontend local
|
||
gateway does not enforce one).
|
||
- **Validation:** none client-side (no `Validators`). Model non-nullable fields
|
||
(`name`, `slug`, `sku`, `categoryId`, `price`, `currency`, `quantity`) are the
|
||
only implicit "required" signal. Uniqueness of `slug`/`sku`, price ≥ 0, etc.
|
||
are all **Requires backend decision** (no `isSlugTaken` equivalent exists for
|
||
products, unlike categories). Recommended `422 VALIDATION_FAILED` with
|
||
`details[].field` for `sku`/`slug`/`price`.
|
||
- **Error responses:** `404` (AP2/AP5-AP9 unknown id), `409 CONFLICT`
|
||
(duplicate slug/sku on AP4/AP5 — Requires backend decision, no proactive
|
||
pre-check exists), `422` (field validation), plus the common set.
|
||
- **Example AP4 request (trimmed):**
|
||
```json
|
||
{ "name": "Phone X", "slug": "phone-x", "sku": "PHN-X-001", "barcode": "", "brand": "Acme", "categoryId": "3", "visible": true, "archived": false, "priority": 0, "price": 62560, "discount": 0, "currency": "RUB", "quantity": 100, "stockStatus": "in_stock", "media": { "images": [], "gallery": [], "videos": [] }, "specifications": [], "attributes": [], "variantAttributes": [], "variants": [], "relatedProductIds": [], "translations": {}, "seo": { "metaTitle": "", "metaDescription": "", "keywords": "" }, "badges": [] }
|
||
```
|
||
- **Example AP1 response:**
|
||
```json
|
||
{ "items": [ { "id": "p_1024", "name": "Phone X", "slug": "phone-x", "sku": "PHN-X-001", "categoryId": "3", "price": 62560, "currency": "RUB", "quantity": 100, "stockStatus": "in_stock", "visible": true, "archived": false } ], "total": 213, "page": 1, "pageSize": 20 }
|
||
```
|
||
|
||
---
|
||
|
||
### 3.2 Categories
|
||
|
||
Three surfaces: storefront read (LIVE, legacy + clean stack), and admin CRUD
|
||
(the **only** admin domain with a real HTTP impl).
|
||
|
||
#### 3.2.a Storefront category read — LIVE
|
||
|
||
| # | Method | Path (LIVE literal) | Backed by | Success |
|
||
|---|---|---|---|---|
|
||
| C1 | GET | `{base}/category` | `ApiService.getCategories()` (legacy) and `ApiCategoryRepository` (clean stack, `CATEGORY_REPOSITORY`, retry x2) | `200` array of category wire objects |
|
||
|
||
- Two parallel stacks read the same endpoint (`BACKEND-AUDIT.md` §8). Legacy
|
||
wire shape: `Category`/`Subcategory` (`src/app/models/category.model.ts`) via
|
||
`normalizeCategory()`. Clean wire DTO: `CategoryDto`/`CategoryNameDto`
|
||
(`src/app/core/categories/dto/category.dto.ts`) via `CategoryMapper`. Both
|
||
tolerate `names[]` (multi-lang, incl. the `valuue` typo variant), nested
|
||
`subcategories[]`, `img`↔`icon`, string↔numeric ids.
|
||
- **Headers/JWT:** marketplace headers, no JWT, public.
|
||
- **Response `CategoryDto` fields:** `categoryID?`, `parentID?`, `name?`,
|
||
`icon?`, `img?`, `priority?`, `visible?`, `categoriesCount?`, `itemCount?`,
|
||
`names?: [{ language, value|valuue }]`, `subcategories?: CategoryDto[]`.
|
||
- **Error responses:** `500/503` → falls through to empty categories (clean
|
||
stack retries twice first). No 404 (collection endpoint).
|
||
- **Example response:**
|
||
```json
|
||
[ { "categoryID": 3, "parentID": 0, "name": "Phones", "icon": "https://…/phones.svg", "priority": 10, "visible": true, "itemCount": 213, "names": [ { "language": "RU", "value": "Телефоны" }, { "language": "EN", "value": "Phones" } ], "subcategories": [] } ]
|
||
```
|
||
- **Note (`BACKEND-AUDIT.md` §8):** two different `Category` TS types exist —
|
||
flag for backend/naming reconciliation.
|
||
|
||
#### 3.2.b Admin categories CRUD — LIVE (real HTTP gateway exists)
|
||
|
||
Contract: `AdminCategoriesGateway`. **Real impl:** `AdminCategoriesApiGateway`
|
||
(literal paths below — the canonical precedent for all other admin domains).
|
||
Mock impl: `AdminCategoriesLocalGateway`. Bound via `ADMIN_CATEGORIES_GATEWAY`
|
||
token (MOCK-SWAPPABLE — this domain *can* be rebound to the real gateway today).
|
||
DTO: `AdminCategory` (`src/app/features/admin/categories/models/admin-category.model.ts`).
|
||
|
||
| # | Method | Path (LIVE literal) | Gateway method | Success |
|
||
|---|---|---|---|---|
|
||
| AC1 | GET | `{base}/backoffice/categories?search&visibility&includeDeleted` | `loadCategories(filters)` | `200 AdminCategory[]` |
|
||
| AC2 | GET | `{base}/backoffice/categories/{id}` | `loadCategory(id)` | `200 AdminCategory` / `null` on error |
|
||
| AC3 | POST | `{base}/backoffice/categories` | `createCategory(cat)` | `201 AdminCategory` |
|
||
| AC4 | PUT | `{base}/backoffice/categories/{id}` | `updateCategory(cat)` | `200 AdminCategory` |
|
||
| AC5 | DELETE | `{base}/backoffice/categories/{id}` | `deleteCategory(id)` | `204` (**soft** delete) |
|
||
| AC6 | POST | `{base}/backoffice/categories/{id}/restore` | `restoreCategory(id)` | `200 AdminCategory` / `null` |
|
||
| AC7 | GET | `{base}/backoffice/categories/slug-taken?slug&excludingId` | `isSlugTaken(slug,excludingId)` | `200 { taken: boolean }` |
|
||
|
||
- **Headers/JWT:** admin auth (§3.0).
|
||
- **Permission (Proposed):** AC1/AC2/AC7 → `backoffice.read`; AC3-AC6 →
|
||
`backoffice.write`.
|
||
- **AC1 query params** (`AdminCategoryListFilters`): `search` (string),
|
||
`visibility` ∈ `all|visible|hidden`, `includeDeleted` (bool sent as string).
|
||
**No pagination** — returns a plain array. No sort param.
|
||
- **AC3 request body:** `AdminCategory` **minus** `id, itemsCount, deletedAt,
|
||
createdAt, updatedAt` (server-owned — stripped by the real gateway, line 38).
|
||
AC4 sends the full object, `id` in path.
|
||
- **AC7:** proactive slug-uniqueness pre-check the frontend calls *before*
|
||
submit (`excludingId` omitted on create, set to the editing id on update). This
|
||
is the only proactive conflict check in the whole admin surface. On network
|
||
error it returns `false` (best-effort, TOCTOU-prone — `ERROR_CONTRACT.md` §409).
|
||
- **Validation:** slug uniqueness via AC7; everything else none client-side.
|
||
`title`, `slug` are non-nullable in the model. Recommended `409 CONFLICT` on
|
||
AC3/AC4 slug collision as a race backstop (`ERROR_CONTRACT.md` §409), and
|
||
`422` for field errors.
|
||
- **Error responses:** AC2/AC6 swallow errors → `null` (so a `404` there renders
|
||
as "not found"/empty, not an error screen). AC5 hard `404` if unknown. `409`
|
||
slug conflict. Common set otherwise.
|
||
- **Example AC3 request:**
|
||
```json
|
||
{ "parentId": null, "title": "Phones", "slug": "phones", "description": "", "icon": "", "imageUrl": "", "imageAlt": "", "order": 0, "visible": true, "status": "published", "translations": {}, "seo": { "metaTitle": "", "metaDescription": "", "keywords": "" }, "attributes": [] }
|
||
```
|
||
- **Example AC7 response:** `{ "taken": true }`
|
||
- **Note:** categories have **no hard delete** — only soft delete + restore
|
||
(gateway doc comment). `deletedAt` timestamps the soft delete.
|
||
|
||
---
|
||
|
||
### 3.3 Orders
|
||
|
||
Two surfaces: **checkout order creation** (LIVE, storefront) and **admin order
|
||
management** (MOCK-ONLY, no seam). These are separate models — the storefront
|
||
`CreateOrderRequest`/`CreateOrderResponse` (inline in `api.service.ts`) is not the
|
||
admin `AdminOrder`.
|
||
|
||
#### 3.3.a Cart → payment → order creation — LIVE
|
||
|
||
Cart contents are **LOCAL-ONLY** (`CartService`, localStorage key
|
||
`marketplace_cart` + Telegram CloudStorage — no cart backend). Checkout produces
|
||
these live calls (`BACKEND-AUDIT.md` §10). Payment DTOs are inline in
|
||
`src/app/services/api.service.ts`.
|
||
|
||
| # | Method | Path | Base | Request → Response |
|
||
|---|---|---|---|---|
|
||
| O1 | POST | `/cart` | marketplace | `CartPaymentRequest` → `QrCreateResponse` (creates the QR/card payment) |
|
||
| O2 | POST | `/qr` | qrApiUrl (headers `authorization-key`, `userid-value`) | `QrCreateRequest` → `QrCreateResponse` |
|
||
| O3 | GET | `/qr/dynamic/{partnerId}/{qrId}` | qrApiUrl | → `QrDynamicStatusResponse` (poll) |
|
||
| O4 | GET | `/card/{partnerId}/{orderId}` | qrApiUrl | → `QrDynamicStatusResponse` (poll) |
|
||
| O5 | POST | `/orders` | marketplace | `CreateOrderRequest` → `CreateOrderResponse` (records paid cart; **fire-and-forget** after payment) |
|
||
| O6 | POST | `/purchase-email` | marketplace | email/phone + items → `{ message }` (receipt) |
|
||
| O7 | POST | `/websession/{sessionId}` | marketplace | item array → any (server-side cart mirror) |
|
||
|
||
- `partnerId` const = `web-97ec-9c57-4dde-9037-3a68f7f83750`.
|
||
- **Headers/JWT:** marketplace headers (O1/O5/O6/O7); O2-O4 use the payment API
|
||
with `authorization-key`/`userid-value`. No JWT; customer session via
|
||
`WebSessionID`.
|
||
- **`CartPaymentRequest` (O1):** `{ amount, currency:'RUB', siteuserID,
|
||
siteorderID, redirectUrl, telegramUsername, paymentMethod:'qr'|'card',
|
||
qrDescription?, customerID?, items:[{ itemID, price, name, quantity?,
|
||
delivery? }] }`.
|
||
- **`CreateOrderRequest` (O5):** `{ items:[{ productId, name, quantity, price }],
|
||
customer:{ name, email, phone }, payment?:{ method, currency }, shipping?:{
|
||
address, method, trackingNumber } }`.
|
||
- **`CreateOrderResponse` (O5):** `{ id, orderNumber, status, total, currency }`.
|
||
- **`QrCreateResponse`** is a very tolerant union (`api.service.ts` 21-41) —
|
||
resolvers pick the first present of `qrId/qrID/nspkID/…`. A backend can emit
|
||
any of the documented aliases.
|
||
- **Error responses:** O5 is fire-and-forget — a failure must **never** block the
|
||
payment-confirmed flow (source comment, line 635). `401` on a customer-facing
|
||
`/cart`/`/orders` has **no unified re-auth UX** today (`ERROR_CONTRACT.md` §401,
|
||
Requires backend decision). Payment status polling handles its own timeouts.
|
||
- **Validation:** none client-side. Amounts/quantities ≥ 0, valid email/phone =
|
||
Requires backend decision.
|
||
- **Example O5 request:**
|
||
```json
|
||
{ "items": [ { "productId": "1024", "name": "Phone X", "quantity": 1, "price": 62560 } ], "customer": { "name": "Ivan", "email": "ivan@example.com", "phone": "+79990000000" }, "payment": { "method": "qr", "currency": "RUB" } }
|
||
```
|
||
- **Example O5 response:** `{ "id": "ord_88", "orderNumber": "R-000088", "status": "pending", "total": 62560, "currency": "RUB" }`
|
||
|
||
#### 3.3.b Admin orders management — PROPOSED (MOCK-ONLY, no seam)
|
||
|
||
Contract: `AdminOrdersGateway`. Impl: `AdminOrdersLocalGateway` (injected
|
||
concretely by `AdminOrdersFacade`, and reused by `AdminCustomersFacade` and
|
||
`AdminAnalyticsFacade`). DTO: `AdminOrder`
|
||
(`src/app/features/admin/orders/models/admin-order.model.ts`).
|
||
|
||
| # | Method | Path (PROPOSED) | Gateway method | Success |
|
||
|---|---|---|---|---|
|
||
| AO1 | GET | `{base}/backoffice/orders?search&status&page&pageSize` | `loadOrders(filters)` | `200 AdminOrdersListResult` |
|
||
| AO2 | GET | `{base}/backoffice/orders/{id}` | `loadOrder(id)` | `200 AdminOrder` / `404` |
|
||
| AO3 | PATCH | `{base}/backoffice/orders/{id}/status` | `updateStatus(id,status)` | `200 AdminOrder` |
|
||
| AO4 | POST | `{base}/backoffice/orders/{id}/refund` | `requestRefund(id)` | `200 AdminOrder` |
|
||
| AO5 | POST | `{base}/backoffice/orders/{id}/notes` | `addNote(id,note,internal)` | `200 AdminOrder` |
|
||
| AO6 | POST | `{base}/backoffice/orders/{id}/archive` | `archiveOrder(id)` | `200 AdminOrder` |
|
||
| AO7 | POST | `{base}/backoffice/orders/{id}/restore` | `restoreOrder(id)` | `200 AdminOrder` |
|
||
| AO8 | DELETE | `{base}/backoffice/orders/{id}` | `deleteOrder(id)` | `204` |
|
||
|
||
- **Headers/JWT:** admin auth (§3.0).
|
||
- **Permission (Proposed):** AO1/AO2 → `backoffice.read`; AO3-AO8 →
|
||
`backoffice.write`.
|
||
- **AO1 query params** (`AdminOrderListFilters`): `search`, `status` ∈
|
||
`all|pending|processing|shipped|delivered|cancelled|refunded`, `page`,
|
||
`pageSize`. Response `AdminOrdersListResult { items, total, page, pageSize }`.
|
||
No sort param.
|
||
- **AO3 body:** `{ status: AdminOrderStatus }` where status ∈ the 6-value union
|
||
above. **State-transition rules** (which transitions are legal, e.g.
|
||
`delivered → pending`?) are **Requires backend decision** — the frontend
|
||
enforces none. `payment.status` (`unpaid|paid|refund_requested|refunded`) is a
|
||
separate axis; AO4 (`requestRefund`) is what moves it to `refund_requested`.
|
||
- **AO5 body:** `{ note: string, internal: boolean }` — `internal:true` writes
|
||
`internalNotes`, else `notes` (both plain strings on `AdminOrder`). Appends a
|
||
timeline entry.
|
||
- **`AdminOrder` timeline:** `timeline: [{ status, timestamp, eventKey:
|
||
'created'|'statusChanged'|'refundRequested' }]` — server should append on each
|
||
mutation.
|
||
- **Validation:** none client-side. Legal status transitions, refund
|
||
eligibility = Requires backend decision.
|
||
- **Error responses:** `404` (unknown id), `409` (illegal transition — proposed),
|
||
common set.
|
||
- **Example AO3 request:** `PATCH …/orders/ord_88/status` body `{ "status": "shipped" }`
|
||
- **Example AO2 response (trimmed):**
|
||
```json
|
||
{ "id": "ord_88", "orderNumber": "R-000088", "status": "shipped", "customer": { "name": "Ivan", "email": "ivan@example.com", "phone": "+79990000000" }, "payment": { "method": "qr", "status": "paid", "amount": 62560, "currency": "RUB" }, "shipping": { "address": "…", "method": "courier", "trackingNumber": "TRK1" }, "items": [ { "productId": "1024", "name": "Phone X", "quantity": 1, "price": 62560 } ], "total": 62560, "currency": "RUB", "notes": "", "internalNotes": "", "timeline": [ { "status": "pending", "timestamp": "2026-07-20T10:00:00Z", "eventKey": "created" } ], "archived": false, "createdAt": "2026-07-20T10:00:00Z", "updatedAt": "2026-07-21T09:00:00Z" }
|
||
```
|
||
|
||
---
|
||
|
||
### 3.4 Customers — PROPOSED (derived, no gateway of its own)
|
||
|
||
Per `BACKEND-AUDIT.md` §14 and the model comment
|
||
(`src/app/features/admin/customers/models/admin-customer.model.ts`): a customer is
|
||
**not a stored entity** — `AdminCustomersFacade` derives customers by grouping
|
||
`AdminOrder` records by `customer.email` (it injects `AdminOrdersLocalGateway`).
|
||
Every `AdminCustomer` field is a real aggregate over that customer's orders, never
|
||
fabricated.
|
||
|
||
**Frontend implication:** there is **no customer CRUD in the frontend at all** —
|
||
no create/update/delete, only a derived list/detail. A backend can satisfy this
|
||
either by (a) exposing a real customers endpoint returning the aggregate shape, or
|
||
(b) letting the frontend keep deriving from orders. This is **Requires backend
|
||
decision** (whether customers become a first-class backend resource).
|
||
|
||
| # | Method | Path (PROPOSED) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| CU1 | GET | `{base}/backoffice/customers` | List (derived aggregate) | `200 AdminCustomer[]` |
|
||
| CU2 | GET | `{base}/backoffice/customers/{email}` | Detail (aggregate + orders) | `200 AdminCustomer` / `404` |
|
||
|
||
- **Key = `email`** (customers are keyed by email, not an id).
|
||
- **`AdminCustomer`:** `{ email, name, phone, orderCount, totalSpent, currency,
|
||
firstOrderAt, lastOrderAt, addresses[], orders: AdminOrder[] }`.
|
||
- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`.
|
||
- **Pagination/sort/filter:** none in the model (plain array). Derivation is
|
||
entirely client-side today.
|
||
- **Validation:** n/a (read-only, derived).
|
||
- **Error responses:** common read set; `404` if a real customers endpoint is
|
||
built and the email is unknown.
|
||
- **Example CU1 response (trimmed):**
|
||
```json
|
||
[ { "email": "ivan@example.com", "name": "Ivan", "phone": "+79990000000", "orderCount": 3, "totalSpent": 187680, "currency": "RUB", "firstOrderAt": "2026-01-10T…", "lastOrderAt": "2026-07-20T…", "addresses": ["…"], "orders": [] } ]
|
||
```
|
||
|
||
---
|
||
|
||
### 3.5 Users — PROPOSED (MOCK-ONLY, no seam)
|
||
|
||
Contract: `AdminUsersGateway`
|
||
(`src/app/features/admin/users/services/admin-users-gateway.interface.ts`). Impl:
|
||
`AdminUsersLocalGateway` (injected concretely by `AdminUsersFacade`). DTOs in
|
||
`admin-user.model.ts`: `AdminUser`, `AdminRole`, `AdminInvitation`, `AdminSession`,
|
||
`AdminUserAuditEntry`.
|
||
|
||
| # | Method | Path (PROPOSED) | Gateway method | Success |
|
||
|---|---|---|---|---|
|
||
| U1 | GET | `{base}/backoffice/users` | `loadUsers()` | `200 AdminUser[]` |
|
||
| U2 | GET | `{base}/backoffice/roles` | `loadRoles()` | `200 AdminRole[]` |
|
||
| U3 | GET | `{base}/backoffice/invitations` | `loadInvitations()` | `200 AdminInvitation[]` |
|
||
| U4 | GET | `{base}/backoffice/users/{userId}/sessions` | `loadSessions(userId)` | `200 AdminSession[]` |
|
||
| U5 | GET | `{base}/backoffice/users/{userId}/audit` | `loadAudit(userId)` | `200 AdminUserAuditEntry[]` |
|
||
| U6 | PATCH | `{base}/backoffice/users/{userId}/role` | `setUserRole(userId,roleId)` | `200 AdminUser` |
|
||
| U7 | PATCH | `{base}/backoffice/users/{userId}/status` | `setUserStatus(userId,status)` | `200 AdminUser` |
|
||
| U8 | POST | `{base}/backoffice/invitations` | `inviteUser(email,roleId,scope)` | `201 AdminInvitation` |
|
||
| U9 | DELETE | `{base}/backoffice/invitations/{id}` | `revokeInvitation(id)` | `204` |
|
||
| U10 | DELETE | `{base}/backoffice/sessions/{sessionId}` | `revokeSession(sessionId)` | `204` |
|
||
|
||
- **Headers/JWT:** admin auth (§3.0).
|
||
- **Permission (Proposed):** all user/role/invitation/session management →
|
||
`users.manage` (the one permission specifically about this domain — see
|
||
`AUTHENTICATION.md` §9.1). Read-only U1-U5 could be `backoffice.read` — Requires
|
||
backend decision.
|
||
- **U6 body:** `{ roleId: string }`. **U7 body:** `{ status: AdminUserStatus }`
|
||
where status ∈ `active|invited|suspended`. **U8 body:** `{ email, roleId,
|
||
scope }` where scope ∈ `marketplace|office`.
|
||
- **`AdminRole`** here is `{ id, name, permissions: string[], builtIn: boolean }`
|
||
— **a different type from the auth `AdminRole` string-union** used by the JWT
|
||
`role` claim (`Owner|Administrator|Editor|Support|ReadOnly`,
|
||
`src/app/core/auth/models/permission.model.ts`). **This is the flagged
|
||
duplicate** (`BACKEND-AUDIT.md` §14, `AUTHENTICATION.md` §9). Reconciliation
|
||
needed: treat the auth string-union as the JWT/role-claim contract; treat this
|
||
interface as an admin-users-management row. Recommend renaming the latter (e.g.
|
||
`AdminUserRoleRecord`). **Requires backend decision / naming reconciliation.**
|
||
- **Roles CRUD:** the gateway exposes only `loadRoles()` (read). There is **no
|
||
create/update/delete role** endpoint in the frontend — role definitions are
|
||
read-only from the frontend's perspective. Whether roles are backend-editable =
|
||
Requires backend decision.
|
||
- **Sessions/audit:** `revokeSession` (U10) exists as a mock method; whether an
|
||
Owner can remotely kill another admin's live session, and how that propagates to
|
||
the already-logged-in client, is **Requires backend decision**
|
||
(`AUTHENTICATION.md` §8 — no push/poll mechanism exists client-side).
|
||
- **Validation:** none client-side. Email format, role existence, self-demotion
|
||
guards = Requires backend decision.
|
||
- **Error responses:** `404` (unknown user/session/invitation), `409` (e.g.
|
||
inviting an already-registered email — proposed), `422` (bad email/role),
|
||
common set.
|
||
- **Example U8 request:** `{ "email": "new.admin@example.com", "roleId": "role_editor", "scope": "office" }`
|
||
- **Example U1 response (trimmed):**
|
||
```json
|
||
[ { "id": "u_1", "name": "Alice", "telegramUsername": "@alice", "email": "alice@example.com", "scope": "office", "roleId": "role_owner", "status": "active", "lastLoginAt": "2026-07-24T…", "createdAt": "2026-01-01T…" } ]
|
||
```
|
||
|
||
---
|
||
|
||
### 3.6 Roles
|
||
|
||
Covered structurally under Users (§3.5, endpoint U2 `loadRoles`). Summary of the
|
||
reconciliation the backend must make (this is the audit's flagged item):
|
||
|
||
- **`AdminRole` #1** — string union `'Owner'|'Administrator'|'Editor'|'Support'
|
||
|'ReadOnly'` (`src/app/core/auth/models/permission.model.ts`). Used by the JWT
|
||
`role` claim and `PermissionService`. Ordered high→low by convention only. Maps
|
||
to permissions via `ROLE_PERMISSIONS` (`AUTHENTICATION.md` §9.1). **This is the
|
||
role contract for authorization.**
|
||
- **`AdminRole` #2** — interface `{ id, name, permissions: string[], builtIn }`
|
||
(`src/app/features/admin/users/models/admin-user.model.ts`). A management row for
|
||
the users screen. `AdminUser.roleId` references `AdminRole.id` here (a
|
||
string id like `role_owner`), **not** the string-union value.
|
||
- **The two do not reference each other.** A backend role table must decide
|
||
whether `AdminRole.id` (#2) resolves to one of the five #1 union values (i.e.
|
||
`builtIn` roles map onto the permission model) and whether custom
|
||
(`builtIn:false`) roles with arbitrary `permissions[]` are allowed. **Requires
|
||
backend decision** — the frontend does not resolve this collision.
|
||
- **No role CRUD endpoint** exists in the frontend beyond read (`loadRoles`).
|
||
Custom-role create/edit/delete = Requires backend decision.
|
||
|
||
---
|
||
|
||
### 3.7 Media — PROPOSED (MOCK-SWAPPABLE via abstract-class token)
|
||
|
||
Contract: abstract class `MediaRepository` (`src/app/core/media/media-repository.ts`).
|
||
Bound in `app.config.ts` → `MockMediaRepository` (`useClass`). MOCK-SWAPPABLE (a
|
||
real impl drops in via the same class token — no new seam needed). DTO:
|
||
`MediaAsset` (`src/app/core/media/models/media-asset.model.ts`).
|
||
|
||
**Scope note (per task):** this section covers the **CRUD list/delete/metadata**
|
||
endpoints only. Upload *mechanics* (multipart, presigned URLs, chunking) are
|
||
deferred to a sibling "Uploads" section of the master doc — here `upload` is
|
||
listed for completeness but its transport is out of scope.
|
||
|
||
| # | Method | Path (PROPOSED) | Repository method | Success |
|
||
|---|---|---|---|---|
|
||
| M1 | GET | `{base}/media?page&pageSize&search&folder&tag&kind&sort` | `list(params)` | `200 MediaListResult { items, total }` |
|
||
| M2 | POST | `{base}/media` (multipart — see Uploads section) | `upload(file,options)` | `201 MediaAsset` |
|
||
| M3 | DELETE | `{base}/media/{id}` | `remove(id)` | `204` |
|
||
| M4 | PATCH | `{base}/media/{id}` | `update(id,patch)` | `200 MediaAsset` |
|
||
| M5 | GET | `{base}/media/folders` | `listFolders()` | `200 string[]` |
|
||
|
||
- **Headers/JWT:** the `adminAuthHeadersInterceptor` already gates `/media/`
|
||
paths (§3.0, anticipatory) — admin auth applies. Permission (Proposed):
|
||
`backoffice.read` (M1/M5), `backoffice.write` (M2/M3/M4).
|
||
- **M1 query params** (`MediaListParams`): `page?`, `pageSize?`, `search?`,
|
||
`folder?`, `tag?`, `kind?` ∈ `image|svg|pdf|other`, `sort?` ∈
|
||
`recent|name|size`. Response `{ items: MediaAsset[], total }` (offset paginated).
|
||
- **M4 patch body** — restricted to metadata: `Partial<Pick<MediaAsset, 'altText'
|
||
| 'tags' | 'folder' | 'caption' | 'description' | 'decorative'>>`. The
|
||
repository signature **forbids** patching `url/filename/mimeType/size/width/
|
||
height` — a backend should reject those on PATCH.
|
||
- **`MediaAsset`:** `{ id, url, thumbnailUrl?, filename, mimeType, size, width?,
|
||
height?, altText?: Record<locale,string>, caption?, description?, decorative?,
|
||
tags?, folder?, createdAt }`.
|
||
- **Validation:** none client-side. Allowed mime types / max size = Requires
|
||
backend decision (also relevant to the Uploads section).
|
||
- **Error responses:** `404` (M3/M4 unknown id), `422` (invalid patch field),
|
||
common set. `MediaUsageService` exists client-side to warn on in-use assets —
|
||
whether delete is blocked when an asset is referenced is Requires backend
|
||
decision.
|
||
- **Example M4 request:** `PATCH …/media/asset_9` body `{ "altText": { "en": "Product hero", "ru": "…" }, "tags": ["hero"], "decorative": false }`
|
||
- **Example M1 response (trimmed):**
|
||
```json
|
||
{ "items": [ { "id": "asset_9", "url": "https://…/hero.webp", "filename": "hero.webp", "mimeType": "image/webp", "size": 48213, "width": 1200, "height": 630, "folder": "products", "createdAt": "2026-07-01T…" } ], "total": 342 }
|
||
```
|
||
|
||
---
|
||
|
||
### 3.8 CMS — Static pages — PROPOSED / FUTURE (LOCAL-ONLY today)
|
||
|
||
**Status: LOCAL-ONLY.** `ContentManagementFacade`
|
||
(`src/app/features/content-management/facade/content-management.facade.ts`) →
|
||
`ContentPageService` operates entirely on the **already-loaded
|
||
`BootstrapConfig.staticPages`** — there are **no dedicated CMS backend calls**
|
||
(`BACKEND-AUDIT.md` §16). Editing writes to in-memory bootstrap + localStorage
|
||
drafts; "publishing" = writing bootstrap back, for which **no client HTTP call
|
||
exists**. So every endpoint below is **PROPOSED / FUTURE**.
|
||
|
||
Backend-shaped model: `StaticPageConfig` / `StaticPagesConfig` /
|
||
`ResolvedStaticPage` (`src/app/shared/models/config/static-page.model.ts`). Editor
|
||
view model: `ContentPage` (`src/app/features/content-management/models/content-page.model.ts`);
|
||
`ContentPageService` is the adapter (`toBootstrapRecord`/`serializePages`).
|
||
|
||
| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| CM1 | GET | `{base}/builder/static-pages` | List pages | `200 StaticPagesConfig` |
|
||
| CM2 | GET | `{base}/builder/static-pages/{id}` | Single page | `200 StaticPageConfig` / `404` |
|
||
| CM3 | POST | `{base}/builder/static-pages` | Create page | `201 StaticPageConfig` |
|
||
| CM4 | PUT | `{base}/builder/static-pages/{id}` | Update page | `200 StaticPageConfig` |
|
||
| CM5 | DELETE | `{base}/builder/static-pages/{id}` | Delete page | `204` |
|
||
|
||
- **Reality check:** today the frontend gets these pages **inside `GET /bootstrap`**
|
||
and never calls CM1-CM5. If the backend keeps CMS inside bootstrap, these
|
||
endpoints may never exist — the alternative is a builder-publish endpoint that
|
||
writes the whole bootstrap (see §3.15). **Requires backend decision:**
|
||
per-page CMS endpoints vs. whole-bootstrap publish.
|
||
- **Headers/JWT:** admin/builder auth (§3.0). Permission (Proposed):
|
||
`builder.read` (CM1/CM2), `builder.write` (CM3-CM5).
|
||
- **`StaticPageConfig` key fields:** `{ id, slug, title: string|Localized,
|
||
showInFooter?, showInHeader?, showInSitemap?, icon?, order?, requiresAuthentication?,
|
||
translations?: Record<locale, { title?, html?, seo? }>, html?: string|Localized,
|
||
seo?: StaticPageSeoConfig, visible?, enabled?, status?: 'draft'|'published',
|
||
heroImage?, gallery?, updatedAt? }`. Two lifecycle switches: `enabled` (master
|
||
on/off) and per-page `status` (`draft` never resolves on storefront even if the
|
||
bootstrap is published).
|
||
- **Validation:** the client-side `ContentPageService.validatePages()` +
|
||
`ProjectValidator` convention exists (`ProjectValidationIssue { code, message,
|
||
section, fieldKey, severity }`) and is the shape `ERROR_CONTRACT.md` §422
|
||
recommends aligning backend `details[]` to. Actual required fields (slug
|
||
uniqueness, non-empty title) = Requires backend decision.
|
||
- **Error responses:** `404` (CM2/CM4/CM5), `409` (duplicate slug — proposed),
|
||
`422` (validation), common set. None handled specially by the UI today.
|
||
- **Example CM4 request (trimmed):**
|
||
```json
|
||
{ "id": "about", "slug": "about-us", "title": { "en": "About us", "ru": "О нас" }, "showInFooter": true, "status": "published", "enabled": true, "html": { "en": "<h1>About</h1>" }, "seo": { "title": { "en": "About us" }, "robots": "index,follow" } }
|
||
```
|
||
|
||
---
|
||
|
||
### 3.9 Homepage — PROPOSED / FUTURE (LOCAL-ONLY)
|
||
|
||
**Status: LOCAL-ONLY** (project-editor, `BACKEND-AUDIT.md` §17). The homepage is a
|
||
**composition of `SectionConfig` → `WidgetConfig`** stored inside
|
||
`BootstrapConfig` (`layout`/`pages`). Edited via `ProjectEditorFacade` +
|
||
`homepage-section.component.ts` / `homepage-overview.component.ts` against
|
||
in-memory bootstrap; persisted only to localStorage drafts. No HTTP save/publish
|
||
call exists.
|
||
|
||
Models: `SectionConfig` (`src/app/shared/models/config/section.model.ts`) —
|
||
`{ id, type, order, layout?: { strategy: 'stack'|'grid'|'hero'|'carousel'|'split',
|
||
columns?, gap?, align? }, visibility?, widgets: WidgetConfig[], visible? }`.
|
||
|
||
| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| HP1 | GET | `{base}/builder/homepage` | Load section/widget composition | `200 SectionConfig[]` |
|
||
| HP2 | PUT | `{base}/builder/homepage` | Replace composition (reorder, add/remove sections & widgets) | `200 SectionConfig[]` |
|
||
|
||
- **Why a single PUT, not granular CRUD:** the editor mutates an in-memory tree
|
||
and saves it whole (undo/redo over `History<BootstrapConfig>`); there is no
|
||
per-section/per-widget network operation in the frontend. A whole-composition
|
||
PUT matches how the client actually works. Granular section/widget endpoints
|
||
would be new backend design. **Requires backend decision.**
|
||
- **Headers/JWT:** builder auth (§3.0). Permission (Proposed): `builder.read`
|
||
(HP1), `builder.write` (HP2).
|
||
- **Section ordering** is by the numeric `order` field; widgets carry their own
|
||
`order` within a section.
|
||
- **Validation:** `ProjectValidator` (client-side, localStorage-scoped). Section
|
||
`type` allow-list, widget `type` must exist in the widget manifest (§3.10) =
|
||
Requires backend decision.
|
||
- **Error responses:** `422` (invalid section/widget type or malformed tree),
|
||
common set.
|
||
- **Example HP2 request (trimmed):**
|
||
```json
|
||
[ { "id": "hero", "type": "hero", "order": 0, "layout": { "strategy": "hero" }, "visible": true, "widgets": [ { "id": "w1", "type": "hero-banner", "version": "1.0.0", "order": 0, "props": { "title": "Sale" } } ] } ]
|
||
```
|
||
|
||
---
|
||
|
||
### 3.10 Widgets — PROPOSED / FUTURE (LOCAL-ONLY) + widget manifest (LIVE)
|
||
|
||
Two things named "widgets": the **manifest** (LIVE) and the **widget instances**
|
||
inside sections (LOCAL-ONLY, edited via project-editor `widgets-section.component.ts`).
|
||
|
||
#### 3.10.a Widget manifest — LIVE
|
||
|
||
`WidgetManifestService` (`src/app/widgets/registry/widget-manifest.service.ts`):
|
||
|
||
| # | Method | Path (LIVE) | Success |
|
||
|---|---|---|---|
|
||
| W1 | GET | `bootstrap.widgetRegistry.manifestUrl` (fallback `/assets/mock/bootstrap/widget-manifest.json`) | `200 WidgetManifestFile` |
|
||
|
||
- Read-only registry of available widget types + their settings schema
|
||
(`WidgetManifestEntry/File`, `WidgetSettingsSchema` — `src/app/widgets/contracts/widget-manifest.contract.ts`).
|
||
- **Headers/JWT:** the manifest URL is bootstrap-driven; public read.
|
||
- **Validation/errors:** on failure falls back to the static asset. No mutation.
|
||
|
||
#### 3.10.b Widget instances CRUD — PROPOSED / FUTURE
|
||
|
||
Widget instances (`WidgetConfig`, `src/app/shared/models/config/widget.model.ts`)
|
||
live inside `SectionConfig.widgets[]` and are created/updated/removed/reordered
|
||
purely in-memory via `ProjectEditorFacade` (localStorage drafts). There is **no
|
||
per-widget backend call** — they are saved as part of the homepage composition
|
||
(§3.9 HP2). So there is **no separate widget CRUD endpoint** unless the backend
|
||
chooses granular editing (Requires backend decision). If it does:
|
||
|
||
| # | Method | Path (PROPOSED / FUTURE) | Purpose |
|
||
|---|---|---|---|
|
||
| W2 | POST | `{base}/builder/sections/{sectionId}/widgets` | Add widget |
|
||
| W3 | PUT | `{base}/builder/sections/{sectionId}/widgets/{widgetId}` | Update widget |
|
||
| W4 | DELETE | `{base}/builder/sections/{sectionId}/widgets/{widgetId}` | Remove widget |
|
||
|
||
- **`WidgetConfig`:** `{ id, type, version, title?, subtitle?, order?, padding?,
|
||
visibility?: { desktop?, tablet?, mobile? }, animation?, style?: Record<string,
|
||
string>, permissions?: { requireAuthenticated?, roles?, permissions? }, props:
|
||
Record<string, unknown>, actions?, featureFlag?, visible? }`.
|
||
- **Permission (Proposed):** `builder.write`. **Validation:** widget `type` must
|
||
exist in the manifest (W1); `props` shape validated against
|
||
`WidgetSettingsSchema`. Both client-side/manifest-driven; backend enforcement =
|
||
Requires backend decision.
|
||
- **Recommendation:** treat §3.9 HP2 (whole-composition PUT) as canonical and
|
||
W2-W4 as optional granular sugar the frontend does not need today.
|
||
|
||
---
|
||
|
||
### 3.11 Navigation — PROPOSED / FUTURE (LOCAL-ONLY via builder)
|
||
|
||
**Status: LOCAL-ONLY.** Header/footer nav is `BootstrapConfig.navigation`
|
||
(`src/app/shared/models/config/navigation.model.ts`), edited via
|
||
`ProjectEditorFacade` + `navigation-section.component.ts`. Facade methods
|
||
(all in-memory + localStorage; `project-editor.facade.ts`): `addNavLink`,
|
||
`addStaticPageNavLink`, `removeNavLink`, `updateNavLink`, `updateNavLinkLabel`,
|
||
`reorderNavLink` — all take `target: 'header' | 'footer'`. No HTTP call exists.
|
||
|
||
Model: `NavigationConfig { header: NavigationItemConfig[], footer:
|
||
NavigationItemConfig[] | FooterNavigationGroupConfig[], sidebar? }`.
|
||
`NavigationItemConfig { id, labelKey?, label?: string|Localized, route?, type?,
|
||
key?, icon?, order?, visible?, visibleWhenFlags?, children? }`.
|
||
|
||
| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| N1 | GET | `{base}/builder/navigation` | Load nav config | `200 NavigationConfig` |
|
||
| N2 | PUT | `{base}/builder/navigation` | Replace nav config (add/remove/update/reorder items) | `200 NavigationConfig` |
|
||
|
||
- **Whole-config PUT** matches the editor (it mutates the nav tree in memory and
|
||
saves whole). Granular per-item endpoints = Requires backend decision.
|
||
- **Headers/JWT:** builder auth. Permission (Proposed): `builder.read` (N1),
|
||
`builder.write` (N2).
|
||
- **Ordering:** `reorderNavLink(target, id, direction: -1|1)` moves an item; the
|
||
numeric `order` field is authoritative on the wire.
|
||
- **Localized labels:** `updateNavLinkLabel(target, id, value, locale?)` writes
|
||
into `label` as a `{ [locale]: string }` map.
|
||
- **Validation:** none client-side beyond the builder validator. Route validity,
|
||
no orphan `children` = Requires backend decision.
|
||
- **Errors:** `422` (malformed tree), common set.
|
||
- **Example N2 request (trimmed):**
|
||
```json
|
||
{ "header": [ { "id": "n1", "label": { "en": "Catalog", "ru": "Каталог" }, "route": "/catalog", "order": 0, "visible": true } ], "footer": [] }
|
||
```
|
||
|
||
---
|
||
|
||
### 3.12 Footer — PROPOSED / FUTURE (LOCAL-ONLY via builder)
|
||
|
||
**Status: LOCAL-ONLY.** `BootstrapConfig.footer` (`FooterConfig`,
|
||
`src/app/shared/models/config/footer-config.model.ts`), edited via
|
||
`footer-section.component.ts` + `ProjectEditorFacade`. No HTTP call.
|
||
|
||
`FooterConfig { logoUrl?, paymentIcons?: [{ src, alt, width?, height? }],
|
||
copyrightText?: string|Localized, columns?: [{ id, title, links: [{ id, label,
|
||
pageKey?, url? }] }], socialLinks?: [{ id, label, url, icon? }], legalPageKeys?
|
||
(deprecated), staticPageKeys? (deprecated) }`.
|
||
|
||
| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| F1 | GET | `{base}/builder/footer` | Load footer config | `200 FooterConfig` |
|
||
| F2 | PUT | `{base}/builder/footer` | Replace footer (payment icons, columns/legal links, copyright, socials) | `200 FooterConfig` |
|
||
|
||
- **Headers/JWT:** builder auth. Permission (Proposed): `builder.read` (F1),
|
||
`builder.write` (F2).
|
||
- **Link resolution:** `FooterLinkConfig.pageKey` (preferred, references a CMS
|
||
page id — stays correct if the page route changes) vs. `url` (raw external).
|
||
`legalPageKeys`/`staticPageKeys` are **deprecated**, superseded by `columns` —
|
||
a backend should accept them for back-compat but write `columns`.
|
||
- **Validation:** none client-side. Payment-icon URL validity, non-empty column
|
||
titles = Requires backend decision.
|
||
- **Errors:** `422`, common set.
|
||
- **Example F2 request (trimmed):**
|
||
```json
|
||
{ "logoUrl": "https://…/logo.svg", "paymentIcons": [ { "src": "https://…/visa.svg", "alt": "Visa", "width": 40, "height": 24 } ], "copyrightText": { "en": "© 2026 Acme" }, "columns": [ { "id": "legal", "title": "Legal", "links": [ { "id": "l1", "label": "Terms", "pageKey": "terms" } ] } ], "socialLinks": [] }
|
||
```
|
||
|
||
---
|
||
|
||
### 3.13 Branding / Theme — PROPOSED / FUTURE (LOCAL-ONLY via builder)
|
||
|
||
**Status: LOCAL-ONLY.** `BootstrapConfig.branding` (`BrandingConfig`,
|
||
`src/app/shared/models/config/branding.model.ts`) and `BootstrapConfig.theme`
|
||
(`theme.model.ts`), edited via `branding-section.component.ts` /
|
||
`theme-section.component.ts` / `brand-overview.component.ts`. No HTTP call.
|
||
|
||
`BrandingConfig { brandName, legalName, slogan?, logoUrl, logoCompactUrl?,
|
||
faviconUrl, appIconUrl?, socialImageUrl?, galleryUrls?, supportEmail?,
|
||
supportPhone? }`.
|
||
|
||
| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| B1 | GET | `{base}/builder/branding` | Load branding + theme | `200 { branding: BrandingConfig, theme: ThemeConfig }` |
|
||
| B2 | PUT | `{base}/builder/branding` | Update branding | `200 BrandingConfig` |
|
||
| B3 | PUT | `{base}/builder/theme` | Update theme (colors, tokens) | `200 ThemeConfig` |
|
||
|
||
- **Headers/JWT:** builder auth. Permission (Proposed): `builder.read` (B1),
|
||
`builder.write` (B2/B3). Could be `settings.manage` if branding is treated as a
|
||
tenant-settings concern — Requires backend decision.
|
||
- **Theme model** (`src/app/shared/models/config/theme.model.ts`, not re-listed
|
||
here) carries color tokens; the editor computes contrast client-side
|
||
(`sections/brand/contrast.util.ts`). Contrast/accessibility is enforced
|
||
client-side only.
|
||
- **Validation:** none backend-driven. `brandName`, `logoUrl`, `faviconUrl`
|
||
non-optional in the model; valid email for `supportEmail` = Requires backend
|
||
decision.
|
||
- **Errors:** `422`, common set.
|
||
- **Example B2 request (trimmed):**
|
||
```json
|
||
{ "brandName": "Acme", "legalName": "Acme LLC", "logoUrl": "https://…/logo.svg", "faviconUrl": "https://…/favicon.ico", "supportEmail": "help@acme.com" }
|
||
```
|
||
|
||
---
|
||
|
||
### 3.14 Languages — PROPOSED / FUTURE (LOCAL-ONLY via builder)
|
||
|
||
**Status: LOCAL-ONLY.** Locale management is `BootstrapConfig.localization` +
|
||
`BootstrapConfig.tenant.supportedLocales/defaultLocale`, edited via
|
||
`languages-section.component.ts` + `ProjectEditorFacade` methods `addLocale`,
|
||
`removeLocale`, `setDefaultLocale` — all mutate in-memory bootstrap only. No HTTP.
|
||
|
||
`LocalizationConfig { defaultLocale, supportedLocales: string[], currencyByLocale:
|
||
Record<locale,currency>, dictionaries: [{ locale, dictionaryUrl, version }] }`.
|
||
|
||
| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| L1 | GET | `{base}/builder/languages` | Load locale config | `200 LocalizationConfig` |
|
||
| L2 | POST | `{base}/builder/languages` | Add a supported locale | `201 LocalizationConfig` |
|
||
| L3 | DELETE | `{base}/builder/languages/{code}` | Remove a locale | `200 LocalizationConfig` |
|
||
| L4 | PUT | `{base}/builder/languages/default` | Set default locale | `200 LocalizationConfig` |
|
||
|
||
- **Headers/JWT:** builder auth. Permission (Proposed): `builder.write` for
|
||
L2-L4, `builder.read` for L1. Could be `settings.manage` — Requires backend
|
||
decision.
|
||
- **Client-side rules that a backend should mirror:** locale code is lowercased
|
||
and trimmed on add; adding an already-present locale is rejected client-side
|
||
with `builder.languageAlreadyAdded` (so **`409 CONFLICT`** on L2 duplicate is
|
||
the right backstop). L2 body: `{ code: string }` (a locale like `en`, `ru`,
|
||
`hy`). L4 body: `{ code: string }` — must be an already-supported locale.
|
||
- **Removing a locale** shows a confirm dialog (destructive: strips that locale's
|
||
content). Whether removing the *default* locale is allowed = Requires backend
|
||
decision (the UI lets you set a new default first).
|
||
- **Validation:** non-empty lowercased code; membership check for default. Valid
|
||
BCP-47 code = Requires backend decision.
|
||
- **Errors:** `409` (duplicate add), `422` (invalid code / removing last locale),
|
||
common set.
|
||
- **Example L2 request:** `{ "code": "hy" }`
|
||
|
||
---
|
||
|
||
### 3.15 Marketplace Settings — PROPOSED / FUTURE
|
||
|
||
There is **no dedicated "settings" admin gateway or page** beyond the builder
|
||
sections already covered (§3.8-3.14) and `BootstrapConfig`'s remaining sub-configs
|
||
(`featureFlags`, `features`, `seo`, `catalog`, `header`, `layout`,
|
||
`productPage`, `userExperience`, `company`, `apiEndpoints`, `permissions`). All of
|
||
these are **served inside `GET /bootstrap`** and edited (where editable) via the
|
||
project-editor in-memory (LOCAL-ONLY). The only permission that names settings is
|
||
`settings.manage` (Owner-only, `AUTHENTICATION.md` §9.1).
|
||
|
||
The unifying gap: **there is no client-side "publish/save bootstrap" HTTP call
|
||
anywhere** (`BACKEND-AUDIT.md` §17, §25.5). Everything in §3.8-3.15 is persisted
|
||
only to localStorage drafts + in-memory bootstrap. A real backend needs **one of**:
|
||
|
||
| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| S1 | GET | `{base}/bootstrap` | Load full config (**LIVE** — this one exists) | `200 BootstrapConfig` |
|
||
| S2 | PUT | `{base}/builder/bootstrap` (or `/builder/publish`) | Persist the edited bootstrap (publish) | `200 BootstrapConfig` |
|
||
| S3 | GET | `{base}/builder/settings` | General tenant settings (feature flags, catalog, SEO) | `200` (subset of BootstrapConfig) |
|
||
| S4 | PUT | `{base}/builder/settings` | Update general settings | `200` |
|
||
|
||
- **S1 is the only LIVE endpoint here** (`ApiBootstrapProvider`, `GET /bootstrap`).
|
||
- **S2 is the single most important FUTURE decision:** a whole-bootstrap publish
|
||
endpoint vs. the per-section builder endpoints proposed in §3.8-3.14. The
|
||
frontend has an `apiEndpoints.builder` record slot for exactly this but no code
|
||
that calls it. **Requires backend decision:** whole-document publish vs.
|
||
granular section writes (and optimistic-concurrency / versioning via
|
||
`BootstrapConfig.schemaVersion`/`generatedAt`).
|
||
- **Headers/JWT:** builder/settings auth. Permission (Proposed): `settings.manage`
|
||
(S2/S4), `builder.read`/`settings` read for S1/S3.
|
||
- **Validation:** `ProjectValidator` runs client-side before a (currently local)
|
||
publish. Backend-side schema validation of the whole `BootstrapConfig` =
|
||
Requires backend decision.
|
||
- **Errors:** `422` (invalid config), `409` (concurrent edit — proposed, if
|
||
versioning adopted), common set.
|
||
|
||
---
|
||
|
||
### 3.16 Transactions — PROPOSED (MOCK-ONLY, no seam)
|
||
|
||
Contract: `AdminTransactionsGateway`
|
||
(`src/app/features/admin/transactions/services/admin-transactions-gateway.interface.ts`).
|
||
Impl: `AdminTransactionsLocalGateway` (injected concretely by
|
||
`AdminTransactionsFacade`). DTO: `AdminTransaction`
|
||
(`src/app/features/admin/transactions/models/admin-transaction.model.ts`).
|
||
|
||
| # | Method | Path (PROPOSED) | Gateway method | Success |
|
||
|---|---|---|---|---|
|
||
| T1 | GET | `{base}/backoffice/transactions?search&status&type&page&pageSize` | `loadTransactions(filters)` | `200 AdminTransactionsListResult` |
|
||
| T2 | POST | `{base}/backoffice/transactions/{id}/retry` | `retryFailed(id)` | `200 AdminTransaction` |
|
||
| T3 | PATCH | `{base}/backoffice/transactions/{id}/fraud-flag` | `setFraudFlag(id,flagged)` | `200 AdminTransaction` |
|
||
|
||
- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`
|
||
(T1), `backoffice.write` (T2/T3).
|
||
- **T1 query params** (`AdminTransactionListFilters`): `search`, `status` ∈
|
||
`all|pending|success|failed|retried`, `type` ∈ `all|payment|refund|qr_payment`,
|
||
`page`, `pageSize`. Response `AdminTransactionsListResult { items, total, page,
|
||
pageSize }`. No sort param.
|
||
- **T2:** only meaningful for `status:'failed'` transactions (method name
|
||
`retryFailed`); a backend should reject retry on non-failed (proposed `409`).
|
||
- **T3 body:** `{ flagged: boolean }`.
|
||
- **`AdminTransaction`:** `{ id, orderId, orderNumber, type, method, status,
|
||
amount, currency, fraudFlag, audit: [{ action, actor, timestamp }], createdAt,
|
||
updatedAt }`. Transactions link to orders via `orderId` — how they relate to the
|
||
live payment records (§3.3.a QR/card) is **Requires backend decision** (the
|
||
live payment API and the admin transactions model are currently unconnected).
|
||
- **Validation:** none client-side.
|
||
- **Errors:** `404` (unknown id), `409` (retry on non-failed — proposed), common
|
||
set.
|
||
- **Example T3 request:** `PATCH …/transactions/tx_5/fraud-flag` body `{ "flagged": true }`
|
||
|
||
---
|
||
|
||
### 3.17 Reviews — storefront submission (LIVE) + admin moderation (MOCK-ONLY)
|
||
|
||
#### 3.17.a Storefront review/question submission — LIVE
|
||
|
||
Via `ApiService` (`ProductDataProvider.submitReview/submitQuestion`). Reviews and
|
||
questions are **read** by deriving them from the `GET /items/{id}` payload — there
|
||
are **no dedicated list endpoints** (`BACKEND-AUDIT.md` §11). Writes:
|
||
|
||
| # | Method | Path (LIVE literal) | Request | Success |
|
||
|---|---|---|---|---|
|
||
| R1 | POST | `{base}/items/{itemID}/callback` | `{ rating, comment, sessionID, timestamp }` | `200 { message }` |
|
||
| R2 | POST | `{base}/items/{itemID}/questiion` | `{ question, sessionID, timestamp }` | `200 { message }` |
|
||
|
||
- **Note the literal typo `questiion`** (R2) — matches the backend spec, must be
|
||
preserved (`api.service.ts` line 617).
|
||
- **Headers/JWT:** marketplace headers; customer `WebSessionID` identifies the
|
||
reviewer. No JWT.
|
||
- **Models:** `SubmitReviewInput`/`SubmitQuestionInput`
|
||
(`src/app/core/products/models/product-engagement.model.ts`). `rating` is
|
||
1-5 stars (`RatingStars`).
|
||
- **Validation:** none client-side. Rating range 1-5, non-empty comment, one
|
||
review per customer/product = Requires backend decision.
|
||
- **Errors:** `404` (unknown item), `422` (bad rating), `429` (spam throttle —
|
||
proposed, nothing client-side), common set.
|
||
- **Example R1 request:** `{ "rating": 5, "comment": "Great", "sessionID": "…", "timestamp": "2026-07-25T12:00:00Z" }`
|
||
|
||
#### 3.17.b Admin review moderation — PROPOSED (MOCK-ONLY, no seam)
|
||
|
||
Contract: `AdminModerationGateway`
|
||
(`src/app/features/admin/moderation/services/admin-moderation-gateway.interface.ts`).
|
||
Impl: `AdminModerationLocalGateway` (injected concretely by
|
||
`AdminModerationFacade`; reused by `AdminAnalyticsFacade`). DTO: `AdminReview`
|
||
(`src/app/features/admin/moderation/models/admin-review.model.ts`).
|
||
|
||
| # | Method | Path (PROPOSED) | Gateway method | Success |
|
||
|---|---|---|---|---|
|
||
| MR1 | GET | `{base}/backoffice/moderation/reviews?search&status&rating&page&pageSize` | `loadReviews(filters)` | `200 AdminReviewsListResult` |
|
||
| MR2 | GET | `{base}/backoffice/moderation/reviews/{id}` | `loadReview(id)` | `200 AdminReview` / `404` |
|
||
| MR3 | PATCH | `{base}/backoffice/moderation/reviews/{id}/status` | `setReviewStatus(id,status,note)` | `200 AdminReview` |
|
||
| MR4 | PATCH | `{base}/backoffice/moderation/reviews/{id}/visible` | `setReviewVisible(id,visible)` | `200 AdminReview` |
|
||
| MR5 | PATCH | `{base}/backoffice/moderation/reviews/{id}/pinned` | `setReviewPinned(id,pinned)` | `200 AdminReview` |
|
||
| MR6 | PATCH | `{base}/backoffice/moderation/reviews/{id}/featured` | `setReviewFeatured(id,featured)` | `200 AdminReview` |
|
||
| MR7 | POST | `{base}/backoffice/moderation/reviews/{id}/notes` | `addModeratorNote(id,note)` | `200 AdminReview` |
|
||
| MR8 | DELETE | `{base}/backoffice/moderation/reviews/{id}` | `deleteReview(id)` | `204` |
|
||
|
||
- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`
|
||
(MR1/MR2), `backoffice.write` (MR3-MR8).
|
||
- **MR1 query params** (`AdminReviewListFilters`): `search`, `status` ∈
|
||
`all|pending|approved|rejected|spam`, `rating` ∈ `all` or a number 1-5, `page`,
|
||
`pageSize`. Response `AdminReviewsListResult { items, total, page, pageSize }`.
|
||
- **MR3 (approve/reject) body:** `{ status: AdminReviewStatus, note: string }`
|
||
where status ∈ `pending|approved|rejected|spam`. This is the approve/reject
|
||
action — approving = `status:'approved'`, rejecting = `'rejected'`, marking spam
|
||
= `'spam'`. The `note` is a moderator reason, appended to the review timeline.
|
||
- **`AdminReview`:** `{ id, productId, productName, customerName, customerEmail,
|
||
rating, text, photos[], status, visible, pinned, featured, reportCount,
|
||
moderatorNotes, timeline: [{ eventKey: 'submitted'|'statusChanged'|'restored'|
|
||
'hidden', actor: 'admin'|'customer', status?, note, timestamp }], createdAt,
|
||
updatedAt }`. Server appends timeline entries on each mutation.
|
||
- **Relation to storefront reviews (§3.17.a):** the admin `AdminReview` and the
|
||
storefront `Review` (`product-engagement.model.ts`) are different types over the
|
||
same underlying data. How a `POST /items/{id}/callback` submission surfaces in
|
||
the moderation queue = Requires backend decision (they are unconnected today).
|
||
- **Validation:** none client-side.
|
||
- **Errors:** `404` (unknown id), common set.
|
||
- **Example MR3 request:** `{ "status": "approved", "note": "Looks legit" }`
|
||
|
||
---
|
||
|
||
### 3.18 Reports — PROPOSED (MOCK-ONLY, no seam)
|
||
|
||
Part of `AdminModerationGateway` (same gateway/facade as reviews §3.17.b). DTO:
|
||
`AdminReport` (`src/app/features/admin/moderation/models/admin-report.model.ts`).
|
||
|
||
| # | Method | Path (PROPOSED) | Gateway method | Success |
|
||
|---|---|---|---|---|
|
||
| RP1 | GET | `{base}/backoffice/moderation/reports` | `loadReports()` | `200 AdminReport[]` |
|
||
| RP2 | PATCH | `{base}/backoffice/moderation/reports/{id}/status` | `setReportStatus(id,status)` | `200 AdminReport` |
|
||
|
||
- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`
|
||
(RP1), `backoffice.write` (RP2).
|
||
- **RP1:** returns a **plain array, no pagination/filter params** (`loadReports()`
|
||
takes no arguments).
|
||
- **RP2 body:** `{ status: AdminReportStatus }` where status ∈
|
||
`open|resolved|dismissed`.
|
||
- **`AdminReport`:** `{ id, targetType: 'product'|'review'|'customer'|'category'
|
||
|'unknown', targetId, targetLabel, reason, reporterEmail, status, createdAt }`.
|
||
A report points at another entity via `targetType`+`targetId`.
|
||
- **No report *creation* endpoint in the frontend** — reports are read + status
|
||
changed only. Who/what creates reports (customer-facing "report this" flow) =
|
||
Requires backend decision (no such storefront submission exists in code).
|
||
- **Validation:** none client-side. **Errors:** `404` (RP2 unknown id), common set.
|
||
- **Example RP2 request:** `{ "status": "resolved" }`
|
||
|
||
---
|
||
|
||
### 3.19 Monitoring — PROPOSED, READ-ONLY (MOCK-ONLY, no seam)
|
||
|
||
Contract: `AdminMonitoringGateway`
|
||
(`src/app/features/admin/monitoring/services/admin-monitoring-gateway.interface.ts`).
|
||
Impl: `AdminMonitoringLocalGateway` (injected concretely by
|
||
`AdminMonitoringFacade`). **Read-only** — the gateway has no mutation methods
|
||
(`BACKEND-AUDIT.md` §14). DTOs in `admin-monitoring.model.ts`.
|
||
|
||
| # | Method | Path (PROPOSED) | Gateway method | Success |
|
||
|---|---|---|---|---|
|
||
| MO1 | GET | `{base}/backoffice/monitoring/events?category&search` | `loadEvents(filters)` | `200 AdminMonitoringEvent[]` |
|
||
| MO2 | GET | `{base}/backoffice/monitoring/queues` | `loadQueues()` | `200 AdminQueue[]` |
|
||
| MO3 | GET | `{base}/backoffice/monitoring/webhooks` | `loadWebhooks()` | `200 AdminWebhookDelivery[]` |
|
||
|
||
- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`.
|
||
- **MO1 query params** (`AdminMonitoringEventFilters`): `category` ∈
|
||
`all|audit|security|login|failed_login|api|error|warning`, `search`. **No
|
||
pagination** (plain array) — a backend feeding a real event stream would likely
|
||
need to add paging/time-range params; **Requires backend decision** on
|
||
windowing.
|
||
- **Models:** `AdminMonitoringEvent { id, category, level: 'info'|'warning'
|
||
|'error', message, technicalDetail?, actor, timestamp }`; `AdminQueue { name,
|
||
depth, status: 'healthy'|'degraded'|'down' }`; `AdminWebhookDelivery { id,
|
||
endpoint, event, status: 'delivered'|'failed'|'pending', timestamp }`.
|
||
- **Webhooks here are read-only observability** (delivery log), **not** webhook
|
||
configuration CRUD — there is no create/update/delete webhook endpoint in the
|
||
frontend. Webhook *management* = Requires backend decision (out of current scope).
|
||
- **Validation/errors:** read-only; common read error set.
|
||
- **Example MO2 response:** `[ { "name": "orders", "depth": 3, "status": "healthy" } ]`
|
||
|
||
---
|
||
|
||
### 3.20 Analytics — PROPOSED, DERIVED / NO REAL DATA SOURCE (MOCK-ONLY)
|
||
|
||
Per `BACKEND-AUDIT.md` §14: `AdminAnalyticsFacade` has **no gateway of its own** —
|
||
it derives everything by reusing orders/products/moderation local gateways +
|
||
`ADMIN_CATEGORIES_GATEWAY` + `AdminDashboardFacade`. Several analytics values are
|
||
**honestly `null` because no data source exists** (e.g. `conversionRate`,
|
||
`retentionPercent` — model comments: "null = unknown - no visitor/traffic tracking
|
||
exists yet. Never fabricated"). DTOs in `admin-analytics.model.ts`.
|
||
|
||
| # | Method | Path (PROPOSED) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| AN1 | GET | `{base}/backoffice/analytics/summary?range` | KPI summary | `200 AdminAnalyticsSummary` |
|
||
| AN2 | GET | `{base}/backoffice/analytics/products?range` | Product analytics | `200 AdminProductAnalytics` |
|
||
| AN3 | GET | `{base}/backoffice/analytics/customers?range` | Customer analytics | `200 AdminCustomerAnalytics` |
|
||
| AN4 | GET | `{base}/backoffice/analytics/series?range&metric` | Time series | `200 AdminAnalyticsSeriesPoint[]` |
|
||
|
||
- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`.
|
||
- **`range` query param** ∈ `7 | 30 | 90` (`AdminAnalyticsDateRange`) — the only
|
||
filter the frontend models.
|
||
- **`AdminAnalyticsSummary`:** `{ revenueTotal, currency, ordersCount,
|
||
avgOrderValue, productsCount, categoriesCount, customersCount, conversionRate:
|
||
number | null }`. **`conversionRate` MUST be `null` when unknown** — a backend
|
||
must not fabricate it absent real visitor/traffic tracking. Same for
|
||
`AdminCustomerAnalytics.retentionPercent`.
|
||
- **What has no backend data source at all (mark accordingly):** conversion rate,
|
||
retention, and anything needing visitor/traffic/pageview data — there is **no
|
||
traffic analytics ingestion anywhere in the frontend**. Building real analytics
|
||
(beyond order/product aggregates) is **Requires backend decision** end to end.
|
||
- **Validation/errors:** read-only; common read set. A backend that can't compute
|
||
a metric should return `null`, not omit or fake it.
|
||
- **Example AN1 response:**
|
||
```json
|
||
{ "revenueTotal": 4820000, "currency": "RUB", "ordersCount": 213, "avgOrderValue": 22629, "productsCount": 512, "categoriesCount": 24, "customersCount": 178, "conversionRate": null }
|
||
```
|
||
|
||
**Dashboard metrics (adjacent).** `AdminDashboardMetricsGateway.loadMetrics()`
|
||
(`ADMIN_DASHBOARD_METRICS_GATEWAY`, MOCK-SWAPPABLE token) returns
|
||
`AdminDashboardMetrics { categoriesCount, productsCount }` — the one other
|
||
token-bound admin gateway. Proposed: `GET {base}/backoffice/dashboard/metrics` →
|
||
`200 { categoriesCount, productsCount }`, permission `backoffice.read`.
|
||
|
||
---
|
||
|
||
### 3.21 Cross-cutting "Requires backend decision" register (this section)
|
||
|
||
Consolidated so the master doc can dedupe against the auth/error registers:
|
||
|
||
1. **DI seam prerequisite** — orders, products, users, transactions, monitoring,
|
||
moderation (+ derived customers/analytics) inject their `*LocalGateway`
|
||
concretely; a backend requires introducing a DI token first (`BACKEND-AUDIT.md`
|
||
§14). Only categories + dashboard-metrics are token-bound today.
|
||
2. **All admin CRUD paths are PROPOSED** — only `/backoffice/categories*` and the
|
||
storefront catalog/cart/engagement paths are literal. Adopt the
|
||
`/backoffice/<domain>` convention (or reject it).
|
||
3. **Server-owned field lists** on create/update for products/orders/etc. (only
|
||
categories defines one, by stripping `id/itemsCount/deletedAt/createdAt/updatedAt`).
|
||
4. **Field validation + error wording** — the frontend enforces essentially none
|
||
on admin CRUD (no reactive `Validators`); all `422` field rules and messages are
|
||
backend-owned.
|
||
5. **Product slug/sku uniqueness** — no proactive check exists (unlike categories'
|
||
`isSlugTaken`); decide `409` behavior.
|
||
6. **Order status-transition legality** and refund eligibility rules.
|
||
7. **Customers as a first-class resource** vs. derived-from-orders aggregate.
|
||
8. **`AdminRole` duplication** — auth string-union vs. users-management interface
|
||
(naming reconciliation; role CRUD / custom roles).
|
||
9. **Remote session revocation propagation** (`revokeSession`) to a logged-in
|
||
client (`AUTHENTICATION.md` §8 — no push/poll exists).
|
||
10. **Media**: allowed mime/size; whether in-use assets are delete-blocked.
|
||
11. **CMS/homepage/widgets/nav/footer/branding/languages/settings are LOCAL-ONLY**
|
||
— the single biggest decision is **whole-bootstrap publish (S2) vs. granular
|
||
builder endpoints** (§3.15). No builder write call exists in code today.
|
||
12. **Languages**: duplicate-add `409`, removing the default/last locale, BCP-47
|
||
validation.
|
||
13. **Transactions ↔ live payment records** are unconnected models; how they link.
|
||
14. **Reviews**: storefront `Review` vs. admin `AdminReview` linkage; how a
|
||
submitted review enters the moderation queue.
|
||
15. **Reports**: no creation flow exists — who creates reports.
|
||
16. **Monitoring**: event windowing/pagination for a real stream; webhook
|
||
*management* (vs. read-only delivery log) is out of scope.
|
||
17. **Analytics**: `conversionRate`/`retentionPercent` must stay `null` when
|
||
unknown; no traffic/visitor data source exists anywhere — real analytics is a
|
||
from-scratch backend build.
|
||
18. **Variants/options** shape reconciliation between storefront `variantOptions`
|
||
(grouped) and admin `variants` (flat priced rows); no dedicated variants
|
||
endpoint (created within the product body).
|
||
|
||
---
|
||
|
||
_All paths repo-relative to `F:\dx\remote\marketplaces\`. Storefront/category
|
||
literals and the `AdminCategoriesApiGateway` paths are verified in source; every
|
||
other endpoint is PROPOSED per the conventions in §3.0._
|
||
|
||
---
|
||
|
||
## 4. Authentication
|
||
|
||
Standalone, backend-implementable authentication contract for the marketplace
|
||
platform (Angular frontend, branch `B2B`). Derived directly from source —
|
||
`src/app/core/auth/**`, `src/app/core/admin-auth/**`,
|
||
`src/app/services/{auth,telegram-session-api}.service.ts`,
|
||
`src/app/components/telegram-login/**`, `src/app/guards/language.guard.ts`,
|
||
`src/app/app.routes.ts`, `src/app/app.config.ts`,
|
||
`src/app/core/config/tenant-resolver.service.ts` — plus
|
||
`docs/context/BACKEND-AUDIT.md` (this-session audit) and the prior
|
||
`docs/AUTH.md`. Where the frontend does not already imply a behavior, this
|
||
document says **"Requires backend decision"** rather than inventing one.
|
||
|
||
Two authentication mechanisms coexist in the codebase today, at different
|
||
maturity levels:
|
||
|
||
| Mechanism | Used by | Status |
|
||
|---|---|---|
|
||
| Telegram QR / deep-link session auth | Storefront customers **and** admin/backoffice (same API) | **LIVE** — real endpoints, in production use |
|
||
| Ed25519 challenge/response admin auth | Admin/backoffice (intended replacement) | **Frontend fully wired, backend endpoints do not exist yet** (404s today) |
|
||
|
||
Both are documented in full below. Nothing here should be read as "the
|
||
platform has JWTs today" — it does not, except inside the not-yet-live
|
||
Ed25519 flow.
|
||
|
||
---
|
||
|
||
### Table of contents
|
||
|
||
1. [Mechanism A — Telegram QR / session login (LIVE)](#1-mechanism-a--telegram-qr--session-login-live)
|
||
2. [Mechanism B — Ed25519 challenge/response admin auth (NOT LIVE)](#2-mechanism-b--ed25519-challengeresponse-admin-auth-not-live)
|
||
3. [JWT structure](#3-jwt-structure)
|
||
4. [Refresh token](#4-refresh-token)
|
||
5. [Token expiration handling](#5-token-expiration-handling)
|
||
6. [Token rotation](#6-token-rotation)
|
||
7. [Logout](#7-logout)
|
||
8. [Session invalidation](#8-session-invalidation)
|
||
9. [Role hierarchy](#9-role-hierarchy)
|
||
10. [Tenant isolation](#10-tenant-isolation)
|
||
11. [Permission model / route guards](#11-permission-model--route-guards)
|
||
12. [Open items — "Requires backend decision"](#12-open-items--requires-backend-decision)
|
||
|
||
---
|
||
|
||
### 1. Mechanism A — Telegram QR / session login (LIVE)
|
||
|
||
Single source for **both** customer and admin login:
|
||
`TelegramSessionApiService` (`src/app/services/telegram-session-api.service.ts`).
|
||
There is no separate admin backend endpoint — the same three calls back the
|
||
customer `AuthService` (`src/app/services/auth.service.ts`) and the admin
|
||
`AdminAuthService` (`src/app/core/admin-auth/admin-auth.service.ts`). Only the
|
||
**storage** differs (cookie name, in-memory signal), so an admin QR scan
|
||
never authenticates the customer session or vice versa.
|
||
|
||
#### 1.1 Endpoints (base = `environment.authApiUrl`, e.g. `https://api.dexarmarket.ru:445`)
|
||
|
||
| Method | Path | Request | Response |
|
||
|---|---|---|---|
|
||
| POST | `/users/sessions` | body `{ webSessionID }` (client-generated GUID), header `WebSessionID: <same guid>` | `{ webSessionID, url }` — `url` is the Telegram bot deep link |
|
||
| GET | `/users/sessions/{id}` | — | Session object, heavily field-tolerant (see §1.3) |
|
||
| DELETE | `/users/sessions/{id}` | header `WebSessionID: <id>` | ignored/discarded |
|
||
|
||
#### 1.2 Frontend-driven flow
|
||
|
||
The frontend, not the backend, generates the session id. Sequence:
|
||
|
||
1. User opens login (storefront "Sign in" or admin `/admin-login` gate).
|
||
2. Frontend generates a random GUID client-side (`generateGuid()`,
|
||
`src/app/shared/util/guid.util.ts`) — this **is** the `webSessionID`, sent
|
||
to the backend, not received from it.
|
||
3. `POST {authApiUrl}/users/sessions` with `{ webSessionID }` body and
|
||
`WebSessionID` header set to the same value. Backend response's own id
|
||
field is preferred if present (see `extractSessionId` — checks
|
||
`webSessionID/WebSessionID/webSessionId/sessionID/SessionID/sessionId/id/ID`
|
||
in that order), else the frontend's generated GUID is used as fallback.
|
||
4. Frontend builds two login links from the returned id:
|
||
- Web: `https://t.me/{bot}?start={webSessionID}` (`getBotLoginUrl`)
|
||
- App deep link: `tg://resolve?domain={bot}&start={webSessionID}`
|
||
(`getBotAppLoginUrl`)
|
||
- `bot` = `environment.telegramBot` (`'myAMLKYCBOT'` in current env config;
|
||
code fallback `'DexarSupport_bot'` if the env key is absent).
|
||
5. Frontend renders both as a QR code (external image generator
|
||
`https://api.qrserver.com/v1/create-qr-code/...` — not a backend of this
|
||
platform, purely a QR bitmap renderer for the `url`) plus the app deep
|
||
link for mobile. This is orchestrated by `QrLoginEngine`
|
||
(`src/app/shared/qr-login/qr-login.engine.ts`) shared identically by both
|
||
customer and admin modes via `TelegramLoginComponent`
|
||
(`src/app/components/telegram-login/telegram-login.component.ts`,
|
||
`[mode]="'customer' | 'admin'"`).
|
||
6. User scans the QR (or taps the deep link on mobile) and completes the
|
||
Telegram bot interaction out-of-band. The **backend** is expected to mark
|
||
that `webSessionID` as active/logged-in once the Telegram bot confirms the
|
||
user, associating a Telegram user identity with it.
|
||
7. Frontend polls `GET /users/sessions/{id}` (`checkSessionOnce`, driven by
|
||
`QrLoginEngine`'s polling loop) until the session normalizes to `active:
|
||
true`, or the user cancels/times out.
|
||
8. On an active session, the frontend calls `activateSession()` internally
|
||
(sets in-memory signal, stores the id per §1.4, schedules a re-check —
|
||
see §5) and redirects: customer → wherever the login was triggered from;
|
||
admin → `/{lang}/backoffice/dashboard` (hardcoded in
|
||
`telegram-login.component.ts`).
|
||
|
||
#### 1.3 Session response normalization (backend field tolerance)
|
||
|
||
`TelegramSessionApiService.normalizeWebSession()` is deliberately
|
||
tolerant of multiple backend field-naming conventions (evidence the backend
|
||
contract was never fully pinned down). A backend implementation can emit any
|
||
of these; the frontend reads the **first key found** in this priority order:
|
||
|
||
- **Active/status**: `status`, `Status`, `active`, `Active`, `loggedIn`,
|
||
`LoggedIn`, `isLoggedIn`, `IsLoggedIn`, `authenticated`, `Authenticated`.
|
||
Value is considered "active" if boolean `true`/`1`, or (case-insensitive)
|
||
one of `true, 1, active, authenticated, confirmed, success, logged_in`.
|
||
- **User object**: nested under `user`/`User`/`telegramUser`/`TelegramUser`,
|
||
else the top-level response object itself is used as the user record.
|
||
- **Username**: `username`/`Username` (user object first, then top-level).
|
||
- **First/last name**: `firstName`/`first_name`/`FirstName`/`First_name`,
|
||
`lastName`/`last_name`/`LastName`/`Last_name` — joined with a space if both
|
||
present.
|
||
- **Display name**: explicit `displayName`/`DisplayName`/`name`/`Name` (user
|
||
object or top-level) wins; else falls back to `username`; else falls back
|
||
to the joined full name; else literal `'Telegram User'`.
|
||
- **Telegram user id**: `userId`/`telegramUserId`/`telegramUserID`/
|
||
`TelegramUserID`/`id`/`ID` (user object), else `userId`/`telegramUserId`/
|
||
`telegramUserID`/`TelegramUserID`/`userID`/`UserID`/`UserId` (top-level).
|
||
- **Session id**: same priority list as `extractSessionId` above.
|
||
- **Expiry**: `expiresAt`/`ExpiresAt`/`expires`/`Expires` (ISO 8601 string);
|
||
if absent, the frontend fabricates `now + 3600s` client-side — **the
|
||
backend should always send a real `expiresAt`/`expires`** so the frontend's
|
||
refresh-scheduling (§5) reflects the true session lifetime rather than a
|
||
guessed one.
|
||
|
||
Normalized shape consumed by the frontend (`AuthSession`,
|
||
`src/app/models/auth.model.ts`):
|
||
|
||
```ts
|
||
interface AuthSession {
|
||
sessionId: string;
|
||
userId: number | null;
|
||
username: string | null;
|
||
displayName: string;
|
||
active: boolean;
|
||
expires: string; // ISO 8601
|
||
}
|
||
```
|
||
|
||
#### 1.4 Storage (customer vs admin — kept fully separate)
|
||
|
||
| | Customer (`AuthService`) | Admin (`AdminAuthService`) |
|
||
|---|---|---|
|
||
| Cookie name | `webSessionID` | `adminSessionID` |
|
||
| Cookie attrs | `Max-Age=3600; Path=/; SameSite=Lax` (+`Secure` over HTTPS) | `Max-Age=3600; Path=/; SameSite=Strict` (+`Secure` over HTTPS) |
|
||
| In-memory state | `sessionSignal`, `statusSignal` (`unknown\|checking\|authenticated\|expired\|unauthenticated`) | Same shape, separate signals |
|
||
| Extra storage | — | Reserved JWT-pair slots `localStorage['adminToken']` / `localStorage['adminRefreshToken']` — **unused today**, see §12 |
|
||
|
||
Both send a `WebSessionID` header on every marketplace API request via
|
||
`apiHeadersInterceptor` (see `docs/context/BACKEND-AUDIT.md` §3) — this is
|
||
the anonymous-or-authenticated session identity the backend correlates
|
||
requests against; there is no `Authorization: Bearer` header in this
|
||
mechanism.
|
||
|
||
#### 1.5 Session re-check / soft refresh (not a token refresh)
|
||
|
||
Both `AuthService` and `AdminAuthService` self-schedule a re-check of
|
||
`GET /users/sessions/{id}` 60 seconds before `expires`, minimum 30s out
|
||
(`scheduleSessionRefresh`). This is **not** a refresh-token exchange — it
|
||
just re-polls the same session-status endpoint and re-activates if still
|
||
active, or clears local state if not. There is no rotation of the
|
||
`webSessionID` itself in this mechanism.
|
||
|
||
#### 1.6 Admin dev bypass (non-production only)
|
||
|
||
`AdminAuthService.devBypassLogin()` fabricates a local session
|
||
(`sessionId: 'dev-bypass-{timestamp}'`, `active: true`, 1-hour expiry) and
|
||
activates it directly, skipping the QR flow entirely. Guarded by
|
||
`environment.production` at runtime (not just build-time) — the checked
|
||
condition is inside the function body, so it is dead code in a production
|
||
build.
|
||
|
||
#### 1.7 Sequence diagram — storefront/admin Telegram-QR login
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User as User (browser)
|
||
participant FE as Frontend (AuthService / AdminAuthService)
|
||
participant BE as Backend (authApiUrl)
|
||
participant TG as Telegram bot
|
||
|
||
User->>FE: Open login (customer checkout, or /admin-login gate)
|
||
FE->>FE: generate webSessionID (client GUID)
|
||
FE->>BE: POST /users/sessions { webSessionID } (header WebSessionID)
|
||
BE-->>FE: 200 { webSessionID, ... }
|
||
FE->>FE: build QR + tg:// deep link from webSessionID
|
||
FE-->>User: render QR code / "Open in Telegram" button
|
||
User->>TG: scan QR / tap deep link, confirm in bot
|
||
TG->>BE: (out of band) associate webSessionID with Telegram user
|
||
loop poll every N seconds
|
||
FE->>BE: GET /users/sessions/{webSessionID}
|
||
BE-->>FE: session (active:false while pending)
|
||
end
|
||
BE-->>FE: session (active:true, user fields, expires)
|
||
FE->>FE: activateSession(): store cookie, set signals,<br/>schedule re-check at expires-60s
|
||
alt mode = admin
|
||
FE-->>User: redirect to /{lang}/backoffice/dashboard
|
||
else mode = customer
|
||
FE-->>User: close dialog, resume prior action (e.g. checkout)
|
||
end
|
||
```
|
||
|
||
---
|
||
|
||
### 2. Mechanism B — Ed25519 challenge/response admin auth (NOT LIVE)
|
||
|
||
**Status: frontend fully implemented and wired to real `HttpClient` calls;
|
||
the backend does not implement these endpoints yet — calls 404/error today.**
|
||
No route currently requires this flow (`ed25519AuthGuard` is not referenced
|
||
by any route in `app.routes.ts`; the live admin gate is still
|
||
`adminAuthGuard` / Telegram QR, §1). This is the target contract for closing
|
||
the security gap in §1: today the Telegram session API has no concept of
|
||
"admin," so the backend cannot distinguish an admin login attempt from a
|
||
customer one at the moment of login. Ed25519 closes that by requiring proof
|
||
of possession of a specific, pre-registered private key before any session
|
||
is issued.
|
||
|
||
#### 2.1 Key generation (device-local, once per device)
|
||
|
||
`Ed25519KeypairService` (`src/app/core/auth/services/ed25519-keypair.service.ts`):
|
||
|
||
- `getOrCreateKeyPair()`: generates a **non-extractable** Ed25519 keypair via
|
||
`crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])`
|
||
(real WebCrypto Ed25519 — RFC 8032, not a placeholder), persists the raw
|
||
`CryptoKey` handles in IndexedDB (`admin-auth-ed25519` DB, object store
|
||
`keypair`, single record `id: 'device-keypair'`).
|
||
- The private key is never exported, serialized, or transmitted — by
|
||
construction (`extractable: false`), not by convention or policy.
|
||
- `sign(message)`: signs a UTF-8-encoded string with `crypto.subtle.sign
|
||
('Ed25519', privateKey, ...)`, returns a base64-encoded signature.
|
||
- `clear()`: deletes the IndexedDB record ("forget this device"). A new
|
||
keypair generated after this requires re-registration with the backend
|
||
(§2.2) before it can complete a login.
|
||
- **Public key registration is explicitly out of scope for the frontend.**
|
||
An Owner/Administrator must associate a new device's `publicKeyBase64`
|
||
with an admin account through some out-of-band mechanism (backend admin
|
||
tool, one-time enrollment link, etc.) — not prescribed here (see §12).
|
||
|
||
#### 2.2 Login flow, step by step
|
||
|
||
Orchestrated by `AuthService.login()` (`src/app/core/auth/services/auth.service.ts`,
|
||
distinct from the customer/admin `AuthService` in §1 despite the identical
|
||
class name — different module, `core/auth/` vs `services/`):
|
||
|
||
1. `GET {authApiUrl}/api/admin/auth/challenge` → `AuthChallenge { nonce,
|
||
issuedAt, expiresAt }` (all ISO 8601 except `nonce`, an opaque string).
|
||
2. `Ed25519KeypairService.getOrCreateKeyPair()` (generates on first use).
|
||
3. `Ed25519KeypairService.sign(nonce)` — signs the **raw nonce string
|
||
exactly as received**, no additional framing/prefix/hashing applied
|
||
client-side.
|
||
4. `POST {authApiUrl}/api/admin/auth/verify` with body
|
||
`VerifySignatureRequest { publicKey, signature, nonce }` (`publicKey` =
|
||
base64 raw Ed25519 public key, `signature` = base64 signature over the
|
||
nonce, `nonce` = the same value echoed back).
|
||
5. Backend must: re-derive the exact signed message from the nonce it
|
||
issued, verify the signature against its own `publicKey → admin account`
|
||
mapping, confirm the nonce hasn't expired or been used before, and only
|
||
then issue tokens.
|
||
6. On success: `200 AuthTokenPair { token, refreshToken }`.
|
||
`SessionService.activate(tokens)` decodes the JWT (§3), stores both
|
||
tokens (§4), and schedules the next refresh (§5).
|
||
7. On failure: `401`/`403` → `AuthService` maps it through
|
||
`authErrorCodeFromStatus()` to `invalid-signature` (or a more specific
|
||
code — see §2.5) and the UI routes to
|
||
`/admin-login/error/invalid-signature`.
|
||
|
||
#### 2.3 API contracts (all under `{environment.authApiUrl}/api/admin/auth`)
|
||
|
||
| Method | Path | Request body | Response | Notes |
|
||
|---|---|---|---|---|
|
||
| GET | `/challenge` | — | `200 AuthChallenge` | `{ nonce, issuedAt, expiresAt }` |
|
||
| POST | `/verify` | `VerifySignatureRequest { publicKey, signature, nonce }` | `200 AuthTokenPair` \| `401` \| `403` | Issues `{ token, refreshToken }` |
|
||
| POST | `/refresh` | `RefreshTokenRequest { refreshToken }` | `200 AuthTokenPair` \| `401` | Rotation expected — see §6 |
|
||
| POST | `/logout` | `{ refreshToken }` | `204` (frontend clears local state regardless of response code/body) | Should revoke server-side |
|
||
|
||
Types: `src/app/core/auth/models/auth-api.model.ts`. HTTP client:
|
||
`src/app/core/auth/services/auth-api.service.ts` (`AuthApiService`) — thin
|
||
wrapper, no retries, no fabricated mock responses.
|
||
|
||
#### 2.4 Sequence diagram — admin login with Ed25519 signing
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Admin as Admin (browser)
|
||
participant FE as Frontend (AuthService, core/auth)
|
||
participant Key as Ed25519KeypairService (WebCrypto + IndexedDB)
|
||
participant BE as Backend
|
||
|
||
Admin->>FE: Click "Sign in"
|
||
FE->>BE: GET /api/admin/auth/challenge
|
||
BE-->>FE: 200 { nonce, issuedAt, expiresAt }
|
||
FE->>Key: getOrCreateKeyPair() (generate on first use, non-extractable)
|
||
Key-->>FE: { publicKeyBase64 }
|
||
FE->>Key: sign(nonce)
|
||
Key-->>FE: signature (base64)
|
||
FE->>BE: POST /api/admin/auth/verify { publicKey, signature, nonce }
|
||
alt signature valid & publicKey is a provisioned admin key & nonce fresh/unused
|
||
BE-->>FE: 200 { token, refreshToken }
|
||
FE->>FE: SessionService.activate(tokens):<br/>decode JWT claims, persist, schedule refresh
|
||
FE-->>Admin: redirect to /backoffice
|
||
else invalid signature / unknown key / expired or reused nonce
|
||
BE-->>FE: 401 / 403
|
||
FE-->>Admin: redirect to /admin-login/error/invalid-signature
|
||
end
|
||
```
|
||
|
||
#### 2.5 Error screens
|
||
|
||
Single component `AuthErrorPageComponent` at route `/admin-login/error/:code`
|
||
renders all five, keyed by route param. `authErrorCodeFromStatus()`
|
||
(`src/app/core/auth/models/auth-error.model.ts`) maps HTTP status → code:
|
||
`401→unauthorized`, `403→forbidden`, `0→backend-unavailable`,
|
||
`5xx→backend-unavailable`, else `unauthorized`.
|
||
|
||
| Code | Trigger | User action offered |
|
||
|---|---|---|
|
||
| `session-expired` | Refresh token rejected/expired | Sign in again |
|
||
| `invalid-signature` | `verify` returns 401/403 during login, or any client-side failure in the challenge→sign→verify chain that isn't a clearer HTTP-derived code | Try again |
|
||
| `unauthorized` | Route guard sees no active session | Sign in |
|
||
| `forbidden` | `permissionGuard` denies (authenticated but insufficient role) | Back to dashboard |
|
||
| `backend-unavailable` | Network error / 5xx / status 0 | Retry |
|
||
|
||
#### 2.6 Interceptor status — NOT registered
|
||
|
||
`src/app/core/auth/interceptors/auth.interceptor.ts` exists (adds
|
||
`Authorization: Bearer` + reactive 401-refresh-and-retry, see §5) but **is
|
||
not included** in `app.config.ts`'s `withInterceptors([...])` list today.
|
||
Confirmed in `app.config.ts`:
|
||
|
||
```
|
||
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor,
|
||
apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor])
|
||
```
|
||
|
||
`authInterceptor` is absent. Until it is registered, no request in the app
|
||
automatically attaches the Ed25519-flow JWT as a bearer token — this
|
||
confirms the mechanism is fully dormant, not partially live.
|
||
|
||
#### 2.7 Module map
|
||
|
||
```
|
||
src/app/core/auth/
|
||
├── auth.routes.ts # /admin-login, /admin-login/error/:code
|
||
├── models/
|
||
│ ├── auth-api.model.ts # AuthChallenge, VerifySignatureRequest, AuthTokenPair, JwtClaims
|
||
│ ├── auth-error.model.ts # AuthErrorCode, authErrorCodeFromStatus()
|
||
│ └── permission.model.ts # AdminRole, Permission, ROLE_PERMISSIONS
|
||
├── services/
|
||
│ ├── ed25519-keypair.service.ts # WebCrypto keygen/sign, IndexedDB persistence
|
||
│ ├── auth-api.service.ts # HttpClient calls to the 4 endpoints in §2.3
|
||
│ ├── jwt.service.ts # decode-only JWT parsing
|
||
│ ├── session.service.ts # token/claims state, persistence, refresh scheduling
|
||
│ ├── permission.service.ts # role -> permission set
|
||
│ ├── auth.service.ts # orchestrates challenge -> sign -> verify -> refresh -> logout
|
||
│ └── auth-facade.service.ts # public surface for components
|
||
├── interceptors/
|
||
│ └── auth.interceptor.ts # Authorization: Bearer + 401 refresh-and-retry (NOT registered, §2.6)
|
||
├── guards/
|
||
│ ├── ed25519-auth.guard.ts # requires SessionService.isAuthenticated() (not referenced by any route)
|
||
│ └── permission.guard.ts # permissionGuard(permission) factory
|
||
└── pages/
|
||
├── admin-login-page.component.* # sign-in UI
|
||
└── auth-error-page.component.* # parameterized error screen (§2.5)
|
||
```
|
||
|
||
`AuthFacade` (`src/app/core/auth/services/auth-facade.service.ts`) is the
|
||
only thing components/pages should depend on; `AuthService`/
|
||
`SessionService`/`PermissionService` are internal collaborators.
|
||
|
||
---
|
||
|
||
### 3. JWT structure
|
||
|
||
Only defined for Mechanism B (Ed25519 flow) — Mechanism A (§1) issues no JWT,
|
||
only an opaque session id. Expected claims
|
||
(`src/app/core/auth/models/auth-api.model.ts::JwtClaims`):
|
||
|
||
```ts
|
||
interface JwtClaims {
|
||
sub: string; // admin account id
|
||
role: AdminRole; // 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly'
|
||
iat: number; // seconds since epoch (standard `iat`)
|
||
exp: number; // seconds since epoch (standard `exp`)
|
||
publicKey: string; // the Ed25519 public key this token was issued for
|
||
}
|
||
```
|
||
|
||
`JwtService.decode()` (`src/app/core/auth/services/jwt.service.ts`) does
|
||
**decode-only** parsing (base64url payload → JSON), and validates only the
|
||
minimal shape: `sub` is a string, `role` is a string, `exp` is a number — if
|
||
any of these three checks fail, decoding returns `null` and the caller
|
||
(`SessionService`) discards the session as malformed.
|
||
|
||
The frontend never verifies the JWT signature — it has no trusted key to
|
||
check it against; that is exclusively the backend's job on every
|
||
subsequent admin request. A decoded-but-unverified claim is UX (role-gated
|
||
menus, expiry countdowns) — never proof of authorization to any client-side
|
||
check.
|
||
|
||
---
|
||
|
||
### 4. Refresh token
|
||
|
||
Defined only for Mechanism B. `AuthTokenPair { token, refreshToken }` is
|
||
returned by both `/verify` and `/refresh`. Storage
|
||
(`SessionService`, `src/app/core/auth/services/session.service.ts`):
|
||
|
||
- `localStorage['ed25519AdminToken']` — access token (JWT)
|
||
- `localStorage['ed25519AdminRefreshToken']` — refresh token (opaque to the
|
||
frontend; never decoded, only round-tripped)
|
||
|
||
Both are written together in `activate()` and cleared together in `clear()`.
|
||
There is no separate expiry tracked for the refresh token client-side —
|
||
the frontend only reacts to a `401` from `/refresh` (see §5/§6).
|
||
|
||
Separately, `AdminAuthService` (Mechanism A, Telegram) reserves
|
||
`localStorage['adminToken']` / `localStorage['adminRefreshToken']` with
|
||
`getAdminToken()`/`setAdminTokens()`/`clearAdminTokens()` methods —
|
||
**written by no code path today** ("reserved for once the backend issues
|
||
admin access/refresh tokens... unused until then," per the source comment).
|
||
These are a distinct, currently-dead pair of storage keys from the Ed25519
|
||
ones above; do not conflate them.
|
||
|
||
---
|
||
|
||
### 5. Token expiration handling
|
||
|
||
#### 5.1 Mechanism A (Telegram session) — expiry via re-poll
|
||
|
||
See §1.5. `expires` from the session payload drives a `setTimeout` at
|
||
`max(expiresMs - now - 60_000, 30_000)` that re-calls `GET /users/sessions/
|
||
{id}`; if the backend now reports inactive, local state is cleared to
|
||
`unauthenticated`. There is no interceptor-level reactive handling for this
|
||
mechanism — a 401/expired session surfaces only through the next explicit
|
||
`checkSessionOnce()` poll or session re-check timer, not a per-request
|
||
retry.
|
||
|
||
#### 5.2 Mechanism B (Ed25519/JWT) — proactive + reactive
|
||
|
||
`SessionService.scheduleRefresh(claims)`: computes
|
||
`refreshInMs = max(claims.exp*1000 - now - 60_000, 5_000)` and sets a timer.
|
||
When it fires, `AuthService.refresh()` runs automatically
|
||
(`session.onRefreshDue(callback)` wiring, set up once in `AuthService`'s
|
||
constructor to avoid a circular DI dependency between the two services).
|
||
|
||
`SessionService.restore()` (intended to run once at app bootstrap, from an
|
||
`APP_INITIALIZER` calling `AuthFacade.restoreSession()` — **not yet wired
|
||
into the bootstrap process today**, see §12): reads persisted tokens,
|
||
decodes claims, and either resumes with a scheduled refresh or marks
|
||
`expired` immediately without any network call, so a stale session is
|
||
caught before any component/guard runs.
|
||
|
||
`authInterceptor` (present in source, not registered — §2.6) is documented
|
||
as: catch a 401 on any admin-gated request → attempt one `refresh()` →
|
||
retry the original request once on success → route to `session-expired` on
|
||
failure. Does not retry more than once; a second 401 after an
|
||
apparently-successful refresh is treated as a server-side problem, not a
|
||
transient race.
|
||
|
||
#### 5.3 Sequence diagram — token expiration / refresh (Ed25519 flow)
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant FE as Frontend (SessionService)
|
||
participant IC as authInterceptor (not yet registered, §2.6)
|
||
participant BE as Backend
|
||
|
||
Note over FE: Timer fires ~60s before JWT exp
|
||
FE->>BE: POST /api/admin/auth/refresh { refreshToken }
|
||
alt refresh token still valid
|
||
BE-->>FE: 200 { token, refreshToken }
|
||
FE->>FE: activate(tokens) - reschedules next refresh
|
||
else refresh token expired/revoked
|
||
BE-->>FE: 401
|
||
FE->>FE: SessionService.markExpired()
|
||
FE-->>FE: route to /admin-login/error/session-expired
|
||
end
|
||
|
||
Note over IC: Reactive path - any 401 on an admin request<br/>(inactive until authInterceptor is registered)
|
||
IC->>BE: Admin API request (expired token)
|
||
BE-->>IC: 401
|
||
IC->>BE: POST /api/admin/auth/refresh (single retry)
|
||
alt refresh succeeds
|
||
BE-->>IC: 200 tokens
|
||
IC->>BE: retry original request with new token
|
||
else refresh fails
|
||
IC-->>FE: propagate error, route to session-expired
|
||
end
|
||
```
|
||
|
||
---
|
||
|
||
### 6. Token rotation
|
||
|
||
**Mechanism A**: no token to rotate — the `webSessionID` itself is stable
|
||
for the life of the session; expiry is handled by re-polling status (§5.1),
|
||
not by issuing a new id.
|
||
|
||
**Mechanism B**: rotation is *expected* by the frontend but not verifiable
|
||
until the backend exists. Per the source comment in `AuthApiService` and the
|
||
security notes in the prior `docs/AUTH.md`:
|
||
|
||
- Every `POST /refresh` response is expected to include a **new**
|
||
`refreshToken`; the backend should invalidate the one just used
|
||
(single-use refresh tokens).
|
||
- The frontend always stores whatever pair it receives from `/verify` or
|
||
`/refresh` and never reuses an old refresh token after a successful
|
||
rotation — there is no client-side retry logic that would resend a stale
|
||
refresh token.
|
||
- **Requires backend decision**: refresh-token reuse detection / revocation
|
||
cascade (e.g. if a rotated-out refresh token is presented again, should the
|
||
backend revoke the entire token family as a compromise signal?). Nothing
|
||
in the frontend implies or depends on this — it is a pure backend policy
|
||
choice.
|
||
|
||
---
|
||
|
||
### 7. Logout
|
||
|
||
**Mechanism A** (`AdminAuthService.logout()` / `AuthService.logout()` in
|
||
`src/app/services/auth.service.ts`): `DELETE /users/sessions/{id}` with
|
||
`WebSessionID` header, then unconditionally clears local state (cookie,
|
||
signals, timers) regardless of the HTTP result.
|
||
|
||
**Mechanism B** (`AuthService.logout()` in `src/app/core/auth/services/`):
|
||
clears `SessionService` state **immediately and unconditionally** (before
|
||
the network call resolves), then best-effort calls
|
||
`POST /api/admin/auth/logout { refreshToken }` if a refresh token was
|
||
present; any error from that call is swallowed (`catchError(() =>
|
||
throwError(() => null))`). If no refresh token exists locally, no network
|
||
call is made at all. **Backend implication**: the frontend cannot be relied
|
||
upon to reliably deliver the logout call (network failure, tab closed
|
||
mid-request, etc.) — server-side session/token expiry must not depend on a
|
||
client-issued logout ever arriving. `AuthFacade.logout()` additionally
|
||
always navigates to `/admin-login` (default) via `finalize()`, regardless of
|
||
API outcome.
|
||
|
||
#### 7.1 Sequence diagram — logout (both mechanisms)
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User
|
||
participant FE as Frontend
|
||
participant BE as Backend
|
||
|
||
User->>FE: Click "Log out"
|
||
FE->>FE: Clear local session state immediately<br/>(cookie / tokens / signals / timers)
|
||
alt Mechanism A (Telegram session)
|
||
FE->>BE: DELETE /users/sessions/{id} (header WebSessionID)
|
||
BE-->>FE: any response (ignored)
|
||
else Mechanism B (Ed25519/JWT) - only if a refresh token existed
|
||
FE->>BE: POST /api/admin/auth/logout { refreshToken }
|
||
BE-->>FE: 204 (or error, swallowed)
|
||
end
|
||
FE-->>User: redirect to login page
|
||
```
|
||
|
||
---
|
||
|
||
### 8. Session invalidation
|
||
|
||
Client-side triggers that clear local auth state, both mechanisms:
|
||
|
||
- Explicit logout (§7).
|
||
- Session status re-check (Mechanism A) returning `active: false` (§1.5).
|
||
- JWT decode failure on restore (Mechanism B) — a malformed/unparsable
|
||
stored token is treated as no session at all (`SessionService.restore()`
|
||
calls `clear()`).
|
||
- Refresh failure (Mechanism B) — any error from `/refresh` calls
|
||
`SessionService.markExpired()`.
|
||
- `AdminAuthService.clearAuthState()` also clears the reserved
|
||
`adminToken`/`adminRefreshToken` keys (§4) even though nothing currently
|
||
writes them, for forward-compatibility once Mechanism A gains a token
|
||
pair.
|
||
|
||
**Requires backend decision**: server-side session/token revocation
|
||
propagation — e.g., can an Owner revoke another admin's active session
|
||
remotely (relevant given `AdminUsersGateway.revokeSession` already exists as
|
||
a **mock-only** admin-users gateway method per
|
||
`docs/context/BACKEND-AUDIT.md` §14)? If so, the frontend has no push
|
||
mechanism (no websocket, no polling of "is my token still valid" beyond the
|
||
scheduled refresh) to learn about a remote revocation before its next
|
||
refresh/request attempt — a session could remain "authenticated" client-side
|
||
for up to the refresh interval after a backend-side revocation. If real-time
|
||
revocation is required, that is new frontend work, not something already
|
||
implied by existing code.
|
||
|
||
---
|
||
|
||
### 9. Role hierarchy
|
||
|
||
**Discrepancy flagged by the backend audit — `AdminRole` is defined twice
|
||
with different meanings. Reconciliation needed before backend
|
||
implementation:**
|
||
|
||
1. `src/app/core/auth/models/permission.model.ts` — a **string union** used
|
||
by the Ed25519/JWT flow's `role` claim and `PermissionService`:
|
||
```ts
|
||
type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly';
|
||
```
|
||
Ordered highest-to-lowest privilege by convention (not enforced in code —
|
||
`PermissionService` does not rely on ordering, only exact role → permission
|
||
set lookup).
|
||
|
||
2. `src/app/features/admin/users/models/admin-user.model.ts` — an
|
||
**interface** describing an admin-users-management row (`{ id, name, ...
|
||
}`), unrelated in shape to #1 and used only by the mock admin-users
|
||
gateway/facade (`AdminUsersGateway`, MOCK-ONLY, no backend seam per the
|
||
audit).
|
||
|
||
These two `AdminRole` symbols do not currently reference each other and are
|
||
imported from different modules by different features. A backend
|
||
implementer should treat #1 (the permission-model union) as the JWT/role
|
||
claim contract for auth purposes, and flag #2 for a naming rename (e.g.
|
||
`AdminUserRoleRecord`) rather than assuming they describe the same concept.
|
||
This document does not resolve the collision — it is called out so a human
|
||
reconciles it before building the backend role table.
|
||
|
||
#### 9.1 Permission-to-role mapping (from `ROLE_PERMISSIONS`)
|
||
|
||
| Role | Permissions |
|
||
|---|---|
|
||
| `Owner` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage`, `settings.manage` |
|
||
| `Administrator` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage` |
|
||
| `Editor` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write` |
|
||
| `Support` | `backoffice.read` |
|
||
| `ReadOnly` | `backoffice.read`, `builder.read` |
|
||
|
||
This is deliberately coarse and mirrors the bootstrap-level
|
||
`PermissionsConfig` shape (`src/app/shared/models/config/permissions.model.ts`).
|
||
Finer-grained, per-domain permissions (e.g. "can edit prices but not delete
|
||
products") do not exist anywhere client-side and stay server-side —
|
||
**Requires backend decision** if finer granularity is ever needed.
|
||
|
||
---
|
||
|
||
### 10. Tenant isolation
|
||
|
||
`TenantResolverService` (`src/app/core/config/tenant-resolver.service.ts`)
|
||
resolves tenant by **subdomain**, not by header or path prefix:
|
||
|
||
```ts
|
||
getTenantKey(): string {
|
||
if (isLocalhost()) return environment.fallbackTenantKey ?? 'default';
|
||
const segments = hostname.split('.').filter(Boolean);
|
||
if (segments.length === 0) return environment.fallbackTenantKey ?? 'default';
|
||
if (segments[0] === 'www' && segments.length > 1) return segments[1];
|
||
return segments[0];
|
||
}
|
||
```
|
||
|
||
- `isLocalhost()` matches `localhost`, `127.0.0.1`, `::1`.
|
||
- On a real host, the tenant key is the **first DNS label**, skipping a
|
||
leading `www`. E.g. `dexarmarket.api.dexarmarket.ru` → tenant key
|
||
`dexarmarket`; `www.acme.com` → `acme`.
|
||
- This tenant key feeds `ApiConfigService.getBaseUrl()`
|
||
(`src/app/core/config/api-config.service.ts`, documented in
|
||
`docs/context/BACKEND-AUDIT.md` §2) to pick the marketplace API base URL:
|
||
localhost → `environment.localhostApiUrl` (`/api`); else
|
||
`environment.tenantApiBaseUrls[tenantKey]`; else
|
||
`environment.tenantApiTemplate` with `{tenant}` substituted; else
|
||
(gated by `allowBootstrapApiOverride`, off by default) a value read out of
|
||
the already-loaded bootstrap document
|
||
(`bootstrap.apiEndpoints.website.baseUrl` /
|
||
`bootstrap.tenant.apiBaseUrl`).
|
||
- **No `X-Tenant` header or `/tenant/{id}/...` path prefix is sent by the
|
||
frontend anywhere** — tenant isolation for the marketplace API is achieved
|
||
purely by **which base URL/subdomain the request is sent to**, not by a
|
||
request attribute the backend reads per-call. Auth (both mechanisms) does
|
||
**not** carry any tenant identifier in its request bodies or headers
|
||
either — `POST /users/sessions`, `/api/admin/auth/challenge`, etc. are all
|
||
called against `environment.authApiUrl`, a single fixed origin, with no
|
||
per-tenant variation in the auth-flow code today.
|
||
- **Requires backend decision**: if auth (session creation, Ed25519
|
||
challenge/verify) must be tenant-scoped (e.g. an admin's Ed25519 public key
|
||
should only authorize them for one tenant's backoffice), the frontend
|
||
currently has no mechanism to communicate which tenant a login attempt is
|
||
for beyond whatever the backend can infer from the request's origin/
|
||
Referer header — nothing in the auth payloads carries a tenant id
|
||
explicitly. This would be new frontend work if required.
|
||
|
||
---
|
||
|
||
### 11. Permission model / route guards
|
||
|
||
#### 11.1 `adminAuthGuard` (live, Mechanism A) — `src/app/core/admin-auth/admin-auth.guard.ts`
|
||
|
||
```ts
|
||
export const adminAuthGuard: CanActivateFn = () => {
|
||
const adminAuth = inject(AdminAuthService);
|
||
if (adminAuth.isAuthenticated()) return true;
|
||
adminAuth.requestLogin();
|
||
return false;
|
||
};
|
||
```
|
||
|
||
Checks only `AdminAuthService.isAuthenticated()` (Telegram session status
|
||
`=== 'authenticated'`) — no role/permission check at all. Applied to
|
||
`/edit`, `/edit/:section`, and `/backoffice` (and its children) in
|
||
`app.routes.ts`. This guard **cannot** distinguish admin roles from each
|
||
other — it is purely "is there an active admin Telegram session," which is
|
||
the exact gap Mechanism B is meant to close.
|
||
|
||
#### 11.2 `ed25519AuthGuard` (dormant, Mechanism B) — `src/app/core/auth/guards/ed25519-auth.guard.ts`
|
||
|
||
Requires `SessionService.isAuthenticated()` (JWT status `=== 'authenticated'`).
|
||
Not referenced by any route in `app.routes.ts` today — confirmed by source
|
||
search. Exists purely as the cutover target (see §2's "not live" status).
|
||
|
||
#### 11.3 `permissionGuard(permission)` — `src/app/core/auth/guards/permission.guard.ts`
|
||
|
||
Factory guard that checks `PermissionService.has(permission)` against the
|
||
Mechanism B role → permission table (§9.1). Also unused by any live route
|
||
until Mechanism B is cut over, but ready to gate specific admin sub-routes
|
||
by permission once it is (e.g. `permissionGuard('users.manage')` on a users
|
||
page).
|
||
|
||
#### 11.4 `languageGuard` — `src/app/guards/language.guard.ts`
|
||
|
||
Not an authentication guard, but gates every localized route (`:lang`
|
||
segment wraps the entire route tree in `app.routes.ts`). Behavior:
|
||
|
||
- If `:lang` param is a known, enabled language: preload its translation
|
||
pack (`TranslateService.preloadLanguage`), set it as current
|
||
(`LanguageService.setLanguage`), allow navigation.
|
||
- If known but **disabled**: redirect to the current default language,
|
||
preserving the rest of the path.
|
||
- If unrecognized entirely: treat the URL as a legacy no-lang-prefix URL and
|
||
redirect to `/{defaultLang}{originalUrl}`, preserving query string/fragment
|
||
via `router.parseUrl` (not a hand-built `UrlTree`, to avoid double-encoding
|
||
the query string into the path segment).
|
||
|
||
#### 11.5 `canDeactivate` guards (dirty-state guards, not auth)
|
||
|
||
Also not authentication, but listed since the task asked for "what guards
|
||
check": `projectEditorDirtyGuard`, `adminProductDirtyGuard`,
|
||
`adminCategoryDirtyGuard` — all gate navigation *away* from an in-progress
|
||
editor (builder section, product editor, category editor) to warn about
|
||
unsaved changes. They read editor dirty-state signals, not auth state, and
|
||
are unrelated to session/token validity.
|
||
|
||
#### 11.6 What the frontend actually gates, summarized
|
||
|
||
| Concern | Mechanism | Guard/service |
|
||
|---|---|---|
|
||
| "Is there an active admin session at all" | Telegram (A) | `adminAuthGuard` → `AdminAuthService.isAuthenticated()` |
|
||
| "Is there an active admin JWT session" | Ed25519 (B), not live | `ed25519AuthGuard` → `SessionService.isAuthenticated()` |
|
||
| "Does this role have permission X" | Ed25519 (B), not live | `permissionGuard(permission)` → `PermissionService.has()` |
|
||
| "Is `:lang` valid/enabled" | n/a | `languageGuard` |
|
||
| "Unsaved editor changes" | n/a | `*DirtyGuard` (project editor / product / category) |
|
||
|
||
**Every one of these is a client-side UX gate only.** None of them are a
|
||
substitute for server-side authorization — the backend must independently
|
||
verify role/permission on every admin mutation regardless of what a route
|
||
guard decided, per the security note already present in the prior
|
||
`docs/AUTH.md` and repeated here: a passing client-side check is not proof
|
||
of anything to the backend.
|
||
|
||
---
|
||
|
||
### 12. Open items — "Requires backend decision"
|
||
|
||
Consolidated list of everything this document could not derive from
|
||
existing frontend code and therefore does not prescribe:
|
||
|
||
- **Public-key enrollment mechanism** (§2.1) — how an admin's Ed25519
|
||
`publicKeyBase64` gets associated with an account/role server-side (admin
|
||
tool? one-time enrollment link? manual DB entry?). Zero frontend code
|
||
exists for this by design.
|
||
- **Refresh-token reuse/compromise detection** (§6) — whether presenting an
|
||
already-rotated-out refresh token should revoke the whole token family.
|
||
Not implied by any frontend behavior.
|
||
- **Session/token revocation propagation** (§8) — whether/how a
|
||
remotely-revoked admin session (e.g. via the mock `AdminUsersGateway.
|
||
revokeSession`) is communicated to an already-logged-in client before its
|
||
next refresh cycle. No push/poll mechanism exists today.
|
||
- **Tenant scoping of auth requests** (§10) — whether login/challenge/verify
|
||
need an explicit tenant identifier in the payload, versus relying on
|
||
request origin. Not present in any current auth payload.
|
||
- **Relationship between the two mechanisms at cutover** — replace
|
||
`adminAuthGuard` with `ed25519AuthGuard` outright, or run both and let
|
||
role/tenant config decide? Explicitly called out in the prior
|
||
`docs/AUTH.md` as "a product decision, not made here," and nothing has
|
||
changed that.
|
||
- **`AdminAuthService`'s reserved JWT-pair slots** (`adminToken`/
|
||
`adminRefreshToken`, §4) — whether Mechanism A is ever meant to gain its
|
||
own token pair (as the reserved-but-unused storage suggests) independent
|
||
of the Ed25519 migration, or whether that code is dead and should be
|
||
removed. Not resolved by current usage (nothing writes to it).
|
||
- **`AdminRole` naming collision** (§9) — a reconciliation/rename decision
|
||
between `core/auth/models/permission.model.ts`'s string union and
|
||
`features/admin/users/models/admin-user.model.ts`'s interface; flagged,
|
||
not resolved, by this document.
|
||
- **Fine-grained/per-domain permissions** (§9.1) — the current model is
|
||
intentionally coarse; whether a richer permission model is ever needed is
|
||
a backend/product decision.
|
||
- **`APP_INITIALIZER` wiring for `AuthFacade.restoreSession()`** (§5.2) — the
|
||
code comment says this should be wired in before the Ed25519 flow goes
|
||
live, but it is not wired in today. This is frontend follow-up work, not a
|
||
backend decision, but is listed here because it changes what "session
|
||
restored on refresh" means in practice until it lands.
|
||
|
||
---
|
||
|
||
## 5. Security
|
||
|
||
Every claim below is grounded in current source on branch `B2B`. Where the
|
||
frontend implies nothing, the item is marked **Requires backend decision**.
|
||
Auth and error contracts are owned by sibling docs — this section references
|
||
`docs/AUTHENTICATION.md` and `docs/ERROR_CONTRACT.md` rather than restating
|
||
them, and only covers the security-relevant angle.
|
||
|
||
### 5.1 Origin
|
||
|
||
The frontend calls a small, fixed set of origins, all declared in
|
||
`src/environments/environment.ts` / `environment.production.ts`:
|
||
|
||
| Origin | Source key | Used for |
|
||
|---|---|---|
|
||
| `https://api.dexarmarket.ru:445` | `tenantApiBaseUrls.default`, `authApiUrl`, and prod `apiUrl`/`localhostApiUrl` | marketplace API + session auth + Ed25519 admin auth |
|
||
| `https://{tenant}.api.dexarmarket.ru:445` | `tenantApiTemplate` | per-tenant marketplace API (subdomain-substituted) |
|
||
| `https://qr.vitanova.network/api` | `qrApiUrl` | payment / QR / card status |
|
||
| `/api` (relative) | dev `apiUrl`/`localhostApiUrl` | localhost, proxied by nginx per tenant |
|
||
| `https://api.qrserver.com/...` | literal in `ApiService` | QR **bitmap** image only (not a platform backend) |
|
||
| `http://ip-api.com/json/...` | literal in `LocationService` | external geo-IP autodetect (no key, plaintext HTTP) |
|
||
|
||
The tenant marketplace origin is chosen by **subdomain** (`TenantResolverService`,
|
||
see `docs/AUTHENTICATION.md` §10) — there is no `X-Tenant` header or tenant
|
||
path prefix. **Backend note:** the browser will make cross-origin requests to
|
||
`api.dexarmarket.ru:445` and `qr.vitanova.network` from whatever host the SPA
|
||
is served on (e.g. `dexarmarket.ru`), so those origins must return correct
|
||
CORS headers (§5.2). `ip-api.com` is called over plaintext HTTP — a mixed-content
|
||
and privacy concern flagged here; the frontend already tolerates its failure
|
||
(falls back to 6 hardcoded regions).
|
||
|
||
### 5.2 CORS
|
||
|
||
**Bearer/header-only, never credentialed.** No `HttpClient` call in the
|
||
codebase sets `withCredentials: true` (grep: zero matches in `src/`). Identity
|
||
travels in **headers**, not cookies:
|
||
|
||
- `WebSessionID` on every marketplace API request (`apiHeadersInterceptor`).
|
||
- `AdminWebSessionID` + `Authorization: Bearer <token>` on `/admin/`,
|
||
`/backoffice/`, `/builder/`, `/media/` requests (`adminAuthHeadersInterceptor`).
|
||
- `authorization-key` / `userid-value` on QR payment calls.
|
||
|
||
The auth **cookies** (`webSessionID`, `adminSessionID`) are same-site,
|
||
first-party, and are **not** sent cross-origin to the API — they exist only so
|
||
the SPA can restore its own session locally. **Backend requirement:** the API
|
||
must accept these requests as CORS with `Access-Control-Allow-Origin` set to
|
||
the specific SPA origin(s) and `Access-Control-Allow-Headers` including
|
||
`WebSessionID, AdminWebSessionID, Authorization, X-Region, X-Language, Currency`
|
||
(and the QR API's `authorization-key, userid-value`). Because no request is
|
||
credentialed, `Access-Control-Allow-Credentials` is **not** required and
|
||
`Allow-Origin` may be specific per tenant.
|
||
|
||
### 5.3 CSRF
|
||
|
||
**Not applicable to the API surface, by construction.** All API authentication
|
||
is bearer/header-based (§5.2) with **no cookie ever transmitted to the API**
|
||
and `withCredentials` never set. A CSRF attack relies on the browser
|
||
auto-attaching an ambient credential (cookie) to a forged cross-site request;
|
||
since the API authenticates on a header the attacker's page cannot set
|
||
cross-origin, a classic CSRF POST cannot forge an authenticated call. There is
|
||
zero CSRF-token handling in the frontend (no XSRF interceptor, no
|
||
`X-CSRF-Token`/`X-XSRF-TOKEN` reference anywhere in `src/`), and none is
|
||
needed for the current design. **Backend note:** this guarantee holds only as
|
||
long as the API does **not** start accepting a cookie as an auth credential —
|
||
if a future endpoint authenticates via cookie, CSRF protection becomes
|
||
mandatory. Requires backend decision only if that design change is made.
|
||
|
||
### 5.4 JWT
|
||
|
||
Cross-reference `docs/AUTHENTICATION.md` §3–§4. Security-relevant storage
|
||
angle only:
|
||
|
||
- **Live today (Telegram session, Mechanism A):** no JWT at all — an opaque
|
||
`webSessionID` in a cookie + in-memory signal. The reserved
|
||
`localStorage['adminToken']` / `adminRefreshToken` slots are **dead** (nothing
|
||
writes them).
|
||
- **Ed25519 flow (Mechanism B, dormant):** access + refresh tokens live in
|
||
`localStorage` (`ed25519AdminToken`, `ed25519AdminRefreshToken`).
|
||
**XSS exposure implication:** `localStorage` is readable by any script in the
|
||
origin, so a single XSS foothold (see §5.8) exfiltrates the admin token pair
|
||
outright — there is no `HttpOnly` cookie protecting them. This is the
|
||
standard SPA trade-off and is acceptable **only** if the XSS surface (§5.8)
|
||
is genuinely closed server-side. **Requires backend decision:** whether admin
|
||
tokens should instead be delivered as `HttpOnly; Secure; SameSite` cookies
|
||
to remove the XSS-exfiltration path — the frontend currently assumes
|
||
`localStorage`, so this would be a coordinated frontend+backend change.
|
||
|
||
### 5.5 Refresh
|
||
|
||
Refresh tokens are protected client-side only as well as `localStorage`
|
||
protects them — i.e. not from XSS (§5.4). The frontend never decodes the
|
||
refresh token (round-tripped opaque), stores it beside the access token, and
|
||
replaces the pair on every `/verify` and `/refresh` (`docs/AUTHENTICATION.md`
|
||
§4, §6). Rotation is **expected** but only the backend can enforce it. There is
|
||
no client-side reuse-detection. **Requires backend decision** (already flagged
|
||
in `docs/AUTHENTICATION.md` §6): single-use refresh tokens + reuse/compromise
|
||
revocation cascade — nothing in the frontend implies or depends on it.
|
||
|
||
### 5.6 Rate limiting
|
||
|
||
There is **no client-side rate-limit handling** (grep: no `429` / "rate limit"
|
||
reference in `src/`; confirmed by `docs/ERROR_CONTRACT.md` §429). The frontend
|
||
does, however, apply **debounce** on user-driven request bursts, which implies
|
||
the natural request cadence a backend limiter should tolerate rather than block:
|
||
|
||
- Search-as-you-type: `debounceTime(220)` in `SearchFacade`
|
||
(`src/app/features/search/facade/search.facade.ts:98`) — so a typing user
|
||
produces at most ~4–5 `GET /searchitems` calls/second, not one per keystroke.
|
||
- Project-editor autosave: ~300 ms debounce before a draft commit
|
||
(localStorage only, not a backend call today).
|
||
|
||
**Requires backend decision** on the rate-limit contract end to end (limits,
|
||
`Retry-After` header vs `retryAfterSeconds` body field, and whether the
|
||
frontend should auto-retry). Per `docs/ERROR_CONTRACT.md` §429, no retry
|
||
interceptor exists — honoring 429 is net-new frontend work, not a config
|
||
change.
|
||
|
||
### 5.7 Replay protection
|
||
|
||
The Ed25519 signing flow (`docs/AUTHENTICATION.md` §2) signs the **raw
|
||
backend-issued nonce string exactly as received** — `Ed25519KeypairService.sign(nonce)`
|
||
applies no client-side framing, prefix, hashing, timestamp, or
|
||
counter (`src/app/core/auth/services/auth.service.ts` login flow;
|
||
`ed25519-keypair.service.ts`). The `VerifySignatureRequest` body is
|
||
`{ publicKey, signature, nonce }` and nothing else. Therefore **all** replay
|
||
protection is the backend's responsibility via the nonce it issues:
|
||
`AuthChallenge { nonce, issuedAt, expiresAt }`. **Backend requirement:** treat
|
||
each nonce as single-use, bind it to the issuing client, reject it after
|
||
`expiresAt`, and reject any reuse. The frontend contributes no independent
|
||
freshness signal — there is no client timestamp or per-request nonce on any
|
||
other call either (the Telegram session flow has no signing at all).
|
||
|
||
### 5.8 XSS
|
||
|
||
The frontend renders **backend/CMS-controlled HTML** in three places — this is
|
||
the single most important input for what the backend must sanitize:
|
||
|
||
| Location | File | Handling today |
|
||
|---|---|---|
|
||
| Storefront static page body (CMS content) | `src/app/pages/static-page/static-page.component.ts:104-106` | Runs `DomSanitizer.sanitize(SecurityContext.HTML, html)` **first**, then `bypassSecurityTrustHtml(sanitized)`. So it is Angular-sanitized before injection into `[innerHTML]`. |
|
||
| Static-page **preview** (editor) | `src/app/features/content-management/components/static-page-preview/static-page-preview.component.ts:49` | `bypassSecurityTrustHtml(sanitized)` after a sanitize pass. |
|
||
| Payment bank-frame URL | `src/app/pages/cart/cart.component.ts:475` | `bypassSecurityTrustResourceUrl(bankUrl)` — trusts a backend-supplied iframe URL. |
|
||
|
||
The static-page path is the risky one: CMS body HTML originates from the
|
||
bootstrap document (`BootstrapConfig.staticPages`, backend-served). It **is**
|
||
run through Angular's sanitizer client-side, but Angular sanitization strips
|
||
active content for the DOM context only — it is **not** a substitute for
|
||
server-side sanitization/storage validation. **Backend requirement:** treat all
|
||
CMS static-page HTML and any product-description HTML as untrusted on write,
|
||
sanitize/allowlist server-side, and never assume the client sanitizer is the
|
||
only defense. The `bypassSecurityTrustResourceUrl` on the bank payment frame
|
||
means the backend-supplied payment URL must be strictly validated server-side
|
||
(a malicious value renders in an iframe with `ResourceUrl` trust). Product
|
||
descriptions are currently normalized to plain strings by
|
||
`ApiService.normalizeItem` and are **not** rendered via `[innerHTML]` today, so
|
||
they are lower-risk — but that holds only while descriptions stay plain-text.
|
||
|
||
Note: the project-editor HTML editor (`marketplace-html-editor.component.ts`)
|
||
uses `innerHTML` on a `contenteditable` surface for authoring; that is
|
||
author-local editor state, not backend-rendered content, so it is out of the
|
||
backend's sanitization scope.
|
||
|
||
### 5.9 CSP
|
||
|
||
**No CSP meta tag in `src/index.html`** (confirmed — only SEO/OG/Twitter/theme
|
||
meta tags are present). CSP is delivered **at the web-server layer** via
|
||
`nginx.conf`, and only on the primary tenant block:
|
||
|
||
- `dexarmarket.ru` server block (`nginx.conf:41`) sets a full
|
||
`Content-Security-Policy`:
|
||
`default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;`
|
||
plus `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`,
|
||
`X-XSS-Protection: 1; mode=block`, `Referrer-Policy: strict-origin-when-cross-origin`,
|
||
`Permissions-Policy: camera=(), microphone=(), geolocation=()`.
|
||
- **The `lovero.store` block and the new-tenant template block set the other
|
||
security headers but omit the `Content-Security-Policy` header entirely** —
|
||
an inconsistency the backend/ops team should reconcile so every tenant gets
|
||
the same CSP.
|
||
|
||
**Backend/ops observations:** the current CSP is permissive — `script-src`
|
||
allows `'unsafe-inline'` and `'unsafe-eval'`, `connect-src 'self' https:`
|
||
allows connections to any HTTPS origin, and `img-src` allows any `https:` and
|
||
`data:`. This weakens the XSS defense-in-depth that would otherwise back up
|
||
§5.8. **Requires backend decision:** whether to tighten the CSP (drop
|
||
`unsafe-inline`/`unsafe-eval`, pin `connect-src` to the known API origins in
|
||
§5.1) and whether to apply it uniformly across all tenant server blocks.
|
||
|
||
### 5.10 Upload validation (client-side, in the media picker/library)
|
||
|
||
The only upload path is `MockMediaRepository.upload()`
|
||
(`src/app/core/media/mock-media-repository.service.ts`). Before storing, it
|
||
runs `validateFile(file)`:
|
||
|
||
- **Size:** rejects if `file.size > MAX_FILE_SIZE_BYTES` where
|
||
`MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024` (**10 MB**, line 9).
|
||
- **Type:** rejects if `file.type` is not in `ALLOWED_MIME_TYPES`
|
||
(line 12) — see §5.12 for the exact list.
|
||
|
||
It then transforms before storage: raster images are downscaled to
|
||
`MAX_IMAGE_DIMENSION = 2000` px on the longest edge at `COMPRESS_QUALITY = 0.85`
|
||
(canvas re-encode to JPEG/PNG); GIFs pass through untouched; **SVGs are
|
||
sanitized** (§5.11). Validation is keyed on `file.type` (the browser-reported
|
||
MIME), which is trivially spoofable — see §5.11. The `<input type=file>` in the
|
||
media picker does not set an `accept` attribute filtering the OS picker, so the
|
||
MIME check is the only gate.
|
||
|
||
### 5.11 Media validation (backend-side expectations)
|
||
|
||
Because the client checks above run in the browser and can be bypassed
|
||
(direct API call, forged `Content-Type`, renamed file), the backend must
|
||
**re-validate everything independently**:
|
||
|
||
- **Re-derive the real content type** from magic bytes / content sniffing, not
|
||
the client-declared MIME or filename extension. `assetKind()` client-side
|
||
keys purely off the declared MIME string.
|
||
- **Re-enforce the size limit** server-side (10 MB is the frontend's number,
|
||
§5.10 / §5.12 — the backend may set its own, but must enforce one).
|
||
- **Re-sanitize SVGs.** The frontend's `sanitizeSvg()` only strips `<script>`
|
||
elements and `on*="..."` inline event-handler attributes via regex
|
||
(lines 124-131). This is **naive** — it misses `<foreignObject>`,
|
||
`javascript:` URIs in `href`/`xlink:href`, CSS-based vectors, and namespaced
|
||
handlers. SVG is served with `image/svg+xml` and can execute script when
|
||
loaded as a document, so the backend must run a real SVG sanitizer/allowlist,
|
||
never trust the client's regex pass.
|
||
- **Strip/normalize metadata** (EXIF, embedded payloads) on ingest.
|
||
- **Serve uploaded assets from an isolated origin / with `Content-Disposition`
|
||
and `X-Content-Type-Options: nosniff`** so a malicious file can't be
|
||
interpreted as active content on the app origin.
|
||
|
||
### 5.12 Maximum file sizes
|
||
|
||
Exact client-enforced constant: **10 MB** —
|
||
`MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024`
|
||
(`mock-media-repository.service.ts:9`). The user-facing error is
|
||
`"File exceeds the 10MB limit."`. Related transform constants (not rejection
|
||
limits): `MAX_IMAGE_DIMENSION = 2000` px, `COMPRESS_QUALITY = 0.85`. No
|
||
per-account or aggregate storage quota is enforced client-side — see §7 Limits.
|
||
|
||
### 5.13 Mime validation
|
||
|
||
Exact client-accepted MIME allowlist
|
||
(`ALLOWED_MIME_TYPES`, `mock-media-repository.service.ts:12`):
|
||
|
||
```
|
||
image/jpeg, image/png, image/webp, image/gif, image/svg+xml, application/pdf
|
||
```
|
||
|
||
These map to the `MediaAssetKind` union (`image` | `svg` | `pdf` | `other`)
|
||
via `assetKind()`. The media picker's type filter exposes only
|
||
`image / svg / pdf` to the user. No extension allowlist exists separately — the
|
||
check is MIME-string only. The backend allowlist should mirror this set (and
|
||
re-derive type from content, §5.11).
|
||
|
||
### 5.14 Audit logging
|
||
|
||
The admin **Monitoring** feature defines an event taxonomy the backend is
|
||
expected to populate. `AdminMonitoringCategory`
|
||
(`src/app/features/admin/monitoring/models/admin-monitoring.model.ts:1`) is:
|
||
|
||
```
|
||
'audit' | 'security' | 'login' | 'failed_login' | 'api' | 'error' | 'warning'
|
||
```
|
||
|
||
Each `AdminMonitoringEvent` carries `{ id, category, level (info|warning|error),
|
||
message, technicalDetail?, actor, timestamp }`. The page filters events by these
|
||
categories (`admin-monitoring-page.component.ts:26`). This is **MOCK-ONLY today**
|
||
(no backend seam — see `docs/context/BACKEND-AUDIT.md` §14), but it defines the
|
||
**event types the backend audit log must eventually emit** for the UI to be
|
||
meaningful:
|
||
|
||
- `audit` — admin actions taken (create/update/delete on any admin domain).
|
||
- `security` — security-relevant events (permission changes, revocations).
|
||
- `login` / `failed_login` — successful and failed authentication attempts,
|
||
with an `actor` identity.
|
||
- `api` / `error` / `warning` — request-level diagnostics.
|
||
|
||
Additionally, `AdminUserAuditEntry`
|
||
(`src/app/features/admin/users/models/admin-user.model.ts:43`) implies a
|
||
per-user audit trail of `roleChanged` / `statusChanged` events with `actor` and
|
||
`timestamp`. **Backend requirement:** every admin mutation and auth event must
|
||
be recorded with actor + timestamp so these two audit surfaces have real data;
|
||
each event needs a stable `category`/`eventKey`, a human `message`, and an
|
||
optional `technicalDetail`. **Requires backend decision:** retention window,
|
||
whether failed-login events capture source IP/device (the `AdminSession` model
|
||
carries `device`/`ip`, so the frontend already anticipates it), and the exact
|
||
action→category mapping.
|
||
|
||
### 5.15 Permission matrix (role × domain × actions)
|
||
|
||
**Critical finding first:** the **live** admin gate does **no role check at
|
||
all**. `adminAuthGuard` (`src/app/core/admin-auth/admin-auth.guard.ts`) only
|
||
verifies `AdminAuthService.isAuthenticated()` — a binary "is there an active
|
||
Telegram admin session." No admin component conditionally shows/hides actions
|
||
by the current user's role (grep of `src/app/features/admin`: the `permission`/
|
||
`role` matches are all about **managing other users as data** — role labels,
|
||
the users-management `AdminRole` record, audit entries — never gating the
|
||
current operator's own UI). So today, any authenticated admin can invoke every
|
||
admin action the UI exposes; enforcement must be entirely server-side.
|
||
|
||
The **intended** matrix comes from the dormant Ed25519 flow's `ROLE_PERMISSIONS`
|
||
table (`src/app/core/auth/models/permission.model.ts`, mirrored in
|
||
`docs/AUTHENTICATION.md` §9.1). Roles are the string union
|
||
`Owner | Administrator | Editor | Support | ReadOnly`; permission domains are
|
||
`backoffice`, `builder`, `users`, `settings`:
|
||
|
||
| Role | backoffice.read | backoffice.write | builder.read | builder.write | users.manage | settings.manage |
|
||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||
| **Owner** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||
| **Administrator** | ✅ | ✅ | ✅ | ✅ | ✅ | — |
|
||
| **Editor** | ✅ | ✅ | ✅ | ✅ | — | — |
|
||
| **Support** | ✅ | — | — | — | — | — |
|
||
| **ReadOnly** | ✅ | — | ✅ | — | — | — |
|
||
|
||
Guards that would eventually enforce this (`ed25519AuthGuard`,
|
||
`permissionGuard(permission)`) exist but are **not referenced by any route**
|
||
today (`docs/AUTHENTICATION.md` §11). The mapping is deliberately coarse —
|
||
there is no per-domain granularity (e.g. "edit prices but not delete products")
|
||
anywhere client-side.
|
||
|
||
**Backend requirements / decisions:**
|
||
- The backend **must** independently authorize every admin mutation against the
|
||
authenticated role — a passing client guard is never proof of authorization.
|
||
- **Requires backend decision:** finer-grained per-domain permissions if ever
|
||
needed (none implied client-side).
|
||
- **Requires backend decision:** the `AdminRole` naming collision (auth string
|
||
union vs the users-management `AdminRole` interface with `{id, name,
|
||
permissions[], builtIn}`) — flagged in `docs/AUTHENTICATION.md` §9; the
|
||
users-management interface suggests custom/non-builtin roles with arbitrary
|
||
permission-string sets, which the coarse 5-role union does not model. Reconcile
|
||
before building the backend role table.
|
||
|
||
---
|
||
|
||
|
||
---
|
||
|
||
## 6. Error Model
|
||
|
||
Single unified error-response format the backend must return for every non-2xx
|
||
response across all API surfaces (marketplace API, payment/QR API, session
|
||
auth API, Ed25519 admin auth API, and any future admin/builder/backoffice
|
||
APIs). Derived by cross-referencing every place the Angular frontend
|
||
currently parses, catches, or reacts to an HTTP error — see
|
||
`docs/context/BACKEND-AUDIT.md` for the full backend-surface audit this is
|
||
based on.
|
||
|
||
**Finding: the frontend does not currently parse any backend error envelope.**
|
||
No `HttpInterceptor` in the pipeline (`src/app/app.config.ts` →
|
||
`mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor,
|
||
adminAuthHeadersInterceptor, cacheInterceptor`) inspects error responses —
|
||
all five only touch outgoing requests or successful GET caching. Every
|
||
consumer that reacts to failure does so on the RxJS/`HttpErrorResponse`
|
||
level (`error.status`, `error.message`), never on a parsed JSON error body.
|
||
The one exception is the Ed25519 admin-auth flow, which has a client-side
|
||
`AuthErrorCode` union but (see §"Known frontend gap" below) currently derives
|
||
it from **HTTP status only**, not from any body field. Because of this, the
|
||
envelope below is a **clean proposal, not a reverse-engineered contract** —
|
||
every shape decision is marked accordingly.
|
||
|
||
### The envelope
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "VALIDATION_FAILED",
|
||
"message": "One or more fields are invalid.",
|
||
"status": 422,
|
||
"requestId": "b3f1c2a0-4e21-4d3a-9e77-1e8f6a2d9c11",
|
||
"details": [
|
||
{ "field": "sku", "code": "REQUIRED", "message": "SKU is required." }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**Requires backend decision: adopt this envelope.** The frontend has no
|
||
existing opinion to preserve (no code reads `error.error.code` today), so
|
||
this is a recommendation, chosen to be consistent with the shapes the
|
||
frontend *does* already have opinions about:
|
||
|
||
- Top-level `{ code, message, status }` mirrors the existing `AuthError`
|
||
interface (`src/app/core/auth/models/auth-error.model.ts:13-18`) almost
|
||
field-for-field — reusing that shape means the Ed25519 auth module can
|
||
parse the new envelope with only a `status` fallback removed, not a
|
||
rewrite.
|
||
- `details[]` entries `{ field, code, message }` mirror the existing
|
||
client-side `ProjectValidationIssue` convention (`code, message, section,
|
||
fieldKey, severity` — `src/app/features/project-editor/services/
|
||
project-validator.service.ts:23-36`, consumed via `ProjectEditorFacade
|
||
.fieldError(fieldKey)`). No backend field-error shape exists to preserve
|
||
today (admin CRUD is 100% local/mock — see BACKEND-AUDIT.md §14), so this
|
||
is the closest existing frontend convention to align a real one to.
|
||
- `requestId` is new (no frontend code reads it yet) — recommended so
|
||
support/ops can correlate a user-visible failure to server logs. If
|
||
adopted, the frontend would need a small addition to surface it in
|
||
error-state UI (not present today).
|
||
|
||
Field notes:
|
||
|
||
| Field | Required | Notes |
|
||
|---|---|---|
|
||
| `error.code` | yes | Stable, machine-readable, `UPPER_SNAKE_CASE`. Never localized. This is what the frontend should branch on, not `message`. |
|
||
| `error.message` | yes | Human-readable fallback (English), safe to show only when the frontend has no i18n mapping for `code`. Never the sole signal for UI branching. |
|
||
| `error.status` | yes | Must equal the HTTP status of the response (redundant with the transport layer, but the frontend's own `AuthError.status` already carries this, so keep parity). |
|
||
| `error.requestId` | recommended | Opaque correlation id, echoed in logs. |
|
||
| `error.details` | only for 422 | Array of field-level issues, see §422 below. |
|
||
|
||
---
|
||
|
||
### Status-by-status contract
|
||
|
||
#### 401 — Unauthenticated / expired token
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "UNAUTHENTICATED",
|
||
"message": "Authentication is required to access this resource.",
|
||
"status": 401,
|
||
"requestId": "…"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend reaction today:**
|
||
- **Admin Ed25519 flow** (`AuthService.login()`/`refresh()` in
|
||
`src/app/core/auth/services/auth.service.ts`): any `HttpErrorResponse` with
|
||
status 401 is mapped via `authErrorCodeFromStatus()` → `AuthErrorCode
|
||
'unauthorized'`, surfaced by `AuthErrorPageComponent`
|
||
(`src/app/core/auth/pages/auth-error-page.component.ts`) with copy
|
||
"Unauthorized… Sign in" and a button that calls `router.navigateByUrl
|
||
('/admin-login')`.
|
||
- **Customer Telegram session auth** (`TelegramSessionApiService`,
|
||
`AuthService` customer-facing, `src/app/services/auth.service.ts`): no
|
||
code branches on a 401 status anywhere — session validity is instead
|
||
polled via `checkSessionOnce()` returning `AuthSession | null`. **Requires
|
||
backend decision**: whether/how a mid-session 401 on a customer-facing
|
||
marketplace call (e.g. `POST /cart`, `POST /orders`) should be surfaced —
|
||
today it would fall through to each caller's generic `catchError`/`error:`
|
||
handler (if any) with no unified "session expired, please re-auth" UX.
|
||
- **Admin backoffice CRUD (products/orders/users/etc.)**: these facades
|
||
(`AdminUsersFacade`, `AdminOrdersFacade`, …) currently only ever talk to
|
||
local/mock gateways, so no real 401 has ever reached them. Their existing
|
||
generic `error` boolean signal + `common.errorTitle`/`common.errorDescription`
|
||
+ retry button (see "Generic list-page error UI" below) is the pattern a
|
||
real 401 would fall into **unless** the facades are updated to branch on
|
||
status — they don't today.
|
||
|
||
#### 403 — Forbidden (wrong role or tenant)
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "FORBIDDEN",
|
||
"message": "Your account does not have permission to perform this action.",
|
||
"status": 403,
|
||
"requestId": "…"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend reaction today:** Ed25519 admin flow only. `authErrorCodeFromStatus(403)`
|
||
→ `'forbidden'` → `AuthErrorPageComponent` copy "Forbidden… Back to
|
||
dashboard", button `router.navigateByUrl('/backoffice')`. No tenant-scoping
|
||
distinction exists in this code path — a 403 caused by wrong role and a 403
|
||
caused by wrong tenant render identical copy today. **Requires backend
|
||
decision**: if tenant-mismatch should be visually distinct from
|
||
role-mismatch, it needs its own `error.code` (e.g. `TENANT_FORBIDDEN` vs
|
||
`ROLE_FORBIDDEN`) since the frontend has no other signal to key off besides
|
||
status today.
|
||
|
||
#### 404 — Not found
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "NOT_FOUND",
|
||
"message": "The requested item could not be found.",
|
||
"status": 404,
|
||
"requestId": "…"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend reaction today:** No code path distinguishes 404 from any other
|
||
failure. `catalog-container.component.ts` and
|
||
`product-details-container.component.ts` both catch *any* load error and
|
||
render the same generic `catalog.errorTitle`/`productDetails.errorTitle`
|
||
empty-state (`en.ts:176,277`) — a real 404 (product deleted) and a 500
|
||
(server crash) look identical to the user today. **Requires backend
|
||
decision**: whether the frontend should be enhanced to show a distinct
|
||
"this product no longer exists" message for 404 specifically (would need a
|
||
status/code check added to those two containers — not present now).
|
||
|
||
#### 409 — Conflict
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "CONFLICT",
|
||
"message": "A category with this slug already exists.",
|
||
"status": 409,
|
||
"requestId": "…"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend reaction today:** no code catches or branches on 409 anywhere.
|
||
The one related concept in the codebase is `AdminCategoriesGateway
|
||
.isSlugTaken(slug, excludingId)` (BACKEND-AUDIT.md §14) — a **proactive**
|
||
pre-check call the frontend makes *before* submitting, not a reaction to a
|
||
409 conflict response. **Requires backend decision**: whether create/update
|
||
endpoints should also return 409 on the same slug/uniqueness conflict as a
|
||
race-condition backstop, and whether the frontend should add a 409 handler
|
||
that surfaces `error.details` inline (there is no such handler today —
|
||
`isSlugTaken` is the only existing conflict-avoidance mechanism, and it is
|
||
best-effort/TOCTOU-prone).
|
||
|
||
#### 422 — Validation failure
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "VALIDATION_FAILED",
|
||
"message": "One or more fields are invalid.",
|
||
"status": 422,
|
||
"requestId": "…",
|
||
"details": [
|
||
{ "field": "sku", "code": "REQUIRED", "message": "SKU is required." },
|
||
{ "field": "price", "code": "OUT_OF_RANGE", "message": "Price must be greater than 0." }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend reaction today:** no admin form currently parses a backend
|
||
validation-error body — all admin CRUD is local/mock (BACKEND-AUDIT.md §14),
|
||
so there has never been a real 422 to react to. The frontend **does** have
|
||
an established field-error UI convention worth preserving: `ProjectEditorFacade
|
||
.fieldError(fieldKey): string | null`
|
||
(`src/app/features/project-editor/facade/project-editor.facade.ts:371-374`)
|
||
reads from `issuesByField` (a `Map<fieldKey, ProjectValidationIssue[]>`) and
|
||
returns the first issue's `message`, for inline per-field template binding.
|
||
That mechanism is entirely client-side validation today (`ProjectValidator`
|
||
service), not backend-driven. **Requires backend decision**: adopting
|
||
`details[].field` as the join key would let a future `fieldError()`-style
|
||
adapter merge backend 422 errors into the same inline-error UI pattern
|
||
without inventing a second one — but the adapter itself does not exist yet
|
||
and would need to be built.
|
||
|
||
#### 429 — Rate limited
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "RATE_LIMITED",
|
||
"message": "Too many requests. Please slow down.",
|
||
"status": 429,
|
||
"requestId": "…",
|
||
"retryAfterSeconds": 30
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend reaction today: none whatsoever.** No interceptor, facade, or
|
||
component in the codebase references `429` or "rate limit" in any form (grepped
|
||
across `src/`). **Requires backend decision** on every aspect:
|
||
- Whether the backend sends a `Retry-After` HTTP header, a body field
|
||
(`retryAfterSeconds` above), or both.
|
||
- Whether the frontend should retry automatically (with backoff) or only
|
||
show the user a "please wait Ns" message. Recommend: since no retry
|
||
interceptor exists today, add one is a new build item, not a config
|
||
change.
|
||
|
||
#### 500 — Server error
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "INTERNAL_ERROR",
|
||
"message": "An unexpected error occurred. Please try again.",
|
||
"status": 500,
|
||
"requestId": "…"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend reaction today:** falls into whichever generic catch-all a given
|
||
caller has:
|
||
- Ed25519 admin flow: `authErrorCodeFromStatus()` default branch → `status
|
||
>= 500 ? 'backend-unavailable' : 'unauthorized'` → same
|
||
"Backend unavailable… Retry" screen as a network-down 503 (see below) —
|
||
the frontend does not distinguish "server is up but this request 500'd"
|
||
from "server is completely unreachable."
|
||
- Admin list pages (`AdminUsersFacade` and siblings): generic `error`
|
||
boolean signal set to `true` in the RxJS `error:` callback, rendering
|
||
`common.errorTitle`/`common.errorDescription` + a retry button that
|
||
re-invokes the same load call. No status differentiation.
|
||
- Storefront catalog/product pages: same generic empty-state pattern as 404
|
||
above.
|
||
- `LocationService.getRegions()`-equivalent: falls back silently to 6
|
||
hardcoded regions on *any* error (including 500), no user-visible error at
|
||
all (`src/app/services/location.service.ts`).
|
||
|
||
#### 503 — Maintenance / unavailable
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "SERVICE_UNAVAILABLE",
|
||
"message": "The service is temporarily unavailable. Please try again shortly.",
|
||
"status": 503,
|
||
"requestId": "…"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend reaction today:** Ed25519 admin flow only, via the same
|
||
`status >= 500` branch as 500 above → `'backend-unavailable'` →
|
||
`AuthErrorPageComponent` "Backend unavailable… Retry." No other code path
|
||
reacts to 503 specifically today (marketplace API calls that 503 would just
|
||
fall into each caller's generic error handling, same as 500 above).
|
||
|
||
**Distinguishing signal from Maintenance mode (see next section):** use
|
||
`error.code`, not the HTTP status. A plain infra 503 (database down,
|
||
overload) should send `"code": "SERVICE_UNAVAILABLE"`; a deliberate
|
||
maintenance window should send `"code": "MAINTENANCE_MODE"` (still with HTTP
|
||
status 503, since it's a byte-identical "the service is not accepting
|
||
requests" situation, but a different reason). This is the only way for the
|
||
frontend to build a distinct maintenance-mode UX later, since status alone
|
||
is not enough. `docs/MAINTENANCE_MODE.md` (sibling task, in progress) owns
|
||
the UX/copy for the maintenance case — this document only fixes the wire
|
||
signal it must key off (`error.code === "MAINTENANCE_MODE"`), so the two
|
||
docs stay consistent without duplicating UX detail here.
|
||
|
||
#### Maintenance mode
|
||
|
||
Same HTTP status as above (503), distinguished purely by `error.code`:
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "MAINTENANCE_MODE",
|
||
"message": "This marketplace is temporarily down for maintenance.",
|
||
"status": 503,
|
||
"requestId": "…",
|
||
"maintenanceUntil": "2026-07-26T04:00:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
`maintenanceUntil` (ISO 8601, optional) lets the maintenance-mode UX (sibling
|
||
doc) show an ETA if the backend has one. **Requires backend decision:**
|
||
whether `maintenanceUntil` is populated reliably enough to promise in UI, or
|
||
should be treated as advisory-only.
|
||
|
||
**Frontend reaction today:** none — no maintenance-mode concept exists in
|
||
the frontend at all currently (confirmed: no matches for "maintenance" in
|
||
`src/`). This entire row is new; the sibling `docs/MAINTENANCE_MODE.md` task
|
||
should treat it as building from scratch, not preserving anything.
|
||
|
||
#### Tenant disabled
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "TENANT_DISABLED",
|
||
"message": "This marketplace is not currently active.",
|
||
"status": 403,
|
||
"requestId": "…"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend reaction today: none.** Tenant resolution
|
||
(`TenantResolverService`, `src/app/core/config/tenant-resolver.service.ts`)
|
||
only ever resolves *which* tenant a request targets (via host/subdomain); no
|
||
code path in the audited surface handles a backend telling the frontend
|
||
"this tenant exists but is disabled." **Requires backend decision** end to
|
||
end: status code (403 recommended, to reuse the existing `forbidden`
|
||
auth-error screen plumbing, vs. a dedicated status), and whether this should
|
||
route to a dedicated "tenant disabled" screen or reuse
|
||
`AuthErrorPageComponent`'s `forbidden` copy (which currently says "Your
|
||
account role does not have permission" — wrong wording for a
|
||
tenant-disabled scenario, would need a new `AuthErrorCode` entry and copy if
|
||
reused).
|
||
|
||
#### Rate limit
|
||
|
||
See **429** above — same contract, called out separately here only because
|
||
the task list asked for it as its own row. No additional distinguishing
|
||
signal needed beyond the 429 status + `RATE_LIMITED` code.
|
||
|
||
#### Expired token
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "TOKEN_EXPIRED",
|
||
"message": "Your session has expired. Please sign in again.",
|
||
"status": 401,
|
||
"requestId": "…"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend reaction today — known gap, read carefully:** `AuthService
|
||
.refresh()` (`src/app/core/auth/services/auth.service.ts:65-76`) has a
|
||
client-side `AuthErrorCode` value `'session-expired'` and passes it as
|
||
`fallbackCode` into `handleAuthError()`. **However**, `toAuthErrorShape()`
|
||
(lines 110-118) only uses `fallbackCode` when the caught error is **not** an
|
||
`HttpErrorResponse` — for an actual HTTP error it always calls
|
||
`authErrorCodeFromStatus(error.status)`, which maps 401 → `'unauthorized'`,
|
||
never `'session-expired'`, regardless of `fallbackCode`. So today, a real
|
||
backend 401 on `/refresh` renders the **generic "Unauthorized" screen**, not
|
||
"Session expired" — the "Session expired" screen is only ever reached via
|
||
the *no-refresh-token-present* client-side branch (line 67-70), never from a
|
||
real HTTP response. **Requires backend decision + frontend fix**: for a
|
||
distinct "your session expired, please sign in again" screen to actually
|
||
render on a real backend 401, either (a) the backend returns a body
|
||
`error.code: "TOKEN_EXPIRED"` and the frontend is updated to read it instead
|
||
of relying solely on `authErrorCodeFromStatus(status)`, or (b) this
|
||
distinction is accepted as unreachable today and left as future work. Flag
|
||
this gap explicitly to whoever picks up the fix — it is a pre-existing bug,
|
||
not something this contract can silently paper over.
|
||
|
||
#### Invalid signature
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "INVALID_SIGNATURE",
|
||
"message": "The signed challenge could not be verified.",
|
||
"status": 401,
|
||
"requestId": "…"
|
||
}
|
||
}
|
||
```
|
||
|
||
Ties to the Ed25519 admin-auth flow documented in `AUTHENTICATION.md`
|
||
(sibling task, in progress) — keep the `code` value (`INVALID_SIGNATURE`)
|
||
consistent with whatever that doc names the failure mode, since this
|
||
contract only defines the wire shape and that doc owns the auth-flow
|
||
narrative.
|
||
|
||
**Frontend reaction today:** same gap as "Expired token" above.
|
||
`AuthService.login()` passes `fallbackCode: 'invalid-signature'` into
|
||
`handleAuthError()`, but `toAuthErrorShape()` discards it for any real
|
||
`HttpErrorResponse` and maps a 401 from `/verify` to the generic
|
||
`'unauthorized'` screen via `authErrorCodeFromStatus()`. The dedicated
|
||
"Invalid signature… Try again" screen
|
||
(`src/app/core/auth/pages/auth-error-page.component.ts:21-25`) exists in the
|
||
copy table but is **currently unreachable from a real backend response** for
|
||
the same reason as `session-expired` above. **Requires backend decision +
|
||
frontend fix**: backend must send a body-level `error.code:
|
||
"INVALID_SIGNATURE"` and the frontend's `toAuthErrorShape()` must be updated
|
||
to prefer a body code over the status-only mapping, or this screen stays
|
||
dead code reachable only via non-HTTP error paths.
|
||
|
||
---
|
||
|
||
### Generic list-page error UI (for reference)
|
||
|
||
Every admin backoffice list page (`AdminUsersFacade`, `AdminOrdersFacade`,
|
||
`AdminMonitoringFacade`, `AdminModerationFacade`, `AdminTransactionsFacade`,
|
||
`AdminProductsFacade`, `AdminCategoriesFacade`, `AdminAnalyticsFacade`,
|
||
`AdminCustomersFacade`, `AdminDashboardFacade`) follows the same shape,
|
||
added by RC-02 (`e153a67 fix(backoffice): add error+retry states to Users,
|
||
Monitoring, Analytics, Reports`):
|
||
|
||
```ts
|
||
readonly error = signal(false);
|
||
// on load:
|
||
error: () => { this.items.set([]); this.loading.set(false); this.error.set(true); }
|
||
```
|
||
|
||
```html
|
||
@else if (facade.error()) {
|
||
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
|
||
<span slot="actions">
|
||
<app-button variant="primary" (click)="facade.loadAll()">{{ 'common.retry' | translate }}</app-button>
|
||
</span>
|
||
</app-empty-state>
|
||
}
|
||
```
|
||
|
||
This is a **boolean** error flag — it does not branch on HTTP status or
|
||
`error.code` today. Every status in this contract (401/403/404/409/422/429/
|
||
500/503) would currently collapse into the same generic "Something went
|
||
wrong / retry" UI on these pages **unless** the facades are individually
|
||
updated to inspect `error.code`/`status` and branch — none do today. Wiring
|
||
that up is out of scope for this document (it defines the wire contract);
|
||
flagging it here so whoever wires real backends into these facades knows
|
||
the current ceiling of frontend error UX is "generic retry," not
|
||
per-status handling, except in the Ed25519 admin-auth module.
|
||
|
||
---
|
||
|
||
### Summary: "Requires backend decision" items
|
||
|
||
- **Envelope adoption** — the `{ error: { code, message, status, requestId,
|
||
details? } }` shape itself; no frontend code parses any envelope today.
|
||
- **401 on customer-facing marketplace calls** (`/cart`, `/orders`, etc.) —
|
||
no unified "session expired, please re-auth" UX exists for the customer
|
||
Telegram-session flow.
|
||
- **403 tenant-mismatch vs role-mismatch** distinct copy/code.
|
||
- **404 vs generic-error distinct UX** on catalog/product pages (currently
|
||
identical).
|
||
- **409 conflict handling on submit** (today only a proactive
|
||
`isSlugTaken` pre-check exists; no reactive 409 handler).
|
||
- **422 `details[]` → inline field-error adapter** for admin forms (the
|
||
client-side `fieldError()` convention exists but nothing feeds it from a
|
||
backend response yet).
|
||
- **429 rate-limit contract end to end** — header vs body, retry-after
|
||
value, and whether the frontend auto-retries (nothing exists today).
|
||
- **Maintenance-mode `maintenanceUntil` reliability** — advisory only, or
|
||
can the frontend promise an ETA.
|
||
- **Tenant-disabled status code and screen** — reuse `forbidden` copy (wrong
|
||
wording today) vs. add a dedicated `AuthErrorCode`.
|
||
- **Expired-token / invalid-signature body-code fix** — both are
|
||
**pre-existing frontend bugs**, not just missing decisions:
|
||
`toAuthErrorShape()` in `auth.service.ts` currently derives the error code
|
||
from HTTP status only and ignores the `fallbackCode` for real HTTP errors,
|
||
so the `'session-expired'` and `'invalid-signature'` screens are dead code
|
||
from any real backend response today. Fixing this requires both a backend
|
||
body `error.code` and a frontend change to prefer it.
|
||
|
||
---
|
||
|
||
## 7. Uploads
|
||
|
||
Full media-upload lifecycle contract, derived from the actual media stack:
|
||
`MediaRepository` (abstract token), `MockMediaRepository` (the only impl),
|
||
`MediaLibraryFacade`, `MediaPickerComponent`, `MediaUsageService`, and the
|
||
`MediaAsset` model. **Structural status:** media is MOCK-SWAPPABLE — bound via
|
||
`{ provide: MediaRepository, useClass: MockMediaRepository }` in `app.config.ts`,
|
||
so a real HTTP repository drops in behind the same abstract class with no UI
|
||
change. **No real HTTP media backend exists yet**, and **no literal media
|
||
endpoint path exists anywhere in code** — every path below is **PROPOSED** to
|
||
satisfy the existing `MediaRepository` method shapes. The admin-auth interceptor
|
||
already attaches `AdminWebSessionID` + `Authorization: Bearer` to any `/media/`
|
||
URL (`docs/context/BACKEND-AUDIT.md` §3), so the backend can expect those headers.
|
||
|
||
### 7.1 The `MediaAsset` contract (real interface)
|
||
|
||
`src/app/core/media/models/media-asset.model.ts`:
|
||
|
||
```ts
|
||
export type MediaAssetKind = 'image' | 'svg' | 'pdf' | 'other';
|
||
|
||
export interface MediaAsset {
|
||
id: string;
|
||
url: string;
|
||
thumbnailUrl?: string;
|
||
filename: string;
|
||
mimeType: string;
|
||
size: number; // bytes
|
||
width?: number;
|
||
height?: number;
|
||
altText?: Record<string, string>; // locale -> alt text
|
||
caption?: string; // plain, non-localized
|
||
description?: string;
|
||
decorative?: boolean; // suppresses missing-alt-text warning
|
||
tags?: string[];
|
||
folder?: string;
|
||
createdAt: string; // ISO 8601
|
||
}
|
||
|
||
export interface MediaListResult { items: MediaAsset[]; total: number; }
|
||
export interface MediaListParams {
|
||
page?: number; pageSize?: number; search?: string;
|
||
folder?: string; tag?: string; kind?: MediaAssetKind; sort?: MediaSort;
|
||
}
|
||
export type MediaSort = 'recent' | 'name' | 'size';
|
||
export interface MediaUploadOptions { folder?: string; tags?: string[]; }
|
||
```
|
||
|
||
The abstract `MediaRepository` (the exact backend contract) is:
|
||
|
||
```ts
|
||
list(params?: MediaListParams): Promise<MediaListResult>;
|
||
upload(file: File, options?: MediaUploadOptions): Promise<MediaAsset>;
|
||
remove(id: string): Promise<void>;
|
||
update(id, patch: Partial<Pick<MediaAsset,
|
||
'altText'|'tags'|'folder'|'caption'|'description'|'decorative'>>): Promise<MediaAsset>;
|
||
listFolders(): Promise<string[]>;
|
||
```
|
||
|
||
### 7.2 Media upload
|
||
|
||
- **Method / path (PROPOSED):** `POST /media` (or `/media/upload`).
|
||
- **Content type:** `multipart/form-data` — the frontend holds a raw `File`
|
||
(`MediaLibraryFacade.upload(file, options)` → `repository.upload(file,
|
||
{ folder, ...options })`). The natural wire form is a multipart body with the
|
||
binary file plus `folder` and `tags[]` fields from `MediaUploadOptions`.
|
||
- **Headers:** `AdminWebSessionID` + `Authorization: Bearer <token>` (auto-added
|
||
for `/media/` by `adminAuthHeadersInterceptor`), plus multipart boundary.
|
||
- **Request:** the file blob + optional `folder: string` and `tags: string[]`.
|
||
The current folder is injected by the facade when none is passed.
|
||
- **Response:** a single fully-populated `MediaAsset` (§7.1). The backend
|
||
assigns `id`, canonical `url`, `thumbnailUrl`, `size` (post-processing),
|
||
`width`/`height`, and `createdAt`.
|
||
- **Client pre-processing to be aware of:** the mock downscales images to
|
||
2000 px and re-encodes at quality 0.85 before "upload." A real backend
|
||
receives whatever the browser sends; it should **not** assume the client
|
||
compressed anything (§5.11) and must do its own validation and any canonical
|
||
resizing.
|
||
- **Retry:** the facade keeps the last failed `File` and exposes
|
||
`retryUpload()` / `canRetry()`, and `cancelUpload()` uses an upload-token
|
||
guard to ignore a stale in-flight result. So the backend should make upload
|
||
**idempotent-safe** on retry (a retried upload after a client-perceived
|
||
failure may duplicate) — **Requires backend decision** on dedup strategy.
|
||
|
||
### 7.3 Replace
|
||
|
||
**No distinct "replace" operation exists.** `MediaRepository` has no `replace`
|
||
method; `update()` only patches metadata (`altText, tags, folder, caption,
|
||
description, decorative`), never the binary. Changing an asset's actual file =
|
||
`remove(id)` + `upload(newFile)`, producing a **new `id` and new `url`**.
|
||
**Backend implication:** references to the old asset URL (tracked by
|
||
`MediaUsageService`, §7.5) are **not** auto-updated on replace — a true
|
||
in-place replace that preserves `id`/`url` would be new frontend+backend work.
|
||
**Requires backend decision** if in-place replace is wanted.
|
||
|
||
### 7.4 Delete (single + bulk)
|
||
|
||
- **Single:** `remove(id): Promise<void>` → **PROPOSED** `DELETE /media/{id}`.
|
||
`MediaLibraryFacade.remove(id)` also deselects and reloads.
|
||
- **Bulk:** `MediaLibraryFacade.bulkDelete(ids[])` loops `repository.remove(id)`
|
||
**one call per id** (no batch endpoint) — so bulk delete today is N ×
|
||
`DELETE /media/{id}`. The confirm-dialog / selection flow (RC-02) gates this
|
||
in the UI; the backend just sees serial deletes. **Requires backend decision:**
|
||
whether to add a batch `DELETE /media` (body `{ ids: [] }`) to avoid N
|
||
round-trips — the facade would need a matching `bulkRemove` method (not
|
||
present today).
|
||
- There is also `bulkMoveToFolder(ids, folder)` = N × `update(id, { folder })`.
|
||
- **Hard vs soft delete: Requires backend decision** — the frontend calls
|
||
`remove` and expects the asset gone from `list()`; it has no restore/trash
|
||
concept for media (unlike admin orders/categories which do have
|
||
archive/restore).
|
||
|
||
### 7.5 Metadata
|
||
|
||
`MediaAsset` carries this metadata (all editable via `update()` except the
|
||
system-assigned fields):
|
||
|
||
| Field | Editable by frontend? | Notes |
|
||
|---|---|---|
|
||
| `altText: Record<string,string>` | yes | **Per-locale** alt text (locale key → string). |
|
||
| `caption` | yes | Plain, non-localized. |
|
||
| `description` | yes | Plain. |
|
||
| `decorative: boolean` | yes | Suppresses the missing-alt warning. |
|
||
| `tags: string[]` | yes | Free-form; drives the tag filter. |
|
||
| `folder: string` | yes | Single folder; `listFolders()` derives the folder list. |
|
||
| `width` / `height` | no (system) | Set from image dimensions at upload. |
|
||
| `size` | no (system) | Bytes, post-processing. |
|
||
| `mimeType` | no (system) | |
|
||
| `filename` | no (system) | Original filename. |
|
||
| `createdAt` | no (system) | ISO 8601 upload timestamp. |
|
||
|
||
There is **no `uploader`/`updatedAt`/usage-count field** on `MediaAsset`.
|
||
Usage is computed **client-side on demand** by `MediaUsageService.findUsages(bootstrap, url)`
|
||
(`src/app/core/media/media-usage.service.ts`) — it walks the in-memory
|
||
`BootstrapConfig` looking for the asset's `url` as a string value and reports
|
||
where it is referenced (branding, theme, header, footer, static pages, etc.).
|
||
So "usage tracking" is a read-time scan of the config, **not** a stored
|
||
reverse-index. **Requires backend decision:** whether the backend should track
|
||
uploader identity and a stored usage/reference count (the frontend does not
|
||
require either today, but `metadata`-export and audit (§5.14) would benefit).
|
||
**Metadata update endpoint (PROPOSED):** `PATCH /media/{id}` with the
|
||
`update()` patch body, returning the updated `MediaAsset`.
|
||
|
||
### 7.6 Image variants
|
||
|
||
The frontend **does not use responsive image variants**. Zero `srcset`/`sizes`
|
||
usage in Angular templates (the only `srcset`-ish hit is unrelated). Images are
|
||
consumed as a **single `url`**, with an optional single `thumbnailUrl`. In the
|
||
mock, `thumbnailUrl === url` (same object URL) — there is no distinct thumbnail
|
||
rendition today. **Backend implication:** the contract is single-`url` +
|
||
optional single-`thumbnailUrl`; the frontend will happily consume a
|
||
backend-generated thumbnail if `thumbnailUrl` differs from `url`, but it never
|
||
requests a specific size or a `srcset` set. Adding true responsive variants
|
||
would be new frontend work.
|
||
|
||
### 7.7 Thumbnail generation
|
||
|
||
**Requires backend decision** on strategy — nothing in the frontend prescribes
|
||
sizes or formats. But the frontend gives a concrete **display target** so the
|
||
backend has a real number to aim at:
|
||
|
||
- The media library / picker renders a **grid** whose cells are ~150 px wide
|
||
(`MediaPickerComponent.onGridKeydown` computes columns as
|
||
`clientWidth / 150`), page size **24** items.
|
||
- A `thumbnailUrl` sized around **~150–300 px** (accounting for 2× DPI) would
|
||
serve the grid without shipping full-resolution originals.
|
||
|
||
If the backend populates `thumbnailUrl` with such a rendition, the UI uses it
|
||
automatically (falls back to `url` when absent). No other thumbnail sizes are
|
||
consumed anywhere.
|
||
|
||
### 7.8 Limits
|
||
|
||
- **Per-file size:** 10 MB, client-enforced (§5.12) — the backend must
|
||
re-enforce (§5.11).
|
||
- **MIME allowlist:** the 6 types in §5.13.
|
||
- **Upload count / batch size:** no client cap — `bulkDelete`/`bulkMoveToFolder`
|
||
loop over arbitrary-length id arrays; single-file upload only (the picker's
|
||
`<input>` takes `files?.[0]`, so **one file per upload call**).
|
||
- **Storage quota (per tenant / per account):** none client-side —
|
||
**Requires backend decision**.
|
||
- **Pagination:** `pageSize` default **24** (`DEFAULT_PAGE_SIZE`/`PAGE_SIZE`);
|
||
`list()` returns `{ items, total }` so the backend must return an accurate
|
||
`total` for the pager.
|
||
|
||
### 7.9 Future S3 compatibility
|
||
|
||
The `MediaAsset`/`MediaRepository` contract is deliberately **storage-agnostic**
|
||
and this is a **design constraint for the backend, not a frontend requirement**:
|
||
the frontend only ever sees opaque `url` / `thumbnailUrl` strings and an opaque
|
||
`id`. It never constructs storage paths, never assumes a host, and never signs
|
||
or negotiates storage directly. Therefore the backend is free to back media
|
||
with S3, a CDN, local disk, or anything else — including returning
|
||
pre-signed/CDN URLs in `url`/`thumbnailUrl` — with **no frontend change**, as
|
||
long as:
|
||
|
||
- `upload()` still returns a complete `MediaAsset` synchronously with a usable
|
||
`url` (a direct-to-S3 pre-signed **upload** flow, where the client PUTs to S3
|
||
itself, is **not** what the current `repository.upload(file)` shape expects —
|
||
it passes the `File` to the repository and awaits the finished asset;
|
||
switching to browser-direct-to-S3 would be new frontend work). **Requires
|
||
backend decision** if a pre-signed direct-upload flow is desired.
|
||
- Served asset URLs are treated as untrusted-origin content (§5.11) —
|
||
isolated origin, `nosniff`, correct `Content-Type`/`Content-Disposition`.
|
||
|
||
---
|
||
|
||
## 8. Real Backend Implementation Guide
|
||
|
||
This section is the practical, top-to-bottom guide a backend engineer follows to replace the
|
||
frontend's mock/local data layer with real endpoints. It is derived entirely from
|
||
`docs/context/BACKEND-AUDIT.md` (the exhaustive source audit) — no business logic is invented
|
||
beyond what the frontend gateway interfaces already fix. Where the frontend has no seam or no
|
||
mapper today, that gap is stated explicitly rather than papered over.
|
||
|
||
The core principle, already proven for Categories, is: **the gateway/provider _interface_ is the
|
||
contract; a real implementation only has to satisfy that interface.** You never rewrite the
|
||
interface, and — if the domain already has a DI-token seam — you never touch the facade or the
|
||
UI. The only structural surprise, called out repeatedly below, is that **most admin domains do
|
||
not have that seam yet** (audit §1, §14, §25 #1): their facade injects the concrete
|
||
`Admin*LocalGateway` class directly, so a token must be introduced before a real gateway can be
|
||
bound at all.
|
||
|
||
### 8.1 Which frontend gateways must be replaced
|
||
|
||
Every data boundary the audit found, with its swap status. "DI token status" is the single most
|
||
important column — it tells you whether you can drop in a real class (`already token-bound`) or
|
||
must first introduce a seam (`needs token added first`).
|
||
|
||
| Gateway / provider interface | Current mock/local impl file | DI token status | Real impl class to write | Consuming facade(s) |
|
||
|---|---|---|---|---|
|
||
| `ConfigProvider` | `core/bootstrap/providers/mock-bootstrap.provider.ts` | already token-bound (`CONFIG_PROVIDER`) | **already exists & LIVE** — `ApiBootstrapProvider` (`GET /bootstrap`) | UiRuntime, WebsiteRuntime, ProjectEditor, ContentManagement, Diagnostics (via `ConfigService`) |
|
||
| `ProductDataProvider` | none bound (mock via `mockDataInterceptor` only) | already token-bound (`PRODUCT_DATA_PROVIDER`) | **already exists & LIVE** — `ApiProductDataProvider` | `ProductFacade` |
|
||
| `CategoryRepository` | none bound (mock via interceptor) | already token-bound (`CATEGORY_REPOSITORY`) | **already exists & LIVE** — `ApiCategoryRepository` (`GET /category`) | `CategoryFacade` |
|
||
| `BackofficeDataProvider` | `core/backoffice/providers/mock-backoffice-data.provider.ts` | already token-bound (`BACKOFFICE_DATA_PROVIDER`) | **already exists & LIVE** — `ApiBackofficeDataProvider` (`/api/backoffice/*`) | storefront cards |
|
||
| `AdminCategoriesGateway` | `features/admin/categories/services/admin-categories-local.gateway.ts` | already token-bound (`ADMIN_CATEGORIES_GATEWAY`) | **already exists & LIVE** — `AdminCategoriesApiGateway` | `AdminCategoriesFacade`, `AdminAnalyticsFacade` |
|
||
| `AdminDashboardMetricsGateway` | `features/admin/dashboard/services/admin-dashboard-metrics.local.gateway.ts` | already token-bound (`ADMIN_DASHBOARD_METRICS_GATEWAY`) | `AdminDashboardMetricsApiGateway` (new) | `AdminDashboardFacade` |
|
||
| `MediaRepository` (abstract class) | `core/media/mock-media-repository.service.ts` | already token-bound (abstract-class token in `app.config.ts`) | `ApiMediaRepository` (new) | `MediaLibraryFacade` |
|
||
| `UserExperienceRepository` | `core/user-experience/repositories/local-user-experience.repository.ts` | already token-bound (`USER_EXPERIENCE_REPOSITORY`) | `AuthenticatedUserExperienceRepository` (new; see §8.3 note on redesign) | `UserExperienceFacade` |
|
||
| `AdminOrdersGateway` | `features/admin/orders/services/admin-orders-local.gateway.ts` | **needs token added first** | `AdminOrdersApiGateway` (new) | `AdminOrdersFacade`, `AdminCustomersFacade`, `AdminAnalyticsFacade` |
|
||
| `AdminProductsGateway` | `features/admin/products/services/admin-products-local.gateway.ts` | **needs token added first** | `AdminProductsApiGateway` (new) | `AdminProductsFacade`, `AdminAnalyticsFacade` |
|
||
| `AdminUsersGateway` | `features/admin/users/services/admin-users-local.gateway.ts` | **needs token added first** | `AdminUsersApiGateway` (new) | `AdminUsersFacade` |
|
||
| `AdminTransactionsGateway` | `features/admin/transactions/services/admin-transactions-local.gateway.ts` | **needs token added first** | `AdminTransactionsApiGateway` (new) | `AdminTransactionsFacade` |
|
||
| `AdminMonitoringGateway` | `features/admin/monitoring/services/admin-monitoring-local.gateway.ts` | **needs token added first** | `AdminMonitoringApiGateway` (new) | `AdminMonitoringFacade` |
|
||
| `AdminModerationGateway` | `features/admin/moderation/services/admin-moderation-local.gateway.ts` | **needs token added first** | `AdminModerationApiGateway` (new) | `AdminModerationFacade`, `AdminAnalyticsFacade` |
|
||
| (Customers) | reuses `AdminOrdersLocalGateway` (derived) | **needs token added first** (inherits Orders seam) | none of its own — derive from `AdminOrdersApiGateway`, or add a `/customers` source | `AdminCustomersFacade` |
|
||
| (Analytics) | reuses orders/products/moderation local + `ADMIN_CATEGORIES_GATEWAY` + `AdminDashboardFacade` | partial (categories/dashboard token-bound; rest not) | no aggregation endpoint exists — see §8.3 | `AdminAnalyticsFacade` |
|
||
| Auth session (`TelegramSessionApiService`) | mock via `mockDataInterceptor` | n/a (concrete service, LIVE) | **already exists & LIVE** — no swap; implement server endpoints only | `AuthService`, `AdminAuthService`, `AuthFacade` |
|
||
| Ed25519 admin auth (`AuthApiService`) | none | n/a (concrete service, LIVE wiring) | **client wiring exists; backend absent** — implement `/api/admin/auth/*` server-side | `AuthService` (Ed25519 flow) |
|
||
| `SearchHistoryRepository` | localStorage impl (concrete) | injected concretely — LOCAL-ONLY by design | — (leave local; no backend) | `SearchFacade` (via `SearchHistoryService`) |
|
||
|
||
Read this table as three tiers:
|
||
|
||
1. **Done / LIVE (no gateway work):** ConfigProvider, ProductDataProvider, CategoryRepository,
|
||
BackofficeDataProvider, AdminCategories. These already talk to real HTTP through a real
|
||
`*Api*` class. The backend job here is only to _stand up the server endpoints_ these clients
|
||
already call — not to touch frontend code.
|
||
2. **Token-bound, real impl missing:** DashboardMetrics, Media, UserExperience. The seam exists;
|
||
write the `*Api*` class and bind it. No facade edit.
|
||
3. **No seam yet (the bulk of admin CRUD):** Orders, Products, Users, Transactions, Monitoring,
|
||
Moderation (plus derived Customers/Analytics). Each needs a token introduced _and_ a facade
|
||
injection change _before_ a real gateway can even be bound.
|
||
|
||
### 8.2 Interfaces that stay unchanged (do not touch)
|
||
|
||
The gateway/provider interface files _are_ the frozen seam. A real implementation satisfies the
|
||
existing TypeScript interface; changing an interface is a frontend contract change and forces UI
|
||
churn. **Do not edit any of these** while wiring a backend:
|
||
|
||
- `core/config/config-provider.interface.ts` (`ConfigProvider`)
|
||
- `core/products/providers/product-data-provider.interface.ts` (`ProductDataProvider`)
|
||
- `core/categories/repositories/category.repository.ts` (`CategoryRepository`)
|
||
- `core/backoffice/providers/backoffice-data-provider.interface.ts` (`BackofficeDataProvider`)
|
||
- `core/media/media-repository.ts` (abstract class `MediaRepository`)
|
||
- `core/user-experience/repositories/user-experience.repository.ts` (`UserExperienceRepository`)
|
||
- `features/admin/categories/services/admin-categories-gateway.interface.ts` (`AdminCategoriesGateway`)
|
||
- `features/admin/dashboard/services/admin-dashboard-metrics.gateway.interface.ts` (`AdminDashboardMetricsGateway`)
|
||
- `features/admin/orders/services/admin-orders-gateway.interface.ts` (`AdminOrdersGateway`)
|
||
- `features/admin/products/services/admin-products-gateway.interface.ts` (`AdminProductsGateway`)
|
||
- `features/admin/users/services/admin-users-gateway.interface.ts` (`AdminUsersGateway`)
|
||
- `features/admin/transactions/services/admin-transactions-gateway.interface.ts` (`AdminTransactionsGateway`)
|
||
- `features/admin/monitoring/services/admin-monitoring-gateway.interface.ts` (`AdminMonitoringGateway`)
|
||
- `features/admin/moderation/services/admin-moderation-gateway.interface.ts` (`AdminModerationGateway`)
|
||
|
||
The interface method lists (the shapes the backend must satisfy) are enumerated in audit §14 and
|
||
must not drift. If the backend genuinely cannot meet a method's shape, that is a conversation to
|
||
have with the frontend owner and change the interface deliberately — not something to work around
|
||
inside a mapper.
|
||
|
||
### 8.3 Facades: which stay unchanged, which need a small adjustment
|
||
|
||
The facade is the abstraction the components consume. **If a domain already has a DI-token seam
|
||
and the real gateway honors the interface, its facade needs zero changes.** That covers all of
|
||
tier 1 and tier 2 above (ConfigProvider/Product/Category/Backoffice/AdminCategories/Dashboard/
|
||
Media/UserExperience facades are all injection-by-token already).
|
||
|
||
Facades that **do** need a (small, mechanical) change:
|
||
|
||
- **`AdminOrdersFacade`, `AdminProductsFacade`, `AdminUsersFacade`, `AdminTransactionsFacade`,
|
||
`AdminMonitoringFacade`, `AdminModerationFacade`** — each currently injects the concrete
|
||
`Admin*LocalGateway` class directly (audit §14, §21). The one-line change is to inject the new
|
||
DI token instead of the concrete class. This is unavoidable for these six because the seam does
|
||
not exist yet; it is the "add a token seam" half of the migration, not a behavior change.
|
||
|
||
Facades whose current behavior is **mock-shaped** and needs more than a wiring swap:
|
||
|
||
- **`AdminCustomersFacade`** (`features/admin/customers/facade/…`) derives its customer list by
|
||
reading `AdminOrdersLocalGateway` in memory (audit §14, §21). Once orders are real, it can keep
|
||
deriving customers from the Orders _token_, but if the backend exposes a first-class customers
|
||
source, prefer that. Either way the "derive from local orders" assumption is mock-specific and
|
||
should be revisited when Orders goes real.
|
||
- **`AdminAnalyticsFacade`** (`features/admin/analytics/facade/…`) composes orders + products +
|
||
moderation local gateways plus the categories/dashboard tokens (audit §14, §21). There is **no
|
||
analytics aggregation endpoint and no tracking pipeline today** (audit §14; remaining-work
|
||
#13/#17). Real analytics is not a gateway swap — it needs a data source that does not exist yet.
|
||
Treat it as the last domain, dependent on orders/products/moderation all being real first.
|
||
- **`UserExperienceFacade`** (`facades/platform/user-experience.facade.ts`) stores fully
|
||
denormalized wishlist/compare objects synchronously in localStorage (audit §19). The backend
|
||
decision (remaining-work #16) is id-only sync + a `GET /items/batch?ids=` hydration endpoint,
|
||
which requires redesigning the repository to store id-arrays and hydrate from a local product
|
||
cache — a real refactor, not a drop-in `*Api*` class. Flagged so nobody assumes the token swap
|
||
alone finishes wishlist sync.
|
||
- **`ContentManagementFacade`** and **`ProjectEditorFacade`** have **no backend call at all**
|
||
today (audit §16, §17). They read/mutate the in-memory `BootstrapConfig` and persist drafts to
|
||
localStorage; "publishing a marketplace" = writing bootstrap back, for which **no client write
|
||
call exists**. Wiring these to a real backend means _adding_ a draft/publish HTTP path
|
||
(`ProjectEditorIoService` currently only does JSON.stringify/parse; drafts live in
|
||
`ProjectEditorDraftStorageService` / localStorage), not swapping a gateway.
|
||
- **Cart** (`CartService`) is local-only by design (localStorage `marketplace_cart`); checkout
|
||
already emits real `POST /cart` / `POST /orders` calls (audit §10). No facade change — just be
|
||
aware cart _contents_ never round-trip to a backend.
|
||
|
||
Also note the mock gateways commonly apply an **artificial delay** and keep state in
|
||
localStorage/in-memory overlays (audit §14). A real gateway should drop the fake latency; the
|
||
facades tolerate real async already (they're signal/Observable-based), so no facade edit is needed
|
||
for that specifically.
|
||
|
||
### 8.4 DTO mapping examples (worked)
|
||
|
||
The frontend already carries adapters for its LIVE domains; a backend engineer should treat those
|
||
adapters as the **tolerance contract** for the wire shape. Three representative cases:
|
||
|
||
#### Example A — Categories (clean stack, mapper already exists)
|
||
|
||
Real backend JSON (`GET /category`, shape the client already tolerates):
|
||
|
||
```json
|
||
[
|
||
{
|
||
"categoryID": 12,
|
||
"names": [ { "lang": "ru", "name": "Электроника" }, { "lang": "en", "name": "Electronics" } ],
|
||
"subcategories": [ { "categoryID": 34, "names": [ { "lang": "en", "name": "Phones" } ] } ]
|
||
}
|
||
]
|
||
```
|
||
|
||
Frontend wire DTO: `CategoryDto`, `CategoryNameDto` (`core/categories/dto/category.dto.ts`).
|
||
Domain model: `Category`, `CategoryTranslation` (`core/categories/models/category-domain.model.ts`).
|
||
Bridge: **`CategoryMapper`** (`core/categories/mappers/category.mapper.ts`) — flattens the
|
||
subcategory tree, dedupes by id, and normalizes the language code `am → hy`. **Already written.**
|
||
Backend action: match the `CategoryDto` field names; no new mapper.
|
||
|
||
#### Example B — Products (largest existing mapper; match its tolerance)
|
||
|
||
Real backend JSON (`GET /items/{id}`) → frontend `Item` (`src/app/models/item.model.ts`).
|
||
Bridge: **`ApiService.normalizeItem()`** — the single largest inline adapter in the codebase
|
||
(audit §7). It reconciles two historical shapes and normalizes, among others:
|
||
|
||
- `id` (string) ↔ `itemID` (numeric)
|
||
- `imgs[]` ↔ `photos[]` (→ `ProductMedia`)
|
||
- `names[]` ↔ `translations`
|
||
- `description` as key/value array ↔ string
|
||
- `comments` ↔ `callbacks` (reviews)
|
||
- color `0xRRGGBB` → `#RRGGBB`; `remaining` count → stock band
|
||
|
||
Backend action: **do not "clean up" these dual shapes** — `normalizeItem`/`normalizeCategory`
|
||
define exactly what the client accepts. Producing a payload inside that tolerance envelope is the
|
||
contract; a stricter/renamed shape breaks the storefront. No new mapper needed — the adapter
|
||
exists and is LIVE.
|
||
|
||
#### Example C — Admin Orders (no mapper exists yet — one must be written)
|
||
|
||
Unlike A and B, the admin domains have **no wire DTO and no mapper** — the local gateways
|
||
construct `AdminOrder` view models directly in memory and never touch a URL (audit §14, §24).
|
||
So this is the shape you have _freedom_ to design on the backend, as long as the new
|
||
`AdminOrdersApiGateway` maps it into the existing view model.
|
||
|
||
Proposed backend JSON (`GET /backoffice/orders/:id`):
|
||
|
||
```json
|
||
{
|
||
"id": "ord_1042",
|
||
"status": "processing",
|
||
"paymentStatus": "paid",
|
||
"customer": { "id": "cus_88", "name": "…", "email": "…" },
|
||
"items": [ { "productId": "…", "title": "…", "qty": 2, "unitPrice": 1990 } ],
|
||
"shipping": { "method": "…", "address": "…" },
|
||
"timeline": [ { "event": "created", "at": "2026-07-01T10:00:00Z" } ]
|
||
}
|
||
```
|
||
|
||
Frontend view model: `AdminOrder` + `AdminOrderCustomer`, `AdminOrderPayment`,
|
||
`AdminOrderShipping`, `AdminOrderItem`, `AdminOrderTimelineEntry`, `AdminOrderStatus`,
|
||
`AdminOrderPaymentStatus` (`features/admin/orders/models/admin-order.model.ts`, audit §23).
|
||
Bridge: **a new `AdminOrdersApiGateway` must contain the mapping** JSON → `AdminOrder`, honoring
|
||
the `AdminOrdersGateway` interface methods (`loadOrders`, `loadOrder`, `updateStatus`,
|
||
`requestRefund`, `addNote`, `archiveOrder`, `restoreOrder`, `deleteOrder`). The `status` field
|
||
must respect the order state machine (see the Orders CRUD contract / archive/BACKEND_API.md §8.1).
|
||
The same "no mapper exists, write one inside the new `*ApiGateway`" note applies to Products,
|
||
Users, Transactions, Monitoring, and Moderation.
|
||
|
||
### 8.5 Per-domain migration checklist (the pattern, then the table)
|
||
|
||
**Generic pattern to flip one domain from mock to real.** Steps 1–3 are only needed for the
|
||
"no seam yet" domains; token-bound domains start at step 4.
|
||
|
||
1. **Ensure the gateway interface exists** (all admin domains already have one — audit §14). If a
|
||
domain truly has none (e.g. Customers, Analytics derive from others), decide whether to add one
|
||
or keep deriving.
|
||
2. **Add a DI token** — `InjectionToken` + factory that selects mock vs. real off
|
||
`RuntimeProviderStrategyService`, mirroring `admin-categories-gateway.token.ts`.
|
||
3. **Update the facade injection** from the concrete `Admin*LocalGateway` class to the new token.
|
||
4. **Implement the `*ApiGateway`** class satisfying the existing interface (contains the DTO→view
|
||
mapper, per §8.4 Example C).
|
||
5. **Bind the token to the real impl** in the providers (factory returns the api gateway for
|
||
`api` mode).
|
||
6. **Retire the `*LocalGateway`** — either delete it or keep it behind the existing
|
||
`useMockData` / mode flag as a dev fixture (the factory already lets both coexist).
|
||
|
||
This is the exact pattern Categories already follows (`admin-categories-api.gateway.ts` +
|
||
`admin-categories-gateway.token.ts`); replicate it verbatim.
|
||
|
||
| Domain | Needs steps 1–3 (add seam)? | Interface exists? | Real impl exists? | Net work |
|
||
|---|---|---|---|---|
|
||
| Categories (admin) | no (token-bound) | yes | **yes** | server endpoints only |
|
||
| Dashboard metrics | no (token-bound) | yes | no | steps 4–6 |
|
||
| Media | no (token-bound, class token) | yes (abstract class) | no | steps 4–6 |
|
||
| User experience | no (token-bound) | yes | no | steps 4–6 **+ repo redesign** (id-only + batch hydrate) |
|
||
| Orders | **yes** | yes | no | steps 1–6 (skip 1) |
|
||
| Products (admin) | **yes** | yes | no | steps 1–6 (skip 1) |
|
||
| Users / roles | **yes** | yes | no | steps 1–6 (skip 1) |
|
||
| Transactions | **yes** | yes | no | steps 1–6 (skip 1) |
|
||
| Monitoring | **yes** | yes | no | steps 1–6 (skip 1) |
|
||
| Moderation | **yes** | yes | no | steps 1–6 (skip 1) |
|
||
| Customers | inherits Orders seam | no (derived) | no | derive from Orders token, or add source |
|
||
| Analytics | partial | no (derived) | no | needs data source first (last) |
|
||
| Content management | n/a (no gateway) | n/a | no | **add** draft/publish HTTP path |
|
||
| Project editor / builder | n/a (no gateway) | n/a | no | **add** draft/publish HTTP path |
|
||
|
||
### 8.6 Recommended backend implementation order (all domains)
|
||
|
||
Ordered by the frontend's actual dependency structure (audit §5–§18), not by convenience:
|
||
|
||
1. **Auth + session first.** `TelegramSessionApiService` is LIVE and every admin path is gated by
|
||
the `adminAuthHeadersInterceptor` (`AdminWebSessionID` / `Bearer`, audit §3). Nothing
|
||
role-gated works until session issuance and (if used) the Ed25519 `/api/admin/auth/*` flow are
|
||
real. Blocks everything admin.
|
||
2. **Bootstrap / tenant resolution.** `GET /bootstrap` transport is done, but its **content**
|
||
(branding/theme/nav/seo) is still stubbed (remaining-work #1). Tenant resolution and
|
||
`ApiConfigService.getBaseUrl()` depend on it; every tenant-scoped call resolves through it.
|
||
3. **Categories.** Already LIVE for both storefront (`GET /category`) and admin
|
||
(`AdminCategoriesApiGateway`). Products reference categories, so categories must be real and
|
||
populated before products are meaningful. Mostly "confirm server endpoints" work.
|
||
4. **Products / catalog.** Storefront reads (`GET /items`, `/searchitems`, `/randomitems`) are
|
||
LIVE; the **admin Products CRUD** is the first no-seam domain to build (remaining-work #3, P0),
|
||
and it depends on categories.
|
||
5. **Media.** Products/categories editors reference media assets; a real `ApiMediaRepository`
|
||
(remaining-work #5, P0) should land alongside/just after admin Products.
|
||
6. **Cart / Orders / Transactions.** Checkout `POST /cart` + `POST /orders` are LIVE; admin
|
||
**Orders CRUD** (remaining-work #6) then **Transactions** (tied to orders, #7) are the P1 core
|
||
commerce domains. Orders before Transactions/Customers/Analytics (they derive from it).
|
||
7. **Reviews / Moderation.** Customer review/question writes are LIVE; admin **Moderation**
|
||
(#10) gates them. Depends on products existing.
|
||
8. **Users / roles / invitations** (#9) — admin governance; independent of commerce but needs
|
||
auth (step 1).
|
||
9. **Dashboard metrics** (#11) then **Monitoring** (#12) — operational visibility over the
|
||
domains above.
|
||
10. **Analytics** (#13/#17) — **last**: needs orders/products/moderation real _and_ a tracking
|
||
pipeline that does not exist yet.
|
||
11. **Builder draft/publish + Content/CMS** (#2 P0 for builder transport, #14 for CMS) — these are
|
||
net-new write paths (no client call exists, audit §16/§17). Builder publish is high priority
|
||
for the builder product but is orthogonal to the storefront/commerce chain, so it can proceed
|
||
in parallel once bootstrap content (step 2) is real.
|
||
12. **User-experience sync** (#16) and **search suggestions/filters** (#15) — P2/P3 enhancements
|
||
over already-working local features.
|
||
|
||
### 8.7 Estimated effort (frontend-informed)
|
||
|
||
These are **frontend-informed** sizes only — they reflect how much of the contract is already
|
||
seamed/mapped on the client. Backend has its own unknowns (schema design, storage, infra, the
|
||
tracking pipeline) that are **out of scope for this document**; a domain marked S here can still be
|
||
L on the server.
|
||
|
||
| Domain | Size | Why |
|
||
|---|---|---|
|
||
| Categories (storefront + admin) | **S** | LIVE both sides; `CategoryMapper` + `AdminCategoriesApiGateway` exist. Server endpoints only. |
|
||
| Products storefront reads | **S** | LIVE; `normalizeItem` absorbs shape variance. |
|
||
| Bootstrap content | **M** | Transport done; producing real branding/theme/nav/seo values is the work (#1). |
|
||
| Media | **M** | Token seam + abstract interface ready; write `ApiMediaRepository` + upload pipeline (#5). |
|
||
| Admin Orders | **M** | Interface + rich view models exist, but no seam and no mapper — steps 1–6 + mapper. |
|
||
| Admin Products CRUD + variants | **L** | No seam, no mapper; largest admin model set (variants/attributes/translations/seo). |
|
||
| Admin Users/roles/invitations | **M** | No seam; also reconcile the duplicate `AdminRole` naming (audit §14/§25 #3). |
|
||
| Admin Transactions | **M** | No seam; tied to Orders shape. |
|
||
| Admin Moderation | **M** | No seam; two state machines (reviews + reports). |
|
||
| Admin Monitoring | **M** | No seam; three sub-resources (events/queues/webhooks). |
|
||
| Dashboard metrics | **S–M** | Token-bound already; single `loadMetrics` shape. |
|
||
| Customers | **S** | Derives from Orders once Orders is real. |
|
||
| Analytics | **XL** | No data source; needs tracking pipeline + aggregation, gated on everything above. |
|
||
| User-experience sync | **L** | Requires client repository redesign (id-only + batch hydrate), not a drop-in. |
|
||
| Builder draft/publish | **L** | Net-new write path; no client call exists today. |
|
||
| Content management / CMS | **M–L** | Net-new write path; adapter (`ContentPageService`) exists but no HTTP. |
|
||
| Search suggestions/filters | **M** | Client orchestration exists; backend suggestion source is new. |
|
||
|
||
---
|
||
|
||
## 9. Backend Checklist
|
||
|
||
A literal, top-to-bottom checklist. Work the phases in order; within a phase, items are roughly
|
||
independent. Section references point to the assembled backend-integration document (this doc's
|
||
§8, the CRUD-contracts sections, and `docs/archive/BACKEND_API.md` where a full shape already lives).
|
||
|
||
### Phase 1 — Foundation (nothing role-gated works until these land)
|
||
|
||
- [ ] Implement session issuance/check/logout: `POST /users/sessions`, `GET /users/sessions/:id`, `DELETE /users/sessions/:id` — per Auth contract (§5a; archive/BACKEND_API.md `/users/sessions/*`, already client-LIVE).
|
||
- [ ] Implement the Ed25519 admin-auth flow `GET /api/admin/auth/challenge`, `POST /verify`, `POST /refresh`, `POST /logout` — client wiring is LIVE and 404s today (§5b). Return `AuthChallenge` / `AuthTokenPair` shapes exactly.
|
||
- [ ] Honor the admin auth headers on every gated path: `AdminWebSessionID` + `Authorization: Bearer` for URLs containing `/admin/`, `/backoffice/`, `/builder/`, `/media/` (§3 interceptor pipeline).
|
||
- [ ] Serve real `GET /bootstrap` **content** (branding, theme, navigation, seo — not just the transport) — per Bootstrap contract (§6; archive/BACKEND_API.md §4). This is a P0 blocker.
|
||
- [ ] Populate `bootstrap.apiEndpoints.{website,builder,backoffice}` records so tenant-scoped paths resolve at runtime (§6; audit §24 — no path literals exist in client code).
|
||
- [ ] Confirm tenant resolution inputs (host/slug/code) match `TenantConfig` so `ApiConfigService.getBaseUrl()` resolves the right base (§2, §6).
|
||
- [ ] Adopt a consistent error envelope; the client maps failures to a `backend-unavailable` screen for admin auth — keep error bodies non-leaky (§5b; security guidance).
|
||
- [ ] Set the header contract: accept `X-Region`, `X-Language` (RU/EN/AM), `Currency` (default RUB), `WebSessionID` on marketplace requests (§3).
|
||
|
||
### Phase 2 — Read-heavy domains (mostly confirm; already client-LIVE)
|
||
|
||
- [ ] Stand up `GET /category` returning the `CategoryDto` shape `CategoryMapper` tolerates (§8.4 Example A; audit §8) — already LIVE client-side.
|
||
- [ ] Stand up `GET /items/:id`, `GET /category/:id`, `GET /items/randomitems`, `GET /searchitems` within the `normalizeItem` tolerance envelope (§8.4 Example B; audit §4, §7).
|
||
- [ ] Serve admin categories CRUD via the existing `AdminCategoriesApiGateway` contract: `loadCategories`, `loadCategory`, `create/update/delete/restore`, `isSlugTaken` — per Categories CRUD contract (archive/BACKEND_API.md §6.9). **Already wired client-side (DONE).**
|
||
- [ ] Serve `GET /api/backoffice/products` and `GET /api/backoffice/categories` (storefront cards) — `ApiBackofficeDataProvider` is LIVE (audit §9).
|
||
- [ ] Stand up `GET /regions` → `Region[]` (feeds the `X-Region` header; client falls back to 6 hardcoded regions) (audit §12).
|
||
|
||
### Phase 3 — Write-heavy customer domains
|
||
|
||
- [ ] Keep `POST /cart` (`CartPaymentRequest` → `QrCreateResponse`) and the frozen QR/card payment polling working unchanged (§10; payments frozen per archive/BACKEND_API.md §2.8).
|
||
- [ ] Implement `POST /orders` (`CreateOrderRequest` → `CreateOrderResponse`) — client call is LIVE, fire-and-forget after payment (§10; archive/BACKEND_API.md §16.9, marked DONE client-side).
|
||
- [ ] Implement `POST /purchase-email` (email receipt) (audit §4).
|
||
- [ ] Accept review/question writes `POST /items/:id/callback` and `POST /items/:id/questiion` (**preserve the `questiion` typo** — it matches the client literal) (§11; audit §4).
|
||
|
||
### Phase 4 — Admin domains (each needs the token seam added first — §8.5)
|
||
|
||
- [ ] Add `AdminOrdersGateway` token + `AdminOrdersApiGateway`, switch `AdminOrdersFacade` to the token; implement `GET/POST /backoffice/orders*` incl. `POST /backoffice/orders/:id/status` respecting the order state machine — per Orders CRUD contract (§8.4 Example C, §8.5; archive/BACKEND_API.md §6.11/§8.1).
|
||
- [ ] Add `AdminProductsGateway` token + `AdminProductsApiGateway`, switch `AdminProductsFacade`; implement Products CRUD + variants — per Products CRUD contract (§8.5; archive/BACKEND_API.md §6.10/§7.2).
|
||
- [ ] Add `AdminTransactionsGateway` token + api gateway, switch `AdminTransactionsFacade`; implement transactions list/detail + `retryFailed` + `setFraudFlag` (tied to orders) — per Transactions contract (§8.5; archive/BACKEND_API.md §6.12).
|
||
- [ ] Add `AdminUsersGateway` token + api gateway, switch `AdminUsersFacade`; implement users/roles/invitations/sessions/audit — per Users contract (§8.5; archive/BACKEND_API.md §6.13). Reconcile the duplicate `AdminRole` naming (audit §25 #3).
|
||
- [ ] Add `AdminModerationGateway` token + api gateway, switch `AdminModerationFacade`; implement review + report status transitions — per Moderation contract (§8.5; archive/BACKEND_API.md §6.14/§8.4/§8.5).
|
||
- [ ] Add `AdminMonitoringGateway` token + api gateway, switch `AdminMonitoringFacade`; implement events/queues/webhooks reads — per Monitoring contract (§8.5; archive/BACKEND_API.md §6.16).
|
||
- [ ] Wire `AdminDashboardMetricsApiGateway` to the existing `ADMIN_DASHBOARD_METRICS_GATEWAY` token; implement `loadMetrics` — per Dashboard contract (§8.5; archive/BACKEND_API.md §6.15).
|
||
- [ ] Resolve Customers: derive from the real Orders token (`AdminCustomersFacade`) or add a first-class customers source (§8.3).
|
||
- [ ] Defer Analytics until orders/products/moderation are real and a tracking pipeline exists; then implement the analytics summary source (§8.3, §8.6 step 10; archive/BACKEND_API.md §6.17).
|
||
|
||
### Phase 5 — Builder / CMS (net-new write paths — no client call exists today)
|
||
|
||
- [ ] Implement builder bootstrap draft/publish/validate: `GET/PUT /builder/bootstrap/draft`, `POST /builder/bootstrap/publish`, `POST /builder/bootstrap/validate` — and add the client-side write call in `ProjectEditorFacade`/`ProjectEditorIoService` (§8.3, §17; archive/BACKEND_API.md §6.7). P0 for the builder.
|
||
- [ ] Implement content pages / CMS write path and wire `ContentManagementFacade` beyond in-memory bootstrap (§8.3, §16; archive/BACKEND_API.md §6.8).
|
||
- [ ] Implement the media upload/delete/replace pipeline behind `ApiMediaRepository` bound to the `MediaRepository` token (§8.5; archive/BACKEND_API.md §6.18/§10).
|
||
|
||
### Phase 6 — Hardening
|
||
|
||
- [ ] Add rate limiting on all write endpoints (orders, reviews, admin CRUD, media upload) (security guidance).
|
||
- [ ] Set CSP / security headers for the frontend origin (frontend + API share a domain by decision — remaining-work "Explicitly not in this list").
|
||
- [ ] Add audit logging for admin mutations (order status, role changes, moderation actions, publish) — the client already models `*AuditEntry` / timeline shapes (audit §23).
|
||
- [ ] Implement maintenance-mode / graceful `backend-unavailable` responses the client can surface (§5b).
|
||
- [ ] Implement `GET /items/batch?ids=` to unblock the user-experience id-only sync redesign (remaining-work #16; §8.3).
|
||
- [ ] Add search suggestions/catalog-filter source if pursuing #15 (§8.6 step 12; archive/BACKEND_API.md §6.6).
|
||
- [ ] Plan dynamic sitemap generation (server-side, no frontend action) (remaining-work #18).
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 10. Maintenance Mode
|
||
|
||
Frontend contract for backend maintenance/availability signals: global, per-tenant,
|
||
per-module, read-only, scheduled, and single-feature-disable scenarios. Written from
|
||
the current source tree (branch `B2B`) — see `docs/context/BACKEND-AUDIT.md` for the
|
||
full backend surface this builds on.
|
||
|
||
**Existing frontend handling today: none.** There is no maintenance concept anywhere
|
||
in the frontend — no model field, no interceptor branch, no route, no component. This
|
||
document proposes a contract and marks every open question explicitly as either
|
||
"Requires backend decision" (the backend hasn't decided the signal shape) or "No
|
||
frontend UI currently exists for this - requires a future frontend task" (the signal
|
||
is plausible but no UI has been built to react to it).
|
||
|
||
The one adjacent, already-built pattern worth reusing is `AuthErrorPageComponent`
|
||
(`src/app/core/auth/pages/auth-error-page.component.ts`): a single component keyed by
|
||
an error-code route param, rendering `EmptyStateComponent` +
|
||
`ButtonComponent`, with a `Record<Code, {title, description, actionLabel}>` copy table
|
||
and a `retry()` handler. Section 7 proposes the maintenance screens follow this exact
|
||
shape rather than inventing a new one.
|
||
|
||
---
|
||
|
||
### 1. Global maintenance
|
||
|
||
Whole platform down for all tenants.
|
||
|
||
**What the backend should send:** `503 Service Unavailable` on every endpoint
|
||
(including `GET /bootstrap`), with a `Retry-After` header (seconds) and a structured
|
||
JSON body (see §7 for exact shape). `GET /bootstrap` is the critical path — it is the
|
||
first call the frontend makes (`ApiBootstrapProvider.loadBootstrap()`,
|
||
`src/app/core/bootstrap/providers/api-bootstrap.provider.ts`, `GET /bootstrap`) and
|
||
every facade that renders anything (`UiRuntimeFacade`, `WebsiteRuntimeFacade`,
|
||
`ProjectEditorFacade`, `ContentManagementFacade`, `DiagnosticsFacade`) depends on it
|
||
resolving.
|
||
|
||
**What the frontend currently does:** nothing maintenance-specific. Tracing the call
|
||
chain in `src/app/core/config/config.service.ts`:
|
||
|
||
```ts
|
||
this.bootstrap$ = this.provider.loadBootstrap().pipe(
|
||
tap(config => { this.bootstrapSnapshot = config; ... }),
|
||
shareReplay(1),
|
||
catchError(error => {
|
||
this.bootstrap$ = undefined;
|
||
this.bootstrapSnapshot = null;
|
||
return throwError(() => error);
|
||
})
|
||
);
|
||
```
|
||
|
||
Any bootstrap failure (503 or otherwise) just rethrows. Every one of the ~14 call
|
||
sites of `configService.loadBootstrap()` (footer, theme engine, branding engine,
|
||
platform-runtime, page-resolver, static-page-resolver, footer-resolver, diagnostics,
|
||
etc. — see `Grep` results for `loadBootstrap()` across `src/app`) either does not
|
||
subscribe to the error channel at all, or handles it locally and inconsistently.
|
||
There is no global "the whole app is down" screen.
|
||
|
||
**No frontend UI currently exists for this — requires a future frontend task.** A
|
||
clean contract would intercept a `503` on the bootstrap call specifically (distinct
|
||
from a 503 on a leaf endpoint, which should degrade that one section instead — see
|
||
§3) and route to a full-page takeover, structurally identical to
|
||
`AuthErrorPageComponent`: a `maintenance-page.component.ts` using
|
||
`EmptyStateComponent` + `ButtonComponent`, keyed off the response body's `reason`
|
||
(§7), with a retry button that calls `configService.loadBootstrap(true)`.
|
||
|
||
**Requires backend decision:** whether maintenance state is signaled by response
|
||
status alone (`503` on `/bootstrap`) or also via a dedicated
|
||
`GET /status` / `GET /maintenance` probe the frontend could poll while showing the
|
||
takeover screen, to auto-recover without the user manually retrying.
|
||
|
||
---
|
||
|
||
### 2. Per-tenant maintenance
|
||
|
||
Single tenant disabled while others operate normally.
|
||
|
||
This ties directly into tenant resolution: `TenantResolverService`
|
||
(`src/app/core/config/tenant-resolver.service.ts`) determines the tenant key before
|
||
`ApiConfigService.getBaseUrl()` resolves which base URL to call (`tenantApiBaseUrls`
|
||
map, or `tenantApiTemplate` with `{tenant}` substituted — see
|
||
`docs/context/BACKEND-AUDIT.md` §2). Because tenant resolution happens client-side
|
||
before any network call, a per-tenant maintenance signal can only surface through the
|
||
response to that tenant's own `GET /bootstrap` call — there is no separate
|
||
"is this tenant up" check today.
|
||
|
||
**What the backend should send:** the *same* `503` + structured body as global
|
||
maintenance (§7) on that tenant's `/bootstrap` response. The frontend has no way to
|
||
distinguish "this tenant is down" from "the whole platform is down" except by the
|
||
response body's content — so the body must carry enough to tell (e.g. a `scope` field:
|
||
`"global" | "tenant"`).
|
||
|
||
**What the frontend currently does:** nothing. `ConfigService.loadBootstrap()` is
|
||
tenant-agnostic from the frontend's point of view — it just calls whatever base URL
|
||
`ApiConfigService` resolved and doesn't know if a 503 means "this tenant" vs.
|
||
"everything."
|
||
|
||
**Requires backend decision:** the `scope` discriminator mentioned above, and whether
|
||
a disabled tenant's static/marketing content (branding, footer) should still resolve
|
||
from a cached/last-known bootstrap so the takeover page can show the tenant's own
|
||
logo, or whether it's a fully generic (unbranded) page. Given
|
||
`BootstrapConfig.branding`/`theme` are only available *after* a successful bootstrap
|
||
load, a tenant-branded maintenance page is not achievable without a design decision
|
||
here (e.g. serving branding via a separate lightweight endpoint that stays up even
|
||
when the tenant is otherwise disabled).
|
||
|
||
**No frontend UI currently exists for this — requires a future frontend task.** Same
|
||
takeover component as §1 can likely serve both scopes once the backend supplies
|
||
`scope`, but nothing renders differently for tenant-vs-global today because nothing
|
||
renders a maintenance screen at all yet.
|
||
|
||
---
|
||
|
||
### 3. Per-module maintenance
|
||
|
||
E.g. payments down but catalog still browsable.
|
||
|
||
**Existing granularity concept:** `BootstrapConfig.featureFlags`
|
||
(`FeatureFlagsConfig`, `src/app/shared/models/config/feature-flags.model.ts`) —
|
||
a flat `Record<string, boolean>` with known keys `wishlist, compare, reviews,
|
||
questions, comments, recommendations, blog, chat, analytics, notifications, coupons,
|
||
loyalty, giftCards, invoices` and an index signature for tenant-specific extras. This
|
||
is a **static, bootstrap-time** on/off switch per feature — not a live "is this
|
||
service currently degraded" signal, and it has no `payments` or `catalog` key today.
|
||
It's read once at bootstrap load and doesn't change until the next bootstrap refresh.
|
||
|
||
There is no separate "module health" concept distinct from `featureFlags`. The admin
|
||
dashboard's `healthChecks()` / `homeHealthChecks()` (`AdminDashboardFacade`,
|
||
`src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts`) are **not**
|
||
module-availability checks — they validate the *local bootstrap document itself*
|
||
(schema version present, no missing translations, no invalid colors/widget refs/
|
||
layouts, draft-exists, etc.), entirely client-side, with no backend health probe
|
||
behind any row except product/category counts (which reflect load success/failure of
|
||
`ProductFacade`/`CategoryFacade`, not an explicit "payments module is down" signal).
|
||
`AdminMonitoringPageComponent` reuses the same boolean-shaped `healthChecks()` — it is
|
||
not a live service-status board either.
|
||
|
||
**What the backend should send:** each domain-specific endpoint (e.g. `POST /cart`,
|
||
`POST /orders`, `{qrApiUrl}/qr`) should independently return `503` with the structured
|
||
body (§7) with `scope: "module"` and a `module` field (e.g. `"payments"`) when that
|
||
subsystem specifically is down, while unrelated endpoints (`GET /category`,
|
||
`GET /items/{id}`) keep responding normally. This requires no new bootstrap field —
|
||
it's a per-request response behavior, consistent with REST conventions (the resource
|
||
itself is unavailable, not the whole API).
|
||
|
||
**What the frontend currently does:** nothing differentiates a per-module outage from
|
||
any other request failure. `ApiService` (`src/app/services/api.service.ts`) has no
|
||
per-endpoint error branching for 503; a failed `createCartPayment()`/`createOrder()`
|
||
call surfaces through whatever generic error handling the checkout components already
|
||
have for network failures (out of scope for this doc — see the sibling
|
||
`ERROR_CONTRACT.md` task for the general error-response shape).
|
||
|
||
**No frontend UI currently exists for this — requires a future frontend task.** The
|
||
checkout flow would need a "payments unavailable" inline state (banner or disabled
|
||
submit + tooltip, per §7) distinct from a generic error toast, and catalog browsing
|
||
would need to keep working untouched — which it structurally already would, since
|
||
`ProductFacade`/`CategoryFacade` and the payment calls are fully independent code
|
||
paths today (no shared failure state). That independence is a real asset: a payments
|
||
outage cannot accidentally break catalog browsing given the current facade
|
||
separation, but no UI exists yet to *tell the user* payments specifically are down
|
||
rather than "something went wrong."
|
||
|
||
---
|
||
|
||
### 4. Read-only mode
|
||
|
||
Writes disabled, reads still work.
|
||
|
||
**Does the frontend already assume this is possible?** Partially, structurally, but
|
||
not deliberately. Cart state is `LOCAL-ONLY` (`CartService`,
|
||
`src/app/services/cart.service.ts`, signal-based, persisted to `localStorage` key
|
||
`marketplace_cart`) — adding items to cart, changing quantities, and browsing the cart
|
||
UI works entirely client-side with **no backend call at all** until checkout. The
|
||
only writes that hit a backend are at the checkout boundary: `POST /cart`
|
||
(`createCartPayment`), `POST /orders` (`createOrder`), `POST /purchase-email`, and the
|
||
QR/card payment polling. So today, if the backend rejected writes only, catalog
|
||
browsing, search, wishlist/compare (also `LOCAL-ONLY`,
|
||
`LocalUserExperienceRepository`), and cart-building would all continue working simply
|
||
because they never touch the backend — but reviews (`POST /items/{id}/callback`) and
|
||
questions (`POST /items/{id}/questiion`) are also writes and would fail the same as
|
||
checkout, since both are LIVE endpoints via `ProductDataProvider`.
|
||
|
||
There is no code today that *checks for* a read-only flag and proactively disables
|
||
write UI (e.g. graying out "Add to cart" or the checkout button ahead of time). A
|
||
write attempt would only be discovered to be blocked when the write call itself
|
||
fails.
|
||
|
||
**What the backend should send:** `503` (or `403`, see note below) with the
|
||
structured body (§7), `scope: "readonly"`, on write endpoints specifically —
|
||
`POST /cart`, `POST /orders`, `POST /purchase-email`, `POST /items/{id}/callback`,
|
||
`POST /items/{id}/questiion`, `POST /websession/{sessionId}` (cart sync) — while GET
|
||
endpoints keep working. `403 Forbidden` is arguably more correct REST semantics for
|
||
"this resource forbids this method during a maintenance window" than `503`, but `503`
|
||
+ `Retry-After` communicates "temporary" more clearly to a client and is
|
||
recommended so the frontend can offer a countdown/retry consistent with §5's pattern.
|
||
**Requires backend decision:** which status code is authoritative — this should be
|
||
pinned down jointly with whatever `ERROR_CONTRACT.md` settles on for its 5xx
|
||
conventions, since read-only is really "a subset of write endpoints return
|
||
maintenance-503."
|
||
|
||
**No frontend UI currently exists for this — requires a future frontend task.** No
|
||
bootstrap flag exists to proactively disable checkout/review/question submission
|
||
ahead of a failed request (e.g. `featureFlags.readOnly` or a dedicated
|
||
`platformStatus.readOnly` field would need to be added to `BootstrapConfig` if the
|
||
product wants a proactive banner instead of a reactive failure). Reactive handling
|
||
(showing an error when the write call 503s) can reuse the same inline
|
||
error-state pattern as §3/§6 once `ERROR_CONTRACT.md` defines the generic error body
|
||
handling.
|
||
|
||
---
|
||
|
||
### 5. Scheduled maintenance
|
||
|
||
Advance notice pattern (banner / countdown) ahead of a maintenance window.
|
||
|
||
**What exists in the frontend today:** nothing. No banner component, no countdown
|
||
component, no bootstrap field for an upcoming maintenance window.
|
||
|
||
**Requires backend decision — proposed minimal contract:** add an optional field to
|
||
`BootstrapConfig` (loaded once per session/on refresh via `GET /bootstrap`), e.g.:
|
||
|
||
```ts
|
||
interface ScheduledMaintenanceNotice {
|
||
startsAt: string; // ISO 8601
|
||
endsAt?: string; // ISO 8601, optional if duration is unknown
|
||
scope: 'global' | 'tenant' | 'module';
|
||
module?: string; // present when scope === 'module'
|
||
messageKey?: string; // optional i18n key/translated string for custom copy
|
||
}
|
||
```
|
||
|
||
surfaced as `bootstrap.maintenanceNotice?: ScheduledMaintenanceNotice | null`. This
|
||
keeps the mechanism consistent with how the platform already declares other
|
||
runtime-configured, backend-authored state (feature flags, tenant config, API
|
||
endpoint records all live in the bootstrap document per
|
||
`docs/context/BACKEND-AUDIT.md` §6) rather than inventing a new polling endpoint. A
|
||
polling `GET /maintenance-notice` endpoint is an alternative if the notice needs to
|
||
appear/change without a full bootstrap refresh — that tradeoff is the backend
|
||
decision.
|
||
|
||
**No frontend UI currently exists for this — requires a future frontend task.** A
|
||
dismissible banner component reading `bootstrap.maintenanceNotice` and showing a
|
||
localized "maintenance starts in Xh Ym" countdown would need to be built and mounted
|
||
at a layout level (header or a global banner slot) — no such banner or countdown
|
||
component exists in `src/app/shared/ui/` today.
|
||
|
||
---
|
||
|
||
### 6. Temporary feature disable
|
||
|
||
Single feature toggled off without full maintenance — e.g. reviews temporarily
|
||
disabled while the rest of the product page works.
|
||
|
||
**This is the one scenario the frontend already has a real mechanism for**, via
|
||
`BootstrapConfig.featureFlags` (§3). Setting `featureFlags.reviews = false` in the
|
||
bootstrap document is exactly the existing, live mechanism for "reviews are off right
|
||
now" — it's read by whatever consumes `FeatureConfigService`
|
||
(`src/app/core/config/*`) and gates the relevant UI. This is a **deploy/config-time**
|
||
toggle (changes on next bootstrap load), not a live incident-response toggle, but
|
||
structurally it is the same shape a backend team would use to kill a misbehaving
|
||
feature quickly: update the bootstrap document (or whatever backend-side config
|
||
drives it), and the next bootstrap fetch picks it up.
|
||
|
||
**Recommendation:** reuse `featureFlags` for this scenario rather than introducing a
|
||
parallel mechanism — it already exists, is already wired through to the UI in the
|
||
relevant places, and matches the "temporary, single-feature, not a full outage"
|
||
framing exactly. No backend decision needed for the *mechanism*; only for *process*
|
||
(how fast a flag flip propagates — depends on bootstrap cache/refresh cadence, which
|
||
is outside this doc's scope).
|
||
|
||
**Gap:** `featureFlags` has no `payments` or `catalog` key and is a boolean only — it
|
||
can't express "reviews disabled with reason X, back at time Y" the way §5's proposed
|
||
`maintenanceNotice` can. If product wants a "reviews are temporarily unavailable —
|
||
back tomorrow" message rather than the feature silently disappearing, that needs the
|
||
richer shape from §5, scoped to `module`, not a plain `featureFlags` boolean.
|
||
|
||
---
|
||
|
||
### 7. Recommended API responses
|
||
|
||
All maintenance-scenario responses use HTTP `503 Service Unavailable` (except the
|
||
read-only debate in §4) with a `Retry-After` header (seconds, standard HTTP) and a
|
||
JSON body. This is written to be consistent with, not contradict, whatever
|
||
`ERROR_CONTRACT.md` (sibling task, in progress) settles on for its general
|
||
structured-error envelope — if that doc defines a different top-level error
|
||
shape (e.g. `{ error: { code, message, ... } }` vs. a flatter shape), this body
|
||
should be nested under that envelope rather than duplicating a competing shape.
|
||
Pending that reconciliation, the fields below are what the frontend needs regardless
|
||
of the outer envelope:
|
||
|
||
```json
|
||
{
|
||
"status": 503,
|
||
"code": "maintenance",
|
||
"scope": "global",
|
||
"module": null,
|
||
"reason": "scheduled",
|
||
"message": "The marketplace is temporarily unavailable for scheduled maintenance.",
|
||
"retryAfter": 1800,
|
||
"startedAt": "2026-07-26T02:00:00Z",
|
||
"expectedEndAt": "2026-07-26T03:00:00Z"
|
||
}
|
||
```
|
||
|
||
Field notes:
|
||
- `scope`: `"global" | "tenant" | "module" | "readonly"` — lets the frontend pick the
|
||
right UI (full takeover vs. inline banner vs. disabled control) without guessing
|
||
from status code alone.
|
||
- `module`: present only when `scope === "module"` (e.g. `"payments"`, `"reviews"`).
|
||
- `reason`: `"scheduled" | "incident" | "disabled"` — free-form enough for the
|
||
frontend to choose copy tone (planned vs. unplanned) without needing new fields
|
||
per scenario.
|
||
- `retryAfter`: mirrors the `Retry-After` header in the body too, so a client that
|
||
only reads JSON (not headers) still gets it — useful since some HttpClient error
|
||
paths surface the body more readily than headers depending on interceptor
|
||
structure.
|
||
- `startedAt` / `expectedEndAt`: optional, ISO 8601, for countdown/banner copy (§5).
|
||
|
||
Per-scenario summary:
|
||
|
||
| Scenario | Status | `scope` | Notes |
|
||
|---|---|---|---|
|
||
| Global | 503 | `"global"` | On every endpoint, especially `/bootstrap` |
|
||
| Per-tenant | 503 | `"tenant"` | On that tenant's `/bootstrap` and all its endpoints |
|
||
| Per-module | 503 | `"module"` | Only on that module's endpoints (e.g. `/cart`, `/orders`) |
|
||
| Read-only | 503 or 403 | `"readonly"` | Only on write endpoints; GETs unaffected — pin down with `ERROR_CONTRACT.md` |
|
||
| Scheduled (advance notice) | 200, via `bootstrap.maintenanceNotice` | n/a | Not an error response — a proactive field on the normal `/bootstrap` payload, see §5 |
|
||
| Temporary feature disable | 200, via `bootstrap.featureFlags.<key> = false` | n/a | Not an error response — existing bootstrap mechanism, see §6 |
|
||
|
||
---
|
||
|
||
### 8. Frontend behavior
|
||
|
||
Grounded in the UI patterns that already exist (`EmptyStateComponent`
|
||
(`src/app/shared/ui/empty-state/empty-state.component.ts`), the `errorTitle` /
|
||
`error` / `retry` i18n-key convention used across catalog, product details, and
|
||
generic list widgets (`src/app/i18n/en.ts`), and `AuthErrorPageComponent`'s
|
||
code-keyed full-page pattern). No new UI concepts are invented below beyond composing
|
||
these.
|
||
|
||
| Scenario | Recommended UI | Existing pattern reused | Status |
|
||
|---|---|---|---|
|
||
| Global maintenance | Full-page takeover, replaces the entire app shell (no header/footer, since branding may be unavailable — see §2) | `AuthErrorPageComponent` shape: `EmptyStateComponent` + `ButtonComponent`, code-keyed copy, `retry()` action | No frontend UI currently exists for this — requires a future frontend task |
|
||
| Per-tenant maintenance | Same full-page takeover as global, ideally tenant-branded if the backend decision in §2 allows branding to still resolve | Same as above | No frontend UI currently exists for this — requires a future frontend task |
|
||
| Per-module maintenance | Inline empty-state/banner scoped to the affected section only (e.g. checkout step shows `EmptyStateComponent` with `errorTitle`/`error`/`retry` copy; catalog pages untouched) | `EmptyStateComponent` + the `errorTitle`/`error`/`retry` i18n triple already used in `catalog`/`productDetails`/generic-list translations | No frontend UI currently exists for this — requires a future frontend task |
|
||
| Read-only mode | Disabled write control (e.g. "Add to cart" / "Submit review" button) + tooltip explaining why, OR a reactive error state on submit if no proactive flag exists (§4) | Disabled-button-plus-tooltip is a common pattern in the design system but not wired to any maintenance signal today | No frontend UI currently exists for this — requires a future frontend task |
|
||
| Scheduled maintenance | Dismissible banner at layout/header level with countdown copy | No banner/countdown component exists in `src/app/shared/ui/` today | No frontend UI currently exists for this — requires a future frontend task |
|
||
| Temporary feature disable | Feature's own UI simply doesn't render (existing `featureFlags` gating), optionally with a short "temporarily unavailable" note if `messageKey` (§5) is present | Existing `featureFlags` boolean gating (already live) | Existing mechanism works; richer messaging is the only gap |
|
||
|
||
---
|
||
|
||
### Summary: what's proposed/new vs. what already exists
|
||
|
||
**Already exists and can be reused as-is:**
|
||
- `BootstrapConfig.featureFlags` — static per-feature kill switch (§3, §6).
|
||
- `EmptyStateComponent` + `errorTitle`/`error`/`retry` i18n convention — the inline
|
||
error-state building block for any scenario.
|
||
- `AuthErrorPageComponent` — the full-page-takeover shape (code-keyed copy record,
|
||
`EmptyStateComponent` + `ButtonComponent`, `retry()` handler) to model a maintenance
|
||
page after.
|
||
- Cart's `LOCAL-ONLY` design already means most of "read-only browsing" works
|
||
incidentally, since browsing/cart-building never call the backend.
|
||
|
||
**Proposed/new (this document introduces):**
|
||
- The `scope`/`module`/`reason` structured 503 body (§7).
|
||
- `bootstrap.maintenanceNotice` (§5) for scheduled-maintenance advance notice.
|
||
- A dedicated `maintenance-page.component.ts` full-page takeover (§1/§2).
|
||
- Inline per-module/read-only error and disabled-control states wired to the new 503
|
||
shape (§3/§4).
|
||
|
||
---
|
||
|
||
### Requires backend decision (full list)
|
||
|
||
- §1: whether a dedicated `GET /status`/`GET /maintenance` probe should exist for
|
||
auto-recovery polling, beyond a plain 503 on `/bootstrap`.
|
||
- §2: the `scope` discriminator (`"global"` vs `"tenant"`) so the frontend can tell
|
||
the two apart from a single tenant's bootstrap response; and whether a
|
||
disabled tenant's branding can still resolve for a branded takeover page.
|
||
- §3: none beyond adopting the §7 response shape per-endpoint — this one is mostly
|
||
frontend-gap, not backend-undecided.
|
||
- §4: which status code is authoritative for read-only (`503` vs `403`) — to be
|
||
pinned down jointly with `ERROR_CONTRACT.md`.
|
||
- §5: whether scheduled-maintenance notice ships via a `bootstrap.maintenanceNotice`
|
||
field (proposed) or a separate polling endpoint.
|
||
- §7: how this document's 503 body nests inside whatever outer envelope
|
||
`ERROR_CONTRACT.md` defines.
|
||
|
||
### No frontend UI currently exists for this — requires a future frontend task (full list)
|
||
|
||
- Global maintenance full-page takeover component.
|
||
- Per-tenant maintenance takeover (branded or not, pending §2's backend decision).
|
||
- Per-module inline maintenance banner/empty-state wiring on checkout/payment flows.
|
||
- Proactive read-only disabling of write controls (Add to cart / Submit review /
|
||
Submit question / Checkout) ahead of a failed request.
|
||
- Scheduled-maintenance banner + countdown component at the layout/header level.
|
||
- Richer "temporarily unavailable, back at X" messaging for `featureFlags`-gated
|
||
features (today they just silently don't render — no explanatory copy).
|
||
|
||
---
|
||
|
||
## Appendix: `docs/TODO.md` items merged into this document (2026-07-26)
|
||
|
||
Final Project Closeout moved every backend-shaped item out of `docs/TODO.md` into this
|
||
document. None were duplicated as raw new bullets — each is already covered by an
|
||
existing section above:
|
||
|
||
| TODO item | Covered by |
|
||
|---|---|
|
||
| `bootstrap.json` real content (branding/theme/nav/seo) | §1 Bootstrap |
|
||
| Builder bootstrap draft/publish/validate | §1 (Draft vs Published), §8, §9 Phase 5 |
|
||
| Backoffice Products CRUD | §3 Products, §8, §9 Phase 4 |
|
||
| Media upload/delete/replace pipeline | §7 Uploads, §9 Phase 5 |
|
||
| Backoffice Orders CRUD + status transitions | §3 Orders, §8, §9 Phase 4 |
|
||
| Backoffice Transactions | §3 Transactions, §9 Phase 4 |
|
||
| Backoffice Users/roles/invitations | §3 Users/Roles, §9 Phase 4 |
|
||
| Backoffice Moderation (reviews/reports) | §3 Reviews/Reports, §9 Phase 4 |
|
||
| Backend Ready sprint / no real API contract | This entire document |
|
||
|
||
`docs/TODO.md` is now empty of blockers — see that file.
|