diff --git a/docs/ERROR_CONTRACT.md b/docs/ERROR_CONTRACT.md new file mode 100644 index 0000000..231eedd --- /dev/null +++ b/docs/ERROR_CONTRACT.md @@ -0,0 +1,490 @@ +# Error Response Contract + +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`) and +returns the first issue's `message`, for inline per-field template binding. +That mechanism is entirely client-side validation today (`ProjectValidator` +service), not backend-driven. **Requires backend decision**: adopting +`details[].field` as the join key would let a future `fieldError()`-style +adapter merge backend 422 errors into the same inline-error UI pattern +without inventing a second one — but the adapter itself does not exist yet +and would need to be built. + +### 429 — Rate limited + +```json +{ + "error": { + "code": "RATE_LIMITED", + "message": "Too many requests. Please slow down.", + "status": 429, + "requestId": "…", + "retryAfterSeconds": 30 + } +} +``` + +**Frontend reaction today: none whatsoever.** No interceptor, facade, or +component in the codebase references `429` or "rate limit" in any form (grepped +across `src/`). **Requires backend decision** on every aspect: +- Whether the backend sends a `Retry-After` HTTP header, a body field + (`retryAfterSeconds` above), or both. +- Whether the frontend should retry automatically (with backoff) or only + show the user a "please wait Ns" message. Recommend: since no retry + interceptor exists today, add one is a new build item, not a config + change. + +### 500 — Server error + +```json +{ + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred. Please try again.", + "status": 500, + "requestId": "…" + } +} +``` + +**Frontend reaction today:** falls into whichever generic catch-all a given +caller has: +- Ed25519 admin flow: `authErrorCodeFromStatus()` default branch → `status + >= 500 ? 'backend-unavailable' : 'unauthorized'` → same + "Backend unavailable… Retry" screen as a network-down 503 (see below) — + the frontend does not distinguish "server is up but this request 500'd" + from "server is completely unreachable." +- Admin list pages (`AdminUsersFacade` and siblings): generic `error` + boolean signal set to `true` in the RxJS `error:` callback, rendering + `common.errorTitle`/`common.errorDescription` + a retry button that + re-invokes the same load call. No status differentiation. +- Storefront catalog/product pages: same generic empty-state pattern as 404 + above. +- `LocationService.getRegions()`-equivalent: falls back silently to 6 + hardcoded regions on *any* error (including 500), no user-visible error at + all (`src/app/services/location.service.ts`). + +### 503 — Maintenance / unavailable + +```json +{ + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "The service is temporarily unavailable. Please try again shortly.", + "status": 503, + "requestId": "…" + } +} +``` + +**Frontend reaction today:** Ed25519 admin flow only, via the same +`status >= 500` branch as 500 above → `'backend-unavailable'` → +`AuthErrorPageComponent` "Backend unavailable… Retry." No other code path +reacts to 503 specifically today (marketplace API calls that 503 would just +fall into each caller's generic error handling, same as 500 above). + +**Distinguishing signal from Maintenance mode (see next section):** use +`error.code`, not the HTTP status. A plain infra 503 (database down, +overload) should send `"code": "SERVICE_UNAVAILABLE"`; a deliberate +maintenance window should send `"code": "MAINTENANCE_MODE"` (still with HTTP +status 503, since it's a byte-identical "the service is not accepting +requests" situation, but a different reason). This is the only way for the +frontend to build a distinct maintenance-mode UX later, since status alone +is not enough. `docs/MAINTENANCE_MODE.md` (sibling task, in progress) owns +the UX/copy for the maintenance case — this document only fixes the wire +signal it must key off (`error.code === "MAINTENANCE_MODE"`), so the two +docs stay consistent without duplicating UX detail here. + +### Maintenance mode + +Same HTTP status as above (503), distinguished purely by `error.code`: + +```json +{ + "error": { + "code": "MAINTENANCE_MODE", + "message": "This marketplace is temporarily down for maintenance.", + "status": 503, + "requestId": "…", + "maintenanceUntil": "2026-07-26T04:00:00Z" + } +} +``` + +`maintenanceUntil` (ISO 8601, optional) lets the maintenance-mode UX (sibling +doc) show an ETA if the backend has one. **Requires backend decision:** +whether `maintenanceUntil` is populated reliably enough to promise in UI, or +should be treated as advisory-only. + +**Frontend reaction today:** none — no maintenance-mode concept exists in +the frontend at all currently (confirmed: no matches for "maintenance" in +`src/`). This entire row is new; the sibling `docs/MAINTENANCE_MODE.md` task +should treat it as building from scratch, not preserving anything. + +### Tenant disabled + +```json +{ + "error": { + "code": "TENANT_DISABLED", + "message": "This marketplace is not currently active.", + "status": 403, + "requestId": "…" + } +} +``` + +**Frontend reaction today: none.** Tenant resolution +(`TenantResolverService`, `src/app/core/config/tenant-resolver.service.ts`) +only ever resolves *which* tenant a request targets (via host/subdomain); no +code path in the audited surface handles a backend telling the frontend +"this tenant exists but is disabled." **Requires backend decision** end to +end: status code (403 recommended, to reuse the existing `forbidden` +auth-error screen plumbing, vs. a dedicated status), and whether this should +route to a dedicated "tenant disabled" screen or reuse +`AuthErrorPageComponent`'s `forbidden` copy (which currently says "Your +account role does not have permission" — wrong wording for a +tenant-disabled scenario, would need a new `AuthErrorCode` entry and copy if +reused). + +### Rate limit + +See **429** above — same contract, called out separately here only because +the task list asked for it as its own row. No additional distinguishing +signal needed beyond the 429 status + `RATE_LIMITED` code. + +### Expired token + +```json +{ + "error": { + "code": "TOKEN_EXPIRED", + "message": "Your session has expired. Please sign in again.", + "status": 401, + "requestId": "…" + } +} +``` + +**Frontend reaction today — known gap, read carefully:** `AuthService +.refresh()` (`src/app/core/auth/services/auth.service.ts:65-76`) has a +client-side `AuthErrorCode` value `'session-expired'` and passes it as +`fallbackCode` into `handleAuthError()`. **However**, `toAuthErrorShape()` +(lines 110-118) only uses `fallbackCode` when the caught error is **not** an +`HttpErrorResponse` — for an actual HTTP error it always calls +`authErrorCodeFromStatus(error.status)`, which maps 401 → `'unauthorized'`, +never `'session-expired'`, regardless of `fallbackCode`. So today, a real +backend 401 on `/refresh` renders the **generic "Unauthorized" screen**, not +"Session expired" — the "Session expired" screen is only ever reached via +the *no-refresh-token-present* client-side branch (line 67-70), never from a +real HTTP response. **Requires backend decision + frontend fix**: for a +distinct "your session expired, please sign in again" screen to actually +render on a real backend 401, either (a) the backend returns a body +`error.code: "TOKEN_EXPIRED"` and the frontend is updated to read it instead +of relying solely on `authErrorCodeFromStatus(status)`, or (b) this +distinction is accepted as unreachable today and left as future work. Flag +this gap explicitly to whoever picks up the fix — it is a pre-existing bug, +not something this contract can silently paper over. + +### Invalid signature + +```json +{ + "error": { + "code": "INVALID_SIGNATURE", + "message": "The signed challenge could not be verified.", + "status": 401, + "requestId": "…" + } +} +``` + +Ties to the Ed25519 admin-auth flow documented in `AUTHENTICATION.md` +(sibling task, in progress) — keep the `code` value (`INVALID_SIGNATURE`) +consistent with whatever that doc names the failure mode, since this +contract only defines the wire shape and that doc owns the auth-flow +narrative. + +**Frontend reaction today:** same gap as "Expired token" above. +`AuthService.login()` passes `fallbackCode: 'invalid-signature'` into +`handleAuthError()`, but `toAuthErrorShape()` discards it for any real +`HttpErrorResponse` and maps a 401 from `/verify` to the generic +`'unauthorized'` screen via `authErrorCodeFromStatus()`. The dedicated +"Invalid signature… Try again" screen +(`src/app/core/auth/pages/auth-error-page.component.ts:21-25`) exists in the +copy table but is **currently unreachable from a real backend response** for +the same reason as `session-expired` above. **Requires backend decision + +frontend fix**: backend must send a body-level `error.code: +"INVALID_SIGNATURE"` and the frontend's `toAuthErrorShape()` must be updated +to prefer a body code over the status-only mapping, or this screen stays +dead code reachable only via non-HTTP error paths. + +--- + +## Generic list-page error UI (for reference) + +Every admin backoffice list page (`AdminUsersFacade`, `AdminOrdersFacade`, +`AdminMonitoringFacade`, `AdminModerationFacade`, `AdminTransactionsFacade`, +`AdminProductsFacade`, `AdminCategoriesFacade`, `AdminAnalyticsFacade`, +`AdminCustomersFacade`, `AdminDashboardFacade`) follows the same shape, +added by RC-02 (`e153a67 fix(backoffice): add error+retry states to Users, +Monitoring, Analytics, Reports`): + +```ts +readonly error = signal(false); +// on load: +error: () => { this.items.set([]); this.loading.set(false); this.error.set(true); } +``` + +```html +@else if (facade.error()) { + + + {{ 'common.retry' | translate }} + + +} +``` + +This is a **boolean** error flag — it does not branch on HTTP status or +`error.code` today. Every status in this contract (401/403/404/409/422/429/ +500/503) would currently collapse into the same generic "Something went +wrong / retry" UI on these pages **unless** the facades are individually +updated to inspect `error.code`/`status` and branch — none do today. Wiring +that up is out of scope for this document (it defines the wire contract); +flagging it here so whoever wires real backends into these facades knows +the current ceiling of frontend error UX is "generic retry," not +per-status handling, except in the Ed25519 admin-auth module. + +--- + +## Summary: "Requires backend decision" items + +- **Envelope adoption** — the `{ error: { code, message, status, requestId, + details? } }` shape itself; no frontend code parses any envelope today. +- **401 on customer-facing marketplace calls** (`/cart`, `/orders`, etc.) — + no unified "session expired, please re-auth" UX exists for the customer + Telegram-session flow. +- **403 tenant-mismatch vs role-mismatch** distinct copy/code. +- **404 vs generic-error distinct UX** on catalog/product pages (currently + identical). +- **409 conflict handling on submit** (today only a proactive + `isSlugTaken` pre-check exists; no reactive 409 handler). +- **422 `details[]` → inline field-error adapter** for admin forms (the + client-side `fieldError()` convention exists but nothing feeds it from a + backend response yet). +- **429 rate-limit contract end to end** — header vs body, retry-after + value, and whether the frontend auto-retries (nothing exists today). +- **Maintenance-mode `maintenanceUntil` reliability** — advisory only, or + can the frontend promise an ETA. +- **Tenant-disabled status code and screen** — reuse `forbidden` copy (wrong + wording today) vs. add a dedicated `AuthErrorCode`. +- **Expired-token / invalid-signature body-code fix** — both are + **pre-existing frontend bugs**, not just missing decisions: + `toAuthErrorShape()` in `auth.service.ts` currently derives the error code + from HTTP status only and ignores the `fallbackCode` for real HTTP errors, + so the `'session-expired'` and `'invalid-signature'` screens are dead code + from any real backend response today. Fixing this requires both a backend + body `error.code` and a frontend change to prefer it. diff --git a/docs/MAINTENANCE_MODE.md b/docs/MAINTENANCE_MODE.md new file mode 100644 index 0000000..a179bee --- /dev/null +++ b/docs/MAINTENANCE_MODE.md @@ -0,0 +1,398 @@ +# 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` 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` with known keys `wishlist, compare, reviews, +questions, comments, recommendations, blog, chat, analytics, notifications, coupons, +loyalty, giftCards, invoices` and an index signature for tenant-specific extras. This +is a **static, bootstrap-time** on/off switch per feature — not a live "is this +service currently degraded" signal, and it has no `payments` or `catalog` key today. +It's read once at bootstrap load and doesn't change until the next bootstrap refresh. + +There is no separate "module health" concept distinct from `featureFlags`. The admin +dashboard's `healthChecks()` / `homeHealthChecks()` (`AdminDashboardFacade`, +`src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts`) are **not** +module-availability checks — they validate the *local bootstrap document itself* +(schema version present, no missing translations, no invalid colors/widget refs/ +layouts, draft-exists, etc.), entirely client-side, with no backend health probe +behind any row except product/category counts (which reflect load success/failure of +`ProductFacade`/`CategoryFacade`, not an explicit "payments module is down" signal). +`AdminMonitoringPageComponent` reuses the same boolean-shaped `healthChecks()` — it is +not a live service-status board either. + +**What the backend should send:** each domain-specific endpoint (e.g. `POST /cart`, +`POST /orders`, `{qrApiUrl}/qr`) should independently return `503` with the structured +body (§7) with `scope: "module"` and a `module` field (e.g. `"payments"`) when that +subsystem specifically is down, while unrelated endpoints (`GET /category`, +`GET /items/{id}`) keep responding normally. This requires no new bootstrap field — +it's a per-request response behavior, consistent with REST conventions (the resource +itself is unavailable, not the whole API). + +**What the frontend currently does:** nothing differentiates a per-module outage from +any other request failure. `ApiService` (`src/app/services/api.service.ts`) has no +per-endpoint error branching for 503; a failed `createCartPayment()`/`createOrder()` +call surfaces through whatever generic error handling the checkout components already +have for network failures (out of scope for this doc — see the sibling +`ERROR_CONTRACT.md` task for the general error-response shape). + +**No frontend UI currently exists for this — requires a future frontend task.** The +checkout flow would need a "payments unavailable" inline state (banner or disabled +submit + tooltip, per §7) distinct from a generic error toast, and catalog browsing +would need to keep working untouched — which it structurally already would, since +`ProductFacade`/`CategoryFacade` and the payment calls are fully independent code +paths today (no shared failure state). That independence is a real asset: a payments +outage cannot accidentally break catalog browsing given the current facade +separation, but no UI exists yet to *tell the user* payments specifically are down +rather than "something went wrong." + +--- + +## 4. Read-only mode + +Writes disabled, reads still work. + +**Does the frontend already assume this is possible?** Partially, structurally, but +not deliberately. Cart state is `LOCAL-ONLY` (`CartService`, +`src/app/services/cart.service.ts`, signal-based, persisted to `localStorage` key +`marketplace_cart`) — adding items to cart, changing quantities, and browsing the cart +UI works entirely client-side with **no backend call at all** until checkout. The +only writes that hit a backend are at the checkout boundary: `POST /cart` +(`createCartPayment`), `POST /orders` (`createOrder`), `POST /purchase-email`, and the +QR/card payment polling. So today, if the backend rejected writes only, catalog +browsing, search, wishlist/compare (also `LOCAL-ONLY`, +`LocalUserExperienceRepository`), and cart-building would all continue working simply +because they never touch the backend — but reviews (`POST /items/{id}/callback`) and +questions (`POST /items/{id}/questiion`) are also writes and would fail the same as +checkout, since both are LIVE endpoints via `ProductDataProvider`. + +There is no code today that *checks for* a read-only flag and proactively disables +write UI (e.g. graying out "Add to cart" or the checkout button ahead of time). A +write attempt would only be discovered to be blocked when the write call itself +fails. + +**What the backend should send:** `503` (or `403`, see note below) with the +structured body (§7), `scope: "readonly"`, on write endpoints specifically — +`POST /cart`, `POST /orders`, `POST /purchase-email`, `POST /items/{id}/callback`, +`POST /items/{id}/questiion`, `POST /websession/{sessionId}` (cart sync) — while GET +endpoints keep working. `403 Forbidden` is arguably more correct REST semantics for +"this resource forbids this method during a maintenance window" than `503`, but `503` ++ `Retry-After` communicates "temporary" more clearly to a client and is +recommended so the frontend can offer a countdown/retry consistent with §5's pattern. +**Requires backend decision:** which status code is authoritative — this should be +pinned down jointly with whatever `ERROR_CONTRACT.md` settles on for its 5xx +conventions, since read-only is really "a subset of write endpoints return +maintenance-503." + +**No frontend UI currently exists for this — requires a future frontend task.** No +bootstrap flag exists to proactively disable checkout/review/question submission +ahead of a failed request (e.g. `featureFlags.readOnly` or a dedicated +`platformStatus.readOnly` field would need to be added to `BootstrapConfig` if the +product wants a proactive banner instead of a reactive failure). Reactive handling +(showing an error when the write call 503s) can reuse the same inline +error-state pattern as §3/§6 once `ERROR_CONTRACT.md` defines the generic error body +handling. + +--- + +## 5. Scheduled maintenance + +Advance notice pattern (banner / countdown) ahead of a maintenance window. + +**What exists in the frontend today:** nothing. No banner component, no countdown +component, no bootstrap field for an upcoming maintenance window. + +**Requires backend decision — proposed minimal contract:** add an optional field to +`BootstrapConfig` (loaded once per session/on refresh via `GET /bootstrap`), e.g.: + +```ts +interface ScheduledMaintenanceNotice { + startsAt: string; // ISO 8601 + endsAt?: string; // ISO 8601, optional if duration is unknown + scope: 'global' | 'tenant' | 'module'; + module?: string; // present when scope === 'module' + messageKey?: string; // optional i18n key/translated string for custom copy +} +``` + +surfaced as `bootstrap.maintenanceNotice?: ScheduledMaintenanceNotice | null`. This +keeps the mechanism consistent with how the platform already declares other +runtime-configured, backend-authored state (feature flags, tenant config, API +endpoint records all live in the bootstrap document per +`docs/context/BACKEND-AUDIT.md` §6) rather than inventing a new polling endpoint. A +polling `GET /maintenance-notice` endpoint is an alternative if the notice needs to +appear/change without a full bootstrap refresh — that tradeoff is the backend +decision. + +**No frontend UI currently exists for this — requires a future frontend task.** A +dismissible banner component reading `bootstrap.maintenanceNotice` and showing a +localized "maintenance starts in Xh Ym" countdown would need to be built and mounted +at a layout level (header or a global banner slot) — no such banner or countdown +component exists in `src/app/shared/ui/` today. + +--- + +## 6. Temporary feature disable + +Single feature toggled off without full maintenance — e.g. reviews temporarily +disabled while the rest of the product page works. + +**This is the one scenario the frontend already has a real mechanism for**, via +`BootstrapConfig.featureFlags` (§3). Setting `featureFlags.reviews = false` in the +bootstrap document is exactly the existing, live mechanism for "reviews are off right +now" — it's read by whatever consumes `FeatureConfigService` +(`src/app/core/config/*`) and gates the relevant UI. This is a **deploy/config-time** +toggle (changes on next bootstrap load), not a live incident-response toggle, but +structurally it is the same shape a backend team would use to kill a misbehaving +feature quickly: update the bootstrap document (or whatever backend-side config +drives it), and the next bootstrap fetch picks it up. + +**Recommendation:** reuse `featureFlags` for this scenario rather than introducing a +parallel mechanism — it already exists, is already wired through to the UI in the +relevant places, and matches the "temporary, single-feature, not a full outage" +framing exactly. No backend decision needed for the *mechanism*; only for *process* +(how fast a flag flip propagates — depends on bootstrap cache/refresh cadence, which +is outside this doc's scope). + +**Gap:** `featureFlags` has no `payments` or `catalog` key and is a boolean only — it +can't express "reviews disabled with reason X, back at time Y" the way §5's proposed +`maintenanceNotice` can. If product wants a "reviews are temporarily unavailable — +back tomorrow" message rather than the feature silently disappearing, that needs the +richer shape from §5, scoped to `module`, not a plain `featureFlags` boolean. + +--- + +## 7. Recommended API responses + +All maintenance-scenario responses use HTTP `503 Service Unavailable` (except the +read-only debate in §4) with a `Retry-After` header (seconds, standard HTTP) and a +JSON body. This is written to be consistent with, not contradict, whatever +`ERROR_CONTRACT.md` (sibling task, in progress) settles on for its general +structured-error envelope — if that doc defines a different top-level error +shape (e.g. `{ error: { code, message, ... } }` vs. a flatter shape), this body +should be nested under that envelope rather than duplicating a competing shape. +Pending that reconciliation, the fields below are what the frontend needs regardless +of the outer envelope: + +```json +{ + "status": 503, + "code": "maintenance", + "scope": "global", + "module": null, + "reason": "scheduled", + "message": "The marketplace is temporarily unavailable for scheduled maintenance.", + "retryAfter": 1800, + "startedAt": "2026-07-26T02:00:00Z", + "expectedEndAt": "2026-07-26T03:00:00Z" +} +``` + +Field notes: +- `scope`: `"global" | "tenant" | "module" | "readonly"` — lets the frontend pick the + right UI (full takeover vs. inline banner vs. disabled control) without guessing + from status code alone. +- `module`: present only when `scope === "module"` (e.g. `"payments"`, `"reviews"`). +- `reason`: `"scheduled" | "incident" | "disabled"` — free-form enough for the + frontend to choose copy tone (planned vs. unplanned) without needing new fields + per scenario. +- `retryAfter`: mirrors the `Retry-After` header in the body too, so a client that + only reads JSON (not headers) still gets it — useful since some HttpClient error + paths surface the body more readily than headers depending on interceptor + structure. +- `startedAt` / `expectedEndAt`: optional, ISO 8601, for countdown/banner copy (§5). + +Per-scenario summary: + +| Scenario | Status | `scope` | Notes | +|---|---|---|---| +| Global | 503 | `"global"` | On every endpoint, especially `/bootstrap` | +| Per-tenant | 503 | `"tenant"` | On that tenant's `/bootstrap` and all its endpoints | +| Per-module | 503 | `"module"` | Only on that module's endpoints (e.g. `/cart`, `/orders`) | +| Read-only | 503 or 403 | `"readonly"` | Only on write endpoints; GETs unaffected — pin down with `ERROR_CONTRACT.md` | +| Scheduled (advance notice) | 200, via `bootstrap.maintenanceNotice` | n/a | Not an error response — a proactive field on the normal `/bootstrap` payload, see §5 | +| Temporary feature disable | 200, via `bootstrap.featureFlags. = false` | n/a | Not an error response — existing bootstrap mechanism, see §6 | + +--- + +## 8. Frontend behavior + +Grounded in the UI patterns that already exist (`EmptyStateComponent` +(`src/app/shared/ui/empty-state/empty-state.component.ts`), the `errorTitle` / +`error` / `retry` i18n-key convention used across catalog, product details, and +generic list widgets (`src/app/i18n/en.ts`), and `AuthErrorPageComponent`'s +code-keyed full-page pattern). No new UI concepts are invented below beyond composing +these. + +| Scenario | Recommended UI | Existing pattern reused | Status | +|---|---|---|---| +| Global maintenance | Full-page takeover, replaces the entire app shell (no header/footer, since branding may be unavailable — see §2) | `AuthErrorPageComponent` shape: `EmptyStateComponent` + `ButtonComponent`, code-keyed copy, `retry()` action | No frontend UI currently exists for this — requires a future frontend task | +| Per-tenant maintenance | Same full-page takeover as global, ideally tenant-branded if the backend decision in §2 allows branding to still resolve | Same as above | No frontend UI currently exists for this — requires a future frontend task | +| Per-module maintenance | Inline empty-state/banner scoped to the affected section only (e.g. checkout step shows `EmptyStateComponent` with `errorTitle`/`error`/`retry` copy; catalog pages untouched) | `EmptyStateComponent` + the `errorTitle`/`error`/`retry` i18n triple already used in `catalog`/`productDetails`/generic-list translations | No frontend UI currently exists for this — requires a future frontend task | +| Read-only mode | Disabled write control (e.g. "Add to cart" / "Submit review" button) + tooltip explaining why, OR a reactive error state on submit if no proactive flag exists (§4) | Disabled-button-plus-tooltip is a common pattern in the design system but not wired to any maintenance signal today | No frontend UI currently exists for this — requires a future frontend task | +| Scheduled maintenance | Dismissible banner at layout/header level with countdown copy | No banner/countdown component exists in `src/app/shared/ui/` today | No frontend UI currently exists for this — requires a future frontend task | +| Temporary feature disable | Feature's own UI simply doesn't render (existing `featureFlags` gating), optionally with a short "temporarily unavailable" note if `messageKey` (§5) is present | Existing `featureFlags` boolean gating (already live) | Existing mechanism works; richer messaging is the only gap | + +--- + +## Summary: what's proposed/new vs. what already exists + +**Already exists and can be reused as-is:** +- `BootstrapConfig.featureFlags` — static per-feature kill switch (§3, §6). +- `EmptyStateComponent` + `errorTitle`/`error`/`retry` i18n convention — the inline + error-state building block for any scenario. +- `AuthErrorPageComponent` — the full-page-takeover shape (code-keyed copy record, + `EmptyStateComponent` + `ButtonComponent`, `retry()` handler) to model a maintenance + page after. +- Cart's `LOCAL-ONLY` design already means most of "read-only browsing" works + incidentally, since browsing/cart-building never call the backend. + +**Proposed/new (this document introduces):** +- The `scope`/`module`/`reason` structured 503 body (§7). +- `bootstrap.maintenanceNotice` (§5) for scheduled-maintenance advance notice. +- A dedicated `maintenance-page.component.ts` full-page takeover (§1/§2). +- Inline per-module/read-only error and disabled-control states wired to the new 503 + shape (§3/§4). + +--- + +## Requires backend decision (full list) + +- §1: whether a dedicated `GET /status`/`GET /maintenance` probe should exist for + auto-recovery polling, beyond a plain 503 on `/bootstrap`. +- §2: the `scope` discriminator (`"global"` vs `"tenant"`) so the frontend can tell + the two apart from a single tenant's bootstrap response; and whether a + disabled tenant's branding can still resolve for a branded takeover page. +- §3: none beyond adopting the §7 response shape per-endpoint — this one is mostly + frontend-gap, not backend-undecided. +- §4: which status code is authoritative for read-only (`503` vs `403`) — to be + pinned down jointly with `ERROR_CONTRACT.md`. +- §5: whether scheduled-maintenance notice ships via a `bootstrap.maintenanceNotice` + field (proposed) or a separate polling endpoint. +- §7: how this document's 503 body nests inside whatever outer envelope + `ERROR_CONTRACT.md` defines. + +## No frontend UI currently exists for this — requires a future frontend task (full list) + +- Global maintenance full-page takeover component. +- Per-tenant maintenance takeover (branded or not, pending §2's backend decision). +- Per-module inline maintenance banner/empty-state wiring on checkout/payment flows. +- Proactive read-only disabling of write controls (Add to cart / Submit review / + Submit question / Checkout) ahead of a failed request. +- Scheduled-maintenance banner + countdown component at the layout/header level. +- Richer "temporarily unavailable, back at X" messaging for `featureFlags`-gated + features (today they just silently don't render — no explanatory copy).