From b400c76c0a7871556e27af6d3dfa71b34d91f686 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 04:49:46 -0800 Subject: [PATCH] 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 --- eslint.config.js | 8 + packages/auth-client/package.json | 21 ++ .../src/__tests__/auth-client.test.ts | 346 ++++++++++++++++++ packages/auth-client/src/client.ts | 265 ++++++++++++++ packages/auth-client/src/index.ts | 2 + packages/auth-client/src/types.ts | 68 ++++ packages/auth-client/tsconfig.json | 10 + packages/react-auth/src/auth-context.tsx | 279 ++++++++++++-- packages/react-auth/src/types.ts | 14 + packages/telemetry-client/package.json | 21 ++ .../src/__tests__/telemetry-client.test.ts | 255 +++++++++++++ packages/telemetry-client/src/client.ts | 236 ++++++++++++ packages/telemetry-client/src/index.ts | 7 + packages/telemetry-client/src/types.ts | 107 ++++++ packages/telemetry-client/tsconfig.json | 10 + pnpm-lock.yaml | 4 + 16 files changed, 1623 insertions(+), 30 deletions(-) create mode 100644 packages/auth-client/package.json create mode 100644 packages/auth-client/src/__tests__/auth-client.test.ts create mode 100644 packages/auth-client/src/client.ts create mode 100644 packages/auth-client/src/index.ts create mode 100644 packages/auth-client/src/types.ts create mode 100644 packages/auth-client/tsconfig.json create mode 100644 packages/telemetry-client/package.json create mode 100644 packages/telemetry-client/src/__tests__/telemetry-client.test.ts create mode 100644 packages/telemetry-client/src/client.ts create mode 100644 packages/telemetry-client/src/index.ts create mode 100644 packages/telemetry-client/src/types.ts create mode 100644 packages/telemetry-client/tsconfig.json diff --git a/eslint.config.js b/eslint.config.js index 48f7b1f1..cc8b98f4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', diff --git a/packages/auth-client/package.json b/packages/auth-client/package.json new file mode 100644 index 00000000..b393f536 --- /dev/null +++ b/packages/auth-client/package.json @@ -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" + } +} diff --git a/packages/auth-client/src/__tests__/auth-client.test.ts b/packages/auth-client/src/__tests__/auth-client.test.ts new file mode 100644 index 00000000..ac5292af --- /dev/null +++ b/packages/auth-client/src/__tests__/auth-client.test.ts @@ -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 } { + const store = new Map(); + 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; + + 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).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).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).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).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).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).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).mock.calls[0][1].body); + expect(body.email).toBe('user@test.com'); + expect(body.productId).toBe('chronomind'); + }); + }); +}); diff --git a/packages/auth-client/src/client.ts b/packages/auth-client/src/client.ts new file mode 100644 index 00000000..9ba1ff58 --- /dev/null +++ b/packages/auth-client/src/client.ts @@ -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( + path: string, + method: string, + body?: unknown, + opts?: { skipAuth?: boolean } + ): Promise { + const headers: Record = { + '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).message || + (data as Record).error || + `HTTP ${res.status}` + ); + } + + if (res.status === 204) return undefined as T; + return res.json() as Promise; + } finally { + clearTimeout(timer); + } + } + + // ── Singleton refresh guard ───────────────────── + + let _refreshPromise: Promise | null = null; + + async function refreshAccessToken(): Promise { + 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 { + const result = await request('/auth/login', 'POST', { + email, + password, + productId, + }); + setTokens(result.accessToken, result.refreshToken); + return result; + } + + async function register( + email: string, + password: string, + displayName: string + ): Promise { + const result = await request('/auth/register', 'POST', { + email, + password, + displayName, + productId, + }); + setTokens(result.accessToken, result.refreshToken); + return result; + } + + async function getMe(): Promise { + return request('/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, + }; +} diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts new file mode 100644 index 00000000..8e46b225 --- /dev/null +++ b/packages/auth-client/src/index.ts @@ -0,0 +1,2 @@ +export { createAuthClient } from './client.js'; +export type { AuthClient, AuthClientConfig, AuthResult, AuthUser, TokenStorage } from './types.js'; diff --git a/packages/auth-client/src/types.ts b/packages/auth-client/src/types.ts new file mode 100644 index 00000000..20391b48 --- /dev/null +++ b/packages/auth-client/src/types.ts @@ -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; + register(email: string, password: string, displayName: string): Promise; + getMe(): Promise; + refreshAccessToken(): Promise; + + // ── 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 }>; +} diff --git a/packages/auth-client/tsconfig.json b/packages/auth-client/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/auth-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/react-auth/src/auth-context.tsx b/packages/react-auth/src/auth-context.tsx index 8e017566..5ccf4f66 100644 --- a/packages/react-auth/src/auth-context.tsx +++ b/packages/react-auth/src/auth-context.tsx @@ -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({ * 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(config: AuthConfig) { - 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(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(getStoredUser); - const isLoading = false; + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const refreshTimerRef = useRef | 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(loginEndpoint, { - method: 'POST', - body: JSON.stringify({ email, password }), - }); + setIsLoading(true); + setError(null); + try { + const { data, error: fetchError } = await api.safeFetch(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(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 ( - + {children} ); diff --git a/packages/react-auth/src/types.ts b/packages/react-auth/src/types.ts index 0aff152f..04bbac40 100644 --- a/packages/react-auth/src/types.ts +++ b/packages/react-auth/src/types.ts @@ -9,8 +9,15 @@ export interface AuthContextValue { user: TUser | null; isAuthenticated: boolean; isLoading: boolean; + error: string | null; + success: string | null; login: (email: string, password: string) => Promise; + register: (email: string, password: string, displayName: string) => Promise; logout: () => void; + forgotPassword: (email: string) => Promise; + changePassword: (currentPassword: string, newPassword: string) => Promise; + deleteAccount: (password: string) => Promise; + clearMessages: () => void; } export interface LoginResult { @@ -22,6 +29,13 @@ export interface LoginResult { export interface AuthConfig { 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; onLoginFallback?: ( email: string, diff --git a/packages/telemetry-client/package.json b/packages/telemetry-client/package.json new file mode 100644 index 00000000..cc6fd777 --- /dev/null +++ b/packages/telemetry-client/package.json @@ -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" + } +} diff --git a/packages/telemetry-client/src/__tests__/telemetry-client.test.ts b/packages/telemetry-client/src/__tests__/telemetry-client.test.ts new file mode 100644 index 00000000..33cc1d8d --- /dev/null +++ b/packages/telemetry-client/src/__tests__/telemetry-client.test.ts @@ -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 } { + const store = new Map(); + 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; + + 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).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).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).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).mockClear(); + + client.trackEvent('info', 'test', 'event1'); + client.shutdown(); + + expect(globalThis.fetch).toHaveBeenCalledOnce(); + + // After shutdown, periodic flush should not fire + (globalThis.fetch as ReturnType).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).mock.calls[0][1].body); + expect(body.events[0].userId).toBe('user-123'); + }); + }); +}); diff --git a/packages/telemetry-client/src/client.ts b/packages/telemetry-client/src/client.ts new file mode 100644 index 00000000..13f91e24 --- /dev/null +++ b/packages/telemetry-client/src/client.ts @@ -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 | 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; + metrics?: Record; + 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, + }; +} diff --git a/packages/telemetry-client/src/index.ts b/packages/telemetry-client/src/index.ts new file mode 100644 index 00000000..378b8e07 --- /dev/null +++ b/packages/telemetry-client/src/index.ts @@ -0,0 +1,7 @@ +export { createTelemetryClient } from './client.js'; +export type { + TelemetryClient, + TelemetryClientConfig, + TelemetryEvent, + TelemetryStorage, +} from './types.js'; diff --git a/packages/telemetry-client/src/types.ts b/packages/telemetry-client/src/types.ts new file mode 100644 index 00000000..519b99cc --- /dev/null +++ b/packages/telemetry-client/src/types.ts @@ -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; + metrics?: Record; + 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; + metrics?: Record; + 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; +} diff --git a/packages/telemetry-client/tsconfig.json b/packages/telemetry-client/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/telemetry-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1c47d8b..d9aded40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: