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