feat: extract auth into @marketplaces/auth package, add backoffice admin provisioning spec

- ADR-0001: decision to extract auth/payment into shared @marketplaces/* packages
- Scaffold packages/auth, packages/payment; @marketplaces/auth now holds the real
  telegram (customer+admin QR/session) and ed25519 (future admin challenge/response)
  auth implementation, pushed to sources.vitanova.network/sdarbinyan/vitanovaPackages
- Rewire ~30 call sites to import from @marketplaces/auth; delete migrated originals
  from core/auth, core/admin-auth, services/, models/
- Replace environment coupling with AUTH_API_URL/TELEGRAM_BOT_USERNAME injection
  tokens and isDevMode(); wired as file:packages/auth pending registry publish
- Add TRACK-S §8: bootstrap per-marketplace admin login + marketplace-scoped
  sub-admin invite/role endpoints
- Build, arch:check:boundaries, and full test suite (103/103) all green

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-18 01:05:16 +04:00
parent 23060261c7
commit 14c72d1a6a
62 changed files with 420 additions and 127 deletions

View File

@@ -0,0 +1,21 @@
{
"name": "@marketplaces/auth",
"version": "0.1.0",
"description": "Shared customer + admin auth client (VK ID/OTP/session, ed25519 admin verification, guards, interceptors) for marketplaces projects.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "echo \"no tests yet\" && exit 0"
},
"peerDependencies": {
"@angular/core": ">=22.0.0",
"@angular/common": ">=22.0.0",
"rxjs": ">=7.8.0"
},
"publishConfig": {
"access": "restricted"
},
"license": "UNLICENSED"
}

View File

@@ -0,0 +1,7 @@
import { InjectionToken } from '@angular/core';
/** Base URL for the auth backend, e.g. `https://api.example.com`. Provide from the consuming app's environment config. */
export const AUTH_API_URL = new InjectionToken<string>('@marketplaces/auth AUTH_API_URL');
/** Telegram bot username used to build QR/deep-link login URLs. Optional — falls back to a default if not provided. */
export const TELEGRAM_BOT_USERNAME = new InjectionToken<string>('@marketplaces/auth TELEGRAM_BOT_USERNAME');

View File

@@ -0,0 +1,34 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { AUTH_API_URL } from '../config';
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.
*/
@Injectable({ providedIn: 'root' })
export class AuthApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${inject(AUTH_API_URL)}/api/admin/auth`;
requestChallenge(): Observable<AuthChallenge> {
return this.http.get<AuthChallenge>(`${this.baseUrl}/challenge`);
}
verifySignature(request: VerifySignatureRequest): Observable<AuthTokenPair> {
return this.http.post<AuthTokenPair>(`${this.baseUrl}/verify`, request);
}
refresh(request: RefreshTokenRequest): Observable<AuthTokenPair> {
return this.http.post<AuthTokenPair>(`${this.baseUrl}/refresh`, request);
}
logout(refreshToken: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/logout`, { refreshToken } satisfies RefreshTokenRequest);
}
}

View File

@@ -0,0 +1,56 @@
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';
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.
*/
@Injectable({ providedIn: 'root' })
export class AuthFacade {
private readonly auth = inject(AuthService);
private readonly session = inject(SessionService);
private readonly permissions = inject(PermissionService);
private readonly router = inject(Router);
readonly isAuthenticated = this.session.isAuthenticated;
readonly status = this.session.status;
readonly role = this.session.role;
readonly loginPhase = this.auth.loginPhase;
readonly lastError = this.auth.lastError;
restoreSession(): void {
this.auth.restoreSession();
}
login(onSuccessRedirectTo?: string): void {
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'): void {
this.auth
.logout()
.pipe(finalize(() => this.router.navigateByUrl(redirectTo)))
.subscribe({ error: () => undefined });
}
can(permission: Permission): boolean {
return this.permissions.has(permission);
}
}

View File

@@ -0,0 +1,128 @@
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 { AuthTokenPair } from './models/auth-api.model';
import { AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from './models/auth-error.model';
import { AuthApiService } from './auth-api.service';
import { Ed25519KeypairService } from './ed25519-keypair.service';
import { SessionService } from './session.service';
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`.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly api = inject(AuthApiService);
private readonly keypair = inject(Ed25519KeypairService);
private readonly session = inject(SessionService);
private readonly loginPhaseSignal = signal<LoginPhase>('idle');
private readonly lastErrorSignal = signal<AuthError | null>(null);
readonly loginPhase = this.loginPhaseSignal.asReadonly();
readonly lastError = this.lastErrorSignal.asReadonly();
constructor() {
this.session.onRefreshDue(() => this.refresh().subscribe());
}
/** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */
restoreSession(): void {
this.session.restore();
}
login(): Observable<AuthTokenPair> {
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<AuthTokenPair>(error, 'invalid-signature'))
);
}
refresh(): Observable<AuthTokenPair> {
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<AuthTokenPair>(error, 'session-expired', () => this.session.markExpired()))
);
}
logout(): Observable<void> {
const refreshToken = this.session.getRefreshToken();
this.session.clear();
if (!refreshToken) {
return new Observable<void>(subscriber => {
subscriber.next();
subscriber.complete();
});
}
return this.api.logout(refreshToken).pipe(catchError(() => throwError(() => null)));
}
private signChallenge(nonce: string): Observable<{ publicKeyBase64: string; signature: string }> {
this.loginPhaseSignal.set('signing');
return new Observable<{ publicKeyBase64: string; signature: string }>(subscriber => {
this.keypair
.getOrCreateKeyPair()
.then(({ publicKeyBase64 }) =>
this.keypair.sign(nonce).then(signature => {
subscriber.next({ publicKeyBase64, signature });
subscriber.complete();
})
)
.catch(error => subscriber.error(error));
});
}
private handleAuthError<T>(error: unknown, fallbackCode: AuthError['code'], onError?: () => void): Observable<T> {
onError?.();
return throwError(() => this.toAuthError(this.toAuthErrorShape(error, fallbackCode)));
}
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
if (error instanceof HttpErrorResponse) {
const bodyCode = (error.error as { error?: { code?: unknown } } | null)?.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.' };
}
private toAuthError(error: AuthError): AuthError {
this.lastErrorSignal.set(error);
return error;
}
}

View File

@@ -0,0 +1,125 @@
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';
interface StoredKeyPair {
id: string;
publicKey: CryptoKey;
privateKey: CryptoKey;
publicKeyBase64: string;
}
@Injectable({ providedIn: 'root' })
export class Ed25519KeypairService {
private cached: StoredKeyPair | null = null;
isSupported(): boolean {
return typeof crypto !== 'undefined' && !!crypto.subtle && typeof indexedDB !== 'undefined';
}
/** Returns the device's Ed25519 keypair, generating and persisting one on first use. */
async getOrCreateKeyPair(): Promise<{ publicKeyBase64: string }> {
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: string): Promise<string> {
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(): Promise<void> {
this.cached = null;
const db = await this.openDatabase();
await new Promise<void>((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);
});
}
private async generateAndPersist(): Promise<StoredKeyPair> {
const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])) as CryptoKeyPair;
const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
const publicKeyBase64 = this.toBase64(new Uint8Array(publicKeyRaw));
const record: StoredKeyPair = {
id: KEY_RECORD_ID,
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
publicKeyBase64
};
const db = await this.openDatabase();
await new Promise<void>((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;
}
private async loadFromStore(): Promise<StoredKeyPair | null> {
const db = await this.openDatabase();
return new Promise<StoredKeyPair | null>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).get(KEY_RECORD_ID);
request.onsuccess = () => resolve((request.result as StoredKeyPair | undefined) ?? null);
request.onerror = () => reject(request.error);
});
}
private openDatabase(): Promise<IDBDatabase> {
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);
});
}
private toBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
}

View File

@@ -0,0 +1,31 @@
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 abstract class Ed25519VerificationService {
abstract requestChallenge(): Observable<Ed25519Challenge>;
abstract verify(response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
}

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
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").
*/
@Injectable({ providedIn: 'root' })
export class JwtService {
decode(token: string): JwtClaims | null {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
try {
const payload = this.base64UrlDecode(parts[1]);
const claims = JSON.parse(payload) as JwtClaims;
return this.isJwtClaims(claims) ? claims : null;
} catch {
return null;
}
}
isExpired(claims: JwtClaims, skewSeconds = 0): boolean {
return claims.exp * 1000 <= Date.now() + skewSeconds * 1000;
}
private isJwtClaims(value: unknown): value is JwtClaims {
if (!value || typeof value !== 'object') {
return false;
}
const claims = value as Partial<JwtClaims>;
return typeof claims.sub === 'string' && typeof claims.role === 'string' && typeof claims.exp === 'number';
}
private base64UrlDecode(input: string): string {
const base64 = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(input.length + ((4 - (input.length % 4)) % 4), '=');
return decodeURIComponent(escape(atob(base64)));
}
}

View File

@@ -0,0 +1,41 @@
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;
}

View File

@@ -0,0 +1,45 @@
/**
* 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;
}
/** 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: Record<string, AuthErrorCode> = {
TOKEN_EXPIRED: 'session-expired',
INVALID_SIGNATURE: 'invalid-signature',
UNAUTHENTICATED: 'unauthorized',
FORBIDDEN: 'forbidden',
SERVICE_UNAVAILABLE: 'backend-unavailable',
};
export function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined {
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: number): AuthErrorCode {
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,24 @@
/** 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 const ROLE_PERMISSIONS: Readonly<Record<AdminRole, readonly Permission[]>> = {
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,20 @@
import { Injectable } from '@angular/core';
import { Observable, throwError } 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.
*/
@Injectable({ providedIn: 'root' })
export class NoopEd25519VerificationService implements Ed25519VerificationService {
requestChallenge(): Observable<Ed25519Challenge> {
return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.'));
}
verify(_response: Ed25519SignedResponse): Observable<Ed25519VerificationResult> {
return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.'));
}
}

View File

@@ -0,0 +1,26 @@
import { Injectable, computed, inject } from '@angular/core';
import { Permission, 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.
*/
@Injectable({ providedIn: 'root' })
export class PermissionService {
private readonly session = inject(SessionService);
readonly permissions = computed<readonly Permission[]>(() => {
const role = this.session.role();
return role ? ROLE_PERMISSIONS[role] : [];
});
has(permission: Permission): boolean {
return this.permissions().includes(permission);
}
hasAny(permissions: readonly Permission[]): boolean {
return permissions.some(permission => this.has(permission));
}
}

View File

@@ -0,0 +1,133 @@
import { Injectable, computed, signal } from '@angular/core';
import { AuthTokenPair, JwtClaims } from './models/auth-api.model';
import { JwtService } from './jwt.service';
export type SessionStatus = 'unknown' | 'restoring' | 'authenticated' | 'unauthenticated' | 'expired';
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.
*/
@Injectable({ providedIn: 'root' })
export class SessionService {
private readonly jwt = new JwtService();
private readonly tokenSignal = signal<string | null>(null);
private readonly refreshTokenSignal = signal<string | null>(null);
private readonly claimsSignal = signal<JwtClaims | null>(null);
private readonly statusSignal = signal<SessionStatus>('unknown');
readonly token = this.tokenSignal.asReadonly();
readonly claims = this.claimsSignal.asReadonly();
readonly status = this.statusSignal.asReadonly();
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
readonly role = computed(() => this.claimsSignal()?.role ?? null);
private refreshTimer?: ReturnType<typeof setTimeout>;
private refreshCallback?: () => void;
/** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */
onRefreshDue(callback: () => void): void {
this.refreshCallback = callback;
}
/** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */
restore(): boolean {
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: AuthTokenPair): void {
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(): string | null {
return this.refreshTokenSignal();
}
markExpired(): void {
this.statusSignal.set('expired');
this.clearRefreshTimer();
}
clear(): void {
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();
}
private scheduleRefresh(claims: JwtClaims): void {
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);
}
private clearRefreshTimer(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = undefined;
}
}
private readStorage(key: string): string | null {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
}
private writeStorage(key: string, value: string): void {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
}
}
private removeStorage(key: string): void {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
}
}
}

View File

@@ -0,0 +1,43 @@
// @marketplaces/auth — public API barrel.
// Two independent auth mechanisms, per ADR-0001 (marketplaces repo:
// docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md):
// - telegram/ — live Telegram QR/session auth (customer + admin)
// - ed25519/ — future Ed25519 challenge/response admin auth (backend not shipped yet)
// Provide AUTH_API_URL (and optionally TELEGRAM_BOT_USERNAME) from the consuming app's config.
export { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from './config';
// Telegram module
export { AuthSession, WebSessionStart, AuthStatus, AdminAuthStatus } from './telegram/models/session.model';
export { TelegramSessionApiService } from './telegram/telegram-session-api.service';
export { AuthService } from './telegram/auth.service';
export { AdminAuthService } from './telegram/admin-auth.service';
export { adminAuthGuard } from './telegram/admin-auth.guard';
export { adminAuthHeadersInterceptor } from './telegram/admin-auth-headers.interceptor';
// Ed25519 module (namespaced re-exports to avoid colliding with the telegram module's AuthService)
export { AuthService as Ed25519AuthService } from './ed25519/auth.service';
export { AuthFacade } from './ed25519/auth-facade.service';
export { AuthApiService } from './ed25519/auth-api.service';
export { SessionService } from './ed25519/session.service';
export { JwtService } from './ed25519/jwt.service';
export { Ed25519KeypairService } from './ed25519/ed25519-keypair.service';
export { PermissionService } from './ed25519/permission.service';
export {
Ed25519VerificationService,
Ed25519Challenge,
Ed25519SignedResponse,
Ed25519VerificationResult
} from './ed25519/ed25519-verification.model';
export { NoopEd25519VerificationService } from './ed25519/noop-ed25519-verification.service';
export {
AuthChallenge,
VerifySignatureRequest,
AuthTokenPair,
RefreshTokenRequest,
JwtClaims
} from './ed25519/models/auth-api.model';
export { AuthErrorCode, AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from './ed25519/models/auth-error.model';
export { AdminRole, Permission, ROLE_PERMISSIONS } from './ed25519/models/permission.model';
export type { LoginPhase } from './ed25519/auth.service';
export type { SessionStatus } from './ed25519/session.service';

View File

@@ -0,0 +1,32 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AdminAuthService } from './admin-auth.service';
/** Backend paths that require an active AdminWebSessionID. Adjust to match your API surface if consuming this outside marketplaces. */
const ADMIN_GATED_PATH_SEGMENTS = ['/admin/', '/backoffice/', '/builder/', '/media/'];
/**
* Attaches admin session/token headers only to admin API requests. Scoped to
* admin-gated paths so it never touches customer requests and never reads
* the customer AuthService's session.
*/
export const adminAuthHeadersInterceptor: HttpInterceptorFn = (req, next) => {
const isAdminRequest = ADMIN_GATED_PATH_SEGMENTS.some(segment => req.url.includes(segment));
if (!isAdminRequest) {
return next(req);
}
const adminAuth = inject(AdminAuthService);
const session = adminAuth.session();
const token = adminAuth.getAdminToken();
let headers = req.headers;
if (session?.sessionId) {
headers = headers.set('AdminWebSessionID', session.sessionId);
}
if (token) {
headers = headers.set('Authorization', `Bearer ${token}`);
}
return next(req.clone({ headers }));
};

View File

@@ -0,0 +1,15 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { AdminAuthService } from './admin-auth.service';
/** Guards `/admin/**`-style routes. Never shares state with the customer auth guard/service. */
export const adminAuthGuard: CanActivateFn = () => {
const adminAuth = inject(AdminAuthService);
if (adminAuth.isAuthenticated()) {
return true;
}
adminAuth.requestLogin();
return false;
};

View File

@@ -0,0 +1,210 @@
import { Injectable, signal, computed, inject, isDevMode } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { AdminAuthStatus, AuthSession, WebSessionStart } from './models/session.model';
import { TelegramSessionApiService } from './telegram-session-api.service';
/**
* Admin login uses the exact same Telegram QR/session API as the customer
* login (TelegramSessionApiService) - there is no separate admin backend
* endpoint, and none should be invented client-side. Only the *storage* is
* kept separate from AuthService, so an admin QR scan never authenticates
* the customer session or vice versa: distinct cookie name, distinct
* signals, distinct guard/interceptor.
*
* Since the session API itself has no concept of "admin", the frontend
* cannot tell an admin Telegram session from a regular one. Actual admin
* authorization must be enforced server-side when admin API calls are made
* with the resulting session id - the frontend only decides where to
* *store* the result.
*/
const ADMIN_SESSION_COOKIE = 'adminSessionID';
const ADMIN_TOKEN_STORAGE_KEY = 'adminToken';
const ADMIN_REFRESH_STORAGE_KEY = 'adminRefreshToken';
const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
@Injectable({ providedIn: 'root' })
export class AdminAuthService {
private readonly api = inject(TelegramSessionApiService);
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
private readonly showLoginSignal = signal(false);
readonly session = this.sessionSignal.asReadonly();
readonly status = this.statusSignal.asReadonly();
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
readonly showLoginDialog = this.showLoginSignal.asReadonly();
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
constructor() {
this.checkSession();
}
checkSession(): void {
const webSessionID = this.getStoredAdminSessionID();
if (!webSessionID) {
this.clearAuthState('unauthenticated');
return;
}
this.statusSignal.set('checking');
this.checkSessionOnce(webSessionID).subscribe(session => {
if (!session?.active) {
this.clearAuthState('unauthenticated');
}
});
}
/** Check session without mutating internal state beyond activating on success (used for polling). */
checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable<AuthSession | null> {
return this.api.checkSessionOnce(webSessionID).pipe(
tap(session => {
if (session?.active) {
this.activateSession(session);
}
})
);
}
/** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */
createWebSession(): Observable<WebSessionStart> {
return this.api.createSession();
}
getAdminAppLoginUrl(webSessionID: string): string {
return this.api.getBotAppLoginUrl(webSessionID);
}
onLoginComplete(): void {
this.hideLogin();
if (!this.isAuthenticated()) {
this.checkSession();
}
}
requestLogin(): void {
this.showLoginSignal.set(true);
}
/**
* Dev-only shortcut for local testing without a reachable Telegram/session
* backend: fabricates a local session and activates it directly, skipping
* the QR flow entirely. No-ops in production builds (checked via Angular's
* isDevMode() at runtime, not just build-time, so it is safe even if this
* code ships). Never call this from anywhere reachable in a production build.
*/
devBypassLogin(): void {
if (!isDevMode()) {
return;
}
this.hideLogin();
this.activateSession({
sessionId: `dev-bypass-${Date.now()}`,
userId: 0,
username: 'dev-admin',
displayName: 'Dev Admin (local bypass)',
active: true,
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
}
hideLogin(): void {
this.showLoginSignal.set(false);
}
logout(): void {
const webSessionID = this.sessionSignal()?.sessionId || this.getStoredAdminSessionID();
if (!webSessionID) {
this.clearAuthState('unauthenticated');
return;
}
this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated'));
}
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
getAdminToken(): string | null {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);
}
setAdminTokens(token: string, refreshToken: string): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token);
localStorage.setItem(ADMIN_REFRESH_STORAGE_KEY, refreshToken);
}
clearAdminTokens(): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY);
}
private activateSession(session: AuthSession): void {
this.sessionSignal.set(session);
this.statusSignal.set('authenticated');
this.setStoredAdminSessionID(session.sessionId);
this.scheduleSessionRefresh(session.expires);
}
private clearAuthState(status: AdminAuthStatus): void {
this.sessionSignal.set(null);
this.statusSignal.set(status);
this.clearStoredAdminSessionID();
this.clearAdminTokens();
this.clearSessionRefresh();
}
private scheduleSessionRefresh(expiresAt: string): void {
this.clearSessionRefresh();
const expiresMs = new Date(expiresAt).getTime();
const nowMs = Date.now();
const refreshIn = Number.isFinite(expiresMs)
? Math.max(expiresMs - nowMs - 60_000, 30_000)
: ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS * 1000;
this.sessionCheckTimer = setTimeout(() => this.checkSession(), refreshIn);
}
private clearSessionRefresh(): void {
if (this.sessionCheckTimer) {
clearTimeout(this.sessionCheckTimer);
this.sessionCheckTimer = undefined;
}
}
private getStoredAdminSessionID(): string | null {
if (typeof document === 'undefined') {
return null;
}
const cookie = document.cookie.split('; ').find(row => row.startsWith(`${ADMIN_SESSION_COOKIE}=`));
if (!cookie) {
return null;
}
try {
return decodeURIComponent(cookie.substring(ADMIN_SESSION_COOKIE.length + 1));
} catch {
return null;
}
}
private setStoredAdminSessionID(webSessionID: string): void {
if (typeof document === 'undefined') {
return;
}
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Strict${secure}`;
}
private clearStoredAdminSessionID(): void {
if (typeof document === 'undefined') {
return;
}
document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`;
}
}

View File

@@ -0,0 +1,190 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { AuthSession, AuthStatus, WebSessionStart } from './models/session.model';
import { TelegramSessionApiService } from './telegram-session-api.service';
const WEB_SESSION_COOKIE = 'webSessionID';
const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
/** Customer-facing Telegram QR/session auth. Distinct storage/state from AdminAuthService by design. */
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly api = inject(TelegramSessionApiService);
private sessionSignal = signal<AuthSession | null>(null);
private statusSignal = signal<AuthStatus>('unknown');
private showLoginSignal = signal(false);
/** Current auth session */
readonly session = this.sessionSignal.asReadonly();
/** Current auth status */
readonly status = this.statusSignal.asReadonly();
/** Whether user is fully authenticated */
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
/** Whether to show login dialog */
readonly showLoginDialog = this.showLoginSignal.asReadonly();
/** Display name of authenticated user */
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
constructor() {
// On init, check existing session via cookie
this.checkSession();
}
/** Check the current webSessionID cookie against the auth backend. */
checkSession(): void {
const webSessionID = this.getStoredWebSessionID();
if (!webSessionID) {
this.clearAuthState('unauthenticated');
return;
}
this.statusSignal.set('checking');
this.checkSessionOnce(webSessionID).subscribe(session => {
if (!session?.active) {
this.clearAuthState('unauthenticated');
}
});
}
/** Check session without updating internal state beyond activating on success (used for polling). */
checkSessionOnce(webSessionID = this.getStoredWebSessionID()): Observable<AuthSession | null> {
return this.api.checkSessionOnce(webSessionID).pipe(
tap(session => {
if (session?.active) {
this.activateSession(session);
}
})
);
}
/**
* Called after user completes Telegram login.
*/
onTelegramLoginComplete(): void {
this.hideLogin();
if (!this.isAuthenticated()) {
this.checkSession();
}
}
/** Generate the Telegram login URL for bot-based auth */
getTelegramLoginUrl(webSessionID: string): string {
return this.api.getBotLoginUrl(webSessionID);
}
/** Generate a Telegram app deep link for mobile login without opening a browser tab. */
getTelegramAppLoginUrl(webSessionID: string): string {
return this.api.getBotAppLoginUrl(webSessionID);
}
/** Create a backend web session and return the Telegram start link for it. */
createWebSession(): Observable<WebSessionStart> {
return this.api.createSession();
}
/** Show login dialog (called when user tries to pay without being logged in) */
requestLogin(): void {
this.showLoginSignal.set(true);
}
/** Hide login dialog */
hideLogin(): void {
this.showLoginSignal.set(false);
}
/** Logout — clears session on backend and locally */
logout(): void {
const webSessionID = this.sessionSignal()?.sessionId || this.getStoredWebSessionID();
if (!webSessionID) {
this.clearAuthState('unauthenticated');
return;
}
this.api.logout(webSessionID).subscribe(() => {
this.clearAuthState('unauthenticated');
});
}
private activateSession(session: AuthSession): void {
this.sessionSignal.set(session);
this.statusSignal.set('authenticated');
this.setStoredWebSessionID(session.sessionId);
this.scheduleSessionRefresh(session.expires);
}
private clearAuthState(status: AuthStatus): void {
this.sessionSignal.set(null);
this.statusSignal.set(status);
this.clearStoredWebSessionID();
this.clearSessionRefresh();
}
/** Schedule a session re-check before it expires */
private scheduleSessionRefresh(expiresAt: string): void {
this.clearSessionRefresh();
const expiresMs = new Date(expiresAt).getTime();
const nowMs = Date.now();
// Re-check 60 seconds before expiry, minimum 30s from now
const refreshIn = Number.isFinite(expiresMs)
? Math.max(expiresMs - nowMs - 60_000, 30_000)
: WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000;
this.sessionCheckTimer = setTimeout(() => {
this.checkSession();
}, refreshIn);
}
private clearSessionRefresh(): void {
if (this.sessionCheckTimer) {
clearTimeout(this.sessionCheckTimer);
this.sessionCheckTimer = undefined;
}
}
private getStoredWebSessionID(): string | null {
if (typeof document === 'undefined') {
return null;
}
const cookie = document.cookie
.split('; ')
.find(row => row.startsWith(`${WEB_SESSION_COOKIE}=`));
if (!cookie) {
return null;
}
try {
return decodeURIComponent(cookie.substring(WEB_SESSION_COOKIE.length + 1));
} catch {
return null;
}
}
private setStoredWebSessionID(webSessionID: string): void {
if (typeof document === 'undefined') {
return;
}
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${WEB_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${WEB_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Lax${secure}`;
}
private clearStoredWebSessionID(): void {
if (typeof document === 'undefined') {
return;
}
document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`;
}
}

View File

@@ -0,0 +1,16 @@
export interface AuthSession {
sessionId: string;
userId: number | null;
username: string | null;
displayName: string;
active: boolean;
expires: string;
}
export interface WebSessionStart {
webSessionID: string;
url: string;
}
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
export type AdminAuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';

View File

@@ -0,0 +1,157 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, catchError, map } from 'rxjs';
import { AuthSession, WebSessionStart } from './models/session.model';
import { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '../config';
import { generateGuid } from '../util/guid.util';
const SESSION_MAX_AGE_SECONDS = 60 * 60;
const DEFAULT_TELEGRAM_BOT_USERNAME = 'DexarSupport_bot';
/**
* The one Telegram QR/session API (`{authApiUrl}/users/sessions`). Customer
* login (AuthService) and admin login (AdminAuthService) both call this same
* service against this same endpoint - there is no separate admin backend.
* This class only does the HTTP call + response normalization; it holds no
* session state and writes no cookies, so each caller manages its own
* storage/signals independently on top of it.
*/
@Injectable({ providedIn: 'root' })
export class TelegramSessionApiService {
private readonly http = inject(HttpClient);
private readonly authApiUrl = inject(AUTH_API_URL);
private readonly telegramBotUsername = inject(TELEGRAM_BOT_USERNAME, { optional: true });
createSession(): Observable<WebSessionStart> {
const webSessionID = generateGuid();
return this.http.post<Record<string, unknown>>(
`${this.authApiUrl}/users/sessions`,
{ webSessionID },
{ headers: { WebSessionID: webSessionID } }
).pipe(
map(response => {
const responseWebSessionID = this.extractSessionId(response, webSessionID);
return {
webSessionID: responseWebSessionID,
url: this.getBotLoginUrl(responseWebSessionID),
};
})
);
}
checkSessionOnce(webSessionID: string | null): Observable<AuthSession | null> {
if (!webSessionID) {
return of(null);
}
return this.http.get<Record<string, unknown>>(
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`
).pipe(
map(response => this.normalizeWebSession(response, webSessionID)),
catchError(() => of(null))
);
}
logout(webSessionID: string): Observable<unknown> {
return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, {
headers: { WebSessionID: webSessionID }
}).pipe(catchError(() => of(null)));
}
getBotLoginUrl(webSessionID: string): string {
return `https://t.me/${this.getBotUsername()}?start=${encodeURIComponent(webSessionID)}`;
}
getBotAppLoginUrl(webSessionID: string): string {
return `tg://resolve?domain=${encodeURIComponent(this.getBotUsername())}&start=${encodeURIComponent(webSessionID)}`;
}
private getBotUsername(): string {
return this.telegramBotUsername || DEFAULT_TELEGRAM_BOT_USERNAME;
}
private normalizeWebSession(response: Record<string, unknown> | null, fallbackSessionId: string): AuthSession | null {
if (!response) {
return null;
}
const user = this.asRecord(this.readFirst(response, ['user', 'User', 'telegramUser', 'TelegramUser'])) ?? response;
const status = this.readFirst(response, [
'status', 'Status', 'active', 'Active', 'loggedIn', 'LoggedIn',
'isLoggedIn', 'IsLoggedIn', 'authenticated', 'Authenticated'
]);
const active = this.isActiveStatus(status);
const sessionId = this.extractSessionId(response, fallbackSessionId);
const username = this.readString(this.readFirst(user, ['username', 'Username']))
?? this.readString(this.readFirst(response, ['username', 'Username']));
const firstName = this.readString(this.readFirst(user, ['firstName', 'first_name', 'FirstName', 'First_name']));
const lastName = this.readString(this.readFirst(user, ['lastName', 'last_name', 'LastName', 'Last_name']));
const fullName = [firstName, lastName].filter(Boolean).join(' ');
const explicitDisplayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name']))
?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name']));
const displayName = explicitDisplayName ?? username ?? (fullName || 'Telegram User');
const telegramUserId = this.readNumber(this.readFirst(user, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID']))
?? this.readNumber(this.readFirst(response, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID', 'UserId']))
?? null;
const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
?? new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000).toISOString();
return { sessionId, userId: telegramUserId, username, displayName, active, expires: expiresAt };
}
private extractSessionId(response: Record<string, unknown> | null, fallbackSessionId: string): string {
if (!response) {
return fallbackSessionId;
}
return this.readString(this.readFirst(response, [
'webSessionID', 'WebSessionID', 'webSessionId', 'sessionID', 'SessionID', 'sessionId', 'id', 'ID'
])) ?? fallbackSessionId;
}
private readFirst(source: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
return source[key];
}
}
return undefined;
}
private readString(value: unknown): string | null {
if (typeof value === 'string' && value.trim()) {
return value;
}
if (typeof value === 'number' || typeof value === 'bigint') {
return value.toString();
}
return null;
}
private readNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
private asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
private isActiveStatus(status: unknown): boolean {
if (status === true || status === 1) {
return true;
}
if (typeof status !== 'string') {
return false;
}
return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase());
}
}

View File

@@ -0,0 +1,21 @@
/** RFC4122 v4-ish GUID, using crypto when available. Shared by customer and admin session creation. */
export function generateGuid(): string {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index++) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0'));
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`;
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"include": ["src"]
}