Step 1-2 (audit + plan): classified 35 project markdown files into Core/Architecture/ADR/Temporary-audit/Sprint-report/Generated-review/ Duplicate/Obsolete/Historical. Agent-tooling files (.agents/skills/**, .superpowers/**, docs/context/**, CLAUDE.md/GEMINI.md/AGENTS.md/ .github/copilot-instructions.md) explicitly out of scope — intentional per-tool duplication, not documentation debt. Step 3 (merge, no information lost): - docs/PROJECT.md -> docs/PROJECT_INDEX.md, rewritten as the single entry point: system overview, living-doc index, archive pointer, current status, and a critical-finding callout up top. - docs/backend/BACKEND-INTEGRATION.md -> docs/BACKEND_API.md, docs/backend/REMAINING-BACKEND-WORK.md -> docs/BACKEND_API_REMAINING_WORK.md (also folded in a legitimate uncommitted status update that had been sitting unstaged all session: categories marked DONE, order-creation endpoint noted done). - RELEASE-NOTES.md merged into CHANGELOG.md (was a near-duplicate of the same release content in friendlier prose), then deleted. - KNOWN-ISSUES.md: added item 13 (see below) and item 14 (missing canDeactivate on admin/products edit, from the archived PROJECT-STATE audit, re-verified still true); added a correction note to Fixed item 7. - All cross-references to renamed/moved files fixed across every kept doc (grep+sed pass, then verified with a link-existence check across all 58 in-scope markdown files -> 0 broken links). Step 4 (archive, nothing deleted without merging first): created docs/archive/, moved 19 files there (3 root sprint reports, 1 platform report, SPRINT-PLAN.md, and 14 one-off audit/review/report docs). Added correction headers to the 3 archived docs whose conclusions were affected by the finding below, rather than silently leaving them misleading. Step 5: docs/PROJECT_INDEX.md rewritten per the mission brief - someone opening the repo should understand the whole system from it. IMPORTANT FINDING (surfaced during this audit, not the mission's primary goal but too significant to bury): pages/category/*, pages/search/*, pages/item-detail/*, pages/info/**, pages/legal/** (40+ files) are entirely unrouted dead code - app.routes.ts's cmsContentRoutes is a literal empty array, and category/search/product routes redirect to CatalogContainerComponent/ ProductDetailsContainerComponent, not these files. Confirmed against app.routes.ts directly and cross-checked against FRONTEND.md's own routing description. This means several fixes from earlier this cycle (RC-Premium-01, RC STORE-01) and the dead-code cleanup sprint's conclusion that these files were live were all wrong - documented as KNOWN-ISSUES.md item 13, flagged at the top of PROJECT_INDEX.md, and noted on the 3 archived docs whose conclusions it affects. No application code was changed to fix this (out of scope per this session's 'documentation only' constraint) - it needs a wire-it-up-or- delete-it decision first. Verification: tsc --noEmit clean, npm run build green, all markdown links across 58 in-scope files resolve (checked programmatically). No application/Angular/backend code modified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
13 KiB
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_API.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_API.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
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)<br/>(WebCrypto, non-extractable private key)
FE->>BE: POST /api/admin/auth/verify<br/>{ publicKey, signature, nonce }
alt signature valid & publicKey is a provisioned admin key
BE-->>FE: 200 { token, refreshToken }
FE->>FE: SessionService.activate(tokens)<br/>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
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
- Key generation (once per device):
Ed25519KeypairService.getOrCreateKeyPair()generates a non-extractable Ed25519 keypair viacrypto.subtle.generateKeyand persists theCryptoKeyhandles in IndexedDB (admin-auth-ed25519DB). The private key is never exported, serialized, or transmitted — by construction, not by convention. - Registering the public key with the backend is out of scope for this
frontend. An Owner/Administrator must associate a new device's
publicKeyBase64with 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. - Challenge:
GET /api/admin/auth/challengereturns a freshnoncethe client must sign beforeexpiresAt. - Sign: the raw
noncestring is signed with the device's private key (Ed25519KeypairService.sign), producing a base64 signature. - Verify:
POST /api/admin/auth/verifysends{ publicKey, signature, nonce }. The backend re-derives the signed message from the nonce it issued, verifies the signature against its own record of thatpublicKey → admin accountmapping, and only then issues tokens. - Session: the returned
{ token, refreshToken }pair is stored (SessionService,localStorage: ed25519AdminToken/ed25519AdminRefreshToken) and the JWT is decoded client-side forrole/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
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_API.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 formax(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:
authInterceptorcatches a 401 on any admin-gated request, attempts onerefresh(), retries the original request once on success, and routes tosession-expiredon 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 (callAuthFacade.restoreSession()from an app initializer once this flow goes live) — reads persisted tokens, decodes claims, and either resumes with a scheduled refresh or marksexpiredwithout 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)
- Verify the four endpoints in §4 against a real backend, including error shapes.
- Register
authInterceptorinapp.config.ts'swithInterceptors([...])list (currently not registered). - Decide the relationship to the existing Telegram flow: replace
adminAuthGuardwithed25519AuthGuardoutright, or run both and let role/tenant config pick — this is a product decision, not made here. - Wire
AuthFacade.restoreSession()into anAPP_INITIALIZER(or root componentngOnInit) so a page refresh restores state before any guard runs. - Only after 1–4: point
/backofficeand/edit'scanActivateated25519AuthGuard(andpermissionGuard(...)where a route needs a specific role).
11. Security considerations
- Private key never leaves the device. Generated non-extractable via
WebCrypto;
Ed25519KeypairServicehas 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_API.md§2.5 already states for the Telegram flow. A decoded JWT claim or a passingPermissionService.has()check is UX, not proof. - Refresh tokens should rotate. Every
POST /refreshresponse 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
/challengemust 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//verifypair rather than fabricating a session.