feat(packages): add @bytelyst/auth-client + telemetry-client, extend react-auth lifecycle

- @bytelyst/auth-client: browser/RN-safe auth API wrapper (17 tests)
- @bytelyst/telemetry-client: shared telemetry with configurable transport (11 tests)
- @bytelyst/react-auth: add register, forgotPw, changePw, deleteAccount, token refresh (10 tests)
- eslint.config: add missing browser globals
This commit is contained in:
saravanakumardb1 2026-02-28 04:49:46 -08:00
parent 4e94ecd721
commit b400c76c0a
16 changed files with 1623 additions and 30 deletions

View File

@ -40,6 +40,14 @@ export default [
console: 'readonly',
performance: 'readonly',
AbortSignal: 'readonly',
AbortController: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
navigator: 'readonly',
document: 'readonly',
window: 'readonly',
expect: 'readonly',
describe: 'readonly',
it: 'readonly',

View File

@ -0,0 +1,21 @@
{
"name": "@bytelyst/auth-client",
"version": "0.1.0",
"type": "module",
"description": "Browser/React Native-safe auth API client for platform-service",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run"
}
}

View File

@ -0,0 +1,346 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createAuthClient } from '../client.js';
import type { TokenStorage } from '../types.js';
function createMockStorage(): TokenStorage & { store: Map<string, string> } {
const store = new Map<string, string>();
return {
store,
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
};
}
function mockFetchResponse(data: unknown, status = 200) {
return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(data),
});
}
describe('@bytelyst/auth-client', () => {
let storage: ReturnType<typeof createMockStorage>;
beforeEach(() => {
storage = createMockStorage();
vi.restoreAllMocks();
});
describe('createAuthClient', () => {
it('creates a client with all expected methods', () => {
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
expect(client.login).toBeTypeOf('function');
expect(client.register).toBeTypeOf('function');
expect(client.getMe).toBeTypeOf('function');
expect(client.refreshAccessToken).toBeTypeOf('function');
expect(client.forgotPassword).toBeTypeOf('function');
expect(client.resetPassword).toBeTypeOf('function');
expect(client.changePassword).toBeTypeOf('function');
expect(client.deleteAccount).toBeTypeOf('function');
expect(client.verifyEmail).toBeTypeOf('function');
expect(client.resendVerification).toBeTypeOf('function');
expect(client.getAccessToken).toBeTypeOf('function');
expect(client.getRefreshToken).toBeTypeOf('function');
expect(client.setTokens).toBeTypeOf('function');
expect(client.clearTokens).toBeTypeOf('function');
expect(client.isAuthenticated).toBeTypeOf('function');
});
});
describe('token management', () => {
it('stores and retrieves tokens', () => {
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
expect(client.isAuthenticated()).toBe(false);
expect(client.getAccessToken()).toBeNull();
client.setTokens('access-123', 'refresh-456');
expect(client.isAuthenticated()).toBe(true);
expect(client.getAccessToken()).toBe('access-123');
expect(client.getRefreshToken()).toBe('refresh-456');
});
it('clears tokens', () => {
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
client.setTokens('access-123', 'refresh-456');
client.clearTokens();
expect(client.isAuthenticated()).toBe(false);
expect(client.getAccessToken()).toBeNull();
expect(client.getRefreshToken()).toBeNull();
});
it('uses productId as storage key prefix by default', () => {
createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'chronomind',
storage,
}).setTokens('a', 'b');
expect(storage.store.get('chronomind-auth-token')).toBe('a');
expect(storage.store.get('chronomind-refresh-token')).toBe('b');
});
it('respects custom storagePrefix', () => {
createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'chronomind',
storagePrefix: 'cm',
storage,
}).setTokens('a', 'b');
expect(storage.store.get('cm-auth-token')).toBe('a');
});
});
describe('login', () => {
it('sends correct request and stores tokens', async () => {
const mockData = {
accessToken: 'at-123',
refreshToken: 'rt-456',
user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' },
};
globalThis.fetch = mockFetchResponse(mockData);
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
const result = await client.login('a@b.com', 'pass123');
expect(result.user.email).toBe('a@b.com');
expect(client.getAccessToken()).toBe('at-123');
expect(client.getRefreshToken()).toBe('rt-456');
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('http://localhost:4003/api/auth/login');
expect(opts.method).toBe('POST');
const body = JSON.parse(opts.body);
expect(body.email).toBe('a@b.com');
expect(body.productId).toBe('testapp');
expect(opts.headers['x-product-id']).toBe('testapp');
expect(opts.headers['x-request-id']).toBeTruthy();
});
it('throws on login failure', async () => {
globalThis.fetch = mockFetchResponse({ message: 'Invalid credentials' }, 401);
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
await expect(client.login('a@b.com', 'wrong')).rejects.toThrow('Invalid credentials');
expect(client.isAuthenticated()).toBe(false);
});
});
describe('register', () => {
it('sends correct request and stores tokens', async () => {
const mockData = {
accessToken: 'at-new',
refreshToken: 'rt-new',
user: { id: 'u2', email: 'new@b.com', displayName: 'New', role: 'user', plan: 'free' },
};
globalThis.fetch = mockFetchResponse(mockData);
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'nomgap',
storage,
});
const result = await client.register('new@b.com', 'pass1234', 'New User');
expect(result.user.displayName).toBe('New');
expect(client.getAccessToken()).toBe('at-new');
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.displayName).toBe('New User');
expect(body.productId).toBe('nomgap');
});
});
describe('getMe', () => {
it('sends authorization header', async () => {
const mockUser = { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' };
globalThis.fetch = mockFetchResponse(mockUser);
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
client.setTokens('my-token', 'my-refresh');
const user = await client.getMe();
expect(user.email).toBe('a@b.com');
const opts = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(opts.headers['Authorization']).toBe('Bearer my-token');
});
});
describe('refreshAccessToken', () => {
it('refreshes and stores new tokens', async () => {
const mockRefresh = { accessToken: 'at-refreshed', refreshToken: 'rt-refreshed' };
globalThis.fetch = mockFetchResponse(mockRefresh);
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
client.setTokens('old-at', 'old-rt');
const ok = await client.refreshAccessToken();
expect(ok).toBe(true);
expect(client.getAccessToken()).toBe('at-refreshed');
expect(client.getRefreshToken()).toBe('rt-refreshed');
});
it('clears tokens on refresh failure', async () => {
globalThis.fetch = mockFetchResponse({ error: 'expired' }, 401);
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
client.setTokens('old-at', 'old-rt');
const ok = await client.refreshAccessToken();
expect(ok).toBe(false);
expect(client.isAuthenticated()).toBe(false);
});
it('returns false if no refresh token', async () => {
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
const ok = await client.refreshAccessToken();
expect(ok).toBe(false);
});
});
describe('forgotPassword', () => {
it('sends email and productId', async () => {
globalThis.fetch = mockFetchResponse({ message: 'Reset email sent' });
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'mindlyst',
storage,
});
const result = await client.forgotPassword('user@test.com');
expect(result.message).toBe('Reset email sent');
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.email).toBe('user@test.com');
expect(body.productId).toBe('mindlyst');
});
});
describe('changePassword', () => {
it('sends authenticated request', async () => {
globalThis.fetch = mockFetchResponse({ message: 'Password changed' });
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
client.setTokens('tok', 'ref');
const result = await client.changePassword('old', 'new12345');
expect(result.message).toBe('Password changed');
const [, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(opts.headers['Authorization']).toBe('Bearer tok');
const body = JSON.parse(opts.body);
expect(body.currentPassword).toBe('old');
expect(body.newPassword).toBe('new12345');
});
});
describe('deleteAccount', () => {
it('clears tokens after deletion', async () => {
globalThis.fetch = mockFetchResponse({ message: 'Deleted' });
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
client.setTokens('tok', 'ref');
await client.deleteAccount('mypassword');
expect(client.isAuthenticated()).toBe(false);
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain('/auth/account');
expect(opts.method).toBe('DELETE');
});
});
describe('verifyEmail', () => {
it('sends verification token', async () => {
globalThis.fetch = mockFetchResponse({ message: 'Verified' });
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'testapp',
storage,
});
const result = await client.verifyEmail('verify-token-abc');
expect(result.message).toBe('Verified');
});
});
describe('resendVerification', () => {
it('sends email and productId', async () => {
globalThis.fetch = mockFetchResponse({ message: 'Sent' });
const client = createAuthClient({
baseUrl: 'http://localhost:4003/api',
productId: 'chronomind',
storage,
});
await client.resendVerification('user@test.com');
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.email).toBe('user@test.com');
expect(body.productId).toBe('chronomind');
});
});
});

View File

@ -0,0 +1,265 @@
/**
* Browser/React Native-safe auth API client for platform-service.
*
* Replaces hand-rolled auth clients in ChronoMind web, MindLyst web, NomGap, etc.
* No Node.js dependencies uses globalThis.fetch and configurable storage.
*
* @example
* ```ts
* import { createAuthClient } from '@bytelyst/auth-client';
*
* const auth = createAuthClient({
* baseUrl: 'http://localhost:4003/api',
* productId: 'chronomind',
* });
*
* const result = await auth.login('user@example.com', 'password123');
* console.log(result.user.displayName);
* ```
*/
import type { AuthClient, AuthClientConfig, AuthResult, AuthUser, TokenStorage } from './types.js';
// ── Default localStorage adapter ─────────────────────────────────
const noopStorage: TokenStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
function getDefaultStorage(): TokenStorage {
if (
typeof globalThis.localStorage !== 'undefined' &&
typeof globalThis.localStorage?.getItem === 'function'
) {
return globalThis.localStorage;
}
return noopStorage;
}
// ── UUID helper (browser + RN safe) ──────────────────────────────
function uuid(): string {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
// ── Factory ──────────────────────────────────────────────────────
export function createAuthClient(config: AuthClientConfig): AuthClient {
const { baseUrl, productId, timeoutMs = 15_000 } = config;
const storage = config.storage ?? getDefaultStorage();
const prefix = config.storagePrefix ?? productId;
const KEYS = {
accessToken: `${prefix}-auth-token`,
refreshToken: `${prefix}-refresh-token`,
} as const;
// ── Token management ────────────────────────────
function getAccessToken(): string | null {
return storage.getItem(KEYS.accessToken);
}
function getRefreshToken(): string | null {
return storage.getItem(KEYS.refreshToken);
}
function setTokens(accessToken: string, refreshToken: string): void {
storage.setItem(KEYS.accessToken, accessToken);
storage.setItem(KEYS.refreshToken, refreshToken);
}
function clearTokens(): void {
storage.removeItem(KEYS.accessToken);
storage.removeItem(KEYS.refreshToken);
}
function isAuthenticated(): boolean {
return getAccessToken() !== null;
}
// ── HTTP helper ─────────────────────────────────
async function request<T>(
path: string,
method: string,
body?: unknown,
opts?: { skipAuth?: boolean }
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-product-id': productId,
'x-request-id': uuid(),
};
if (!opts?.skipAuth) {
const token = getAccessToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await globalThis.fetch(`${baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(
(data as Record<string, string>).message ||
(data as Record<string, string>).error ||
`HTTP ${res.status}`
);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
} finally {
clearTimeout(timer);
}
}
// ── Singleton refresh guard ─────────────────────
let _refreshPromise: Promise<boolean> | null = null;
async function refreshAccessToken(): Promise<boolean> {
if (_refreshPromise) return _refreshPromise;
_refreshPromise = (async () => {
const rt = getRefreshToken();
if (!rt) return false;
try {
const data = await request<{ accessToken: string; refreshToken: string }>(
'/auth/refresh',
'POST',
{ refreshToken: rt },
{ skipAuth: true }
);
setTokens(data.accessToken, data.refreshToken);
return true;
} catch {
clearTokens();
return false;
}
})();
try {
return await _refreshPromise;
} finally {
_refreshPromise = null;
}
}
// ── Auth operations ─────────────────────────────
async function login(email: string, password: string): Promise<AuthResult> {
const result = await request<AuthResult>('/auth/login', 'POST', {
email,
password,
productId,
});
setTokens(result.accessToken, result.refreshToken);
return result;
}
async function register(
email: string,
password: string,
displayName: string
): Promise<AuthResult> {
const result = await request<AuthResult>('/auth/register', 'POST', {
email,
password,
displayName,
productId,
});
setTokens(result.accessToken, result.refreshToken);
return result;
}
async function getMe(): Promise<AuthUser> {
return request<AuthUser>('/auth/me', 'GET');
}
// ── Password management ─────────────────────────
async function forgotPassword(email: string): Promise<{ message: string }> {
return request<{ message: string }>('/auth/forgot-password', 'POST', {
email,
productId,
});
}
async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> {
return request<{ message: string }>('/auth/reset-password', 'POST', {
token,
newPassword,
});
}
async function changePassword(
currentPassword: string,
newPassword: string
): Promise<{ message: string }> {
return request<{ message: string }>('/auth/change-password', 'POST', {
currentPassword,
newPassword,
});
}
// ── Account management ──────────────────────────
async function deleteAccount(password: string): Promise<{ message: string }> {
const result = await request<{ message: string }>('/auth/account', 'DELETE', {
password,
});
clearTokens();
return result;
}
// ── Email verification ──────────────────────────
async function verifyEmail(token: string): Promise<{ message: string }> {
return request<{ message: string }>('/auth/verify-email', 'POST', { token });
}
async function resendVerification(email: string): Promise<{ message: string }> {
return request<{ message: string }>('/auth/resend-verification', 'POST', {
email,
productId,
});
}
return {
getAccessToken,
getRefreshToken,
setTokens,
clearTokens,
isAuthenticated,
login,
register,
getMe,
refreshAccessToken,
forgotPassword,
resetPassword,
changePassword,
deleteAccount,
verifyEmail,
resendVerification,
};
}

View File

@ -0,0 +1,2 @@
export { createAuthClient } from './client.js';
export type { AuthClient, AuthClientConfig, AuthResult, AuthUser, TokenStorage } from './types.js';

View File

@ -0,0 +1,68 @@
/**
* Types for @bytelyst/auth-client.
* Browser/React Native-safe no Node.js dependencies.
*/
export interface AuthClientConfig {
/** Platform-service base URL (e.g. "http://localhost:4003/api" or "https://api.example.com"). */
baseUrl: string;
/** Product identifier sent with every request as x-product-id header. */
productId: string;
/** Storage adapter for tokens. Defaults to localStorage if available. */
storage?: TokenStorage;
/** Optional prefix for storage keys. Default: product ID. */
storagePrefix?: string;
/** Request timeout in milliseconds. Default: 15000. */
timeoutMs?: number;
}
export interface TokenStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export interface AuthUser {
id: string;
email: string;
displayName: string;
role: string;
plan: string;
}
export interface AuthResult {
accessToken: string;
refreshToken: string;
user: AuthUser;
}
export interface AuthClient {
// ── Token management ────────────────────────────
getAccessToken(): string | null;
getRefreshToken(): string | null;
setTokens(accessToken: string, refreshToken: string): void;
clearTokens(): void;
isAuthenticated(): boolean;
// ── Auth operations ─────────────────────────────
login(email: string, password: string): Promise<AuthResult>;
register(email: string, password: string, displayName: string): Promise<AuthResult>;
getMe(): Promise<AuthUser>;
refreshAccessToken(): Promise<boolean>;
// ── Password management ─────────────────────────
forgotPassword(email: string): Promise<{ message: string }>;
resetPassword(token: string, newPassword: string): Promise<{ message: string }>;
changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }>;
// ── Account management ──────────────────────────
deleteAccount(password: string): Promise<{ message: string }>;
// ── Email verification ──────────────────────────
verifyEmail(token: string): Promise<{ message: string }>;
resendVerification(email: string): Promise<{ message: string }>;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -1,17 +1,33 @@
'use client';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
type ReactNode,
} from 'react';
import { createApiClient } from '@bytelyst/api-client';
import type { AuthConfig, AuthContextValue, BaseUser } from './types.js';
/**
* Create a typed auth provider + hook for a specific user type.
*
* Supports the full auth lifecycle: login, register, forgot password,
* change password, delete account, and automatic token refresh.
*
* @example
* ```tsx
* const { AuthProvider, useAuth } = createAuthProvider<AdminUser>({
* storagePrefix: "admin",
* loginEndpoint: "/auth/login",
* registerEndpoint: "/auth/register",
* forgotPasswordEndpoint: "/auth/forgot-password",
* changePasswordEndpoint: "/auth/change-password",
* deleteAccountEndpoint: "/auth/delete-account",
* refreshEndpoint: "/auth/refresh",
* mapLoginResponse: (data) => ({
* user: data.user,
* accessToken: data.accessToken,
@ -21,7 +37,19 @@ import type { AuthConfig, AuthContextValue, BaseUser } from './types.js';
* ```
*/
export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: AuthConfig<TUser>) {
const { storagePrefix, loginEndpoint, mapLoginResponse, onLoginFallback, onLogout } = config;
const {
storagePrefix,
loginEndpoint,
registerEndpoint,
forgotPasswordEndpoint,
changePasswordEndpoint,
deleteAccountEndpoint,
refreshEndpoint,
refreshIntervalMs = 45 * 60 * 1000,
mapLoginResponse,
onLoginFallback,
onLogout,
} = config;
const USER_KEY = `${storagePrefix}_auth_user`;
const TOKEN_KEY = `${storagePrefix}_access_token`;
@ -39,58 +67,249 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
}
}
function saveSession(user: TUser, accessToken: string, refreshToken: string) {
localStorage.setItem(USER_KEY, JSON.stringify(user));
localStorage.setItem(TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_KEY, refreshToken);
}
function clearSession() {
localStorage.removeItem(USER_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
}
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<TUser | null>(getStoredUser);
const isLoading = false;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const api = createApiClient({
baseUrl: '/api',
getToken: () => (typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null),
});
const clearMessages = useCallback(() => {
setError(null);
setSuccess(null);
}, []);
// ── Token refresh ──────────────────────────────
const refreshAccessToken = useCallback(async () => {
if (!refreshEndpoint) return;
const rt = typeof window !== 'undefined' ? localStorage.getItem(REFRESH_KEY) : null;
if (!rt) return;
try {
const data = await api.fetch<{ accessToken: string; refreshToken: string }>(
refreshEndpoint,
{ method: 'POST', body: JSON.stringify({ refreshToken: rt }) }
);
localStorage.setItem(TOKEN_KEY, data.accessToken);
localStorage.setItem(REFRESH_KEY, data.refreshToken);
} catch {
// Token expired — force logout
setUser(null);
clearSession();
onLogout?.();
}
}, [api]);
useEffect(() => {
if (!user || !refreshEndpoint) return;
refreshTimerRef.current = setInterval(refreshAccessToken, refreshIntervalMs);
return () => {
if (refreshTimerRef.current) clearInterval(refreshTimerRef.current);
};
}, [user, refreshAccessToken, refreshIntervalMs]);
// ── Login ──────────────────────────────────────
const login = useCallback(
async (email: string, password: string) => {
const { data, error } = await api.safeFetch<unknown>(loginEndpoint, {
method: 'POST',
body: JSON.stringify({ email, password }),
});
setIsLoading(true);
setError(null);
try {
const { data, error: fetchError } = await api.safeFetch<unknown>(loginEndpoint, {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (data && !error) {
const mapped = mapLoginResponse(data);
setUser(mapped.user);
localStorage.setItem(USER_KEY, JSON.stringify(mapped.user));
localStorage.setItem(TOKEN_KEY, mapped.accessToken);
localStorage.setItem(REFRESH_KEY, mapped.refreshToken);
return true;
}
// Try fallback (e.g. mock credentials) when API is unavailable
if (error && onLoginFallback) {
const fallback = await onLoginFallback(email, password, error);
if (fallback) {
setUser(fallback.user);
localStorage.setItem(USER_KEY, JSON.stringify(fallback.user));
localStorage.setItem(TOKEN_KEY, fallback.accessToken);
localStorage.setItem(REFRESH_KEY, fallback.refreshToken);
if (data && !fetchError) {
const mapped = mapLoginResponse(data);
setUser(mapped.user);
saveSession(mapped.user, mapped.accessToken, mapped.refreshToken);
return true;
}
}
return false;
if (fetchError && onLoginFallback) {
const fallback = await onLoginFallback(email, password, fetchError);
if (fallback) {
setUser(fallback.user);
saveSession(fallback.user, fallback.accessToken, fallback.refreshToken);
return true;
}
}
setError(fetchError || 'Login failed');
return false;
} finally {
setIsLoading(false);
}
},
[api]
);
// ── Register ───────────────────────────────────
const register = useCallback(
async (email: string, password: string, displayName: string) => {
if (!registerEndpoint) {
setError('Registration not supported');
return false;
}
setIsLoading(true);
setError(null);
try {
const { data, error: fetchError } = await api.safeFetch<unknown>(registerEndpoint, {
method: 'POST',
body: JSON.stringify({ email, password, displayName }),
});
if (data && !fetchError) {
const mapped = mapLoginResponse(data);
setUser(mapped.user);
saveSession(mapped.user, mapped.accessToken, mapped.refreshToken);
return true;
}
setError(fetchError || 'Registration failed');
return false;
} finally {
setIsLoading(false);
}
},
[api]
);
// ── Logout ─────────────────────────────────────
const logout = useCallback(() => {
setUser(null);
localStorage.removeItem(USER_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
clearSession();
if (refreshTimerRef.current) clearInterval(refreshTimerRef.current);
onLogout?.();
}, []);
// ── Forgot password ────────────────────────────
const forgotPassword = useCallback(
async (email: string) => {
if (!forgotPasswordEndpoint) {
setError('Forgot password not supported');
return false;
}
setIsLoading(true);
setError(null);
setSuccess(null);
try {
const { error: fetchError } = await api.safeFetch<{ message: string }>(
forgotPasswordEndpoint,
{ method: 'POST', body: JSON.stringify({ email }) }
);
if (fetchError) {
setError(fetchError);
return false;
}
setSuccess('If that email exists, a reset link has been sent.');
return true;
} finally {
setIsLoading(false);
}
},
[api]
);
// ── Change password ────────────────────────────
const changePassword = useCallback(
async (currentPassword: string, newPassword: string) => {
if (!changePasswordEndpoint) {
setError('Change password not supported');
return false;
}
setIsLoading(true);
setError(null);
setSuccess(null);
try {
const { error: fetchError } = await api.safeFetch<{ message: string }>(
changePasswordEndpoint,
{ method: 'POST', body: JSON.stringify({ currentPassword, newPassword }) }
);
if (fetchError) {
setError(fetchError);
return false;
}
setSuccess('Password changed successfully.');
return true;
} finally {
setIsLoading(false);
}
},
[api]
);
// ── Delete account ─────────────────────────────
const deleteAccount = useCallback(
async (password: string) => {
if (!deleteAccountEndpoint) {
setError('Account deletion not supported');
return false;
}
setIsLoading(true);
setError(null);
try {
const { error: fetchError } = await api.safeFetch<{ message: string }>(
deleteAccountEndpoint,
{ method: 'DELETE', body: JSON.stringify({ password }) }
);
if (fetchError) {
setError(fetchError);
return false;
}
setUser(null);
clearSession();
if (refreshTimerRef.current) clearInterval(refreshTimerRef.current);
onLogout?.();
return true;
} finally {
setIsLoading(false);
}
},
[api]
);
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isLoading, login, logout }}>
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
error,
success,
login,
register,
logout,
forgotPassword,
changePassword,
deleteAccount,
clearMessages,
}}
>
{children}
</AuthContext.Provider>
);

View File

@ -9,8 +9,15 @@ export interface AuthContextValue<TUser extends BaseUser = BaseUser> {
user: TUser | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
success: string | null;
login: (email: string, password: string) => Promise<boolean>;
register: (email: string, password: string, displayName: string) => Promise<boolean>;
logout: () => void;
forgotPassword: (email: string) => Promise<boolean>;
changePassword: (currentPassword: string, newPassword: string) => Promise<boolean>;
deleteAccount: (password: string) => Promise<boolean>;
clearMessages: () => void;
}
export interface LoginResult<TUser extends BaseUser = BaseUser> {
@ -22,6 +29,13 @@ export interface LoginResult<TUser extends BaseUser = BaseUser> {
export interface AuthConfig<TUser extends BaseUser = BaseUser> {
storagePrefix: string;
loginEndpoint: string;
registerEndpoint?: string;
forgotPasswordEndpoint?: string;
changePasswordEndpoint?: string;
deleteAccountEndpoint?: string;
refreshEndpoint?: string;
/** Token refresh interval in ms. Default: 45 * 60 * 1000 (45 minutes). */
refreshIntervalMs?: number;
mapLoginResponse: (data: unknown) => LoginResult<TUser>;
onLoginFallback?: (
email: string,

View File

@ -0,0 +1,21 @@
{
"name": "@bytelyst/telemetry-client",
"version": "0.1.0",
"type": "module",
"description": "Browser/React Native-safe telemetry client for platform-service",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run"
}
}

View File

@ -0,0 +1,255 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createTelemetryClient } from '../client.js';
import type { TelemetryStorage } from '../types.js';
function createMockStorage(): TelemetryStorage & { store: Map<string, string> } {
const store = new Map<string, string>();
return {
store,
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
};
}
describe('@bytelyst/telemetry-client', () => {
let storage: ReturnType<typeof createMockStorage>;
beforeEach(() => {
storage = createMockStorage();
vi.restoreAllMocks();
vi.useFakeTimers();
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true });
});
afterEach(() => {
vi.useRealTimers();
});
describe('createTelemetryClient', () => {
it('creates a client with all expected methods', () => {
const client = createTelemetryClient({
productId: 'testapp',
baseUrl: 'http://localhost:4003/api',
platform: 'web',
channel: 'pwa',
storage,
});
expect(client.init).toBeTypeOf('function');
expect(client.trackEvent).toBeTypeOf('function');
expect(client.flush).toBeTypeOf('function');
expect(client.shutdown).toBeTypeOf('function');
expect(client.getInstallId).toBeTypeOf('function');
expect(client.getSessionId).toBeTypeOf('function');
});
});
describe('install ID', () => {
it('generates and persists install ID', () => {
const client = createTelemetryClient({
productId: 'testapp',
baseUrl: 'http://localhost:4003/api',
platform: 'web',
channel: 'pwa',
storage,
});
const id = client.getInstallId();
expect(id).toBeTruthy();
expect(storage.store.get('testapp_telemetry_install_id')).toBe(id);
});
it('reuses persisted install ID', () => {
storage.store.set('testapp_telemetry_install_id', 'existing-id');
const client = createTelemetryClient({
productId: 'testapp',
baseUrl: 'http://localhost:4003/api',
platform: 'web',
channel: 'pwa',
storage,
});
expect(client.getInstallId()).toBe('existing-id');
});
});
describe('init', () => {
it('generates a session ID on init', () => {
const client = createTelemetryClient({
productId: 'testapp',
baseUrl: 'http://localhost:4003/api',
platform: 'web',
channel: 'pwa',
storage,
});
expect(client.getSessionId()).toBe('');
client.init();
expect(client.getSessionId()).toBeTruthy();
});
it('tracks session_started event on init', () => {
const client = createTelemetryClient({
productId: 'testapp',
baseUrl: 'http://localhost:4003/api',
platform: 'web',
channel: 'pwa',
storage,
});
client.init();
// Flush to see the queued event
client.flush();
expect(globalThis.fetch).toHaveBeenCalled();
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.productId).toBe('testapp');
expect(body.events).toHaveLength(1);
expect(body.events[0].eventName).toBe('session_started');
expect(body.events[0].platform).toBe('web');
expect(body.events[0].channel).toBe('pwa');
});
});
describe('trackEvent', () => {
it('queues events and flushes via fetch', () => {
const client = createTelemetryClient({
productId: 'chronomind',
baseUrl: 'http://localhost:4003/api',
platform: 'web',
channel: 'pwa',
transport: 'fetch',
storage,
});
client.trackEvent('info', 'timer', 'timer_created', {
feature: 'countdown',
tags: { type: 'alarm' },
metrics: { duration: 300 },
});
client.flush();
expect(globalThis.fetch).toHaveBeenCalledOnce();
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('http://localhost:4003/api/telemetry/events');
expect(opts.method).toBe('POST');
expect(opts.headers['x-product-id']).toBe('chronomind');
const body = JSON.parse(opts.body);
expect(body.events).toHaveLength(1);
const ev = body.events[0];
expect(ev.eventType).toBe('info');
expect(ev.module).toBe('timer');
expect(ev.eventName).toBe('timer_created');
expect(ev.feature).toBe('countdown');
expect(ev.tags.type).toBe('alarm');
expect(ev.metrics.duration).toBe(300);
expect(ev.occurredAt).toBeTruthy();
});
it('auto-flushes when queue reaches maxQueue', () => {
const client = createTelemetryClient({
productId: 'testapp',
baseUrl: 'http://localhost:4003/api',
platform: 'mobile',
channel: 'react_native',
transport: 'fetch',
maxQueue: 3,
storage,
});
// Don't init to avoid session_started
client.trackEvent('info', 'a', 'one');
client.trackEvent('info', 'b', 'two');
expect(globalThis.fetch).not.toHaveBeenCalled();
client.trackEvent('info', 'c', 'three');
expect(globalThis.fetch).toHaveBeenCalledOnce();
});
});
describe('flush', () => {
it('does nothing when queue is empty', () => {
const client = createTelemetryClient({
productId: 'testapp',
baseUrl: 'http://localhost:4003/api',
platform: 'web',
channel: 'pwa',
storage,
});
client.flush();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});
describe('periodic flush', () => {
it('flushes on interval', () => {
const client = createTelemetryClient({
productId: 'testapp',
baseUrl: 'http://localhost:4003/api',
platform: 'web',
channel: 'pwa',
flushIntervalMs: 10_000,
storage,
});
client.init();
(globalThis.fetch as ReturnType<typeof vi.fn>).mockClear();
client.trackEvent('info', 'test', 'event1');
expect(globalThis.fetch).not.toHaveBeenCalled();
vi.advanceTimersByTime(10_000);
expect(globalThis.fetch).toHaveBeenCalledOnce();
});
});
describe('shutdown', () => {
it('flushes remaining events and stops timer', () => {
const client = createTelemetryClient({
productId: 'testapp',
baseUrl: 'http://localhost:4003/api',
platform: 'web',
channel: 'pwa',
storage,
});
client.init();
(globalThis.fetch as ReturnType<typeof vi.fn>).mockClear();
client.trackEvent('info', 'test', 'event1');
client.shutdown();
expect(globalThis.fetch).toHaveBeenCalledOnce();
// After shutdown, periodic flush should not fire
(globalThis.fetch as ReturnType<typeof vi.fn>).mockClear();
client.trackEvent('info', 'test', 'event2');
vi.advanceTimersByTime(60_000);
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});
describe('userId passthrough', () => {
it('includes userId in event when provided', () => {
const client = createTelemetryClient({
productId: 'testapp',
baseUrl: 'http://localhost:4003/api',
platform: 'web',
channel: 'pwa',
storage,
});
client.trackEvent('info', 'auth', 'login', { userId: 'user-123' });
client.flush();
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.events[0].userId).toBe('user-123');
});
});
});

View File

@ -0,0 +1,236 @@
/**
* Browser/React Native-safe telemetry client for platform-service.
*
* Replaces hand-rolled telemetry clients in ChronoMind web, NomGap, and LysnrAI user-dashboard.
* No Node.js dependencies uses globalThis.fetch and configurable storage.
*
* @example
* ```ts
* import { createTelemetryClient } from '@bytelyst/telemetry-client';
*
* const telemetry = createTelemetryClient({
* productId: 'chronomind',
* baseUrl: 'http://localhost:4003/api',
* platform: 'web',
* channel: 'pwa',
* transport: 'beacon',
* });
*
* telemetry.init();
* telemetry.trackEvent('info', 'timer', 'timer_created');
* ```
*/
import type {
TelemetryClient,
TelemetryClientConfig,
TelemetryEvent,
TelemetryStorage,
} from './types.js';
// ── UUID helper (browser + RN safe) ──────────────────────────────
function uuid(): string {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
// ── Noop storage ─────────────────────────────────────────────────
const noopStorage: TelemetryStorage = {
getItem: () => null,
setItem: () => {},
};
function getDefaultStorage(): TelemetryStorage {
if (
typeof globalThis.localStorage !== 'undefined' &&
typeof globalThis.localStorage?.getItem === 'function'
) {
return globalThis.localStorage;
}
return noopStorage;
}
// ── Factory ──────────────────────────────────────────────────────
export function createTelemetryClient(config: TelemetryClientConfig): TelemetryClient {
const {
productId,
baseUrl,
endpoint = '/telemetry/events',
platform,
channel,
transport = 'fetch',
maxQueue = 50,
flushIntervalMs = 30_000,
appVersion = '0.0.0',
buildNumber = '0',
releaseChannel = 'dev',
osFamily = 'other',
osVersion = '',
} = config;
const storage = config.storage ?? getDefaultStorage();
const INSTALL_KEY = `${productId}_telemetry_install_id`;
let queue: TelemetryEvent[] = [];
let sessionId = '';
let installId = '';
let flushTimer: ReturnType<typeof setInterval> | null = null;
function getInstallId(): string {
if (installId) return installId;
const stored = storage.getItem(INSTALL_KEY);
if (stored) {
installId = stored;
return installId;
}
installId = uuid();
storage.setItem(INSTALL_KEY, installId);
return installId;
}
function getSessionId(): string {
return sessionId;
}
function flushViaBeacon(): void {
if (queue.length === 0) return;
const events = [...queue];
queue = [];
const body = JSON.stringify({ productId, events });
const url = `${baseUrl}${endpoint}`;
try {
const sent = typeof navigator?.sendBeacon === 'function' && navigator.sendBeacon(url, body);
if (!sent) {
// Fallback to fetch
globalThis
.fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-product-id': productId,
'x-request-id': uuid(),
},
body,
keepalive: true,
})
.catch(() => {});
}
} catch {
// Silently ignore telemetry failures
}
}
function flushViaFetch(): void {
if (queue.length === 0) return;
const events = [...queue];
queue = [];
const body = JSON.stringify({ productId, events });
const url = `${baseUrl}${endpoint}`;
globalThis
.fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-product-id': productId,
'x-request-id': uuid(),
},
body,
})
.catch(() => {});
}
function flush(): void {
if (transport === 'beacon') {
flushViaBeacon();
} else {
flushViaFetch();
}
}
function trackEvent(
eventType: string,
module: string,
eventName: string,
extra?: {
feature?: string;
message?: string;
tags?: Record<string, string>;
metrics?: Record<string, number>;
userId?: string;
}
): void {
const event: TelemetryEvent = {
id: uuid(),
productId,
anonymousInstallId: getInstallId(),
sessionId,
platform,
channel,
osFamily,
osVersion,
appVersion,
buildNumber,
releaseChannel,
eventType,
module,
eventName,
...extra,
occurredAt: new Date().toISOString(),
};
queue.push(event);
if (queue.length >= maxQueue) {
flush();
}
}
function init(): void {
sessionId = uuid();
getInstallId();
// Auto-flush on visibility change (web only)
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flush();
}
});
}
// Periodic flush
if (flushTimer) clearInterval(flushTimer);
flushTimer = setInterval(flush, flushIntervalMs);
trackEvent('info', 'app_lifecycle', 'session_started');
}
function shutdown(): void {
flush();
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = null;
}
}
return {
init,
trackEvent,
flush,
shutdown,
getInstallId,
getSessionId,
};
}

View File

@ -0,0 +1,7 @@
export { createTelemetryClient } from './client.js';
export type {
TelemetryClient,
TelemetryClientConfig,
TelemetryEvent,
TelemetryStorage,
} from './types.js';

View File

@ -0,0 +1,107 @@
/**
* Types for @bytelyst/telemetry-client.
* Browser/React Native-safe no Node.js dependencies.
*/
export interface TelemetryClientConfig {
/** Product identifier (e.g. 'chronomind', 'nomgap', 'lysnrai'). */
productId: string;
/** Platform-service base URL or telemetry ingest endpoint base. */
baseUrl: string;
/** Endpoint path appended to baseUrl. Default: '/telemetry/events'. */
endpoint?: string;
/** Platform identifier (e.g. 'web', 'mobile', 'desktop'). */
platform: string;
/** Channel identifier (e.g. 'pwa', 'react_native', 'web_app'). */
channel: string;
/** Transport: 'beacon' uses sendBeacon (web), 'fetch' uses fetch (RN/fallback). Default: 'fetch'. */
transport?: 'beacon' | 'fetch';
/** Max events to queue before auto-flush. Default: 50. */
maxQueue?: number;
/** Flush interval in milliseconds. Default: 30000. */
flushIntervalMs?: number;
/** App version string. Default: '0.0.0'. */
appVersion?: string;
/** Build number. Default: '0'. */
buildNumber?: string;
/** Release channel. Default: 'dev'. */
releaseChannel?: string;
/** OS family. Default: 'other'. */
osFamily?: string;
/** OS version. Default: ''. */
osVersion?: string;
/** Storage adapter for install ID persistence. Uses localStorage by default. */
storage?: TelemetryStorage;
}
export interface TelemetryStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface TelemetryEvent {
id: string;
productId: string;
userId?: string;
anonymousInstallId: string;
sessionId: string;
platform: string;
channel: string;
osFamily: string;
osVersion: string;
appVersion: string;
buildNumber: string;
releaseChannel: string;
eventType: string;
module: string;
eventName: string;
feature?: string;
message?: string;
tags?: Record<string, string>;
metrics?: Record<string, number>;
occurredAt: string;
}
export interface TelemetryClient {
/** Initialize the telemetry client and start periodic flushing. */
init(): void;
/** Track a telemetry event. */
trackEvent(
eventType: string,
module: string,
eventName: string,
extra?: {
feature?: string;
message?: string;
tags?: Record<string, string>;
metrics?: Record<string, number>;
userId?: string;
}
): void;
/** Flush all queued events immediately. */
flush(): void;
/** Stop the periodic flush timer and flush remaining events. */
shutdown(): void;
/** Get the anonymous install ID. */
getInstallId(): string;
/** Get the current session ID. */
getSessionId(): string;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

4
pnpm-lock.yaml generated
View File

@ -307,6 +307,8 @@ importers:
specifier: '>=5.0.0'
version: 6.1.3
packages/auth-client: {}
packages/blob:
dependencies:
'@azure/storage-blob':
@ -400,6 +402,8 @@ importers:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
packages/telemetry-client: {}
packages/testing:
dependencies:
vitest: