diff --git a/docs/AUTH.md b/docs/AUTH.md
new file mode 100644
index 0000000..f4e7886
--- /dev/null
+++ b/docs/AUTH.md
@@ -0,0 +1,272 @@
+# Admin Authentication — Ed25519 Foundation
+
+Status: **FRONTEND PREPARED, NOT LIVE.** Everything in this document describes
+code that exists in `src/app/core/auth/` today, wired to endpoints that do
+not exist on the backend yet. No route currently requires this flow — the
+live admin gate remains the Telegram-QR-based `AdminAuthService` /
+`adminAuthGuard` (`src/app/core/admin-auth/`, documented in
+`docs/backend/BACKEND-INTEGRATION.md` §2.4–2.5). This module is the
+integration target once the backend ships the endpoints below.
+
+Do not point any live route's `canActivate` at `ed25519AuthGuard` until the
+backend endpoints in §API Contracts exist and have been verified — doing so
+before then would lock every admin out.
+
+## 1. Why this exists
+
+`docs/backend/BACKEND-INTEGRATION.md` §2.5 documents the current system's
+biggest security gap: admin and customer login hit the *same* Telegram
+session endpoint, so the backend has no way to distinguish an admin login
+attempt from a customer one at the moment of login — authorization is
+effectively unenforced. Ed25519 challenge/response auth closes this by
+requiring proof of possession of a specific, pre-registered private key
+before a session is ever issued, instead of "any Telegram account that
+happened to scan the right QR code."
+
+## 2. Sequence diagram
+
+```mermaid
+sequenceDiagram
+ participant Admin as Admin (browser)
+ participant FE as Frontend (AuthService)
+ participant BE as Backend
+
+ Admin->>FE: Click "Sign in"
+ FE->>BE: GET /api/admin/auth/challenge
+ BE-->>FE: { nonce, issuedAt, expiresAt }
+ FE->>FE: Ed25519KeypairService.sign(nonce)
(WebCrypto, non-extractable private key)
+ FE->>BE: POST /api/admin/auth/verify
{ publicKey, signature, nonce }
+ alt signature valid & publicKey is a provisioned admin key
+ BE-->>FE: 200 { token, refreshToken }
+ FE->>FE: SessionService.activate(tokens)
decode JWT claims, schedule refresh
+ FE-->>Admin: Redirect to /backoffice
+ else invalid signature / unknown key / expired nonce
+ BE-->>FE: 401/403
+ FE-->>Admin: Redirect to /admin-login/error/invalid-signature
+ end
+```
+
+### Refresh sequence
+
+```mermaid
+sequenceDiagram
+ participant FE as Frontend (SessionService)
+ participant IC as authInterceptor
+ participant BE as Backend
+
+ Note over FE: Timer fires ~60s before JWT exp
+ FE->>BE: POST /api/admin/auth/refresh { refreshToken }
+ alt refresh token still valid
+ BE-->>FE: 200 { token, refreshToken }
+ FE->>FE: activate(tokens) - reschedules next refresh
+ else refresh token expired/revoked
+ BE-->>FE: 401
+ FE->>FE: SessionService.markExpired()
+ FE-->>FE: Route to /admin-login/error/session-expired
+ end
+
+ Note over IC: Reactive path - any 401 on an admin request
+ IC->>BE: Admin API request (expired token)
+ BE-->>IC: 401
+ IC->>BE: POST /api/admin/auth/refresh (single retry)
+ alt refresh succeeds
+ BE-->>IC: 200 tokens
+ IC->>BE: Retry original request with new token
+ else refresh fails
+ IC-->>FE: Propagate error, route to session-expired
+ end
+```
+
+## 3. Ed25519 flow, step by step
+
+1. **Key generation (once per device):** `Ed25519KeypairService.getOrCreateKeyPair()`
+ generates a non-extractable Ed25519 keypair via `crypto.subtle.generateKey`
+ and persists the `CryptoKey` handles in IndexedDB (`admin-auth-ed25519` DB).
+ The private key is never exported, serialized, or transmitted — by
+ construction, not by convention.
+2. **Registering the public key with the backend is out of scope for this
+ frontend.** An Owner/Administrator must associate a new device's
+ `publicKeyBase64` with an admin account through some out-of-band
+ mechanism (e.g. a backend admin tool, a one-time enrollment link) before
+ that device can complete step 4. This document does not prescribe that
+ mechanism — it is a backend/ops concern.
+3. **Challenge:** `GET /api/admin/auth/challenge` returns a fresh `nonce` the
+ client must sign before `expiresAt`.
+4. **Sign:** the raw `nonce` string is signed with the device's private key
+ (`Ed25519KeypairService.sign`), producing a base64 signature.
+5. **Verify:** `POST /api/admin/auth/verify` sends `{ publicKey, signature,
+ nonce }`. The backend re-derives the signed message from the nonce it
+ issued, verifies the signature against its own record of that
+ `publicKey → admin account` mapping, and only then issues tokens.
+6. **Session:** the returned `{ token, refreshToken }` pair is stored
+ (`SessionService`, `localStorage: ed25519AdminToken` /
+ `ed25519AdminRefreshToken`) and the JWT is decoded client-side for
+ `role`/`exp` — decoding only, never signature verification (the frontend
+ has no trusted key to check it against).
+
+## 4. API contracts
+
+All under `{environment.authApiUrl}/api/admin/auth` (see
+`src/environments/environment.ts`). None of these exist on the backend
+today — this is the contract the frontend was built against, not a
+confirmed backend spec.
+
+| Method | Path | Request body | Response | Notes |
+|---|---|---|---|---|
+| GET | `/challenge` | — | `200 AuthChallenge` | `{ nonce, issuedAt, expiresAt }`, all ISO 8601 except `nonce` |
+| POST | `/verify` | `VerifySignatureRequest` | `200 AuthTokenPair` \| `401` \| `403` | `{ publicKey, signature, nonce }` → `{ token, refreshToken }` |
+| POST | `/refresh` | `RefreshTokenRequest` | `200 AuthTokenPair` \| `401` | `{ refreshToken }` → new pair (rotation expected — old refresh token should be invalidated server-side) |
+| POST | `/logout` | `{ refreshToken }` | `204` | Should revoke the refresh token server-side; frontend clears local state regardless of response |
+
+Types: `src/app/core/auth/models/auth-api.model.ts`.
+
+## 5. JWT claims
+
+```ts
+interface JwtClaims {
+ sub: string; // admin account id
+ role: AdminRole; // 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly'
+ iat: number; // seconds since epoch
+ exp: number; // seconds since epoch
+ publicKey: string; // the Ed25519 public key this token was issued for
+}
+```
+
+The frontend decodes these (`JwtService.decode`) for UX only — role-based UI
+gating, expiry countdowns, refresh scheduling. **Every admin API request
+must be independently authorized server-side**; a decoded-but-unverified
+claim is not proof of anything to the backend.
+
+## 6. Permission model
+
+Five roles, coarse-grained permission keys (`src/app/core/auth/models/permission.model.ts`):
+
+| Role | Permissions |
+|---|---|
+| Owner | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage`, `settings.manage` |
+| Administrator | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage` |
+| Editor | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write` |
+| Support | `backoffice.read` |
+| ReadOnly | `backoffice.read`, `builder.read` |
+
+This is deliberately coarse and mirrors the existing bootstrap-level
+`PermissionsConfig` shape (`src/app/shared/models/config/permissions.model.ts`).
+Finer-grained, per-domain permissions (e.g. "can edit prices but not delete
+products") stay server-side until a real permission model exists there —
+see `docs/backend/BACKEND-INTEGRATION.md` §"Admin-role-required". Use
+`PermissionService.has(permission)` / `permissionGuard(permission)` to gate
+UI and routes; never treat a passing client-side check as authorization by
+itself.
+
+## 7. Error screens
+
+Single component (`AuthErrorPageComponent`, `/admin-login/error/:code`)
+renders all five, keyed by route param:
+
+| Code | Trigger | User action offered |
+|---|---|---|
+| `session-expired` | Refresh token rejected/expired | Sign in again |
+| `invalid-signature` | `verify` returns 401/403 during login | Try again |
+| `unauthorized` | Route guard sees no active session | Sign in |
+| `forbidden` | `permissionGuard` denies (authenticated but insufficient role) | Back to dashboard |
+| `backend-unavailable` | Network error / 5xx / status 0 | Retry |
+
+`authErrorCodeFromStatus` (`models/auth-error.model.ts`) maps HTTP status →
+code: `401→unauthorized`, `403→forbidden`, `0→backend-unavailable`,
+`5xx→backend-unavailable`, else `unauthorized`. `AuthService.login()`
+additionally maps any failure during the challenge/sign/verify sequence to
+`invalid-signature` when it isn't a clearer HTTP-status-derived code.
+
+## 8. Refresh lifecycle
+
+- On `SessionService.activate(tokens)`, a timer is scheduled for
+ `max(exp - now - 60s, 5s)` — refresh fires ~60 seconds before expiry so a
+ concurrent request never races an expiring token.
+- **Proactive path:** the timer fires `AuthService.refresh()` directly.
+- **Reactive path:** `authInterceptor` catches a 401 on any admin-gated
+ request, attempts one `refresh()`, retries the original request once on
+ success, and routes to `session-expired` on failure. It does not retry
+ more than once — a second 401 after a successful-looking refresh means
+ something is wrong server-side, not a transient race.
+- `SessionService.restore()` runs on app bootstrap (call
+ `AuthFacade.restoreSession()` from an app initializer once this flow goes
+ live) — reads persisted tokens, decodes claims, and either resumes with a
+ scheduled refresh or marks `expired` without any network call, so a stale
+ session is caught before it reaches any component.
+
+## 9. Module map
+
+```
+src/app/core/auth/
+├── auth.routes.ts # /admin-login, /admin-login/error/:code
+├── models/
+│ ├── auth-api.model.ts # AuthChallenge, VerifySignatureRequest, AuthTokenPair, JwtClaims
+│ ├── auth-error.model.ts # AuthErrorCode, authErrorCodeFromStatus()
+│ └── permission.model.ts # AdminRole, Permission, ROLE_PERMISSIONS
+├── services/
+│ ├── ed25519-keypair.service.ts # WebCrypto keygen/sign, IndexedDB persistence
+│ ├── auth-api.service.ts # HttpClient calls to the 4 endpoints in §4
+│ ├── jwt.service.ts # decode-only JWT parsing
+│ ├── session.service.ts # token/claims state, persistence, refresh scheduling
+│ ├── permission.service.ts # role -> permission set
+│ ├── auth.service.ts # orchestrates challenge -> sign -> verify -> refresh -> logout
+│ └── auth-facade.service.ts # public surface for components
+├── interceptors/
+│ └── auth.interceptor.ts # Authorization: Bearer + 401 refresh-and-retry
+├── guards/
+│ ├── ed25519-auth.guard.ts # requires SessionService.isAuthenticated()
+│ └── permission.guard.ts # permissionGuard(permission) factory
+└── pages/
+ ├── admin-login-page.component.* # sign-in UI
+ └── auth-error-page.component.* # parameterized error screen (§7)
+```
+
+`AuthFacade` is the only thing components/pages should depend on;
+`AuthService`/`SessionService`/`PermissionService` are internal
+collaborators reachable through it.
+
+## 10. Cutover plan (when the backend ships)
+
+1. Verify the four endpoints in §4 against a real backend, including error
+ shapes.
+2. Register `authInterceptor` in `app.config.ts`'s `withInterceptors([...])`
+ list (currently not registered).
+3. Decide the relationship to the existing Telegram flow: replace
+ `adminAuthGuard` with `ed25519AuthGuard` outright, or run both and let
+ role/tenant config pick — this is a product decision, not made here.
+4. Wire `AuthFacade.restoreSession()` into an `APP_INITIALIZER` (or root
+ component `ngOnInit`) so a page refresh restores state before any guard
+ runs.
+5. Only after 1–4: point `/backoffice` and `/edit`'s `canActivate` at
+ `ed25519AuthGuard` (and `permissionGuard(...)` where a route needs a
+ specific role).
+
+## 11. Security considerations
+
+- **Private key never leaves the device.** Generated non-extractable via
+ WebCrypto; `Ed25519KeypairService` has no export path. Losing the device
+ means losing the key — key rotation/recovery (revoking a lost device's
+ public key, provisioning a new one) is a backend/ops process, not
+ implemented here.
+- **The frontend is not the authorization boundary.** Every admin
+ request must be independently checked server-side against the caller's
+ actual role, exactly as `docs/backend/BACKEND-INTEGRATION.md` §2.5
+ already states for the Telegram flow. A decoded JWT claim or a passing
+ `PermissionService.has()` check is UX, not proof.
+- **Refresh tokens should rotate.** Every `POST /refresh` response is
+ expected to include a *new* refresh token; the backend should invalidate
+ the one just used. The frontend always stores whatever pair it receives
+ and never reuses an old refresh token after a successful rotation.
+- **CSRF/replay:** the nonce from `/challenge` must be single-use and
+ time-boxed server-side (`expiresAt`) — the frontend enforces nothing here
+ beyond passing the nonce back unmodified; replay protection is the
+ backend's responsibility.
+- **No fallback to unsigned auth.** There is no code path in this module
+ that issues a session without a valid signature. If the backend is
+ unreachable, the user sees `backend-unavailable`, never a degraded or
+ bypassed login.
+- **Dev bypass exclusion:** unlike `AdminAuthService.devBypassLogin()` in
+ the Telegram flow, this module intentionally has no dev bypass — an
+ Ed25519 keypair is cheap to generate locally, so local testing should
+ point at a real (even if mocked-in-dev) `/challenge`/`/verify` pair
+ rather than fabricating a session.
diff --git a/src/app/core/auth/pages/auth-error-page.component.ts b/src/app/core/auth/pages/auth-error-page.component.ts
index 20a6ea4..4bdb679 100644
--- a/src/app/core/auth/pages/auth-error-page.component.ts
+++ b/src/app/core/auth/pages/auth-error-page.component.ts
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
-import { ActivatedRoute, Router, RouterLink } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
import { map } from 'rxjs';
import { ButtonComponent } from '../../../shared/ui/button/button.component';
import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
@@ -48,7 +48,7 @@ const COPY: Record = {
@Component({
selector: 'app-auth-error-page',
standalone: true,
- imports: [EmptyStateComponent, ButtonComponent, RouterLink],
+ imports: [EmptyStateComponent, ButtonComponent],
templateUrl: './auth-error-page.component.html',
styleUrl: './auth-error-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush