release: @marketplaces/auth 0.1.0 (built from main)

This commit is contained in:
sdarbinyan
2026-08-18 01:55:26 +04:00
commit 93f99cc7b1
44 changed files with 1601 additions and 0 deletions

17
dist/ed25519/auth-api.service.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
import { Observable } from 'rxjs';
import { AuthChallenge, AuthTokenPair, RefreshTokenRequest, VerifySignatureRequest } from './models/auth-api.model';
/**
* Thin HTTP client for the Ed25519 admin auth endpoints. These endpoints may
* not exist on every backend yet - calling them before the backend ships
* 404s or connection-errors, which AuthService maps to the
* `backend-unavailable` error screen. No mock/fake responses are fabricated
* here; this is real HttpClient wiring against the real contract.
*/
export declare class AuthApiService {
private readonly http;
private readonly baseUrl;
requestChallenge(): Observable<AuthChallenge>;
verifySignature(request: VerifySignatureRequest): Observable<AuthTokenPair>;
refresh(request: RefreshTokenRequest): Observable<AuthTokenPair>;
logout(refreshToken: string): Observable<void>;
}

38
dist/ed25519/auth-api.service.js vendored Normal file
View File

@@ -0,0 +1,38 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { AUTH_API_URL } from '../config';
/**
* Thin HTTP client for the Ed25519 admin auth endpoints. These endpoints may
* not exist on every backend yet - calling them before the backend ships
* 404s or connection-errors, which AuthService maps to the
* `backend-unavailable` error screen. No mock/fake responses are fabricated
* here; this is real HttpClient wiring against the real contract.
*/
let AuthApiService = class AuthApiService {
constructor() {
this.http = inject(HttpClient);
this.baseUrl = `${inject(AUTH_API_URL)}/api/admin/auth`;
}
requestChallenge() {
return this.http.get(`${this.baseUrl}/challenge`);
}
verifySignature(request) {
return this.http.post(`${this.baseUrl}/verify`, request);
}
refresh(request) {
return this.http.post(`${this.baseUrl}/refresh`, request);
}
logout(refreshToken) {
return this.http.post(`${this.baseUrl}/logout`, { refreshToken });
}
};
AuthApiService = __decorate([
Injectable({ providedIn: 'root' })
], AuthApiService);
export { AuthApiService };

22
dist/ed25519/auth-facade.service.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
import { Permission } from './models/permission.model';
/**
* Public surface for components/pages. Components should depend on this,
* not on AuthService/SessionService/PermissionService directly, so the
* orchestration details (which service owns what) can change without
* touching UI code.
*/
export declare class AuthFacade {
private readonly auth;
private readonly session;
private readonly permissions;
private readonly router;
readonly isAuthenticated: import("@angular/core").Signal<boolean>;
readonly status: import("@angular/core").Signal<import("./session.service").SessionStatus>;
readonly role: import("@angular/core").Signal<import("..").AdminRole | null>;
readonly loginPhase: import("@angular/core").Signal<import("./auth.service").LoginPhase>;
readonly lastError: import("@angular/core").Signal<import("..").AuthError | null>;
restoreSession(): void;
login(onSuccessRedirectTo?: string): void;
logout(redirectTo?: string): void;
can(permission: Permission): boolean;
}

60
dist/ed25519/auth-facade.service.js vendored Normal file
View File

@@ -0,0 +1,60 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { finalize } from 'rxjs';
import { AuthService } from './auth.service';
import { PermissionService } from './permission.service';
import { SessionService } from './session.service';
/**
* Public surface for components/pages. Components should depend on this,
* not on AuthService/SessionService/PermissionService directly, so the
* orchestration details (which service owns what) can change without
* touching UI code.
*/
let AuthFacade = class AuthFacade {
constructor() {
this.auth = inject(AuthService);
this.session = inject(SessionService);
this.permissions = inject(PermissionService);
this.router = inject(Router);
this.isAuthenticated = this.session.isAuthenticated;
this.status = this.session.status;
this.role = this.session.role;
this.loginPhase = this.auth.loginPhase;
this.lastError = this.auth.lastError;
}
restoreSession() {
this.auth.restoreSession();
}
login(onSuccessRedirectTo) {
this.auth.login().subscribe({
next: () => {
if (onSuccessRedirectTo) {
this.router.navigateByUrl(onSuccessRedirectTo);
}
},
error: () => {
const code = this.auth.lastError()?.code ?? 'unauthorized';
this.router.navigate(['/admin-login/error', code]);
}
});
}
logout(redirectTo = '/admin-login') {
this.auth
.logout()
.pipe(finalize(() => this.router.navigateByUrl(redirectTo)))
.subscribe({ error: () => undefined });
}
can(permission) {
return this.permissions.has(permission);
}
};
AuthFacade = __decorate([
Injectable({ providedIn: 'root' })
], AuthFacade);
export { AuthFacade };

35
dist/ed25519/auth.service.d.ts vendored Normal file
View File

@@ -0,0 +1,35 @@
import { Observable } from 'rxjs';
import { AuthTokenPair } from './models/auth-api.model';
import { AuthError } from './models/auth-error.model';
export type LoginPhase = 'idle' | 'requesting-challenge' | 'signing' | 'verifying' | 'done';
/**
* Orchestrates the Ed25519 challenge/response admin auth flow end to end:
*
* GET /api/admin/auth/challenge -> { nonce }
* sign(nonce) with local Ed25519 key -> signature
* POST /api/admin/auth/verify -> { token, refreshToken }
*
* This is the lowest-level orchestrator; components should go through
* AuthFacade rather than calling this directly. Exported from the package
* barrel as `Ed25519AuthService` to avoid colliding with the telegram
* module's `AuthService`.
*/
export declare class AuthService {
private readonly api;
private readonly keypair;
private readonly session;
private readonly loginPhaseSignal;
private readonly lastErrorSignal;
readonly loginPhase: import("@angular/core").Signal<LoginPhase>;
readonly lastError: import("@angular/core").Signal<AuthError | null>;
constructor();
/** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */
restoreSession(): void;
login(): Observable<AuthTokenPair>;
refresh(): Observable<AuthTokenPair>;
logout(): Observable<void>;
private signChallenge;
private handleAuthError;
private toAuthErrorShape;
private toAuthError;
}

107
dist/ed25519/auth.service.js vendored Normal file
View File

@@ -0,0 +1,107 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Injectable, inject, signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { catchError, switchMap, tap, throwError } from 'rxjs';
import { Observable } from 'rxjs';
import { authErrorCodeFromBackendCode, authErrorCodeFromStatus } from './models/auth-error.model';
import { AuthApiService } from './auth-api.service';
import { Ed25519KeypairService } from './ed25519-keypair.service';
import { SessionService } from './session.service';
/**
* Orchestrates the Ed25519 challenge/response admin auth flow end to end:
*
* GET /api/admin/auth/challenge -> { nonce }
* sign(nonce) with local Ed25519 key -> signature
* POST /api/admin/auth/verify -> { token, refreshToken }
*
* This is the lowest-level orchestrator; components should go through
* AuthFacade rather than calling this directly. Exported from the package
* barrel as `Ed25519AuthService` to avoid colliding with the telegram
* module's `AuthService`.
*/
let AuthService = class AuthService {
constructor() {
this.api = inject(AuthApiService);
this.keypair = inject(Ed25519KeypairService);
this.session = inject(SessionService);
this.loginPhaseSignal = signal('idle');
this.lastErrorSignal = signal(null);
this.loginPhase = this.loginPhaseSignal.asReadonly();
this.lastError = this.lastErrorSignal.asReadonly();
this.session.onRefreshDue(() => this.refresh().subscribe());
}
/** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */
restoreSession() {
this.session.restore();
}
login() {
this.lastErrorSignal.set(null);
this.loginPhaseSignal.set('requesting-challenge');
return this.api.requestChallenge().pipe(switchMap(challenge => this.signChallenge(challenge.nonce).pipe(switchMap(({ publicKeyBase64, signature }) => {
this.loginPhaseSignal.set('verifying');
return this.api.verifySignature({ publicKey: publicKeyBase64, signature, nonce: challenge.nonce });
}))), tap(tokens => {
this.session.activate(tokens);
this.loginPhaseSignal.set('done');
}), catchError(error => this.handleAuthError(error, 'invalid-signature')));
}
refresh() {
const refreshToken = this.session.getRefreshToken();
if (!refreshToken) {
this.session.markExpired();
return throwError(() => this.toAuthError({ code: 'session-expired', message: 'No refresh token available.' }));
}
return this.api.refresh({ refreshToken }).pipe(tap(tokens => this.session.activate(tokens)), catchError(error => this.handleAuthError(error, 'session-expired', () => this.session.markExpired())));
}
logout() {
const refreshToken = this.session.getRefreshToken();
this.session.clear();
if (!refreshToken) {
return new Observable(subscriber => {
subscriber.next();
subscriber.complete();
});
}
return this.api.logout(refreshToken).pipe(catchError(() => throwError(() => null)));
}
signChallenge(nonce) {
this.loginPhaseSignal.set('signing');
return new Observable(subscriber => {
this.keypair
.getOrCreateKeyPair()
.then(({ publicKeyBase64 }) => this.keypair.sign(nonce).then(signature => {
subscriber.next({ publicKeyBase64, signature });
subscriber.complete();
}))
.catch(error => subscriber.error(error));
});
}
handleAuthError(error, fallbackCode, onError) {
onError?.();
return throwError(() => this.toAuthError(this.toAuthErrorShape(error, fallbackCode)));
}
toAuthErrorShape(error, fallbackCode) {
if (error instanceof HttpErrorResponse) {
const bodyCode = error.error?.error?.code;
const code = authErrorCodeFromBackendCode(bodyCode) ?? authErrorCodeFromStatus(error.status);
return { code, message: error.message, status: error.status };
}
if (error instanceof Error) {
return { code: fallbackCode, message: error.message };
}
return { code: fallbackCode, message: 'Unknown authentication error.' };
}
toAuthError(error) {
this.lastErrorSignal.set(error);
return error;
}
};
AuthService = __decorate([
Injectable({ providedIn: 'root' })
], AuthService);
export { AuthService };

View File

@@ -0,0 +1,15 @@
export declare class Ed25519KeypairService {
private cached;
isSupported(): boolean;
/** Returns the device's Ed25519 keypair, generating and persisting one on first use. */
getOrCreateKeyPair(): Promise<{
publicKeyBase64: string;
}>;
sign(message: string): Promise<string>;
/** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */
clear(): Promise<void>;
private generateAndPersist;
private loadFromStore;
private openDatabase;
private toBase64;
}

114
dist/ed25519/ed25519-keypair.service.js vendored Normal file
View File

@@ -0,0 +1,114 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Injectable } from '@angular/core';
/**
* Manages the browser-local Ed25519 keypair used to sign admin auth
* challenges. Real WebCrypto Ed25519 (RFC 8032 support landed in evergreen
* browsers) - not a placeholder. The private key is generated
* non-extractable and kept only in IndexedDB as a CryptoKey handle; it is
* never serialized, never sent anywhere, and cannot be exported by design.
*
* Registering `publicKey` with an admin's account (associating it with a
* role) is a backend-side, out-of-band operation (e.g. an Owner approving a
* new admin's public key) - entirely outside this frontend's scope.
*/
const DB_NAME = 'admin-auth-ed25519';
const DB_VERSION = 1;
const STORE_NAME = 'keypair';
const KEY_RECORD_ID = 'device-keypair';
let Ed25519KeypairService = class Ed25519KeypairService {
constructor() {
this.cached = null;
}
isSupported() {
return typeof crypto !== 'undefined' && !!crypto.subtle && typeof indexedDB !== 'undefined';
}
/** Returns the device's Ed25519 keypair, generating and persisting one on first use. */
async getOrCreateKeyPair() {
if (!this.isSupported()) {
throw new Error('Ed25519 is not supported in this browser (requires WebCrypto + IndexedDB).');
}
const existing = await this.loadFromStore();
if (existing) {
this.cached = existing;
return { publicKeyBase64: existing.publicKeyBase64 };
}
const generated = await this.generateAndPersist();
this.cached = generated;
return { publicKeyBase64: generated.publicKeyBase64 };
}
async sign(message) {
const keyPair = this.cached ?? (await this.loadFromStore());
if (!keyPair) {
throw new Error('No Ed25519 keypair available - call getOrCreateKeyPair() first.');
}
const signatureBuffer = await crypto.subtle.sign('Ed25519', keyPair.privateKey, new TextEncoder().encode(message));
return this.toBase64(new Uint8Array(signatureBuffer));
}
/** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */
async clear() {
this.cached = null;
const db = await this.openDatabase();
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(KEY_RECORD_ID);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async generateAndPersist() {
const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']));
const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
const publicKeyBase64 = this.toBase64(new Uint8Array(publicKeyRaw));
const record = {
id: KEY_RECORD_ID,
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
publicKeyBase64
};
const db = await this.openDatabase();
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
return record;
}
async loadFromStore() {
const db = await this.openDatabase();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).get(KEY_RECORD_ID);
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error);
});
}
openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
toBase64(bytes) {
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
};
Ed25519KeypairService = __decorate([
Injectable({ providedIn: 'root' })
], Ed25519KeypairService);
export { Ed25519KeypairService };

View File

@@ -0,0 +1,27 @@
import { Observable } from 'rxjs';
/**
* Prep interfaces for a future Ed25519 challenge/response admin auth flow.
* No crypto is implemented here - verification is delegated to an injectable
* service so the real implementation (native WebCrypto Ed25519 support, or a
* backend verification call) can be swapped in once the backend API exists,
* without touching AdminAuthService or components.
*/
export interface Ed25519Challenge {
nonce: string;
timestamp: string;
/** Opaque challenge payload the client must sign with its private key. */
payload: string;
}
export interface Ed25519SignedResponse {
challenge: Ed25519Challenge;
publicKey: string;
signature: string;
}
export interface Ed25519VerificationResult {
valid: boolean;
reason?: string;
}
export declare abstract class Ed25519VerificationService {
abstract requestChallenge(): Observable<Ed25519Challenge>;
abstract verify(response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
}

View File

@@ -0,0 +1,2 @@
export class Ed25519VerificationService {
}

14
dist/ed25519/jwt.service.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import { JwtClaims } from './models/auth-api.model';
/**
* Client-side JWT *decoding* only - never verification. The signature is
* meaningless to check here because the frontend has no trusted key to check
* it against; verifying a JWT's signature is the backend's job on every
* request. This service exists purely so the UI can read `role`/`exp` for
* display and route-gating UX (e.g. "session expires in 4m").
*/
export declare class JwtService {
decode(token: string): JwtClaims | null;
isExpired(claims: JwtClaims, skewSeconds?: number): boolean;
private isJwtClaims;
private base64UrlDecode;
}

48
dist/ed25519/jwt.service.js vendored Normal file
View File

@@ -0,0 +1,48 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Injectable } from '@angular/core';
/**
* Client-side JWT *decoding* only - never verification. The signature is
* meaningless to check here because the frontend has no trusted key to check
* it against; verifying a JWT's signature is the backend's job on every
* request. This service exists purely so the UI can read `role`/`exp` for
* display and route-gating UX (e.g. "session expires in 4m").
*/
let JwtService = class JwtService {
decode(token) {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
try {
const payload = this.base64UrlDecode(parts[1]);
const claims = JSON.parse(payload);
return this.isJwtClaims(claims) ? claims : null;
}
catch {
return null;
}
}
isExpired(claims, skewSeconds = 0) {
return claims.exp * 1000 <= Date.now() + skewSeconds * 1000;
}
isJwtClaims(value) {
if (!value || typeof value !== 'object') {
return false;
}
const claims = value;
return typeof claims.sub === 'string' && typeof claims.role === 'string' && typeof claims.exp === 'number';
}
base64UrlDecode(input) {
const base64 = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(input.length + ((4 - (input.length % 4)) % 4), '=');
return decodeURIComponent(escape(atob(base64)));
}
};
JwtService = __decorate([
Injectable({ providedIn: 'root' })
], JwtService);
export { JwtService };

36
dist/ed25519/models/auth-api.model.d.ts vendored Normal file
View File

@@ -0,0 +1,36 @@
import { AdminRole } from './permission.model';
/** Wire contracts for the Ed25519 challenge/response admin auth flow. */
export interface AuthChallenge {
nonce: string;
/** ISO 8601 issue time of the challenge. */
issuedAt: string;
/** ISO 8601 - challenge must be used before this or the backend rejects it. */
expiresAt: string;
}
export interface VerifySignatureRequest {
publicKey: string;
signature: string;
nonce: string;
}
export interface AuthTokenPair {
token: string;
refreshToken: string;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
/**
* Claims expected in the JWT `token`. Decoded client-side for display/UX
* only (role-gating UI, expiry countdown) - the frontend never treats this
* as proof of authorization; every admin request is still re-checked
* server-side.
*/
export interface JwtClaims {
sub: string;
role: AdminRole;
/** Issued-at, seconds since epoch (standard `iat` claim). */
iat: number;
/** Expiry, seconds since epoch (standard `exp` claim). */
exp: number;
publicKey: string;
}

1
dist/ed25519/models/auth-api.model.js vendored Normal file
View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,15 @@
/**
* Error codes the Ed25519 admin auth flow can surface to the UI. Each maps to
* a dedicated screen rather than a generic toast, because the recovery
* action differs per code (re-login vs. retry vs. wait).
*/
export type AuthErrorCode = 'session-expired' | 'invalid-signature' | 'unauthorized' | 'forbidden' | 'backend-unavailable';
export interface AuthError {
code: AuthErrorCode;
message: string;
/** HTTP status that produced this error, when known (absent for client-side errors, e.g. no Ed25519 support). */
status?: number;
}
export declare function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined;
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
export declare function authErrorCodeFromStatus(status: number): AuthErrorCode;

24
dist/ed25519/models/auth-error.model.js vendored Normal file
View File

@@ -0,0 +1,24 @@
/** Maps a backend error envelope's `error.code` to the client's AuthErrorCode screens. Only codes with a dedicated screen are mapped; anything else falls back to the HTTP-status-derived code via authErrorCodeFromStatus. */
const BACKEND_ERROR_CODE_MAP = {
TOKEN_EXPIRED: 'session-expired',
INVALID_SIGNATURE: 'invalid-signature',
UNAUTHENTICATED: 'unauthorized',
FORBIDDEN: 'forbidden',
SERVICE_UNAVAILABLE: 'backend-unavailable',
};
export function authErrorCodeFromBackendCode(code) {
return typeof code === 'string' ? BACKEND_ERROR_CODE_MAP[code] : undefined;
}
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
export function authErrorCodeFromStatus(status) {
switch (status) {
case 401:
return 'unauthorized';
case 403:
return 'forbidden';
case 0:
return 'backend-unavailable';
default:
return status >= 500 ? 'backend-unavailable' : 'unauthorized';
}
}

View File

@@ -0,0 +1,10 @@
/** Roles the Ed25519 JWT `role` claim is expected to carry. Ordered highest-to-lowest privilege; PermissionService does not rely on the order, it is documentation only. */
export type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly';
/**
* Coarse-grained permission keys. Intentionally small and domain-agnostic -
* fine-grained, per-domain permissions stay server-side; the frontend only
* needs enough to hide/disable UI, never to be the source of truth for
* authorization.
*/
export type Permission = 'backoffice.read' | 'backoffice.write' | 'builder.read' | 'builder.write' | 'users.manage' | 'settings.manage';
export declare const ROLE_PERMISSIONS: Readonly<Record<AdminRole, readonly Permission[]>>;

View File

@@ -0,0 +1,7 @@
export const 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']
};

View File

@@ -0,0 +1,12 @@
import { Observable } from 'rxjs';
import { Ed25519Challenge, Ed25519SignedResponse, Ed25519VerificationResult, Ed25519VerificationService } from './ed25519-verification.model';
/**
* Default DI binding for Ed25519VerificationService until the backend ships
* the real challenge/verify endpoints. Intentionally fails closed (throws)
* rather than pretending to verify anything, so accidental use in a login
* path is loud instead of silently accepting unsigned sessions.
*/
export declare class NoopEd25519VerificationService implements Ed25519VerificationService {
requestChallenge(): Observable<Ed25519Challenge>;
verify(_response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
}

View File

@@ -0,0 +1,26 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Injectable } from '@angular/core';
import { throwError } from 'rxjs';
/**
* Default DI binding for Ed25519VerificationService until the backend ships
* the real challenge/verify endpoints. Intentionally fails closed (throws)
* rather than pretending to verify anything, so accidental use in a login
* path is loud instead of silently accepting unsigned sessions.
*/
let NoopEd25519VerificationService = class NoopEd25519VerificationService {
requestChallenge() {
return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.'));
}
verify(_response) {
return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.'));
}
};
NoopEd25519VerificationService = __decorate([
Injectable({ providedIn: 'root' })
], NoopEd25519VerificationService);
export { NoopEd25519VerificationService };

12
dist/ed25519/permission.service.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import { Permission } from './models/permission.model';
/**
* Derives the current admin's permission set from their JWT `role` claim.
* UI-only gate (hide/disable) - the backend must independently enforce
* every mutation server-side.
*/
export declare class PermissionService {
private readonly session;
readonly permissions: import("@angular/core").Signal<readonly Permission[]>;
has(permission: Permission): boolean;
hasAny(permissions: readonly Permission[]): boolean;
}

33
dist/ed25519/permission.service.js vendored Normal file
View File

@@ -0,0 +1,33 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Injectable, computed, inject } from '@angular/core';
import { ROLE_PERMISSIONS } from './models/permission.model';
import { SessionService } from './session.service';
/**
* Derives the current admin's permission set from their JWT `role` claim.
* UI-only gate (hide/disable) - the backend must independently enforce
* every mutation server-side.
*/
let PermissionService = class PermissionService {
constructor() {
this.session = inject(SessionService);
this.permissions = computed(() => {
const role = this.session.role();
return role ? ROLE_PERMISSIONS[role] : [];
});
}
has(permission) {
return this.permissions().includes(permission);
}
hasAny(permissions) {
return permissions.some(permission => this.has(permission));
}
};
PermissionService = __decorate([
Injectable({ providedIn: 'root' })
], PermissionService);
export { PermissionService };

35
dist/ed25519/session.service.d.ts vendored Normal file
View File

@@ -0,0 +1,35 @@
import { AuthTokenPair, JwtClaims } from './models/auth-api.model';
export type SessionStatus = 'unknown' | 'restoring' | 'authenticated' | 'unauthenticated' | 'expired';
/**
* Holds the Ed25519-flow JWT/refresh-token pair and derived claims. Separate
* from the telegram module's AdminAuthService (Telegram-session state) by
* design - the two auth mechanisms are not merged until both ship on the
* same backend and a migration decision is made.
*/
export declare class SessionService {
private readonly jwt;
private readonly tokenSignal;
private readonly refreshTokenSignal;
private readonly claimsSignal;
private readonly statusSignal;
readonly token: import("@angular/core").Signal<string | null>;
readonly claims: import("@angular/core").Signal<JwtClaims | null>;
readonly status: import("@angular/core").Signal<SessionStatus>;
readonly isAuthenticated: import("@angular/core").Signal<boolean>;
readonly role: import("@angular/core").Signal<import("..").AdminRole | null>;
private refreshTimer?;
private refreshCallback?;
/** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */
onRefreshDue(callback: () => void): void;
/** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */
restore(): boolean;
activate(tokens: AuthTokenPair): void;
getRefreshToken(): string | null;
markExpired(): void;
clear(): void;
private scheduleRefresh;
private clearRefreshTimer;
private readStorage;
private writeStorage;
private removeStorage;
}

120
dist/ed25519/session.service.js vendored Normal file
View File

@@ -0,0 +1,120 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Injectable, computed, signal } from '@angular/core';
import { JwtService } from './jwt.service';
const TOKEN_STORAGE_KEY = 'ed25519AdminToken';
const REFRESH_STORAGE_KEY = 'ed25519AdminRefreshToken';
/** Refresh this long before actual expiry, so a request never races an expiring token. */
const REFRESH_SKEW_MS = 60_000;
/**
* Holds the Ed25519-flow JWT/refresh-token pair and derived claims. Separate
* from the telegram module's AdminAuthService (Telegram-session state) by
* design - the two auth mechanisms are not merged until both ship on the
* same backend and a migration decision is made.
*/
let SessionService = class SessionService {
constructor() {
this.jwt = new JwtService();
this.tokenSignal = signal(null);
this.refreshTokenSignal = signal(null);
this.claimsSignal = signal(null);
this.statusSignal = signal('unknown');
this.token = this.tokenSignal.asReadonly();
this.claims = this.claimsSignal.asReadonly();
this.status = this.statusSignal.asReadonly();
this.isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
this.role = computed(() => this.claimsSignal()?.role ?? null);
}
/** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */
onRefreshDue(callback) {
this.refreshCallback = callback;
}
/** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */
restore() {
this.statusSignal.set('restoring');
const token = this.readStorage(TOKEN_STORAGE_KEY);
const refreshToken = this.readStorage(REFRESH_STORAGE_KEY);
if (!token || !refreshToken) {
this.statusSignal.set('unauthenticated');
return false;
}
const claims = this.jwt.decode(token);
if (!claims) {
this.clear();
return false;
}
this.tokenSignal.set(token);
this.refreshTokenSignal.set(refreshToken);
this.claimsSignal.set(claims);
if (this.jwt.isExpired(claims)) {
this.statusSignal.set('expired');
}
else {
this.statusSignal.set('authenticated');
this.scheduleRefresh(claims);
}
return true;
}
activate(tokens) {
const claims = this.jwt.decode(tokens.token);
if (!claims) {
throw new Error('Received a malformed JWT from the auth backend.');
}
this.tokenSignal.set(tokens.token);
this.refreshTokenSignal.set(tokens.refreshToken);
this.claimsSignal.set(claims);
this.statusSignal.set('authenticated');
this.writeStorage(TOKEN_STORAGE_KEY, tokens.token);
this.writeStorage(REFRESH_STORAGE_KEY, tokens.refreshToken);
this.scheduleRefresh(claims);
}
getRefreshToken() {
return this.refreshTokenSignal();
}
markExpired() {
this.statusSignal.set('expired');
this.clearRefreshTimer();
}
clear() {
this.tokenSignal.set(null);
this.refreshTokenSignal.set(null);
this.claimsSignal.set(null);
this.statusSignal.set('unauthenticated');
this.removeStorage(TOKEN_STORAGE_KEY);
this.removeStorage(REFRESH_STORAGE_KEY);
this.clearRefreshTimer();
}
scheduleRefresh(claims) {
this.clearRefreshTimer();
const expiresInMs = claims.exp * 1000 - Date.now();
const refreshInMs = Math.max(expiresInMs - REFRESH_SKEW_MS, 5_000);
this.refreshTimer = setTimeout(() => this.refreshCallback?.(), refreshInMs);
}
clearRefreshTimer() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = undefined;
}
}
readStorage(key) {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
}
writeStorage(key, value) {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
}
}
removeStorage(key) {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
}
}
};
SessionService = __decorate([
Injectable({ providedIn: 'root' })
], SessionService);
export { SessionService };