Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Goal: this document alone, pasted into a fresh Claude session, should be enough for a backend dev to implement against without getting stuck or inventing conventions the frontend doesn't actually need. Fixed a real bug: ~65 cross-references throughout the document pointed to docs/AUTHENTICATION.md, docs/ERROR_CONTRACT.md, docs/MAINTENANCE_MODE.md - three sibling docs that were deleted and fully merged into this document's own §4/§6/§10 during the earlier doc-consolidation pass, but the in-text references were never updated. A fresh agent following those links would hit dead ends repeatedly. Bulk-replaced with in- document section references; hand-fixed ~4 sentences that framed §6/ §10 as "sibling task, in progress" (stale - both are complete, this document's §6/§10 already are the settled contract, nothing to wait on). Added "Recommended default" to every item across the document's 5 consolidated "Requires backend decision" registers (§2.12 framework, §3.21 CRUD cross-cutting - 18 items, §4 §12 auth open items - 9 items, §6 error-model summary - 9 items, §10 maintenance-mode list - 5 items). Each default is derived from what the frontend already implies or standard REST/security convention - no invented APIs or business rules. ~6 items are explicitly flagged as real business/security decisions instead (order state-machine rules, Ed25519 cutover strategy, refresh-token reuse-detection posture) since those carry consequences no amount of frontend-code-reading can resolve. Added a "How to use this document" preamble up front: work in §9's dependency order not document order, apply recommended defaults and keep moving, only stop for the explicitly-flagged business/security items, don't invent beyond what's written or directly implied. Frontend side: confirmed nothing else is left. docs/TODO.md already has zero blockers; docs/KNOWN-ISSUES.md's one open item (unreachable Ed25519 error-code UI) is correctly left open and documented rather than faked closed - fixing it needs a real backend emitting real distinguishable error codes, which doesn't exist yet and can't be fabricated without inventing an API contract. No frontend code touched. No architecture changed. No APIs invented.
4997 lines
270 KiB
Markdown
4997 lines
270 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. Authentication (§4), the Error Model (§6), and Maintenance Mode (§10) are sections of this same document, not separate files — earlier drafts existed as standalone `AUTHENTICATION.md`/`ERROR_CONTRACT.md`/`MAINTENANCE_MODE.md` files and were fully merged in; those files no longer exist. This document also supersedes (already archived, content re-derived from current source) `docs/archive/BACKEND_API.md` and `docs/archive/BACKEND_API_REMAINING_WORK.md`.
|
||
|
||
Everything here is derived from the actual current frontend source code (branch `B2B`), not from prior/stale documentation. Primary input: `docs/context/BACKEND-AUDIT.md` (exhaustive audit of every HTTP call, gateway, facade, and model in the frontend).
|
||
|
||
**Convention used throughout:** where the frontend already implies a concrete behavior, it's documented as-is. Where the frontend has no opinion and a real backend needs one, it's marked **"Requires backend decision"** — nothing beyond what the frontend requires is invented.
|
||
|
||
## How to use this document (read this first if you're implementing from it)
|
||
|
||
You are building a real backend for an existing, feature-complete Angular
|
||
frontend (branch `B2B`) that currently runs entirely on mock/local data. This
|
||
document is the complete contract — every endpoint, DTO, status code, and
|
||
convention the frontend already expects. Treat it as your spec, not as
|
||
background reading.
|
||
|
||
1. **Work in dependency order**, not top-to-bottom document order: §9 (Backend
|
||
Checklist) gives the actual build sequence in 6 phases (foundation → auth/
|
||
tenant/bootstrap → read-heavy catalog → write-heavy customer domains →
|
||
admin domains → builder/CMS → hardening). Start there.
|
||
2. **Every "Requires backend decision" marker has a Recommended default**
|
||
attached to it (in §2.12, §3.21, §4 §12, §6's summary, and §10's list —
|
||
the five per-section consolidated registers). Apply the recommended
|
||
default and keep moving. Do not stop and wait for human input on these —
|
||
they exist so you don't have to invent a convention from nothing, and
|
||
they were chosen to match what the frontend already assumes.
|
||
3. **A small number of items are explicitly marked as business/security
|
||
decisions, not technical ones** (e.g. order status-transition rules, the
|
||
Ed25519 cutover strategy, refresh-token reuse detection posture). These
|
||
are the only points where you should actually stop and ask a human,
|
||
because they carry money, legal, or security consequences the frontend
|
||
code can't tell you the answer to.
|
||
4. **Do not invent new endpoints, fields, or business rules beyond what's
|
||
written here or directly implied by an existing frontend call.** If you
|
||
find yourself needing something this document doesn't cover and no
|
||
recommended default applies, that's a real gap — flag it explicitly
|
||
rather than guessing silently.
|
||
5. **§8 (Real Backend Implementation Guide)** has the frontend-side
|
||
mechanics: which DI-token gateways to swap, which facades change, worked
|
||
DTO-mapping examples. Read it before touching any single domain's
|
||
endpoints in §3 — it explains the pattern every domain repeats.
|
||
6. **Verify against the live frontend where you can.** Every literal
|
||
(non-PROPOSED) path, DTO, and example in this document was checked
|
||
against actual source on `B2B` — but the frontend evolves. If your
|
||
implementation and this document ever disagree with what the running
|
||
frontend actually sends/expects, the frontend's actual behavior wins;
|
||
flag the doc as stale rather than building against a description that
|
||
no longer matches reality.
|
||
|
||
## Table of contents
|
||
|
||
1. [Bootstrap](#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 `§4 (Authentication, this document)` §9 for the flagged `AdminRole` naming
|
||
collision.)
|
||
- **`header`** (`HeaderConfig`, optional) — header layout config.
|
||
- **`catalog`** (`CatalogConfig`, `catalog-config.model.ts`, optional) — the
|
||
storefront catalog behavior contract:
|
||
`{ layout, loadingStrategy: 'pagination'|'loadMore'|'infiniteScroll',
|
||
navigationMode, defaultSort, availableSorts[], enabledFilters[],
|
||
showBreadcrumbs, showCategoryBanner, showSubcategoryChips, showRatings,
|
||
showDiscounts, showAvailability, suggestionsEnabled, searchHistoryEnabled }`.
|
||
`defaultSort`/`availableSorts` enumerate `relevance | latest | price_asc |
|
||
price_desc | rating | popular | discount` (see §2.3). Defaults in
|
||
`DEFAULT_CATALOG_CONFIG`.
|
||
- **`layout`** (`PlatformLayoutConfig`, optional) — global layout type.
|
||
- **`navigation`** (`NavigationConfig`, `navigation.model.ts`) —
|
||
`{ header: NavigationItemConfig[], footer: NavigationItemConfig[] |
|
||
FooterNavigationGroupConfig[], sidebar?: NavigationItemConfig[] }`. Items
|
||
carry `{ id, labelKey?|label?, route?, icon?, order?, visible?,
|
||
visibleWhenFlags?, children? }` — note `visibleWhenFlags` lets a nav item be
|
||
gated on a `featureFlags` key.
|
||
- **`footer`** (`FooterConfig`, `footer-config.model.ts`, optional) —
|
||
`{ logoUrl?, paymentIcons?, copyrightText? (string|localized), columns?
|
||
(FooterColumnConfig[]), socialLinks? }`. `legalPageKeys`/`staticPageKeys` are
|
||
`@deprecated` in favor of `columns` but still resolve for older saved configs.
|
||
- **`productPage`** (`ProductPageConfig`, optional) — per-PDP config; in the
|
||
mock: `rating{enabled}, reviews{enabled,pageSize,showSummary},
|
||
questions{enabled,pageSize}, tabs{enabled,items[]}, relatedProducts{enabled}`.
|
||
Note `reviews.pageSize`/`questions.pageSize` (5 in mock) are the storefront
|
||
engagement page sizes (§2.2).
|
||
- **`userExperience`** (`UserExperienceConfig`, `user-experience-config.model.ts`,
|
||
optional) — `{ wishlist, compare{maxItems,...}, recentlyViewed{maxItems,...},
|
||
share, continueBrowsing, savedSearches{maxItems} }` limits/toggles for the
|
||
guest-first UX features. Defaults in `DEFAULT_USER_EXPERIENCE_CONFIG`.
|
||
- **`pages`** (`PageConfig[]`, `page.model.ts`) — the dynamic page tree the
|
||
renderer builds routes from: each `{ id, key, title, route{path,exact?},
|
||
layout, sections: SectionConfig[], seoKey?, featureFlag?, visible? }`. Each
|
||
section holds ordered `widgets` with `props` (see mock §1.5 for a full
|
||
hero/categories/product-collection example). This is the builder's output.
|
||
- **`staticPages`** (`StaticPagesConfig`, `static-page.model.ts`, optional) —
|
||
either `Record<slug, StaticPageConfig>` or a legacy array. Each page carries
|
||
localized `title` + `html`/`content`, `route`, `seo`, `status:
|
||
'draft'|'published'`, `enabled`, `visibility`, `updatedAt`, etc. Storefront
|
||
legal/content pages live here (about-us, privacy-policy, terms-of-service in
|
||
the mock). Content-management edits operate on this in-memory (BACKEND-AUDIT
|
||
§16) — no dedicated content backend.
|
||
- **`widgetRegistry`** (`WidgetRegistryConfig`, optional) —
|
||
`{ manifestUrl: string }`; the URL `WidgetManifestService` fetches the widget
|
||
manifest from (§2 "Widget manifest" endpoint).
|
||
|
||
### 1.4 Versioning
|
||
|
||
`BootstrapConfig.schemaVersion` (string) **does** exist and is the schema/version
|
||
field. Current usage:
|
||
|
||
- The mock document sets `"schemaVersion": "1.0.0"`.
|
||
- `bootstrap-diagnostics.validator.ts:44` surfaces it as a diagnostic entry
|
||
(`['schemaVersion', bootstrap.schemaVersion]`).
|
||
- `AdminDashboardFacade` (`admin-dashboard.facade.ts:110`) derives the
|
||
`dashboard.healthBootstrapValid` health check purely as
|
||
`healthy: !!current?.schemaVersion` — i.e. "bootstrap is valid" today means
|
||
only "a schemaVersion string is present," **not** any real semantic-version
|
||
compatibility check.
|
||
|
||
> **Requires backend decision:** whether `schemaVersion` should be enforced
|
||
> (frontend rejecting/ warning on an unknown major version). No such
|
||
> enforcement exists today — any string is accepted, and only its presence is
|
||
> checked.
|
||
|
||
### 1.5 Example JSON
|
||
|
||
Realistic example, taken from the actual mock document
|
||
`src/assets/mock/bootstrap/bootstrap.json` (abridged — pages/staticPages
|
||
trimmed for length; full versions in that file):
|
||
|
||
```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. (`§4 (Authentication, this document)` §5.2 separately
|
||
notes the Ed25519 `restoreSession()` initializer is also not wired.)
|
||
- Bootstrap is instead loaded lazily-but-eagerly by the first consumer to need
|
||
it. Multiple root-level services subscribe to `ConfigService.loadBootstrap()`
|
||
at construction: `UiRuntimeFacade` (`ui-runtime.facade.ts:32`),
|
||
`PlatformRuntimeService`, `ThemeEngineService`, `BrandingEngineService`, the
|
||
`FooterComponent`, etc. Because `ConfigService` de-dupes (§1.7), the first
|
||
one triggers the single HTTP call and the rest share it.
|
||
- The `languageGuard` (`src/app/guards/language.guard.ts`, wrapping every
|
||
`:lang` route) runs on navigation and preloads the locale dictionary; it is
|
||
**not** itself the bootstrap fetcher, but it runs in the same startup window.
|
||
Bootstrap and language resolution are effectively concurrent — the app does
|
||
not gate rendering on bootstrap completion, so components must tolerate a
|
||
transient null snapshot.
|
||
|
||
### 1.7 Caching behavior (what the frontend actually does)
|
||
|
||
`ConfigService` (`src/app/core/config/config.service.ts`) is the cache, and it
|
||
is **in-memory only** — no `localStorage`, no HTTP cache header logic:
|
||
|
||
- Holds `bootstrapSnapshot: BootstrapConfig | null` (last successful value) and
|
||
a shared `bootstrap$` observable with `shareReplay(1)`.
|
||
- `loadBootstrap(forceRefresh = false)`: returns the cached `bootstrap$` when a
|
||
snapshot exists and `forceRefresh` is false; otherwise (re)issues the
|
||
provider call.
|
||
- On error, it clears both `bootstrap$` and `bootstrapSnapshot` so the next
|
||
call retries cleanly (`catchError` rethrows).
|
||
- A `bootstrapRevision` signal increments on every successful load/override —
|
||
consumers use it to react to config changes.
|
||
- `getBootstrapSnapshot()` returns the current in-memory value synchronously
|
||
(used by `WidgetManifestService`, diagnostics, etc.).
|
||
- The cache does **not** survive a page reload — every full page load re-fetches
|
||
bootstrap. There is also a separate client-side `cacheInterceptor`
|
||
(GET-response caching, BACKEND-AUDIT §3) that may cache the `/bootstrap`
|
||
response at the HTTP layer within a session, but nothing persists it across
|
||
sessions.
|
||
|
||
### 1.8 ETag / conditional requests
|
||
|
||
**Requires backend decision — no ETag / conditional-request logic exists in
|
||
the frontend today.** The bootstrap provider issues a plain unconditional
|
||
`GET`; nothing reads or sends `ETag`, `If-None-Match`, `Last-Modified`, or
|
||
`If-Modified-Since`. If the backend wants conditional revalidation of the
|
||
(large, per-tenant, infrequently changing) bootstrap document, both the wire
|
||
strategy and the frontend handling for it would be new work.
|
||
|
||
### 1.9 Tenant resolution
|
||
|
||
Tenant is resolved by **subdomain**, not by header or path (full detail in
|
||
`§4 (Authentication, this document)` §10; source
|
||
`src/app/core/config/tenant-resolver.service.ts`):
|
||
|
||
- `TenantResolverService.getTenantKey()`: localhost →
|
||
`environment.fallbackTenantKey ?? 'default'`; otherwise the **first DNS
|
||
label** of the hostname, skipping a leading `www` (e.g.
|
||
`dexarmarket.api.dexarmarket.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
|
||
`§6 (Error Model, this document)`), not a bootstrap-specific one.
|
||
|
||
> **Requires backend decision / frontend follow-up:** whether a hard bootstrap
|
||
> failure should present a dedicated tenant-level error screen. Today the
|
||
> failure is silent-ish (default theme, empty dynamic pages) rather than a
|
||
> blocking error, which is a UX gap if the backend can actually return a fatal
|
||
> bootstrap error.
|
||
|
||
### 1.12 Failure responses
|
||
|
||
The frontend does not parse a bootstrap-specific error body — a failed
|
||
`GET /bootstrap` surfaces as a bare `HttpErrorResponse` handled per §1.11. For
|
||
the standard error envelope every endpoint (including this one) should return
|
||
on non-2xx, and the per-status semantics (401/403/404/409/422/429/500/503,
|
||
plus `TENANT_DISABLED` and `MAINTENANCE_MODE`), see
|
||
**§6 (Error Model)** — that section owns the error envelope and this
|
||
section does not redefine it. Two of its rows are especially relevant to
|
||
bootstrap:
|
||
|
||
- **`TENANT_DISABLED`** (HTTP 403, `error.code: "TENANT_DISABLED"`) — the
|
||
natural failure for `GET /bootstrap` against a known-but-inactive tenant.
|
||
`§6` marks the frontend handling for this as "Requires backend
|
||
decision" (no code path handles it today).
|
||
- **`MAINTENANCE_MODE`** (HTTP 503, `error.code: "MAINTENANCE_MODE"`) — if a
|
||
tenant is down for maintenance, bootstrap is where the frontend would first
|
||
hit it. Again, no maintenance concept exists frontend-side today.
|
||
|
||
---
|
||
|
||
## 2. Endpoint Documentation Framework
|
||
|
||
This section defines the **general contract every endpoint in the system
|
||
follows**, so the reader has the framework before the per-domain endpoint
|
||
catalog (a separate section). Where the frontend does not define something,
|
||
it is marked **"Requires backend decision"** rather than invented.
|
||
|
||
### 2.1 Standard success response envelope
|
||
|
||
**There is no `ApiResponse<T>` / `{ success, data, error }` envelope in this
|
||
codebase.** Every live caller types its `HttpClient` call to the **bare payload
|
||
type** and consumes the raw JSON directly:
|
||
|
||
- `ApiService.getItem()` → `HttpClient.get<Item>(...)` (the item object itself).
|
||
- `ApiService.getCategories()` → `get<Category[]>` (bare array).
|
||
- `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 **`§4 (Authentication, this document)`** (this
|
||
section only references it, does not redefine):
|
||
|
||
- **Telegram session auth (LIVE)** — no `Authorization: Bearer`. Identity is a
|
||
**`WebSessionID`** header (auth session id, or a persisted anonymous 32-hex id
|
||
from `localStorage['web_session_id']`) attached to marketplace API requests by
|
||
`apiHeadersInterceptor`. Admin requests additionally carry an
|
||
**`AdminWebSessionID`** header (`adminAuthHeadersInterceptor`, gating URL
|
||
segments `/admin/`, `/backoffice/`, `/builder/`, `/media/`).
|
||
- **Ed25519 admin auth (wired, backend absent)** — issues a JWT
|
||
`AuthTokenPair { token, refreshToken }`; the intended header is standard
|
||
`Authorization: Bearer <token>`, but its `authInterceptor` is **not
|
||
registered** today (§4 §2.6), so no request auto-attaches the
|
||
bearer token yet. `adminAuthHeadersInterceptor` *does* set `Authorization:
|
||
Bearer <token>` if an admin token happens to be stored, but nothing stores one
|
||
in the live flow.
|
||
|
||
Refer to `§4 (Authentication, this document)` for token structure (`JwtClaims`), refresh,
|
||
rotation, expiry, role hierarchy, and tenant-scoping open items.
|
||
|
||
### 2.8 Live endpoint domains (real HTTP, already implemented)
|
||
|
||
The following domains have **real `HttpClient` calls in code today** (not
|
||
proposals). Documented per-endpoint below. (The Ed25519 admin-auth API is wired
|
||
to real HTTP but the backend does not implement it yet — its full contract is
|
||
in `§4 (Authentication, this document)` §2, not repeated here.)
|
||
|
||
#### 2.8.1 Session auth API (LIVE)
|
||
|
||
Base: `environment.authApiUrl` (= `https://api.dexarmarket.ru:445`). Source:
|
||
`src/app/services/telegram-session-api.service.ts`. Narrative flow in
|
||
`§4 (Authentication, this document)` §1.
|
||
|
||
| Endpoint | Method | Auth | Body / Headers | Response |
|
||
|---|---|---|---|---|
|
||
| `/users/sessions` | POST | none | body `{ webSessionID }` (client-generated GUID) + header `WebSessionID: <same guid>` | `{ webSessionID, url }` — normalized to `WebSessionStart`; `url` = Telegram deep link |
|
||
| `/users/sessions/{id}` | GET | none | — | Session object, field-tolerant → normalized `AuthSession` |
|
||
| `/users/sessions/{id}` | DELETE | none | header `WebSessionID: <id>` | ignored (frontend clears local state regardless) |
|
||
|
||
TypeScript interfaces (`src/app/models/auth.model.ts`):
|
||
|
||
```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 `§4 (Authentication, this document)` §1.3 for the full alias
|
||
priority lists). A backend should send a real `expiresAt`/`expires` (else the
|
||
frontend fabricates `now + 3600s`).
|
||
|
||
#### 2.8.2 Marketplace / storefront API (LIVE)
|
||
|
||
Base: `ApiConfigService.getBaseUrl()` (tenant-resolved; `/api` on localhost,
|
||
else `https://{tenant}.api.dexarmarket.ru:445` / `https://api.dexarmarket.ru:445`).
|
||
Source: `src/app/services/api.service.ts` (`ApiService`). Headers attached by
|
||
`apiHeadersInterceptor`: `X-Region`, `X-Language` (RU/EN/AM), `Currency`
|
||
(default RUB), `WebSessionID`. The primary product wire DTO is `Item`
|
||
(`src/app/models/item.model.ts`), reconciled by the large tolerant
|
||
`normalizeItem()` adapter (BACKEND-AUDIT §7 — treat it as the tolerance
|
||
contract). This domain's full per-endpoint catalog belongs to the CRUD-domain
|
||
section; summarized here as the framework anchor:
|
||
|
||
| Endpoint | Method | Auth | Params / Body | Response |
|
||
|---|---|---|---|---|
|
||
| `/ping` | GET | session headers | — | `{ message }` |
|
||
| `/category` | GET | session headers | — | `Category[]` (normalized) |
|
||
| `/category/{id}` | GET | session headers | `count`, `skip` | `Item[]` |
|
||
| `/items/{id}` | GET | session headers | — | `Item` |
|
||
| `/searchitems` | GET | session headers | `search`, `count`, `skip`, `categoryIDs?`, `minPrice?`, `maxPrice?`, `tag?`, `sort?` | `{ items: Item[], total: number }` |
|
||
| `/items/randomitems` | GET | session headers | `count`, `category?` | `Item[]` (featured/random) |
|
||
| `/websession/{sessionId}` | POST | session headers | item array | cart echo |
|
||
| `/items/{id}/callback` | POST | session headers | `{ rating, comment, sessionID, timestamp }` | `{ message }` (review) |
|
||
| `/items/{id}/questiion` | POST | session headers | `{ question, sessionID, timestamp }` | `{ message }` (question — **literal typo `questiion` matches backend spec**) |
|
||
| `/purchase-email` | POST | session headers | `{ email, phone?, telegramUserId, items[] }` | `{ message }` |
|
||
|
||
#### 2.8.3 Cart / order / payment API (LIVE)
|
||
|
||
Order/cart-payment endpoints on the **marketplace base**; QR/card status on the
|
||
**payment base** `environment.qrApiUrl` (= `https://qr.vitanova.network/api`).
|
||
Source: `src/app/services/api.service.ts`. DTOs are inline in that file.
|
||
|
||
| Endpoint | Method | Base | Auth | Body | Response |
|
||
|---|---|---|---|---|---|
|
||
| `/cart` | POST | marketplace | session headers | `CartPaymentRequest` | `QrCreateResponse` |
|
||
| `/orders` | POST | marketplace | session headers | `CreateOrderRequest` | `CreateOrderResponse` (fire-and-forget after payment) |
|
||
| `/qr` | POST | `qrApiUrl` | headers `authorization-key`, `userid-value` | `QrCreateRequest` | `QrCreateResponse` |
|
||
| `/qr/dynamic/{partnerId}/{qrId}` | GET | `qrApiUrl` | — | `QrDynamicStatusResponse` |
|
||
| `/card/{partnerId}/{orderId}` | GET | `qrApiUrl` | — | `QrDynamicStatusResponse` |
|
||
|
||
Const `partnerId` = `web-97ec-9c57-4dde-9037-3a68f7f83750`
|
||
(`ApiService.cartPaymentPartnerId`). Key interfaces (copied from
|
||
`api.service.ts`):
|
||
|
||
```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 `§4 (Authentication, this document)`. This section never
|
||
re-defines the auth header format; it references it.
|
||
- **Error response envelope + per-status semantics** — see
|
||
`§6 (Error Model, this document)`. This section names *which* statuses apply per
|
||
endpoint and *why*, but the wire shape of the error body is owned by that doc.
|
||
|
||
### 3.0 Conventions used in this section
|
||
|
||
**Two API base URLs are in play** (`BACKEND-AUDIT.md` §2):
|
||
|
||
- **Marketplace / tenant API** — `ApiConfigService.getBaseUrl()`
|
||
(`src/app/core/config/api-config.service.ts`). Localhost → `/api`; otherwise a
|
||
per-tenant origin (default `https://api.dexarmarket.ru:445`). Tenant isolation
|
||
is **by subdomain/base-URL only** — no `X-Tenant` header, no `/tenant/{id}`
|
||
prefix (`§4` §10). All storefront reads and all admin CRUD go
|
||
here.
|
||
- **Payment / QR API** — `environment.qrApiUrl` (`https://qr.vitanova.network/api`).
|
||
Only cart/order payment + status polling (§3.3).
|
||
- **Session-auth API** — `environment.authApiUrl`. Login/session only, covered by
|
||
`§4 (Authentication, this document)`, not repeated here.
|
||
|
||
**LIVE vs PROPOSED paths.** Only the storefront catalog/cart/engagement reads and
|
||
the single `AdminCategoriesApiGateway` have **literal HTTP paths in code**. Every
|
||
other admin CRUD path below is **PROPOSED** — no URL literal exists anywhere in
|
||
the frontend for it (`BACKEND-AUDIT.md` §24: "No literal `/admin/*`, `/builder/*`,
|
||
or per-admin-domain backoffice CRUD paths exist in code"). The admin gateways are
|
||
in-memory `*LocalGateway` classes that never construct a URL. The proposals here
|
||
follow the **one real precedent** in the codebase — `AdminCategoriesApiGateway`
|
||
(`src/app/features/admin/categories/services/admin-categories-api.gateway.ts`),
|
||
which uses:
|
||
|
||
```
|
||
GET {base}/backoffice/categories ?search&visibility&includeDeleted
|
||
GET {base}/backoffice/categories/{id}
|
||
POST {base}/backoffice/categories (body = category minus server-owned fields)
|
||
PUT {base}/backoffice/categories/{id}
|
||
DELETE {base}/backoffice/categories/{id} (soft delete)
|
||
POST {base}/backoffice/categories/{id}/restore
|
||
GET {base}/backoffice/categories/slug-taken ?slug&excludingId
|
||
```
|
||
|
||
All other admin paths below are proposed by analogy to this shape (`{base}/backoffice/<domain>`).
|
||
Builder/config domains (nav, footer, branding, languages, CMS, homepage, widgets)
|
||
are proposed under `{base}/builder/...` because the bootstrap document declares a
|
||
`apiEndpoints.builder` record for exactly this, but **no builder write call exists
|
||
in code today** — the entire builder/CMS surface is LOCAL-ONLY (in-memory
|
||
bootstrap + localStorage drafts), so those endpoints are marked **PROPOSED /
|
||
FUTURE** throughout.
|
||
|
||
**Headers (all requests).** Marketplace API requests carry `X-Region`,
|
||
`X-Language` (`RU|EN|AM`), `Currency` (default `RUB`), and `WebSessionID`
|
||
(`apiHeadersInterceptor`, `BACKEND-AUDIT.md` §3). Requests whose URL contains
|
||
`/admin/`, `/backoffice/`, `/builder/`, or `/media/` **additionally** get
|
||
`AdminWebSessionID` and, if an admin token is stored, `Authorization: Bearer
|
||
<token>` (`adminAuthHeadersInterceptor`). These header names/values are owned by
|
||
`§4` — assume them on every admin endpoint below unless stated.
|
||
|
||
**JWT requirement.** No endpoint in the app requires a verified JWT **today**.
|
||
Mechanism A (Telegram session) authenticates admins with an opaque
|
||
`AdminWebSessionID`, not a JWT; the Ed25519 JWT flow (Mechanism B) is fully wired
|
||
but dormant (`§4` §2). For every admin endpoint below, "JWT
|
||
requirement" is therefore stated as: **Today: `AdminWebSessionID` session header
|
||
(Mechanism A). Target: `Authorization: Bearer <Ed25519 JWT>` once Mechanism B is
|
||
cut over.** Server-side authorization must be enforced regardless of any
|
||
client-side guard (`§4` §11.6).
|
||
|
||
**Required permission / role.** The frontend's only permission model is the
|
||
coarse `ROLE_PERMISSIONS` table (`§4` §9.1): `backoffice.read`,
|
||
`backoffice.write`, `builder.read`, `builder.write`, `users.manage`,
|
||
`settings.manage`. The frontend does **not** enforce per-domain permissions on
|
||
admin CRUD today (the live `adminAuthGuard` only checks "is there an admin
|
||
session at all"). Where a specific permission is the obvious fit it is named as
|
||
**Proposed**; any finer per-role assignment is **Requires backend decision**
|
||
(`§4` §9.1 already flags fine-grained permissions as out of scope).
|
||
|
||
**Error responses.** Unless a domain-specific note says otherwise, every endpoint
|
||
below can return the envelope from `§6` with these statuses:
|
||
`401 UNAUTHENTICATED` (no/expired session), `403 FORBIDDEN` (wrong role/tenant),
|
||
`500 INTERNAL_ERROR`, `503 SERVICE_UNAVAILABLE`/`MAINTENANCE_MODE`. Per-endpoint
|
||
notes below add `404`, `409`, `422`, `429` only where they are meaningful. **The
|
||
frontend does not parse the error body today** (`§6` "Finding") —
|
||
admin list pages collapse every non-2xx into one generic "retry" state
|
||
(`§6` "Generic list-page error UI"). So all per-status handling
|
||
below is the *contract the backend should honor*, not behavior the current UI
|
||
distinguishes.
|
||
|
||
**Validation rules.** Admin create/edit forms
|
||
(`admin-product-form.component.ts`, `admin-category-form.component.ts`) use **no
|
||
Angular reactive `Validators`** — confirmed by source search (zero
|
||
`Validators.*` matches). Form state is built by factories
|
||
(`admin-products-form.factory.ts`, `admin-categories-form.factory.ts`) that seed
|
||
empty defaults (e.g. `slug: ''`). Therefore **the frontend enforces essentially
|
||
no field validation** on admin CRUD; every validation rule below beyond "the
|
||
field exists in the model / is non-nullable in TS" is **Requires backend
|
||
decision**. The one real client-side validation convention lives in the builder
|
||
(`ProjectValidator` → `ProjectEditorFacade.fieldError()`), which is client-only
|
||
and localStorage-scoped, not backend-fed.
|
||
|
||
**Pagination.** The admin list models that paginate use a uniform shape:
|
||
`filters { …, page: number, pageSize: number }` in, `{ items[], total, page,
|
||
pageSize }` out (products, orders, transactions, reviews). Categories, users,
|
||
monitoring, customers, analytics return **plain arrays with no pagination** —
|
||
noted per domain.
|
||
|
||
---
|
||
|
||
### 3.1 Products
|
||
|
||
Two distinct surfaces: **storefront catalog read** (LIVE) and **admin CRUD**
|
||
(MOCK-ONLY, no seam — `BACKEND-AUDIT.md` §14).
|
||
|
||
#### 3.1.a Storefront catalog read — LIVE
|
||
|
||
Backed by `ApiService` (`src/app/services/api.service.ts`) via
|
||
`ApiProductDataProvider` → `PRODUCT_DATA_PROVIDER` → `ProductFacade`. The wire
|
||
DTO is `Item` (`src/app/models/item.model.ts`); `ApiService.normalizeItem()` is
|
||
the tolerance contract (the single largest inline mapper — a backend engineer
|
||
should treat it as authoritative for accepted field name variants; see
|
||
`BACKEND-AUDIT.md` §7).
|
||
|
||
| # | Method | Path (LIVE literal) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| P1 | GET | `{base}/items/{itemID}` | Single product | `200` single `Item` (raw wire shape) |
|
||
| P2 | GET | `{base}/category/{categoryID}?count&skip` | Products in a category | `200` `Item[]` |
|
||
| P3 | GET | `{base}/searchitems?search&count&skip[&categoryIDs&minPrice&maxPrice&tag&sort]` | Search | `200` `{ items: Item[], total: number }` |
|
||
| P4 | GET | `{base}/items/randomitems?count[&category]` | Featured/random | `200` `Item[]` |
|
||
|
||
- **Headers/JWT:** marketplace headers (§3.0). No JWT; anonymous `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
|
||
(`§6` §404); a deleted product and a 500 render the same generic
|
||
empty-state. `500/503` generic. Calls retry x2 with exponential backoff
|
||
(`ApiService.retryConfig`).
|
||
- **Validation:** none (read).
|
||
- **Example P3 request:** `GET {base}/searchitems?search=phone&count=24&skip=0&categoryIDs=3,7&minPrice=1000&sort=price_asc`
|
||
- **Example P3 response:**
|
||
```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 — `§6` §409).
|
||
- **Validation:** slug uniqueness via AC7; everything else none client-side.
|
||
`title`, `slug` are non-nullable in the model. Recommended `409 CONFLICT` on
|
||
AC3/AC4 slug collision as a race backstop (`§6` §409), and
|
||
`422` for field errors.
|
||
- **Error responses:** AC2/AC6 swallow errors → `null` (so a `404` there renders
|
||
as "not found"/empty, not an error screen). AC5 hard `404` if unknown. `409`
|
||
slug conflict. Common set otherwise.
|
||
- **Example AC3 request:**
|
||
```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 (`§6` §401,
|
||
Requires backend decision). Payment status polling handles its own timeouts.
|
||
- **Validation:** none client-side. Amounts/quantities ≥ 0, valid email/phone =
|
||
Requires backend decision.
|
||
- **Example O5 request:**
|
||
```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
|
||
`§4` §9.1). Read-only U1-U5 could be `backoffice.read` — Requires
|
||
backend decision.
|
||
- **U6 body:** `{ roleId: string }`. **U7 body:** `{ status: AdminUserStatus }`
|
||
where status ∈ `active|invited|suspended`. **U8 body:** `{ email, roleId,
|
||
scope }` where scope ∈ `marketplace|office`.
|
||
- **`AdminRole`** here is `{ id, name, permissions: string[], builtIn: boolean }`
|
||
— **a different type from the auth `AdminRole` string-union** used by the JWT
|
||
`role` claim (`Owner|Administrator|Editor|Support|ReadOnly`,
|
||
`src/app/core/auth/models/permission.model.ts`). **This is the flagged
|
||
duplicate** (`BACKEND-AUDIT.md` §14, `§4` §9). Reconciliation
|
||
needed: treat the auth string-union as the JWT/role-claim contract; treat this
|
||
interface as an admin-users-management row. Recommend renaming the latter (e.g.
|
||
`AdminUserRoleRecord`). **Requires backend decision / naming reconciliation.**
|
||
- **Roles CRUD:** the gateway exposes only `loadRoles()` (read). There is **no
|
||
create/update/delete role** endpoint in the frontend — role definitions are
|
||
read-only from the frontend's perspective. Whether roles are backend-editable =
|
||
Requires backend decision.
|
||
- **Sessions/audit:** `revokeSession` (U10) exists as a mock method; whether an
|
||
Owner can remotely kill another admin's live session, and how that propagates to
|
||
the already-logged-in client, is **Requires backend decision**
|
||
(`§4` §8 — no push/poll mechanism exists client-side).
|
||
- **Validation:** none client-side. Email format, role existence, self-demotion
|
||
guards = Requires backend decision.
|
||
- **Error responses:** `404` (unknown user/session/invitation), `409` (e.g.
|
||
inviting an already-registered email — proposed), `422` (bad email/role),
|
||
common set.
|
||
- **Example U8 request:** `{ "email": "new.admin@example.com", "roleId": "role_editor", "scope": "office" }`
|
||
- **Example U1 response (trimmed):**
|
||
```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` (`§4` §9.1). **This is the
|
||
role contract for authorization.**
|
||
- **`AdminRole` #2** — interface `{ id, name, permissions: string[], builtIn }`
|
||
(`src/app/features/admin/users/models/admin-user.model.ts`). A management row for
|
||
the users screen. `AdminUser.roleId` references `AdminRole.id` here (a
|
||
string id like `role_owner`), **not** the string-union value.
|
||
- **The two do not reference each other.** A backend role table must decide
|
||
whether `AdminRole.id` (#2) resolves to one of the five #1 union values (i.e.
|
||
`builtIn` roles map onto the permission model) and whether custom
|
||
(`builtIn:false`) roles with arbitrary `permissions[]` are allowed. **Requires
|
||
backend decision** — the frontend does not resolve this collision.
|
||
- **No role CRUD endpoint** exists in the frontend beyond read (`loadRoles`).
|
||
Custom-role create/edit/delete = Requires backend decision.
|
||
|
||
---
|
||
|
||
### 3.7 Media — PROPOSED (MOCK-SWAPPABLE via abstract-class token)
|
||
|
||
Contract: abstract class `MediaRepository` (`src/app/core/media/media-repository.ts`).
|
||
Bound in `app.config.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 `§6` §422
|
||
recommends aligning backend `details[]` to. Actual required fields (slug
|
||
uniqueness, non-empty title) = Requires backend decision.
|
||
- **Error responses:** `404` (CM2/CM4/CM5), `409` (duplicate slug — proposed),
|
||
`422` (validation), common set. None handled specially by the UI today.
|
||
- **Example CM4 request (trimmed):**
|
||
```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, `§4` §9.1).
|
||
|
||
The unifying gap: **there is no client-side "publish/save bootstrap" HTTP call
|
||
anywhere** (`BACKEND-AUDIT.md` §17, §25.5). Everything in §3.8-3.15 is persisted
|
||
only to localStorage drafts + in-memory bootstrap. A real backend needs **one of**:
|
||
|
||
| # | Method | Path (PROPOSED / FUTURE) | Purpose | Success |
|
||
|---|---|---|---|---|
|
||
| S1 | GET | `{base}/bootstrap` | Load full config (**LIVE** — this one exists) | `200 BootstrapConfig` |
|
||
| S2 | PUT | `{base}/builder/bootstrap` (or `/builder/publish`) | Persist the edited bootstrap (publish) | `200 BootstrapConfig` |
|
||
| S3 | GET | `{base}/builder/settings` | General tenant settings (feature flags, catalog, SEO) | `200` (subset of BootstrapConfig) |
|
||
| S4 | PUT | `{base}/builder/settings` | Update general settings | `200` |
|
||
|
||
- **S1 is the only LIVE endpoint here** (`ApiBootstrapProvider`, `GET /bootstrap`).
|
||
- **S2 is the single most important FUTURE decision:** a whole-bootstrap publish
|
||
endpoint vs. the per-section builder endpoints proposed in §3.8-3.14. The
|
||
frontend has an `apiEndpoints.builder` record slot for exactly this but no code
|
||
that calls it. **Requires backend decision:** whole-document publish vs.
|
||
granular section writes (and optimistic-concurrency / versioning via
|
||
`BootstrapConfig.schemaVersion`/`generatedAt`).
|
||
- **Headers/JWT:** builder/settings auth. Permission (Proposed): `settings.manage`
|
||
(S2/S4), `builder.read`/`settings` read for S1/S3.
|
||
- **Validation:** `ProjectValidator` runs client-side before a (currently local)
|
||
publish. Backend-side schema validation of the whole `BootstrapConfig` =
|
||
Requires backend decision.
|
||
- **Errors:** `422` (invalid config), `409` (concurrent edit — proposed, if
|
||
versioning adopted), common set.
|
||
|
||
---
|
||
|
||
### 3.16 Transactions — PROPOSED (MOCK-ONLY, no seam)
|
||
|
||
Contract: `AdminTransactionsGateway`
|
||
(`src/app/features/admin/transactions/services/admin-transactions-gateway.interface.ts`).
|
||
Impl: `AdminTransactionsLocalGateway` (injected concretely by
|
||
`AdminTransactionsFacade`). DTO: `AdminTransaction`
|
||
(`src/app/features/admin/transactions/models/admin-transaction.model.ts`).
|
||
|
||
| # | Method | Path (PROPOSED) | Gateway method | Success |
|
||
|---|---|---|---|---|
|
||
| T1 | GET | `{base}/backoffice/transactions?search&status&type&page&pageSize` | `loadTransactions(filters)` | `200 AdminTransactionsListResult` |
|
||
| T2 | POST | `{base}/backoffice/transactions/{id}/retry` | `retryFailed(id)` | `200 AdminTransaction` |
|
||
| T3 | PATCH | `{base}/backoffice/transactions/{id}/fraud-flag` | `setFraudFlag(id,flagged)` | `200 AdminTransaction` |
|
||
|
||
- **Headers/JWT:** admin auth (§3.0). Permission (Proposed): `backoffice.read`
|
||
(T1), `backoffice.write` (T2/T3).
|
||
- **T1 query params** (`AdminTransactionListFilters`): `search`, `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.
|
||
Each item has a **Recommended default** — apply it unless you have a specific
|
||
reason not to; only stop and ask a human where none is given or the item has
|
||
real business/money/legal weight (marked accordingly).
|
||
|
||
1. **DI seam prerequisite** — orders, products, users, transactions, monitoring,
|
||
moderation (+ derived customers/analytics) inject their `*LocalGateway`
|
||
concretely; a backend requires introducing a DI token first (`BACKEND-AUDIT.md`
|
||
§14). Only categories + dashboard-metrics are token-bound today.
|
||
**Recommended default:** this is frontend work (adding the token seam), do
|
||
it for every domain before wiring its real gateway — mechanical, no backend
|
||
input needed. See §8.5 for the exact per-domain pattern.
|
||
2. **All admin CRUD paths are PROPOSED** — only `/backoffice/categories*` and the
|
||
storefront catalog/cart/engagement paths are literal. Adopt the
|
||
`/backoffice/<domain>` convention (or reject it).
|
||
**Recommended default:** adopt it as-is — it's already live for categories,
|
||
changing it later is a bigger cost than any alternative naming would save.
|
||
3. **Server-owned field lists** on create/update for products/orders/etc. (only
|
||
categories defines one, by stripping `id/itemsCount/deletedAt/createdAt/updatedAt`).
|
||
**Recommended default:** mirror the categories pattern exactly (server strips
|
||
`id`/counts/timestamps from client payloads; client never sends them).
|
||
4. **Field validation + error wording** — the frontend enforces essentially none
|
||
on admin CRUD (no reactive `Validators`); all `422` field rules and messages are
|
||
backend-owned.
|
||
**Recommended default:** validate required/type/length server-side per the DTO
|
||
shapes given in §3; return field errors in the §6 `422` shape (`fieldErrors`
|
||
keyed by field path). Don't wait on frontend validation to be added first.
|
||
5. **Product slug/sku uniqueness** — no proactive check exists (unlike categories'
|
||
`isSlugTaken`); decide `409` behavior.
|
||
**Recommended default:** mirror categories — add an `isSlugTaken`-equivalent
|
||
check endpoint, and return `409` with the §6 conflict shape on collision.
|
||
6. **Order status-transition legality** and refund eligibility rules.
|
||
**Business decision, not technical** — the valid state machine and who can
|
||
trigger which transition depends on the merchant's actual fulfillment
|
||
process. Ask.
|
||
7. **Customers as a first-class resource** vs. derived-from-orders aggregate.
|
||
**Recommended default:** derived-from-orders aggregate (§3.4 as written) —
|
||
it's what the frontend already assumes and needs no new storage model. Only
|
||
promote to first-class if a real need appears (e.g. customer accounts,
|
||
guest-order merging).
|
||
8. **`AdminRole` duplication** — auth string-union vs. users-management interface
|
||
(naming reconciliation; role CRUD / custom roles).
|
||
**Recommended default:** keep the coarse `AdminRole` string union (§4 §9.1)
|
||
as the enforced permission source; treat the users-management interface's
|
||
richer shape as display-only until custom roles are actually requested by
|
||
the product.
|
||
9. **Remote session revocation propagation** (`revokeSession`) to a logged-in
|
||
client (`§4` §8 — no push/poll exists).
|
||
**Recommended default:** accept the propagation delay (revoked session stays
|
||
client-usable until its next natural refresh-interval tick, §4 §8) rather
|
||
than building push/poll infrastructure for this alone — revisit only if a
|
||
real incident makes the delay unacceptable.
|
||
10. **Media**: allowed mime/size; whether in-use assets are delete-blocked.
|
||
**Recommended default:** allow the mime types the client already filters
|
||
for (`image/*`, `application/pdf`, `image/svg+xml` — §7.1 `MediaAssetKind`),
|
||
10MB per file; block delete of in-use assets with a `409` listing the
|
||
referencing entities (safer default than silent broken references).
|
||
11. **CMS/homepage/widgets/nav/footer/branding/languages/settings are LOCAL-ONLY**
|
||
— the single biggest decision is **whole-bootstrap publish (S2) vs. granular
|
||
builder endpoints** (§3.15). No builder write call exists in code today.
|
||
**Recommended default:** whole-bootstrap publish (a single `PUT`/`POST` that
|
||
replaces the tenant's bootstrap document) — matches how the frontend already
|
||
edits it as one in-memory draft object; granular per-section endpoints are
|
||
strictly more backend work for no frontend benefit today.
|
||
12. **Languages**: duplicate-add `409`, removing the default/last locale, BCP-47
|
||
validation.
|
||
**Recommended default:** `409` on duplicate-add; reject removing the last
|
||
remaining locale or the current default (`422`); validate against BCP-47
|
||
syntax, not a fixed enum (frontend already treats locale as an open string).
|
||
13. **Transactions ↔ live payment records** are unconnected models; how they link.
|
||
**Recommended default:** key `AdminTransaction` off the same order/payment id
|
||
the live QR/card flow already returns (§3.3.a) rather than inventing a new
|
||
identifier — one payment, one transaction record.
|
||
14. **Reviews**: storefront `Review` vs. admin `AdminReview` linkage; how a
|
||
submitted review enters the moderation queue.
|
||
**Recommended default:** every storefront-submitted review starts in the
|
||
moderation queue at `pending` status; `AdminReview` is the same underlying
|
||
record with moderation fields attached, not a separate entity.
|
||
15. **Reports**: no creation flow exists — who creates reports.
|
||
**Business decision** — depends on whether "reports" means customer-flagged
|
||
content, automated abuse detection, or admin-initiated audits. Ask.
|
||
16. **Monitoring**: event windowing/pagination for a real stream; webhook
|
||
*management* (vs. read-only delivery log) is out of scope.
|
||
**Recommended default:** offset-paginated `GET` with a `since`/time-range
|
||
filter (matches §2.2's standard pagination contract) — no need for a
|
||
streaming protocol for an admin-facing log view.
|
||
17. **Analytics**: `conversionRate`/`retentionPercent` must stay `null` when
|
||
unknown; no traffic/visitor data source exists anywhere — real analytics is a
|
||
from-scratch backend build.
|
||
**Recommended default:** ship order/product/revenue aggregates first (real
|
||
data, computable from orders/products already in scope); leave traffic-based
|
||
metrics `null` until a real analytics/traffic pipeline exists — never
|
||
fabricate a number, the frontend already renders `null` as "Unknown."
|
||
18. **Variants/options** shape reconciliation between storefront `variantOptions`
|
||
(grouped) and admin `variants` (flat priced rows); no dedicated variants
|
||
endpoint (created within the product body).
|
||
**Recommended default:** keep variants embedded in the product body (no
|
||
separate variants endpoint) — matches how the admin editor already sends
|
||
them; the backend computes/derives the storefront's grouped
|
||
`variantOptions` shape from the same flat rows at read time.
|
||
|
||
---
|
||
|
||
_All paths repo-relative to `F:\dx\remote\marketplaces\`. Storefront/category
|
||
literals and the `AdminCategoriesApiGateway` paths are verified in source; every
|
||
other endpoint is PROPOSED per the conventions in §3.0._
|
||
|
||
---
|
||
|
||
## 4. Authentication
|
||
|
||
Standalone, backend-implementable authentication contract for the marketplace
|
||
platform (Angular frontend, branch `B2B`). Derived directly from source —
|
||
`src/app/core/auth/**`, `src/app/core/admin-auth/**`,
|
||
`src/app/services/{auth,telegram-session-api}.service.ts`,
|
||
`src/app/components/telegram-login/**`, `src/app/guards/language.guard.ts`,
|
||
`src/app/app.routes.ts`, `src/app/app.config.ts`,
|
||
`src/app/core/config/tenant-resolver.service.ts` — plus
|
||
`docs/context/BACKEND-AUDIT.md` (this-session audit) and the prior
|
||
`docs/AUTH.md`. Where the frontend does not already imply a behavior, this
|
||
document says **"Requires backend decision"** rather than inventing one.
|
||
|
||
Two authentication mechanisms coexist in the codebase today, at different
|
||
maturity levels:
|
||
|
||
| Mechanism | Used by | Status |
|
||
|---|---|---|
|
||
| Telegram QR / deep-link session auth | Storefront customers **and** admin/backoffice (same API) | **LIVE** — real endpoints, in production use |
|
||
| Ed25519 challenge/response admin auth | Admin/backoffice (intended replacement) | **Frontend fully wired, backend endpoints do not exist yet** (404s today) |
|
||
|
||
Both are documented in full below. Nothing here should be read as "the
|
||
platform has JWTs today" — it does not, except inside the not-yet-live
|
||
Ed25519 flow.
|
||
|
||
---
|
||
|
||
### Table of contents
|
||
|
||
1. [Mechanism A — Telegram QR / session login (LIVE)](#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. Each has a
|
||
**Recommended default**; apply it unless the item is flagged as a real
|
||
business/security decision.
|
||
|
||
- **Public-key enrollment mechanism** (§2.1) — how an admin's Ed25519
|
||
`publicKeyBase64` gets associated with an account/role server-side (admin
|
||
tool? one-time enrollment link? manual DB entry?). Zero frontend code
|
||
exists for this by design.
|
||
**Recommended default:** an authenticated admin-management endpoint
|
||
(`POST /backoffice/users/{id}/public-key`), callable only by an
|
||
already-authenticated Owner/Admin — treat it as a users-management
|
||
action (§3.5), not a public/unauthenticated enrollment flow.
|
||
- **Refresh-token reuse/compromise detection** (§6) — whether presenting an
|
||
already-rotated-out refresh token should revoke the whole token family.
|
||
Not implied by any frontend behavior.
|
||
**Recommended default:** yes — revoke the whole token family on reuse of
|
||
a rotated-out refresh token (standard rotation-detection practice); this
|
||
is a security posture, not just a technical convention, so treat as
|
||
security-review-required rather than silently skippable.
|
||
- **Session/token revocation propagation** (§8) — whether/how a
|
||
remotely-revoked admin session (e.g. via the mock `AdminUsersGateway.
|
||
revokeSession`) is communicated to an already-logged-in client before its
|
||
next refresh cycle. No push/poll mechanism exists today.
|
||
**Recommended default:** accept the propagation delay (§3.21 item 9) —
|
||
don't build push infrastructure for this alone.
|
||
- **Tenant scoping of auth requests** (§10) — whether login/challenge/verify
|
||
need an explicit tenant identifier in the payload, versus relying on
|
||
request origin. Not present in any current auth payload.
|
||
**Recommended default:** rely on request origin/host (matches how
|
||
`ApiConfigService.getBaseUrl()` already resolves tenant elsewhere, §1.9)
|
||
rather than adding a redundant payload field.
|
||
- **Relationship between the two mechanisms at cutover** — replace
|
||
`adminAuthGuard` with `ed25519AuthGuard` outright, or run both and let
|
||
role/tenant config decide? Explicitly called out in the prior
|
||
`docs/AUTH.md` as "a product decision, not made here," and nothing has
|
||
changed that.
|
||
**Business decision, not technical** — ask. (This is about auth security
|
||
posture and rollout risk, not a convention.)
|
||
- **`AdminAuthService`'s reserved JWT-pair slots** (`adminToken`/
|
||
`adminRefreshToken`, §4) — whether Mechanism A is ever meant to gain its
|
||
own token pair (as the reserved-but-unused storage suggests) independent
|
||
of the Ed25519 migration, or whether that code is dead and should be
|
||
removed. Not resolved by current usage (nothing writes to it).
|
||
**Recommended default:** treat as dead/reserved-but-unused; don't build
|
||
backend support for it. Remove client-side only if a dedicated frontend
|
||
cleanup pass confirms nothing depends on the reserved slots existing.
|
||
- **`AdminRole` naming collision** (§9) — a reconciliation/rename decision
|
||
between `core/auth/models/permission.model.ts`'s string union and
|
||
`features/admin/users/models/admin-user.model.ts`'s interface; flagged,
|
||
not resolved, by this document.
|
||
**Recommended default:** same as §3.21 item 8 — keep the coarse string
|
||
union as the enforced source, treat the richer interface as display-only.
|
||
- **Fine-grained/per-domain permissions** (§9.1) — the current model is
|
||
intentionally coarse; whether a richer permission model is ever needed is
|
||
a backend/product decision.
|
||
**Recommended default:** keep it coarse (`backoffice.read`/
|
||
`backoffice.write`/`builder.read`/`builder.write`/`settings.manage`) —
|
||
don't build fine-grained permissions speculatively; the frontend has no
|
||
UI that would use finer granularity today.
|
||
- **`APP_INITIALIZER` wiring for `AuthFacade.restoreSession()`** (§5.2) — the
|
||
code comment says this should be wired in before the Ed25519 flow goes
|
||
live, but it is not wired in today. This is frontend follow-up work, not a
|
||
backend decision, but is listed here because it changes what "session
|
||
restored on refresh" means in practice until it lands.
|
||
**Recommended default:** frontend must wire this before Ed25519 goes live
|
||
in production — flag as a blocking frontend task in the cutover plan, not
|
||
something backend needs to wait on.
|
||
|
||
---
|
||
|
||
## 5. Security
|
||
|
||
Every claim below is grounded in current source on branch `B2B`. Where the
|
||
frontend implies nothing, the item is marked **Requires backend decision**.
|
||
Auth and error contracts are owned by §4 and §6 respectively — this section
|
||
references them rather than restating them, and only covers the
|
||
security-relevant angle.
|
||
|
||
### 5.1 Origin
|
||
|
||
The frontend calls a small, fixed set of origins, all declared in
|
||
`src/environments/environment.ts` / `environment.production.ts`:
|
||
|
||
| Origin | Source key | Used for |
|
||
|---|---|---|
|
||
| `https://api.dexarmarket.ru:445` | `tenantApiBaseUrls.default`, `authApiUrl`, and prod `apiUrl`/`localhostApiUrl` | marketplace API + session auth + Ed25519 admin auth |
|
||
| `https://{tenant}.api.dexarmarket.ru:445` | `tenantApiTemplate` | per-tenant marketplace API (subdomain-substituted) |
|
||
| `https://qr.vitanova.network/api` | `qrApiUrl` | payment / QR / card status |
|
||
| `/api` (relative) | dev `apiUrl`/`localhostApiUrl` | localhost, proxied by nginx per tenant |
|
||
| `https://api.qrserver.com/...` | literal in `ApiService` | QR **bitmap** image only (not a platform backend) |
|
||
| `http://ip-api.com/json/...` | literal in `LocationService` | external geo-IP autodetect (no key, plaintext HTTP) |
|
||
|
||
The tenant marketplace origin is chosen by **subdomain** (`TenantResolverService`,
|
||
see `§4 (Authentication, this document)` §10) — there is no `X-Tenant` header or tenant
|
||
path prefix. **Backend note:** the browser will make cross-origin requests to
|
||
`api.dexarmarket.ru:445` and `qr.vitanova.network` from whatever host the SPA
|
||
is served on (e.g. `dexarmarket.ru`), so those origins must return correct
|
||
CORS headers (§5.2). `ip-api.com` is called over plaintext HTTP — a mixed-content
|
||
and privacy concern flagged here; the frontend already tolerates its failure
|
||
(falls back to 6 hardcoded regions).
|
||
|
||
### 5.2 CORS
|
||
|
||
**Bearer/header-only, never credentialed.** No `HttpClient` call in the
|
||
codebase sets `withCredentials: true` (grep: zero matches in `src/`). Identity
|
||
travels in **headers**, not cookies:
|
||
|
||
- `WebSessionID` on every marketplace API request (`apiHeadersInterceptor`).
|
||
- `AdminWebSessionID` + `Authorization: Bearer <token>` on `/admin/`,
|
||
`/backoffice/`, `/builder/`, `/media/` requests (`adminAuthHeadersInterceptor`).
|
||
- `authorization-key` / `userid-value` on QR payment calls.
|
||
|
||
The auth **cookies** (`webSessionID`, `adminSessionID`) are same-site,
|
||
first-party, and are **not** sent cross-origin to the API — they exist only so
|
||
the SPA can restore its own session locally. **Backend requirement:** the API
|
||
must accept these requests as CORS with `Access-Control-Allow-Origin` set to
|
||
the specific SPA origin(s) and `Access-Control-Allow-Headers` including
|
||
`WebSessionID, AdminWebSessionID, Authorization, X-Region, X-Language, Currency`
|
||
(and the QR API's `authorization-key, userid-value`). Because no request is
|
||
credentialed, `Access-Control-Allow-Credentials` is **not** required and
|
||
`Allow-Origin` may be specific per tenant.
|
||
|
||
### 5.3 CSRF
|
||
|
||
**Not applicable to the API surface, by construction.** All API authentication
|
||
is bearer/header-based (§5.2) with **no cookie ever transmitted to the API**
|
||
and `withCredentials` never set. A CSRF attack relies on the browser
|
||
auto-attaching an ambient credential (cookie) to a forged cross-site request;
|
||
since the API authenticates on a header the attacker's page cannot set
|
||
cross-origin, a classic CSRF POST cannot forge an authenticated call. There is
|
||
zero CSRF-token handling in the frontend (no XSRF interceptor, no
|
||
`X-CSRF-Token`/`X-XSRF-TOKEN` reference anywhere in `src/`), and none is
|
||
needed for the current design. **Backend note:** this guarantee holds only as
|
||
long as the API does **not** start accepting a cookie as an auth credential —
|
||
if a future endpoint authenticates via cookie, CSRF protection becomes
|
||
mandatory. Requires backend decision only if that design change is made.
|
||
|
||
### 5.4 JWT
|
||
|
||
Cross-reference `§4 (Authentication, this document)` §3–§4. Security-relevant storage
|
||
angle only:
|
||
|
||
- **Live today (Telegram session, Mechanism A):** no JWT at all — an opaque
|
||
`webSessionID` in a cookie + in-memory signal. The reserved
|
||
`localStorage['adminToken']` / `adminRefreshToken` slots are **dead** (nothing
|
||
writes them).
|
||
- **Ed25519 flow (Mechanism B, dormant):** access + refresh tokens live in
|
||
`localStorage` (`ed25519AdminToken`, `ed25519AdminRefreshToken`).
|
||
**XSS exposure implication:** `localStorage` is readable by any script in the
|
||
origin, so a single XSS foothold (see §5.8) exfiltrates the admin token pair
|
||
outright — there is no `HttpOnly` cookie protecting them. This is the
|
||
standard SPA trade-off and is acceptable **only** if the XSS surface (§5.8)
|
||
is genuinely closed server-side. **Requires backend decision:** whether admin
|
||
tokens should instead be delivered as `HttpOnly; Secure; SameSite` cookies
|
||
to remove the XSS-exfiltration path — the frontend currently assumes
|
||
`localStorage`, so this would be a coordinated frontend+backend change.
|
||
|
||
### 5.5 Refresh
|
||
|
||
Refresh tokens are protected client-side only as well as `localStorage`
|
||
protects them — i.e. not from XSS (§5.4). The frontend never decodes the
|
||
refresh token (round-tripped opaque), stores it beside the access token, and
|
||
replaces the pair on every `/verify` and `/refresh` (`§4 (Authentication, this document)`
|
||
§4, §6). Rotation is **expected** but only the backend can enforce it. There is
|
||
no client-side reuse-detection. **Requires backend decision** (already flagged
|
||
in `§4 (Authentication, this document)` §6): single-use refresh tokens + reuse/compromise
|
||
revocation cascade — nothing in the frontend implies or depends on it.
|
||
|
||
### 5.6 Rate limiting
|
||
|
||
There is **no client-side rate-limit handling** (grep: no `429` / "rate limit"
|
||
reference in `src/`; confirmed by `§6 (Error Model, this document)` §429). The frontend
|
||
does, however, apply **debounce** on user-driven request bursts, which implies
|
||
the natural request cadence a backend limiter should tolerate rather than block:
|
||
|
||
- Search-as-you-type: `debounceTime(220)` in `SearchFacade`
|
||
(`src/app/features/search/facade/search.facade.ts:98`) — so a typing user
|
||
produces at most ~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 `§6 (Error Model, this document)` §429, no retry
|
||
interceptor exists — honoring 429 is net-new frontend work, not a config
|
||
change.
|
||
|
||
### 5.7 Replay protection
|
||
|
||
The Ed25519 signing flow (`§4 (Authentication, this document)` §2) signs the **raw
|
||
backend-issued nonce string exactly as received** — `Ed25519KeypairService.sign(nonce)`
|
||
applies no client-side framing, prefix, hashing, timestamp, or
|
||
counter (`src/app/core/auth/services/auth.service.ts` login flow;
|
||
`ed25519-keypair.service.ts`). The `VerifySignatureRequest` body is
|
||
`{ publicKey, signature, nonce }` and nothing else. Therefore **all** replay
|
||
protection is the backend's responsibility via the nonce it issues:
|
||
`AuthChallenge { nonce, issuedAt, expiresAt }`. **Backend requirement:** treat
|
||
each nonce as single-use, bind it to the issuing client, reject it after
|
||
`expiresAt`, and reject any reuse. The frontend contributes no independent
|
||
freshness signal — there is no client timestamp or per-request nonce on any
|
||
other call either (the Telegram session flow has no signing at all).
|
||
|
||
### 5.8 XSS
|
||
|
||
The frontend renders **backend/CMS-controlled HTML** in three places — this is
|
||
the single most important input for what the backend must sanitize:
|
||
|
||
| Location | File | Handling today |
|
||
|---|---|---|
|
||
| Storefront static page body (CMS content) | `src/app/pages/static-page/static-page.component.ts:104-106` | Runs `DomSanitizer.sanitize(SecurityContext.HTML, html)` **first**, then `bypassSecurityTrustHtml(sanitized)`. So it is Angular-sanitized before injection into `[innerHTML]`. |
|
||
| Static-page **preview** (editor) | `src/app/features/content-management/components/static-page-preview/static-page-preview.component.ts:49` | `bypassSecurityTrustHtml(sanitized)` after a sanitize pass. |
|
||
| Payment bank-frame URL | `src/app/pages/cart/cart.component.ts:475` | `bypassSecurityTrustResourceUrl(bankUrl)` — trusts a backend-supplied iframe URL. |
|
||
|
||
The static-page path is the risky one: CMS body HTML originates from the
|
||
bootstrap document (`BootstrapConfig.staticPages`, backend-served). It **is**
|
||
run through Angular's sanitizer client-side, but Angular sanitization strips
|
||
active content for the DOM context only — it is **not** a substitute for
|
||
server-side sanitization/storage validation. **Backend requirement:** treat all
|
||
CMS static-page HTML and any product-description HTML as untrusted on write,
|
||
sanitize/allowlist server-side, and never assume the client sanitizer is the
|
||
only defense. The `bypassSecurityTrustResourceUrl` on the bank payment frame
|
||
means the backend-supplied payment URL must be strictly validated server-side
|
||
(a malicious value renders in an iframe with `ResourceUrl` trust). Product
|
||
descriptions are currently normalized to plain strings by
|
||
`ApiService.normalizeItem` and are **not** rendered via `[innerHTML]` today, so
|
||
they are lower-risk — but that holds only while descriptions stay plain-text.
|
||
|
||
Note: the project-editor HTML editor (`marketplace-html-editor.component.ts`)
|
||
uses `innerHTML` on a `contenteditable` surface for authoring; that is
|
||
author-local editor state, not backend-rendered content, so it is out of the
|
||
backend's sanitization scope.
|
||
|
||
### 5.9 CSP
|
||
|
||
**No CSP meta tag in `src/index.html`** (confirmed — only SEO/OG/Twitter/theme
|
||
meta tags are present). CSP is delivered **at the web-server layer** via
|
||
`nginx.conf`, and only on the primary tenant block:
|
||
|
||
- `dexarmarket.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
|
||
`§4 (Authentication, this document)` §9.1). Roles are the string union
|
||
`Owner | Administrator | Editor | Support | ReadOnly`; permission domains are
|
||
`backoffice`, `builder`, `users`, `settings`:
|
||
|
||
| Role | backoffice.read | backoffice.write | builder.read | builder.write | users.manage | settings.manage |
|
||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||
| **Owner** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||
| **Administrator** | ✅ | ✅ | ✅ | ✅ | ✅ | — |
|
||
| **Editor** | ✅ | ✅ | ✅ | ✅ | — | — |
|
||
| **Support** | ✅ | — | — | — | — | — |
|
||
| **ReadOnly** | ✅ | — | ✅ | — | — | — |
|
||
|
||
Guards that would eventually enforce this (`ed25519AuthGuard`,
|
||
`permissionGuard(permission)`) exist but are **not referenced by any route**
|
||
today (`§4 (Authentication, this document)` §11). The mapping is deliberately coarse —
|
||
there is no per-domain granularity (e.g. "edit prices but not delete products")
|
||
anywhere client-side.
|
||
|
||
**Backend requirements / decisions:**
|
||
- The backend **must** independently authorize every admin mutation against the
|
||
authenticated role — a passing client guard is never proof of authorization.
|
||
- **Requires backend decision:** finer-grained per-domain permissions if ever
|
||
needed (none implied client-side).
|
||
- **Requires backend decision:** the `AdminRole` naming collision (auth string
|
||
union vs the users-management `AdminRole` interface with `{id, name,
|
||
permissions[], builtIn}`) — flagged in `§4 (Authentication, this document)` §9; the
|
||
users-management interface suggests custom/non-builtin roles with arbitrary
|
||
permission-string sets, which the coarse 5-role union does not model. Reconcile
|
||
before building the backend role table.
|
||
|
||
---
|
||
|
||
|
||
---
|
||
|
||
## 6. Error Model
|
||
|
||
Single unified error-response format the backend must return for every non-2xx
|
||
response across all API surfaces (marketplace API, payment/QR API, session
|
||
auth API, Ed25519 admin auth API, and any future admin/builder/backoffice
|
||
APIs). Derived by cross-referencing every place the Angular frontend
|
||
currently parses, catches, or reacts to an HTTP error — see
|
||
`docs/context/BACKEND-AUDIT.md` for the full backend-surface audit this is
|
||
based on.
|
||
|
||
**Finding: the frontend does not currently parse any backend error envelope.**
|
||
No `HttpInterceptor` in the pipeline (`src/app/app.config.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. §10 (Maintenance Mode) owns the UX/copy for the maintenance
|
||
case — this section only fixes the wire signal it must key off
|
||
(`error.code === "MAINTENANCE_MODE"`), consistent with §10 without
|
||
duplicating UX detail here.
|
||
|
||
#### Maintenance mode
|
||
|
||
Same HTTP status as above (503), distinguished purely by `error.code`:
|
||
|
||
```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 `§10 (Maintenance Mode, this document)` 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 §4 — keep the `code`
|
||
value (`INVALID_SIGNATURE`) consistent with what §4 names the failure mode;
|
||
this contract only defines the wire shape, §4 owns the auth-flow narrative.
|
||
|
||
**Frontend reaction today:** same gap as "Expired token" above.
|
||
`AuthService.login()` passes `fallbackCode: 'invalid-signature'` into
|
||
`handleAuthError()`, but `toAuthErrorShape()` discards it for any real
|
||
`HttpErrorResponse` and maps a 401 from `/verify` to the generic
|
||
`'unauthorized'` screen via `authErrorCodeFromStatus()`. The dedicated
|
||
"Invalid signature… Try again" screen
|
||
(`src/app/core/auth/pages/auth-error-page.component.ts:21-25`) exists in the
|
||
copy table but is **currently unreachable from a real backend response** for
|
||
the same reason as `session-expired` above. **Requires backend decision +
|
||
frontend fix**: backend must send a body-level `error.code:
|
||
"INVALID_SIGNATURE"` and the frontend's `toAuthErrorShape()` must be updated
|
||
to prefer a body code over the status-only mapping, or this screen stays
|
||
dead code reachable only via non-HTTP error paths.
|
||
|
||
---
|
||
|
||
### Generic list-page error UI (for reference)
|
||
|
||
Every admin backoffice list page (`AdminUsersFacade`, `AdminOrdersFacade`,
|
||
`AdminMonitoringFacade`, `AdminModerationFacade`, `AdminTransactionsFacade`,
|
||
`AdminProductsFacade`, `AdminCategoriesFacade`, `AdminAnalyticsFacade`,
|
||
`AdminCustomersFacade`, `AdminDashboardFacade`) follows the same shape,
|
||
added by RC-02 (`e153a67 fix(backoffice): add error+retry states to Users,
|
||
Monitoring, Analytics, Reports`):
|
||
|
||
```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
|
||
|
||
Each has a **Recommended default** — apply it unless flagged otherwise.
|
||
|
||
- **Envelope adoption** — the `{ error: { code, message, status, requestId,
|
||
details? } }` shape itself; no frontend code parses any envelope today.
|
||
**Recommended default:** adopt it as specified — every status-by-status
|
||
example in this section already uses it; it's the shape to build against.
|
||
- **401 on customer-facing marketplace calls** (`/cart`, `/orders`, etc.) —
|
||
no unified "session expired, please re-auth" UX exists for the customer
|
||
Telegram-session flow.
|
||
**Recommended default:** return the standard envelope with
|
||
`code: "UNAUTHORIZED"`; the frontend re-auth UX is a follow-up frontend
|
||
task once the backend consistently returns this, not something to block
|
||
the backend on.
|
||
- **403 tenant-mismatch vs role-mismatch** distinct copy/code.
|
||
**Recommended default:** two distinct codes (`TENANT_MISMATCH`,
|
||
`FORBIDDEN`) — the frontend can pick copy per-code once they exist;
|
||
collapsing them into one code loses information for no benefit.
|
||
- **404 vs generic-error distinct UX** on catalog/product pages (currently
|
||
identical).
|
||
**Recommended default:** return real `404` for missing resources (not a
|
||
generic `500`) — this is a backend correctness item, not a decision;
|
||
the frontend UX unification is a separate, non-blocking follow-up.
|
||
- **409 conflict handling on submit** (today only a proactive
|
||
`isSlugTaken` pre-check exists; no reactive 409 handler).
|
||
**Recommended default:** return `409` with the standard envelope on any
|
||
uniqueness/concurrency conflict; treat the proactive pre-check as
|
||
best-effort only (§3.21 item 5 — TOCTOU-prone by nature).
|
||
- **422 `details[]` → inline field-error adapter** for admin forms (the
|
||
client-side `fieldError()` convention exists but nothing feeds it from a
|
||
backend response yet).
|
||
**Recommended default:** populate `details[]` as `{ field, message }` per
|
||
invalid field on every `422` — the client-side adapter already expects
|
||
this shape, it just has nothing to consume yet.
|
||
- **429 rate-limit contract end to end** — header vs body, retry-after
|
||
value, and whether the frontend auto-retries (nothing exists today).
|
||
**Recommended default:** standard `Retry-After` header (seconds) +
|
||
envelope body with `code: "RATE_LIMITED"`; no frontend auto-retry (the
|
||
user retries manually) — simplest safe default, matches how the rest of
|
||
the app already treats errors as terminal-until-user-action.
|
||
- **Maintenance-mode `maintenanceUntil` reliability** — advisory only, or
|
||
can the frontend promise an ETA.
|
||
**Recommended default:** advisory only — word the frontend copy as
|
||
"expected back around X" not a guarantee, so an inaccurate estimate
|
||
doesn't need special-case backend handling.
|
||
- **Tenant-disabled status code and screen** — reuse `forbidden` copy (wrong
|
||
wording today) vs. add a dedicated `AuthErrorCode`.
|
||
**Recommended default:** dedicated `code: "TENANT_DISABLED"` (already
|
||
named consistently elsewhere in this doc, §1.12) rather than overloading
|
||
`forbidden` — one wrong-wording bug is enough of a reason not to add a
|
||
second overload.
|
||
- **Expired-token / invalid-signature body-code fix** — both are
|
||
**pre-existing frontend bugs**, not just missing decisions:
|
||
`toAuthErrorShape()` in `auth.service.ts` currently derives the error code
|
||
from HTTP status only and ignores the `fallbackCode` for real HTTP errors,
|
||
so the `'session-expired'` and `'invalid-signature'` screens are dead code
|
||
from any real backend response today. Fixing this requires both a backend
|
||
body `error.code` and a frontend change to prefer it.
|
||
**Recommended default:** backend returns `code: "TOKEN_EXPIRED"` /
|
||
`code: "INVALID_SIGNATURE"` on the relevant `401`s; frontend fix (prefer
|
||
body code over status-derived fallback) is tracked separately in
|
||
`docs/KNOWN-ISSUES.md` and is not blocking backend work — the backend
|
||
side of this can and should ship regardless of when the frontend fix
|
||
lands.
|
||
|
||
---
|
||
|
||
## 7. Uploads
|
||
|
||
Full media-upload lifecycle contract, derived from the actual media stack:
|
||
`MediaRepository` (abstract token), `MockMediaRepository` (the only impl),
|
||
`MediaLibraryFacade`, `MediaPickerComponent`, `MediaUsageService`, and the
|
||
`MediaAsset` model. **Structural status:** media is MOCK-SWAPPABLE — bound via
|
||
`{ provide: MediaRepository, useClass: MockMediaRepository }` in `app.config.ts`,
|
||
so a real HTTP repository drops in behind the same abstract class with no UI
|
||
change. **No real HTTP media backend exists yet**, and **no literal media
|
||
endpoint path exists anywhere in code** — every path below is **PROPOSED** to
|
||
satisfy the existing `MediaRepository` method shapes. The admin-auth interceptor
|
||
already attaches `AdminWebSessionID` + `Authorization: Bearer` to any `/media/`
|
||
URL (`docs/context/BACKEND-AUDIT.md` §3), so the backend can expect those headers.
|
||
|
||
### 7.1 The `MediaAsset` contract (real interface)
|
||
|
||
`src/app/core/media/models/media-asset.model.ts`:
|
||
|
||
```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 section — see §6 for the
|
||
general error-response shape).
|
||
|
||
**No frontend UI currently exists for this — requires a future frontend task.** The
|
||
checkout flow would need a "payments unavailable" inline state (banner or disabled
|
||
submit + tooltip, per §7) distinct from a generic error toast, and catalog browsing
|
||
would need to keep working untouched — which it structurally already would, since
|
||
`ProductFacade`/`CategoryFacade` and the payment calls are fully independent code
|
||
paths today (no shared failure state). That independence is a real asset: a payments
|
||
outage cannot accidentally break catalog browsing given the current facade
|
||
separation, but no UI exists yet to *tell the user* payments specifically are down
|
||
rather than "something went wrong."
|
||
|
||
---
|
||
|
||
### 4. Read-only mode
|
||
|
||
Writes disabled, reads still work.
|
||
|
||
**Does the frontend already assume this is possible?** Partially, structurally, but
|
||
not deliberately. Cart state is `LOCAL-ONLY` (`CartService`,
|
||
`src/app/services/cart.service.ts`, signal-based, persisted to `localStorage` key
|
||
`marketplace_cart`) — adding items to cart, changing quantities, and browsing the cart
|
||
UI works entirely client-side with **no backend call at all** until checkout. The
|
||
only writes that hit a backend are at the checkout boundary: `POST /cart`
|
||
(`createCartPayment`), `POST /orders` (`createOrder`), `POST /purchase-email`, and the
|
||
QR/card payment polling. So today, if the backend rejected writes only, catalog
|
||
browsing, search, wishlist/compare (also `LOCAL-ONLY`,
|
||
`LocalUserExperienceRepository`), and cart-building would all continue working simply
|
||
because they never touch the backend — but reviews (`POST /items/{id}/callback`) and
|
||
questions (`POST /items/{id}/questiion`) are also writes and would fail the same as
|
||
checkout, since both are LIVE endpoints via `ProductDataProvider`.
|
||
|
||
There is no code today that *checks for* a read-only flag and proactively disables
|
||
write UI (e.g. graying out "Add to cart" or the checkout button ahead of time). A
|
||
write attempt would only be discovered to be blocked when the write call itself
|
||
fails.
|
||
|
||
**What the backend should send:** `503` (or `403`, see note below) with the
|
||
structured body (§7), `scope: "readonly"`, on write endpoints specifically —
|
||
`POST /cart`, `POST /orders`, `POST /purchase-email`, `POST /items/{id}/callback`,
|
||
`POST /items/{id}/questiion`, `POST /websession/{sessionId}` (cart sync) — while GET
|
||
endpoints keep working. `403 Forbidden` is arguably more correct REST semantics for
|
||
"this resource forbids this method during a maintenance window" than `503`, but `503`
|
||
+ `Retry-After` communicates "temporary" more clearly to a client and is
|
||
recommended so the frontend can offer a countdown/retry consistent with §5's pattern.
|
||
**Requires backend decision:** which status code is authoritative — this should be
|
||
pinned down jointly with whatever `§6` settles on for its 5xx
|
||
conventions, since read-only is really "a subset of write endpoints return
|
||
maintenance-503."
|
||
|
||
**No frontend UI currently exists for this — requires a future frontend task.** No
|
||
bootstrap flag exists to proactively disable checkout/review/question submission
|
||
ahead of a failed request (e.g. `featureFlags.readOnly` or a dedicated
|
||
`platformStatus.readOnly` field would need to be added to `BootstrapConfig` if the
|
||
product wants a proactive banner instead of a reactive failure). Reactive handling
|
||
(showing an error when the write call 503s) can reuse the same inline
|
||
error-state pattern as §3/§6 once `§6` defines the generic error body
|
||
handling.
|
||
|
||
---
|
||
|
||
### 5. Scheduled maintenance
|
||
|
||
Advance notice pattern (banner / countdown) ahead of a maintenance window.
|
||
|
||
**What exists in the frontend today:** nothing. No banner component, no countdown
|
||
component, no bootstrap field for an upcoming maintenance window.
|
||
|
||
**Requires backend decision — proposed minimal contract:** add an optional field to
|
||
`BootstrapConfig` (loaded once per session/on refresh via `GET /bootstrap`), e.g.:
|
||
|
||
```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, nested under the §6 error envelope (`{ error: { code, message, ... } }`)
|
||
rather than as a competing top-level shape. The fields below are what the frontend needs regardless
|
||
of the outer envelope:
|
||
|
||
```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 `§6` |
|
||
| Scheduled (advance notice) | 200, via `bootstrap.maintenanceNotice` | n/a | Not an error response — a proactive field on the normal `/bootstrap` payload, see §5 |
|
||
| Temporary feature disable | 200, via `bootstrap.featureFlags.<key> = false` | n/a | Not an error response — existing bootstrap mechanism, see §6 |
|
||
|
||
---
|
||
|
||
### 8. Frontend behavior
|
||
|
||
Grounded in the UI patterns that already exist (`EmptyStateComponent`
|
||
(`src/app/shared/ui/empty-state/empty-state.component.ts`), the `errorTitle` /
|
||
`error` / `retry` i18n-key convention used across catalog, product details, and
|
||
generic list widgets (`src/app/i18n/en.ts`), and `AuthErrorPageComponent`'s
|
||
code-keyed full-page pattern). No new UI concepts are invented below beyond composing
|
||
these.
|
||
|
||
| Scenario | Recommended UI | Existing pattern reused | Status |
|
||
|---|---|---|---|
|
||
| Global maintenance | Full-page takeover, replaces the entire app shell (no header/footer, since branding may be unavailable — see §2) | `AuthErrorPageComponent` shape: `EmptyStateComponent` + `ButtonComponent`, code-keyed copy, `retry()` action | No frontend UI currently exists for this — requires a future frontend task |
|
||
| Per-tenant maintenance | Same full-page takeover as global, ideally tenant-branded if the backend decision in §2 allows branding to still resolve | Same as above | No frontend UI currently exists for this — requires a future frontend task |
|
||
| Per-module maintenance | Inline empty-state/banner scoped to the affected section only (e.g. checkout step shows `EmptyStateComponent` with `errorTitle`/`error`/`retry` copy; catalog pages untouched) | `EmptyStateComponent` + the `errorTitle`/`error`/`retry` i18n triple already used in `catalog`/`productDetails`/generic-list translations | No frontend UI currently exists for this — requires a future frontend task |
|
||
| Read-only mode | Disabled write control (e.g. "Add to cart" / "Submit review" button) + tooltip explaining why, OR a reactive error state on submit if no proactive flag exists (§4) | Disabled-button-plus-tooltip is a common pattern in the design system but not wired to any maintenance signal today | No frontend UI currently exists for this — requires a future frontend task |
|
||
| Scheduled maintenance | Dismissible banner at layout/header level with countdown copy | No banner/countdown component exists in `src/app/shared/ui/` today | No frontend UI currently exists for this — requires a future frontend task |
|
||
| Temporary feature disable | Feature's own UI simply doesn't render (existing `featureFlags` gating), optionally with a short "temporarily unavailable" note if `messageKey` (§5) is present | Existing `featureFlags` boolean gating (already live) | Existing mechanism works; richer messaging is the only gap |
|
||
|
||
---
|
||
|
||
### Summary: what's proposed/new vs. what already exists
|
||
|
||
**Already exists and can be reused as-is:**
|
||
- `BootstrapConfig.featureFlags` — static per-feature kill switch (§3, §6).
|
||
- `EmptyStateComponent` + `errorTitle`/`error`/`retry` i18n convention — the inline
|
||
error-state building block for any scenario.
|
||
- `AuthErrorPageComponent` — the full-page-takeover shape (code-keyed copy record,
|
||
`EmptyStateComponent` + `ButtonComponent`, `retry()` handler) to model a maintenance
|
||
page after.
|
||
- Cart's `LOCAL-ONLY` design already means most of "read-only browsing" works
|
||
incidentally, since browsing/cart-building never call the backend.
|
||
|
||
**Proposed/new (this document introduces):**
|
||
- The `scope`/`module`/`reason` structured 503 body (§7).
|
||
- `bootstrap.maintenanceNotice` (§5) for scheduled-maintenance advance notice.
|
||
- A dedicated `maintenance-page.component.ts` full-page takeover (§1/§2).
|
||
- Inline per-module/read-only error and disabled-control states wired to the new 503
|
||
shape (§3/§4).
|
||
|
||
---
|
||
|
||
### Requires backend decision (full list)
|
||
|
||
Each has a **Recommended default** — apply it unless flagged otherwise.
|
||
|
||
- §1: whether a dedicated `GET /status`/`GET /maintenance` probe should exist for
|
||
auto-recovery polling, beyond a plain 503 on `/bootstrap`.
|
||
**Recommended default:** no dedicated probe — reuse `GET /bootstrap`'s
|
||
existing 503/200 as the recovery signal; a second endpoint duplicates it.
|
||
- §2: the `scope` discriminator (`"global"` vs `"tenant"`) so the frontend can tell
|
||
the two apart from a single tenant's bootstrap response; and whether a
|
||
disabled tenant's branding can still resolve for a branded takeover page.
|
||
**Recommended default:** include `scope` in the 503 body (§7 shape);
|
||
serve branding for a disabled-but-known tenant so the takeover page can
|
||
still be branded (only truly unknown tenants get the unbranded default).
|
||
- §3: none beyond adopting the §7 response shape per-endpoint — this one is mostly
|
||
frontend-gap, not backend-undecided.
|
||
- §4: which status code is authoritative for read-only (`503` vs `403`) — to be
|
||
pinned down jointly with §6.
|
||
**Recommended default:** `503` with `code: "READ_ONLY"` — it's a temporary
|
||
service-state condition, not a permissions failure, so it belongs in the
|
||
5xx family alongside the rest of maintenance mode, not 4xx.
|
||
- §5: whether scheduled-maintenance notice ships via a `bootstrap.maintenanceNotice`
|
||
field (proposed) or a separate polling endpoint.
|
||
**Recommended default:** the `bootstrap.maintenanceNotice` field — it
|
||
rides the existing bootstrap fetch/cache lifecycle for free; a separate
|
||
polling endpoint adds a new recurring request for no added value.
|
||
- §7: how this document's 503 body nests inside whatever outer envelope
|
||
§6 defines.
|
||
**Recommended default:** already resolved — nest under the §6 envelope
|
||
(`{ error: { code, message, status, requestId, ... } }`) as shown in this
|
||
section's JSON examples; no separate top-level shape.
|
||
|
||
### No frontend UI currently exists for this — requires a future frontend task (full list)
|
||
|
||
- Global maintenance full-page takeover component.
|
||
- Per-tenant maintenance takeover (branded or not, pending §2's backend decision).
|
||
- Per-module inline maintenance banner/empty-state wiring on checkout/payment flows.
|
||
- Proactive read-only disabling of write controls (Add to cart / Submit review /
|
||
Submit question / Checkout) ahead of a failed request.
|
||
- Scheduled-maintenance banner + countdown component at the layout/header level.
|
||
- Richer "temporarily unavailable, back at X" messaging for `featureFlags`-gated
|
||
features (today they just silently don't render — no explanatory copy).
|
||
|
||
---
|
||
|
||
## 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.
|