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:
parent
4e94ecd721
commit
b400c76c0a
@ -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',
|
||||
|
||||
21
packages/auth-client/package.json
Normal file
21
packages/auth-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
346
packages/auth-client/src/__tests__/auth-client.test.ts
Normal file
346
packages/auth-client/src/__tests__/auth-client.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
265
packages/auth-client/src/client.ts
Normal file
265
packages/auth-client/src/client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
packages/auth-client/src/index.ts
Normal file
2
packages/auth-client/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { createAuthClient } from './client.js';
|
||||
export type { AuthClient, AuthClientConfig, AuthResult, AuthUser, TokenStorage } from './types.js';
|
||||
68
packages/auth-client/src/types.ts
Normal file
68
packages/auth-client/src/types.ts
Normal 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 }>;
|
||||
}
|
||||
10
packages/auth-client/tsconfig.json
Normal file
10
packages/auth-client/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
21
packages/telemetry-client/package.json
Normal file
21
packages/telemetry-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
255
packages/telemetry-client/src/__tests__/telemetry-client.test.ts
Normal file
255
packages/telemetry-client/src/__tests__/telemetry-client.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
236
packages/telemetry-client/src/client.ts
Normal file
236
packages/telemetry-client/src/client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
7
packages/telemetry-client/src/index.ts
Normal file
7
packages/telemetry-client/src/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { createTelemetryClient } from './client.js';
|
||||
export type {
|
||||
TelemetryClient,
|
||||
TelemetryClientConfig,
|
||||
TelemetryEvent,
|
||||
TelemetryStorage,
|
||||
} from './types.js';
|
||||
107
packages/telemetry-client/src/types.ts
Normal file
107
packages/telemetry-client/src/types.ts
Normal 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;
|
||||
}
|
||||
10
packages/telemetry-client/tsconfig.json
Normal file
10
packages/telemetry-client/tsconfig.json
Normal 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
4
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user